Initial replay writing support.

- Added Replay.save() method to save replays to a file.
- Separated Replay loading from the constructor into a load() method.
- Added OsuWriter class to write replays.
- Parse replay seeds (what do they do?).
- Added Updater.getBuildDate() method to retrieve the current build date (for the replay 'version' field).
- Added osu! mode constants in OsuFile.
- Added methods to retrieve raw ReplayFrame coordinates.
- Added replay fields/methods to GameData and Game state.
- Added jponge/lzma-java dependency for LZMA compression, since it isn't implemented in Apache commons-compress...

Signed-off-by: Jeffrey Han <itdelatrisu@gmail.com>
This commit is contained in:
Jeffrey Han 2015-03-10 18:10:51 -04:00
parent 3da7b320f0
commit 7536056a59
9 changed files with 402 additions and 18 deletions

View File

@ -202,5 +202,10 @@
<artifactId>commons-compress</artifactId>
<version>1.8</version>
</dependency>
<dependency>
<groupId>com.github.jponge</groupId>
<artifactId>lzma-java</artifactId>
<version>1.2</version>
</dependency>
</dependencies>
</project>

View File

@ -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

View File

@ -32,6 +32,9 @@ import org.newdawn.slick.util.Log;
* Data type storing parsed data from OSU files.
*/
public class OsuFile implements Comparable<OsuFile> {
/** 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<OsuFile, Image> bgImageMap = new HashMap<OsuFile, Image>();
@ -66,8 +69,8 @@ public class OsuFile implements Comparable<OsuFile> {
/** 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;

View File

@ -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;

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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 <length> <char>* = normal string
// <length> is encoded as an LEB, and is the byte length of the rest.
// <char>* 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);
}
}

View File

@ -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

View File

@ -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<ReplayFrame> replayFrameList = new ArrayList<ReplayFrame>(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();
}
}

View File

@ -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).
*/

View File

@ -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<ReplayFrame> 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<ReplayFrame>();
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();
}