From 7536056a5984f597eb548eb3ba40adb906dfdbce Mon Sep 17 00:00:00 2001 From: Jeffrey Han Date: Tue, 10 Mar 2015 18:10:51 -0400 Subject: [PATCH] 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 --- pom.xml | 5 + src/itdelatrisu/opsu/GameData.java | 48 +++++- src/itdelatrisu/opsu/OsuFile.java | 7 +- src/itdelatrisu/opsu/OsuParser.java | 2 +- src/itdelatrisu/opsu/OsuWriter.java | 162 +++++++++++++++++++ src/itdelatrisu/opsu/downloads/Updater.java | 33 ++++ src/itdelatrisu/opsu/replay/Replay.java | 140 ++++++++++++++-- src/itdelatrisu/opsu/replay/ReplayFrame.java | 12 +- src/itdelatrisu/opsu/states/Game.java | 11 +- 9 files changed, 402 insertions(+), 18 deletions(-) create mode 100644 src/itdelatrisu/opsu/OsuWriter.java 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(); }