diff --git a/.gitignore b/.gitignore index 19586e3e..37399e16 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/build.gradle b/build.gradle index 378e8923..59fd9e7b 100644 --- a/build.gradle +++ b/build.gradle @@ -34,7 +34,7 @@ buildscript { dependencies { classpath 'gradle.plugin.com.github.jengelman.gradle.plugins:shadow:7.0.0' if (fabric) { - classpath 'fabric-loom:fabric-loom.gradle.plugin:0.8-SNAPSHOT' + classpath 'fabric-loom:fabric-loom.gradle.plugin:0.10-SNAPSHOT' } else if (mcVersion >= 11400) { classpath('net.minecraftforge.gradle:ForgeGradle:5.0.5') { // the FG people still haven't learned to not do breaking changes exclude group: 'trove', module: 'trove' // preprocessor/idea requires more recent one @@ -43,7 +43,7 @@ buildscript { classpath('com.github.ReplayMod:ForgeGradle:' + ( mcVersion >= 11200 ? '34ab703' : // FG 2.3 mcVersion >= 10904 ? '5d1e8d8' : // FG 2.2 - 'd1a7165' // FG 2.1 + 'ceb83c0' // FG 2.1 ) + ':all') } else { classpath 'com.github.ReplayMod:ForgeGradle:a8a9e0ca:all' // FG 1.2 @@ -108,9 +108,9 @@ preprocess { def mcVersionStr = "${(int)(mcVersion/10000)}.${(int)(mcVersion/100)%100}" + (mcVersion%100==0 ? '' : ".${mcVersion%100}") -sourceCompatibility = targetCompatibility = mcVersion >= 11700 ? 16 : 1.8 +sourceCompatibility = targetCompatibility = mcVersion >= 11800 ? 17 : mcVersion >= 11700 ? 16 : 1.8 tasks.withType(JavaCompile).configureEach { - options.release = mcVersion >= 11700 ? 16 : 8 + options.release = mcVersion >= 11800 ? 17 : mcVersion >= 11700 ? 16 : 8 } if (mcVersion >= 11400) { @@ -124,8 +124,8 @@ group= "com.replaymod" archivesBaseName = "replaymod" if (FABRIC) { - minecraft { - refmapName = 'mixins.replaymod.refmap.json' + loom { + mixin.defaultRefmapName.set('mixins.replaymod.refmap.json') runConfigs.all { ideConfigGenerated = true } @@ -243,6 +243,8 @@ dependencies { 11604: '1.16.4', 11700: '1.17', 11701: '1.17.1', + 11800: '1.18', + 11801: '1.18.1', ][mcVersion] mappings 'net.fabricmc:yarn:' + [ 11404: '1.14.4+build.16', @@ -252,8 +254,10 @@ dependencies { 11604: '1.16.4+build.6:v2', 11700: '1.17+build.13:v2', 11701: '1.17.1+build.29:v2', + 11800: '1.18+build.1:v2', + 11801: '1.18.1+build.1:v2', ][mcVersion] - modImplementation 'net.fabricmc:fabric-loader:0.11.6' + modImplementation 'net.fabricmc:fabric-loader:0.12.5' def fabricApiVersion = [ 11404: '0.4.3+build.247-1.14', 11502: '0.5.1+build.294-1.15', @@ -262,6 +266,8 @@ dependencies { 11604: '0.25.1+build.416-1.16', 11700: '0.36.0+1.17', 11701: '0.37.1+1.17', + 11800: '0.43.1+1.18', + 11801: '0.43.1+1.18', ][mcVersion] def fabricApiModules = [ "api-base", @@ -332,15 +338,17 @@ dependencies { shadow 'com.github.ReplayMod.JavaBlend:2.79.0:a0696f8' - shadow "com.github.ReplayMod:ReplayStudio:c9de2f5", shadeExclusions + shadow "com.github.ReplayMod:ReplayStudio:70f59ef", shadeExclusions - implementation(jGui){ + implementation(FABRIC ? dependencies.project(path: jGui.path, configuration: "namedElements") : jGui) { transitive = false // FG 1.2 puts all MC deps into the compile configuration and we don't want to shade those } shadow 'com.github.ReplayMod:lwjgl-utils:27dcd66' if (FABRIC) { - if (mcVersion >= 11700) { + if (mcVersion >= 11800) { + modImplementation 'com.terraformersmc:modmenu:3.0.0' + } else if (mcVersion >= 11700) { modImplementation 'com.terraformersmc:modmenu:2.0.0-beta.7' } else if (mcVersion >= 11602) { modImplementation 'com.terraformersmc:modmenu:1.16.8' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0f80bbf5..e750102e 100755 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/jGui b/jGui index 31bcfabe..c79b62a7 160000 --- a/jGui +++ b/jGui @@ -1 +1 @@ -Subproject commit 31bcfabe67a5b2de490e0f5c53c1a59e636497be +Subproject commit c79b62a73e649fd6d16a1ffbd9e320555834bc46 diff --git a/root.gradle.kts b/root.gradle.kts index 870e71bc..f94b12fc 100755 --- a/root.gradle.kts +++ b/root.gradle.kts @@ -2,8 +2,8 @@ import groovy.json.JsonOutput import java.io.ByteArrayOutputStream plugins { - id("fabric-loom") version "0.8-SNAPSHOT" apply false - id("com.replaymod.preprocess") version "123fb7a" + id("fabric-loom") version "0.10-SNAPSHOT" apply false + id("com.replaymod.preprocess") version "7746c47" id("com.github.hierynomus.license") version "0.15.0" } @@ -189,6 +189,7 @@ val doRelease by tasks.registering { defaultTasks("bundleJar") preprocess { + val mc11801 = createNode("1.18.1", 11801, "yarn") val mc11701 = createNode("1.17.1", 11701, "yarn") val mc11700 = createNode("1.17", 11700, "yarn") val mc11604 = createNode("1.16.4", 11604, "yarn") @@ -207,10 +208,11 @@ preprocess { val mc10800 = createNode("1.8", 10800, "srg") val mc10710 = createNode("1.7.10", 10710, "srg") + mc11801.link(mc11701, file("versions/mapping-fabric-1.18.1-1.17.1.txt")) mc11701.link(mc11700) mc11700.link(mc11604, file("versions/mapping-fabric-1.17-1.16.4.txt")) mc11604.link(mc11601) - mc11601.link(mc11502) + mc11601.link(mc11502, file("versions/mapping-fabric-1.16.1-1.15.2.txt")) mc11502.link(mc11404, file("versions/mapping-fabric-1.15.2-1.14.4.txt")) mc11404.link(mc11404Forge, file("versions/mapping-1.14.4-fabric-forge.txt")) mc11404Forge.link(mc11202, file("versions/1.14.4-forge/mapping.txt")) diff --git a/settings.gradle.kts b/settings.gradle.kts index 02c25403..2030c3cd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,6 +31,7 @@ val jGuiVersions = listOf( "1.16.4", "1.17", "1.17.1", + "1.18.1", ) val replayModVersions = listOf( // "1.7.10", @@ -50,6 +51,7 @@ val replayModVersions = listOf( "1.16.4", "1.17", "1.17.1", + "1.18.1", ) rootProject.buildFileName = "root.gradle.kts" diff --git a/src/main/java/com/replaymod/core/ReplayMod.java b/src/main/java/com/replaymod/core/ReplayMod.java index c21ed0be..39e55c12 100644 --- a/src/main/java/com/replaymod/core/ReplayMod.java +++ b/src/main/java/com/replaymod/core/ReplayMod.java @@ -1,10 +1,10 @@ package com.replaymod.core; -import com.google.common.net.PercentEscaper; import com.replaymod.compat.ReplayModCompat; +import com.replaymod.core.files.ReplayFilesService; +import com.replaymod.core.files.ReplayFoldersService; import com.replaymod.core.gui.GuiBackgroundProcesses; import com.replaymod.core.gui.GuiReplaySettings; -import com.replaymod.core.gui.RestoreReplayGui; import com.replaymod.core.versions.MCVer; import com.replaymod.core.versions.scheduler.Scheduler; import com.replaymod.core.versions.scheduler.SchedulerImpl; @@ -14,12 +14,9 @@ import com.replaymod.recording.ReplayModRecording; import com.replaymod.render.ReplayModRender; import com.replaymod.replay.ReplayModReplay; import com.replaymod.replaystudio.lib.viaversion.api.protocol.version.ProtocolVersion; -import com.replaymod.replaystudio.replay.ReplayFile; -import com.replaymod.replaystudio.replay.ZipReplayFile; import com.replaymod.replaystudio.studio.ReplayStudio; import com.replaymod.replaystudio.util.I18n; import com.replaymod.simplepathing.ReplayModSimplePathing; -import de.johni0702.minecraft.gui.container.GuiScreen; import net.minecraft.client.MinecraftClient; import net.minecraft.client.options.Option; import net.minecraft.resource.DirectoryResourcePack; @@ -29,21 +26,12 @@ import net.minecraft.text.Text; import net.minecraft.text.TranslatableText; import net.minecraft.util.Formatting; import net.minecraft.util.Identifier; -import org.apache.commons.io.FileUtils; -import org.apache.commons.io.FilenameUtils; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -import java.nio.file.DirectoryStream; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutionException; @@ -73,6 +61,8 @@ public class ReplayMod implements Module, Scheduler { private final List modules = new ArrayList<>(); private final GuiBackgroundProcesses backgroundProcesses = new GuiBackgroundProcesses(); + public final ReplayFoldersService folders = new ReplayFoldersService(settingsRegistry); + public final ReplayFilesService files = new ReplayFilesService(folders); /** * Whether the current MC version is supported by the embedded ReplayStudio version. @@ -116,58 +106,6 @@ public class ReplayMod implements Module, Scheduler { return settingsRegistry; } - public Path getReplayFolder() throws IOException { - String str = getSettingsRegistry().get(Setting.RECORDING_PATH); - return Files.createDirectories(getMinecraft().runDirectory.toPath().resolve(str)); - } - - /** - * Folder into which replay backups are saved before the MarkerProcessor is unleashed. - */ - public Path getRawReplayFolder() throws IOException { - return Files.createDirectories(getReplayFolder().resolve("raw")); - } - - /** - * Folder into which replays are recorded. - * Distinct from the main folder, so they cannot be opened while they are still saving. - */ - public Path getRecordingFolder() throws IOException { - return Files.createDirectories(getReplayFolder().resolve("recording")); - } - - /** - * Folder in which replay cache files are stored. - * Distinct from the recording folder cause people kept confusing them with recordings. - */ - public Path getCacheFolder() throws IOException { - String str = getSettingsRegistry().get(Setting.CACHE_PATH); - Path path = getMinecraft().runDirectory.toPath().resolve(str); - Files.createDirectories(path); - try { - Files.setAttribute(path, "dos:hidden", true); - } catch (UnsupportedOperationException ignored) { - } catch (Exception e) { - e.printStackTrace(); - } - return path; - } - - private static final PercentEscaper CACHE_FILE_NAME_ENCODER = new PercentEscaper("-_ ", false); - - public Path getCachePathForReplay(Path replay) throws IOException { - Path replayFolder = getReplayFolder(); - Path cacheFolder = getCacheFolder(); - Path relative = replayFolder.toAbsolutePath().relativize(replay.toAbsolutePath()); - return cacheFolder.resolve(CACHE_FILE_NAME_ENCODER.escape(relative.toString())); - } - - public Path getReplayPathForCache(Path cache) throws IOException { - String relative = URLDecoder.decode(cache.getFileName().toString(), "UTF-8"); - Path replayFolder = getReplayFolder(); - return replayFolder.resolve(relative); - } - public static final DirectoryResourcePack jGuiResourcePack = createJGuiResourcePack(); public static final String JGUI_RESOURCE_PACK_NAME = "replaymod_jgui"; private static DirectoryResourcePack createJGuiResourcePack() { @@ -230,114 +168,7 @@ public class ReplayMod implements Module, Scheduler { } //#endif - runPostStartup(() -> { - final long DAYS = 24 * 60 * 60 * 1000; - - // Cleanup any cache folders still remaining in the recording folder (we once used to put them there) - try { - Files.walkFileTree(getReplayFolder(), new SimpleFileVisitor() { - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { - String name = dir.getFileName().toString(); - if (name.endsWith(".mcpr.cache")) { - FileUtils.deleteDirectory(dir.toFile()); - return FileVisitResult.SKIP_SUBTREE; - } - return super.preVisitDirectory(dir, attrs); - } - }); - } catch (IOException e) { - e.printStackTrace(); - } - - // Cleanup raw folder content three weeks after creation (these are pretty valuable for debugging) - try (DirectoryStream paths = Files.newDirectoryStream(getRawReplayFolder())) { - for (Path path : paths) { - if (Files.getLastModifiedTime(path).toMillis() + 21 * DAYS < System.currentTimeMillis()) { - Files.delete(path); - } - } - } catch (IOException e) { - e.printStackTrace(); - } - - // Move anything which is still in the recording folder into the regular replay folder - // so it can be opened and/or recovered - try (DirectoryStream paths = Files.newDirectoryStream(getRecordingFolder())) { - for (Path path : paths) { - Path destination = getReplayFolder().resolve(path.getFileName()); - if (Files.exists(destination)) { - continue; // better play it save - } - Files.move(path, destination); - } - } catch (IOException e) { - e.printStackTrace(); - } - - // Cleanup cache folders 7 days after last modification or when its replay is gone - try (DirectoryStream paths = Files.newDirectoryStream(getCacheFolder())) { - for (Path path : paths) { - if (Files.isDirectory(path)) { - Path replay = getReplayPathForCache(path); - long lastModified = Files.getLastModifiedTime(path).toMillis(); - if (lastModified + 7 * DAYS < System.currentTimeMillis() || !Files.exists(replay)) { - FileUtils.deleteDirectory(path.toFile()); - } - } - } - } catch (IOException e) { - e.printStackTrace(); - } - - // Cleanup deleted corrupted replays - try (DirectoryStream paths = Files.newDirectoryStream(getReplayFolder())) { - for (Path path : paths) { - String name = path.getFileName().toString(); - if (name.endsWith(".mcpr.del") && Files.isDirectory(path)) { - long lastModified = Files.getLastModifiedTime(path).toMillis(); - if (lastModified + 2 * DAYS < System.currentTimeMillis()) { - FileUtils.deleteDirectory(path.toFile()); - } - } - } - } catch (IOException e) { - e.printStackTrace(); - } - - // Restore corrupted replays - try (DirectoryStream paths = Files.newDirectoryStream(getReplayFolder())) { - for (Path path : paths) { - String name = path.getFileName().toString(); - if (name.endsWith(".mcpr.tmp") && Files.isDirectory(path)) { - Path original = path.resolveSibling(FilenameUtils.getBaseName(name)); - Path noRecoverMarker = original.resolveSibling(original.getFileName() + ".no_recover"); - if (Files.exists(noRecoverMarker)) { - // This file, when its markers are processed, doesn't actually result in any replays. - // So we don't really need to recover it either, let's just get rid of it. - FileUtils.deleteDirectory(path.toFile()); - Files.delete(noRecoverMarker); - continue; - } - new RestoreReplayGui(this, GuiScreen.wrap(mc.currentScreen), original.toFile()).display(); - } - } - } catch (IOException e) { - e.printStackTrace(); - } - - // Cleanup leftover no_recover files - try (DirectoryStream paths = Files.newDirectoryStream(getReplayFolder())) { - for (Path path : paths) { - String name = path.getFileName().toString(); - if (name.endsWith(".no_recover")) { - Files.delete(path); - } - } - } catch (IOException e) { - e.printStackTrace(); - } - }); + runPostStartup(() -> files.initialScan(this)); } @Override @@ -437,17 +268,4 @@ public class ReplayMod implements Module, Scheduler { return new ReplayStudio().isCompatible(fileFormatVersion, protocolVersion, MCVer.getProtocolVersion()); } } - - public ReplayFile openReplay(Path path) throws IOException { - return openReplay(path, path); - } - - public ReplayFile openReplay(Path input, Path output) throws IOException { - return new ZipReplayFile( - new ReplayStudio(), - input != null ? input.toFile() : null, - output.toFile(), - getCachePathForReplay(output).toFile() - ); - } } diff --git a/src/main/java/com/replaymod/core/files/DelegatingReplayFile.java b/src/main/java/com/replaymod/core/files/DelegatingReplayFile.java new file mode 100644 index 00000000..26950f93 --- /dev/null +++ b/src/main/java/com/replaymod/core/files/DelegatingReplayFile.java @@ -0,0 +1,202 @@ +package com.replaymod.core.files; + +import com.replaymod.replaystudio.data.Marker; +import com.replaymod.replaystudio.data.ModInfo; +import com.replaymod.replaystudio.data.ReplayAssetEntry; +import com.replaymod.replaystudio.io.ReplayInputStream; +import com.replaymod.replaystudio.io.ReplayOutputStream; +import com.replaymod.replaystudio.lib.guava.base.Optional; +import com.replaymod.replaystudio.pathing.PathingRegistry; +import com.replaymod.replaystudio.pathing.path.Timeline; +import com.replaymod.replaystudio.protocol.PacketTypeRegistry; +import com.replaymod.replaystudio.replay.ReplayFile; +import com.replaymod.replaystudio.replay.ReplayMetaData; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +public class DelegatingReplayFile implements ReplayFile { + private final ReplayFile delegate; + + public DelegatingReplayFile(ReplayFile delegate) { + this.delegate = delegate; + } + + @Override + public Optional get(String entry) throws IOException { + return this.delegate.get(entry); + } + + @Override + public Optional getCache(String entry) throws IOException { + return this.delegate.getCache(entry); + } + + @Override + public Map getAll(Pattern pattern) throws IOException { + return this.delegate.getAll(pattern); + } + + @Override + public OutputStream write(String entry) throws IOException { + return this.delegate.write(entry); + } + + @Override + public OutputStream writeCache(String entry) throws IOException { + return this.delegate.writeCache(entry); + } + + @Override + public void remove(String entry) throws IOException { + this.delegate.remove(entry); + } + + @Override + public void removeCache(String entry) throws IOException { + this.delegate.removeCache(entry); + } + + @Override + public void save() throws IOException { + this.delegate.save(); + } + + @Override + public void saveTo(File target) throws IOException { + this.delegate.saveTo(target); + } + + @Override + public ReplayMetaData getMetaData() throws IOException { + return this.delegate.getMetaData(); + } + + @Override + public void writeMetaData(PacketTypeRegistry registry, ReplayMetaData metaData) throws IOException { + this.delegate.writeMetaData(registry, metaData); + } + + @Override + public ReplayInputStream getPacketData(PacketTypeRegistry registry) throws IOException { + return this.delegate.getPacketData(registry); + } + + @Override + public ReplayOutputStream writePacketData() throws IOException { + return this.delegate.writePacketData(); + } + + @Override + public Map getResourcePackIndex() throws IOException { + return this.delegate.getResourcePackIndex(); + } + + @Override + public void writeResourcePackIndex(Map index) throws IOException { + this.delegate.writeResourcePackIndex(index); + } + + @Override + public Optional getResourcePack(String hash) throws IOException { + return this.delegate.getResourcePack(hash); + } + + @Override + public OutputStream writeResourcePack(String hash) throws IOException { + return this.delegate.writeResourcePack(hash); + } + + @Override + public Map getTimelines(PathingRegistry pathingRegistry) throws IOException { + return this.delegate.getTimelines(pathingRegistry); + } + + @Override + public void writeTimelines(PathingRegistry pathingRegistry, Map timelines) throws IOException { + this.delegate.writeTimelines(pathingRegistry, timelines); + } + + @Override + public Optional getThumb() throws IOException { + return this.delegate.getThumb(); + } + + @Override + public void writeThumb(BufferedImage image) throws IOException { + this.delegate.writeThumb(image); + } + + @Override + public Optional getThumbBytes() throws IOException { + return this.delegate.getThumbBytes(); + } + + @Override + public void writeThumbBytes(byte[] image) throws IOException { + this.delegate.writeThumbBytes(image); + } + + @Override + public Optional> getInvisiblePlayers() throws IOException { + return this.delegate.getInvisiblePlayers(); + } + + @Override + public void writeInvisiblePlayers(Set uuids) throws IOException { + this.delegate.writeInvisiblePlayers(uuids); + } + + @Override + public Optional> getMarkers() throws IOException { + return this.delegate.getMarkers(); + } + + @Override + public void writeMarkers(Set markers) throws IOException { + this.delegate.writeMarkers(markers); + } + + @Override + public Collection getAssets() throws IOException { + return this.delegate.getAssets(); + } + + @Override + public Optional getAsset(UUID uuid) throws IOException { + return this.delegate.getAsset(uuid); + } + + @Override + public OutputStream writeAsset(ReplayAssetEntry asset) throws IOException { + return this.delegate.writeAsset(asset); + } + + @Override + public void removeAsset(UUID uuid) throws IOException { + this.delegate.removeAsset(uuid); + } + + @Override + public Collection getModInfo() throws IOException { + return this.delegate.getModInfo(); + } + + @Override + public void writeModInfo(Collection modInfo) throws IOException { + this.delegate.writeModInfo(modInfo); + } + + @Override + public void close() throws IOException { + this.delegate.close(); + } +} diff --git a/src/main/java/com/replaymod/core/files/ManagedReplayFile.java b/src/main/java/com/replaymod/core/files/ManagedReplayFile.java new file mode 100644 index 00000000..e000e354 --- /dev/null +++ b/src/main/java/com/replaymod/core/files/ManagedReplayFile.java @@ -0,0 +1,23 @@ +package com.replaymod.core.files; + +import com.replaymod.replaystudio.replay.ReplayFile; + +import java.io.IOException; + +public class ManagedReplayFile extends DelegatingReplayFile { + private Runnable onClose; + + public ManagedReplayFile(ReplayFile delegate, Runnable onClose) { + super(delegate); + + this.onClose = onClose; + } + + @Override + public void close() throws IOException { + super.close(); + + onClose.run(); + onClose = () -> {}; + } +} diff --git a/src/main/java/com/replaymod/core/files/ReplayFilesService.java b/src/main/java/com/replaymod/core/files/ReplayFilesService.java new file mode 100644 index 00000000..9090f4b5 --- /dev/null +++ b/src/main/java/com/replaymod/core/files/ReplayFilesService.java @@ -0,0 +1,192 @@ +package com.replaymod.core.files; + +import com.replaymod.core.ReplayMod; +import com.replaymod.core.gui.RestoreReplayGui; +import com.replaymod.replaystudio.replay.ReplayFile; +import com.replaymod.replaystudio.replay.ZipReplayFile; +import com.replaymod.replaystudio.studio.ReplayStudio; +import de.johni0702.minecraft.gui.container.GuiScreen; +import net.minecraft.util.Util; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public class ReplayFilesService { + private final ReplayFoldersService folders; + private final Set lockedPaths = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + public ReplayFilesService(ReplayFoldersService folders) { + this.folders = folders; + } + + public ReplayFile open(Path path) throws IOException { + return open(path, path); + } + + public ReplayFile open(Path input, Path output) throws IOException { + Path realInput = input != null ? input.toAbsolutePath().normalize() : null; + Path realOutput = output.toAbsolutePath().normalize(); + + if (realInput != null && !lockedPaths.add(realInput)) { + throw new FileLockedException(realInput); + } + if (!Objects.equals(realInput, realOutput) && !lockedPaths.add(realOutput)) { + if (realInput != null) { + lockedPaths.remove(realInput); + } + throw new FileLockedException(realOutput); + } + + Runnable onClose = () -> { + if (realInput != null) { + lockedPaths.remove(realInput); + } + lockedPaths.remove(realOutput); + }; + + ReplayFile replayFile; + try { + replayFile = new ZipReplayFile( + new ReplayStudio(), + realInput != null ? realInput.toFile() : null, + realOutput.toFile(), + folders.getCachePathForReplay(realOutput).toFile() + ); + } catch (IOException e) { + onClose.run(); + throw e; + } + return new ManagedReplayFile(replayFile, onClose); + } + + public void initialScan(ReplayMod core) { + // Move anything which is still in the recording folder into the regular replay folder + // so it can be opened and/or recovered + try (DirectoryStream paths = Files.newDirectoryStream(folders.getRecordingFolder())) { + for (Path path : paths) { + Path destination = folders.getReplayFolder().resolve(path.getFileName()); + if (Files.exists(destination)) { + continue; // better play it save + } + Files.move(path, destination); + } + } catch (IOException e) { + e.printStackTrace(); + } + + // Restore corrupted replays + try (DirectoryStream paths = Files.newDirectoryStream(folders.getReplayFolder())) { + for (Path path : paths) { + String name = path.getFileName().toString(); + if (name.endsWith(".mcpr.tmp") && Files.isDirectory(path)) { + Path original = path.resolveSibling(FilenameUtils.getBaseName(name)); + Path noRecoverMarker = original.resolveSibling(original.getFileName() + ".no_recover"); + if (Files.exists(noRecoverMarker)) { + // This file, when its markers are processed, doesn't actually result in any replays. + // So we don't really need to recover it either, let's just get rid of it. + FileUtils.deleteDirectory(path.toFile()); + Files.delete(noRecoverMarker); + continue; + } + new RestoreReplayGui(core, GuiScreen.wrap(core.getMinecraft().currentScreen), original.toFile()).display(); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + + // Run general purpose, non-essential cleanup in a background thread + new Thread(this::cleanup, "replaymod-cleanup").start(); + } + + private void cleanup() { + final long DAYS = 24 * 60 * 60 * 1000; + + // Cleanup any cache folders still remaining in the recording folder (we once used to put them there) + try { + Files.walkFileTree(folders.getReplayFolder(), new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + String name = dir.getFileName().toString(); + if (name.endsWith(".mcpr.cache")) { + FileUtils.deleteDirectory(dir.toFile()); + return FileVisitResult.SKIP_SUBTREE; + } + return super.preVisitDirectory(dir, attrs); + } + }); + } catch (IOException e) { + e.printStackTrace(); + } + + // Cleanup raw folder content three weeks after creation (these are pretty valuable for debugging) + try (DirectoryStream paths = Files.newDirectoryStream(folders.getRawReplayFolder())) { + for (Path path : paths) { + if (Files.getLastModifiedTime(path).toMillis() + 21 * DAYS < System.currentTimeMillis()) { + Files.delete(path); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + + // Cleanup cache folders 7 days after last modification or when its replay is gone + try (DirectoryStream paths = Files.newDirectoryStream(folders.getCacheFolder())) { + for (Path path : paths) { + if (Files.isDirectory(path)) { + Path replay = folders.getReplayPathForCache(path); + long lastModified = Files.getLastModifiedTime(path).toMillis(); + if (lastModified + 7 * DAYS < System.currentTimeMillis() || !Files.exists(replay)) { + FileUtils.deleteDirectory(path.toFile()); + } + } + } + } catch (IOException e) { + e.printStackTrace(); + } + + // Cleanup deleted corrupted replays + try (DirectoryStream paths = Files.newDirectoryStream(folders.getReplayFolder())) { + for (Path path : paths) { + String name = path.getFileName().toString(); + if (name.endsWith(".mcpr.del") && Files.isDirectory(path)) { + long lastModified = Files.getLastModifiedTime(path).toMillis(); + if (lastModified + 2 * DAYS < System.currentTimeMillis()) { + FileUtils.deleteDirectory(path.toFile()); + } + } + } + } catch (IOException e) { + e.printStackTrace(); + } + + // Cleanup leftover no_recover files + try (DirectoryStream paths = Files.newDirectoryStream(folders.getReplayFolder())) { + for (Path path : paths) { + String name = path.getFileName().toString(); + if (name.endsWith(".no_recover")) { + Files.delete(path); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static class FileLockedException extends IOException { + public FileLockedException(Path path) { + super(path.toString()); + } + } +} diff --git a/src/main/java/com/replaymod/core/files/ReplayFoldersService.java b/src/main/java/com/replaymod/core/files/ReplayFoldersService.java new file mode 100644 index 00000000..6587704d --- /dev/null +++ b/src/main/java/com/replaymod/core/files/ReplayFoldersService.java @@ -0,0 +1,69 @@ +package com.replaymod.core.files; + +import com.google.common.net.PercentEscaper; +import com.replaymod.core.Setting; +import com.replaymod.core.SettingsRegistry; +import net.minecraft.client.MinecraftClient; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.file.Files; +import java.nio.file.Path; + +public class ReplayFoldersService { + private final Path mcDir = MinecraftClient.getInstance().runDirectory.toPath(); + private final SettingsRegistry settings; + + public ReplayFoldersService(SettingsRegistry settings) { + this.settings = settings; + } + + public Path getReplayFolder() throws IOException { + return Files.createDirectories(mcDir.resolve(settings.get(Setting.RECORDING_PATH))); + } + + /** + * Folder into which replay backups are saved before the MarkerProcessor is unleashed. + */ + public Path getRawReplayFolder() throws IOException { + return Files.createDirectories(getReplayFolder().resolve("raw")); + } + + /** + * Folder into which replays are recorded. + * Distinct from the main folder, so they cannot be opened while they are still saving. + */ + public Path getRecordingFolder() throws IOException { + return Files.createDirectories(getReplayFolder().resolve("recording")); + } + + /** + * Folder in which replay cache files are stored. + * Distinct from the recording folder cause people kept confusing them with recordings. + */ + public Path getCacheFolder() throws IOException { + Path path = Files.createDirectories(mcDir.resolve(settings.get(Setting.CACHE_PATH))); + try { + Files.setAttribute(path, "dos:hidden", true); + } catch (UnsupportedOperationException ignored) { + } catch (Exception e) { + e.printStackTrace(); + } + return path; + } + + private static final PercentEscaper CACHE_FILE_NAME_ENCODER = new PercentEscaper("-_ ", false); + + public Path getCachePathForReplay(Path replay) throws IOException { + Path replayFolder = getReplayFolder(); + Path cacheFolder = getCacheFolder(); + Path relative = replayFolder.toAbsolutePath().relativize(replay.toAbsolutePath()); + return cacheFolder.resolve(CACHE_FILE_NAME_ENCODER.escape(relative.toString())); + } + + public Path getReplayPathForCache(Path cache) throws IOException { + String relative = URLDecoder.decode(cache.getFileName().toString(), "UTF-8"); + Path replayFolder = getReplayFolder(); + return replayFolder.resolve(relative); + } +} diff --git a/src/main/java/com/replaymod/core/gui/RestoreReplayGui.java b/src/main/java/com/replaymod/core/gui/RestoreReplayGui.java index 4d9ba137..3a9f13c0 100644 --- a/src/main/java/com/replaymod/core/gui/RestoreReplayGui.java +++ b/src/main/java/com/replaymod/core/gui/RestoreReplayGui.java @@ -108,7 +108,7 @@ public class RestoreReplayGui extends AbstractGuiScreen { } private void tryRecover(Consumer progress) throws IOException { - ReplayFile replayFile = ReplayMod.instance.openReplay(file.toPath()); + ReplayFile replayFile = ReplayMod.instance.files.open(file.toPath()); // Commit all not-yet-committed files into the main zip file. // If we don't do this, then re-writing packet data below can actually overwrite uncommitted packet data! replayFile.save(); diff --git a/src/main/java/com/replaymod/core/mixin/MinecraftAccessor.java b/src/main/java/com/replaymod/core/mixin/MinecraftAccessor.java index 8f26e4f5..5e09f787 100644 --- a/src/main/java/com/replaymod/core/mixin/MinecraftAccessor.java +++ b/src/main/java/com/replaymod/core/mixin/MinecraftAccessor.java @@ -10,6 +10,10 @@ import org.spongepowered.asm.mixin.gen.Accessor; import java.util.Queue; +//#if MC>=11800 +//$$ import java.util.function.Supplier; +//#endif + //#if MC>=11400 import java.util.concurrent.CompletableFuture; //#endif @@ -49,7 +53,11 @@ public interface MinecraftAccessor { //#endif @Accessor("crashReport") + //#if MC>=11800 + //$$ Supplier getCrashReporter(); + //#else CrashReport getCrashReporter(); + //#endif //#if MC<11400 //$$ @Accessor diff --git a/src/main/java/com/replaymod/core/versions/MCVer.java b/src/main/java/com/replaymod/core/versions/MCVer.java index ac4dd754..cb7c7f00 100644 --- a/src/main/java/com/replaymod/core/versions/MCVer.java +++ b/src/main/java/com/replaymod/core/versions/MCVer.java @@ -13,6 +13,12 @@ import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.render.BufferBuilder; import net.minecraft.util.Identifier; import net.minecraft.util.Util; +import net.minecraft.util.math.Vec3d; + +//#if MC>=11604 +//#else +//$$ import net.minecraft.entity.Entity; +//#endif //#if MC>=11600 import net.minecraft.resource.ResourcePackSource; @@ -55,7 +61,6 @@ import org.lwjgl.glfw.GLFW; //#if MC>=10904 import com.replaymod.render.blend.mixin.ParticleAccessor; import net.minecraft.client.particle.Particle; -import net.minecraft.util.math.Vec3d; //#endif //#if MC>=10800 @@ -297,6 +302,12 @@ public class MCVer { } //#endif + //#if MC<=11601 + //$$ public static Vec3d getTrackedPosition(Entity entity) { + //$$ return new Vec3d(entity.trackedX, entity.trackedY, entity.trackedZ); + //$$ } + //#endif + public static void openFile(File file) { //#if MC>=11400 Util.getOperatingSystem().open(file); diff --git a/src/main/java/com/replaymod/core/versions/Patterns.java b/src/main/java/com/replaymod/core/versions/Patterns.java index f7dee207..884eb955 100644 --- a/src/main/java/com/replaymod/core/versions/Patterns.java +++ b/src/main/java/com/replaymod/core/versions/Patterns.java @@ -1,5 +1,9 @@ package com.replaymod.core.versions; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.replaymod.core.mixin.MinecraftAccessor; import com.replaymod.gradle.remap.Pattern; import net.minecraft.client.MinecraftClient; import net.minecraft.client.options.KeyBinding; @@ -10,11 +14,14 @@ import net.minecraft.client.render.Tessellator; import net.minecraft.client.render.entity.EntityRenderDispatcher; import net.minecraft.client.sound.PositionedSoundInstance; import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.util.crash.CrashException; +import net.minecraft.util.crash.CrashReport; import net.minecraft.util.crash.CrashReportSection; import net.minecraft.entity.Entity; import net.minecraft.entity.player.PlayerEntity; import net.minecraft.network.PacketByteBuf; import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; import net.minecraft.world.chunk.WorldChunk; @@ -23,6 +30,12 @@ import net.minecraft.world.chunk.WorldChunk; import org.lwjgl.opengl.GL11; //#endif +//#if MC>=11600 +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.util.math.Matrix4f; +//#else +//#endif + //#if MC>=11400 import net.minecraft.client.gui.widget.AbstractButtonWidget; import net.minecraft.client.util.Window; @@ -509,4 +522,55 @@ class Patterns { GL11.glRotatef(angle, x, y, z); //#endif } + + // FIXME preprocessor bug: there are mappings for this, not sure why it doesn't remap by itself + //#if MC>=11600 + @Pattern + private static Matrix4f getPositionMatrix(MatrixStack.Entry stack) { + //#if MC>=11800 + //$$ return stack.getPositionMatrix(); + //#else + return stack.getModel(); + //#endif + } + //#else + //$$ private static void getPositionMatrix() {} + //#endif + + @SuppressWarnings("rawtypes") // preprocessor bug: doesn't work with generics + @Pattern + private static void Futures_addCallback(ListenableFuture future, FutureCallback callback) { + //#if MC>=11800 + //$$ Futures.addCallback(future, callback, Runnable::run); + //#else + Futures.addCallback(future, callback); + //#endif + } + + @Pattern + private static void setCrashReport(MinecraftClient mc, CrashReport report) { + //#if MC>=11800 + //$$ mc.setCrashReportSupplier(() -> report); + //#else + mc.setCrashReport(report); + //#endif + } + + @Pattern + private static CrashException crashReportToException(MinecraftClient mc) { + //#if MC>=11800 + //$$ return new CrashException(((MinecraftAccessor) mc).getCrashReporter().get()); + //#else + return new CrashException(((MinecraftAccessor) mc).getCrashReporter()); + //#endif + } + + @Pattern + private static Vec3d getTrackedPosition(Entity entity) { + //#if MC>=11604 + return entity.getTrackedPosition(); + //#else + //$$ return com.replaymod.core.versions.MCVer.getTrackedPosition(entity); + //#endif + } } diff --git a/src/main/java/com/replaymod/editor/gui/GuiEditReplay.java b/src/main/java/com/replaymod/editor/gui/GuiEditReplay.java index 1ee86995..205d4059 100644 --- a/src/main/java/com/replaymod/editor/gui/GuiEditReplay.java +++ b/src/main/java/com/replaymod/editor/gui/GuiEditReplay.java @@ -60,7 +60,7 @@ public class GuiEditReplay extends AbstractGuiPopup { super(container); this.inputPath = inputPath; - try (ReplayFile replayFile = ReplayMod.instance.openReplay(inputPath)) { + try (ReplayFile replayFile = ReplayMod.instance.files.open(inputPath)) { markers = replayFile.getMarkers().or(HashSet::new); timeline = new EditTimeline(new HashSet<>(markers), markers -> this.markers = markers); timeline.setSize(300, 20) @@ -147,7 +147,7 @@ public class GuiEditReplay extends AbstractGuiPopup { ProgressPopup progressPopup = new ProgressPopup(this); new Thread(() -> { - try (ReplayFile replayFile = ReplayMod.instance.openReplay(inputPath)) { + try (ReplayFile replayFile = ReplayMod.instance.files.open(inputPath)) { replayFile.writeMarkers(markers); replayFile.save(); } catch (IOException e) { diff --git a/src/main/java/com/replaymod/editor/gui/MarkerProcessor.java b/src/main/java/com/replaymod/editor/gui/MarkerProcessor.java index e3c40d5a..0bce65f3 100644 --- a/src/main/java/com/replaymod/editor/gui/MarkerProcessor.java +++ b/src/main/java/com/replaymod/editor/gui/MarkerProcessor.java @@ -4,6 +4,7 @@ import com.replaymod.core.ReplayMod; import com.replaymod.core.versions.MCVer; import com.replaymod.replaystudio.PacketData; import com.replaymod.replaystudio.data.Marker; +import com.replaymod.replaystudio.filter.DimensionTracker; import com.replaymod.replaystudio.filter.SquashFilter; import com.replaymod.replaystudio.filter.StreamFilter; import com.replaymod.replaystudio.io.ReplayInputStream; @@ -48,7 +49,7 @@ public class MarkerProcessor { public static final String MARKER_NAME_SPLIT = "_RM_SPLIT"; private static boolean hasWork(Path path) throws IOException { - try (ReplayFile inputReplayFile = ReplayMod.instance.openReplay(path)) { + try (ReplayFile inputReplayFile = ReplayMod.instance.files.open(path)) { return inputReplayFile.getMarkers().or(HashSet::new).stream().anyMatch(m -> m.getName() != null && m.getName().startsWith("_RM_")); } } @@ -112,7 +113,7 @@ public class MarkerProcessor { ReplayMod mod = ReplayMod.instance; if (!hasWork(path)) { ReplayMetaData metaData; - try (ReplayFile inputReplayFile = mod.openReplay(path)) { + try (ReplayFile inputReplayFile = mod.files.open(path)) { metaData = inputReplayFile.getMetaData(); } return Collections.singletonList(Pair.of(path, metaData)); @@ -122,11 +123,12 @@ public class MarkerProcessor { int splitCounter = 0; PacketTypeRegistry registry = MCVer.getPacketTypeRegistry(true); - SquashFilter squashFilter = new SquashFilter(); + DimensionTracker dimensionTracker = new DimensionTracker(); + SquashFilter squashFilter = new SquashFilter(null, null); List> outputPaths = new ArrayList<>(); - Path rawFolder = ReplayMod.instance.getRawReplayFolder(); + Path rawFolder = ReplayMod.instance.folders.getRawReplayFolder(); Path inputPath = rawFolder.resolve(path.getFileName()); for (int i = 1; Files.exists(inputPath); i++) { inputPath = inputPath.resolveSibling(replayName + "." + i + ".mcpr"); @@ -134,7 +136,7 @@ public class MarkerProcessor { Files.createDirectories(inputPath.getParent()); Files.move(path, inputPath); - try (ReplayFile inputReplayFile = mod.openReplay(inputPath)) { + try (ReplayFile inputReplayFile = mod.files.open(inputPath)) { List markers = inputReplayFile.getMarkers().or(HashSet::new) .stream().sorted(Comparator.comparing(Marker::getTime)).collect(Collectors.toList()); Iterator markerIterator = markers.iterator(); @@ -151,7 +153,7 @@ public class MarkerProcessor { while (nextPacket != null && outputFileSuffixes.hasNext()) { Path outputPath = path.resolveSibling(replayName + outputFileSuffixes.next() + ".mcpr"); - try (ReplayFile outputReplayFile = mod.openReplay(null, outputPath)) { + try (ReplayFile outputReplayFile = mod.files.open(null, outputPath)) { long duration = 0; Set outputMarkers = new HashSet<>(); ReplayMetaData metaData = inputReplayFile.getMetaData(); @@ -180,7 +182,7 @@ public class MarkerProcessor { cutFilter.release(); } startCutOffset = nextMarker.getTime(); - cutFilter = new SquashFilter(); + cutFilter = new SquashFilter(dimensionTracker); } else if (MARKER_NAME_END_CUT.equals(nextMarker.getName())) { timeOffset += nextMarker.getTime() - startCutOffset; if (cutFilter != null) { @@ -208,6 +210,7 @@ public class MarkerProcessor { continue; } + dimensionTracker.onPacket(null, nextPacket); if (hasFurtherOutputs) { squashFilter.onPacket(null, nextPacket); } diff --git a/src/main/java/com/replaymod/extras/playeroverview/PlayerOverview.java b/src/main/java/com/replaymod/extras/playeroverview/PlayerOverview.java index d5ecb0be..14045dff 100644 --- a/src/main/java/com/replaymod/extras/playeroverview/PlayerOverview.java +++ b/src/main/java/com/replaymod/extras/playeroverview/PlayerOverview.java @@ -1,6 +1,5 @@ package com.replaymod.extras.playeroverview; -import com.google.common.base.Optional; import com.replaymod.core.ReplayMod; import com.replaymod.core.events.PreRenderHandCallback; import com.replaymod.core.utils.Utils; @@ -10,6 +9,7 @@ import com.replaymod.replay.ReplayModReplay; import com.replaymod.replay.camera.CameraEntity; import com.replaymod.replay.events.ReplayClosedCallback; import com.replaymod.replay.events.ReplayOpenedCallback; +import com.replaymod.replaystudio.lib.guava.base.Optional; import de.johni0702.minecraft.gui.utils.EventRegistrations; import net.minecraft.entity.Entity; import net.minecraft.entity.player.PlayerEntity; diff --git a/src/main/java/com/replaymod/pathing/player/RealtimeTimelinePlayer.java b/src/main/java/com/replaymod/pathing/player/RealtimeTimelinePlayer.java index af53c5b8..cde6c3e9 100644 --- a/src/main/java/com/replaymod/pathing/player/RealtimeTimelinePlayer.java +++ b/src/main/java/com/replaymod/pathing/player/RealtimeTimelinePlayer.java @@ -67,7 +67,7 @@ public class RealtimeTimelinePlayer extends AbstractTimelinePlayer { @Override public long getTimePassed() { - if (firstFrame) return 0; + if (firstFrame) return startOffset; if (loadingResources) return timeBeforeResourceLoading; return System.currentTimeMillis() - startTime; } diff --git a/src/main/java/com/replaymod/recording/gui/GuiRecordingControls.java b/src/main/java/com/replaymod/recording/gui/GuiRecordingControls.java index e766f0a5..708daee9 100644 --- a/src/main/java/com/replaymod/recording/gui/GuiRecordingControls.java +++ b/src/main/java/com/replaymod/recording/gui/GuiRecordingControls.java @@ -87,6 +87,9 @@ public class GuiRecordingControls extends EventRegistrations { if (!(guiScreen instanceof GameMenuScreen)) { return; } + if (buttonList.isEmpty()) { + return; // menu-less pause (F3+Esc) + } Function yPos = MCVer.findButton(buttonList, "menu.returnToMenu", 1) .map(Optional::of) diff --git a/src/main/java/com/replaymod/recording/gui/GuiSavingReplay.java b/src/main/java/com/replaymod/recording/gui/GuiSavingReplay.java index 5ca75e7e..6fb66db9 100644 --- a/src/main/java/com/replaymod/recording/gui/GuiSavingReplay.java +++ b/src/main/java/com/replaymod/recording/gui/GuiSavingReplay.java @@ -154,7 +154,7 @@ public class GuiSavingReplay { } try { - Path replaysFolder = core.getReplayFolder(); + Path replaysFolder = core.folders.getReplayFolder(); Path newPath = replaysFolder.resolve(Utils.replayNameToFileName(newName)); for (int i = 1; Files.exists(newPath); i++) { newPath = replaysFolder.resolve(Utils.replayNameToFileName(newName + " (" + i + ")")); diff --git a/src/main/java/com/replaymod/recording/handler/ConnectionEventHandler.java b/src/main/java/com/replaymod/recording/handler/ConnectionEventHandler.java index 0e90c7eb..60a43a50 100644 --- a/src/main/java/com/replaymod/recording/handler/ConnectionEventHandler.java +++ b/src/main/java/com/replaymod/recording/handler/ConnectionEventHandler.java @@ -127,8 +127,8 @@ public class ConnectionEventHandler { } String name = sdf.format(Calendar.getInstance().getTime()); - Path outputPath = core.getRecordingFolder().resolve(Utils.replayNameToFileName(name)); - ReplayFile replayFile = core.openReplay(outputPath); + Path outputPath = core.folders.getRecordingFolder().resolve(Utils.replayNameToFileName(name)); + ReplayFile replayFile = core.files.open(outputPath); replayFile.writeModInfo(ModCompat.getInstalledNetworkMods()); diff --git a/src/main/java/com/replaymod/recording/handler/RecordingEventHandler.java b/src/main/java/com/replaymod/recording/handler/RecordingEventHandler.java index 2a7cee0f..4ccc2d9e 100644 --- a/src/main/java/com/replaymod/recording/handler/RecordingEventHandler.java +++ b/src/main/java/com/replaymod/recording/handler/RecordingEventHandler.java @@ -41,12 +41,10 @@ import java.util.Collections; //#endif //#if MC>=10904 -import com.replaymod.recording.mixin.EntityLivingBaseAccessor; import net.minecraft.network.packet.s2c.play.EntityTrackerUpdateS2CPacket; import net.minecraft.network.packet.s2c.play.PlaySoundS2CPacket; import net.minecraft.network.packet.s2c.play.WorldEventS2CPacket; import net.minecraft.entity.EquipmentSlot; -import net.minecraft.entity.data.DataTracker; import net.minecraft.util.Hand; import net.minecraft.sound.SoundCategory; import net.minecraft.sound.SoundEvent; @@ -73,10 +71,6 @@ public class RecordingEventHandler extends EventRegistrations { private boolean wasSleeping; private int lastRiding = -1; private Integer rotationYawHeadBefore; - //#if MC>=10904 - private boolean wasHandActive; - private Hand lastActiveHand; - //#endif public RecordingEventHandler(PacketListener packetListener) { this.packetListener = packetListener; @@ -335,18 +329,6 @@ public class RecordingEventHandler extends EventRegistrations { wasSleeping = false; } - //#if MC>=10904 - // Active hand (e.g. eating, drinking, blocking) - if (player.isUsingItem() ^ wasHandActive || player.getActiveHand() != lastActiveHand) { - wasHandActive = player.isUsingItem(); - lastActiveHand = player.getActiveHand(); - DataTracker dataManager = new DataTracker(null); - int state = (wasHandActive ? 1 : 0) | (lastActiveHand == Hand.OFF_HAND ? 2 : 0); - dataManager.startTracking(EntityLivingBaseAccessor.getLivingFlags(), (byte) state); - packetListener.save(new EntityTrackerUpdateS2CPacket(player.getEntityId(), dataManager, true)); - } - //#endif - } catch(Exception e1) { e1.printStackTrace(); } diff --git a/src/main/java/com/replaymod/recording/packet/PacketListener.java b/src/main/java/com/replaymod/recording/packet/PacketListener.java index 1771395a..e37fefad 100644 --- a/src/main/java/com/replaymod/recording/packet/PacketListener.java +++ b/src/main/java/com/replaymod/recording/packet/PacketListener.java @@ -280,7 +280,7 @@ public class PacketListener extends ChannelInboundHandlerAdapter { // We still have the replay, so we just save it (at least for a few weeks) in case they change their mind String replayName = FilenameUtils.getBaseName(outputPath.getFileName().toString()); - Path rawFolder = ReplayMod.instance.getRawReplayFolder(); + Path rawFolder = ReplayMod.instance.folders.getRawReplayFolder(); Path rawPath = rawFolder.resolve(outputPath.getFileName()); for (int i = 1; Files.exists(rawPath); i++) { rawPath = rawPath.resolveSibling(replayName + "." + i + ".mcpr"); diff --git a/src/main/java/com/replaymod/render/blend/Util.java b/src/main/java/com/replaymod/render/blend/Util.java index c43e695a..78138995 100644 --- a/src/main/java/com/replaymod/render/blend/Util.java +++ b/src/main/java/com/replaymod/render/blend/Util.java @@ -183,12 +183,16 @@ public class Util { } public static String getTileEntityId(BlockEntity tileEntity) { + //#if MC>=11800 + //$$ NbtCompound nbt = tileEntity.createNbt(); + //#else CompoundTag nbt = new CompoundTag(); //#if MC>=11400 tileEntity.toTag(nbt); //#else //$$ tileEntity.writeToNBT(nbt); //#endif + //#endif return nbt.getString("id"); } diff --git a/src/main/java/com/replaymod/render/gui/GuiRenderQueue.java b/src/main/java/com/replaymod/render/gui/GuiRenderQueue.java index a019b565..db37e673 100644 --- a/src/main/java/com/replaymod/render/gui/GuiRenderQueue.java +++ b/src/main/java/com/replaymod/render/gui/GuiRenderQueue.java @@ -228,7 +228,7 @@ public class GuiRenderQueue extends AbstractGuiPopup implements ReplayHandler replayHandler; ReplayFile replayFile = null; try { - replayFile = mod.getCore().openReplay(next.getKey().toPath()); + replayFile = mod.getCore().files.open(next.getKey().toPath()); replayHandler = mod.startReplay(replayFile, true, false); } catch (IOException e) { Utils.error(LOGGER, container, CrashReport.create(e, "Opening replay"), () -> {}); diff --git a/src/main/java/com/replaymod/render/mixin/ChunkInfoAccessor.java b/src/main/java/com/replaymod/render/mixin/ChunkInfoAccessor.java new file mode 100644 index 00000000..03235945 --- /dev/null +++ b/src/main/java/com/replaymod/render/mixin/ChunkInfoAccessor.java @@ -0,0 +1 @@ +// 1.18+ diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_BlockOnChunkRebuilds.java b/src/main/java/com/replaymod/render/mixin/Mixin_BlockOnChunkRebuilds.java index a11818c4..fdbdd826 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_BlockOnChunkRebuilds.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_BlockOnChunkRebuilds.java @@ -24,7 +24,20 @@ import java.util.concurrent.locks.ReentrantLock; public abstract class Mixin_BlockOnChunkRebuilds implements ForceChunkLoadingHook.IBlockOnChunkRebuilds { @Shadow @Final private Queue threadBuffers; + //#if MC>=11800 + //$$ @org.spongepowered.asm.mixin.Unique + //$$ private boolean upload() { + //$$ boolean anything = false; + //$$ Runnable runnable; + //$$ while ((runnable = this.uploadQueue.poll()) != null) { + //$$ runnable.run(); + //$$ anything = true; + //$$ } + //$$ return anything; + //$$ } + //#else @Shadow public abstract boolean upload(); + //#endif @Shadow @Final private TaskExecutor mailbox; diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyColorSky.java b/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyColorSky.java index b43d7bd7..bb60ab4e 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyColorSky.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyColorSky.java @@ -20,7 +20,11 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; public abstract class Mixin_ChromaKeyColorSky { @Shadow @Final private MinecraftClient client; - //#if MC>=11400 || 10710>=MC + //#if MC>=11800 + //$$ @Inject(method = "renderSky(Lnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/util/math/Matrix4f;FLjava/lang/Runnable;)V", + //$$ at = @At(value = "INVOKE", target = "Ljava/lang/Runnable;run()V", remap = false, shift = At.Shift.AFTER), + //$$ cancellable = true) + //#elseif MC>=11400 || 10710>=MC @Inject(method = "renderSky", at = @At("HEAD"), cancellable = true) //#else //$$ @Inject(method = "renderSky(FI)V", at = @At("HEAD"), cancellable = true) diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyForceSky.java b/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyForceSky.java index 1d22d82a..d619f02c 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyForceSky.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyForceSky.java @@ -29,12 +29,10 @@ public abstract class Mixin_ChromaKeyForceSky { //#if MC>=11500 @ModifyConstant(method = "render", constant = @Constant(intValue = 4)) - //#else - //#if MC>=11400 + //#elseif MC>=11400 //$$ @ModifyConstant(method = "renderCenter", constant = @Constant(intValue = 4)) //#else - //$$ @ModifyConstant(method = "updateCameraAndRender(FJ)V", constant = @Constant(intValue = 4)) - //#endif + //$$ @ModifyConstant(method = "renderWorldPass", constant = @Constant(intValue = 4)) //#endif private int forceSkyWhenChromaKeying(int value) { EntityRendererHandler handler = ((EntityRendererHandler.IEntityRenderer) this.client.gameRenderer).replayModRender_getHandler(); diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_PreserveDepthDuringHandRendering.java b/src/main/java/com/replaymod/render/mixin/Mixin_PreserveDepthDuringHandRendering.java index 5edd5e29..fb7e3877 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_PreserveDepthDuringHandRendering.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_PreserveDepthDuringHandRendering.java @@ -10,7 +10,12 @@ import org.spongepowered.asm.mixin.injection.ModifyArg; @Mixin(GameRenderer.class) public abstract class Mixin_PreserveDepthDuringHandRendering { @ModifyArg( + // FIXME preprocessor bug: 1.8.9 uses method with `(FJ)V` when just name would be enough + //#if MC>=10809 method = "renderWorld", + //#else + //$$ method = "updateCameraAndRender(F)V", + //#endif at = @At(value = "INVOKE", target = "Lcom/mojang/blaze3d/systems/RenderSystem;clear(IZ)V"), index = 0 ) diff --git a/src/main/java/com/replaymod/render/utils/RenderJob.java b/src/main/java/com/replaymod/render/utils/RenderJob.java index 5203d254..3554930f 100644 --- a/src/main/java/com/replaymod/render/utils/RenderJob.java +++ b/src/main/java/com/replaymod/render/utils/RenderJob.java @@ -1,12 +1,12 @@ package com.replaymod.render.utils; -import com.google.common.base.Optional; import com.google.gson.GsonBuilder; import com.google.gson.TypeAdapter; import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import com.replaymod.render.RenderSettings; +import com.replaymod.replaystudio.lib.guava.base.Optional; import com.replaymod.replaystudio.pathing.PathingRegistry; import com.replaymod.replaystudio.pathing.path.Timeline; import com.replaymod.replaystudio.pathing.serialize.TimelineSerialization; @@ -81,10 +81,14 @@ public class RenderJob { } try (InputStream in = optIn.get(); InputStreamReader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { - return new GsonBuilder() + List jobs = new GsonBuilder() .registerTypeAdapter(Timeline.class, new TimelineTypeAdapter()) .create() .fromJson(reader, new TypeToken>(){}.getType()); + if (jobs == null) { + jobs = new ArrayList<>(); + } + return jobs; } } } diff --git a/src/main/java/com/replaymod/replay/FullReplaySender.java b/src/main/java/com/replaymod/replay/FullReplaySender.java index c4cc978e..95b944a5 100644 --- a/src/main/java/com/replaymod/replay/FullReplaySender.java +++ b/src/main/java/com/replaymod/replay/FullReplaySender.java @@ -55,6 +55,7 @@ import net.minecraft.network.packet.s2c.play.SignEditorOpenS2CPacket; import net.minecraft.network.packet.s2c.play.StatisticsS2CPacket; import net.minecraft.text.Text; import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; @@ -453,8 +454,14 @@ public class FullReplaySender extends ChannelDuplexHandler implements ReplaySend //#if MC>=11400 if (p instanceof ChunkDataS2CPacket) { Runnable doLightUpdates = () -> { - if (mc.world != null) { - LightingProvider provider = mc.world.getChunkManager().getLightingProvider(); + ClientWorld world = mc.world; + if (world != null) { + //#if MC>=11800 + //$$ while (!world.hasNoChunkUpdaters()) { + //$$ world.runQueuedChunkUpdates(); + //$$ } + //#endif + LightingProvider provider = world.getChunkManager().getLightingProvider(); while (provider.hasUpdates()) { provider.doLightUpdates(Integer.MAX_VALUE, true, true); } @@ -604,14 +611,19 @@ public class FullReplaySender extends ChannelDuplexHandler implements ReplaySend //#if MC>=11400 p = new GameJoinS2CPacket( entId, + //#if MC>=11800 + //$$ packet.hardcore(), + //#endif GameMode.SPECTATOR, //#if MC>=11600 GameMode.SPECTATOR, //#endif + //#if MC<11800 //#if MC>=11500 packet.getSha256Seed(), //#endif false, + //#endif //#if MC>=11600 //#if MC>=11603 packet.getDimensionIds(), @@ -626,11 +638,17 @@ public class FullReplaySender extends ChannelDuplexHandler implements ReplaySend //#else //$$ packet.getDimension(), //#endif + //#if MC>=11800 + //$$ packet.sha256Seed(), + //#endif 0, // max players (has no getter -> never actually used) //#if MC<11600 //$$ packet.getGeneratorType(), //#endif packet.getViewDistance(), + //#if MC>=11800 + //$$ packet.simulationDistance(), + //#endif packet.hasReducedDebugInfo() //#if MC>=11500 , packet.showsDeathScreen() @@ -1194,15 +1212,7 @@ public class FullReplaySender extends ChannelDuplexHandler implements ReplaySend for (Entity entity : entitiesInChunk) { // Skip interpolation of position updates coming from server // (See: newX in EntityLivingBase or otherPlayerMPX in EntityOtherPlayerMP) - // Needs to be called at least 4 times thanks to - // EntityOtherPlayerMP#otherPlayerMPPosRotationIncrements (max vanilla value is 3) - for (int i = 0; i < 4; i++) { - //#if MC>=11400 - entity.tick(); - //#else - //$$ entity.onUpdate(); - //#endif - } + forcePositionForVehicleAndSelf(entity); // Check whether the entity has left the chunk //#if MC>=11700 @@ -1273,6 +1283,26 @@ public class FullReplaySender extends ChannelDuplexHandler implements ReplaySend return p; // During synchronous playback everything is sent normally } + private void forcePositionForVehicleAndSelf(Entity entity) { + Entity vehicle = entity.getVehicle(); + if (vehicle != null) { + forcePositionForVehicleAndSelf(vehicle); + } + + // Skip interpolation of position updates coming from server + // (See: newX in EntityLivingBase or otherPlayerMPX in EntityOtherPlayerMP) + int ticks = 0; + Vec3d prevPos; + do { + prevPos = entity.getPos(); + if (vehicle != null) { + entity.tickRiding(); + } else { + entity.tick(); + } + } while (prevPos.squaredDistanceTo(entity.getPos()) > 0.0001 && ticks++ < 100); + } + private static final class PacketData { private static final com.github.steveice10.netty.buffer.ByteBuf byteBuf = com.github.steveice10.netty.buffer.Unpooled.buffer(); private static final NetOutput netOutput = new ByteBufNetOutput(byteBuf); diff --git a/src/main/java/com/replaymod/replay/ReplayModReplay.java b/src/main/java/com/replaymod/replay/ReplayModReplay.java index 05113411..cb225447 100644 --- a/src/main/java/com/replaymod/replay/ReplayModReplay.java +++ b/src/main/java/com/replaymod/replay/ReplayModReplay.java @@ -159,7 +159,7 @@ public class ReplayModReplay implements Module { } public void startReplay(File file) throws IOException { - startReplay(core.openReplay(file.toPath())); + startReplay(core.files.open(file.toPath())); } public void startReplay(ReplayFile replayFile) throws IOException { diff --git a/src/main/java/com/replaymod/replay/camera/CameraEntity.java b/src/main/java/com/replaymod/replay/camera/CameraEntity.java index ba1b59e3..5ae3fcec 100644 --- a/src/main/java/com/replaymod/replay/camera/CameraEntity.java +++ b/src/main/java/com/replaymod/replay/camera/CameraEntity.java @@ -22,7 +22,6 @@ import net.minecraft.client.MinecraftClient; import net.minecraft.client.network.AbstractClientPlayerEntity; import net.minecraft.client.network.ClientPlayNetworkHandler; import net.minecraft.entity.Entity; -import net.minecraft.entity.LivingEntity; import net.minecraft.entity.mob.MobEntity; import net.minecraft.entity.decoration.ItemFrameEntity; import net.minecraft.entity.player.PlayerEntity; @@ -249,14 +248,7 @@ public class CameraEntity this.lastRenderX = to.lastRenderX; this.lastRenderY = to.lastRenderY + yOffset; this.lastRenderZ = to.lastRenderZ; - if (to instanceof LivingEntity) { - LivingEntity toLiving = (LivingEntity) to; - this.headYaw = toLiving.headYaw; - this.prevHeadYaw = toLiving.prevHeadYaw; - } else { - this.headYaw = to.yaw; - this.prevHeadYaw = to.prevYaw; - } + this.wrapArmYaw(); updateBoundingBox(); } @@ -265,7 +257,7 @@ public class CameraEntity public float getYaw(float tickDelta) { Entity view = this.client.getCameraEntity(); if (view != null && view != this) { - return this.prevHeadYaw + (this.headYaw - this.prevHeadYaw) * tickDelta; + return this.prevYaw + (this.yaw - this.prevYaw) * tickDelta; } return super.getYaw(tickDelta); } @@ -365,6 +357,11 @@ public class CameraEntity public boolean isSubmergedIn(Tag fluid) { return falseUnlessSpectating(entity -> entity.isSubmergedIn(fluid)); } + + @Override + public float getUnderwaterVisibility() { + return falseUnlessSpectating(__ -> true) ? super.getUnderwaterVisibility() : 1f; + } //#else //#if MC>=10800 //$$ @Override @@ -707,7 +704,35 @@ public class CameraEntity this.lastRenderYaw = this.renderYaw; this.lastRenderPitch = this.renderPitch; this.renderPitch = this.renderPitch + (this.pitch - this.renderPitch) * 0.5f; - this.renderYaw = this.renderYaw + (this.headYaw - this.renderYaw) * 0.5f; + this.renderYaw = this.renderYaw + wrapDegrees(this.yaw - this.renderYaw) * 0.5f; + this.wrapArmYaw(); + } + + /** + * Minecraft renders the arm offset based on the difference between {@link #yaw} and {@link #renderYaw}. It does not + * wrap around the difference though, so if {@link #yaw} just wrapped around from 350 to 10 but {@link #renderYaw} + * is still at 355, then the difference will be inappropriately large. To fix this, we always wrap the + * {@link #renderYaw} such that it is no more than 180 degrees away from {@link #yaw}, even if that requires going + * outside the normal range. + */ + private void wrapArmYaw() { + this.renderYaw = wrapDegreesTo(this.renderYaw, this.yaw); + this.lastRenderYaw = wrapDegreesTo(this.lastRenderYaw, this.renderYaw); + } + + private static float wrapDegreesTo(float value, float towardsValue) { + while (towardsValue - value < -180) { + value -= 360; + } + while (towardsValue - value >= 180) { + value += 360; + } + return value; + } + + private static float wrapDegrees(float value) { + value %= 360; + return wrapDegreesTo(value, 0); } public boolean canSpectate(Entity e) { diff --git a/src/main/java/com/replaymod/replay/ext/EntityExt.java b/src/main/java/com/replaymod/replay/ext/EntityExt.java new file mode 100644 index 00000000..5f6e4ce4 --- /dev/null +++ b/src/main/java/com/replaymod/replay/ext/EntityExt.java @@ -0,0 +1,9 @@ +package com.replaymod.replay.ext; + +public interface EntityExt { + float replaymod$getTrackedYaw(); + void replaymod$setTrackedYaw(float value); + + float replaymod$getTrackedPitch(); + void replaymod$setTrackedPitch(float value); +} diff --git a/src/main/java/com/replaymod/replay/gui/screen/GuiReplayViewer.java b/src/main/java/com/replaymod/replay/gui/screen/GuiReplayViewer.java index bd889eb0..de4279aa 100644 --- a/src/main/java/com/replaymod/replay/gui/screen/GuiReplayViewer.java +++ b/src/main/java/com/replaymod/replay/gui/screen/GuiReplayViewer.java @@ -118,7 +118,7 @@ public class GuiReplayViewer extends GuiScreen { @Override public void run() { try { - File folder = mod.getCore().getReplayFolder().toFile(); + File folder = mod.getCore().folders.getReplayFolder().toFile(); MCVer.openFile(folder); } catch (IOException e) { @@ -221,7 +221,7 @@ public class GuiReplayViewer extends GuiScreen { this.mod = mod; try { - list.setFolder(mod.getCore().getReplayFolder().toFile()); + list.setFolder(mod.getCore().folders.getReplayFolder().toFile()); } catch (IOException e) { throw new CrashException(CrashReport.create(e, "Getting replay folder")); } @@ -370,7 +370,7 @@ public class GuiReplayViewer extends GuiScreen { Arrays.sort(files, Comparator.comparingLong(f -> lastModified.computeIfAbsent(f, File::lastModified)).reversed()); for (final File file : files) { if (Thread.interrupted()) break; - try (ReplayFile replayFile = ReplayMod.instance.openReplay(file.toPath())) { + try (ReplayFile replayFile = ReplayMod.instance.files.open(file.toPath())) { final Image thumb = Optional.ofNullable(replayFile.getThumbBytes().orNull()).flatMap(stream -> { try (InputStream in = stream) { return Optional.of(Image.read(in)); diff --git a/src/main/java/com/replaymod/replay/mixin/ClientWorldAccessor.java b/src/main/java/com/replaymod/replay/mixin/ClientWorldAccessor.java new file mode 100644 index 00000000..ba16ed59 --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/ClientWorldAccessor.java @@ -0,0 +1,13 @@ +package com.replaymod.replay.mixin; + +import net.minecraft.client.world.ClientWorld; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(ClientWorld.class) +public interface ClientWorldAccessor { + //#if MC>=11800 + //$$ @Accessor + //$$ net.minecraft.world.EntityList getEntityList(); + //#endif +} diff --git a/src/main/java/com/replaymod/replay/mixin/Mixin_FixEntityNotTracking.java b/src/main/java/com/replaymod/replay/mixin/Mixin_FixEntityNotTracking.java new file mode 100644 index 00000000..464bb200 --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/Mixin_FixEntityNotTracking.java @@ -0,0 +1 @@ +// 1.18+ only diff --git a/src/main/java/com/replaymod/replay/mixin/Mixin_FixHandOffsetTickDelta.java b/src/main/java/com/replaymod/replay/mixin/Mixin_FixHandOffsetTickDelta.java new file mode 100644 index 00000000..fca4cd6c --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/Mixin_FixHandOffsetTickDelta.java @@ -0,0 +1 @@ +// 1.12.2 and below diff --git a/src/main/java/com/replaymod/replay/mixin/entity_tracking/Mixin_EntityExt.java b/src/main/java/com/replaymod/replay/mixin/entity_tracking/Mixin_EntityExt.java new file mode 100644 index 00000000..993e9c6e --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/entity_tracking/Mixin_EntityExt.java @@ -0,0 +1,36 @@ +package com.replaymod.replay.mixin.entity_tracking; + +import com.replaymod.replay.ext.EntityExt; +import net.minecraft.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +@Mixin(Entity.class) +public abstract class Mixin_EntityExt implements EntityExt { + + @Unique + private float trackedYaw; + + @Unique + private float trackedPitch; + + @Override + public float replaymod$getTrackedYaw() { + return this.trackedYaw; + } + + @Override + public float replaymod$getTrackedPitch() { + return this.trackedPitch; + } + + @Override + public void replaymod$setTrackedYaw(float value) { + this.trackedYaw = value; + } + + @Override + public void replaymod$setTrackedPitch(float value) { + this.trackedPitch = value; + } +} diff --git a/src/main/java/com/replaymod/replay/mixin/entity_tracking/Mixin_FixPartialUpdates.java b/src/main/java/com/replaymod/replay/mixin/entity_tracking/Mixin_FixPartialUpdates.java new file mode 100644 index 00000000..255102ae --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/entity_tracking/Mixin_FixPartialUpdates.java @@ -0,0 +1,109 @@ +package com.replaymod.replay.mixin.entity_tracking; + +import com.replaymod.replay.ext.EntityExt; +import net.minecraft.client.network.ClientPlayNetworkHandler; +import net.minecraft.entity.Entity; +import org.objectweb.asm.Opcodes; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.ModifyArg; +import org.spongepowered.asm.mixin.injection.ModifyVariable; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Every Entity on the client has at least two position/rotation states: The local one and the server ("tracked") one. + * When receiving an update from the server, the server one needs to be updated and the local one will then usually be + * interpolated to the server one within a few ticks. + * + * Minecraft however incorrectly implements the server rotation update for position-only update packets by + * interpolating to the local rotation, which might not yet match the server rotation, rather than to the previously + * received server rotation. + * Similarly, with 1.15 and later, it incorrectly implements the server position update for rotation-only update packets + * by interpolating to the local position rather than the server one. + * + * Each of these will cause the client position to be in an incorrect state until the next update packed for the + * respective rotation/position of that entity. + * + * This mixin fixes those two issues by redirecting to the server rotation/position respectively. + * Minecraft does not currently even track the server rotation, so we need to do that as well. + */ +@Mixin(ClientPlayNetworkHandler.class) +public class Mixin_FixPartialUpdates { + + // + // Use correct rotation for position-only updates + // + + //#if MC>=11700 + //$$ @Redirect(method = "onEntityUpdate", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/Entity;getYaw()F")) + //#else + @Redirect(method = "onEntityUpdate", at = @At(value = "FIELD", target = "Lnet/minecraft/entity/Entity;yaw:F", opcode = Opcodes.GETFIELD)) + //#endif + private float getTrackedYaw(Entity instance) { + return ((EntityExt) instance).replaymod$getTrackedYaw(); + } + + //#if MC>=11700 + //$$ @Redirect(method = "onEntityUpdate", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/Entity;getPitch()F")) + //#else + @Redirect(method = "onEntityUpdate", at = @At(value = "FIELD", target = "Lnet/minecraft/entity/Entity;pitch:F", opcode = Opcodes.GETFIELD)) + //#endif + private float getTrackedPitch(Entity instance) { + return ((EntityExt) instance).replaymod$getTrackedPitch(); + } + + //#if MC>=11500 + // + // Use correct position for rotation-only updates + // + + @Redirect(method = "onEntityUpdate", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/Entity;getX()D")) + private double getTrackedX(Entity instance) { + return instance.getTrackedPosition().getX(); + } + + @Redirect(method = "onEntityUpdate", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/Entity;getY()D")) + private double getTrackedY(Entity instance) { + return instance.getTrackedPosition().getY(); + } + + @Redirect(method = "onEntityUpdate", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/Entity;getZ()D")) + private double getTrackedZ(Entity instance) { + return instance.getTrackedPosition().getZ(); + } + //#endif + + // + // Track server rotation + // + + private static final String ENTITY_UPDATE = "Lnet/minecraft/entity/Entity;updateTrackedPositionAndAngles(DDDFFIZ)V"; + + @Unique + private Entity entity; + + @ModifyVariable(method = "onEntityUpdate", at = @At(value = "INVOKE", target = ENTITY_UPDATE), ordinal = 0) + private Entity captureEntity(Entity entity) { + return this.entity = entity; + } + + @Inject(method = "onEntityUpdate", at = @At("RETURN")) + private void resetEntityField(CallbackInfo ci) { + this.entity = null; + } + + @ModifyArg(method = "onEntityUpdate", at = @At(value = "INVOKE", target = ENTITY_UPDATE), index = 3) + private float captureTrackedYaw(float value) { + ((EntityExt) this.entity).replaymod$setTrackedYaw(value); + return value; + } + + @ModifyArg(method = "onEntityUpdate", at = @At(value = "INVOKE", target = ENTITY_UPDATE), index = 4) + private float captureTrackedPitch(float value) { + ((EntityExt) this.entity).replaymod$setTrackedPitch(value); + return value; + } +} diff --git a/src/main/java/com/replaymod/simplepathing/gui/GuiPathing.java b/src/main/java/com/replaymod/simplepathing/gui/GuiPathing.java index d4031bbd..d2117cf8 100644 --- a/src/main/java/com/replaymod/simplepathing/gui/GuiPathing.java +++ b/src/main/java/com/replaymod/simplepathing/gui/GuiPathing.java @@ -451,14 +451,14 @@ public class GuiPathing { prevTime = time; } - public void syncTimeButtonPressed() { + private Integer computeSyncTime(int cursor) { // Current replay time int time = replayHandler.getReplaySender().currentTimeStamp(); - // Position of the cursor - int cursor = timeline.getCursorPosition(); // Get the last time keyframe before the cursor - mod.getCurrentTimeline().getTimePath().getKeyframes().stream() - .filter(it -> it.getTime() <= cursor).reduce((__, last) -> last).ifPresent(keyframe -> { + Keyframe keyframe = mod.getCurrentTimeline().getTimePath().getKeyframes().stream() + .filter(it -> it.getTime() <= cursor).reduce((__, last) -> last) + .orElse(null); + if (keyframe != null) { // Cursor position at the keyframe int keyframeCursor = (int) keyframe.getTime(); // Replay time at the keyframe @@ -470,11 +470,49 @@ public class GuiPathing { double speed = Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) ? 1 : replayHandler.getOverlay().getSpeedSliderValue(); // Cursor time passed int cursorPassed = (int) (timePassed / speed); - // Move cursor to new position - timeline.setCursorPosition(keyframeCursor + cursorPassed).ensureCursorVisibleWithPadding(); - // Deselect keyframe to allow the user to add a new one right away - mod.setSelected(null, 0); - }); + // Return new position + return keyframeCursor + cursorPassed; + } else { + // No keyframes before cursor + return null; + } + } + + public void syncTimeButtonPressed() { + // Position of the cursor + int cursor = timeline.getCursorPosition(); + + // Update cursor once + Integer updatedCursor = computeSyncTime(cursor); + if (updatedCursor == null) { + return; // no keyframes before cursor, nothing we can do + } + cursor = updatedCursor; + + // Repeatedly update until we find a fix point + while (true) { + updatedCursor = computeSyncTime(cursor); + if (updatedCursor == null) { + // Cursor has gotten stuck before in front of all keyframes. + // Let's just use the last value we got, this shouldn't happen with ordinary timelines anyway. + break; + } + if (updatedCursor == cursor) { + // Found the fix point, we can stop now + break; + } + if (updatedCursor < cursor) { + // We've gone backwards, we'll likely get stuck in a loop, so abort the whole thing + return; + } + // Found a new position, take it, repeat + cursor = updatedCursor; + } + + // Move cursor to new position + timeline.setCursorPosition(cursor).ensureCursorVisibleWithPadding(); + // Deselect keyframe to allow the user to add a new one right away + mod.setSelected(null, 0); } public boolean deleteButtonPressed() { diff --git a/src/main/resources/mixins.render.replaymod.json b/src/main/resources/mixins.render.replaymod.json index 7bddb362..108c1484 100644 --- a/src/main/resources/mixins.render.replaymod.json +++ b/src/main/resources/mixins.render.replaymod.json @@ -4,6 +4,9 @@ "mixins": [], "server": [], "client": [ + //#if MC>=11800 + //$$ "ChunkInfoAccessor", + //#endif "Mixin_ChromaKeyColorSky", "Mixin_ChromaKeyDisableFog", "Mixin_ChromaKeyForceSky", diff --git a/src/main/resources/mixins.replay.replaymod.json b/src/main/resources/mixins.replay.replaymod.json index feb30fa7..356d50cc 100644 --- a/src/main/resources/mixins.replay.replaymod.json +++ b/src/main/resources/mixins.replay.replaymod.json @@ -5,14 +5,22 @@ "mixins": [], "server": [], "client": [ + "entity_tracking.Mixin_EntityExt", + "entity_tracking.Mixin_FixPartialUpdates", "Mixin_FixNPCSkinCaching", + //#if MC>=11800 + //$$ "Mixin_FixEntityNotTracking", + //#endif //#if MC>=11600 "Mixin_MoveRealmsButton", //#endif //#if MC>=11400 "MixinCamera", "MixinInGameHud", + //#else + //$$ "Mixin_FixHandOffsetTickDelta", //#endif + "ClientWorldAccessor", "EntityLivingBaseAccessor", //#if MC>=11400 "Mixin_ShowSpectatedHand_NoOF", diff --git a/version.txt b/version.txt index 6a6a3d8e..097a15a2 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.6.1 +2.6.2 diff --git a/versions/1.12.2/src/main/java/com/replaymod/replay/mixin/Mixin_FixHandOffsetTickDelta.java b/versions/1.12.2/src/main/java/com/replaymod/replay/mixin/Mixin_FixHandOffsetTickDelta.java new file mode 100644 index 00000000..6f447aed --- /dev/null +++ b/versions/1.12.2/src/main/java/com/replaymod/replay/mixin/Mixin_FixHandOffsetTickDelta.java @@ -0,0 +1,46 @@ +package com.replaymod.replay.mixin; + +import com.replaymod.replay.camera.CameraEntity; +import net.minecraft.client.entity.EntityPlayerSP; +import net.minecraft.client.renderer.ItemRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +/** + * In 1.12.2 and below, Vanilla does not respect the tickDelta value when getting the yaw/pitch of the player for + * computing the hand offset. + * This causes the hand movement to be jittery when spectating another player in a replay. + */ +@Mixin(ItemRenderer.class) +public abstract class Mixin_FixHandOffsetTickDelta { + @Redirect(method = "rotateArm", at = @At(value = "FIELD", target = "Lnet/minecraft/client/entity/EntityPlayerSP;rotationYaw:F")) + private float getYaw( + EntityPlayerSP instance, + //#if MC<10900 + //$$ EntityPlayerSP arg, + //#endif + float tickDelta + ) { + if (instance instanceof CameraEntity) { + return instance.prevRotationYaw + (instance.rotationYaw - instance.prevRotationYaw) * tickDelta; + } else { + return instance.rotationYaw; + } + } + + @Redirect(method = "rotateArm", at = @At(value = "FIELD", target = "Lnet/minecraft/client/entity/EntityPlayerSP;rotationPitch:F")) + private float getPitch( + EntityPlayerSP instance, + //#if MC<10900 + //$$ EntityPlayerSP arg, + //#endif + float tickDelta + ) { + if (instance instanceof CameraEntity) { + return instance.prevRotationPitch + (instance.rotationPitch - instance.prevRotationPitch) * tickDelta; + } else { + return instance.rotationPitch; + } + } +} diff --git a/versions/1.14.4-forge/mapping.txt b/versions/1.14.4-forge/mapping.txt index f858f3e5..2096663b 100644 --- a/versions/1.14.4-forge/mapping.txt +++ b/versions/1.14.4-forge/mapping.txt @@ -27,6 +27,7 @@ net.minecraft.client.gui.GuiYesNoCallback confirmResult() confirmClicked() net.minecraft.util.text.ITextComponent getString() getUnformattedText() net.minecraft.network.play.server.SPacketRespawn func_212643_b() getDimensionID() net.minecraft.client.Minecraft getPackFinder() getResourcePackRepository() +net.minecraft.entity.Entity getPositionVec() getPositionVector() net.minecraftforge.client.event.GuiScreenEvent.InitGuiEvent addWidget() addButton() net.minecraftforge.client.event.GuiScreenEvent.InitGuiEvent removeWidget() removeButton() diff --git a/versions/1.18.1/.gitkeep b/versions/1.18.1/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/versions/1.18.1/src/main/java/com/replaymod/render/mixin/ChunkInfoAccessor.java b/versions/1.18.1/src/main/java/com/replaymod/render/mixin/ChunkInfoAccessor.java new file mode 100644 index 00000000..c4fbdd96 --- /dev/null +++ b/versions/1.18.1/src/main/java/com/replaymod/render/mixin/ChunkInfoAccessor.java @@ -0,0 +1,12 @@ +// 1.18+ +package com.replaymod.render.mixin; + +import net.minecraft.client.render.chunk.ChunkBuilder; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(targets = "net.minecraft.client.render.WorldRenderer$ChunkInfo") +public interface ChunkInfoAccessor { + @Accessor + ChunkBuilder.BuiltChunk getChunk(); +} diff --git a/versions/1.18.1/src/main/java/com/replaymod/render/mixin/Mixin_ForceChunkLoading.java b/versions/1.18.1/src/main/java/com/replaymod/render/mixin/Mixin_ForceChunkLoading.java new file mode 100644 index 00000000..288c4317 --- /dev/null +++ b/versions/1.18.1/src/main/java/com/replaymod/render/mixin/Mixin_ForceChunkLoading.java @@ -0,0 +1,119 @@ +package com.replaymod.render.mixin; + +import com.replaymod.render.hooks.ForceChunkLoadingHook; +import com.replaymod.render.hooks.IForceChunkLoading; +import com.replaymod.render.utils.FlawlessFrames; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.Camera; +import net.minecraft.client.render.Frustum; +import net.minecraft.client.render.GameRenderer; +import net.minecraft.client.render.LightmapTextureManager; +import net.minecraft.client.render.WorldRenderer; +import net.minecraft.client.render.chunk.ChunkBuilder; +import net.minecraft.client.render.chunk.ChunkRendererRegionBuilder; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.util.math.Matrix4f; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +@Mixin(WorldRenderer.class) +public abstract class Mixin_ForceChunkLoading implements IForceChunkLoading { + private ForceChunkLoadingHook replayModRender_hook; + + @Override + public void replayModRender_setHook(ForceChunkLoadingHook hook) { + this.replayModRender_hook = hook; + } + + @Shadow private ChunkBuilder chunkBuilder; + + @Shadow protected abstract void setupTerrain(Camera par1, Frustum par2, boolean par3, boolean par4); + + @Shadow private Frustum frustum; + + @Shadow private Frustum capturedFrustum; + + @Shadow @Final private MinecraftClient client; + + @Shadow @Final private ObjectArrayList chunkInfos; + + @Shadow private boolean field_34810; + + @Shadow @Final private BlockingQueue builtChunks; + + @Shadow private Future field_34808; + + @Shadow @Final private AtomicBoolean field_34809; + + @Shadow protected abstract void applyFrustum(Frustum par1); + + @Inject(method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/render/WorldRenderer;setupTerrain(Lnet/minecraft/client/render/Camera;Lnet/minecraft/client/render/Frustum;ZZ)V")) + private void forceAllChunks(MatrixStack matrices, float tickDelta, long limitTime, boolean renderBlockOutline, Camera camera, GameRenderer gameRenderer, LightmapTextureManager lightmapTextureManager, Matrix4f matrix4f, CallbackInfo ci) { + if (replayModRender_hook == null) { + return; + } + if (FlawlessFrames.hasSodium()) { + return; + } + + assert this.client.player != null; + + ChunkRendererRegionBuilder chunkRendererRegionBuilder = new ChunkRendererRegionBuilder(); + + do { + // Determine which chunks shall be visible + setupTerrain(camera, this.frustum, this.capturedFrustum != null, this.client.player.isSpectator()); + + // Wait for async processing to be complete + if (this.field_34808 != null) { + try { + this.field_34808.get(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (TimeoutException e) { + e.printStackTrace(); + } + } + + // If that async processing did change the chunk graph, we need to re-apply the frustum (otherwise this is + // only done in the next setupTerrain call, which not happen this frame) + if (this.field_34809.compareAndSet(true, false)) { + this.applyFrustum((new Frustum(frustum)).method_38557(8)); // call based on the one in setupTerrain + } + + // Schedule all chunks which need rebuilding (we schedule even important rebuilds because we wait for + // all of them anyway and this way we can take advantage of threading) + for (ChunkInfoAccessor chunkInfo : this.chunkInfos) { + ChunkBuilder.BuiltChunk builtChunk = chunkInfo.getChunk(); + if (!builtChunk.needsRebuild()) { + continue; + } + // MC sometimes schedules invalid chunks when you're outside of loaded chunks (e.g. y > 256) + if (builtChunk.shouldBuild()) { + builtChunk.scheduleRebuild(this.chunkBuilder, chunkRendererRegionBuilder); + } + builtChunk.cancelRebuild(); + } + + // Upload all chunks + this.field_34810 |= ((ForceChunkLoadingHook.IBlockOnChunkRebuilds) this.chunkBuilder).uploadEverythingBlocking(); + + // Repeat until no more updates are needed + } while (this.field_34810 || !this.builtChunks.isEmpty()); + } +} diff --git a/versions/1.18.1/src/main/java/com/replaymod/replay/mixin/Mixin_FixEntityNotTracking.java b/versions/1.18.1/src/main/java/com/replaymod/replay/mixin/Mixin_FixEntityNotTracking.java new file mode 100644 index 00000000..1d95dd1b --- /dev/null +++ b/versions/1.18.1/src/main/java/com/replaymod/replay/mixin/Mixin_FixEntityNotTracking.java @@ -0,0 +1,38 @@ +package com.replaymod.replay.mixin; + +import net.minecraft.client.network.ClientPlayNetworkHandler; +import net.minecraft.entity.Entity; +import net.minecraft.util.math.Vec3d; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +@Mixin(ClientPlayNetworkHandler.class) +public class Mixin_FixEntityNotTracking { + @ModifyVariable(method = { "onEntityPosition", "onEntity", "onEntityPassengersSet" }, at = @At("RETURN"), ordinal = 0) + private Entity updatePositionIfNotTracked$0(Entity entity) { + if (entity != null) { + entity.streamSelfAndPassengers().forEach(this::updatePositionIfNotTracked); + } + return entity; + } + + private void updatePositionIfNotTracked(Entity entity) { + if (entity != null && entity.world instanceof ClientWorldAccessor world) { + if (!world.getEntityList().has(entity)) { + // Skip interpolation of position updates coming from server + // (See: newX in EntityLivingBase or otherPlayerMPX in EntityOtherPlayerMP) + int ticks = 0; + Vec3d prevPos; + do { + prevPos = entity.getPos(); + if (entity.hasVehicle()) { + entity.tickRiding(); + } else { + entity.tick(); + } + } while (prevPos.squaredDistanceTo(entity.getPos()) > 0.0001 && ticks++ < 100); + } + } + } +} diff --git a/versions/1.9.4/mapping.txt b/versions/1.9.4/mapping.txt index 9225b4c1..ea42a2f1 100644 --- a/versions/1.9.4/mapping.txt +++ b/versions/1.9.4/mapping.txt @@ -1,5 +1,6 @@ net.minecraft.stats.StatisticsManager net.minecraft.stats.StatFileWriter net.minecraft.init.MobEffects net.minecraft.potion.Potion +net.minecraft.util.math.Vec3d net.minecraft.util.Vec3 net.minecraft.util.text.TextComponentString net.minecraft.util.ChatComponentText net.minecraft.util.text.TextComponentTranslation net.minecraft.util.ChatComponentTranslation net.minecraft.util.text.Style net.minecraft.util.ChatStyle @@ -7,6 +8,7 @@ net.minecraft.util.text.TextFormatting net.minecraft.util.EnumChatFormatting net.minecraft.util.text.ITextComponent net.minecraft.util.IChatComponent net.minecraft.network.datasync.EntityDataManager net.minecraft.entity.DataWatcher net.minecraft.network.datasync.EntityDataManager.DataEntry net.minecraft.entity.DataWatcher.WatchableObject +net.minecraft.client.renderer.ItemRenderer rotateArm() rotateWithPlayerRotations() net.minecraft.client.renderer.VertexBuffer net.minecraft.client.renderer.WorldRenderer net.minecraft.client.particle.Particle net.minecraft.client.particle.EntityFX net.minecraft.util.math.MathHelper net.minecraft.util.MathHelper diff --git a/versions/mapping-fabric-1.16.1-1.15.2.txt b/versions/mapping-fabric-1.16.1-1.15.2.txt new file mode 100644 index 00000000..1f8df9e4 --- /dev/null +++ b/versions/mapping-fabric-1.16.1-1.15.2.txt @@ -0,0 +1 @@ +net.minecraft.client.network.ClientPlayerEntity getUnderwaterVisibility() method_3140() diff --git a/versions/mapping-fabric-1.18.1-1.17.1.txt b/versions/mapping-fabric-1.18.1-1.17.1.txt new file mode 100644 index 00000000..1384942e --- /dev/null +++ b/versions/mapping-fabric-1.18.1-1.17.1.txt @@ -0,0 +1,16 @@ +net.minecraft.client.util.math.MatrixStack multiplyPositionMatrix() method_34425() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket playerEntityId() getEntityId() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket hardcore() isHardcore() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket gameMode() getGameMode() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket previousGameMode() getPreviousGameMode() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket dimensionIds() getDimensionIds() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket registryManager() getRegistryManager() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket dimensionType() getDimensionType() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket dimensionId() getDimensionId() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket sha256Seed() getSha256Seed() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket maxPlayers() getMaxPlayers() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket viewDistance() getViewDistance() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket reducedDebugInfo() hasReducedDebugInfo() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket showDeathScreen() showsDeathScreen() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket debugWorld() isDebugWorld() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket flatWorld() isFlatWorld()