Merge branch '1.10.2' into 1.11
c6dec95Merge branch '1.9.4' into 1.10.24fdfa9eMerge branch '1.8.9' into 1.9.444d4a25Merge branch '1.8' into 1.9.401acbc6Update to 1.8.91c60687Update ReplayStudio and jGuid9b796dMixin'd the Shader's mods ShadersRender class to only render hands if the Forge event isn't cancelled6bbff3bUpdated Mixin to 0.6.4-SNAPSHOT and set mixin target level6f3ac71Add CustomMainMenu compatibility info to docsb0a1cdfSearch for ffmpeg executable in common locations to simplify setupd465891Fixed GuiEditPositionKeyframe's width to fit on all screen sizes Centered all rows of GuiEditPositionKeyframe's VerticalLayout1b099f9Add render queue03534c6Update docs4388d69Only try to create the downloads folder if it does not yet exist7ad19aaAdd mod version and accepted MC version directly to the @Mod annotation655bea0Replace @Mod.Instance with direct static references458dad6Merge pull request #4 from ReplayMod/1.8-editoreb5ccb2Add Replay Editor (for now, trimming only)6498d62Fix server game data snapshot being restored in singleplayer (fixes #44)2c898a8Prevent rendering without sufficient keyframes (fixes #45)5d520a5Fix deserialization of chroma key color (fixes #43)c681742Use caching maven proxy for faster and more stable drone buildsfbf165eAdded Anti-Aliasing to the Render Settings
This commit is contained in:
@@ -7,7 +7,10 @@ import net.minecraftforge.fml.common.Mod;
|
||||
import net.minecraftforge.fml.common.event.FMLInitializationEvent;
|
||||
import net.minecraftforge.fml.common.eventhandler.EventBus;
|
||||
|
||||
@Mod(modid = ReplayModCompat.MOD_ID, useMetadata = true)
|
||||
@Mod(modid = ReplayModCompat.MOD_ID,
|
||||
version = "@MOD_VERSION@",
|
||||
acceptedMinecraftVersions = "@MC_VERSION@",
|
||||
useMetadata = true)
|
||||
public class ReplayModCompat {
|
||||
public static final String MOD_ID = "replaymod-compat";
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.replaymod.compat.shaders.mixin;
|
||||
|
||||
import net.minecraft.client.renderer.EntityRenderer;
|
||||
import net.minecraftforge.client.ForgeHooksClient;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Pseudo;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Pseudo
|
||||
@Mixin(targets = "shadersmod/client/ShadersRender", remap = false)
|
||||
public abstract class MixinShadersRender {
|
||||
|
||||
@Inject(method = "renderHand0", at = @At("HEAD"), cancellable = true)
|
||||
private static void replayModCompat_disableRenderHand0(EntityRenderer er, float partialTicks, int renderPass, CallbackInfo ci) {
|
||||
if (ForgeHooksClient.renderFirstPersonHand(er.mc.renderGlobal, partialTicks, renderPass)) {
|
||||
ci.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import java.util.List;
|
||||
|
||||
@Mod(modid = ReplayMod.MOD_ID,
|
||||
useMetadata = true,
|
||||
version = "@MOD_VERSION@",
|
||||
acceptedMinecraftVersions = "@MC_VERSION@",
|
||||
updateJSON = "https://raw.githubusercontent.com/ReplayMod/ReplayMod/master/versions.json",
|
||||
guiFactory = "com.replaymod.core.gui.GuiFactory")
|
||||
public class ReplayMod {
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
package com.replaymod.core.utils;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import de.johni0702.minecraft.gui.GuiRenderer;
|
||||
import de.johni0702.minecraft.gui.RenderInfo;
|
||||
import de.johni0702.minecraft.gui.container.AbstractGuiScrollable;
|
||||
import de.johni0702.minecraft.gui.container.GuiContainer;
|
||||
import de.johni0702.minecraft.gui.container.GuiPanel;
|
||||
import de.johni0702.minecraft.gui.container.GuiScrollable;
|
||||
import de.johni0702.minecraft.gui.element.GuiButton;
|
||||
import de.johni0702.minecraft.gui.element.GuiElement;
|
||||
import de.johni0702.minecraft.gui.element.GuiLabel;
|
||||
import de.johni0702.minecraft.gui.layout.HorizontalLayout;
|
||||
import de.johni0702.minecraft.gui.layout.VerticalLayout;
|
||||
import de.johni0702.minecraft.gui.popup.GuiInfoPopup;
|
||||
import de.johni0702.minecraft.gui.utils.Colors;
|
||||
import net.minecraft.client.gui.GuiScreen;
|
||||
import net.minecraft.client.network.NetworkPlayerInfo;
|
||||
import net.minecraft.client.resources.DefaultPlayerSkin;
|
||||
import net.minecraft.crash.CrashReport;
|
||||
import net.minecraft.util.ResourceLocation;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.lwjgl.input.Keyboard;
|
||||
import org.lwjgl.util.Dimension;
|
||||
import org.lwjgl.util.ReadableDimension;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
import static net.minecraft.client.Minecraft.getMinecraft;
|
||||
|
||||
public class Utils {
|
||||
|
||||
public static final BufferedImage DEFAULT_THUMBNAIL;
|
||||
@@ -63,7 +87,7 @@ public class Utils {
|
||||
}
|
||||
|
||||
public static ResourceLocation getResourceLocationForPlayerUUID(UUID uuid) {
|
||||
NetworkPlayerInfo info = Minecraft.getMinecraft().getConnection().getPlayerInfo(uuid);
|
||||
NetworkPlayerInfo info = getMinecraft().getConnection().getPlayerInfo(uuid);
|
||||
ResourceLocation skinLocation;
|
||||
if (info != null && info.hasLocationSkin()) {
|
||||
skinLocation = info.getLocationSkin();
|
||||
@@ -76,4 +100,80 @@ public class Utils {
|
||||
public static boolean isCtrlDown() {
|
||||
return Keyboard.isKeyDown(Keyboard.KEY_LCONTROL) || Keyboard.isKeyDown(Keyboard.KEY_RCONTROL);
|
||||
}
|
||||
|
||||
public static GuiInfoPopup error(Logger logger, GuiContainer container, CrashReport crashReport, Runnable onClose) {
|
||||
// Convert crash report to string
|
||||
String crashReportStr = crashReport.getCompleteReport();
|
||||
|
||||
// Log via logger
|
||||
logger.error(crashReportStr);
|
||||
|
||||
// Try to save the crash report
|
||||
if (crashReport.getFile() == null) {
|
||||
try {
|
||||
File folder = new File(getMinecraft().mcDataDir, "crash-reports");
|
||||
File file = new File(folder, "crash-" + (new SimpleDateFormat("yyyy-MM-dd_HH.mm.ss")).format(new Date()) + "-client.txt");
|
||||
logger.debug("Saving crash report to file: {}", file);
|
||||
crashReport.saveToFile(file);
|
||||
} catch (Throwable t) {
|
||||
logger.error("Saving crash report file:", t);
|
||||
}
|
||||
} else {
|
||||
logger.debug("Not saving crash report as file already exists: {}", crashReport.getFile());
|
||||
}
|
||||
|
||||
logger.trace("Opening crash report popup GUI");
|
||||
GuiCrashReportPopup popup = new GuiCrashReportPopup(container, crashReportStr);
|
||||
Futures.addCallback(popup.getFuture(), new FutureCallback<Void>() {
|
||||
@Override
|
||||
public void onSuccess(@Nullable Void result) {
|
||||
logger.trace("Crash report popup closed");
|
||||
if (onClose != null) {
|
||||
onClose.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable t) {
|
||||
logger.error("During error popup:", t);
|
||||
}
|
||||
});
|
||||
return popup;
|
||||
}
|
||||
|
||||
private static class GuiCrashReportPopup extends GuiInfoPopup {
|
||||
private final GuiScrollable scrollable;
|
||||
|
||||
public GuiCrashReportPopup(GuiContainer container, String crashReport) {
|
||||
super(container);
|
||||
setBackgroundColor(Colors.DARK_TRANSPARENT);
|
||||
|
||||
// Add crash report to scrollable info
|
||||
getInfo().addElements(new VerticalLayout.Data(0.5),
|
||||
new GuiLabel().setColor(Colors.BLACK).setI18nText("replaymod.gui.unknownerror"),
|
||||
scrollable = new GuiScrollable().setScrollDirection(AbstractGuiScrollable.Direction.VERTICAL)
|
||||
.setLayout(new VerticalLayout().setSpacing(2))
|
||||
.addElements(null,Arrays.stream(crashReport.replace("\t", " ").split("\n")).map(
|
||||
l -> new GuiLabel().setText(l).setColor(Colors.BLACK)).toArray(GuiElement[]::new)));
|
||||
|
||||
// Replace close button with panel containing close and copy buttons
|
||||
GuiButton copyToClipboardButton = new GuiButton().setI18nLabel("chat.copy").onClick(() ->
|
||||
GuiScreen.setClipboardString(crashReport)).setSize(150, 20);
|
||||
GuiButton closeButton = getCloseButton();
|
||||
popup.removeElement(closeButton);
|
||||
popup.addElements(new VerticalLayout.Data(1),
|
||||
new GuiPanel().setLayout(new HorizontalLayout().setSpacing(5)).setSize(305, 20)
|
||||
.addElements(null, copyToClipboardButton, closeButton));
|
||||
|
||||
// And open it
|
||||
open();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(GuiRenderer renderer, ReadableDimension size, RenderInfo renderInfo) {
|
||||
// Re-size the scrollable containing the crash report to 3/4 of the screen
|
||||
scrollable.setSize(size.getWidth() * 3 / 4, size.getHeight() * 3 / 4);
|
||||
super.draw(renderer, size, renderInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
src/main/java/com/replaymod/editor/ReplayModEditor.java
Normal file
41
src/main/java/com/replaymod/editor/ReplayModEditor.java
Normal file
@@ -0,0 +1,41 @@
|
||||
package com.replaymod.editor;
|
||||
|
||||
import com.replaymod.core.ReplayMod;
|
||||
import com.replaymod.editor.handler.GuiHandler;
|
||||
import com.replaymod.online.Setting;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import net.minecraftforge.fml.common.event.FMLInitializationEvent;
|
||||
import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
@Mod(modid = ReplayModEditor.MOD_ID,
|
||||
version = "@MOD_VERSION@",
|
||||
acceptedMinecraftVersions = "@MC_VERSION@",
|
||||
useMetadata = true)
|
||||
public class ReplayModEditor {
|
||||
public static final String MOD_ID = "replaymod-editor";
|
||||
|
||||
@Mod.Instance(MOD_ID)
|
||||
public static ReplayModEditor instance;
|
||||
|
||||
private ReplayMod core;
|
||||
|
||||
public static Logger LOGGER;
|
||||
|
||||
@Mod.EventHandler
|
||||
public void preInit(FMLPreInitializationEvent event) {
|
||||
ReplayModEditor.LOGGER = event.getModLog();
|
||||
core = ReplayMod.instance;
|
||||
|
||||
core.getSettingsRegistry().register(Setting.class);
|
||||
}
|
||||
|
||||
@Mod.EventHandler
|
||||
public void init(FMLInitializationEvent event) {
|
||||
new GuiHandler(this).register();
|
||||
}
|
||||
|
||||
public ReplayMod getCore() {
|
||||
return core;
|
||||
}
|
||||
}
|
||||
360
src/main/java/com/replaymod/editor/gui/GuiReplayEditor.java
Normal file
360
src/main/java/com/replaymod/editor/gui/GuiReplayEditor.java
Normal file
@@ -0,0 +1,360 @@
|
||||
package com.replaymod.editor.gui;
|
||||
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.replaymod.core.ReplayMod;
|
||||
import com.replaymod.core.utils.Utils;
|
||||
import com.replaymod.replaystudio.PacketData;
|
||||
import com.replaymod.replaystudio.Studio;
|
||||
import com.replaymod.replaystudio.filter.StreamFilter;
|
||||
import com.replaymod.replaystudio.io.ReplayInputStream;
|
||||
import com.replaymod.replaystudio.io.ReplayOutputStream;
|
||||
import com.replaymod.replaystudio.replay.ReplayFile;
|
||||
import com.replaymod.replaystudio.replay.ReplayMetaData;
|
||||
import com.replaymod.replaystudio.replay.ZipReplayFile;
|
||||
import com.replaymod.replaystudio.stream.PacketStream;
|
||||
import com.replaymod.replaystudio.studio.ReplayStudio;
|
||||
import de.johni0702.minecraft.gui.GuiRenderer;
|
||||
import de.johni0702.minecraft.gui.RenderInfo;
|
||||
import de.johni0702.minecraft.gui.container.GuiContainer;
|
||||
import de.johni0702.minecraft.gui.container.GuiPanel;
|
||||
import de.johni0702.minecraft.gui.container.GuiScreen;
|
||||
import de.johni0702.minecraft.gui.element.GuiButton;
|
||||
import de.johni0702.minecraft.gui.element.GuiLabel;
|
||||
import de.johni0702.minecraft.gui.element.GuiTextField;
|
||||
import de.johni0702.minecraft.gui.element.GuiTooltip;
|
||||
import de.johni0702.minecraft.gui.element.advanced.GuiProgressBar;
|
||||
import de.johni0702.minecraft.gui.layout.CustomLayout;
|
||||
import de.johni0702.minecraft.gui.layout.GridLayout;
|
||||
import de.johni0702.minecraft.gui.layout.HorizontalLayout;
|
||||
import de.johni0702.minecraft.gui.layout.VerticalLayout;
|
||||
import de.johni0702.minecraft.gui.popup.AbstractGuiPopup;
|
||||
import de.johni0702.minecraft.gui.popup.GuiInfoPopup;
|
||||
import de.johni0702.minecraft.gui.popup.GuiYesNoPopup;
|
||||
import de.johni0702.minecraft.gui.utils.Colors;
|
||||
import net.minecraft.crash.CrashReport;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.lwjgl.util.ReadableDimension;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static com.replaymod.editor.ReplayModEditor.LOGGER;
|
||||
|
||||
public class GuiReplayEditor extends GuiScreen {
|
||||
private final ReplayMod mod;
|
||||
|
||||
private volatile ProcessingStage processingStage;
|
||||
private volatile double progress;
|
||||
private GuiProcessingProgressPopup popup;
|
||||
|
||||
public GuiButton currentTabButton;
|
||||
public GuiPanel currentTabPanel;
|
||||
|
||||
public final GuiPanel tabButtons = new GuiPanel(this).setLayout(new GridLayout().setSpacingX(5));
|
||||
public final List<GuiPanel> tabPanels = new ArrayList<>();
|
||||
|
||||
public final GuiButton saveButton = new GuiButton().setI18nLabel("replaymod.gui.save").setSize(100, 20);
|
||||
public final GuiButton backButton = new GuiButton().setI18nLabel("replaymod.gui.back").setSize(100, 20);
|
||||
|
||||
public final GuiPanel buttonPanel = new GuiPanel(this).setLayout(new HorizontalLayout().setSpacing(5))
|
||||
.addElements(null, saveButton, backButton);
|
||||
|
||||
public GuiReplayEditor(GuiScreen parent, ReplayMod mod) {
|
||||
this.mod = mod;
|
||||
backButton.onClick(parent::display);
|
||||
|
||||
setTitle(new GuiLabel().setI18nText("replaymod.gui.replayeditor"));
|
||||
|
||||
makeTab("trim", () -> new GuiTrimPanel(this));
|
||||
makeTab("connect", GuiPanel::new).setDisabled(); // Not yet implemented
|
||||
makeTab("modify", GuiPanel::new).setDisabled(); // Not yet implemented
|
||||
|
||||
setLayout(new CustomLayout<GuiScreen>() {
|
||||
@Override
|
||||
protected void layout(GuiScreen container, int width, int height) {
|
||||
// Move all inactive panels aside
|
||||
tabPanels.forEach(e -> pos(e, Integer.MIN_VALUE, Integer.MIN_VALUE));
|
||||
|
||||
pos(buttonPanel, width - 10 - width(buttonPanel), height - 10 - height(buttonPanel));
|
||||
|
||||
if (currentTabPanel != null) {
|
||||
pos(currentTabPanel, 10, 50);
|
||||
size(currentTabPanel, width - 20, y(buttonPanel) - 10 - y(currentTabPanel));
|
||||
}
|
||||
|
||||
pos(tabButtons, 10, 20);
|
||||
size(tabButtons, width - 20, 20);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public GuiButton makeTab(String name, Supplier<GuiPanel> panel) {
|
||||
GuiButton button = new GuiButton().setI18nLabel("replaymod.gui.editor." + name + ".title")
|
||||
.setTooltip(new GuiTooltip().setI18nText("replaymod.gui.editor." + name + ".description"))
|
||||
.setSize(Integer.MAX_VALUE, Integer.MAX_VALUE);
|
||||
button.onClick(() -> {
|
||||
// Remove previous tab
|
||||
if (currentTabButton != null) {
|
||||
currentTabButton.setEnabled();
|
||||
removeElement(currentTabPanel);
|
||||
}
|
||||
|
||||
// Activate new tab
|
||||
currentTabButton = button.setDisabled();
|
||||
addElements(null, currentTabPanel = panel.get());
|
||||
});
|
||||
// Add new tab button to tabs panel
|
||||
tabButtons.addElements(null, button);
|
||||
((GridLayout) tabButtons.getLayout()).setColumns(tabButtons.getChildren().size());
|
||||
return button;
|
||||
}
|
||||
|
||||
public void save(File inputFile, PacketStream.FilterInfo...filters) {
|
||||
save(FilenameUtils.getBaseName(inputFile.getName()), (outputFile) -> {
|
||||
Studio studio = new ReplayStudio();
|
||||
try (ReplayFile outputReplay = new ZipReplayFile(studio, inputFile, outputFile);
|
||||
ReplayOutputStream out = outputReplay.writePacketData()) {
|
||||
// The input replay file MUST be closed before saving the output file
|
||||
try (ReplayFile inputReplay = new ZipReplayFile(studio, inputFile);
|
||||
ReplayInputStream in = inputReplay.getPacketData()) {
|
||||
ReplayMetaData metaData = inputReplay.getMetaData();
|
||||
PacketStream stream = studio.createReplayStream(in, true);
|
||||
|
||||
stream.addFilter(new ProgressFilter(metaData.getDuration()));
|
||||
for (PacketStream.FilterInfo info : filters) {
|
||||
stream.addFilter(info.getFilter(), info.getFrom(), info.getTo());
|
||||
LOGGER.debug("Added filter {}", info);
|
||||
}
|
||||
|
||||
LOGGER.info("Built pipeline: {}", stream);
|
||||
stream.start();
|
||||
|
||||
long lastTimestamp = 0;
|
||||
|
||||
PacketData data;
|
||||
while ((data = stream.next()) != null) {
|
||||
out.write(data);
|
||||
lastTimestamp = data.getTime();
|
||||
}
|
||||
|
||||
for (PacketData d : stream.end()) {
|
||||
out.write(d);
|
||||
lastTimestamp = d.getTime();
|
||||
}
|
||||
|
||||
updateProgress(ProcessingStage.FINALIZING, 0);
|
||||
|
||||
// Update duration of new replay
|
||||
metaData.setDuration((int) lastTimestamp);
|
||||
outputReplay.writeMetaData(metaData);
|
||||
}
|
||||
out.close();
|
||||
outputReplay.save();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void save(String sourceName, Consumer<File> runnable) {
|
||||
GuiTextField nameField = new GuiTextField().setSize(200, 20).setFocused(true).setText(sourceName);
|
||||
GuiYesNoPopup popup = GuiYesNoPopup.open(this,
|
||||
nameField
|
||||
).setYesI18nLabel("replaymod.gui.editor.savemode.override").setNoI18nLabel("replaymod.gui.cancel");
|
||||
nameField.onEnter(() -> {
|
||||
if (popup.getYesButton().isEnabled()) {
|
||||
popup.getYesButton().onClick();
|
||||
}
|
||||
}).onTextChanged(obj -> {
|
||||
popup.getYesButton().setEnabled(!nameField.getText().isEmpty());
|
||||
popup.setYesI18nLabel("replaymod.gui.editor.savemode." +
|
||||
(nameField.getText().equals(sourceName) ? "override" : "newfile"));
|
||||
});
|
||||
Futures.addCallback(popup.getFuture(), new FutureCallback<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(@Nullable Boolean result) {
|
||||
if (result == Boolean.TRUE) {
|
||||
// Sanitize their input
|
||||
String name = nameField.getText().trim().replace("[^a-zA-Z0-9\\.\\- ]", "_");
|
||||
// This file is what they want
|
||||
File replayFolder;
|
||||
try {
|
||||
replayFolder = mod.getReplayFolder();
|
||||
} catch (IOException e) {
|
||||
Utils.error(LOGGER, GuiReplayEditor.this, CrashReport.makeCrashReport(e, "Getting replay folder"), null);
|
||||
return;
|
||||
}
|
||||
File targetFile = new File(replayFolder, name + ".mcpr");
|
||||
if (targetFile.exists()) {
|
||||
LOGGER.trace("Selected file already exists, asking for confirmation");
|
||||
Futures.addCallback(GuiYesNoPopup.open(GuiReplayEditor.this,
|
||||
new GuiLabel().setColor(Colors.BLACK)
|
||||
.setI18nText("replaymod.gui.replaymodified.warning1", name),
|
||||
new GuiLabel().setColor(Colors.BLACK)
|
||||
.setI18nText("replaymod.gui.replaymodified.warning2"))
|
||||
.setYesI18nLabel("gui.yes").setNoI18nLabel("gui.no")
|
||||
.getFuture(), new FutureCallback<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(@Nullable Boolean result) {
|
||||
if (result == Boolean.TRUE) {
|
||||
LOGGER.trace("Saving output in {}", targetFile);
|
||||
runEdit(() -> runnable.accept(targetFile));
|
||||
} else {
|
||||
LOGGER.trace("Not overwriting file {}, reopening naming dialog", targetFile);
|
||||
save(sourceName, runnable);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable t) {
|
||||
LOGGER.error("During save file confirmation dialog:", t);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
LOGGER.trace("Saving output in {}", targetFile);
|
||||
runEdit(() -> runnable.accept(targetFile));
|
||||
}
|
||||
} else {
|
||||
LOGGER.trace("Saving cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable t) {
|
||||
LOGGER.error("During save file dialog:", t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void runEdit(Runnable runnable) {
|
||||
popup = new GuiProcessingProgressPopup(this);
|
||||
processingStage = ProcessingStage.INITIALIZING;
|
||||
progress = 0;
|
||||
new Thread(() -> {
|
||||
LOGGER.info("Starting editing of replay");
|
||||
try {
|
||||
runnable.run();
|
||||
} catch (Throwable t) {
|
||||
CrashReport crashReport = CrashReport.makeCrashReport(t, "Editing replay file");
|
||||
mod.runLater(() -> Utils.error(LOGGER, this, crashReport, popup::close));
|
||||
return;
|
||||
}
|
||||
LOGGER.info("Successfully finished editing");
|
||||
mod.runLater(() -> {
|
||||
popup.close();
|
||||
GuiInfoPopup.open(this, "replaymod.gui.editor.progress.status.finished");
|
||||
});
|
||||
}).start();
|
||||
}
|
||||
|
||||
public void updateProgress(ProcessingStage stage, double progress) {
|
||||
if (this.processingStage != stage) {
|
||||
LOGGER.trace("Changing state from {} to {}", this.processingStage, stage);
|
||||
}
|
||||
this.processingStage = stage;
|
||||
this.progress = progress;
|
||||
}
|
||||
|
||||
public ReplayMod getMod() {
|
||||
return mod;
|
||||
}
|
||||
|
||||
public enum ProcessingStage {
|
||||
INITIALIZING("initializing", 0.01),
|
||||
WORKING("writing.raw", 0.89),
|
||||
FINALIZING("writing.final", 0.1),
|
||||
DONE("finished", 0);
|
||||
|
||||
private final String i18nKey;
|
||||
private final double progressFraction;
|
||||
|
||||
ProcessingStage(String i18nKey, double progressFraction) {
|
||||
this.i18nKey = "replaymod.gui.editor.progress.status." + i18nKey;
|
||||
this.progressFraction = progressFraction;
|
||||
}
|
||||
|
||||
public double getOverallProgress(double progress) {
|
||||
progress *= progressFraction;
|
||||
for (ProcessingStage processingStage : values()) {
|
||||
if (processingStage == this) break;
|
||||
progress += processingStage.progressFraction;
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
|
||||
private class ProgressFilter implements StreamFilter {
|
||||
private final long total;
|
||||
|
||||
public ProgressFilter(long total) {
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "progress";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Studio studio, JsonObject config) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart(PacketStream stream) {
|
||||
updateProgress(ProcessingStage.WORKING, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPacket(PacketStream stream, PacketData data) {
|
||||
updateProgress(ProcessingStage.WORKING, (double) data.getTime() / total);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnd(PacketStream stream, long timestamp) {}
|
||||
}
|
||||
|
||||
private class GuiProcessingProgressPopup extends AbstractGuiPopup<GuiProcessingProgressPopup> {
|
||||
private final GuiProgressBar progressBar = new GuiProgressBar().setSize(300, 20);
|
||||
|
||||
{
|
||||
popup.setLayout(new VerticalLayout().setSpacing(10));
|
||||
popup.addElements(new VerticalLayout.Data(0.5),
|
||||
new GuiLabel().setI18nText("replaymod.gui.editor.progress.title").setColor(Colors.BLACK),
|
||||
new GuiLabel().setI18nText("replaymod.gui.editor.progress.pleasewait").setColor(Colors.BLACK),
|
||||
progressBar);
|
||||
}
|
||||
|
||||
public GuiProcessingProgressPopup(GuiContainer container) {
|
||||
super(container);
|
||||
open();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(GuiRenderer renderer, ReadableDimension size, RenderInfo renderInfo) {
|
||||
// Update progress bar
|
||||
progressBar.setI18nLabel(processingStage.i18nKey);
|
||||
progressBar.setProgress((float) processingStage.getOverallProgress(progress));
|
||||
|
||||
super.draw(renderer, size, renderInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
super.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected GuiProcessingProgressPopup getThis() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
198
src/main/java/com/replaymod/editor/gui/GuiTrimPanel.java
Normal file
198
src/main/java/com/replaymod/editor/gui/GuiTrimPanel.java
Normal file
@@ -0,0 +1,198 @@
|
||||
package com.replaymod.editor.gui;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.replaymod.core.utils.Utils;
|
||||
import com.replaymod.replaystudio.data.Marker;
|
||||
import com.replaymod.replaystudio.filter.ChangeTimestampFilter;
|
||||
import com.replaymod.replaystudio.filter.RemoveFilter;
|
||||
import com.replaymod.replaystudio.filter.SquashFilter;
|
||||
import com.replaymod.replaystudio.replay.ReplayFile;
|
||||
import com.replaymod.replaystudio.replay.ReplayMetaData;
|
||||
import com.replaymod.replaystudio.replay.ZipReplayFile;
|
||||
import com.replaymod.replaystudio.stream.PacketStream;
|
||||
import com.replaymod.replaystudio.studio.ReplayStudio;
|
||||
import de.johni0702.minecraft.gui.container.GuiPanel;
|
||||
import de.johni0702.minecraft.gui.element.GuiLabel;
|
||||
import de.johni0702.minecraft.gui.element.GuiNumberField;
|
||||
import de.johni0702.minecraft.gui.element.advanced.GuiDropdownMenu;
|
||||
import de.johni0702.minecraft.gui.layout.GridLayout;
|
||||
import de.johni0702.minecraft.gui.layout.HorizontalLayout;
|
||||
import de.johni0702.minecraft.gui.layout.VerticalLayout;
|
||||
import net.minecraft.client.resources.I18n;
|
||||
import net.minecraft.crash.CrashReport;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.commons.io.IOCase;
|
||||
import org.apache.commons.io.filefilter.SuffixFileFilter;
|
||||
import org.lwjgl.util.Dimension;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileFilter;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static com.replaymod.editor.ReplayModEditor.LOGGER;
|
||||
import static java.util.Optional.ofNullable;
|
||||
|
||||
public class GuiTrimPanel extends GuiPanel {
|
||||
// Special value indicating no replay files were found
|
||||
private static final File NO_REPLAY = new File(".");
|
||||
// Special value for the initial "Select Marker Keyframe" entry
|
||||
private static final Marker NO_MARKER = new Marker(); static {NO_MARKER.setTime(-1);}
|
||||
|
||||
private final GuiReplayEditor gui;
|
||||
|
||||
public final GuiDropdownMenu<File> inputReplays = new GuiDropdownMenu<File>(this)
|
||||
.setMinSize(new Dimension(200, 20)).onSelection(i -> updateSelectedReplay())
|
||||
.setToString(f -> f == NO_REPLAY ? "" : FilenameUtils.getBaseName(f.getName()));
|
||||
|
||||
public final GuiNumberField startHour = new GuiNumberField().setSize(20, 20).setMaxLength(2);
|
||||
public final GuiNumberField startMin = new GuiNumberField().setSize(20, 20).setMaxLength(2);
|
||||
public final GuiNumberField startSec = new GuiNumberField().setSize(20, 20).setMaxLength(2);
|
||||
public final GuiNumberField startMilli = new GuiNumberField().setSize(40, 20).setMaxLength(4);
|
||||
public final GuiDropdownMenu<Marker> startMarker = new GuiDropdownMenu<>();
|
||||
|
||||
public final GuiNumberField endHour = new GuiNumberField().setSize(20, 20).setMaxLength(2);
|
||||
public final GuiNumberField endMin = new GuiNumberField().setSize(20, 20).setMaxLength(2);
|
||||
public final GuiNumberField endSec = new GuiNumberField().setSize(20, 20).setMaxLength(2);
|
||||
public final GuiNumberField endMilli = new GuiNumberField().setSize(40, 20).setMaxLength(4);
|
||||
public final GuiDropdownMenu<Marker> endMarker = new GuiDropdownMenu<>();
|
||||
|
||||
public final GuiPanel timePanel = new GuiPanel(this)
|
||||
.setLayout(new GridLayout().setCellsEqualSize(false).setSpacingX(20).setSpacingY(5).setColumns(3))
|
||||
.addElements(new GridLayout.Data(0, 0.5),
|
||||
new GuiLabel().setI18nText("replaymod.gui.start"),
|
||||
new GuiPanel().setLayout(new HorizontalLayout().setSpacing(2)).addElements(
|
||||
new HorizontalLayout.Data(0.5),
|
||||
startHour, new GuiLabel().setI18nText("replaymod.gui.hours"),
|
||||
startMin, new GuiLabel().setI18nText("replaymod.gui.minutes"),
|
||||
startSec, new GuiLabel().setI18nText("replaymod.gui.seconds"),
|
||||
startMilli, new GuiLabel().setI18nText("replaymod.gui.milliseconds")
|
||||
), startMarker,
|
||||
new GuiLabel().setI18nText("replaymod.gui.end"),
|
||||
new GuiPanel().setLayout(new HorizontalLayout().setSpacing(2)).addElements(
|
||||
new HorizontalLayout.Data(0.5),
|
||||
endHour, new GuiLabel().setI18nText("replaymod.gui.hours"),
|
||||
endMin, new GuiLabel().setI18nText("replaymod.gui.minutes"),
|
||||
endSec, new GuiLabel().setI18nText("replaymod.gui.seconds"),
|
||||
endMilli, new GuiLabel().setI18nText("replaymod.gui.milliseconds")
|
||||
), endMarker);
|
||||
|
||||
public GuiTrimPanel(GuiReplayEditor gui) {
|
||||
this.gui = gui;
|
||||
|
||||
setLayout(new VerticalLayout().setSpacing(10));
|
||||
|
||||
Function<Marker, String> toString = m -> {
|
||||
if (m == NO_MARKER) {
|
||||
return I18n.format("replaymod.gui.editor.trim.marker");
|
||||
} else {
|
||||
return ofNullable(m.getName()).orElse(I18n.format("replaymod.gui.ingame.unnamedmarker"))
|
||||
+ " (" + Utils.convertSecondsToShortString(m.getTime() / 1000) + ")";
|
||||
}
|
||||
};
|
||||
startMarker.setToString(toString).setMinSize(new Dimension(100, 20)).onSelection(i ->
|
||||
onSelectedMarkerChanged(startMarker, startHour, startMin, startSec, startMilli));
|
||||
endMarker.setToString(toString).setMinSize(new Dimension(100, 20)).onSelection(i ->
|
||||
onSelectedMarkerChanged(endMarker, endHour, endMin, endSec, endMilli));
|
||||
|
||||
File[] files = null;
|
||||
try {
|
||||
File folder = gui.getMod().getReplayFolder();
|
||||
files = folder.listFiles((FileFilter) new SuffixFileFilter(".mcpr", IOCase.INSENSITIVE));
|
||||
} catch (IOException e) {
|
||||
Utils.error(LOGGER, gui, CrashReport.makeCrashReport(e, "Listing available replays"), gui.backButton::onClick);
|
||||
}
|
||||
if (files == null) {
|
||||
LOGGER.warn("Replay file list is null, has the replay folder been deleted?");
|
||||
files = new File[0];
|
||||
} else {
|
||||
LOGGER.debug("Found {} replays in the replay folder", files.length);
|
||||
}
|
||||
if (files.length == 0) {
|
||||
inputReplays.setValues(NO_REPLAY);
|
||||
} else {
|
||||
inputReplays.setValues(files);
|
||||
}
|
||||
|
||||
updateSelectedReplay();
|
||||
updateReadyState();
|
||||
|
||||
gui.saveButton.onClick(() -> {
|
||||
// Read start and end times
|
||||
int start = ((startHour.getInteger() * 60 + startMin.getInteger()) * 60 + startSec.getInteger()) * 1000 + startMilli.getInteger();
|
||||
int end = ((endHour.getInteger() * 60 + endMin.getInteger()) * 60 + endSec.getInteger()) * 1000 + endMilli.getInteger();
|
||||
// Configure offset of change timestamp filter
|
||||
ChangeTimestampFilter ctf = new ChangeTimestampFilter();
|
||||
JsonObject config = new JsonObject();
|
||||
config.addProperty("offset", -start);
|
||||
ctf.init(null, config);
|
||||
// Pass filters to save dialog
|
||||
gui.save(inputReplays.getSelectedValue(),
|
||||
new PacketStream.FilterInfo(new SquashFilter(), -1, start),
|
||||
new PacketStream.FilterInfo(ctf, start, end),
|
||||
new PacketStream.FilterInfo(new RemoveFilter(), end, -1)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private void onSelectedMarkerChanged(GuiDropdownMenu<Marker> dropdown, GuiNumberField hour, GuiNumberField min,
|
||||
GuiNumberField sec, GuiNumberField milli) {
|
||||
Marker marker = dropdown.getSelectedValue();
|
||||
if (marker != NO_MARKER) {
|
||||
int time = marker.getTime();
|
||||
setTime(time, hour, min, sec, milli);
|
||||
}
|
||||
}
|
||||
|
||||
private void setTime(int time, GuiNumberField hour, GuiNumberField min,
|
||||
GuiNumberField sec, GuiNumberField milli) {
|
||||
milli.setValue(time % 1000); time /= 1000;
|
||||
sec.setValue(time % 60); time /= 60;
|
||||
min.setValue(time % 60); time /= 60;
|
||||
hour.setValue(time);
|
||||
}
|
||||
|
||||
private void updateSelectedReplay() {
|
||||
File file = inputReplays.getSelectedValue();
|
||||
// Load markers and meta data from replay file
|
||||
ReplayMetaData metaData;
|
||||
Set<Marker> markers;
|
||||
if (file == NO_REPLAY) {
|
||||
metaData = new ReplayMetaData();
|
||||
markers = Collections.emptySet();
|
||||
} else {
|
||||
try (ReplayFile replayFile = new ZipReplayFile(new ReplayStudio(), file)) {
|
||||
metaData = replayFile.getMetaData();
|
||||
markers = replayFile.getMarkers().or(Collections.emptySet());
|
||||
LOGGER.debug("Loaded {} markers from replay {}", markers.size(), file);
|
||||
} catch (Exception e) {
|
||||
Utils.error(LOGGER, gui, CrashReport.makeCrashReport(e, "Reading markers from replay"), null);
|
||||
metaData = new ReplayMetaData();
|
||||
markers = Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
// Sort and add initial "Select Event Marker" element
|
||||
List<Marker> markerList = new ArrayList<>(markers);
|
||||
markerList.sort(Comparator.comparing(Marker::getTime));
|
||||
markerList.add(0, NO_MARKER);
|
||||
|
||||
// Set marker dropdown values
|
||||
Marker[] markerArray = markerList.stream().toArray(Marker[]::new);
|
||||
startMarker.setValues(markerArray).setSelected(0);
|
||||
endMarker.setValues(markerArray).setSelected(0);
|
||||
|
||||
// Set start and end time values
|
||||
setTime(0, startHour, startMin, startSec, startMilli);
|
||||
setTime(metaData.getDuration(), endHour, endMin, endSec, endMilli);
|
||||
}
|
||||
|
||||
private void updateReadyState() {
|
||||
gui.saveButton.setEnabled(inputReplays.getSelectedValue() != NO_REPLAY);
|
||||
}
|
||||
}
|
||||
48
src/main/java/com/replaymod/editor/handler/GuiHandler.java
Normal file
48
src/main/java/com/replaymod/editor/handler/GuiHandler.java
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.replaymod.editor.handler;
|
||||
|
||||
import com.replaymod.editor.ReplayModEditor;
|
||||
import com.replaymod.editor.gui.GuiReplayEditor;
|
||||
import de.johni0702.minecraft.gui.container.GuiScreen;
|
||||
import net.minecraft.client.gui.GuiButton;
|
||||
import net.minecraft.client.gui.GuiMainMenu;
|
||||
import net.minecraft.client.resources.I18n;
|
||||
import net.minecraftforge.client.event.GuiScreenEvent;
|
||||
import net.minecraftforge.common.MinecraftForge;
|
||||
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
|
||||
|
||||
public class GuiHandler {
|
||||
private static final int BUTTON_REPLAY_EDITOR = 17890237;
|
||||
|
||||
private final ReplayModEditor mod;
|
||||
|
||||
public GuiHandler(ReplayModEditor mod) {
|
||||
this.mod = mod;
|
||||
}
|
||||
|
||||
public void register() {
|
||||
MinecraftForge.EVENT_BUS.register(this);
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void injectIntoMainMenu(GuiScreenEvent.InitGuiEvent event) {
|
||||
if (!(event.getGui() instanceof GuiMainMenu)) {
|
||||
return;
|
||||
}
|
||||
|
||||
GuiButton button = new GuiButton(BUTTON_REPLAY_EDITOR, event.getGui().width / 2 + 2,
|
||||
event.getGui().height / 4 + 10 + 3 * 24, I18n.format("replaymod.gui.replayeditor"));
|
||||
button.width = button.width / 2 - 2;
|
||||
event.getButtonList().add(button);
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onButton(GuiScreenEvent.ActionPerformedEvent.Pre event) {
|
||||
if(!event.getButton().enabled) return;
|
||||
|
||||
if (event.getGui() instanceof GuiMainMenu) {
|
||||
if (event.getButton().id == BUTTON_REPLAY_EDITOR) {
|
||||
new GuiReplayEditor(GuiScreen.wrap(event.getGui()), mod.getCore()).display();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,10 @@ import org.apache.logging.log4j.Logger;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Mod(modid = ReplayModExtras.MOD_ID, useMetadata = true)
|
||||
@Mod(modid = ReplayModExtras.MOD_ID,
|
||||
version = "@MOD_VERSION@",
|
||||
acceptedMinecraftVersions = "@MC_VERSION@",
|
||||
useMetadata = true)
|
||||
public class ReplayModExtras {
|
||||
public static final String MOD_ID = "replaymod-extras";
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ import java.io.IOException;
|
||||
|
||||
import static net.minecraft.client.Minecraft.getMinecraft;
|
||||
|
||||
@Mod(modid = ReplayModOnline.MOD_ID, useMetadata = true)
|
||||
@Mod(modid = ReplayModOnline.MOD_ID,
|
||||
version = "@MOD_VERSION@",
|
||||
acceptedMinecraftVersions = "@MC_VERSION@",
|
||||
useMetadata = true)
|
||||
public class ReplayModOnline {
|
||||
public static final String MOD_ID = "replaymod-online";
|
||||
|
||||
@@ -64,8 +67,10 @@ public class ReplayModOnline {
|
||||
|
||||
@Mod.EventHandler
|
||||
public void init(FMLInitializationEvent event) {
|
||||
if (!getDownloadsFolder().mkdirs()) {
|
||||
logger.warn("Failed to create downloads folder: " + getDownloadsFolder());
|
||||
if (!getDownloadsFolder().exists()){
|
||||
if (!getDownloadsFolder().mkdirs()) {
|
||||
logger.warn("Failed to create downloads folder: " + getDownloadsFolder());
|
||||
}
|
||||
}
|
||||
|
||||
new GuiHandler(this).register();
|
||||
|
||||
@@ -14,7 +14,10 @@ import net.minecraftforge.fml.common.network.NetworkRegistry;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.lwjgl.input.Keyboard;
|
||||
|
||||
@Mod(modid = ReplayModRecording.MOD_ID, useMetadata = true)
|
||||
@Mod(modid = ReplayModRecording.MOD_ID,
|
||||
version = "@MOD_VERSION@",
|
||||
acceptedMinecraftVersions = "@MC_VERSION@",
|
||||
useMetadata = true)
|
||||
public class ReplayModRecording {
|
||||
public static final String MOD_ID = "replaymod-recording";
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.replaymod.recording.handler;
|
||||
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.SimpleChannelInboundHandler;
|
||||
import net.minecraftforge.fml.common.network.handshake.FMLHandshakeMessage;
|
||||
|
||||
/**
|
||||
* Filters out all handshake packets that were sent for recording but must
|
||||
* not actually be handled.
|
||||
* This handler is only present when connected to the integrated server as
|
||||
* otherwise all packets must be handled.
|
||||
*
|
||||
* When in single player, the game state packets must never be handled
|
||||
* otherwise wired bugs related to semi-singletons can occur.
|
||||
* See https://bugs.replaymod.com/show_bug.cgi?id=44
|
||||
*/
|
||||
public class FMLHandshakeFilter extends SimpleChannelInboundHandler<FMLHandshakeMessage> {
|
||||
@Override
|
||||
protected void channelRead0(ChannelHandlerContext ctx, FMLHandshakeMessage msg) throws Exception {
|
||||
if (!(msg instanceof FMLHandshakeMessage.RegistryData)) {
|
||||
// Pass on everything but RegistryData messages
|
||||
ctx.fireChannelRead(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.replaymod.recording.mixin;
|
||||
|
||||
import com.replaymod.recording.handler.FMLHandshakeFilter;
|
||||
import io.netty.channel.ChannelPipeline;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import net.minecraftforge.fml.common.network.handshake.FMLHandshakeCodec;
|
||||
import net.minecraftforge.fml.common.network.handshake.NetworkDispatcher;
|
||||
import net.minecraftforge.fml.relauncher.Side;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
@@ -11,17 +15,32 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
@Mixin(value = NetworkDispatcher.class, remap = false)
|
||||
public abstract class MixinNetworkDispatcher {
|
||||
|
||||
@Shadow
|
||||
private Side side;
|
||||
|
||||
@Shadow
|
||||
private EmbeddedChannel handshakeChannel;
|
||||
|
||||
/**
|
||||
* Always sets fml:isLocal to false.
|
||||
* Always sets fml:isLocal to false on the server side.
|
||||
* This effectively removes the difference in the FML handshake between SP and MP
|
||||
* and forces the block/item ids, etc. to always be send.
|
||||
* This might have undesired side effects but at least it works at all.
|
||||
* Injects a {@link FMLHandshakeFilter} on the client side to filter out
|
||||
* those extra, unexpected packets.
|
||||
*/
|
||||
@Inject(method = "insertIntoChannel", at=@At("HEAD"))
|
||||
public void replayModRecording_forceIsLocalToFalse(CallbackInfo cb) {
|
||||
handshakeChannel.attr(NetworkDispatcher.IS_LOCAL).set(false);
|
||||
public void replayModRecording_setupForLocalRecording(CallbackInfo cb) {
|
||||
// If we're in multiplayer, everything is fine as is
|
||||
if (!handshakeChannel.attr(NetworkDispatcher.IS_LOCAL).get()) return;
|
||||
|
||||
if (side == Side.SERVER) {
|
||||
// On the server side, force all packets to be sent
|
||||
handshakeChannel.attr(NetworkDispatcher.IS_LOCAL).set(false);
|
||||
} else {
|
||||
// On the client side, discard additional packets
|
||||
ChannelPipeline pipeline = handshakeChannel.pipeline();
|
||||
pipeline.addAfter(pipeline.context(FMLHandshakeCodec.class).name(),
|
||||
"replaymod_filter", new FMLHandshakeFilter());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.replaymod.render;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import net.minecraft.client.resources.I18n;
|
||||
import org.lwjgl.util.ReadableColor;
|
||||
|
||||
@@ -45,7 +47,7 @@ public class RenderSettings {
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return "-y -f rawvideo -pix_fmt rgb24 -s %WIDTH%x%HEIGHT% -r %FPS% -i - " + preset;
|
||||
return "-y -f rawvideo -pix_fmt rgb24 -s %WIDTH%x%HEIGHT% -r %FPS% -i - %FILTERS%" + preset;
|
||||
}
|
||||
|
||||
public String getFileExtension() {
|
||||
@@ -64,6 +66,19 @@ public class RenderSettings {
|
||||
}
|
||||
}
|
||||
|
||||
@AllArgsConstructor
|
||||
public enum AntiAliasing {
|
||||
NONE(1), X2(2), X4(4), X8(8);
|
||||
|
||||
@Getter
|
||||
private final int factor;
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return I18n.format("replaymod.gui.rendersettings.antialiasing." + name().toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
private final RenderMethod renderMethod;
|
||||
private final EncodingPreset encodingPreset;
|
||||
private final int videoWidth;
|
||||
@@ -78,9 +93,48 @@ public class RenderSettings {
|
||||
private final boolean stabilizeRoll;
|
||||
private final ReadableColor chromaKeyingColor;
|
||||
private final boolean inject360Metadata;
|
||||
private final AntiAliasing antiAliasing;
|
||||
|
||||
private final String exportCommand;
|
||||
private final String exportArguments;
|
||||
|
||||
private final boolean highPerformance;
|
||||
|
||||
/**
|
||||
* @return the width of the output video during rendering, including the upscale for Anti-Aliasing.
|
||||
*/
|
||||
public int getVideoWidth() {
|
||||
return videoWidth * antiAliasing.getFactor();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the height of the output video during rendering, including the upscale for Anti-Aliasing.
|
||||
*/
|
||||
public int getVideoHeight() {
|
||||
return videoHeight * antiAliasing.getFactor();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the actual width of the output video.
|
||||
*/
|
||||
public int getTargetVideoWidth() {
|
||||
return videoWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the actual height of the output video.
|
||||
*/
|
||||
public int getTargetVideoHeight() {
|
||||
return videoHeight;
|
||||
}
|
||||
|
||||
public String getVideoFilters() {
|
||||
if (antiAliasing == AntiAliasing.NONE) {
|
||||
return "";
|
||||
} else {
|
||||
double factor = 1.0 / antiAliasing.getFactor();
|
||||
return String.format("-vf scale=iw*%1$s:ih*%1$s ", factor);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
package com.replaymod.render;
|
||||
|
||||
import com.replaymod.core.ReplayMod;
|
||||
import com.replaymod.render.utils.RenderJob;
|
||||
import com.replaymod.replay.events.ReplayCloseEvent;
|
||||
import net.minecraft.crash.CrashReport;
|
||||
import net.minecraft.util.ReportedException;
|
||||
import net.minecraftforge.common.MinecraftForge;
|
||||
import net.minecraftforge.common.config.Configuration;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
|
||||
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Mod(modid = ReplayModRender.MOD_ID, useMetadata = true)
|
||||
@Mod(modid = ReplayModRender.MOD_ID,
|
||||
version = "@MOD_VERSION@",
|
||||
acceptedMinecraftVersions = "@MC_VERSION@",
|
||||
useMetadata = true)
|
||||
public class ReplayModRender {
|
||||
public static final String MOD_ID = "replaymod-render";
|
||||
|
||||
@@ -21,9 +30,10 @@ public class ReplayModRender {
|
||||
|
||||
private ReplayMod core;
|
||||
|
||||
private Logger logger;
|
||||
public static Logger LOGGER;
|
||||
|
||||
private Configuration configuration;
|
||||
private final List<RenderJob> renderQueue = new ArrayList<>();
|
||||
|
||||
public ReplayMod getCore() {
|
||||
return core;
|
||||
@@ -31,13 +41,20 @@ public class ReplayModRender {
|
||||
|
||||
@Mod.EventHandler
|
||||
public void preInit(FMLPreInitializationEvent event) {
|
||||
logger = event.getModLog();
|
||||
LOGGER = event.getModLog();
|
||||
core = ReplayMod.instance;
|
||||
configuration = new Configuration(event.getSuggestedConfigurationFile());
|
||||
|
||||
MinecraftForge.EVENT_BUS.register(this);
|
||||
|
||||
core.getSettingsRegistry().register(Setting.class);
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onReplayClose(ReplayCloseEvent.Post event) {
|
||||
renderQueue.clear();
|
||||
}
|
||||
|
||||
public File getVideoFolder() {
|
||||
String path = core.getSettingsRegistry().get(Setting.RENDER_PATH);
|
||||
File folder = new File(path.startsWith("./") ? core.getMinecraft().mcDataDir : null, path);
|
||||
@@ -52,4 +69,8 @@ public class ReplayModRender {
|
||||
public Configuration getConfiguration() {
|
||||
return configuration;
|
||||
}
|
||||
|
||||
public List<RenderJob> getRenderQueue() {
|
||||
return renderQueue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,27 @@ import com.replaymod.render.utils.StreamPipe;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.crash.CrashReport;
|
||||
import net.minecraft.crash.CrashReportCategory;
|
||||
import net.minecraft.util.Util;
|
||||
import net.minecraftforge.fml.common.versioning.ComparableVersion;
|
||||
import org.apache.commons.exec.CommandLine;
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.commons.io.output.TeeOutputStream;
|
||||
import org.lwjgl.util.ReadableDimension;
|
||||
|
||||
import java.io.*;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.WritableByteChannel;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static com.replaymod.render.ReplayModRender.LOGGER;
|
||||
import static org.apache.commons.lang3.Validate.isTrue;
|
||||
|
||||
public class VideoWriter implements FrameConsumer<RGBFrame> {
|
||||
@@ -43,10 +53,11 @@ public class VideoWriter implements FrameConsumer<RGBFrame> {
|
||||
.replace("%HEIGHT%", String.valueOf(settings.getVideoHeight()))
|
||||
.replace("%FPS%", String.valueOf(settings.getFramesPerSecond()))
|
||||
.replace("%FILENAME%", fileName)
|
||||
.replace("%BITRATE%", String.valueOf(settings.getBitRate()));
|
||||
.replace("%BITRATE%", String.valueOf(settings.getBitRate()))
|
||||
.replace("%FILTERS%", settings.getVideoFilters());
|
||||
|
||||
String executable = settings.getExportCommand().isEmpty() ? "ffmpeg" : settings.getExportCommand();
|
||||
System.out.println("Starting " + settings.getExportCommand() + " with args: " + commandArgs);
|
||||
String executable = settings.getExportCommand().isEmpty() ? findFFmpeg() : settings.getExportCommand();
|
||||
System.out.println("Starting " + executable + " with args: " + commandArgs);
|
||||
String[] cmdline = new CommandLine(executable).addArguments(commandArgs).toStrings();
|
||||
process = new ProcessBuilder(cmdline).directory(outputFolder).start();
|
||||
File exportLogFile = new File(Minecraft.getMinecraft().mcDataDir, "export.log");
|
||||
@@ -57,6 +68,54 @@ public class VideoWriter implements FrameConsumer<RGBFrame> {
|
||||
channel = Channels.newChannel(outputStream);
|
||||
}
|
||||
|
||||
private String findFFmpeg() {
|
||||
switch (Util.getOSType()) {
|
||||
case WINDOWS:
|
||||
// Allow windows users to unpack the ffmpeg archive into a sub-folder of their .minecraft folder
|
||||
File inDotMinecraft = new File(Minecraft.getMinecraft().mcDataDir, "ffmpeg/bin/ffmpeg.exe");
|
||||
if (inDotMinecraft.exists()) {
|
||||
LOGGER.debug("FFmpeg found in .minecraft/ffmpeg");
|
||||
return inDotMinecraft.getAbsolutePath();
|
||||
}
|
||||
break;
|
||||
case OSX:
|
||||
// The PATH doesn't seem to be set as expected on OSX, therefore we check some common locations ourselves
|
||||
for (String path : new String[]{"/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"}) {
|
||||
File file = new File(path);
|
||||
if (file.exists()) {
|
||||
LOGGER.debug("Found FFmpeg at {}", path);
|
||||
return path;
|
||||
} else {
|
||||
LOGGER.debug("FFmpeg not located at {}", path);
|
||||
}
|
||||
}
|
||||
// Homebrew doesn't seem to reliably symlink its installed binaries either
|
||||
File homebrewFolder = new File("/usr/local/Cellar/ffmpeg");
|
||||
String[] homebrewVersions = homebrewFolder.list();
|
||||
if (homebrewVersions != null) {
|
||||
Optional<File> latestOpt = Arrays.stream(homebrewVersions)
|
||||
.map(ComparableVersion::new) // Convert file name to comparable version
|
||||
.sorted(Comparator.reverseOrder()) // Sort for latest version
|
||||
.map(ComparableVersion::toString) // Convert back to file name
|
||||
.map(v -> new File(new File(homebrewFolder, v), "bin/ffmpeg")) // Convert to binary files
|
||||
.filter(File::exists) // Filter invalid installations (missing executable)
|
||||
.findFirst(); // Take first one
|
||||
if (latestOpt.isPresent()) {
|
||||
File latest = latestOpt.get();
|
||||
LOGGER.debug("Found {} versions of FFmpeg installed with homebrew, chose {}",
|
||||
homebrewVersions.length, latest);
|
||||
return latest.getAbsolutePath();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case LINUX: // Linux users are entrusted to have their PATH configured correctly (most package manager do this)
|
||||
case SOLARIS: // Never heard of anyone running this mod on Solaris having any problems
|
||||
case UNKNOWN: // Unknown OS, just try to use "ffmpeg"
|
||||
}
|
||||
LOGGER.debug("Using default FFmpeg executable");
|
||||
return "ffmpeg";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
IOUtils.closeQuietly(outputStream);
|
||||
|
||||
277
src/main/java/com/replaymod/render/gui/GuiRenderQueue.java
Normal file
277
src/main/java/com/replaymod/render/gui/GuiRenderQueue.java
Normal file
@@ -0,0 +1,277 @@
|
||||
package com.replaymod.render.gui;
|
||||
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.replaymod.render.ReplayModRender;
|
||||
import com.replaymod.render.rendering.VideoRenderer;
|
||||
import com.replaymod.render.utils.RenderJob;
|
||||
import com.replaymod.replay.ReplayHandler;
|
||||
import com.replaymod.replaystudio.pathing.path.Timeline;
|
||||
import com.replaymod.replaystudio.util.I18n;
|
||||
import de.johni0702.minecraft.gui.GuiRenderer;
|
||||
import de.johni0702.minecraft.gui.RenderInfo;
|
||||
import de.johni0702.minecraft.gui.container.AbstractGuiClickableContainer;
|
||||
import de.johni0702.minecraft.gui.container.GuiContainer;
|
||||
import de.johni0702.minecraft.gui.container.GuiPanel;
|
||||
import de.johni0702.minecraft.gui.container.GuiVerticalList;
|
||||
import de.johni0702.minecraft.gui.element.GuiButton;
|
||||
import de.johni0702.minecraft.gui.element.GuiLabel;
|
||||
import de.johni0702.minecraft.gui.element.GuiTextField;
|
||||
import de.johni0702.minecraft.gui.layout.CustomLayout;
|
||||
import de.johni0702.minecraft.gui.layout.GridLayout;
|
||||
import de.johni0702.minecraft.gui.layout.HorizontalLayout;
|
||||
import de.johni0702.minecraft.gui.popup.AbstractGuiPopup;
|
||||
import de.johni0702.minecraft.gui.popup.GuiYesNoPopup;
|
||||
import de.johni0702.minecraft.gui.utils.Colors;
|
||||
import net.minecraft.client.gui.GuiErrorScreen;
|
||||
import net.minecraft.crash.CrashReport;
|
||||
import net.minecraft.util.ReportedException;
|
||||
import org.lwjgl.util.Dimension;
|
||||
import org.lwjgl.util.ReadableDimension;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import static com.replaymod.render.ReplayModRender.LOGGER;
|
||||
|
||||
public class GuiRenderQueue extends AbstractGuiPopup<GuiRenderQueue> {
|
||||
private final GuiLabel title = new GuiLabel().setI18nText("replaymod.gui.renderqueue.title").setColor(Colors.BLACK);
|
||||
private final GuiVerticalList list = new GuiVerticalList().setDrawShadow(true).setDrawSlider(true);
|
||||
private final GuiButton addButton = new GuiButton().setI18nLabel("replaymod.gui.renderqueue.add").setSize(150, 20);
|
||||
private final GuiButton renameButton = new GuiButton().setI18nLabel("replaymod.gui.rename").setSize(73, 20);
|
||||
private final GuiButton removeButton = new GuiButton().setI18nLabel("replaymod.gui.remove").setSize(73, 20);
|
||||
private final GuiButton renderButton = new GuiButton().setI18nLabel("replaymod.gui.render").setSize(150, 20);
|
||||
private final GuiButton closeButton = new GuiButton().setI18nLabel("replaymod.gui.close").setSize(150, 20).onClick(this::close);
|
||||
|
||||
/*
|
||||
|
||||
|---------------------------------|
|
||||
| Add | Render |
|
||||
|---------------------------------|
|
||||
| Rename | Remove | Close |
|
||||
|---------------------------------|
|
||||
|
||||
*/
|
||||
private final GuiPanel buttonPanel = new GuiPanel()
|
||||
.setLayout(new GridLayout().setSpacingX(5).setSpacingY(5).setColumns(2))
|
||||
.addElements(null,
|
||||
addButton,
|
||||
renderButton,
|
||||
new GuiPanel().setLayout(new HorizontalLayout().setSpacing(4)).addElements(null,
|
||||
renameButton, removeButton),
|
||||
closeButton);
|
||||
|
||||
private final GuiContainer container;
|
||||
private Entry selectedEntry;
|
||||
|
||||
{
|
||||
popup.setLayout(new CustomLayout<GuiPanel>() {
|
||||
@Override
|
||||
protected void layout(GuiPanel container, int width, int height) {
|
||||
pos(title, width / 2 - width(title) / 2, 0);
|
||||
pos(list, 0, y(title) + height(title) + 5);
|
||||
pos(buttonPanel, width / 2 - width(buttonPanel) / 2, height - height(buttonPanel));
|
||||
size(list, width, y(buttonPanel) - y(list) - 10);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadableDimension calcMinSize(GuiContainer<?> container) {
|
||||
ReadableDimension screenSize = GuiRenderQueue.this.container.getMinSize();
|
||||
return new Dimension(screenSize.getWidth() - 40,
|
||||
screenSize.getHeight() - 20 - buttonPanel.getMinSize().getHeight() - title.getMinSize().getHeight());
|
||||
}
|
||||
}).addElements(null, title, list, buttonPanel);
|
||||
}
|
||||
|
||||
public GuiRenderQueue(GuiContainer container, GuiRenderSettings guiRenderSettings, ReplayHandler replayHandler, Timeline timeline) {
|
||||
super(container);
|
||||
this.container = container;
|
||||
LOGGER.trace("Opening render queue popup");
|
||||
|
||||
setBackgroundColor(Colors.DARK_TRANSPARENT);
|
||||
|
||||
List<RenderJob> queue = ReplayModRender.instance.getRenderQueue();
|
||||
|
||||
for (RenderJob renderJob : queue) {
|
||||
LOGGER.trace("Adding {} to job queue list", renderJob);
|
||||
list.getListPanel().addElements(null, new Entry(renderJob));
|
||||
}
|
||||
|
||||
addButton.onClick(() -> {
|
||||
LOGGER.trace("Add button clicked");
|
||||
// Open popup
|
||||
GuiYesNoPopup popup = GuiYesNoPopup.open(container)
|
||||
.setYesI18nLabel("replaymod.gui.add").setNoI18nLabel("replaymod.gui.cancel");
|
||||
popup.getInfo().setLayout(new HorizontalLayout(HorizontalLayout.Alignment.CENTER).setSpacing(5));
|
||||
// Add content
|
||||
GuiLabel label = new GuiLabel().setI18nText("replaymod.gui.renderqueue.jobname").setColor(Colors.BLACK);
|
||||
GuiTextField nameField = new GuiTextField().setSize(150, 20).setFocused(true);
|
||||
popup.getInfo().addElements(new HorizontalLayout.Data(0.5), label, nameField);
|
||||
// Disable "Yes" button while name is empty
|
||||
nameField.onTextChanged(old -> popup.getYesButton().setEnabled(!nameField.getText().isEmpty())).onEnter(() -> {
|
||||
if (popup.getYesButton().isEnabled()) {
|
||||
popup.getYesButton().onClick();
|
||||
}
|
||||
});
|
||||
popup.getYesButton().setDisabled();
|
||||
// Register callback
|
||||
Futures.addCallback(popup.getFuture(), new FutureCallback<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(@Nullable Boolean result) {
|
||||
if (result == Boolean.TRUE) {
|
||||
RenderJob newJob = new RenderJob();
|
||||
newJob.setName(nameField.getText());
|
||||
newJob.setSettings(guiRenderSettings.save(false));
|
||||
newJob.setTimeline(timeline);
|
||||
LOGGER.trace("Adding new job: {}", newJob);
|
||||
queue.add(newJob);
|
||||
list.getListPanel().addElements(null, new Entry(newJob));
|
||||
} else {
|
||||
LOGGER.trace("Adding cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable t) {
|
||||
LOGGER.error("Add Job popup:", t);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (guiRenderSettings != null) {
|
||||
addButton.setEnabled(guiRenderSettings.renderButton.isEnabled());
|
||||
}
|
||||
|
||||
renameButton.onClick(() -> {
|
||||
LOGGER.trace("Rename button clicked for {}", selectedEntry.job);
|
||||
// Open popup
|
||||
GuiYesNoPopup popup = GuiYesNoPopup.open(container)
|
||||
.setYesI18nLabel("replaymod.gui.rename").setNoI18nLabel("replaymod.gui.cancel");
|
||||
popup.getInfo().setLayout(new HorizontalLayout(HorizontalLayout.Alignment.CENTER).setSpacing(5));
|
||||
// Add content
|
||||
GuiLabel label = new GuiLabel().setI18nText("replaymod.gui.renderqueue.jobname").setColor(Colors.BLACK);
|
||||
GuiTextField nameField = new GuiTextField().setSize(150, 20).setFocused(true)
|
||||
.setText(selectedEntry.job.getName());
|
||||
popup.getInfo().addElements(new HorizontalLayout.Data(0.5), label, nameField);
|
||||
// Disable "Yes" button while name is empty
|
||||
nameField.onTextChanged(old -> popup.getYesButton().setEnabled(!nameField.getText().isEmpty())).onEnter(() -> {
|
||||
if (popup.getYesButton().isEnabled()) {
|
||||
popup.getYesButton().onClick();
|
||||
}
|
||||
});
|
||||
// Register callback
|
||||
Futures.addCallback(popup.getFuture(), new FutureCallback<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(@Nullable Boolean result) {
|
||||
if (result == Boolean.TRUE) {
|
||||
LOGGER.trace("Renaming {} to \"{}\"", selectedEntry.job, nameField.getText());
|
||||
selectedEntry.setName(nameField.getText());
|
||||
} else {
|
||||
LOGGER.trace("Renaming cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable t) {
|
||||
LOGGER.error("Rename Job popup:", t);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
removeButton.onClick(() -> {
|
||||
LOGGER.trace("Remove button clicked for {}", selectedEntry.job);
|
||||
list.getListPanel().removeElement(selectedEntry);
|
||||
queue.remove(selectedEntry.job);
|
||||
selectedEntry = null;
|
||||
updateButtons();
|
||||
});
|
||||
|
||||
renderButton.onClick(() -> {
|
||||
LOGGER.trace("Render button clicked");
|
||||
// Close all GUIs (so settings in GuiRenderSettings are saved)
|
||||
getMinecraft().displayGuiScreen(null);
|
||||
// Start rendering
|
||||
for (RenderJob renderJob : queue) {
|
||||
LOGGER.info("Starting render job {}", renderJob);
|
||||
try {
|
||||
VideoRenderer videoRenderer = new VideoRenderer(renderJob.getSettings(), replayHandler, renderJob.getTimeline());
|
||||
videoRenderer.renderVideo();
|
||||
} catch (IOException e) {
|
||||
LOGGER.error("Rendering video:", e);
|
||||
GuiErrorScreen errorScreen = new GuiErrorScreen(I18n.format("replaymod.gui.rendering.error.title"),
|
||||
I18n.format("replaymod.gui.rendering.error.message"));
|
||||
getMinecraft().displayGuiScreen(errorScreen);
|
||||
return;
|
||||
} catch (Throwable t) {
|
||||
CrashReport crashReport = CrashReport.makeCrashReport(t, "Rendering video");
|
||||
throw new ReportedException(crashReport);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateButtons();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void open() {
|
||||
super.open();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected GuiRenderQueue getThis() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public void updateButtons() {
|
||||
renameButton.setEnabled(selectedEntry != null);
|
||||
removeButton.setEnabled(selectedEntry != null);
|
||||
renderButton.setEnabled(!list.getListPanel().getChildren().isEmpty());
|
||||
}
|
||||
|
||||
public class Entry extends AbstractGuiClickableContainer<Entry> {
|
||||
public final GuiLabel label = new GuiLabel(this);
|
||||
public final RenderJob job;
|
||||
|
||||
public Entry(RenderJob job) {
|
||||
this.job = job;
|
||||
|
||||
setLayout(new CustomLayout<Entry>() {
|
||||
@Override
|
||||
protected void layout(Entry container, int width, int height) {
|
||||
pos(label, 5, height / 2 - height(label) / 2);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadableDimension calcMinSize(GuiContainer<?> container) {
|
||||
return new Dimension(buttonPanel.calcMinSize().getWidth(), 16);
|
||||
}
|
||||
});
|
||||
label.setText(job.getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onClick() {
|
||||
selectedEntry = this;
|
||||
updateButtons();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(GuiRenderer renderer, ReadableDimension size, RenderInfo renderInfo) {
|
||||
if (selectedEntry == this) {
|
||||
renderer.drawRect(0, 0, size.getWidth(), size.getHeight(), Colors.BLACK);
|
||||
renderer.drawRect(0, 0, 2, size.getHeight(), Colors.WHITE);
|
||||
}
|
||||
super.draw(renderer, size, renderInfo);
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
job.setName(name);
|
||||
label.setText(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Entry getThis() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,13 +32,14 @@ import net.minecraft.crash.CrashReport;
|
||||
import net.minecraft.util.ReportedException;
|
||||
import net.minecraftforge.common.config.Configuration;
|
||||
import net.minecraftforge.common.config.Property;
|
||||
import org.lwjgl.util.Color;
|
||||
import org.lwjgl.util.Dimension;
|
||||
import org.lwjgl.util.ReadableColor;
|
||||
import org.lwjgl.util.ReadableDimension;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
@@ -149,13 +150,18 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
|
||||
public final GuiCheckbox inject360Metadata = new GuiCheckbox()
|
||||
.setI18nLabel("replaymod.gui.rendersettings.360metadata");
|
||||
|
||||
public final GuiDropdownMenu<RenderSettings.AntiAliasing> antiAliasingDropdown = new GuiDropdownMenu<RenderSettings.AntiAliasing>()
|
||||
.setSize(200, 20).setValues(RenderSettings.AntiAliasing.values()).setSelected(RenderSettings.AntiAliasing.NONE);
|
||||
|
||||
public final GuiPanel advancedPanel = new GuiPanel().setLayout(new VerticalLayout().setSpacing(15))
|
||||
.addElements(null, nametagCheckbox, new GuiPanel().setLayout(
|
||||
new GridLayout().setCellsEqualSize(false).setColumns(2).setSpacingX(5).setSpacingY(15))
|
||||
.addElements(new GridLayout.Data(0, 0.5),
|
||||
new GuiLabel().setI18nText("replaymod.gui.rendersettings.stabilizecamera"), stabilizePanel,
|
||||
chromaKeyingCheckbox, chromaKeyingColor,
|
||||
inject360Metadata));
|
||||
inject360Metadata,
|
||||
new GuiLabel(), // to show the anti-aliasing options in a new line
|
||||
new GuiLabel().setI18nText("replaymod.gui.rendersettings.antialiasing"), antiAliasingDropdown));
|
||||
|
||||
public final GuiTextField exportCommand = new GuiTextField().setI18nHint("replaymod.gui.rendersettings.command")
|
||||
.setSize(55, 20).setMaxLength(100);
|
||||
@@ -179,6 +185,12 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
|
||||
|
||||
|
||||
public final GuiPanel buttonPanel = new GuiPanel(contentPanel).setLayout(new HorizontalLayout().setSpacing(4));
|
||||
public final GuiButton queueButton = new GuiButton(buttonPanel).onClick(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
new GuiRenderQueue(GuiRenderSettings.this, GuiRenderSettings.this, replayHandler, timeline).open();
|
||||
}
|
||||
}).setSize(100, 20).setI18nLabel("replaymod.gui.renderqueue.open");
|
||||
public final GuiButton renderButton = new GuiButton(buttonPanel).onClick(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
@@ -198,13 +210,13 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
|
||||
throw new ReportedException(crashReport);
|
||||
}
|
||||
}
|
||||
}).setSize(148, 20).setI18nLabel("replaymod.gui.render");
|
||||
}).setSize(100, 20).setI18nLabel("replaymod.gui.render");
|
||||
public final GuiButton cancelButton = new GuiButton(buttonPanel).onClick(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
getMinecraft().displayGuiScreen(null);
|
||||
}
|
||||
}).setSize(148, 20).setI18nLabel("replaymod.gui.cancel");
|
||||
}).setSize(100, 20).setI18nLabel("replaymod.gui.cancel");
|
||||
|
||||
{
|
||||
setBackground(Background.NONE);
|
||||
@@ -261,12 +273,10 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
|
||||
this.timeline = timeline;
|
||||
|
||||
String json = getConfigProperty(ReplayModRender.instance.getConfiguration()).getString();
|
||||
RenderSettings settings = new GsonBuilder().registerTypeAdapter(RenderSettings.class, new InstanceCreator<RenderSettings>() {
|
||||
@Override
|
||||
public RenderSettings createInstance(Type type) {
|
||||
return getDefaultRenderSettings();
|
||||
}
|
||||
}).create().fromJson(json, RenderSettings.class);
|
||||
RenderSettings settings = new GsonBuilder()
|
||||
.registerTypeAdapter(RenderSettings.class, (InstanceCreator<RenderSettings>) type -> getDefaultRenderSettings())
|
||||
.registerTypeAdapter(ReadableColor.class, new Gson().getAdapter(Color.class))
|
||||
.create().fromJson(json, RenderSettings.class);
|
||||
load(settings);
|
||||
}
|
||||
|
||||
@@ -347,8 +357,8 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
|
||||
public void load(RenderSettings settings) {
|
||||
renderMethodDropdown.setSelected(settings.getRenderMethod());
|
||||
encodingPresetDropdown.setSelected(settings.getEncodingPreset());
|
||||
videoWidth.setValue(settings.getVideoWidth());
|
||||
videoHeight.setValue(settings.getVideoHeight());
|
||||
videoWidth.setValue(settings.getTargetVideoWidth());
|
||||
videoHeight.setValue(settings.getTargetVideoHeight());
|
||||
frameRateSlider.setValue(settings.getFramesPerSecond() - 10);
|
||||
if (settings.getBitRate() % (1 << 20) == 0) {
|
||||
bitRateField.setValue(settings.getBitRate() >> 20);
|
||||
@@ -380,6 +390,7 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
|
||||
chromaKeyingColor.setColor(settings.getChromaKeyingColor());
|
||||
}
|
||||
inject360Metadata.setChecked(settings.isInject360Metadata());
|
||||
antiAliasingDropdown.setSelected(settings.getAntiAliasing());
|
||||
exportCommand.setText(settings.getExportCommand());
|
||||
exportArguments.setText(settings.getExportArguments());
|
||||
|
||||
@@ -401,6 +412,7 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
|
||||
stabilizeRoll.isChecked() && (serialize || stabilizeRoll.isEnabled()),
|
||||
chromaKeyingCheckbox.isChecked() ? chromaKeyingColor.getColor() : null,
|
||||
inject360Metadata.isChecked() && (serialize || inject360Metadata.isEnabled()),
|
||||
antiAliasingDropdown.getSelectedValue(),
|
||||
exportCommand.getText(),
|
||||
exportArguments.getText(),
|
||||
net.minecraft.client.gui.GuiScreen.isCtrlKeyDown()
|
||||
@@ -415,7 +427,7 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
|
||||
|
||||
private RenderSettings getDefaultRenderSettings() {
|
||||
return new RenderSettings(RenderSettings.RenderMethod.DEFAULT, RenderSettings.EncodingPreset.MP4_DEFAULT, 1920, 1080, 60, 10 << 20, null,
|
||||
true, false, false, false, null, false, "", RenderSettings.EncodingPreset.MP4_DEFAULT.getValue(), false);
|
||||
true, false, false, false, null, false, RenderSettings.AntiAliasing.NONE, "", RenderSettings.EncodingPreset.MP4_DEFAULT.getValue(), false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -431,4 +443,8 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
|
||||
return configuration.get("rendersettings", "settings", "{}",
|
||||
"Last state of the render settings GUI. Internal use only.");
|
||||
}
|
||||
|
||||
public ReplayHandler getReplayHandler() {
|
||||
return replayHandler;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.replaymod.render.rendering;
|
||||
|
||||
import com.replaymod.pathing.player.AbstractTimelinePlayer;
|
||||
import com.replaymod.pathing.player.ReplayTimer;
|
||||
import com.replaymod.pathing.properties.TimestampProperty;
|
||||
import com.replaymod.render.RenderSettings;
|
||||
import com.replaymod.render.ReplayModRender;
|
||||
@@ -55,6 +56,7 @@ public class VideoRenderer implements RenderInfo {
|
||||
private boolean debugInfoWasShown;
|
||||
private Map<SoundCategory, Float> originalSoundLevels;
|
||||
|
||||
private TimelinePlayer timelinePlayer;
|
||||
private Future<Void> timelinePlayerFuture;
|
||||
private ChunkLoadingRenderGlobal chunkLoadingRenderGlobal;
|
||||
|
||||
@@ -174,7 +176,8 @@ public class VideoRenderer implements RenderInfo {
|
||||
}
|
||||
|
||||
private void setup() {
|
||||
timelinePlayerFuture = new TimelinePlayer(replayHandler).start(timeline);
|
||||
timelinePlayer = new TimelinePlayer(replayHandler);
|
||||
timelinePlayerFuture = timelinePlayer.start(timeline);
|
||||
|
||||
if (!OpenGlHelper.isFramebufferEnabled()) {
|
||||
Display.setResizable(false);
|
||||
@@ -226,6 +229,8 @@ public class VideoRenderer implements RenderInfo {
|
||||
if (!timelinePlayerFuture.isDone()) {
|
||||
timelinePlayerFuture.cancel(false);
|
||||
}
|
||||
// Tear down of the timeline player might only happen the next tick after it was cancelled
|
||||
timelinePlayer.onTick(new ReplayTimer.UpdatedEvent());
|
||||
|
||||
if (!OpenGlHelper.isFramebufferEnabled()) {
|
||||
Display.setResizable(true);
|
||||
|
||||
12
src/main/java/com/replaymod/render/utils/RenderJob.java
Normal file
12
src/main/java/com/replaymod/render/utils/RenderJob.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.replaymod.render.utils;
|
||||
|
||||
import com.replaymod.render.RenderSettings;
|
||||
import com.replaymod.replaystudio.pathing.path.Timeline;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RenderJob {
|
||||
private String name;
|
||||
private Timeline timeline;
|
||||
private RenderSettings settings;
|
||||
}
|
||||
@@ -28,7 +28,10 @@ import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mod(modid = ReplayModReplay.MOD_ID, useMetadata = true)
|
||||
@Mod(modid = ReplayModReplay.MOD_ID,
|
||||
version = "@MOD_VERSION@",
|
||||
acceptedMinecraftVersions = "@MC_VERSION@",
|
||||
useMetadata = true)
|
||||
public class ReplayModReplay {
|
||||
public static final String MOD_ID = "replaymod-replay";
|
||||
|
||||
|
||||
@@ -26,19 +26,22 @@ import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Mod(modid = ReplayModSimplePathing.MOD_ID, useMetadata = true)
|
||||
@Mod(modid = ReplayModSimplePathing.MOD_ID,
|
||||
version = "@MOD_VERSION@",
|
||||
acceptedMinecraftVersions = "@MC_VERSION@",
|
||||
useMetadata = true)
|
||||
public class ReplayModSimplePathing implements PathingRegistry {
|
||||
public static final String MOD_ID = "replaymod-simplepathing";
|
||||
|
||||
private ReplayMod core;
|
||||
|
||||
private Logger logger;
|
||||
public static Logger LOGGER;
|
||||
|
||||
private GuiPathing guiPathing;
|
||||
|
||||
@Mod.EventHandler
|
||||
public void preInit(FMLPreInitializationEvent event) {
|
||||
logger = event.getModLog();
|
||||
LOGGER = event.getModLog();
|
||||
core = ReplayMod.instance;
|
||||
|
||||
core.getSettingsRegistry().register(Setting.class);
|
||||
|
||||
@@ -63,7 +63,7 @@ public abstract class GuiEditKeyframe<T extends GuiEditKeyframe<T>> extends Abst
|
||||
setBackgroundColor(Colors.DARK_TRANSPARENT);
|
||||
|
||||
popup.setLayout(new VerticalLayout().setSpacing(10))
|
||||
.addElements(new VerticalLayout.Data(0.5), title, inputs, timePanel, buttons);
|
||||
.addElements(new VerticalLayout.Data(0.5, false), title, inputs, timePanel, buttons);
|
||||
}
|
||||
|
||||
public GuiEditKeyframe(GuiPathing gui, Path path, Keyframe keyframe, String type) {
|
||||
@@ -179,13 +179,13 @@ public abstract class GuiEditKeyframe<T extends GuiEditKeyframe<T>> extends Abst
|
||||
}
|
||||
|
||||
public static class Position extends GuiEditKeyframe<Position> {
|
||||
public final GuiNumberField xField = newGuiNumberField().setSize(150, 20).setPrecision(10);
|
||||
public final GuiNumberField yField = newGuiNumberField().setSize(150, 20).setPrecision(10);
|
||||
public final GuiNumberField zField = newGuiNumberField().setSize(150, 20).setPrecision(10);
|
||||
public final GuiNumberField xField = newGuiNumberField().setSize(60, 20).setPrecision(5);
|
||||
public final GuiNumberField yField = newGuiNumberField().setSize(60, 20).setPrecision(5);
|
||||
public final GuiNumberField zField = newGuiNumberField().setSize(60, 20).setPrecision(5);
|
||||
|
||||
public final GuiNumberField yawField = newGuiNumberField().setSize(150, 20).setPrecision(5);
|
||||
public final GuiNumberField pitchField = newGuiNumberField().setSize(150, 20).setPrecision(5);
|
||||
public final GuiNumberField rollField = newGuiNumberField().setSize(150, 20).setPrecision(5);
|
||||
public final GuiNumberField yawField = newGuiNumberField().setSize(60, 20).setPrecision(5);
|
||||
public final GuiNumberField pitchField = newGuiNumberField().setSize(60, 20).setPrecision(5);
|
||||
public final GuiNumberField rollField = newGuiNumberField().setSize(60, 20).setPrecision(5);
|
||||
|
||||
{
|
||||
inputs.setLayout(new GridLayout().setCellsEqualSize(false).setColumns(4).setSpacingX(3).setSpacingY(5))
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.replaymod.replaystudio.pathing.path.Path;
|
||||
import com.replaymod.replaystudio.pathing.path.PathSegment;
|
||||
import com.replaymod.replaystudio.pathing.path.Timeline;
|
||||
import com.replaymod.replaystudio.pathing.property.Property;
|
||||
import com.replaymod.replaystudio.pathing.serialize.TimelineSerialization;
|
||||
import com.replaymod.replaystudio.util.EntityPositionTracker;
|
||||
import com.replaymod.replaystudio.util.Location;
|
||||
import com.replaymod.simplepathing.ReplayModSimplePathing;
|
||||
@@ -41,6 +42,7 @@ import de.johni0702.minecraft.gui.popup.AbstractGuiPopup;
|
||||
import de.johni0702.minecraft.gui.popup.GuiInfoPopup;
|
||||
import de.johni0702.minecraft.gui.popup.GuiYesNoPopup;
|
||||
import de.johni0702.minecraft.gui.utils.Colors;
|
||||
import net.minecraft.crash.CrashReport;
|
||||
import net.minecraft.entity.Entity;
|
||||
import net.minecraftforge.fml.common.Loader;
|
||||
import org.apache.commons.lang3.tuple.Triple;
|
||||
@@ -56,10 +58,14 @@ import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static com.replaymod.core.utils.Utils.error;
|
||||
import static com.replaymod.simplepathing.ReplayModSimplePathing.LOGGER;
|
||||
|
||||
|
||||
/**
|
||||
* Gui plug-in to the GuiReplayOverlay for simple pathing.
|
||||
@@ -91,7 +97,19 @@ public class GuiPathing {
|
||||
@Override
|
||||
public void run() {
|
||||
if (!preparePathsForPlayback()) return;
|
||||
new GuiRenderSettings(replayHandler, mod.getCurrentTimeline()).display();
|
||||
|
||||
// Clone the timeline passed to the settings gui as it may be stored for later rendering in a queue
|
||||
Timeline timeline = mod.getCurrentTimeline();
|
||||
try {
|
||||
TimelineSerialization serialization = new TimelineSerialization(mod, null);
|
||||
String serialized = serialization.serialize(Collections.singletonMap("", timeline));
|
||||
timeline = serialization.deserialize(serialized).get("");
|
||||
} catch (Throwable t) {
|
||||
error(LOGGER, replayHandler.getOverlay(), CrashReport.makeCrashReport(t, "Cloning timeline"), () -> {});
|
||||
return;
|
||||
}
|
||||
|
||||
new GuiRenderSettings(replayHandler, timeline).display();
|
||||
}
|
||||
}).setSize(20, 20).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setTexturePosH(40, 0)
|
||||
.setTooltip(new GuiTooltip().setI18nText("replaymod.gui.ingame.menu.renderpath"));
|
||||
@@ -512,6 +530,13 @@ public class GuiPathing {
|
||||
lastTime = time;
|
||||
}
|
||||
|
||||
// Make sure there are at least two position- and two time-keyframes
|
||||
if (timeline.getPaths().get(POSITION_PATH).getSegments().isEmpty()
|
||||
|| timeline.getPaths().get(TIME_PATH).getSegments().isEmpty()) {
|
||||
GuiInfoPopup.open(replayHandler.getOverlay(), "replaymod.chat.morekeyframes");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -255,6 +255,8 @@ replaymod.gui.editor.trim.description=Removes the beginning and end of a Replay
|
||||
replaymod.gui.editor.connect.description=Connects multiple Replays in the specified order
|
||||
replaymod.gui.editor.modify.description=Provides several filters to modify Replay Files
|
||||
|
||||
replaymod.gui.editor.trim.marker=Select Event Marker
|
||||
|
||||
replaymod.gui.editor.progress.title=Editing Replay File...
|
||||
replaymod.gui.editor.progress.pleasewait=Please wait while the Replay is being edited.
|
||||
|
||||
@@ -424,6 +426,18 @@ replaymod.gui.rendersettings.presets.webm.custom=WEBM - Custom Bitrate
|
||||
replaymod.gui.rendersettings.presets.mkv.lossless=MKV - Lossless
|
||||
replaymod.gui.rendersettings.presets.png=PNG Sequence
|
||||
|
||||
replaymod.gui.rendersettings.antialiasing=Anti-Aliasing
|
||||
replaymod.gui.rendersettings.antialiasing.none=None
|
||||
replaymod.gui.rendersettings.antialiasing.x2=2x
|
||||
replaymod.gui.rendersettings.antialiasing.x4=4x
|
||||
replaymod.gui.rendersettings.antialiasing.x8=8x
|
||||
|
||||
#Render Queue GUI
|
||||
replaymod.gui.renderqueue.title=Render Queue
|
||||
replaymod.gui.renderqueue.open=Open Queue
|
||||
replaymod.gui.renderqueue.jobname=Job Name:
|
||||
replaymod.gui.renderqueue.add=Add current configuration
|
||||
|
||||
#Rendering GUI
|
||||
replaymod.gui.rendering.title=Rendering Video
|
||||
replaymod.gui.rendering.pause=Pause Rendering
|
||||
|
||||
@@ -100,6 +100,23 @@
|
||||
"screenshots": [],
|
||||
"dependencies": ["required-after:replaymod-replay"]
|
||||
},
|
||||
{
|
||||
"modid": "replaymod-editor",
|
||||
"name": "Replay Mod - Replay Editor",
|
||||
"description": "Replay Editor Module of the ReplayMod",
|
||||
"version": "${version}",
|
||||
"mcversion": "${mcversion}",
|
||||
"url": "https://replaymod.com",
|
||||
"updateUrl": "https://replaymod.com/download",
|
||||
"authorList": [
|
||||
"CrushedPixel",
|
||||
"johni0702"
|
||||
],
|
||||
"logoFile": "replaymod_logo.png",
|
||||
"parent": "replaymod",
|
||||
"screenshots": [],
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"modid": "replaymod-extras",
|
||||
"name": "Replay Mod - Extras",
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"client": [
|
||||
"MixinShaderEntityRenderer",
|
||||
"MixinShaderRenderChunk",
|
||||
"MixinShaderRenderGlobal"
|
||||
"MixinShaderRenderGlobal",
|
||||
"MixinShadersRender"
|
||||
],
|
||||
"compatibilityLevel": "JAVA_8",
|
||||
"refmap": "mixins.replaymod.refmap.json"
|
||||
}
|
||||
Reference in New Issue
Block a user