Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework of parsing the header of a replay #143

Merged
merged 26 commits into from
May 18, 2024
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1403423
Separate out the reading and interpreting of the file
Garanas May 6, 2024
01f0f6e
Undo lfs of fafreplay
Sheikah45 May 6, 2024
01f3bc2
Fix replays that were damaged by LFS
Garanas May 7, 2024
20155f0
Make the memory-based functions private
Garanas May 7, 2024
a5ee027
Clean up of the code
Garanas May 7, 2024
c7f75b4
Improve documentation of unknown values
Garanas May 7, 2024
bccff78
Document the header of a replay
Garanas May 7, 2024
a8e7303
First working end-to-end example of the new replay parsing
Garanas May 7, 2024
43c0572
Restructure the project, process feedback by Sheikah in #142
Garanas May 7, 2024
8558a85
Extend tests and fix a few bugs
Garanas May 7, 2024
a5467f0
Discover the byte that is set when queueing orders
Garanas May 8, 2024
d0e9566
Introduce the first semantics
Garanas May 9, 2024
dbb816b
Add interpretation of chat messages
Garanas May 9, 2024
7d4ef66
Introduce additional semantics
Garanas May 11, 2024
0f0aca2
Use atomics
Garanas May 11, 2024
cce2ee3
Rework the enums related to game options
Garanas May 11, 2024
a18552a
Remove excessive whitespace
Garanas May 11, 2024
2caafd3
Add documentation that it still requires to be implemented
Garanas May 11, 2024
07bee25
Fix typo in name
Garanas May 11, 2024
020955d
Rename 'Utils' to 'LoadUtils'
Garanas May 11, 2024
4348c64
Use a better describing exception
Garanas May 11, 2024
49ddff9
Undo formatting changes
Garanas May 11, 2024
76615a8
Remove the tokenizer of the header
Garanas May 17, 2024
706ad77
Extend pattern matching of modern Java
Garanas May 17, 2024
bdee610
DO THAT SCREAMING SNAKE CASE THING
Garanas May 17, 2024
1b9492f
Process feedback
Garanas May 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.faforever.commons.replay;

import com.faforever.commons.replay.body.Event;
import com.faforever.commons.replay.header.Source;

/**
* Combines the tick and source of an event. The tick represents when the event was registered. The source represents who authorised the event.
*
* @param tick
* @param source
* @param event
* @see Event
*/
public record RegisteredEvent(int tick, Source source, Event event) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.faforever.commons.replay;

import com.faforever.commons.replay.header.ReplayHeader;

import java.util.List;

/**
* A container of all the information that a replay may hold once parsed.
*
* @param metadata
* @param header
* @param registeredEvents
*/
public record ReplayContainer(ReplayMetadata metadata, ReplayHeader header, List<RegisteredEvent> registeredEvents) {
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.faforever.commons.replay;

import com.faforever.commons.replay.body.event.Event;
import com.faforever.commons.replay.body.event.LuaData;
import com.faforever.commons.replay.body.event.Parser;
import com.faforever.commons.replay.body.token.Token;
import com.faforever.commons.replay.body.token.Tokenizer;
import com.faforever.commons.replay.body.Event;
import com.faforever.commons.replay.shared.LuaData;
import com.faforever.commons.replay.body.ReplayBodyParser;
import com.faforever.commons.replay.body.ReplayBodyToken;
import com.faforever.commons.replay.body.ReplayBodyTokenizer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.io.BaseEncoding;
Expand Down Expand Up @@ -67,7 +67,7 @@ public class ReplayDataParser {
private List<GameOption> gameOptions;

@Getter
private List<Token> tokens;
private List<ReplayBodyToken> tokens;

@Getter
private List<Event> events;
Expand Down Expand Up @@ -151,8 +151,7 @@ private byte[] decompress(byte[] data, @NotNull ReplayMetadata metadata) throws
}
case ZSTD: {
ByteArrayInputStream arrayInputStream = new ByteArrayInputStream(data);
CompressorInputStream compressorInputStream = new CompressorStreamFactory()
.createCompressorInputStream(arrayInputStream);
CompressorInputStream compressorInputStream = new CompressorStreamFactory().createCompressorInputStream(arrayInputStream);

ByteArrayOutputStream out = new ByteArrayOutputStream();
IOUtils.copy(compressorInputStream, out);
Expand All @@ -167,17 +166,17 @@ private byte[] decompress(byte[] data, @NotNull ReplayMetadata metadata) throws
@SuppressWarnings("unchecked")
private void parseHeader(LittleEndianDataInputStream dataStream) throws IOException {
replayPatchFieldId = readString(dataStream);
dataStream.skipBytes(3);
String arg13 = readString(dataStream); // always \r\n

String[] split = readString(dataStream).split("\\r\\n");
String replayVersionId = split[0];
map = split[1];
dataStream.skipBytes(4);
String arg23 = readString((dataStream)); // always \r\n and some unknown character

int numberOfMods = dataStream.readInt();
int sizeModsInBytes = dataStream.readInt();
mods = (Map<String, Map<String, ?>>) parseLua(dataStream);

int scenarioSize = dataStream.readInt();
int sizeGameOptionsInBytes = dataStream.readInt();
this.gameOptions = ((Map<String, Object>) parseLua(dataStream)).entrySet().stream()
.filter(entry -> "Options".equals(entry.getKey()))
.flatMap(entry -> ((Map<String, Object>) entry.getValue()).entrySet().stream())
Expand All @@ -197,7 +196,7 @@ private void parseHeader(LittleEndianDataInputStream dataStream) throws IOExcept

int numberOfArmies = dataStream.readUnsignedByte();
for (int i = 0; i < numberOfArmies; i++) {
dataStream.skipBytes(4);
int sizePlayerDataInBytes = dataStream.readInt();
Map<String, Object> playerData = (Map<String, Object>) parseLua(dataStream);
int playerSource = dataStream.readUnsignedByte();

Expand All @@ -223,11 +222,11 @@ private void interpretEvents(List<Event> events) {
for (Event event : events) {

switch (event) {
case Event.Unprocessed(Token token, String reason) -> {
case Event.Unprocessed(ReplayBodyToken token, String reason) -> {

}

case Event.ProcessingError(Token token, Exception exception) -> {
case Event.ProcessingError(ReplayBodyToken token, Exception exception) -> {

}

Expand Down Expand Up @@ -286,7 +285,9 @@ private void interpretEvents(List<Event> events) {

}

case Event.IssueCommand(Event.CommandUnits commandUnits, Event.CommandData commandData) -> {
case Event.IssueCommand(
Event.CommandUnits commandUnits, Event.CommandData commandData
) -> {
commandsPerMinuteByPlayer
.computeIfAbsent(player, p -> new HashMap<>())
.computeIfAbsent(ticks, t -> new AtomicInteger())
Expand Down Expand Up @@ -326,7 +327,9 @@ private void interpretEvents(List<Event> events) {

}

case Event.DebugCommand() -> {
case Event.DebugCommand(
String command, float px, float py, float pz, byte focusArmy, Event.CommandUnits units
) -> {

}

Expand Down Expand Up @@ -431,8 +434,7 @@ void parseModeratorEvent(LuaData.Table lua, Integer player) {
}
}

moderatorEvents.add(new ModeratorEvent(tickToTime(ticks), activeCommandSource, fromArmy,
messageContent, playerNameFromArmy, playerNameFromCommandSource));
moderatorEvents.add(new ModeratorEvent(tickToTime(ticks), activeCommandSource, fromArmy, messageContent, playerNameFromArmy, playerNameFromCommandSource));
}

private Duration tickToTime(int tick) {
Expand All @@ -443,9 +445,9 @@ private void parse() throws IOException, CompressorException {
readReplayData(path);
try (LittleEndianDataInputStream dataStream = new LittleEndianDataInputStream(new ByteArrayInputStream(data))) {
parseHeader(dataStream);
tokens = Tokenizer.tokenize(dataStream);
tokens = ReplayBodyTokenizer.tokenize(dataStream);
}
events = Parser.parseTokens(tokens);
events = ReplayBodyParser.parseTokens(tokens);
interpretEvents(events);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package com.faforever.commons.replay;

import com.faforever.commons.replay.body.Event;
import com.faforever.commons.replay.body.ReplayBodyParser;
import com.faforever.commons.replay.body.ReplayBodyToken;
import com.faforever.commons.replay.body.ReplayBodyTokenizer;
import com.faforever.commons.replay.header.*;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.google.common.io.BaseEncoding;
import com.google.common.io.LittleEndianDataInputStream;
import org.apache.commons.compress.compressors.CompressorException;
import org.apache.commons.compress.compressors.CompressorInputStream;
import org.apache.commons.compress.compressors.CompressorStreamFactory;
import org.apache.commons.compress.utils.IOUtils;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;

import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;

public class ReplayLoader {

@Contract(pure = true)
private static ReplayHeader loadSCFAReplayHeader(LittleEndianDataInputStream stream) throws IOException {
return ReplayHeaderParser.parse(stream);
}

@Contract(pure = true)
private static @NotNull List<RegisteredEvent> loadSCFAReplayBody(List<Source> sources, LittleEndianDataInputStream stream) throws IOException {
List<ReplayBodyToken> bodyTokens = ReplayBodyTokenizer.tokenize(stream);
List<Event> bodyEvents = ReplayBodyParser.parseTokens(bodyTokens);
return ReplaySemantics.registerEvents(sources, bodyEvents);
}

@Contract(pure = true)
private static ReplayContainer loadSCFAReplayFromMemory(ReplayMetadata metadata, byte[] scfaReplayBytes) throws IOException {
try (LittleEndianDataInputStream stream = new LittleEndianDataInputStream((new ByteArrayInputStream(scfaReplayBytes)))) {
ReplayHeader replayHeader = loadSCFAReplayHeader(stream);
List<RegisteredEvent> replayBody = loadSCFAReplayBody(replayHeader.sources(), stream);

if (stream.available() > 0) {
throw new EOFException();
}

return new ReplayContainer(metadata, replayHeader, replayBody);
}
}

public static ReplayContainer loadSCFAReplayFromDisk(Path scfaReplayFile) throws IOException, IllegalArgumentException {
if (!scfaReplayFile.toString().toLowerCase().endsWith("scfareplay")) {
throw new IllegalArgumentException("Unknown file format: " + scfaReplayFile.getFileName());
}

byte[] bytes = Files.readAllBytes(scfaReplayFile);
return loadSCFAReplayFromMemory(null, bytes);
}

@Contract(pure = true)
private static ReplayContainer loadFAFReplayFromMemory(byte[] fafReplayBytes) throws IOException, CompressorException {
int separator = findSeparatorIndex(fafReplayBytes);
byte[] metadataBytes = Arrays.copyOfRange(fafReplayBytes, 0, separator);
String metadataString = new String(metadataBytes, StandardCharsets.UTF_8);

ObjectMapper parsedMetadata = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
ReplayMetadata replayMetadata = parsedMetadata.readValue(metadataString, ReplayMetadata.class);

byte[] compressedReplayBytes = Arrays.copyOfRange(fafReplayBytes, separator + 1, fafReplayBytes.length);
byte[] scfaReplayBytes = decompress(compressedReplayBytes, replayMetadata);

return loadSCFAReplayFromMemory(replayMetadata, scfaReplayBytes);
}

public static ReplayContainer loadFAFReplayFromDisk(Path fafReplayFile) throws IOException, CompressorException, IllegalArgumentException {
if (!fafReplayFile.toString().toLowerCase().endsWith("fafreplay")) {
throw new IllegalArgumentException("Unknown file format: " + fafReplayFile.getFileName());
}

byte[] fafReplayBytes = Files.readAllBytes(fafReplayFile);
return loadFAFReplayFromMemory(fafReplayBytes);
}

private static int findSeparatorIndex(byte[] replayData) {
int headerEnd;
for (headerEnd = 0; headerEnd < replayData.length; headerEnd++) {
if (replayData[headerEnd] == '\n') {
return headerEnd;
}
}
throw new IllegalArgumentException("Missing separator between replay header and body");
}

private static byte[] decompress(byte[] data, @NotNull ReplayMetadata metadata) throws IOException, CompressorException {
CompressionType compressionType = Objects.requireNonNullElse(metadata.getCompression(), CompressionType.QTCOMPRESS);

switch (compressionType) {
case QTCOMPRESS: {
return QtCompress.qUncompress(BaseEncoding.base64().decode(new String(data)));
}
case ZSTD: {
ByteArrayInputStream arrayInputStream = new ByteArrayInputStream(data);
CompressorInputStream compressorInputStream = new CompressorStreamFactory()
.createCompressorInputStream(arrayInputStream);

ByteArrayOutputStream out = new ByteArrayOutputStream();
IOUtils.copy(compressorInputStream, out);
return out.toByteArray();
}
case UNKNOWN:
default:
throw new IOException("Unknown replay format in replay file");
}
}
}
Loading