diff --git a/pom.xml b/pom.xml
index 887bced8..d378cac8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -202,5 +202,10 @@
commons-compress
1.8
+
+ com.github.jponge
+ lzma-java
+ 1.2
+
diff --git a/src/itdelatrisu/opsu/GameData.java b/src/itdelatrisu/opsu/GameData.java
index 5b5093c2..ff59e1c6 100644
--- a/src/itdelatrisu/opsu/GameData.java
+++ b/src/itdelatrisu/opsu/GameData.java
@@ -22,10 +22,13 @@ import itdelatrisu.opsu.audio.HitSound;
import itdelatrisu.opsu.audio.MusicController;
import itdelatrisu.opsu.audio.SoundController;
import itdelatrisu.opsu.audio.SoundEffect;
+import itdelatrisu.opsu.downloads.Updater;
+import itdelatrisu.opsu.replay.Replay;
+import itdelatrisu.opsu.replay.ReplayFrame;
+import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
-import java.util.LinkedList;
import java.util.concurrent.LinkedBlockingDeque;
import org.newdawn.slick.Animation;
@@ -275,6 +278,9 @@ public class GameData {
/** The associated score data. */
private ScoreData scoreData;
+ /** The associated replay. */
+ private Replay replay;
+
/** Whether this object is used for gameplay (true) or score viewing (false). */
private boolean gameplay;
@@ -319,6 +325,7 @@ public class GameData {
hitResultCount[HIT_300K] = 0;
hitResultCount[HIT_100K] = s.katu;
hitResultCount[HIT_MISS] = s.miss;
+ this.replay = s.replay;
loadImages();
}
@@ -342,6 +349,7 @@ public class GameData {
comboEnd = 0;
comboBurstIndex = -1;
scoreData = null;
+ replay = null;
}
/**
@@ -1190,9 +1198,10 @@ public class GameData {
* If score data already exists, the existing object will be returned
* (i.e. this will not overwrite existing data).
* @param osu the OsuFile
+ * @param frames the replay frames
* @return the ScoreData object
*/
- public ScoreData getScoreData(OsuFile osu) {
+ public ScoreData getScoreData(OsuFile osu, ReplayFrame[] frames) {
if (scoreData != null)
return scoreData;
@@ -1214,9 +1223,44 @@ public class GameData {
scoreData.combo = comboMax;
scoreData.perfect = (comboMax == fullObjectCount);
scoreData.mods = GameMod.getModState();
+ scoreData.replay = getReplay(frames);
return scoreData;
}
+ /**
+ * Returns a Replay object encapsulating all game data.
+ * If a replay already exists, the existing object will be returned
+ * (i.e. this will not overwrite existing data).
+ * @param frames the replay frames
+ * @return the Replay object
+ */
+ public Replay getReplay(ReplayFrame[] frames) {
+ if (replay != null)
+ return replay;
+
+ replay = new Replay();
+ replay.mode = OsuFile.MODE_OSU;
+ replay.version = Updater.get().getBuildDate();
+ replay.beatmapHash = ""; // TODO
+ replay.playerName = ""; // TODO
+ replay.replayHash = ""; // TODO
+ replay.hit300 = (short) hitResultCount[HIT_300];
+ replay.hit100 = (short) hitResultCount[HIT_100];
+ replay.hit50 = (short) hitResultCount[HIT_50];
+ replay.geki = (short) hitResultCount[HIT_300G];
+ replay.katu = (short) (hitResultCount[HIT_300K] + hitResultCount[HIT_100K]);
+ replay.miss = (short) hitResultCount[HIT_MISS];
+ replay.score = (int) score;
+ replay.combo = (short) comboMax;
+ replay.perfect = (comboMax == fullObjectCount);
+ replay.mods = GameMod.getModState();
+ replay.lifeFrames = null; // TODO
+ replay.timestamp = new Date();
+ replay.frames = frames;
+ replay.seed = 0; // TODO
+ return replay;
+ }
+
/**
* Returns whether or not this object is used for gameplay.
* @return true if gameplay, false if score viewing
diff --git a/src/itdelatrisu/opsu/OsuFile.java b/src/itdelatrisu/opsu/OsuFile.java
index 42887582..388a8ef2 100644
--- a/src/itdelatrisu/opsu/OsuFile.java
+++ b/src/itdelatrisu/opsu/OsuFile.java
@@ -32,6 +32,9 @@ import org.newdawn.slick.util.Log;
* Data type storing parsed data from OSU files.
*/
public class OsuFile implements Comparable {
+ /** Game modes. */
+ public static final byte MODE_OSU = 0, MODE_TAIKO = 1, MODE_CTB = 2, MODE_MANIA = 3;
+
/** Map of all loaded background images. */
private static HashMap bgImageMap = new HashMap();
@@ -66,8 +69,8 @@ public class OsuFile implements Comparable {
/** How often closely placed hit objects will be stacked together. */
public float stackLeniency = 0.7f;
- /** Game mode (0:osu!, 1:taiko, 2:catch the beat, 3:osu!mania). */
- public byte mode = 0;
+ /** Game mode (MODE_* constants). */
+ public byte mode = MODE_OSU;
/** Whether the letterbox (top/bottom black bars) appears during breaks. */
public boolean letterboxInBreaks = false;
diff --git a/src/itdelatrisu/opsu/OsuParser.java b/src/itdelatrisu/opsu/OsuParser.java
index c34f4fd3..01da868d 100644
--- a/src/itdelatrisu/opsu/OsuParser.java
+++ b/src/itdelatrisu/opsu/OsuParser.java
@@ -272,7 +272,7 @@ public class OsuParser {
osu.mode = Byte.parseByte(tokens[1]);
/* Non-Opsu! standard files not implemented (obviously). */
- if (osu.mode != 0)
+ if (osu.mode != OsuFile.MODE_OSU)
return null;
break;
diff --git a/src/itdelatrisu/opsu/OsuWriter.java b/src/itdelatrisu/opsu/OsuWriter.java
new file mode 100644
index 00000000..b1da522e
--- /dev/null
+++ b/src/itdelatrisu/opsu/OsuWriter.java
@@ -0,0 +1,162 @@
+/*
+ * 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;
+
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.TimeZone;
+
+/**
+ * Writer for osu! file types.
+ */
+public class OsuWriter {
+ /** Output stream writer. */
+ private DataOutputStream writer;
+
+ /**
+ * Constructor.
+ * @param file the file to write to
+ * @throws FileNotFoundException
+ */
+ public OsuWriter(File file) throws FileNotFoundException {
+ this(new FileOutputStream(file));
+ }
+
+ /**
+ * Constructor.
+ * @param dest the output stream to write to
+ */
+ public OsuWriter(OutputStream dest) {
+ this.writer = new DataOutputStream(dest);
+ }
+
+ /**
+ * Returns the output stream in use.
+ */
+ public OutputStream getOutputStream() { return writer; }
+
+ /**
+ * Closes the output stream.
+ * @throws IOException
+ */
+ public void close() throws IOException { writer.close(); }
+
+ /**
+ * Writes a 1-byte value.
+ */
+ public void write(byte v) throws IOException { writer.writeByte(v); }
+
+ /**
+ * Writes a 2-byte value.
+ */
+ public void write(short v) throws IOException {
+ byte[] bytes = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(v).array();
+ writer.write(bytes);
+ }
+
+ /**
+ * Writes a 4-byte value.
+ */
+ public void write(int v) throws IOException {
+ byte[] bytes = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v).array();
+ writer.write(bytes);
+ }
+
+ /**
+ * Writes an 8-byte value.
+ */
+ public void write(long v) throws IOException {
+ byte[] bytes = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v).array();
+ writer.write(bytes);
+ }
+
+ /**
+ * Writes a 4-byte float.
+ */
+ public void write(float v) throws IOException { writer.writeFloat(v); }
+
+ /**
+ * Writes an 8-byte double.
+ */
+ public void write(double v) throws IOException { writer.writeDouble(v); }
+
+ /**
+ * Writes a boolean as a 1-byte value.
+ */
+ public void write(boolean v) throws IOException { writer.writeBoolean(v); }
+
+ /**
+ * Writes an unsigned variable length integer (ULEB128).
+ */
+ public void writeULEB128(int i) throws IOException {
+ int value = i;
+ do {
+ byte b = (byte) (value & 0x7F);
+ value >>= 7;
+ if (value != 0)
+ b |= (1 << 7);
+ writer.writeByte(b);
+ } while (value != 0);
+ }
+
+ /**
+ * Writes a variable-length string of 1-byte characters.
+ */
+ public void write(String s) throws IOException {
+ // 00 = empty string
+ // 0B * = normal string
+ // is encoded as an LEB, and is the byte length of the rest.
+ // * is encoded as UTF8, and is the string content.
+ if (s == null || s.length() == 0)
+ writer.writeByte(0x00);
+ else {
+ writer.writeByte(0x0B);
+ writeULEB128(s.length());
+ writer.writeBytes(s);
+ }
+ }
+
+ /**
+ * Writes a date in Windows ticks (8 bytes).
+ */
+ public void write(Date date) throws IOException {
+ final long TICKS_AT_EPOCH = 621355968000000000L;
+ final long TICKS_PER_MILLISECOND = 10000;
+
+ Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+ calendar.setTime(date);
+ long ticks = TICKS_AT_EPOCH + calendar.getTimeInMillis() * TICKS_PER_MILLISECOND;
+ write(ticks);
+ }
+
+ /**
+ * Writes an array of bytes.
+ */
+ public void write(byte[] b) throws IOException {
+ writer.write(b);
+ }
+}
diff --git a/src/itdelatrisu/opsu/downloads/Updater.java b/src/itdelatrisu/opsu/downloads/Updater.java
index 164e279e..759472a2 100644
--- a/src/itdelatrisu/opsu/downloads/Updater.java
+++ b/src/itdelatrisu/opsu/downloads/Updater.java
@@ -28,6 +28,10 @@ import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.net.URL;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
import java.util.Properties;
import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
@@ -93,6 +97,9 @@ public class Updater {
/** The current and latest versions. */
private DefaultArtifactVersion currentVersion, latestVersion;
+ /** The build date. */
+ private int buildDate = -1;
+
/** The download object. */
private Download download;
@@ -115,6 +122,32 @@ public class Updater {
return (status == Status.UPDATE_AVAILABLE || status == Status.UPDATE_DOWNLOADED || status == Status.UPDATE_DOWNLOADING);
}
+ /**
+ * Returns the build date, or the current date if not available.
+ */
+ public int getBuildDate() {
+ if (buildDate == -1) {
+ Date date = null;
+ try {
+ Properties props = new Properties();
+ props.load(ResourceLoader.getResourceAsStream(Options.VERSION_FILE));
+ String build = props.getProperty("build.date");
+ if (build == null || build.equals("${timestamp}") || build.equals("${maven.build.timestamp}"))
+ date = new Date();
+ else {
+ DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.ENGLISH);
+ date = format.parse(build);
+ }
+ } catch (Exception e) {
+ date = new Date();
+ } finally {
+ DateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
+ buildDate = Integer.parseInt(dateFormat.format(date));
+ }
+ }
+ return buildDate;
+ }
+
/**
* Returns the version from a set of properties.
* @param props the set of properties
diff --git a/src/itdelatrisu/opsu/replay/Replay.java b/src/itdelatrisu/opsu/replay/Replay.java
index 086fa9c8..f6a25743 100644
--- a/src/itdelatrisu/opsu/replay/Replay.java
+++ b/src/itdelatrisu/opsu/replay/Replay.java
@@ -18,15 +18,26 @@
package itdelatrisu.opsu.replay;
+import itdelatrisu.opsu.ErrorHandler;
import itdelatrisu.opsu.OsuReader;
+import itdelatrisu.opsu.OsuWriter;
import itdelatrisu.opsu.Utils;
+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;
@@ -59,7 +70,7 @@ public class Replay {
public short hit300, hit100, hit50, geki, katu, miss;
/** The score. */
- public long score;
+ public int score;
/** The max combo. */
public short combo;
@@ -82,19 +93,36 @@ public class Replay {
/** 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.
+ */
+ public void load() {
try {
OsuReader reader = new OsuReader(file);
loadHeader(reader);
loadData(reader);
reader.close();
} catch (IOException e) {
- e.printStackTrace();
+ ErrorHandler.error("Could not load replay data.", e, true);
}
}
@@ -138,8 +166,8 @@ public class Replay {
int time = Integer.parseInt(tokens[0]);
float percentage = Float.parseFloat(tokens[1]);
lifeFrameList.add(new LifeFrame(time, percentage));
- } catch (NumberFormatException | NullPointerException e) {
- Log.warn(String.format("Failed to life frame: '%s'", frame), e);
+ } catch (NumberFormatException e) {
+ Log.warn(String.format("Failed to load life frame: '%s'", frame), e);
}
}
this.lifeFrames = lifeFrameList.toArray(new LifeFrame[lifeFrameList.size()]);
@@ -152,6 +180,7 @@ public class Replay {
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) {
@@ -161,6 +190,10 @@ public class Replay {
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]);
@@ -168,7 +201,7 @@ public class Replay {
int keys = Integer.parseInt(tokens[3]);
replayFrameList.add(new ReplayFrame(timeDiff, time, x, y, keys));
lastTime = time;
- } catch (NumberFormatException | NullPointerException e) {
+ } catch (NumberFormatException e) {
Log.warn(String.format("Failed to parse frame: '%s'", frame), e);
}
}
@@ -176,9 +209,94 @@ public class Replay {
}
}
+ /**
+ * Saves the replay data to a file.
+ * @param file the file to write to
+ */
+ public void save(File file) {
+ 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.getRawX()),
+ nf.format(frame.getRawY()), 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);
+ }
+ }
+
@Override
public String toString() {
- final int LINE_SPLIT = 10;
+ 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');
@@ -197,8 +315,8 @@ public class Replay {
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:\n");
- for (int i = 0; i < lifeFrames.length; i++) {
+ 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]);
@@ -208,14 +326,16 @@ public class Replay {
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:\n");
- for (int i = 0; i < frames.length; i++) {
+ 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();
}
}
diff --git a/src/itdelatrisu/opsu/replay/ReplayFrame.java b/src/itdelatrisu/opsu/replay/ReplayFrame.java
index e3217578..01577433 100644
--- a/src/itdelatrisu/opsu/replay/ReplayFrame.java
+++ b/src/itdelatrisu/opsu/replay/ReplayFrame.java
@@ -78,10 +78,20 @@ public class ReplayFrame {
public int getX() { return (int) (x * OsuHitObject.getXMultiplier() + OsuHitObject.getXOffset()); }
/**
- * Returns the scaled cursor Y coordinate.
+ * Returns the scaled cursor y coordinate.
*/
public int getY() { return (int) (y * OsuHitObject.getYMultiplier() + OsuHitObject.getYOffset()); }
+ /**
+ * Returns the raw cursor x coordinate.
+ */
+ public float getRawX() { return x; }
+
+ /**
+ * Returns the raw cursor y coordinate.
+ */
+ public float getRawY() { return y; }
+
/**
* Returns the keys pressed (KEY_* bitmask).
*/
diff --git a/src/itdelatrisu/opsu/states/Game.java b/src/itdelatrisu/opsu/states/Game.java
index 8d124c6d..128b9a3b 100644
--- a/src/itdelatrisu/opsu/states/Game.java
+++ b/src/itdelatrisu/opsu/states/Game.java
@@ -44,6 +44,7 @@ import itdelatrisu.opsu.replay.Replay;
import itdelatrisu.opsu.replay.ReplayFrame;
import java.io.File;
+import java.util.LinkedList;
import java.util.Stack;
import java.util.concurrent.TimeUnit;
@@ -175,6 +176,9 @@ public class Game extends BasicGameState {
/** The previous game mod state (before the replay). */
private int previousMods = 0;
+ /** The list of current replay frames (for recording replays). */
+ private LinkedList frameList;
+
// game-related variables
private GameContainer container;
private StateBasedGame game;
@@ -468,7 +472,8 @@ public class Game extends BasicGameState {
game.closeRequested();
else { // go to ranking screen
((GameRanking) game.getState(Opsu.STATE_GAMERANKING)).setGameData(data);
- ScoreData score = data.getScoreData(osu);
+ ScoreData score = data.getScoreData(osu, (frameList == null) ? null :
+ frameList.toArray(new ReplayFrame[frameList.size()]));
if (!GameMod.AUTO.isActive() && !GameMod.RELAX.isActive() && !GameMod.AUTOPILOT.isActive() && !isReplay)
ScoreDB.addScore(score);
game.enterState(Opsu.STATE_GAMERANKING, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
@@ -893,7 +898,8 @@ public class Game extends BasicGameState {
};
replayThreadRunning = true;
replayThread.start();
- }
+ } else
+ frameList = new LinkedList();
leadInTime = osu.audioLeadIn + approachTime;
restart = Restart.FALSE;
@@ -938,6 +944,7 @@ public class Game extends BasicGameState {
checkpointLoaded = false;
deaths = 0;
deathTime = -1;
+ frameList = null;
System.gc();
}