├── oo2core_3_win64.dll ├── src └── main │ └── java │ └── io │ └── erosemberg │ └── reader │ ├── parsing │ ├── ParserOptions.java │ ├── StandardParsers.java │ └── events │ │ ├── EventParser.java │ │ └── FortniteEventParser.java │ ├── gamedata │ ├── GameData.java │ └── fortnite │ │ ├── FortniteGameData.java │ │ └── FortniteWeaponTypes.java │ ├── util │ ├── OodleLib.java │ ├── TimeUtils.java │ ├── ByteUtils.java │ └── JARUtils.java │ ├── data │ ├── Chunk.java │ ├── ReplayData.java │ ├── Event.java │ ├── ChunkType.java │ ├── ReplayInfo.java │ ├── ReplayHeader.java │ └── ReplayReader.java │ └── Main.java ├── README.md ├── LICENSE └── pom.xml /oo2core_3_win64.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exception/UnrealReplayReader/master/oo2core_3_win64.dll -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/parsing/ParserOptions.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.parsing; 2 | 3 | import lombok.Builder; 4 | import lombok.Data; 5 | 6 | /** 7 | * @author Erik Rosemberg 8 | * @since 23/12/2018 9 | */ 10 | @Builder 11 | @Data 12 | public class ParserOptions { 13 | 14 | private boolean debug; 15 | private boolean printUnknownWeapons; 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/gamedata/GameData.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.gamedata; 2 | 3 | /** 4 | * @author Erik Rosemberg 5 | * @since 22/12/2018 6 | */ 7 | public interface GameData { 8 | 9 | /** 10 | * Cleans up the event parser so it can be used again without having to 11 | * worry about any duplicate entries or incorrect data. 12 | */ 13 | default void cleanUp() { 14 | // TODO 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/parsing/StandardParsers.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.parsing; 2 | 3 | import io.erosemberg.reader.parsing.events.FortniteEventParser; 4 | import lombok.experimental.UtilityClass; 5 | 6 | /** 7 | * @author Erik Rosemberg 8 | * @since 22/12/2018 9 | */ 10 | @UtilityClass 11 | public class StandardParsers { 12 | 13 | public static final FortniteEventParser FORTNITE_PARSER = new FortniteEventParser(); 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/util/OodleLib.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.util; 2 | 3 | import com.sun.jna.Library; 4 | 5 | public interface OodleLib extends Library { 6 | 7 | int OodleLZ_Decompress(byte[] buffer, long bufferSize, byte[] outputBuffer, long outputBufferSize, 8 | int a, int b, int c, int d, int e, int f, int g, int h, int i, int threadModule); 9 | 10 | boolean DecompressBuffer(byte[] dataBuffer, byte[] output); 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🐎 Unreal Replay Reader 2 | This library/utility is able to parse and output data from Unreal Engine replays. 3 | 4 | ## Getting Started 5 | Clone the repository by doing: 6 | `git clone git@github.com:exception/UnrealReplayReader.git` or `git clone https://github.com/exception/UnrealReplayReader.git`. 7 | 8 | Move into the directory where you cloned it using: 9 | `cd UnrealReplayReader/` 10 | 11 | Compile the project by doing: 12 | `mvn clean install` 13 | 14 | Move into the target directory: 15 | `cd target\` 16 | 17 | Run the compiled jar file: 18 | `java -jar ReplayReader.jar` 19 | 20 | ## Bug Reports 21 | If you find any bugs please open an issue and mark it as a bug! 22 | 23 | ## License 24 | [MIT](LICENSE) © Erik Rosemberg -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/data/Chunk.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.data; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | 6 | /** 7 | * Represents the FLocalFileChunkInfo from Unreal Engine. 8 | * 9 | * @author Erik Rosemberg 10 | * @see FLocalFileChunkInfo 11 | * @since 21/12/2018 12 | */ 13 | @Data 14 | @AllArgsConstructor 15 | public class Chunk { 16 | 17 | private ChunkType chunkType; 18 | private int sizeInBytes; 19 | private long typeOffset; 20 | private long dataOffset; 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/data/ReplayData.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.data; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | 6 | /** 7 | * Represents the FLocalFileReplayDataInfo from Unreal Engine. 8 | * 9 | * @author Erik Rosemberg 10 | * @see FLocalFileReplayDataInfo 11 | * @since 21/12/2018 12 | */ 13 | @Data 14 | @AllArgsConstructor 15 | public class ReplayData { 16 | 17 | private int chunkIndex; 18 | private long time1; 19 | private long time2; 20 | private int sizeInBytes; 21 | private long replayDataOffset; 22 | //private long streamOffset; 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/data/Event.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.data; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | 6 | /** 7 | * Represents the FLocalFileEventInfo from Unreal Engine. 8 | * 9 | * @author Erik Rosemberg 10 | * @see FLocalFileEventInfo 11 | * @since 21/12/2018 12 | */ 13 | @Data 14 | @AllArgsConstructor 15 | public class Event { 16 | 17 | private int chunkIndex; 18 | private String id; 19 | private String group; 20 | private String metadata; 21 | private long time1; 22 | private long time2; 23 | 24 | private int sizeInBytes; 25 | private long eventDataOffset; 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/data/ChunkType.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.data; 2 | 3 | import lombok.AllArgsConstructor; 4 | 5 | import java.util.stream.Stream; 6 | 7 | /** 8 | * Represents the ELocalFileChunkType from Unreal Engine. 9 | * All identifiers should be unsigned integers. 10 | * 11 | * @author Erik Rosemberg 12 | * @see ELocalFileChunkType 13 | * @since 21/12/2018 14 | */ 15 | @AllArgsConstructor 16 | public enum ChunkType { 17 | HEADER(0), 18 | REPLAY_DATA(1), 19 | CHECKPOINT(2), 20 | EVENT(3), 21 | UNKNOWN(0xFFFFFFFF); 22 | 23 | long identifier; 24 | 25 | public static ChunkType from(long identifier) { 26 | return Stream.of(values()).filter(type -> type.identifier == identifier).findAny().orElse(UNKNOWN); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/util/TimeUtils.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.util; 2 | 3 | import lombok.experimental.UtilityClass; 4 | 5 | import java.util.Date; 6 | 7 | /** 8 | * Utility class for any methods to help us interact with time. 9 | * 10 | * @author Erik Rosemberg 11 | * @since 21/12/2018 12 | */ 13 | @UtilityClass 14 | public class TimeUtils { 15 | 16 | private static final long TICKS_AT_EPOCH = 621355968000000000L; 17 | private static final long TICKS_PER_MILLISECOND = 10000; 18 | 19 | public static Date fromTicks(long ticks) { 20 | return new Date((ticks - TICKS_AT_EPOCH) / TICKS_PER_MILLISECOND); 21 | } 22 | 23 | public static String msToTimestamp(long ms) { 24 | long minutes = Math.round(Math.floor(ms / 1000 / 60)); 25 | ms -= minutes * 1000 * 60; 26 | long seconds = Math.round(Math.floor(ms / 1000)); 27 | ms -= seconds * 1000; 28 | return (minutes < 10 ? "0" + minutes : String.valueOf(minutes)) + ":" + (seconds < 10 ? "0" + seconds : String.valueOf(seconds)); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/data/ReplayInfo.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.data; 2 | 3 | import io.erosemberg.reader.gamedata.GameData; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | 7 | import java.util.LinkedList; 8 | 9 | /** 10 | * Represents the FLocalFileReplayInfo from Unreal Engine. 11 | * 12 | * @author Erik Rosemberg 13 | * @see FLocalFileReplayInfo 14 | * @since 21/12/2018 15 | */ 16 | @Data 17 | @Builder 18 | public class ReplayInfo { 19 | 20 | /** 21 | * The header is structured into it's own object to simplify this class. 22 | */ 23 | private ReplayHeader header; 24 | private int headerChunkIndex; 25 | 26 | private LinkedList chunks; 27 | private LinkedList checkpoints; 28 | private LinkedList events; 29 | private LinkedList dataChunks; 30 | 31 | private T gameData; 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Erik Rosemberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/parsing/events/EventParser.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.parsing.events; 2 | 3 | import io.erosemberg.reader.data.Event; 4 | import io.erosemberg.reader.gamedata.GameData; 5 | import io.erosemberg.reader.parsing.ParserOptions; 6 | import me.hugmanrique.jacobin.reader.LittleEndianDataReader; 7 | 8 | import java.io.IOException; 9 | 10 | /** 11 | * @author Erik Rosemberg 12 | * @since 22/12/2018 13 | */ 14 | public interface EventParser { 15 | 16 | /** 17 | * Returns an instance of {@link T} with data gathered from the {@link Event} 18 | * and the {@link LittleEndianDataReader}. 19 | * 20 | * @param event the event to parse, must be non-null. 21 | * @param reader the stream of the replay we're reading 22 | * must be non-null. Since it is not a clone, any changes 23 | * will affect the actual reader being used by the {@link io.erosemberg.reader.data.ReplayReader} 24 | * and may affect it's performance. 25 | * @throws IOException if an I/O error occurs. 26 | */ 27 | T parse(Event event, LittleEndianDataReader reader, ParserOptions options) throws IOException; 28 | 29 | /** 30 | * Once we're done parsing, this will return the finalized GameData object. 31 | */ 32 | T finalFetch(); 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/gamedata/fortnite/FortniteGameData.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.gamedata.fortnite; 2 | 3 | import io.erosemberg.reader.gamedata.GameData; 4 | import io.erosemberg.reader.util.TimeUtils; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | 8 | import java.util.ArrayList; 9 | import java.util.HashSet; 10 | import java.util.LinkedList; 11 | import java.util.List; 12 | import java.util.Set; 13 | 14 | /** 15 | * @author Erik Rosemberg 16 | * @since 22/12/2018 17 | */ 18 | @Data 19 | public class FortniteGameData implements GameData { 20 | 21 | private List warnings = new ArrayList<>(); 22 | 23 | private List kills = new LinkedList<>(); 24 | private Set players = new HashSet<>(); 25 | 26 | private long finalRanking; 27 | private long totalPlayers; 28 | private long totalElims; 29 | 30 | @Override 31 | public void cleanUp() { 32 | this.warnings.clear(); 33 | this.kills.clear(); 34 | this.players.clear(); 35 | 36 | this.finalRanking = 0; 37 | this.totalPlayers = 0; 38 | this.totalElims = 0; 39 | } 40 | 41 | @Data 42 | @AllArgsConstructor 43 | public static class Kill { 44 | String killer; 45 | String killed; 46 | FortniteWeaponTypes type; 47 | 48 | boolean isElimination; 49 | 50 | long time1; 51 | long time2; 52 | 53 | /** 54 | * Returns the formatted timestamp in a mm:ss format. 55 | */ 56 | public String getFormattedTimestamp() { 57 | return TimeUtils.msToTimestamp(time1); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/util/ByteUtils.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.util; 2 | 3 | import com.sun.jna.Native; 4 | import com.sun.xml.internal.messaging.saaj.util.ByteOutputStream; 5 | import lombok.experimental.UtilityClass; 6 | 7 | import java.util.stream.Stream; 8 | 9 | /** 10 | * Utility class containing all methods with reference to binary data. 11 | * 12 | * @author Erik Rosemberg 13 | * @since 21/12/2018 14 | */ 15 | @UtilityClass 16 | public class ByteUtils { 17 | 18 | public static byte[] decompress(byte[] buffer, int size, int uncompressedSize) { 19 | OodleLib oodle = Native.load("oo2core_3_win64.dll", OodleLib.class); 20 | System.out.println("oodle = " + oodle); 21 | byte[] decompressedBuffer = new byte[uncompressedSize]; 22 | int decompressedCount = oodle.OodleLZ_Decompress(buffer, size, decompressedBuffer, uncompressedSize, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3); 23 | System.out.println("decompressedCount = " + decompressedCount); 24 | 25 | if (decompressedCount == uncompressedSize) { 26 | return decompressedBuffer; 27 | } else if (decompressedCount < uncompressedSize) { 28 | return Stream.of(buffer).skip(decompressedCount).collect(ByteOutputStream::new, (b, e) -> { 29 | try { 30 | b.write(e); 31 | } catch (Exception ex) { 32 | ex.printStackTrace(); 33 | } 34 | }, (a, b) -> {}).toByteArray(); 35 | } else { 36 | throw new IllegalStateException("There was an error while decompressing"); 37 | } 38 | } 39 | 40 | public static int adjustLength(int length) { 41 | return length < 0 ? -length * 2 : length; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/util/JARUtils.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.util; 2 | 3 | import java.io.File; 4 | import java.io.FileNotFoundException; 5 | import java.io.FileOutputStream; 6 | import java.io.IOException; 7 | import java.io.InputStream; 8 | import java.io.OutputStream; 9 | 10 | public final class JARUtils { 11 | 12 | private JARUtils() { 13 | // no op 14 | } 15 | 16 | public static void exportAndLoadResource(Class fromClass, String resourceName, String exportPath) { 17 | File temp = new File(exportPath); 18 | if (temp.exists()) { 19 | System.load(temp.getAbsolutePath()); 20 | System.out.println("Already exported and loaded"); 21 | return; 22 | } 23 | InputStream in = null; 24 | OutputStream out = null; 25 | try { 26 | in = fromClass.getResourceAsStream(resourceName); 27 | if (in == null) { 28 | throw new IOException("Cannot get resource \"" + resourceName + "\" from jar."); 29 | } 30 | 31 | int readBytes; 32 | byte[] buffer = new byte[4096]; 33 | out = new FileOutputStream(exportPath); 34 | while ((readBytes = in.read(buffer)) > 0) { 35 | out.write(buffer, 0, readBytes); 36 | } 37 | 38 | System.load(new File(exportPath).getAbsolutePath()); 39 | } catch (IOException e) { 40 | e.printStackTrace(); 41 | } finally { 42 | if (in != null) { 43 | try { 44 | in.close(); 45 | } catch (IOException e) { 46 | e.printStackTrace(); 47 | } 48 | } 49 | 50 | if (out != null) { 51 | try { 52 | out.close(); 53 | } catch (IOException e) { 54 | e.printStackTrace(); 55 | } 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/gamedata/fortnite/FortniteWeaponTypes.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.gamedata.fortnite; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | 6 | import java.util.stream.Stream; 7 | 8 | /** 9 | * @author Erik Rosemberg 10 | * @since 23/12/2018 11 | */ 12 | @AllArgsConstructor 13 | public enum FortniteWeaponTypes { 14 | ASSAULT_RIFLE(4, 260, "Assault Rifle"), 15 | PISTOL(2, 258, "Pistol"), 16 | SMG(5, 261, "Submachine Gun"), 17 | MINIGUN(14, 270, "Minigun"), 18 | SHOTGUN(3, 259, "Shotgun"), 19 | ROCKET_LAUNCHER(13, 269, "Rocket Launcher"), 20 | GRENADE_LAUNCHER(12, 268, "Grenade Launcher"), 21 | SNIPER_RIFLE(7, 263, "Sniper Rifle"), 22 | CROSS_BOW(15, 271, "Crossbow"), 23 | SWITCH_TEAMS(37, 293, "Switch Teams"), 24 | FALL_DAMAGE(1, 257, "Fall Damage"), 25 | RESPAWN(46, -2, "Respawn"), 26 | TRAP(16, 272, "Trap"), 27 | PICKAXE(8, 264, "Pickaxe"), 28 | VEHICLE(23, 279, "Vehicle"), 29 | BIPLANE_GUN(39, 295, "X-4 Fighter Wing"), 30 | TURRET(27, 283, "Turret"), 31 | STINK_NADE(25, 281, "Stink Grenade"), 32 | GRENADE(10, 266, "Grenade"), 33 | KICKED(31, -2, "Kicked from Party"), 34 | UNKNOWN(0, -2, "Unknown"); 35 | 36 | private long id; 37 | private long knockId; 38 | @Getter 39 | private String humanName; 40 | 41 | /** 42 | * Checks if a weapon type matches the knock weapon types. 43 | * Apparently the weapons have different ids for the DBNO (down but not out) state. 44 | *

45 | * If true is returned, the player was fully eliminated from the game. 46 | * If false is returned, the player was knocked out from the game. 47 | * 48 | * @param id the id of the weapon used 49 | */ 50 | public static boolean isKnock(long id) { 51 | return Stream.of(values()).anyMatch(type -> type.id == id); 52 | } 53 | 54 | /** 55 | * Returns the weapon type if any match the id, if not, returns UNKNOWN 56 | * 57 | * @param id the id of the weapon. 58 | */ 59 | public static FortniteWeaponTypes fromId(long id) { 60 | return Stream.of(values()).filter(type -> type.id == id || type.knockId == id).findAny().orElse(UNKNOWN); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/data/ReplayHeader.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.data; 2 | 3 | import io.erosemberg.reader.util.TimeUtils; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | import me.hugmanrique.jacobin.reader.LittleEndianDataReader; 7 | 8 | import java.io.IOException; 9 | import java.nio.charset.StandardCharsets; 10 | import java.util.Date; 11 | 12 | /** 13 | * Represents the header of a replay (or demo) from the Unreal Engine. 14 | * There is no structure for it therefore it is placed inside this single class. 15 | *

16 | * The defined variables can be seen here: 17 | * 18 | * @author Erik Rosemberg 19 | * @see Structure. 20 | * @since 21/12/2018 21 | */ 22 | @Data 23 | @AllArgsConstructor 24 | public class ReplayHeader { 25 | 26 | private long magicNumber; 27 | private long fileVersion; 28 | private int lengthInMs; 29 | private long networkVersion; 30 | private long changeList; 31 | private String friendlyName; 32 | private boolean isLive; 33 | private Date timeStamp; 34 | private boolean compressed; 35 | 36 | static ReplayHeader readHeader(LittleEndianDataReader reader) throws IOException { 37 | long magicNumber = reader.readUInt32(); 38 | long fileVersion = reader.readUInt32(); 39 | int lengthInMs = reader.readInt32(); 40 | long networkVersion = reader.readUInt32(); 41 | long changeList = reader.readUInt32(); 42 | int friendlyNameSize = adjustFriendlySizeName(reader.readInt32()); 43 | 44 | byte[] buffer = new byte[friendlyNameSize]; 45 | reader.read(buffer, 0, friendlyNameSize); 46 | String name = new String(buffer, StandardCharsets.UTF_8).trim().replaceAll("\u0000", ""); 47 | boolean isLive = reader.readUInt32() != 0; 48 | 49 | // read timestamp as uint64 as per Unreal Engine specifications. 50 | // see https://api.unrealengine.com/INT/API/Runtime/Core/Misc/FDateTime/__ctor/2/index.html 51 | Date timestamp = TimeUtils.fromTicks(reader.readUInt64()); 52 | 53 | boolean compressed = reader.readUInt32() != 0; 54 | 55 | System.out.println("compressed = " + compressed); 56 | 57 | return new ReplayHeader( 58 | magicNumber, 59 | fileVersion, 60 | lengthInMs, 61 | networkVersion, 62 | changeList, 63 | name, 64 | isLive, 65 | timestamp, 66 | compressed 67 | ); 68 | } 69 | 70 | private static int adjustFriendlySizeName(int size) { 71 | return size < 0 ? -size * 2 : size; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | io.erosemberg 8 | ReplayReader 9 | 1.0-SNAPSHOT 10 | 11 | 12 | UTF-8 13 | UTF-8 14 | 15 | 16 | 17 | 18 | 19 | org.apache.maven.plugins 20 | maven-compiler-plugin 21 | 22 | 8 23 | 8 24 | 25 | 26 | 27 | org.apache.maven.plugins 28 | maven-shade-plugin 29 | 2.4 30 | 31 | 32 | package 33 | 34 | shade 35 | 36 | 37 | 38 | 39 | 40 | org.apache.maven.plugins 41 | maven-jar-plugin 42 | 43 | 44 | 45 | io.erosemberg.reader.Main 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | jitpack.io 56 | https://jitpack.io 57 | 58 | 59 | 60 | 61 | 62 | org.projectlombok 63 | lombok 64 | 1.18.4 65 | provided 66 | 67 | 68 | com.github.Hugmanrique 69 | Jacobin 70 | master-SNAPSHOT 71 | 72 | 73 | net.java.dev.jna 74 | jna 75 | 5.2.0 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/Main.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader; 2 | 3 | import io.erosemberg.reader.data.ReplayInfo; 4 | import io.erosemberg.reader.data.ReplayReader; 5 | import io.erosemberg.reader.gamedata.fortnite.FortniteGameData; 6 | import io.erosemberg.reader.parsing.ParserOptions; 7 | import io.erosemberg.reader.parsing.StandardParsers; 8 | import io.erosemberg.reader.util.ByteUtils; 9 | import me.hugmanrique.jacobin.reader.LittleEndianDataReader; 10 | 11 | import java.io.FileInputStream; 12 | import java.io.IOException; 13 | import java.nio.file.Path; 14 | import java.nio.file.Paths; 15 | import java.util.Arrays; 16 | import java.util.Scanner; 17 | import java.util.concurrent.ThreadLocalRandom; 18 | 19 | /** 20 | * @author Erik Rosemberg 21 | * @since 21/12/2018 22 | */ 23 | public class Main { 24 | 25 | public static void main(String[] args) throws IOException { 26 | Scanner scanner = new Scanner(System.in); 27 | System.out.print("Please enter the path to the replay: "); 28 | String replayPath = scanner.next(); 29 | 30 | // This is just for testing purposes. 31 | Path path = Paths.get(replayPath); 32 | FileInputStream stream = new FileInputStream(path.toFile()); 33 | LittleEndianDataReader reader = new LittleEndianDataReader(stream); 34 | 35 | ReplayReader replayReader = new ReplayReader<>(reader, StandardParsers.FORTNITE_PARSER, ParserOptions.builder().printUnknownWeapons(true).build()); 36 | ReplayInfo info = replayReader.read(); 37 | FortniteGameData gameData = info.getGameData(); 38 | System.out.println("Finished Reading Replay!"); 39 | System.out.println("*** HEADER DATA:"); 40 | System.out.println(" Name: " + info.getHeader().getFriendlyName()); 41 | System.out.println(" Changelist Size: " + info.getHeader().getChangeList()); 42 | System.out.println(" File Version: " + info.getHeader().getFileVersion()); 43 | System.out.println(" Network Version: " + info.getHeader().getNetworkVersion()); 44 | System.out.println(" Duration: " + info.getHeader().getLengthInMs()); 45 | System.out.println(" Magic Number: " + info.getHeader().getMagicNumber()); 46 | System.out.println("*** BREAKDOWN..."); 47 | System.out.println(" Found " + info.getEvents().size() + " events."); 48 | System.out.println(" Found " + info.getDataChunks().size() + " data chunks."); 49 | System.out.println(" Found " + info.getCheckpoints().size() + " checkpoints."); 50 | System.out.println(" Found " + gameData.getKills().size() + " kills."); 51 | System.out.println(" Found " + gameData.getPlayers().size() + " players."); 52 | System.out.println(gameData.toString()); 53 | 54 | System.setProperty("jna.library.path", System.getProperty("user.dir")); 55 | 56 | byte[] array = new byte[51]; 57 | ByteUtils.decompress(array, 30, 50); // testing 58 | System.out.println("array = " + Arrays.toString(array)); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/parsing/events/FortniteEventParser.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.parsing.events; 2 | 3 | import io.erosemberg.reader.data.Event; 4 | import io.erosemberg.reader.gamedata.fortnite.FortniteGameData; 5 | import io.erosemberg.reader.gamedata.fortnite.FortniteWeaponTypes; 6 | import io.erosemberg.reader.parsing.ParserOptions; 7 | import io.erosemberg.reader.util.ByteUtils; 8 | import io.erosemberg.reader.util.TimeUtils; 9 | import me.hugmanrique.jacobin.reader.LittleEndianDataReader; 10 | 11 | import java.io.IOException; 12 | import java.nio.charset.Charset; 13 | import java.nio.charset.StandardCharsets; 14 | 15 | /** 16 | * @author Erik Rosemberg 17 | * @since 22/12/2018 18 | */ 19 | public class FortniteEventParser implements EventParser { 20 | 21 | private FortniteGameData gameData = new FortniteGameData(); 22 | 23 | @Override 24 | public FortniteGameData parse(Event event, LittleEndianDataReader reader, ParserOptions options) throws IOException { 25 | String group = event.getGroup(); 26 | String metatag = event.getMetadata(); 27 | System.out.println("Meta " + metatag + " | Group " + group); 28 | if (group.equalsIgnoreCase("playerElim")) { 29 | reader.skip(45); // woo magic numbers! 30 | int killedLength = ByteUtils.adjustLength(reader.readInt32()); 31 | byte[] killedBuffer = new byte[killedLength]; 32 | reader.read(killedBuffer, 0, killedLength); 33 | String killed = properParse(killedBuffer, killedLength); 34 | int killerLength = ByteUtils.adjustLength(reader.readInt32()); 35 | byte[] killerBuffer = new byte[killerLength]; 36 | reader.read(killerBuffer, 0, killerLength); 37 | String killer = properParse(killerBuffer, killerLength); 38 | long weaponId = reader.readUInt32(); 39 | 40 | FortniteWeaponTypes type = FortniteWeaponTypes.fromId(weaponId); 41 | boolean knock = FortniteWeaponTypes.isKnock(weaponId); 42 | if (type == FortniteWeaponTypes.UNKNOWN && options.isPrintUnknownWeapons()) { 43 | System.out.println("Unknown weapon type with ID " + weaponId + " at " + TimeUtils.msToTimestamp(event.getTime1())); 44 | knock = false; 45 | } 46 | 47 | gameData.getKills().add(new FortniteGameData.Kill(killer, killed, type, knock, event.getTime1(), event.getTime2())); 48 | gameData.getPlayers().add(killer); 49 | gameData.getPlayers().add(killed); 50 | } else if (metatag.equalsIgnoreCase("AthenaMatchTeamStats")) { 51 | reader.readUInt32(); // ignore this value, always 0. 52 | long finalRanking = reader.readUInt32(); 53 | long totalPlayers = reader.readUInt32(); 54 | 55 | gameData.setTotalPlayers(totalPlayers); 56 | gameData.setFinalRanking(finalRanking); 57 | } else if (metatag.equalsIgnoreCase("AthenaMatchStats")) { 58 | reader.readUInt32(); 59 | reader.readUInt32(); 60 | reader.readUInt32(); 61 | 62 | long totalElims = reader.readUInt32(); 63 | gameData.setTotalElims(totalElims); 64 | } 65 | return gameData; 66 | } 67 | 68 | /** 69 | * Due to Epic's way of handling weird characters, they are using two different encodings. 70 | * This method is checking if the second to last byte is 0, since the UTF-16 null terminator (\0) 71 | * is 0x0 0x0 vs UTF-8's 0x0, hence the comparison of the 2nd to last byte 72 | * instead of the last byte. 73 | * 74 | * @param data the binary data gathered from the stream. 75 | * @param len the length of the binary length. 76 | */ 77 | private String properParse(byte[] data, int len) { 78 | boolean isUtf16 = data[len - 2] == 0; // check if 2nd to last byte is 0x0. 79 | Charset charset = StandardCharsets.UTF_8; 80 | if (isUtf16) { 81 | charset = StandardCharsets.UTF_16LE; 82 | } 83 | 84 | return new String(data, charset); 85 | } 86 | 87 | @Override 88 | public FortniteGameData finalFetch() { 89 | if (gameData.getPlayers().size() < gameData.getTotalPlayers()) { 90 | long diff = gameData.getTotalPlayers() - gameData.getPlayers().size(); 91 | gameData.getWarnings().add("We were unable to track " + diff + " players. This is most likely due to them not getting kills/knocked/killed."); 92 | } 93 | return gameData; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/java/io/erosemberg/reader/data/ReplayReader.java: -------------------------------------------------------------------------------- 1 | package io.erosemberg.reader.data; 2 | 3 | import io.erosemberg.reader.gamedata.GameData; 4 | import io.erosemberg.reader.parsing.ParserOptions; 5 | import io.erosemberg.reader.parsing.events.EventParser; 6 | import io.erosemberg.reader.util.ByteUtils; 7 | import me.hugmanrique.jacobin.reader.LittleEndianDataReader; 8 | 9 | import java.io.IOException; 10 | import java.util.Arrays; 11 | import java.util.HashSet; 12 | import java.util.LinkedList; 13 | import java.util.Set; 14 | 15 | /** 16 | * @author Erik Rosemberg 17 | * @since 21/12/2018 18 | */ 19 | public class ReplayReader { 20 | 21 | private final int INDEX_NONE = -1; 22 | private final EventParser parser; 23 | private final ParserOptions options; 24 | private final LittleEndianDataReader reader; 25 | 26 | /** 27 | * Creates a new instance of the ReplayReader. 28 | * 29 | * @param reader the ByteStreamReader from which we will be reading the data. 30 | */ 31 | public ReplayReader(LittleEndianDataReader reader, EventParser parser, ParserOptions options) { 32 | this.reader = reader; 33 | this.parser = parser; 34 | this.options = options; 35 | } 36 | 37 | public ReplayReader(LittleEndianDataReader reader, EventParser parser) { 38 | this(reader, parser, ParserOptions.builder().debug(true).printUnknownWeapons(true).build()); 39 | } 40 | 41 | @SuppressWarnings("all") 42 | public ReplayInfo read() throws IOException { 43 | ReplayInfo.ReplayInfoBuilder builder = ReplayInfo.builder(); 44 | 45 | ReplayHeader header = ReplayHeader.readHeader(this.reader); 46 | System.out.println("Finished reading header"); 47 | builder.header(header); 48 | 49 | int totalSize = reader.available(); 50 | int chunkIndex = 0; 51 | int checkpointIndex = 0; 52 | int replayDataIndex = 0; 53 | int eventIndex = 0; 54 | int chunkHeader = INDEX_NONE; 55 | 56 | LinkedList chunks = new LinkedList<>(); 57 | LinkedList checkpoints = new LinkedList<>(); 58 | LinkedList events = new LinkedList<>(); 59 | LinkedList dataChunks = new LinkedList<>(); 60 | 61 | Set players = new HashSet<>(); 62 | 63 | System.out.println("Beginning to read all the chunks! (" + reader.available() + ")."); 64 | while (reader.available() > 0) { 65 | // https://github.com/EpicGames/UnrealEngine/blob/master/Engine/Source/Runtime/NetworkReplayStreaming/LocalFileNetworkReplayStreaming/Private/LocalFileNetworkReplayStreaming.cpp#L243 66 | long typeOffset = reader.getOffset(); // Same as FArchive.Tell() 67 | 68 | // Parses ELocalFileChunkType from reader. 69 | long uint = reader.readUInt32(); // unsigned int32 according to https://github.com/EpicGames/UnrealEngine/blob/b70f31f6645d764bcb55829228918a6e3b571e0b/Engine/Source/Runtime/NetworkReplayStreaming/LocalFileNetworkReplayStreaming/Public/LocalFileNetworkReplayStreaming.h#L19 70 | ChunkType type = ChunkType.from(uint); 71 | 72 | int sizeInBytes = reader.readInt32(); 73 | long dataOffset = reader.getOffset(); 74 | 75 | Chunk chunk = new Chunk(type, sizeInBytes, typeOffset, dataOffset); 76 | chunks.add(chunk); 77 | chunkIndex += 1; 78 | 79 | switch (type) { 80 | case HEADER: 81 | if (chunkHeader == INDEX_NONE) { 82 | chunkHeader = chunkIndex; 83 | } else { 84 | throw new IllegalStateException("more than one header chunk found!"); 85 | } 86 | break; 87 | case CHECKPOINT: { 88 | int idLength = reader.readInt32(); 89 | String id = reader.readUTF(idLength).trim(); 90 | int groupLength = reader.readInt32(); 91 | String group = reader.readUTF(groupLength).trim(); 92 | int metadataLength = reader.readInt32(); 93 | String metadata = reader.readUTF(metadataLength).trim(); 94 | 95 | long time1 = reader.readUInt32(); 96 | long time2 = reader.readUInt32(); 97 | int size = reader.readInt32(); 98 | 99 | long eventDataOffset = reader.getOffset(); 100 | 101 | Event checkpoint = new Event(checkpointIndex, id, group, metadata, time1, time2, size, eventDataOffset); 102 | checkpoints.add(checkpoint); 103 | checkpointIndex += 1; 104 | 105 | byte[] buffer = new byte[size]; 106 | reader.read(buffer); 107 | 108 | break; 109 | } 110 | case REPLAY_DATA: { 111 | long time1 = reader.readUInt32(); 112 | long time2 = reader.readUInt32(); 113 | int size = reader.readInt32(); 114 | long replayDataOffset = reader.getOffset(); 115 | 116 | ReplayData data = new ReplayData(replayDataIndex, time1, time2, size, replayDataOffset); 117 | dataChunks.add(data); 118 | replayDataIndex += 1; 119 | break; 120 | } 121 | case EVENT: { 122 | int idLength = reader.readInt32(); 123 | String id = reader.readUTF(idLength).trim(); 124 | int groupLength = reader.readInt32(); 125 | String group = reader.readUTF(groupLength).trim(); 126 | int metadataLength = reader.readInt32(); 127 | String metadata = reader.readUTF(metadataLength).trim(); 128 | 129 | long time1 = reader.readUInt32(); 130 | long time2 = reader.readUInt32(); 131 | int size = reader.readInt32(); 132 | 133 | long eventDataOffset = reader.getOffset(); 134 | 135 | Event event = new Event(eventIndex, id, group, metadata, time1, time2, size, eventDataOffset); 136 | if (parser != null) { 137 | parser.parse(event, reader, options); 138 | } 139 | 140 | events.add(event); 141 | eventIndex += 1; 142 | break; 143 | } 144 | case UNKNOWN: 145 | System.out.println("Encountered unknown type."); 146 | break; 147 | } 148 | 149 | // https://github.com/EpicGames/UnrealEngine/blob/master/Engine/Source/Runtime/NetworkReplayStreaming/LocalFileNetworkReplayStreaming/Private/LocalFileNetworkReplayStreaming.cpp#L401 150 | reader.setOffset(dataOffset + sizeInBytes); // Same as FArchive.Seek() 151 | } 152 | 153 | builder 154 | .chunks(chunks) 155 | .checkpoints(checkpoints) 156 | .dataChunks(dataChunks) 157 | .events(events) 158 | .gameData(parser.finalFetch()); 159 | 160 | return builder.build(); 161 | } 162 | } 163 | --------------------------------------------------------------------------------