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