2015-03-09 23:32:43 +01:00
|
|
|
/*
|
|
|
|
* 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 <http://www.gnu.org/licenses/>.
|
|
|
|
*/
|
|
|
|
|
|
|
|
package itdelatrisu.opsu.replay;
|
|
|
|
|
2015-03-10 23:10:51 +01:00
|
|
|
import itdelatrisu.opsu.ErrorHandler;
|
2015-03-12 01:52:51 +01:00
|
|
|
import itdelatrisu.opsu.Options;
|
2015-04-02 04:10:36 +02:00
|
|
|
import itdelatrisu.opsu.ScoreData;
|
2015-03-09 23:32:43 +01:00
|
|
|
import itdelatrisu.opsu.Utils;
|
2015-06-14 03:16:27 +02:00
|
|
|
import itdelatrisu.opsu.beatmap.Beatmap;
|
2015-03-16 16:58:51 +01:00
|
|
|
import itdelatrisu.opsu.io.OsuReader;
|
|
|
|
import itdelatrisu.opsu.io.OsuWriter;
|
2015-03-09 23:32:43 +01:00
|
|
|
|
2015-04-02 04:10:36 +02:00
|
|
|
import java.io.BufferedOutputStream;
|
2015-03-10 23:10:51 +01:00
|
|
|
import java.io.ByteArrayOutputStream;
|
2015-03-09 23:32:43 +01:00
|
|
|
import java.io.File;
|
2015-03-10 23:10:51 +01:00
|
|
|
import java.io.FileOutputStream;
|
2015-03-09 23:32:43 +01:00
|
|
|
import java.io.IOException;
|
2015-04-02 04:10:36 +02:00
|
|
|
import java.io.OutputStream;
|
2015-03-10 23:10:51 +01:00
|
|
|
import java.nio.CharBuffer;
|
|
|
|
import java.nio.charset.CharsetEncoder;
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
|
import java.text.DecimalFormat;
|
|
|
|
import java.text.NumberFormat;
|
2015-03-09 23:32:43 +01:00
|
|
|
import java.util.ArrayList;
|
|
|
|
import java.util.Date;
|
|
|
|
import java.util.List;
|
|
|
|
|
2015-03-10 23:10:51 +01:00
|
|
|
import lzma.streams.LzmaOutputStream;
|
|
|
|
|
2015-03-09 23:32:43 +01:00
|
|
|
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;
|
|
|
|
|
2015-03-11 20:53:19 +01:00
|
|
|
/** Whether or not the replay data has been loaded from the file. */
|
|
|
|
public boolean loaded = false;
|
|
|
|
|
2015-03-09 23:32:43 +01:00
|
|
|
/** 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. */
|
2015-03-10 23:10:51 +01:00
|
|
|
public int score;
|
2015-03-09 23:32:43 +01:00
|
|
|
|
|
|
|
/** 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;
|
|
|
|
|
2015-03-10 23:10:51 +01:00
|
|
|
/** Seed. (?) */
|
|
|
|
public int seed;
|
|
|
|
|
2015-04-02 04:10:36 +02:00
|
|
|
private ScoreData scoreData;
|
|
|
|
|
2015-03-10 23:10:51 +01:00
|
|
|
/** Seed string. */
|
|
|
|
private static final String SEED_STRING = "-12345";
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Empty constructor.
|
|
|
|
*/
|
|
|
|
public Replay() {}
|
|
|
|
|
2015-03-09 23:32:43 +01:00
|
|
|
/**
|
|
|
|
* Constructor.
|
|
|
|
* @param file the file to load from
|
|
|
|
*/
|
|
|
|
public Replay(File file) {
|
|
|
|
this.file = file;
|
2015-03-10 23:10:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Loads the replay data.
|
2015-03-12 06:18:50 +01:00
|
|
|
* @throws IOException failure to load the data
|
2015-03-10 23:10:51 +01:00
|
|
|
*/
|
2015-03-12 06:18:50 +01:00
|
|
|
public void load() throws IOException {
|
2015-03-11 20:53:19 +01:00
|
|
|
if (loaded)
|
|
|
|
return;
|
|
|
|
|
2015-03-12 06:18:50 +01:00
|
|
|
OsuReader reader = new OsuReader(file);
|
|
|
|
loadHeader(reader);
|
|
|
|
loadData(reader);
|
|
|
|
reader.close();
|
|
|
|
loaded = true;
|
2015-03-09 23:32:43 +01:00
|
|
|
}
|
2015-04-02 04:10:36 +02:00
|
|
|
|
2015-04-05 17:24:05 +02:00
|
|
|
public void loadHeader() throws IOException {
|
|
|
|
OsuReader reader = new OsuReader(file);
|
|
|
|
loadHeader(reader);
|
|
|
|
reader.close();
|
|
|
|
}
|
2015-04-02 04:10:36 +02:00
|
|
|
/**
|
|
|
|
* Returns a ScoreData object encapsulating all game data.
|
|
|
|
* If score data already exists, the existing object will be returned
|
|
|
|
* (i.e. this will not overwrite existing data).
|
|
|
|
* @param osu the OsuFile
|
|
|
|
* @return the ScoreData object
|
|
|
|
*/
|
2015-06-14 03:16:27 +02:00
|
|
|
public ScoreData getScoreData(Beatmap osu) {
|
2015-04-02 04:10:36 +02:00
|
|
|
if (scoreData != null)
|
|
|
|
return scoreData;
|
|
|
|
|
|
|
|
scoreData = new ScoreData();
|
|
|
|
scoreData.timestamp = file.lastModified() / 1000L;
|
|
|
|
scoreData.MID = osu.beatmapID;
|
|
|
|
scoreData.MSID = osu.beatmapSetID;
|
|
|
|
scoreData.title = osu.title;
|
|
|
|
scoreData.artist = osu.artist;
|
|
|
|
scoreData.creator = osu.creator;
|
|
|
|
scoreData.version = osu.version;
|
|
|
|
scoreData.hit300 = hit300;
|
|
|
|
scoreData.hit100 = hit100;
|
|
|
|
scoreData.hit50 = hit50;
|
|
|
|
scoreData.geki = geki;
|
|
|
|
scoreData.katu = katu;
|
|
|
|
scoreData.miss = miss;
|
|
|
|
scoreData.score = score;
|
|
|
|
scoreData.combo = combo;
|
|
|
|
scoreData.perfect = perfect;
|
|
|
|
scoreData.mods = mods;
|
|
|
|
scoreData.replayString = file!=null ? file.getName() : getReplayFilename();
|
|
|
|
scoreData.playerName = playerName!=null ? playerName : "No Name";
|
|
|
|
return scoreData;
|
|
|
|
}
|
|
|
|
|
2015-03-09 23:32:43 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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<LifeFrame> lifeFrameList = new ArrayList<LifeFrame>(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));
|
2015-03-10 23:10:51 +01:00
|
|
|
} catch (NumberFormatException e) {
|
|
|
|
Log.warn(String.format("Failed to load life frame: '%s'", frame), e);
|
2015-03-09 23:32:43 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
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(",");
|
2015-03-10 23:10:51 +01:00
|
|
|
lzma.close();
|
2015-03-09 23:32:43 +01:00
|
|
|
List<ReplayFrame> replayFrameList = new ArrayList<ReplayFrame>(replayFrames.length);
|
|
|
|
int lastTime = 0;
|
|
|
|
for (String frame : replayFrames) {
|
|
|
|
if (frame.isEmpty())
|
|
|
|
continue;
|
|
|
|
String[] tokens = frame.split("\\|");
|
|
|
|
if (tokens.length < 4)
|
|
|
|
continue;
|
|
|
|
try {
|
2015-03-10 23:10:51 +01:00
|
|
|
if (tokens[0].equals(SEED_STRING)) {
|
|
|
|
seed = Integer.parseInt(tokens[3]);
|
|
|
|
continue;
|
|
|
|
}
|
2015-03-09 23:32:43 +01:00
|
|
|
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;
|
2015-03-10 23:10:51 +01:00
|
|
|
} catch (NumberFormatException e) {
|
2015-03-09 23:32:43 +01:00
|
|
|
Log.warn(String.format("Failed to parse frame: '%s'", frame), e);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this.frames = replayFrameList.toArray(new ReplayFrame[replayFrameList.size()]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2015-03-10 23:10:51 +01:00
|
|
|
/**
|
2015-03-12 01:52:51 +01:00
|
|
|
* Saves the replay data to a file in the replays directory.
|
2015-03-10 23:10:51 +01:00
|
|
|
*/
|
2015-03-12 01:52:51 +01:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2015-03-12 02:00:50 +01:00
|
|
|
// write file in new thread
|
|
|
|
final File file = new File(dir, String.format("%s.osr", getReplayFilename()));
|
|
|
|
new Thread() {
|
|
|
|
@Override
|
|
|
|
public void run() {
|
2015-04-02 04:10:36 +02:00
|
|
|
try (OutputStream out = new BufferedOutputStream(new FileOutputStream(file))) {
|
2015-03-12 02:00:50 +01:00
|
|
|
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,",
|
2015-03-13 01:12:43 +01:00
|
|
|
frame.getTimeDiff(), nf.format(frame.getX()),
|
|
|
|
nf.format(frame.getY()), frame.getKeys()));
|
2015-03-12 02:00:50 +01:00
|
|
|
}
|
|
|
|
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();
|
2015-03-10 23:10:51 +01:00
|
|
|
} catch (IOException e) {
|
2015-03-12 02:00:50 +01:00
|
|
|
ErrorHandler.error("Could not save replay data.", e, true);
|
2015-03-10 23:10:51 +01:00
|
|
|
}
|
2015-03-12 02:00:50 +01:00
|
|
|
}
|
|
|
|
}.start();
|
2015-03-10 23:10:51 +01:00
|
|
|
}
|
|
|
|
|
2015-03-12 01:52:51 +01:00
|
|
|
/**
|
|
|
|
* 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);
|
|
|
|
}
|
|
|
|
|
2015-03-09 23:32:43 +01:00
|
|
|
@Override
|
|
|
|
public String toString() {
|
2015-06-22 01:45:38 +02:00
|
|
|
final int LINE_SPLIT = 5;
|
|
|
|
final int MAX_LINES = LINE_SPLIT * 10;
|
2015-03-10 23:10:51 +01:00
|
|
|
|
2015-03-09 23:32:43 +01:00
|
|
|
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');
|
2015-03-10 23:10:51 +01:00
|
|
|
sb.append("Life data ("); sb.append(lifeFrames.length); sb.append(" total):\n");
|
|
|
|
for (int i = 0; i < lifeFrames.length && i < MAX_LINES; i++) {
|
2015-03-09 23:32:43 +01:00
|
|
|
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) {
|
2015-03-10 23:10:51 +01:00
|
|
|
sb.append("Frames ("); sb.append(frames.length); sb.append(" total):\n");
|
|
|
|
for (int i = 0; i < frames.length && i < MAX_LINES; i++) {
|
2015-03-09 23:32:43 +01:00
|
|
|
if (i % LINE_SPLIT == 0)
|
|
|
|
sb.append('\t');
|
|
|
|
sb.append(frames[i]);
|
|
|
|
sb.append((i % LINE_SPLIT == LINE_SPLIT - 1) ? '\n' : ' ');
|
|
|
|
}
|
2015-03-10 23:10:51 +01:00
|
|
|
sb.append('\n');
|
2015-03-09 23:32:43 +01:00
|
|
|
}
|
2015-03-10 23:10:51 +01:00
|
|
|
sb.append("Seed: "); sb.append(seed); sb.append('\n');
|
2015-03-09 23:32:43 +01:00
|
|
|
return sb.toString();
|
|
|
|
}
|
|
|
|
}
|