├── logo ├── logo-1024.png ├── logo-128.png ├── logo-256.png ├── logo-512.png ├── logo.afdesign └── logo.svg ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src ├── main │ ├── resources │ │ ├── assets │ │ │ └── replayfps │ │ │ │ ├── logo.png │ │ │ │ └── lang │ │ │ │ └── en_us.json │ │ ├── replayfps.mixins.gameplay.json │ │ ├── replayfps.mixins.json │ │ └── fabric.mod.json │ └── java │ │ └── com │ │ └── igrium │ │ └── replayfps │ │ ├── game │ │ ├── package-info.java │ │ ├── mixin │ │ │ ├── LivingEntityAccessor.java │ │ │ ├── MinecraftClientMixin.java │ │ │ ├── ClientPlayerEntityMixin.java │ │ │ └── PlayerInventoryMixin.java │ │ ├── event │ │ │ ├── ClientJoinedWorldEvent.java │ │ │ ├── InventoryModifiedEvent.java │ │ │ └── ClientPlayerEvents.java │ │ ├── networking │ │ │ ├── DefaultPacketRedirectors.java │ │ │ ├── DefaultFakePackets.java │ │ │ ├── redirector │ │ │ │ ├── HealthHungerRedirector.java │ │ │ │ ├── UpdateSelectedSlotRedirector.java │ │ │ │ ├── ExperienceUpdateRedirector.java │ │ │ │ ├── EntityEquipmentUpdateRedirector.java │ │ │ │ └── ScreenHandlerSlotUpdateRedirector.java │ │ │ └── fake_packet │ │ │ │ ├── UpdateSelectedSlotFakePacket.java │ │ │ │ ├── SetGamemodeFakePacket.java │ │ │ │ └── UpdateHotbarFakePacket.java │ │ ├── channel │ │ │ ├── handler │ │ │ │ ├── PlayerVelocityChannelHandler.java │ │ │ │ ├── HorizontalSpeedHandler.java │ │ │ │ ├── PlayerStrideChannelHandler.java │ │ │ │ ├── PlayerPosChannelHandler.java │ │ │ │ └── PlayerRotChannelHandler.java │ │ │ └── DefaultChannelHandlers.java │ │ └── BullshitPlayerInventoryManager.java │ │ ├── core │ │ ├── package-info.java │ │ ├── util │ │ │ ├── TimecodeProvider.java │ │ │ ├── NoHeaderException.java │ │ │ ├── GlobalReplayContext.java │ │ │ ├── ReplayModHooks.java │ │ │ ├── PlaybackUtils.java │ │ │ ├── DataReader.java │ │ │ ├── AnimationUtils.java │ │ │ ├── SeekableInputStream.java │ │ │ └── DataWriter.java │ │ ├── mixin │ │ │ ├── ClientConnectionAccessor.java │ │ │ ├── PacketListenerAccessor.java │ │ │ ├── ReplayModMixin.java │ │ │ ├── ReplayHandlerMixin.java │ │ │ ├── ShowHotbarDuringRenderMixin.java │ │ │ ├── ClientConnectionMixin.java │ │ │ ├── FullReplaySenderMixin.java │ │ │ ├── SkipHudDuringRenderMixinSquared.java │ │ │ ├── SkipOverlayDuringRenderMixin.java │ │ │ ├── AbstractChanneledNetworkAddonMixin.java │ │ │ ├── ConnectionEventHandlerMixin.java │ │ │ ├── ChatHudMixin.java │ │ │ ├── PacketListenerMixin.java │ │ │ └── GameRendererMixin.java │ │ ├── networking │ │ │ ├── event │ │ │ │ ├── FakePacketRegistrationCallback.java │ │ │ │ ├── PacketReceivedEvent.java │ │ │ │ └── CustomPacketReceivedEvent.java │ │ │ ├── PacketRedirector.java │ │ │ └── PacketRedirectors.java │ │ ├── events │ │ │ ├── ReplayEvents.java │ │ │ ├── ChannelRegistrationCallback.java │ │ │ └── RecordingEvents.java │ │ ├── recording │ │ │ ├── ClientCaptureContext.java │ │ │ ├── ClientCapWriter.java │ │ │ ├── ClientCapHeader.java │ │ │ ├── ClientCapRecorder.java │ │ │ └── ClientRecordingModule.java │ │ ├── playback │ │ │ ├── ClientPlaybackContext.java │ │ │ ├── ChannelValueCache.java │ │ │ └── UnserializedFrame.java │ │ └── channel │ │ │ ├── type │ │ │ ├── PlaceholderChannel.java │ │ │ ├── Matrix4fChannelType.java │ │ │ ├── Vec3dChannelType.java │ │ │ ├── Vector2fChannelType.java │ │ │ ├── Vector4fChannelType.java │ │ │ ├── ChannelTypes.java │ │ │ └── ChannelType.java │ │ │ ├── ChannelHandler.java │ │ │ └── ChannelHandlers.java │ │ ├── ReplayFPSModMenu.java │ │ ├── ReplayFPS.java │ │ └── config │ │ └── ReplayFPSConfig.java └── test │ └── java │ └── com │ └── igrium │ └── replayfps │ └── test │ ├── BlankInputStream.java │ ├── SimpleSingleThreadExecutor.java │ ├── ConcurrentBufferTest.java │ ├── TestNumberChannels.java │ └── TestChannelHandlers.java ├── ccap_viewer ├── src │ └── main │ │ ├── resources │ │ ├── replayfps_viewer.mixins.json │ │ ├── assets │ │ │ └── replayfps_viewer │ │ │ │ └── ui │ │ │ │ ├── channel_graph.fxml │ │ │ │ ├── loading.fxml │ │ │ │ ├── main.fxml │ │ │ │ └── header.fxml │ │ └── fabric.mod.json │ │ └── java │ │ └── com │ │ └── igrium │ │ └── replayfps_viewer │ │ ├── ReplayFPSViewer.java │ │ ├── mixin │ │ └── TitleScreenMixin.java │ │ ├── ui │ │ ├── LoadingPopup.java │ │ ├── HeaderUI.java │ │ └── MainUI.java │ │ ├── LoadedClientCap.java │ │ ├── util │ │ └── GraphedChannel.java │ │ └── ClientCapViewer.java ├── .gitignore └── build.gradle ├── settings.gradle ├── .gitignore ├── gradle.properties ├── .github └── workflows │ └── build.yml ├── gradlew.bat ├── readme.md └── ccap_spec.md /logo/logo-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Igrium/ReplayFPS/HEAD/logo/logo-1024.png -------------------------------------------------------------------------------- /logo/logo-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Igrium/ReplayFPS/HEAD/logo/logo-128.png -------------------------------------------------------------------------------- /logo/logo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Igrium/ReplayFPS/HEAD/logo/logo-256.png -------------------------------------------------------------------------------- /logo/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Igrium/ReplayFPS/HEAD/logo/logo-512.png -------------------------------------------------------------------------------- /logo/logo.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Igrium/ReplayFPS/HEAD/logo/logo.afdesign -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Igrium/ReplayFPS/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/assets/replayfps/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Igrium/ReplayFPS/HEAD/src/main/resources/assets/replayfps/logo.png -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Contains the details of what gameplay data must be captured to make the replay look good. 3 | */ 4 | package com.igrium.replayfps.game; -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Contains all the core functionality that allows the client to capture data for the replay mod to play back. 3 | */ 4 | package com.igrium.replayfps.core; -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/util/TimecodeProvider.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.util; 2 | 3 | public interface TimecodeProvider { 4 | long getStartTime(); 5 | long getTimePassedWhilePaused(); 6 | boolean getServerWasPaused(); 7 | } 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /ccap_viewer/src/main/resources/replayfps_viewer.mixins.json: -------------------------------------------------------------------------------- 1 | { 2 | "required": true, 3 | "package": "com.igrium.replayfps_viewer.mixin", 4 | "compatibilityLevel": "JAVA_17", 5 | "client": [ 6 | "TitleScreenMixin" 7 | ], 8 | "injectors": { 9 | "defaultRequire": 1 10 | } 11 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | maven { 4 | name = 'Fabric' 5 | url = 'https://maven.fabricmc.net/' 6 | } 7 | mavenCentral() 8 | gradlePluginPortal() 9 | } 10 | } 11 | 12 | rootProject.name = 'replay_fps' 13 | // Disable until CraftFX is updated 14 | // include 'ccap_viewer' -------------------------------------------------------------------------------- /src/test/java/com/igrium/replayfps/test/BlankInputStream.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.test; 2 | 3 | import java.io.InputStream; 4 | 5 | /** 6 | * An input stream that always returns 0. 7 | */ 8 | public class BlankInputStream extends InputStream { 9 | 10 | @Override 11 | public int read() { 12 | return 0; 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/util/NoHeaderException.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.util; 2 | 3 | public class NoHeaderException extends IllegalStateException { 4 | public NoHeaderException() { 5 | super("The header has not been initialized."); 6 | } 7 | 8 | public NoHeaderException(String message) { 9 | super(message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/resources/replayfps.mixins.gameplay.json: -------------------------------------------------------------------------------- 1 | { 2 | "required": true, 3 | "package": "com.igrium.replayfps.game.mixin", 4 | "compatibilityLevel": "JAVA_17", 5 | "mixins": [ 6 | "ClientPlayerEntityMixin", 7 | "LivingEntityAccessor", 8 | "PlayerInventoryMixin", 9 | "MinecraftClientMixin" 10 | ], 11 | "injectors": { 12 | "defaultRequire": 1 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/ReplayFPSModMenu.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps; 2 | 3 | import com.terraformersmc.modmenu.api.ConfigScreenFactory; 4 | import com.terraformersmc.modmenu.api.ModMenuApi; 5 | 6 | public class ReplayFPSModMenu implements ModMenuApi { 7 | @Override 8 | public ConfigScreenFactory getModConfigScreenFactory() { 9 | return parent -> ReplayFPS.getInstance().config().getScreen(parent); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ccap_viewer/.gitignore: -------------------------------------------------------------------------------- 1 | # gradle 2 | 3 | .gradle/ 4 | build/ 5 | out/ 6 | classes/ 7 | 8 | # eclipse 9 | 10 | *.launch 11 | 12 | # idea 13 | 14 | .idea/ 15 | *.iml 16 | *.ipr 17 | *.iws 18 | 19 | # vscode 20 | 21 | .settings/ 22 | .vscode/ 23 | bin/ 24 | .classpath 25 | .project 26 | 27 | # macos 28 | 29 | *.DS_Store 30 | 31 | # fabric 32 | 33 | run/ 34 | 35 | # java 36 | 37 | hs_err_*.log 38 | replay_*.log 39 | *.hprof 40 | *.jfr 41 | 42 | log/* 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # gradle 2 | 3 | .gradle/ 4 | build/ 5 | out/ 6 | classes/ 7 | 8 | # eclipse 9 | 10 | *.launch 11 | 12 | # idea 13 | 14 | .idea/ 15 | *.iml 16 | *.ipr 17 | *.iws 18 | 19 | # vscode 20 | 21 | .settings/ 22 | .vscode/ 23 | bin/ 24 | .classpath 25 | .project 26 | 27 | # macos 28 | 29 | *.DS_Store 30 | 31 | # fabric 32 | 33 | run/ 34 | 35 | # java 36 | 37 | hs_err_*.log 38 | replay_*.log 39 | *.hprof 40 | *.jfr 41 | 42 | log/* 43 | logs/* 44 | 45 | dev/* -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/ClientConnectionAccessor.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.gen.Accessor; 5 | 6 | import io.netty.channel.Channel; 7 | import net.minecraft.network.ClientConnection; 8 | 9 | @Mixin(ClientConnection.class) 10 | public interface ClientConnectionAccessor { 11 | 12 | @Accessor("channel") 13 | Channel replayfps$getChannel(); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/PacketListenerAccessor.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.gen.Accessor; 5 | 6 | import com.replaymod.recording.packet.PacketListener; 7 | import com.replaymod.replaystudio.replay.ReplayFile; 8 | 9 | @Mixin(PacketListener.class) 10 | public interface PacketListenerAccessor { 11 | 12 | @Accessor(value = "replayFile", remap = false) 13 | ReplayFile getReplayFile(); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/mixin/LivingEntityAccessor.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.gen.Accessor; 5 | 6 | import net.minecraft.entity.LivingEntity; 7 | 8 | @Mixin(LivingEntity.class) 9 | public interface LivingEntityAccessor { 10 | 11 | @Accessor("lastDamageTaken") 12 | public float getLastDamageTaken(); 13 | 14 | @Accessor("lastDamageTaken") 15 | public void setLastDamageTaken(float lastDamageTaken); 16 | } 17 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Done to increase the memory available to gradle. 2 | org.gradle.jvmargs = -Xmx4G 3 | org.gradle.parallel = true 4 | 5 | # Fabric Properties 6 | # check these on https://fabricmc.net/develop 7 | minecraft_version = 1.20.2 8 | yarn_mappings = 1.20.2+build.4 9 | loader_version = 0.15.1 10 | 11 | # Mod Properties 12 | mod_version = 0.2.1 13 | maven_group = com.igrium.replayfps 14 | archives_base_name = replayfps 15 | 16 | # Dependencies 17 | fabric_version = 0.91.2+1.20.2 18 | replaymod_version = 1.20.2:2.6.14 -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/util/GlobalReplayContext.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.util; 2 | 3 | import java.util.Map; 4 | import java.util.WeakHashMap; 5 | 6 | import net.minecraft.entity.Entity; 7 | import net.minecraft.util.math.Vec3d; 8 | 9 | public class GlobalReplayContext { 10 | /** 11 | * Because entity positions are updated every client tick (rather than frame), 12 | * they may be cached here for the next tick. 13 | */ 14 | public static final Map ENTITY_POS_OVERRIDES = new WeakHashMap<>(); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/resources/assets/replayfps/lang/en_us.json: -------------------------------------------------------------------------------- 1 | { 2 | "title.replayfps.config": "Replay FPS Config", 3 | "category.replayfps.general": "General", 4 | "option.replayfps.use_clientcap": "Client-Capture Enabled", 5 | "option.replayfps.use_clientcap.tooltip": "Whether to use the client-capture playback system in the first place.", 6 | 7 | "category.replayfps.hud": "HUD", 8 | "option.replayfps.drawhud": "Render HUD", 9 | "option.replayfps.drawhud.tooltip": "Render the HUD (hotbar, etc) in replay renderings.", 10 | "option.replayfps.drawhotbar": "Show Hotbar", 11 | "option.replayfps.drawhotbar.tooltip": "Show the hotbar in replay renderings." 12 | } -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/mixin/MinecraftClientMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 5 | 6 | import com.igrium.replayfps.game.event.ClientJoinedWorldEvent; 7 | 8 | import net.minecraft.client.MinecraftClient; 9 | import net.minecraft.client.world.ClientWorld; 10 | 11 | @Mixin(MinecraftClient.class) 12 | public class MinecraftClientMixin { 13 | void replayfps$onJoinWorld(ClientWorld world, CallbackInfo ci) { 14 | ClientJoinedWorldEvent.EVENT.invoker().onJoinedWorld((MinecraftClient) (Object) this, world); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/ReplayModMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.injection.At; 5 | import org.spongepowered.asm.mixin.injection.Inject; 6 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 7 | 8 | import com.igrium.replayfps.core.util.ReplayModHooks; 9 | import com.replaymod.core.ReplayMod; 10 | 11 | @Mixin(ReplayMod.class) 12 | public class ReplayModMixin { 13 | @Inject(method = "initModules", at = @At("RETURN"), remap = false) 14 | void afterInit(CallbackInfo ci) { 15 | ReplayModHooks.waitForInit().complete((ReplayMod) (Object) this); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/networking/event/FakePacketRegistrationCallback.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.networking.event; 2 | 3 | import com.igrium.replayfps.core.networking.FakePacketManager; 4 | 5 | import net.fabricmc.fabric.api.event.Event; 6 | import net.fabricmc.fabric.api.event.EventFactory; 7 | 8 | public interface FakePacketRegistrationCallback { 9 | 10 | public static final Event EVENT = EventFactory.createArrayBacked( 11 | FakePacketRegistrationCallback.class, listeners -> manager -> { 12 | for (var l : listeners) { 13 | l.register(manager); 14 | } 15 | }); 16 | 17 | void register(FakePacketManager manager); 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/event/ClientJoinedWorldEvent.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.event; 2 | 3 | import net.fabricmc.fabric.api.event.Event; 4 | import net.fabricmc.fabric.api.event.EventFactory; 5 | import net.minecraft.client.MinecraftClient; 6 | import net.minecraft.client.world.ClientWorld; 7 | 8 | public interface ClientJoinedWorldEvent { 9 | public static final Event EVENT = EventFactory.createArrayBacked( 10 | ClientJoinedWorldEvent.class, listeners -> (client, world) -> { 11 | for (var l : listeners) { 12 | l.onJoinedWorld(client, world); 13 | } 14 | }); 15 | 16 | void onJoinedWorld(MinecraftClient client, ClientWorld world); 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/ReplayHandlerMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.injection.At; 5 | import org.spongepowered.asm.mixin.injection.Inject; 6 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 7 | 8 | import com.igrium.replayfps.core.events.ReplayEvents; 9 | import com.replaymod.replay.ReplayHandler; 10 | 11 | @Mixin(ReplayHandler.class) 12 | public class ReplayHandlerMixin { 13 | 14 | @Inject(method = "setup", at = @At("HEAD"), remap = false) 15 | private void replayfps$onSetup(CallbackInfo ci) { 16 | ReplayEvents.REPLAY_SETUP.invoker().onReplaySetup((ReplayHandler) (Object) this); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ccap_viewer/src/main/resources/assets/replayfps_viewer/ui/channel_graph.fxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/events/ReplayEvents.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.events; 2 | 3 | import com.replaymod.lib.de.johni0702.minecraft.gui.utils.Event; 4 | import com.replaymod.replay.ReplayHandler; 5 | 6 | public class ReplayEvents { 7 | public static final Event REPLAY_SETUP = Event.create( 8 | listeners -> handler -> { 9 | for (var l : listeners) { 10 | l.onReplaySetup(handler); 11 | } 12 | } 13 | ); 14 | 15 | /** 16 | * Called before the replay is loaded for the first time and every time 17 | * it is restarted due to backwards seeking. 18 | */ 19 | public static interface ReplaySetup { 20 | void onReplaySetup(ReplayHandler handler); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/resources/replayfps.mixins.json: -------------------------------------------------------------------------------- 1 | { 2 | "required": true, 3 | "package": "com.igrium.replayfps.core.mixin", 4 | "compatibilityLevel": "JAVA_17", 5 | "mixins": [ 6 | "ReplayModMixin", 7 | "PacketListenerAccessor", 8 | "ConnectionEventHandlerMixin", 9 | "PacketListenerMixin", 10 | "GameRendererMixin", 11 | "SkipHudDuringRenderMixinSquared", 12 | "SkipOverlayDuringRenderMixin", 13 | "ShowHotbarDuringRenderMixin", 14 | "ClientConnectionAccessor", 15 | "ClientConnectionMixin", 16 | "ReplayHandlerMixin", 17 | "FullReplaySenderMixin", 18 | "ChatHudMixin", 19 | "AbstractChanneledNetworkAddonMixin" 20 | ], 21 | "injectors": { 22 | "defaultRequire": 1 23 | } 24 | } -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/networking/DefaultPacketRedirectors.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.networking; 2 | 3 | import com.igrium.replayfps.core.networking.PacketRedirectors; 4 | import com.igrium.replayfps.game.networking.redirector.EntityEquipmentUpdateRedirector; 5 | import com.igrium.replayfps.game.networking.redirector.ExperienceUpdateRedirector; 6 | import com.igrium.replayfps.game.networking.redirector.HealthHungerRedirector; 7 | 8 | public class DefaultPacketRedirectors { 9 | public static void registerDefaults() { 10 | PacketRedirectors.register(new HealthHungerRedirector()); 11 | PacketRedirectors.register(new ExperienceUpdateRedirector()); 12 | 13 | PacketRedirectors.register(new EntityEquipmentUpdateRedirector()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/event/InventoryModifiedEvent.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.event; 2 | 3 | import it.unimi.dsi.fastutil.ints.Int2ObjectMap; 4 | import net.fabricmc.fabric.api.event.Event; 5 | import net.fabricmc.fabric.api.event.EventFactory; 6 | import net.minecraft.entity.player.PlayerInventory; 7 | import net.minecraft.item.ItemStack; 8 | 9 | public interface InventoryModifiedEvent { 10 | 11 | public Event EVENT = EventFactory.createArrayBacked( 12 | InventoryModifiedEvent.class, listeners -> (inv, updates) -> { 13 | for (var l : listeners) { 14 | l.onInventoryModified(inv, updates); 15 | } 16 | }); 17 | 18 | public void onInventoryModified(PlayerInventory inventory, Int2ObjectMap updates); 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/events/ChannelRegistrationCallback.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.events; 2 | 3 | import java.util.function.Consumer; 4 | 5 | import com.igrium.replayfps.core.channel.ChannelHandler; 6 | 7 | import net.fabricmc.fabric.api.event.Event; 8 | import net.fabricmc.fabric.api.event.EventFactory; 9 | 10 | 11 | /** 12 | * Called when a client-capture starts recording and needs to decide which 13 | * channels it's going to capture. 14 | */ 15 | public interface ChannelRegistrationCallback { 16 | 17 | Event EVENT = EventFactory.createArrayBacked(ChannelRegistrationCallback.class, 18 | listeners -> channels -> { 19 | for (var l : listeners) l.createChannels(channels); 20 | }); 21 | 22 | void createChannels(Consumer> channels); 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/recording/ClientCaptureContext.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.recording; 2 | 3 | import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext; 4 | import net.minecraft.client.MinecraftClient; 5 | import net.minecraft.client.network.ClientPlayerEntity; 6 | import net.minecraft.client.render.Camera; 7 | import net.minecraft.client.render.GameRenderer; 8 | import net.minecraft.client.world.ClientWorld; 9 | import net.minecraft.entity.Entity; 10 | 11 | /** 12 | * The context in which a frame is captured. 13 | */ 14 | public interface ClientCaptureContext { 15 | MinecraftClient client(); 16 | 17 | float tickDelta(); 18 | 19 | Entity cameraEntity(); 20 | 21 | Camera camera(); 22 | 23 | ClientPlayerEntity localPlayer(); 24 | 25 | GameRenderer gameRenderer(); 26 | 27 | ClientWorld world(); 28 | 29 | WorldRenderContext renderContext(); 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/ShowHotbarDuringRenderMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.injection.At; 5 | import org.spongepowered.asm.mixin.injection.Inject; 6 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 7 | 8 | import com.igrium.replayfps.ReplayFPS; 9 | import com.igrium.replayfps.core.util.PlaybackUtils; 10 | 11 | @Mixin(targets = "com.replaymod.replay.camera.CameraEntity$EventHandler") 12 | public class ShowHotbarDuringRenderMixin { 13 | 14 | @Inject(method = "shouldRenderHotbar", at = @At("HEAD"), remap = false, cancellable = true) 15 | void replayfps$forceShowHotbar(CallbackInfoReturnable cir) { 16 | if (PlaybackUtils.isViewingPlaybackPlayer() && ReplayFPS.getConfig().shouldDrawHotbar()) { 17 | cir.setReturnValue(true); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/ClientConnectionMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.injection.At; 5 | import org.spongepowered.asm.mixin.injection.Inject; 6 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 7 | 8 | import com.igrium.replayfps.core.networking.event.PacketReceivedEvent; 9 | 10 | import net.minecraft.network.ClientConnection; 11 | import net.minecraft.network.listener.PacketListener; 12 | import net.minecraft.network.packet.Packet; 13 | 14 | @Mixin(ClientConnection.class) 15 | public class ClientConnectionMixin { 16 | 17 | @Inject(method = "handlePacket", at = @At("HEAD"), cancellable = true) 18 | private static void replayfps$onHandlePacket(Packet packet, PacketListener listener, CallbackInfo ci) { 19 | if (PacketReceivedEvent.EVENT.invoker().onPacketReceived(packet, listener)) { 20 | ci.cancel(); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ccap_viewer/src/main/resources/assets/replayfps_viewer/ui/loading.fxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/util/ReplayModHooks.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.util; 2 | 3 | import java.util.concurrent.CompletableFuture; 4 | import java.util.function.Consumer; 5 | 6 | import com.replaymod.core.ReplayMod; 7 | 8 | public final class ReplayModHooks { 9 | private ReplayModHooks() {}; 10 | private static CompletableFuture future = new CompletableFuture<>(); 11 | 12 | /** 13 | * Return a CompletableFuture that completes once the Replay Mod 14 | * has finished initializing. 15 | * 16 | * @return The future. 17 | */ 18 | public static CompletableFuture waitForInit() { 19 | return future; 20 | } 21 | 22 | /** 23 | * Run a piece of code directly after the Replay Mod has initialized. If the 24 | * replay mod is already loaded, run the code immedietly. 25 | * 26 | * @param r The code to run. 27 | */ 28 | public static void onReplayModInit(Consumer r) { 29 | future.thenAccept(r); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ccap_viewer/src/main/java/com/igrium/replayfps_viewer/ReplayFPSViewer.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps_viewer; 2 | 3 | import com.igrium.craftfx.application.ApplicationManager; 4 | import com.igrium.craftfx.application.ApplicationType; 5 | import com.mojang.logging.LogUtils; 6 | 7 | import net.fabricmc.api.ClientModInitializer; 8 | import net.minecraft.util.Identifier; 9 | 10 | public class ReplayFPSViewer implements ClientModInitializer { 11 | 12 | public static final ApplicationType VIEWER = ApplicationType 13 | .register(new Identifier("replayfps_viewer"), new ApplicationType<>(ClientCapViewer::new)); 14 | 15 | @Override 16 | public void onInitializeClient() { 17 | LogUtils.getLogger().info("Hello from the CCap viewer!"); 18 | } 19 | 20 | public static void launchViewer() { 21 | try { 22 | ApplicationManager.getInstance().launch(VIEWER, null); 23 | } catch (Exception e) { 24 | LogUtils.getLogger().error("Error launching CraftFX.", e); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/FullReplaySenderMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | 4 | import org.spongepowered.asm.mixin.Mixin; 5 | import org.spongepowered.asm.mixin.injection.At; 6 | 7 | import com.igrium.replayfps.core.networking.PacketRedirectors; 8 | import com.replaymod.lib.com.llamalad7.mixinextras.injector.ModifyExpressionValue; 9 | import com.replaymod.replay.FullReplaySender; 10 | 11 | import net.minecraft.network.packet.Packet; 12 | 13 | @Mixin(FullReplaySender.class) 14 | public class FullReplaySenderMixin { 15 | 16 | // Mix into if(BAD_PACKETS.contains(p.getClass())) return null; 17 | @ModifyExpressionValue(method = "processPacket", at = @At(value = "INVOKE", target = "Ljava/util/List;contains(Ljava/lang/Object;)Z")) 18 | private boolean replayfps$checkForRedirect(boolean original, Packet packet) { 19 | if (PacketRedirectors.shouldRedirect(packet)) { 20 | PacketRedirectors.REDIRECT_QUEUED.add(packet); 21 | return false; 22 | } 23 | return original; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/mixin/ClientPlayerEntityMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.Shadow; 5 | import org.spongepowered.asm.mixin.injection.At; 6 | import org.spongepowered.asm.mixin.injection.Inject; 7 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 8 | 9 | import com.igrium.replayfps.game.event.ClientPlayerEvents; 10 | 11 | import net.minecraft.client.MinecraftClient; 12 | import net.minecraft.client.network.ClientPlayerEntity; 13 | import net.minecraft.world.GameMode; 14 | 15 | @Mixin(ClientPlayerEntity.class) 16 | public class ClientPlayerEntityMixin { 17 | 18 | @Shadow 19 | private MinecraftClient client; 20 | 21 | @Inject(method = "onGameModeChanged", at = @At("HEAD")) 22 | void replayfps$gamemodeChanged(GameMode gameMode, CallbackInfo ci) { 23 | ClientPlayerEvents.SET_GAMEMODE.invoker().onSetGamemode((ClientPlayerEntity) (Object) this, client.interactionManager.getCurrentGameMode(), gameMode); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ccap_viewer/src/main/resources/fabric.mod.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 1, 3 | "id": "replayfps_viewer", 4 | "version": "${version}", 5 | "name": "ReplayFPS Debugger", 6 | "description": "This is an example description! Tell everyone what your mod is about!", 7 | "authors": [ 8 | "Me!" 9 | ], 10 | "contact": { 11 | "homepage": "https://fabricmc.net/", 12 | "sources": "https://github.com/FabricMC/fabric-example-mod" 13 | }, 14 | "license": "CC0-1.0", 15 | "icon": "assets/replayfps/icon.png", 16 | "environment": "*", 17 | "entrypoints": { 18 | "client": [ 19 | "com.igrium.replayfps_viewer.ReplayFPSViewer" 20 | ] 21 | }, 22 | "mixins": [ 23 | "replayfps_viewer.mixins.json" 24 | ], 25 | "depends": { 26 | "fabricloader": ">=0.14.24", 27 | "minecraft": "~1.20.1", 28 | "java": ">=17", 29 | "fabric-api": "*", 30 | "replayfps": "${version}", 31 | "craftfx": "~0.2.0+1.20.1" 32 | }, 33 | "suggests": { 34 | "another-mod": "*" 35 | } 36 | } -------------------------------------------------------------------------------- /ccap_viewer/src/main/java/com/igrium/replayfps_viewer/mixin/TitleScreenMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps_viewer.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.injection.At; 5 | import org.spongepowered.asm.mixin.injection.Inject; 6 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 7 | 8 | import com.igrium.replayfps_viewer.ReplayFPSViewer; 9 | 10 | import net.minecraft.client.gui.screen.Screen; 11 | import net.minecraft.client.gui.screen.TitleScreen; 12 | import net.minecraft.client.gui.widget.ButtonWidget; 13 | import net.minecraft.text.Text; 14 | 15 | @Mixin(TitleScreen.class) 16 | public class TitleScreenMixin extends Screen { 17 | 18 | protected TitleScreenMixin(Text title) { 19 | super(title); 20 | } 21 | 22 | @Inject(method = "init()V", at = @At("RETURN")) 23 | protected void replayfps$onInit(CallbackInfo ci) { 24 | this.addDrawableChild(ButtonWidget.builder(Text.literal("Debug replays"), (button) -> { 25 | ReplayFPSViewer.launchViewer(); 26 | }).build()); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/networking/DefaultFakePackets.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.networking; 2 | 3 | import com.igrium.replayfps.core.networking.event.FakePacketRegistrationCallback; 4 | import com.igrium.replayfps.game.networking.fake_packet.SetGamemodeFakePacket; 5 | import com.igrium.replayfps.game.networking.fake_packet.UpdateHotbarFakePacket; 6 | import com.igrium.replayfps.game.networking.fake_packet.UpdateSelectedSlotFakePacket; 7 | 8 | public class DefaultFakePackets { 9 | public static void registerDefaults() { 10 | FakePacketRegistrationCallback.EVENT.register(manager -> { 11 | manager.registerReceiver(UpdateHotbarFakePacket.TYPE, UpdateHotbarFakePacket::apply); 12 | manager.registerReceiver(UpdateSelectedSlotFakePacket.TYPE, UpdateSelectedSlotFakePacket::apply); 13 | manager.registerReceiver(SetGamemodeFakePacket.TYPE, SetGamemodeFakePacket::apply); 14 | }); 15 | 16 | UpdateHotbarFakePacket.registerListener(); 17 | UpdateSelectedSlotFakePacket.registerListener(); 18 | SetGamemodeFakePacket.registerListener(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/SkipHudDuringRenderMixinSquared.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.injection.At; 5 | import org.spongepowered.asm.mixin.injection.Inject; 6 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 7 | 8 | import com.bawnorton.mixinsquared.TargetHandler; 9 | import com.igrium.replayfps.ReplayFPS; 10 | import com.igrium.replayfps.core.util.PlaybackUtils; 11 | 12 | import net.minecraft.client.gui.hud.InGameHud; 13 | 14 | @Mixin(value = InGameHud.class, priority = 1500) 15 | public class SkipHudDuringRenderMixinSquared { 16 | @TargetHandler( 17 | mixin = "com.replaymod.render.mixin.Mixin_SkipHudDuringRender", 18 | name = "replayModRender_skipHudDuringRender" 19 | ) 20 | @Inject(method = "@MixinSquared:Handler", at = @At("HEAD"), cancellable = true) 21 | void replayfps$dontSkipHudDuringRender(CallbackInfo ci) { 22 | if (PlaybackUtils.isViewingPlaybackPlayer() && ReplayFPS.getConfig().shouldDrawHud()) { 23 | ci.cancel(); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/networking/redirector/HealthHungerRedirector.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.networking.redirector; 2 | 3 | import com.igrium.replayfps.core.networking.PacketRedirector; 4 | 5 | import net.minecraft.client.MinecraftClient; 6 | import net.minecraft.entity.player.PlayerEntity; 7 | import net.minecraft.network.packet.s2c.play.HealthUpdateS2CPacket; 8 | 9 | public class HealthHungerRedirector implements PacketRedirector { 10 | 11 | @Override 12 | public Class getPacketClass() { 13 | return HealthUpdateS2CPacket.class; 14 | } 15 | 16 | @Override 17 | public boolean shouldRedirect(HealthUpdateS2CPacket packet, PlayerEntity localPlayer, MinecraftClient client) { 18 | return true; 19 | } 20 | 21 | @Override 22 | public void redirect(HealthUpdateS2CPacket packet, PlayerEntity localPlayer, MinecraftClient client) { 23 | client.execute(() -> { 24 | localPlayer.getHungerManager().setFoodLevel(packet.getFood()); 25 | localPlayer.getHungerManager().setSaturationLevel(packet.getSaturation()); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/channel/handler/PlayerVelocityChannelHandler.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.channel.handler; 2 | 3 | import com.igrium.replayfps.core.channel.ChannelHandler; 4 | import com.igrium.replayfps.core.channel.type.ChannelType; 5 | import com.igrium.replayfps.core.channel.type.ChannelTypes; 6 | import com.igrium.replayfps.core.playback.ClientPlaybackContext; 7 | import com.igrium.replayfps.core.recording.ClientCaptureContext; 8 | 9 | import net.minecraft.util.math.Vec3d; 10 | 11 | public class PlayerVelocityChannelHandler implements ChannelHandler { 12 | 13 | @Override 14 | public ChannelType getChannelType() { 15 | return ChannelTypes.VEC3D; 16 | } 17 | 18 | @Override 19 | public Vec3d capture(ClientCaptureContext context) throws Exception { 20 | return context.localPlayer().getVelocity(); 21 | } 22 | 23 | @Override 24 | public void apply(Vec3d val, ClientPlaybackContext context) throws Exception { 25 | context.localPlayer().ifPresent(player -> player.setVelocity(val)); 26 | } 27 | 28 | @Override 29 | public boolean shouldInterpolate() { 30 | return true; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/resources/fabric.mod.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 1, 3 | "id": "replayfps", 4 | "version": "${version}", 5 | "name": "Replay FPS", 6 | "description": "An addon to the Replay Mod that improves how first-person views are handled.", 7 | "authors": [ 8 | "Igrium" 9 | ], 10 | "contact": { 11 | "homepage": "https://github.com/Igrium/ReplayFPS", 12 | "sources": "https://github.com/Igrium/ReplayFPS" 13 | }, 14 | "license": "MIT", 15 | "icon": "assets/replayfps/logo.png", 16 | "environment": "*", 17 | "entrypoints": { 18 | "main": [ 19 | "com.igrium.replayfps.ReplayFPS" 20 | ], 21 | "modmenu": [ 22 | "com.igrium.replayfps.ReplayFPSModMenu" 23 | ] 24 | }, 25 | "mixins": [ 26 | "replayfps.mixins.json", 27 | "replayfps.mixins.gameplay.json" 28 | ], 29 | "depends": { 30 | "fabricloader": ">=0.14.24", 31 | "minecraft": "~1.20.2", 32 | "java": ">=17", 33 | "fabric-api": "*", 34 | "replaymod": ">=1.20.2-2.6.14", 35 | "cloth-config": ">=11.1.106" 36 | }, 37 | "suggests": { 38 | "another-mod": "*" 39 | } 40 | } -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/channel/handler/HorizontalSpeedHandler.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.channel.handler; 2 | 3 | import com.igrium.replayfps.core.channel.ChannelHandler; 4 | import com.igrium.replayfps.core.channel.type.ChannelType; 5 | import com.igrium.replayfps.core.channel.type.ChannelTypes; 6 | import com.igrium.replayfps.core.playback.ClientPlaybackContext; 7 | import com.igrium.replayfps.core.recording.ClientCaptureContext; 8 | 9 | public class HorizontalSpeedHandler implements ChannelHandler { 10 | 11 | @Override 12 | public ChannelType getChannelType() { 13 | return ChannelTypes.FLOAT; 14 | } 15 | 16 | @Override 17 | public Float capture(ClientCaptureContext context) throws Exception { 18 | return context.localPlayer().horizontalSpeed; 19 | } 20 | 21 | @Override 22 | public void apply(Float val, ClientPlaybackContext context) throws Exception { 23 | context.localPlayer().ifPresent(player -> { 24 | player.prevHorizontalSpeed = player.horizontalSpeed; 25 | player.horizontalSpeed = val; 26 | }); 27 | } 28 | 29 | @Override 30 | public boolean applyPerTick() { 31 | return true; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/channel/handler/PlayerStrideChannelHandler.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.channel.handler; 2 | 3 | import com.igrium.replayfps.core.channel.ChannelHandler; 4 | import com.igrium.replayfps.core.channel.type.ChannelType; 5 | import com.igrium.replayfps.core.channel.type.ChannelTypes; 6 | import com.igrium.replayfps.core.playback.ClientPlaybackContext; 7 | import com.igrium.replayfps.core.recording.ClientCaptureContext; 8 | 9 | public class PlayerStrideChannelHandler implements ChannelHandler { 10 | 11 | @Override 12 | public ChannelType getChannelType() { 13 | return ChannelTypes.FLOAT; 14 | } 15 | 16 | @Override 17 | public Float capture(ClientCaptureContext context) throws Exception { 18 | return context.localPlayer().strideDistance; 19 | } 20 | 21 | @Override 22 | public void apply(Float val, ClientPlaybackContext context) throws Exception { 23 | context.localPlayer().ifPresent(player -> { 24 | player.prevStrideDistance = player.strideDistance; 25 | player.strideDistance = val; 26 | }); 27 | } 28 | 29 | @Override 30 | public boolean applyPerTick() { 31 | return true; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/mixin/PlayerInventoryMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.Shadow; 5 | import org.spongepowered.asm.mixin.Unique; 6 | import org.spongepowered.asm.mixin.injection.At; 7 | import org.spongepowered.asm.mixin.injection.Inject; 8 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 9 | 10 | import com.igrium.replayfps.game.event.ClientPlayerEvents; 11 | 12 | import net.minecraft.entity.player.PlayerInventory; 13 | import net.minecraft.item.ItemStack; 14 | 15 | @Mixin(PlayerInventory.class) 16 | public abstract class PlayerInventoryMixin { 17 | 18 | @Shadow 19 | public abstract ItemStack getStack(int slot); 20 | 21 | @Shadow 22 | int selectedSlot; 23 | 24 | @Unique 25 | private int prevSelectedSlot = -1; 26 | 27 | 28 | @Inject(method = "updateItems", at = @At("RETURN")) 29 | void replayfps$onUpdateItems(CallbackInfo ci) { 30 | if (selectedSlot != prevSelectedSlot) { 31 | ClientPlayerEvents.SELECT_SLOT.invoker().onSelectSlot((PlayerInventory) (Object) this, selectedSlot); 32 | } 33 | prevSelectedSlot = selectedSlot; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/test/java/com/igrium/replayfps/test/SimpleSingleThreadExecutor.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.test; 2 | 3 | import java.util.Queue; 4 | import java.util.concurrent.ConcurrentLinkedDeque; 5 | import java.util.concurrent.Executor; 6 | import java.util.concurrent.ThreadFactory; 7 | import java.util.concurrent.locks.LockSupport; 8 | 9 | public class SimpleSingleThreadExecutor implements Executor { 10 | private Queue queue = new ConcurrentLinkedDeque<>(); 11 | private Thread thread; 12 | private boolean shouldShutdown; 13 | 14 | public SimpleSingleThreadExecutor(ThreadFactory threadFactory) { 15 | thread = threadFactory.newThread(this::runThread); 16 | thread.setDaemon(true); 17 | thread.start(); 18 | } 19 | 20 | 21 | @Override 22 | public void execute(Runnable command) { 23 | queue.add(command); 24 | LockSupport.unpark(thread); 25 | } 26 | 27 | private void runThread() { 28 | while (!shouldShutdown) { 29 | while (!queue.isEmpty()) { 30 | queue.poll().run(); 31 | } 32 | LockSupport.parkNanos(100000l); 33 | } 34 | } 35 | 36 | public void shutdown() { 37 | shouldShutdown = true; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/networking/event/PacketReceivedEvent.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.networking.event; 2 | 3 | import net.fabricmc.fabric.api.event.Event; 4 | import net.fabricmc.fabric.api.event.EventFactory; 5 | import net.minecraft.network.listener.PacketListener; 6 | import net.minecraft.network.packet.Packet; 7 | 8 | /** 9 | * Called when a packet of any kind is received. 10 | */ 11 | public interface PacketReceivedEvent { 12 | 13 | public static Event EVENT = EventFactory.createArrayBacked(PacketReceivedEvent.class, 14 | listeners -> (packet, listener) -> { 15 | for (var l : listeners) { 16 | if (l.onPacketReceived(packet, listener)) return true; 17 | } 18 | return false; 19 | }); 20 | 21 | /** 22 | * Called when a packet of any kind is received. 23 | * @param packet The packet. 24 | * @param listener Relevant packet listener. 25 | * @return If this packet should be "consumed". If true no other 26 | * recievers (including the default one) will recieve the packet. 27 | */ 28 | public boolean onPacketReceived(Packet packet, PacketListener listener); 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/networking/redirector/UpdateSelectedSlotRedirector.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.networking.redirector; 2 | 3 | import com.igrium.replayfps.core.networking.PacketRedirector; 4 | 5 | import net.minecraft.client.MinecraftClient; 6 | import net.minecraft.entity.player.PlayerEntity; 7 | import net.minecraft.entity.player.PlayerInventory; 8 | import net.minecraft.network.packet.s2c.play.UpdateSelectedSlotS2CPacket; 9 | 10 | public class UpdateSelectedSlotRedirector implements PacketRedirector { 11 | 12 | @Override 13 | public Class getPacketClass() { 14 | return UpdateSelectedSlotS2CPacket.class; 15 | } 16 | 17 | @Override 18 | public boolean shouldRedirect(UpdateSelectedSlotS2CPacket packet, PlayerEntity localPlayer, 19 | MinecraftClient client) { 20 | return true; 21 | } 22 | 23 | @Override 24 | public void redirect(UpdateSelectedSlotS2CPacket packet, PlayerEntity localPlayer, MinecraftClient client) { 25 | client.execute(() -> { 26 | if (PlayerInventory.isValidHotbarIndex(packet.getSlot())) { 27 | localPlayer.getInventory().selectedSlot = packet.getSlot(); 28 | } 29 | }); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/SkipOverlayDuringRenderMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.injection.At; 5 | import org.spongepowered.asm.mixin.injection.Inject; 6 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 7 | 8 | import com.replaymod.lib.de.johni0702.minecraft.gui.GuiRenderer; 9 | import com.replaymod.lib.de.johni0702.minecraft.gui.RenderInfo; 10 | import com.replaymod.lib.de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension; 11 | import com.replaymod.render.hooks.EntityRendererHandler; 12 | import com.replaymod.replay.gui.overlay.GuiReplayOverlay; 13 | 14 | import net.minecraft.client.MinecraftClient; 15 | 16 | @Mixin(GuiReplayOverlay.class) 17 | public class SkipOverlayDuringRenderMixin { 18 | 19 | @Inject(method = "draw", at = @At("HEAD"), cancellable = true, remap = false) 20 | @SuppressWarnings("resource") 21 | void replayfps$dontDrawIfRendering(GuiRenderer renderer, ReadableDimension size, RenderInfo renderInfo,CallbackInfo ci) { 22 | if (((EntityRendererHandler.IEntityRenderer) MinecraftClient.getInstance().gameRenderer) 23 | .replayModRender_getHandler() != null) { 24 | ci.cancel(); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/playback/ClientPlaybackContext.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.playback; 2 | 3 | import java.util.Optional; 4 | 5 | import com.replaymod.replay.ReplayHandler; 6 | 7 | import net.minecraft.client.MinecraftClient; 8 | import net.minecraft.client.network.AbstractClientPlayerEntity; 9 | import net.minecraft.client.render.Camera; 10 | import net.minecraft.client.world.ClientWorld; 11 | import net.minecraft.entity.Entity; 12 | 13 | public interface ClientPlaybackContext { 14 | /** 15 | * The Minecraft client. 16 | */ 17 | MinecraftClient client(); 18 | 19 | /** 20 | * The handler for the replay being played back. 21 | */ 22 | ReplayHandler replayHandler(); 23 | 24 | /** 25 | * The current camera entity. 26 | */ 27 | Optional cameraEntity(); 28 | 29 | /** 30 | * The player that the client was controlling during recording. 31 | */ 32 | Optional localPlayer(); 33 | 34 | /** 35 | * The active camera. 36 | */ 37 | Camera camera(); 38 | 39 | /** 40 | * The current timestamp in the replay (milliseconds). 41 | */ 42 | int timestamp(); 43 | 44 | /** 45 | * The current world. 46 | */ 47 | Optional world(); 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/networking/event/CustomPacketReceivedEvent.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.networking.event; 2 | 3 | import net.fabricmc.fabric.api.event.Event; 4 | import net.fabricmc.fabric.api.event.EventFactory; 5 | import net.fabricmc.fabric.impl.networking.payload.ResolvablePayload; 6 | 7 | /** 8 | * Called when a custom packet of any kind is recieved on the client. 9 | */ 10 | public interface CustomPacketReceivedEvent { 11 | 12 | public static final Event EVENT = EventFactory.createArrayBacked( 13 | CustomPacketReceivedEvent.class, 14 | listeners -> payload -> { 15 | 16 | for (CustomPacketReceivedEvent listener : listeners) { 17 | if (listener.onPacketReceived(payload)) 18 | return true; 19 | } 20 | 21 | return false; 22 | }); 23 | 24 | /** 25 | * Called whenever a custom packet of any kind is recieved on the client. 26 | * 27 | * @param payload The packet's payload. 28 | * @return If this packet should be "consumed". If true no other 29 | * recievers (including the registered one) will recieve the packet. 30 | */ 31 | public boolean onPacketReceived(ResolvablePayload payload); 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/AbstractChanneledNetworkAddonMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.injection.At; 5 | import org.spongepowered.asm.mixin.injection.Inject; 6 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 7 | 8 | import com.igrium.replayfps.core.networking.event.CustomPacketReceivedEvent; 9 | 10 | import net.fabricmc.fabric.impl.networking.AbstractChanneledNetworkAddon; 11 | import net.fabricmc.fabric.impl.networking.payload.ResolvablePayload; 12 | 13 | // TODO: Should we make and register a custom subclass instead of mixing in? 14 | @Mixin(value = AbstractChanneledNetworkAddon.class, remap = false) 15 | public class AbstractChanneledNetworkAddonMixin { 16 | 17 | @Inject(method = "handle", at = @At(value = "INVOKE", 18 | target = "Lnet/fabricmc/fabric/impl/networking/AbstractChanneledNetworkAddon;getHandler(Lnet/minecraft/util/Identifier;)Ljava/lang/Object;"), 19 | remap = true, 20 | cancellable = true) 21 | public void replayfps$handle(ResolvablePayload payload, CallbackInfoReturnable ci) { 22 | if (CustomPacketReceivedEvent.EVENT.invoker().onPacketReceived(payload)) { 23 | ci.setReturnValue(true); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/channel/handler/PlayerPosChannelHandler.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.channel.handler; 2 | 3 | import com.igrium.replayfps.core.channel.ChannelHandler; 4 | import com.igrium.replayfps.core.channel.type.ChannelType; 5 | import com.igrium.replayfps.core.channel.type.ChannelTypes; 6 | import com.igrium.replayfps.core.playback.ClientPlaybackContext; 7 | import com.igrium.replayfps.core.recording.ClientCaptureContext; 8 | 9 | import net.minecraft.util.math.Vec3d; 10 | 11 | public class PlayerPosChannelHandler implements ChannelHandler { 12 | 13 | @Override 14 | public ChannelType getChannelType() { 15 | return ChannelTypes.VEC3D; 16 | } 17 | 18 | @Override 19 | public Vec3d capture(ClientCaptureContext context) { 20 | return context.localPlayer().getPos(); 21 | } 22 | 23 | @Override 24 | public void apply(Vec3d val, ClientPlaybackContext context) { 25 | if (context.localPlayer().isPresent()) { 26 | context.localPlayer().get().setPosition(val); 27 | } 28 | // context.localPlayer().ifPresent(player -> { 29 | // GlobalReplayContext.ENTITY_POS_OVERRIDES.put(player, val); 30 | // }); 31 | } 32 | 33 | 34 | @Override 35 | public boolean applyPerTick() { 36 | return true; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/event/ClientPlayerEvents.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.event; 2 | 3 | import net.fabricmc.fabric.api.event.Event; 4 | import net.fabricmc.fabric.api.event.EventFactory; 5 | import net.minecraft.client.network.ClientPlayerEntity; 6 | import net.minecraft.entity.player.PlayerInventory; 7 | import net.minecraft.world.GameMode; 8 | 9 | public class ClientPlayerEvents { 10 | public static final Event SET_GAMEMODE = EventFactory.createArrayBacked( 11 | SetGamemodeEvent.class, listeners -> (player, oldGamemode, newGamemode) -> { 12 | for (var l : listeners) { 13 | l.onSetGamemode(player, oldGamemode, newGamemode); 14 | } 15 | }); 16 | 17 | public static final Event SELECT_SLOT = EventFactory.createArrayBacked( 18 | SelectSlotEvent.class, listeners -> (inventory, slot) -> { 19 | for (var l : listeners) { 20 | l.onSelectSlot(inventory, slot); 21 | } 22 | }); 23 | 24 | public static interface SetGamemodeEvent { 25 | void onSetGamemode(ClientPlayerEntity player, GameMode oldGamemode, GameMode newGamemode); 26 | } 27 | 28 | public static interface SelectSlotEvent { 29 | public void onSelectSlot(PlayerInventory inventory, int slot); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/channel/type/PlaceholderChannel.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.channel.type; 2 | 3 | import java.io.DataInput; 4 | import java.io.DataOutput; 5 | import java.io.IOException; 6 | 7 | /** 8 | * A channel type designed to be used as a placeholder when a channel type was not found. 9 | */ 10 | public class PlaceholderChannel implements ChannelType { 11 | 12 | private final int size; 13 | private final byte[] buffer; 14 | 15 | public PlaceholderChannel(int size) { 16 | this.size = size; 17 | this.buffer = new byte[size]; 18 | } 19 | 20 | @Override 21 | public Class getType() { 22 | return Object.class; 23 | } 24 | 25 | @Override 26 | public int getSize() { 27 | return size; 28 | } 29 | 30 | @Override 31 | public Object read(DataInput in) throws IOException { 32 | in.readFully(buffer); 33 | return null; 34 | } 35 | 36 | @Override 37 | public void write(DataOutput out, Object val) throws IOException { 38 | for (int i = 0; i < buffer.length; i++) { 39 | buffer[i] = 0; 40 | } 41 | out.write(buffer); 42 | } 43 | 44 | @Override 45 | public Object defaultValue() { 46 | return null; 47 | } 48 | 49 | @Override 50 | public String getName() { 51 | return "[unknown]"; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/ConnectionEventHandlerMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.Shadow; 5 | import org.spongepowered.asm.mixin.injection.At; 6 | import org.spongepowered.asm.mixin.injection.Inject; 7 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 8 | 9 | import com.igrium.replayfps.core.events.RecordingEvents; 10 | import com.replaymod.recording.handler.ConnectionEventHandler; 11 | import com.replaymod.recording.packet.PacketListener; 12 | 13 | @Mixin(ConnectionEventHandler.class) 14 | public class ConnectionEventHandlerMixin { 15 | 16 | @Shadow(remap = false) 17 | private PacketListener packetListener; 18 | 19 | @Inject(method = "onConnectedToServerEvent", at = @At(value = "TAIL"), remap = false) 20 | void finishReplaySetup(CallbackInfo ci) { 21 | RecordingEvents.STARTED_RECORDING.invoker().onStartRecording(packetListener, ((PacketListenerAccessor) packetListener).getReplayFile()); 22 | } 23 | 24 | @Inject(method = "reset", at = @At("HEAD"), remap = false) 25 | void reset(CallbackInfo ci) { 26 | // This is easier than trying to target the middle of the real if statement. 27 | if (packetListener != null) { 28 | RecordingEvents.STOPPED_RECORDING.invoker().onStoppedRecording(); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/channel/type/Matrix4fChannelType.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.channel.type; 2 | 3 | import java.io.DataInput; 4 | import java.io.DataOutput; 5 | import java.io.IOException; 6 | 7 | import org.joml.Matrix4f; 8 | import org.joml.Matrix4fc; 9 | 10 | public class Matrix4fChannelType implements ChannelType { 11 | 12 | private static final int BUFFER_SIZE = 4 * 4; 13 | private float[] buffer = new float[BUFFER_SIZE]; 14 | 15 | @Override 16 | public Class getType() { 17 | return Matrix4fc.class; 18 | } 19 | 20 | @Override 21 | public int getSize() { 22 | return BUFFER_SIZE * Float.BYTES; 23 | } 24 | 25 | @Override 26 | public Matrix4fc read(DataInput in) throws IOException { 27 | for (int i = 0; i < buffer.length; i++) { 28 | buffer[i] = in.readFloat(); 29 | } 30 | return new Matrix4f().set(buffer); 31 | } 32 | 33 | @Override 34 | public void write(DataOutput out, Matrix4fc val) throws IOException { 35 | val.get(buffer); 36 | for (int i = 0; i < buffer.length; i++) { 37 | out.writeFloat(buffer[i]); 38 | } 39 | } 40 | 41 | @Override 42 | public Matrix4fc defaultValue() { 43 | return new Matrix4f(); 44 | } 45 | 46 | @Override 47 | public String getName() { 48 | return "Matrix4f"; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/channel/type/Vec3dChannelType.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.channel.type; 2 | 3 | import java.io.DataInput; 4 | import java.io.DataOutput; 5 | import java.io.IOException; 6 | 7 | import net.minecraft.util.math.Vec3d; 8 | 9 | public class Vec3dChannelType implements ChannelType { 10 | 11 | @Override 12 | public Class getType() { 13 | return Vec3d.class; 14 | } 15 | 16 | @Override 17 | public int getSize() { 18 | return Double.BYTES * 3; 19 | } 20 | 21 | @Override 22 | public Vec3d read(DataInput in) throws IOException { 23 | double x = in.readDouble(); 24 | double y = in.readDouble(); 25 | double z = in.readDouble(); 26 | return new Vec3d(x, y, z); 27 | } 28 | 29 | @Override 30 | public void write(DataOutput out, Vec3d val) throws IOException { 31 | out.writeDouble(val.getX()); 32 | out.writeDouble(val.getY()); 33 | out.writeDouble(val.getZ()); 34 | } 35 | 36 | @Override 37 | public Vec3d defaultValue() { 38 | return new Vec3d(0, 0, 0); 39 | } 40 | 41 | @Override 42 | public Vec3d interpolate(Vec3d from, Vec3d to, float delta) { 43 | return from.lerp(to, delta); 44 | } 45 | 46 | @Override 47 | public float[] getRawValues(Vec3d value) { 48 | return new float[] { (float) value.x, (float) value.y, (float) value.z }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/channel/type/Vector2fChannelType.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.channel.type; 2 | 3 | import java.io.DataInput; 4 | import java.io.DataOutput; 5 | import java.io.IOException; 6 | 7 | import org.joml.Vector2f; 8 | import org.joml.Vector2fc; 9 | 10 | public class Vector2fChannelType implements ChannelType { 11 | 12 | @Override 13 | public Class getType() { 14 | return Vector2fc.class; 15 | } 16 | 17 | @Override 18 | public int getSize() { 19 | return Float.BYTES * 2; 20 | } 21 | 22 | @Override 23 | public Vector2fc read(DataInput in) throws IOException { 24 | float x = in.readFloat(); 25 | float y = in.readFloat(); 26 | return new Vector2f(x, y); 27 | } 28 | 29 | @Override 30 | public void write(DataOutput out, Vector2fc val) throws IOException { 31 | out.writeFloat(val.x()); 32 | out.writeFloat(val.y()); 33 | } 34 | 35 | @Override 36 | public Vector2fc defaultValue() { 37 | return new Vector2f(); 38 | } 39 | 40 | @Override 41 | public Vector2fc interpolate(Vector2fc from, Vector2fc to, float delta) { 42 | return from.lerp(to, delta, new Vector2f()); 43 | } 44 | 45 | @Override 46 | public String getName() { 47 | return "Vector2f"; 48 | } 49 | 50 | @Override 51 | public float[] getRawValues(Vector2fc value) { 52 | return new float[] { value.x(), value.y() }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # Automatically build the project and run any configured tests for every push 2 | # and submitted pull request. This can help catch issues that only occur on 3 | # certain platforms or Java versions, and provides a first line of defence 4 | # against bad commits. 5 | 6 | name: build 7 | on: [pull_request, push] 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | # Use these Java versions 14 | java: [ 15 | 17, # Current Java LTS & minimum supported by Minecraft 16 | ] 17 | # and run on both Linux and Windows 18 | os: [ubuntu-22.04, windows-2022] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - name: checkout repository 22 | uses: actions/checkout@v3 23 | - name: validate gradle wrapper 24 | uses: gradle/wrapper-validation-action@v1 25 | - name: setup jdk ${{ matrix.java }} 26 | uses: actions/setup-java@v3 27 | with: 28 | java-version: ${{ matrix.java }} 29 | distribution: 'microsoft' 30 | - name: make gradle wrapper executable 31 | if: ${{ runner.os != 'Windows' }} 32 | run: chmod +x ./gradlew 33 | - name: build 34 | run: ./gradlew build 35 | - name: capture build artifacts 36 | if: ${{ runner.os == 'Linux' && matrix.java == '17' }} # Only upload artifacts built from latest java on one OS 37 | uses: actions/upload-artifact@v3 38 | with: 39 | name: Artifacts 40 | path: build/libs/ -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/channel/type/Vector4fChannelType.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.channel.type; 2 | 3 | import java.io.DataInput; 4 | import java.io.DataOutput; 5 | import java.io.IOException; 6 | 7 | import org.joml.Vector4f; 8 | import org.joml.Vector4fc; 9 | 10 | public class Vector4fChannelType implements ChannelType { 11 | 12 | @Override 13 | public Class getType() { 14 | return Vector4fc.class; 15 | } 16 | 17 | @Override 18 | public int getSize() { 19 | return Float.BYTES * 4; 20 | } 21 | 22 | @Override 23 | public Vector4fc read(DataInput in) throws IOException { 24 | float x = in.readFloat(); 25 | float y = in.readFloat(); 26 | float z = in.readFloat(); 27 | float w = in.readFloat(); 28 | 29 | return new Vector4f(x, y, z, w); 30 | } 31 | 32 | @Override 33 | public void write(DataOutput out, Vector4fc val) throws IOException { 34 | out.writeFloat(val.x()); 35 | out.writeFloat(val.y()); 36 | out.writeFloat(val.z()); 37 | out.writeFloat(val.w()); 38 | } 39 | 40 | @Override 41 | public Vector4fc defaultValue() { 42 | return new Vector4f(); 43 | } 44 | 45 | @Override 46 | public String getName() { 47 | return "Vector4f"; 48 | } 49 | 50 | @Override 51 | public float[] getRawValues(Vector4fc value) { 52 | return new float[] { value.x(), value.y(), value.z(), value.w() }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /ccap_viewer/src/main/java/com/igrium/replayfps_viewer/ui/LoadingPopup.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps_viewer.ui; 2 | 3 | import java.io.IOException; 4 | 5 | import javafx.fxml.FXML; 6 | import javafx.fxml.FXMLLoader; 7 | import javafx.scene.Parent; 8 | import javafx.scene.Scene; 9 | import javafx.scene.control.Label; 10 | import javafx.stage.Modality; 11 | import javafx.stage.Stage; 12 | import javafx.stage.Window; 13 | 14 | public class LoadingPopup { 15 | 16 | @FXML 17 | private Label label; 18 | 19 | public Label getLabel() { 20 | return label; 21 | } 22 | 23 | private Stage stage; 24 | 25 | public Stage getStage() { 26 | return stage; 27 | } 28 | 29 | public static LoadingPopup createLoadingPopup(Window owner) { 30 | FXMLLoader loader = new FXMLLoader(LoadingPopup.class.getResource("/assets/replayfps_viewer/ui/loading.fxml")); 31 | 32 | Parent loading; 33 | try { 34 | loading = loader.load(); 35 | } catch (IOException e) { 36 | throw new RuntimeException(e); 37 | } 38 | 39 | Scene scene = new Scene(loading); 40 | Stage stage = new Stage(); 41 | 42 | stage.setScene(scene); 43 | stage.setTitle("loading"); 44 | stage.setResizable(false); 45 | stage.initModality(Modality.WINDOW_MODAL); 46 | stage.initOwner(owner); 47 | 48 | LoadingPopup controller = loader.getController(); 49 | controller.stage = stage; 50 | 51 | return controller; 52 | } 53 | 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/networking/redirector/ExperienceUpdateRedirector.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.networking.redirector; 2 | 3 | import com.igrium.replayfps.core.networking.PacketRedirector; 4 | 5 | import net.minecraft.client.MinecraftClient; 6 | import net.minecraft.entity.player.PlayerEntity; 7 | import net.minecraft.network.packet.s2c.play.ExperienceBarUpdateS2CPacket; 8 | 9 | public class ExperienceUpdateRedirector implements PacketRedirector { 10 | 11 | @Override 12 | public Class getPacketClass() { 13 | return ExperienceBarUpdateS2CPacket.class; 14 | } 15 | 16 | @Override 17 | public boolean shouldRedirect(ExperienceBarUpdateS2CPacket packet, PlayerEntity localPlayer, 18 | MinecraftClient client) { 19 | return true; 20 | } 21 | 22 | @Override 23 | public void redirect(ExperienceBarUpdateS2CPacket packet, PlayerEntity localPlayer, MinecraftClient client) { 24 | // What's up with these mappings, Yarn? 25 | client.execute(() -> { 26 | localPlayer.experienceProgress = packet.getBarProgress(); 27 | localPlayer.totalExperience = packet.getExperienceLevel(); 28 | localPlayer.experienceLevel = packet.getExperience(); 29 | 30 | // The hud looks for the client's local entity instead of the camera entity. 31 | client.player.setExperience(packet.getBarProgress(), packet.getExperienceLevel(), packet.getExperience()); 32 | }); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/ChatHudMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.injection.At; 5 | import org.spongepowered.asm.mixin.injection.Inject; 6 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 7 | 8 | import com.igrium.replayfps.core.playback.ClientPlaybackModule; 9 | import com.replaymod.replay.FullReplaySender; 10 | import com.replaymod.replay.ReplayHandler; 11 | 12 | import net.minecraft.client.gui.hud.ChatHud; 13 | import net.minecraft.client.gui.hud.MessageIndicator; 14 | import net.minecraft.network.message.MessageSignatureData; 15 | import net.minecraft.text.Text; 16 | 17 | @Mixin(ChatHud.class) 18 | public class ChatHudMixin { 19 | 20 | @Inject(method = "addMessage(Lnet/minecraft/text/Text;Lnet/minecraft/network/message/MessageSignatureData;ILnet/minecraft/client/gui/hud/MessageIndicator;Z)V", 21 | at = @At("HEAD"), cancellable = true) 22 | void replayfps$onAddMessage(Text message, MessageSignatureData signature, int ticks, MessageIndicator indicator, boolean refresh, CallbackInfo ci) { 23 | // Do not display chat messages if we're currently hurrying. 24 | // TODO: implement a system where the hud actually ticks properly in this situation rather than supressing chat. 25 | ReplayHandler handler = ClientPlaybackModule.getInstance().getCurrentReplay(); 26 | if (handler != null && handler.getReplaySender() instanceof FullReplaySender sender) { 27 | if (sender.isHurrying()) ci.cancel(); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ccap_viewer/src/main/java/com/igrium/replayfps_viewer/LoadedClientCap.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps_viewer; 2 | 3 | import java.io.Closeable; 4 | import java.io.File; 5 | import java.io.IOException; 6 | import java.io.RandomAccessFile; 7 | 8 | import com.igrium.replayfps.core.playback.ClientCapReader; 9 | import com.igrium.replayfps.core.recording.ClientCapHeader; 10 | 11 | public class LoadedClientCap implements Closeable { 12 | private final ClientCapReader reader; 13 | private int length; 14 | 15 | public LoadedClientCap(ClientCapReader reader) throws IOException { 16 | this.reader = reader; 17 | reader.readHeader(); 18 | length = reader.countFramesOrThrow(); 19 | } 20 | 21 | public LoadedClientCap(File file) throws IOException { 22 | this.reader = new ClientCapReader(file); 23 | reader.readHeader(); 24 | length = reader.countFramesOrThrow(); 25 | } 26 | 27 | public LoadedClientCap(RandomAccessFile file) throws IOException { 28 | this.reader = new ClientCapReader(file); 29 | reader.readHeader(); 30 | length = reader.countFramesOrThrow(); 31 | } 32 | 33 | public final ClientCapHeader getHeader() { 34 | return reader.getHeader(); 35 | } 36 | 37 | public ClientCapReader getReader() { 38 | return reader; 39 | } 40 | 41 | /** 42 | * Get the length of this client-capture. 43 | * @return Number of frames. 44 | */ 45 | public int getLength() { 46 | return length; 47 | } 48 | 49 | @Override 50 | public void close() throws IOException { 51 | reader.close(); 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/channel/handler/PlayerRotChannelHandler.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.channel.handler; 2 | 3 | import org.joml.Vector2f; 4 | import org.joml.Vector2fc; 5 | 6 | import com.igrium.replayfps.core.channel.ChannelHandler; 7 | import com.igrium.replayfps.core.channel.type.ChannelType; 8 | import com.igrium.replayfps.core.channel.type.ChannelTypes; 9 | import com.igrium.replayfps.core.playback.ClientPlaybackContext; 10 | import com.igrium.replayfps.core.recording.ClientCaptureContext; 11 | 12 | import net.minecraft.client.network.ClientPlayerEntity; 13 | 14 | public class PlayerRotChannelHandler implements ChannelHandler { 15 | 16 | @Override 17 | public ChannelType getChannelType() { 18 | return ChannelTypes.VECTOR2F; 19 | } 20 | 21 | @Override 22 | public Vector2fc capture(ClientCaptureContext context) { 23 | ClientPlayerEntity player = context.localPlayer(); 24 | return new Vector2f(player.getPitch(), player.getYaw()); 25 | } 26 | 27 | @Override 28 | public void apply(Vector2fc val, ClientPlaybackContext context) { 29 | context.localPlayer().ifPresent(player -> { 30 | player.setPitch(val.x()); 31 | player.setYaw(val.y()); 32 | 33 | player.prevPitch = val.x(); 34 | player.prevYaw = val.y(); 35 | 36 | // For some reason, yaw doesn't render properly if we don't do this. 37 | player.setHeadYaw(val.y()); 38 | player.prevHeadYaw = val.y(); 39 | }); 40 | } 41 | 42 | @Override 43 | public boolean shouldInterpolate() { 44 | return true; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/channel/ChannelHandler.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.channel; 2 | 3 | import java.io.DataInput; 4 | import java.io.DataOutput; 5 | 6 | import com.igrium.replayfps.core.channel.type.ChannelType; 7 | import com.igrium.replayfps.core.playback.ClientPlaybackContext; 8 | import com.igrium.replayfps.core.recording.ClientCaptureContext; 9 | 10 | /** 11 | * Handles the application and capturing of a specific animation channel. 12 | * Handlers are not tied to any given recording instance; they are 13 | * registered globally. 14 | */ 15 | public interface ChannelHandler { 16 | public ChannelType getChannelType(); 17 | 18 | public T capture(ClientCaptureContext context) throws Exception; 19 | 20 | public void apply(T val, ClientPlaybackContext context) throws Exception; 21 | 22 | public default Class getType() { 23 | return getChannelType().getType(); 24 | } 25 | 26 | public default boolean shouldInterpolate() { 27 | return false; 28 | } 29 | 30 | /** 31 | * If true, this channel applies every client tick instead of every frame. 32 | */ 33 | public default boolean applyPerTick() { 34 | return false; 35 | } 36 | 37 | public static void writeChannel(ClientCaptureContext context, DataOutput out, ChannelHandler handler) throws Exception { 38 | T val = handler.capture(context); 39 | handler.getChannelType().write(out, val); 40 | } 41 | 42 | public static void readChannel(DataInput in, ChannelHandler handler, ClientPlaybackContext context) throws Exception { 43 | T val = handler.getChannelType().read(in); 44 | handler.apply(val, context); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/channel/type/ChannelTypes.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.channel.type; 2 | 3 | import com.igrium.replayfps.core.channel.type.NumberChannel.ByteChannel; 4 | import com.igrium.replayfps.core.channel.type.NumberChannel.DoubleChannel; 5 | import com.igrium.replayfps.core.channel.type.NumberChannel.FloatChannel; 6 | import com.igrium.replayfps.core.channel.type.NumberChannel.IntegerChannel; 7 | import com.igrium.replayfps.core.channel.type.NumberChannel.LongChannel; 8 | import com.igrium.replayfps.core.channel.type.NumberChannel.ShortChannel; 9 | import com.igrium.replayfps.core.channel.type.NumberChannel.UnsignedByteChannel; 10 | import com.igrium.replayfps.core.channel.type.NumberChannel.UnsignedShortChannel; 11 | 12 | public class ChannelTypes { 13 | public static final ByteChannel BYTE = new ByteChannel(); 14 | public static final ShortChannel SHORT = new ShortChannel(); 15 | public static final IntegerChannel INTEGER = new IntegerChannel(); 16 | public static final LongChannel LONG = new LongChannel(); 17 | public static final FloatChannel FLOAT = new FloatChannel(); 18 | public static final DoubleChannel DOUBLE = new DoubleChannel(); 19 | public static final UnsignedShortChannel UNSIGNED_SHORT = new UnsignedShortChannel(); 20 | public static final UnsignedByteChannel UNSIGNED_BYTE = new UnsignedByteChannel(); 21 | 22 | public static final Matrix4fChannelType MATRIX4F = new Matrix4fChannelType(); 23 | public static final Vector2fChannelType VECTOR2F = new Vector2fChannelType(); 24 | 25 | public static final Vec3dChannelType VEC3D = new Vec3dChannelType(); 26 | 27 | public static PlaceholderChannel placeholder(int size) { 28 | return new PlaceholderChannel(size); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/networking/redirector/EntityEquipmentUpdateRedirector.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.networking.redirector; 2 | 3 | import com.igrium.replayfps.core.networking.PacketRedirector; 4 | 5 | import net.minecraft.client.MinecraftClient; 6 | import net.minecraft.entity.EquipmentSlot; 7 | import net.minecraft.entity.player.PlayerEntity; 8 | import net.minecraft.network.packet.s2c.play.EntityEquipmentUpdateS2CPacket; 9 | 10 | public class EntityEquipmentUpdateRedirector implements PacketRedirector { 11 | 12 | @Override 13 | public Class getPacketClass() { 14 | return EntityEquipmentUpdateS2CPacket.class; 15 | } 16 | 17 | @Override 18 | public boolean shouldRedirect(EntityEquipmentUpdateS2CPacket packet, PlayerEntity localPlayer, 19 | MinecraftClient client) { 20 | return localPlayer != null && packet.getId() == localPlayer.getId(); 21 | } 22 | 23 | @Override 24 | public void redirect(EntityEquipmentUpdateS2CPacket packet, PlayerEntity localPlayer, MinecraftClient client) { 25 | client.execute(() -> doRedirect(packet, localPlayer, client)); 26 | } 27 | 28 | private void doRedirect(EntityEquipmentUpdateS2CPacket packet, PlayerEntity localPlayer, MinecraftClient client) { 29 | if (packet.getId() != localPlayer.getId()) 30 | throw new IllegalStateException("This packet should not redirect for entities other than the local player."); 31 | 32 | // Supress update of main hand equipment slot. 33 | for (var pair : packet.getEquipmentList()) { 34 | if (pair.getFirst() != EquipmentSlot.MAINHAND) { 35 | localPlayer.equipStack(pair.getFirst(), pair.getSecond()); 36 | } 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/recording/ClientCapWriter.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.recording; 2 | 3 | import java.io.DataOutput; 4 | import java.io.DataOutputStream; 5 | import java.io.IOException; 6 | import java.io.OutputStream; 7 | import com.igrium.replayfps.core.channel.type.ChannelType; 8 | import com.igrium.replayfps.core.playback.UnserializedFrame; 9 | 10 | public class ClientCapWriter { 11 | private final OutputStream out; 12 | 13 | private int writtenFrames; 14 | 15 | /** 16 | * Create a ClientCap writer. 17 | * 18 | * @param out Output stream to write to. 19 | */ 20 | public ClientCapWriter(OutputStream out) { 21 | this.out = out; 22 | } 23 | 24 | /** 25 | * Write a frame of animation. 26 | * 27 | * @param frame Frame to write 28 | * @throws IOException If an IO exception occurs while writing the frame. 29 | */ 30 | public void writeFrame(UnserializedFrame frame) throws IOException { 31 | try { 32 | DataOutputStream dataOut = new DataOutputStream(out); 33 | for (var entry : frame.getValues().entrySet()) { 34 | writeChannel(entry.getKey().getChannelType(), dataOut, entry.getValue()); 35 | } 36 | } finally { 37 | writtenFrames++; 38 | 39 | } 40 | } 41 | 42 | // Seperate function needed to handle generic. 43 | private void writeChannel(ChannelType channelType, DataOutput out, Object value) throws IOException { 44 | T val = channelType.getType().cast(value); 45 | channelType.write(out, val); 46 | } 47 | 48 | 49 | /** 50 | * Get the number of frames that have been written. 51 | * @return Written frames. 52 | */ 53 | public int getWrittenFrames() { 54 | return writtenFrames; 55 | } 56 | 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/networking/PacketRedirector.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.networking; 2 | 3 | import net.minecraft.client.MinecraftClient; 4 | import net.minecraft.entity.player.PlayerEntity; 5 | import net.minecraft.network.packet.Packet; 6 | 7 | /** 8 | * Modifies the functionality of a packet being played by a replay. If the 9 | * packet type is listed in BAD_PACKETS, this redirector plays in 10 | * any case. 11 | */ 12 | public interface PacketRedirector> { 13 | 14 | /** 15 | * Get the class of the packet that will be captured. 16 | */ 17 | public Class getPacketClass(); 18 | 19 | /** 20 | * Decide whether a given packet should redirect given the current context. Do 21 | * not modify the gameplay state during this method. Instead, use 22 | * {@link #redirect}. 23 | * 24 | * @param packet Subject packet. 25 | * @param localPlayer The player who recorded the replay. 26 | * @param client The Minecraft client. 27 | * @return true if this packet should redirect. false 28 | * will resort to vanilla behavior. 29 | */ 30 | public boolean shouldRedirect(T packet, PlayerEntity localPlayer, MinecraftClient client); 31 | 32 | /** 33 | * Called when the packet is recieved during the replay. 34 | * 35 | * @param packet The deserialized packet. 36 | * @param localPlayer The player that recorded the replay. 37 | * @param client The Minecraft client. 38 | */ 39 | public void redirect(T packet, PlayerEntity localPlayer, MinecraftClient client); 40 | 41 | default boolean shouldRedirect(Object packet, PlayerEntity localPlayer, MinecraftClient client) { 42 | return shouldRedirect(getPacketClass().cast(packet), localPlayer, client); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/playback/ChannelValueCache.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.playback; 2 | 3 | import java.util.Collections; 4 | import java.util.HashMap; 5 | 6 | import java.util.Map; 7 | import com.igrium.replayfps.core.channel.ChannelHandler; 8 | 9 | public class ChannelValueCache { 10 | private Map, Object> map = new HashMap<>(); 11 | private Map, Object> unmodifiable = Collections.unmodifiableMap(map); 12 | 13 | // private List> list = new ArrayList<>(); 14 | 15 | public void put(ChannelHandler channel, T value) { 16 | map.put(channel, value); 17 | } 18 | 19 | // Because we control the only way to add items to the map, we know this matches. 20 | @SuppressWarnings("unchecked") 21 | public T get(ChannelHandler channel) { 22 | return (T) map.get(channel); 23 | } 24 | 25 | public Map, Object> map() { 26 | return unmodifiable; 27 | } 28 | 29 | // Because we control the only way to add items to the map, we know this matches. 30 | @SuppressWarnings("unchecked") 31 | public T remove(ChannelHandler channel) { 32 | return (T) map.remove(channel); 33 | } 34 | 35 | public void clear() { 36 | map.clear(); 37 | } 38 | 39 | public void forEach(ChannelValueConsumer consumer) { 40 | for (var entry : map.entrySet()) { 41 | doCast(consumer, entry.getKey(), entry.getValue()); 42 | } 43 | } 44 | 45 | // Arent generics fun?? 46 | private void doCast(ChannelValueConsumer consumer, ChannelHandler channel, Object value) { 47 | consumer.accept(channel, channel.getType().cast(value)); 48 | } 49 | 50 | public interface ChannelValueConsumer { 51 | void accept(ChannelHandler channel, T value); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/channel/DefaultChannelHandlers.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.channel; 2 | 3 | import static com.igrium.replayfps.core.channel.ChannelHandlers.register; 4 | 5 | import com.igrium.replayfps.core.events.ChannelRegistrationCallback; 6 | import com.igrium.replayfps.game.channel.handler.HorizontalSpeedHandler; 7 | import com.igrium.replayfps.game.channel.handler.PlayerPosChannelHandler; 8 | import com.igrium.replayfps.game.channel.handler.PlayerRotChannelHandler; 9 | import com.igrium.replayfps.game.channel.handler.PlayerStrideChannelHandler; 10 | import com.igrium.replayfps.game.channel.handler.PlayerVelocityChannelHandler; 11 | 12 | import net.minecraft.util.Identifier; 13 | 14 | public class DefaultChannelHandlers { 15 | public static final PlayerPosChannelHandler PLAYER_POS = register(new PlayerPosChannelHandler(), new Identifier("replayfps:player_pos")); 16 | public static final PlayerRotChannelHandler PLAYER_ROT = register(new PlayerRotChannelHandler(), new Identifier("replayfps:player_rot")); 17 | public static final PlayerVelocityChannelHandler PLAYER_VELOCITY = register(new PlayerVelocityChannelHandler(), new Identifier("replayfps:player_velocity")); 18 | public static final PlayerStrideChannelHandler PLAYER_STRIDE = register(new PlayerStrideChannelHandler(), new Identifier("replayfps:player_stride")); 19 | public static final HorizontalSpeedHandler HORIZONTAL_SPEED = register(new HorizontalSpeedHandler(), new Identifier("replayfps:horizontal_speed")); 20 | 21 | public static void registerDefaults() { 22 | ChannelRegistrationCallback.EVENT.register(consumer -> { 23 | consumer.accept(PLAYER_POS); 24 | consumer.accept(PLAYER_ROT); 25 | consumer.accept(PLAYER_VELOCITY); 26 | consumer.accept(PLAYER_STRIDE); 27 | consumer.accept(HORIZONTAL_SPEED); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/PacketListenerMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import java.util.concurrent.ExecutorService; 4 | 5 | import org.spongepowered.asm.mixin.Mixin; 6 | import org.spongepowered.asm.mixin.Shadow; 7 | import org.spongepowered.asm.mixin.injection.At; 8 | import org.spongepowered.asm.mixin.injection.Inject; 9 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 10 | 11 | import com.igrium.replayfps.core.events.RecordingEvents; 12 | import com.igrium.replayfps.core.util.TimecodeProvider; 13 | import com.replaymod.recording.packet.PacketListener; 14 | import com.replaymod.replaystudio.replay.ReplayFile; 15 | 16 | import io.netty.channel.ChannelHandlerContext; 17 | 18 | @Mixin(PacketListener.class) 19 | public class PacketListenerMixin implements TimecodeProvider { 20 | 21 | @Shadow(remap = false) 22 | private ReplayFile replayFile; 23 | 24 | @Shadow(remap = false) 25 | private ExecutorService saveService; 26 | 27 | @Shadow(remap = false) 28 | private long startTime; 29 | 30 | @Shadow(remap = false) 31 | private long timePassedWhilePaused; 32 | 33 | @Shadow(remap = false) 34 | private volatile boolean serverWasPaused; 35 | 36 | @Inject(method = "channelInactive", at = @At("HEAD"), remap = false) 37 | void channelInactive(ChannelHandlerContext ctx, CallbackInfo ci) { 38 | saveService.submit(() -> { 39 | RecordingEvents.STOP_RECORDING.invoker().onStopRecording((PacketListener) (Object) this, replayFile); 40 | }); 41 | } 42 | 43 | @Override 44 | public long getStartTime() { 45 | return startTime; 46 | } 47 | 48 | @Override 49 | public long getTimePassedWhilePaused() { 50 | return timePassedWhilePaused; 51 | } 52 | 53 | @Override 54 | public boolean getServerWasPaused() { 55 | return serverWasPaused; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/networking/fake_packet/UpdateSelectedSlotFakePacket.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.networking.fake_packet; 2 | 3 | import com.igrium.replayfps.core.networking.FakePacketManager; 4 | import com.igrium.replayfps.core.playback.ClientCapPlayer; 5 | import com.igrium.replayfps.core.playback.ClientPlaybackModule; 6 | import com.igrium.replayfps.game.event.ClientPlayerEvents; 7 | 8 | import net.fabricmc.fabric.api.networking.v1.FabricPacket; 9 | import net.fabricmc.fabric.api.networking.v1.PacketType; 10 | import net.minecraft.entity.player.PlayerEntity; 11 | import net.minecraft.network.PacketByteBuf; 12 | import net.minecraft.util.Identifier; 13 | 14 | public record UpdateSelectedSlotFakePacket(int slot) implements FabricPacket { 15 | 16 | public static final PacketType TYPE = PacketType 17 | .create(new Identifier("replayfps:update_slot"), UpdateSelectedSlotFakePacket::read); 18 | 19 | public static UpdateSelectedSlotFakePacket read(PacketByteBuf buf) { 20 | return new UpdateSelectedSlotFakePacket(buf.readInt()); 21 | } 22 | 23 | @Override 24 | public void write(PacketByteBuf buf) { 25 | buf.writeInt(slot); 26 | } 27 | 28 | @Override 29 | public PacketType getType() { 30 | return TYPE; 31 | } 32 | 33 | public static void apply(UpdateSelectedSlotFakePacket packet, ClientPlaybackModule module, 34 | ClientCapPlayer clientCap, PlayerEntity localPlayer) { 35 | localPlayer.getInventory().selectedSlot = packet.slot(); 36 | } 37 | 38 | @SuppressWarnings("resource") 39 | public static void registerListener() { 40 | ClientPlayerEvents.SELECT_SLOT.register((inv, slot) -> { 41 | if (!inv.player.getWorld().isClient) return; 42 | FakePacketManager.injectFakePacket(new UpdateSelectedSlotFakePacket(slot)); 43 | }); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/events/RecordingEvents.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.events; 2 | 3 | import com.replaymod.lib.de.johni0702.minecraft.gui.utils.Event; 4 | import com.replaymod.recording.packet.PacketListener; 5 | import com.replaymod.replaystudio.replay.ReplayFile; 6 | 7 | public final class RecordingEvents { 8 | 9 | /** 10 | * Called after the Replay Mod starts recording. 11 | */ 12 | public static final Event STARTED_RECORDING = Event.create((listeners) -> (packetListener, replayFile) -> { 13 | for (StartedRecording listener : listeners) { 14 | listener.onStartRecording(packetListener, replayFile); 15 | } 16 | }); 17 | 18 | /** 19 | * Called when the Replay Mod stops recording but before it saves. Execution happens on the Save Service. 20 | */ 21 | public static final Event STOP_RECORDING = Event.create((listeners) -> (packetListener, replayFile) -> { 22 | for (StopRecording listener : listeners) { 23 | listener.onStopRecording(packetListener, replayFile); 24 | } 25 | }); 26 | 27 | /** 28 | * Called after the Replay mod has stopped recording. Use to clean up any excess 29 | * resources. Note that the replay might still be saving. 30 | */ 31 | public static final Event STOPPED_RECORDING = Event.create(listeners -> () -> { 32 | for (StoppedRecording listener : listeners) { 33 | listener.onStoppedRecording(); 34 | } 35 | }); 36 | 37 | public interface StartedRecording { 38 | void onStartRecording(PacketListener packetListener, ReplayFile replayFile); 39 | } 40 | 41 | public interface StopRecording { 42 | void onStopRecording(PacketListener packetListener, ReplayFile replayFile); 43 | } 44 | 45 | public interface StoppedRecording { 46 | void onStoppedRecording(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ccap_viewer/src/main/resources/assets/replayfps_viewer/ui/main.fxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/test/java/com/igrium/replayfps/test/ConcurrentBufferTest.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.test; 2 | 3 | import java.util.concurrent.Executor; 4 | 5 | 6 | import org.junit.jupiter.api.Assertions; 7 | import org.junit.jupiter.api.RepeatedTest; 8 | 9 | import com.igrium.replayfps.core.util.ConcurrentBuffer; 10 | 11 | public class ConcurrentBufferTest { 12 | class DemoBuffer extends ConcurrentBuffer { 13 | 14 | public DemoBuffer(Executor executor) { 15 | super(executor); 16 | setBufferSize(256); 17 | setBufferThreshold(1024); 18 | } 19 | 20 | @Override 21 | protected Integer load(int index) throws Exception { 22 | Thread.sleep(1); 23 | 24 | if (index == 69420) { 25 | throw new Exception("Test Exception"); 26 | } 27 | 28 | if (index > 1024) return null; 29 | 30 | 31 | return index; 32 | } 33 | 34 | } 35 | 36 | @RepeatedTest(8) 37 | public void testBuffer() throws Exception { 38 | SimpleSingleThreadExecutor executor = new SimpleSingleThreadExecutor(r -> new Thread(r, "BufferThread")); 39 | DemoBuffer buffer = new DemoBuffer(executor); 40 | 41 | int val0 = buffer.poll(); 42 | Assertions.assertEquals(0, val0); 43 | 44 | int val1 = buffer.poll(); 45 | Assertions.assertEquals(1, val1); 46 | 47 | buffer.seek(2048); 48 | // if (buffer.hasErrored()) { 49 | // throw buffer.getError().get(); 50 | // } 51 | Assertions.assertEquals(null, buffer.poll()); 52 | 53 | buffer.seek(3); 54 | Assertions.assertEquals(3, buffer.peek()); 55 | Assertions.assertEquals(3, buffer.poll()); 56 | 57 | buffer.seek(69420); 58 | buffer.peek(); 59 | Assertions.assertNotNull(buffer.getError().orElse(null)); 60 | 61 | Assertions.assertNull(buffer.peek()); 62 | 63 | executor.shutdown(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /ccap_viewer/src/main/java/com/igrium/replayfps_viewer/util/GraphedChannel.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps_viewer.util; 2 | 3 | import java.io.IOException; 4 | import java.util.ArrayList; 5 | import java.util.List; 6 | 7 | import com.igrium.replayfps.core.channel.ChannelHandler; 8 | import com.igrium.replayfps.core.playback.ClientCapReader; 9 | import com.igrium.replayfps.core.playback.UnserializedFrame; 10 | import com.igrium.replayfps.core.util.NoHeaderException; 11 | 12 | import javafx.scene.chart.XYChart; 13 | import javafx.scene.chart.XYChart.Series; 14 | 15 | public class GraphedChannel { 16 | public static List> create(ClientCapReader reader, ChannelHandler channel) throws NoHeaderException, IOException { 17 | List> data = new ArrayList<>(); 18 | reader.seek(0); 19 | 20 | UnserializedFrame first = reader.readFrame(); 21 | T firstVal = first.getValue(channel); 22 | if (firstVal == null) return data; 23 | 24 | // Initialize series objects using first frame. 25 | for (float val : channel.getChannelType().getRawValues(firstVal)) { 26 | Series series = new Series<>(); 27 | series.getData().add(new XYChart.Data<>(0, val)); 28 | data.add(series); 29 | } 30 | 31 | if (data.isEmpty()) return data; 32 | 33 | // Read all frames. 34 | int frameNum = 1; 35 | while (!reader.isEndOfFile()) { 36 | UnserializedFrame frame = reader.readFrame(); 37 | T value = frame.getValue(channel); 38 | 39 | if (value == null) { 40 | frameNum++; 41 | continue; 42 | } 43 | 44 | int i = 0; 45 | for (float val : channel.getChannelType().getRawValues(value)) { 46 | data.get(i).getData().add(new XYChart.Data<>(frameNum, val)); 47 | i++; 48 | } 49 | frameNum++; 50 | } 51 | 52 | return data; 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/BullshitPlayerInventoryManager.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game; 2 | 3 | import com.igrium.replayfps.game.event.ClientJoinedWorldEvent; 4 | import com.igrium.replayfps.game.event.InventoryModifiedEvent; 5 | 6 | import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap; 7 | import it.unimi.dsi.fastutil.ints.Int2ObjectMap; 8 | import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; 9 | import net.minecraft.client.MinecraftClient; 10 | import net.minecraft.entity.player.PlayerEntity; 11 | import net.minecraft.entity.player.PlayerInventory; 12 | import net.minecraft.item.ItemStack; 13 | 14 | /** 15 | * Until I get screen handlers working properly, this helps with syncing the player hotbar. 16 | */ 17 | public class BullshitPlayerInventoryManager { 18 | public static void register() { 19 | ClientTickEvents.END_CLIENT_TICK.register(BullshitPlayerInventoryManager::onEndTick); 20 | ClientJoinedWorldEvent.EVENT.register((client, world) -> reset()); 21 | } 22 | 23 | private static ItemStack[] prevInventory = new ItemStack[36]; 24 | 25 | private static void onEndTick(MinecraftClient client) { 26 | PlayerEntity player = client.player; 27 | if (player == null) return; 28 | 29 | Int2ObjectMap updated = new Int2ObjectArrayMap<>(36); 30 | 31 | PlayerInventory inventory = player.getInventory(); 32 | for (int i = 0; i < prevInventory.length; i++) { 33 | ItemStack newStack = inventory.getStack(i); 34 | if (prevInventory[i] == null || !ItemStack.areEqual(prevInventory[i], newStack)) { 35 | updated.put(i, newStack); 36 | } 37 | 38 | prevInventory[i] = newStack.copy(); 39 | } 40 | 41 | if (!updated.isEmpty()) { 42 | InventoryModifiedEvent.EVENT.invoker().onInventoryModified(player.getInventory(), updated); 43 | } 44 | 45 | } 46 | 47 | private static void reset() { 48 | for (int i = 0; i < prevInventory.length; i++) { 49 | prevInventory[i] = null; 50 | } 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/networking/fake_packet/SetGamemodeFakePacket.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.networking.fake_packet; 2 | 3 | import com.igrium.replayfps.core.networking.FakePacketManager; 4 | import com.igrium.replayfps.core.playback.ClientCapPlayer; 5 | import com.igrium.replayfps.core.playback.ClientPlaybackModule; 6 | import com.igrium.replayfps.game.event.ClientJoinedWorldEvent; 7 | import com.igrium.replayfps.game.event.ClientPlayerEvents; 8 | 9 | import net.fabricmc.fabric.api.networking.v1.FabricPacket; 10 | import net.fabricmc.fabric.api.networking.v1.PacketType; 11 | import net.minecraft.entity.player.PlayerEntity; 12 | import net.minecraft.network.PacketByteBuf; 13 | import net.minecraft.util.Identifier; 14 | import net.minecraft.world.GameMode; 15 | 16 | public record SetGamemodeFakePacket(GameMode gamemode) implements FabricPacket { 17 | 18 | public static final PacketType TYPE = PacketType 19 | .create(new Identifier("replayfps:set_gamemode"), SetGamemodeFakePacket::read); 20 | 21 | public static SetGamemodeFakePacket read(PacketByteBuf buf) { 22 | return new SetGamemodeFakePacket(buf.readEnumConstant(GameMode.class)); 23 | } 24 | 25 | @Override 26 | public void write(PacketByteBuf buf) { 27 | buf.writeEnumConstant(gamemode); 28 | } 29 | 30 | @Override 31 | public PacketType getType() { 32 | return TYPE; 33 | } 34 | 35 | public static void apply(SetGamemodeFakePacket packet, ClientPlaybackModule module, 36 | ClientCapPlayer clientCap, PlayerEntity localPlayer) { 37 | module.setHudGamemode(packet.gamemode()); 38 | } 39 | 40 | public static void registerListener() { 41 | ClientPlayerEvents.SET_GAMEMODE.register((player, oldGamemode, newGamemode) -> { 42 | FakePacketManager.injectFakePacket(new SetGamemodeFakePacket(newGamemode)); 43 | }); 44 | 45 | ClientJoinedWorldEvent.EVENT.register((client, world) -> { 46 | FakePacketManager.injectFakePacket(new SetGamemodeFakePacket( 47 | client.interactionManager.getCurrentGameMode())); 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/networking/redirector/ScreenHandlerSlotUpdateRedirector.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.networking.redirector; 2 | 3 | import com.igrium.replayfps.core.networking.PacketRedirector; 4 | 5 | import net.minecraft.client.MinecraftClient; 6 | import net.minecraft.entity.player.PlayerEntity; 7 | import net.minecraft.item.ItemStack; 8 | import net.minecraft.network.packet.s2c.play.ScreenHandlerSlotUpdateS2CPacket; 9 | import net.minecraft.screen.PlayerScreenHandler; 10 | 11 | public class ScreenHandlerSlotUpdateRedirector implements PacketRedirector { 12 | 13 | @Override 14 | public Class getPacketClass() { 15 | return ScreenHandlerSlotUpdateS2CPacket.class; 16 | } 17 | 18 | @Override 19 | public boolean shouldRedirect(ScreenHandlerSlotUpdateS2CPacket packet, PlayerEntity localPlayer, 20 | MinecraftClient client) { 21 | return true; 22 | } 23 | 24 | @Override 25 | public void redirect(ScreenHandlerSlotUpdateS2CPacket packet, PlayerEntity localPlayer, MinecraftClient client) { 26 | client.execute(() -> doRedirect(packet, localPlayer, client)); 27 | } 28 | 29 | private void doRedirect(ScreenHandlerSlotUpdateS2CPacket packet, PlayerEntity localPlayer, MinecraftClient client) { 30 | ItemStack itemStack = packet.getStack(); 31 | int slot = packet.getSlot(); 32 | 33 | if (packet.getSyncId() == ScreenHandlerSlotUpdateS2CPacket.UPDATE_PLAYER_INVENTORY_SYNC_ID) { 34 | localPlayer.getInventory().setStack(slot, itemStack); 35 | } else { 36 | if (packet.getSyncId() == 0 && PlayerScreenHandler.isInHotbar(slot)) { 37 | if (!itemStack.isEmpty()) { 38 | ItemStack prevStack = localPlayer.playerScreenHandler.getSlot(slot).getStack(); 39 | if (prevStack.isEmpty() || prevStack.getCount() < itemStack.getCount()) { 40 | itemStack.setBobbingAnimationTime(5); 41 | } 42 | } 43 | 44 | localPlayer.playerScreenHandler.setStackInSlot(slot, packet.getRevision(), itemStack); 45 | 46 | // TODO: find a cleaner way to do this. 47 | localPlayer.getInventory().setStack(slot, itemStack); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/channel/ChannelHandlers.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.channel; 2 | 3 | import com.google.common.collect.BiMap; 4 | import com.google.common.collect.HashBiMap; 5 | 6 | import com.igrium.replayfps.core.channel.type.ChannelType; 7 | import com.igrium.replayfps.core.channel.type.ChannelTypes; 8 | import com.igrium.replayfps.core.channel.type.PlaceholderChannel; 9 | import com.igrium.replayfps.core.playback.ClientPlaybackContext; 10 | import com.igrium.replayfps.core.recording.ClientCaptureContext; 11 | 12 | import net.minecraft.util.Identifier; 13 | 14 | public class ChannelHandlers { 15 | 16 | public static final BiMap> REGISTRY = HashBiMap.create(); 17 | 18 | public static final ChannelHandler DUMMY = register(new DummyChannelHandler(), new Identifier("replayfps:dummy")); 19 | 20 | 21 | public static class PlaceholderChannelHandler implements ChannelHandler { 22 | private final ChannelType type; 23 | 24 | public PlaceholderChannelHandler(int size) { 25 | this.type = new PlaceholderChannel(size); 26 | } 27 | 28 | @Override 29 | public ChannelType getChannelType() { 30 | return type; 31 | } 32 | 33 | @Override 34 | public Object capture(ClientCaptureContext context) { 35 | return null; 36 | } 37 | 38 | @Override 39 | public void apply(Object val, ClientPlaybackContext context) { 40 | } 41 | } 42 | 43 | /** 44 | * Register a channel handler. 45 | * @param Channel handler type. 46 | * @param handler The channel handler. 47 | * @param id ID to register with. 48 | * @return handler 49 | */ 50 | public static > T register(T handler, Identifier id) { 51 | REGISTRY.put(id, handler); 52 | return handler; 53 | } 54 | 55 | private static class DummyChannelHandler implements ChannelHandler { 56 | 57 | @Override 58 | public ChannelType getChannelType() { 59 | return ChannelTypes.SHORT; 60 | } 61 | 62 | @Override 63 | public Short capture(ClientCaptureContext context) { 64 | return 0xFBF; 65 | } 66 | 67 | @Override 68 | public void apply(Short val, ClientPlaybackContext context) { 69 | } 70 | 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/ReplayFPS.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | 6 | import com.igrium.replayfps.config.ReplayFPSConfig; 7 | import com.igrium.replayfps.core.playback.ClientPlaybackModule; 8 | import com.igrium.replayfps.core.recording.ClientRecordingModule; 9 | import com.igrium.replayfps.core.util.ReplayModHooks; 10 | import com.igrium.replayfps.game.BullshitPlayerInventoryManager; 11 | import com.igrium.replayfps.game.channel.DefaultChannelHandlers; 12 | import com.igrium.replayfps.game.networking.DefaultFakePackets; 13 | import com.igrium.replayfps.game.networking.DefaultPacketRedirectors; 14 | 15 | import net.fabricmc.api.ModInitializer; 16 | 17 | public class ReplayFPS implements ModInitializer { 18 | public static final Logger LOGGER = LoggerFactory.getLogger("ReplayFPS"); 19 | 20 | private static ReplayFPS instance; 21 | 22 | public static ReplayFPS getInstance() { 23 | return instance; 24 | } 25 | 26 | private ReplayFPSConfig config; 27 | 28 | public ReplayFPSConfig config() { 29 | return config; 30 | } 31 | 32 | public static ReplayFPSConfig getConfig() { 33 | return getInstance().config(); 34 | } 35 | 36 | private ClientRecordingModule clientRecordingModule; 37 | 38 | public ClientRecordingModule getClientRecordingModule() { 39 | return clientRecordingModule; 40 | } 41 | 42 | private ClientPlaybackModule clientPlaybackModule; 43 | 44 | public ClientPlaybackModule getClientPlaybackModule() { 45 | return clientPlaybackModule; 46 | } 47 | 48 | @Override 49 | public void onInitialize() { 50 | instance = this; 51 | config = ReplayFPSConfig.load(); 52 | 53 | ReplayModHooks.onReplayModInit(mod -> { 54 | clientRecordingModule = new ClientRecordingModule(mod); 55 | clientRecordingModule.initCommon(); 56 | clientRecordingModule.initClient(); 57 | clientRecordingModule.register(); 58 | 59 | clientPlaybackModule = new ClientPlaybackModule(); 60 | clientPlaybackModule.initCommon(); 61 | clientPlaybackModule.initClient(); 62 | clientPlaybackModule.register(); 63 | }); 64 | 65 | DefaultChannelHandlers.registerDefaults(); 66 | DefaultPacketRedirectors.registerDefaults(); 67 | DefaultFakePackets.registerDefaults(); 68 | 69 | BullshitPlayerInventoryManager.register(); 70 | } 71 | } -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/networking/PacketRedirectors.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.networking; 2 | 3 | import java.util.Collections; 4 | import java.util.Map; 5 | import java.util.Set; 6 | import java.util.WeakHashMap; 7 | import java.util.concurrent.ConcurrentHashMap; 8 | 9 | import com.igrium.replayfps.core.util.PlaybackUtils; 10 | 11 | import net.minecraft.client.MinecraftClient; 12 | import net.minecraft.entity.player.PlayerEntity; 13 | import net.minecraft.network.packet.Packet; 14 | 15 | public class PacketRedirectors { 16 | 17 | private static final Map>, PacketRedirector> REGISTRY = new ConcurrentHashMap<>(); 18 | 19 | /** 20 | * During replay playback, packets get marked for redirect by getting added to 21 | * this list. I would have used a mixin to add a 'willRedirect' field, but 22 | * Packet is an interface, so that's out of the question. 23 | */ 24 | public static final Set> REDIRECT_QUEUED = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>())); 25 | 26 | public static boolean shouldRedirect(Packet packet) { 27 | MinecraftClient client = MinecraftClient.getInstance(); 28 | PlayerEntity localPlayer = PlaybackUtils.getCurrentPlaybackPlayer(); 29 | return shouldRedirect(packet, localPlayer, client); 30 | } 31 | 32 | public static boolean shouldRedirect(Packet packet, PlayerEntity localPlayer, MinecraftClient client) { 33 | PacketRedirector redirector = REGISTRY.get(packet.getClass()); 34 | if (redirector != null && redirector.getPacketClass().isInstance(packet)) { 35 | return redirector.shouldRedirect(packet, localPlayer, client); 36 | } 37 | return false; 38 | } 39 | 40 | public static void applyRedirect(Packet packet, PlayerEntity localPlayer, MinecraftClient client) { 41 | PacketRedirector redirector = REGISTRY.get(packet.getClass()); 42 | if (redirector == null) 43 | return; 44 | tryHandle(packet, redirector, localPlayer, client); 45 | } 46 | 47 | private static > void tryHandle(Packet packet, PacketRedirector redirector, 48 | PlayerEntity localPlayer, MinecraftClient client) { 49 | redirector.redirect(redirector.getPacketClass().cast(packet), localPlayer, client); 50 | } 51 | 52 | public static void register(PacketRedirector redirector) { 53 | REGISTRY.put(redirector.getPacketClass(), redirector); 54 | } 55 | 56 | 57 | } 58 | -------------------------------------------------------------------------------- /ccap_viewer/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'fabric-loom' version '1.4-SNAPSHOT' 3 | id 'maven-publish' 4 | id 'org.openjfx.javafxplugin' version '0.0.13' 5 | } 6 | 7 | version = rootProject.mod_version 8 | group = project.maven_group 9 | 10 | repositories { 11 | ivy { 12 | setName("ReplayMod") 13 | setUrl("https://minio.replaymod.com/replaymod/") 14 | patternLayout { artifact("[artifact]-[revision](-[classifier])(.[ext])") } 15 | metadataSources { artifact() } 16 | content { includeGroup("com.replaymod") } 17 | } 18 | mavenLocal() 19 | } 20 | 21 | dependencies { 22 | // To change the versions see the gradle.properties file 23 | minecraft "com.mojang:minecraft:${rootProject.minecraft_version}" 24 | mappings "net.fabricmc:yarn:${rootProject.yarn_mappings}:v2" 25 | modImplementation "net.fabricmc:fabric-loader:${rootProject.loader_version}" 26 | 27 | // Fabric API. This is technically optional, but you probably want it anyway. 28 | modImplementation "net.fabricmc.fabric-api:fabric-api:${rootProject.fabric_version}" 29 | // modImplementation "com.replaymod:replaymod-${rootProject.replaymod_version}" 30 | 31 | implementation project(path: ":", configuration: "namedElements") 32 | modImplementation "com.replaymod:replaymod-${project.replaymod_version}" 33 | modImplementation "com.igrium:craftfx:0.2.0+1.20.1" 34 | 35 | // Uncomment the following line to enable the deprecated Fabric API modules. 36 | // These are included in the Fabric API production distribution and allow you to update your mod to the latest modules at a later more convenient time. 37 | 38 | // modImplementation "net.fabricmc.fabric-api:fabric-api-deprecated:${project.fabric_version}" 39 | 40 | testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' 41 | testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.1' 42 | testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' 43 | } 44 | 45 | javafx { 46 | version = '17-ea+13' 47 | modules = [ 'javafx.controls', 'javafx.fxml' ] 48 | } 49 | 50 | loom { 51 | runs { 52 | client { 53 | runDir = "../run" 54 | } 55 | } 56 | } 57 | 58 | processResources { 59 | inputs.property "version", project.version 60 | 61 | filesMatching("fabric.mod.json") { 62 | expand "version": rootProject.version 63 | } 64 | } 65 | 66 | tasks.withType(JavaCompile).configureEach { 67 | // Minecraft 1.18 (1.18-pre2) upwards uses Java 17. 68 | it.options.release = 17 69 | } 70 | 71 | java { 72 | withSourcesJar() 73 | } -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/mixin/GameRendererMixin.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.mixin; 2 | 3 | import org.spongepowered.asm.mixin.Mixin; 4 | import org.spongepowered.asm.mixin.injection.At; 5 | import org.spongepowered.asm.mixin.injection.Inject; 6 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 7 | 8 | import com.igrium.replayfps.ReplayFPS; 9 | import com.igrium.replayfps.core.playback.ClientCapPlayer; 10 | import com.igrium.replayfps.core.playback.ClientPlaybackModule; 11 | 12 | import net.minecraft.client.MinecraftClient; 13 | import net.minecraft.client.render.GameRenderer; 14 | import net.minecraft.entity.Entity; 15 | import net.minecraft.entity.player.PlayerEntity; 16 | import net.minecraft.world.GameMode; 17 | 18 | @Mixin(GameRenderer.class) 19 | public class GameRendererMixin { 20 | private GameMode replayfps$prevGamemode = null; 21 | 22 | // Trick game renderer into thinking we're in survival mode so that it renders the survival mod hud. 23 | 24 | @Inject(method = "render", at = @At("HEAD")) 25 | void replayfps$onStartRender(float tickDelta, long startTime, boolean tick, CallbackInfo ci) { 26 | ClientCapPlayer playback = ClientPlaybackModule.getInstance().getCurrentPlayer(); 27 | MinecraftClient client = MinecraftClient.getInstance(); 28 | if (playback == null || client.world == null || !ReplayFPS.getInstance().config().shouldDrawHud()) { 29 | replayfps$prevGamemode = null; 30 | return; 31 | } 32 | 33 | int localPlayerID = playback.getReader().getHeader().getLocalPlayerID(); 34 | Entity localPlayer = client.world.getEntityById(localPlayerID); 35 | if (localPlayer == null) { 36 | replayfps$prevGamemode = null; 37 | return; 38 | } 39 | 40 | if (localPlayer.equals(client.getCameraEntity()) && localPlayer instanceof PlayerEntity localPlayerEnt) { 41 | // TODO: Store recording gamemode. 42 | replayfps$prevGamemode = client.interactionManager.getCurrentGameMode(); 43 | client.interactionManager.setGameMode(ClientPlaybackModule.getInstance().getHudGamemode()); 44 | } 45 | } 46 | 47 | @Inject(method = "render", at = @At("RETURN")) 48 | void replayfps$onEndRender(float tickDelta, long startTime, boolean tick, CallbackInfo ci) { 49 | if (replayfps$prevGamemode != null) { 50 | MinecraftClient client = MinecraftClient.getInstance(); 51 | client.interactionManager.setGameMode(replayfps$prevGamemode); 52 | } 53 | replayfps$prevGamemode = null; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/test/java/com/igrium/replayfps/test/TestNumberChannels.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.test; 2 | 3 | import java.io.IOException; 4 | import java.util.Random; 5 | 6 | import org.junit.jupiter.api.RepeatedTest; 7 | import org.junit.jupiter.api.RepetitionInfo; 8 | 9 | import com.igrium.replayfps.core.channel.type.ChannelTypes; 10 | import com.igrium.replayfps.core.channel.type.NumberChannel.ByteChannel; 11 | 12 | public class TestNumberChannels { 13 | 14 | @RepeatedTest(256) 15 | public void testByte(RepetitionInfo repetitionInfo) throws IOException { 16 | byte val = (byte) (repetitionInfo.getCurrentRepetition() - 1); 17 | 18 | ByteChannel channel = ChannelTypes.BYTE; 19 | TestChannelHandlers.testChannelConsistency(channel, val); 20 | } 21 | 22 | private Random random = new Random(); 23 | 24 | @RepeatedTest(256) 25 | public void testShort() throws IOException { 26 | short val = (short) random.nextInt(Short.MIN_VALUE, Short.MAX_VALUE); 27 | TestChannelHandlers.testChannelConsistency(ChannelTypes.SHORT, val); 28 | } 29 | 30 | @RepeatedTest(256) 31 | public void testInt() throws IOException { 32 | int val = random.nextInt(Integer.MIN_VALUE, Integer.MAX_VALUE); 33 | TestChannelHandlers.testChannelConsistency(ChannelTypes.INTEGER, val); 34 | } 35 | 36 | @RepeatedTest(256) 37 | public void testLong() throws IOException { 38 | long val = random.nextLong(Long.MIN_VALUE, Long.MAX_VALUE); 39 | TestChannelHandlers.testChannelConsistency(ChannelTypes.LONG, val); 40 | } 41 | 42 | @RepeatedTest(256) 43 | public void testFloat() throws IOException { 44 | float val = Float.intBitsToFloat(random.nextInt(Integer.MIN_VALUE, Integer.MAX_VALUE)); 45 | TestChannelHandlers.testChannelConsistency(ChannelTypes.FLOAT, val); 46 | } 47 | 48 | @RepeatedTest(256) 49 | public void testDouble() throws IOException { 50 | double val = Double.longBitsToDouble(random.nextLong(Long.MIN_VALUE, Long.MAX_VALUE)); 51 | TestChannelHandlers.testChannelConsistency(ChannelTypes.DOUBLE, val); 52 | } 53 | 54 | @RepeatedTest(256) 55 | public void testUnsignedShort() throws IOException { 56 | int val = random.nextInt(Short.MAX_VALUE * 2); 57 | TestChannelHandlers.testChannelConsistency(ChannelTypes.UNSIGNED_SHORT, val); 58 | } 59 | 60 | @RepeatedTest(256) 61 | public void testUnsignedByte(RepetitionInfo repetitionInfo) throws IOException { 62 | int val = repetitionInfo.getCurrentRepetition() - 1; 63 | TestChannelHandlers.testChannelConsistency(ChannelTypes.UNSIGNED_BYTE, val); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/channel/type/ChannelType.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.channel.type; 2 | 3 | import java.io.DataInput; 4 | import java.io.DataOutput; 5 | import java.io.IOException; 6 | 7 | /** 8 | * A data channel that can exist in the replay. Each channel is serialized for 9 | * each frame, and must be the same size in each frame. The size of each channel 10 | * will deternmine the size of the frame. 11 | */ 12 | public interface ChannelType { 13 | 14 | public Class getType(); 15 | 16 | /** 17 | * The size of the serialized data in this channel. 18 | * 19 | * @return Size of the frame in bytes. 20 | */ 21 | public int getSize(); 22 | 23 | /** 24 | * Read a frame of this channel from the specified input stream. 25 | * 26 | * @param in Input stream to read from. 27 | * @return Parsed value. 28 | * @throws IOException If an IO exception occurs. 29 | */ 30 | public T read(DataInput in) throws IOException; 31 | 32 | /** 33 | * Write a frame of this channel to the specified output stream. 34 | * 35 | * @param out Output stream to write to. 36 | * @param val Value to write. 37 | * @throws IOException If an IO exception occurs. 38 | */ 39 | public void write(DataOutput out, T val) throws IOException; 40 | 41 | /** 42 | * Get a "default" value for this channel. Used primarily for testing. 43 | * @return Channel's default value. 44 | */ 45 | public T defaultValue(); 46 | 47 | /** 48 | * Interpolate linearly between two values of this type. 49 | * 50 | * @param from Value A. 51 | * @param to Value B. 52 | * @param delta A float from 0 - 1 determinating the progress of the 53 | * interpolation. 0 will return from, and 54 | * 1 will return to. Behavior for values 55 | * outside this range is undefined. 56 | * @return The interpolated value. 57 | */ 58 | public default T interpolate(T from, T to, float delta) { 59 | return from; 60 | } 61 | 62 | /** 63 | * Get a simple name that identifies this channel type in the debugger. 64 | * @return Channel name. 65 | */ 66 | public default String getName() { 67 | return getType().getSimpleName(); 68 | } 69 | 70 | /** 71 | * Get raw scalar values that to represent this channel in the debugger graph. 72 | * 73 | * @param value Value to extract. 74 | * @return An array of all relevent values as floats. Must return the same 75 | * number of values each call. 76 | */ 77 | public default float[] getRawValues(T value) { 78 | return new float[0]; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/test/java/com/igrium/replayfps/test/TestChannelHandlers.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.test; 2 | 3 | import java.io.ByteArrayInputStream; 4 | import java.io.ByteArrayOutputStream; 5 | import java.io.DataInputStream; 6 | import java.io.DataOutputStream; 7 | import java.io.IOException; 8 | import java.io.OutputStream; 9 | import java.util.stream.Stream; 10 | 11 | import org.junit.jupiter.api.Assertions; 12 | import org.junit.jupiter.params.ParameterizedTest; 13 | import org.junit.jupiter.params.provider.MethodSource; 14 | 15 | import com.google.common.io.CountingInputStream; 16 | import com.igrium.replayfps.core.channel.ChannelHandlers; 17 | import com.igrium.replayfps.core.channel.type.ChannelType; 18 | 19 | public class TestChannelHandlers { 20 | 21 | private static Stream> provideChannelTypes() { 22 | return ChannelHandlers.REGISTRY.values().stream() 23 | .map(handler -> handler.getChannelType()) 24 | .distinct(); 25 | } 26 | 27 | @ParameterizedTest 28 | @MethodSource("provideChannelTypes") 29 | public void testChannelWrite(ChannelType channel) throws IOException { 30 | int size = channel.getSize(); 31 | DataOutputStream dataOut = new DataOutputStream(OutputStream.nullOutputStream()); 32 | 33 | channel.write(dataOut, channel.defaultValue()); 34 | Assertions.assertEquals(size, dataOut.size(), "Channel's declared size and written size should match."); 35 | } 36 | 37 | @ParameterizedTest 38 | @MethodSource("provideChannelTypes") 39 | public void testChannelRead(ChannelType channel) throws IOException { 40 | CountingInputStream counter = new CountingInputStream(new BlankInputStream()); 41 | DataInputStream dataIn = new DataInputStream(counter); 42 | int size = channel.getSize(); 43 | 44 | channel.read(dataIn); 45 | Assertions.assertEquals(size, counter.getCount(), "Channel's declared size and read size should match."); 46 | } 47 | 48 | @ParameterizedTest 49 | @MethodSource("provideChannelTypes") 50 | public void testChannelConsistency(ChannelType channel) throws IOException { 51 | testChannelConsistency(channel, channel.defaultValue()); 52 | } 53 | 54 | public static void testChannelConsistency(ChannelType channel, T value) throws IOException { 55 | ByteArrayOutputStream buffer = new ByteArrayOutputStream(channel.getSize()); 56 | channel.write(new DataOutputStream(buffer), value); 57 | 58 | ByteArrayInputStream bufferIn = new ByteArrayInputStream(buffer.toByteArray()); 59 | T readValue = channel.read(new DataInputStream(bufferIn)); 60 | 61 | Assertions.assertEquals(value, readValue, "The channel's parsing function is not consistent with its writing function."); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/game/networking/fake_packet/UpdateHotbarFakePacket.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.game.networking.fake_packet; 2 | 3 | import com.igrium.replayfps.core.networking.FakePacketManager; 4 | import com.igrium.replayfps.core.playback.ClientCapPlayer; 5 | import com.igrium.replayfps.core.playback.ClientPlaybackModule; 6 | import com.igrium.replayfps.game.event.InventoryModifiedEvent; 7 | 8 | import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap; 9 | import it.unimi.dsi.fastutil.ints.Int2ObjectMap; 10 | import net.fabricmc.fabric.api.networking.v1.FabricPacket; 11 | import net.fabricmc.fabric.api.networking.v1.PacketType; 12 | import net.minecraft.entity.player.PlayerEntity; 13 | import net.minecraft.item.ItemStack; 14 | import net.minecraft.network.PacketByteBuf; 15 | import net.minecraft.util.Identifier; 16 | 17 | public class UpdateHotbarFakePacket implements FabricPacket { 18 | 19 | public static final PacketType TYPE = PacketType 20 | .create(new Identifier("replayfps:update_hotbar"), UpdateHotbarFakePacket::new); 21 | 22 | public final Int2ObjectMap map; 23 | 24 | public UpdateHotbarFakePacket(Int2ObjectMap map) { 25 | this.map = map; 26 | } 27 | 28 | public UpdateHotbarFakePacket(PacketByteBuf buf) { 29 | int size = buf.readInt(); 30 | map = new Int2ObjectArrayMap<>(size); 31 | for (int i = 0; i < size; i++) { 32 | int slot = buf.readInt(); 33 | ItemStack stack = buf.readItemStack(); 34 | map.put(slot, stack); 35 | } 36 | } 37 | 38 | @Override 39 | public void write(PacketByteBuf buf) { 40 | buf.writeInt(map.size()); 41 | map.forEach((slot, stack) -> { 42 | buf.writeInt(slot); 43 | buf.writeItemStack(stack); 44 | }); 45 | } 46 | 47 | @Override 48 | public PacketType getType() { 49 | return TYPE; 50 | } 51 | 52 | public static void apply(UpdateHotbarFakePacket packet, ClientPlaybackModule module, 53 | ClientCapPlayer clientCap, PlayerEntity localPlayer) { 54 | packet.map.forEach((slot, stack) -> { 55 | localPlayer.getInventory().setStack(slot, stack); 56 | }); 57 | } 58 | 59 | @SuppressWarnings("resource") 60 | public static void registerListener() { 61 | InventoryModifiedEvent.EVENT.register((inv, map) -> { 62 | if (!inv.player.getWorld().isClient) return; 63 | 64 | // TODO: Remember why I needed to reconstruct the map 65 | Int2ObjectMap changed = new Int2ObjectArrayMap<>(inv.main.size()); 66 | int i = 0; 67 | for (ItemStack stack : inv.main) { 68 | changed.put(i, stack); 69 | i++; 70 | } 71 | FakePacketManager.injectFakePacket(new UpdateHotbarFakePacket(map)); 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/util/PlaybackUtils.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.util; 2 | 3 | import com.igrium.replayfps.core.playback.ClientCapPlayer; 4 | import com.igrium.replayfps.core.playback.ClientPlaybackContext; 5 | import com.igrium.replayfps.core.playback.ClientPlaybackModule; 6 | import com.replaymod.simplepathing.ReplayModSimplePathing; 7 | 8 | import net.minecraft.client.MinecraftClient; 9 | import net.minecraft.entity.Entity; 10 | import net.minecraft.entity.player.PlayerEntity; 11 | import net.minecraft.world.World; 12 | 13 | public final class PlaybackUtils { 14 | private PlaybackUtils() {}; 15 | 16 | /** 17 | * If there is a client-capture playing, get the ID of the local player it was 18 | * captured on. Should only be used when you don't have access to a 19 | * {@link ClientPlaybackContext}. 20 | * 21 | * @return ID of the player who captured the replay, or null if there is no 22 | * client-capture playing. 23 | */ 24 | public static Integer getCurrentPlaybackPlayerID() { 25 | ClientCapPlayer player = ClientPlaybackModule.getInstance().getCurrentPlayer(); 26 | if (player == null) return null; 27 | if (player.getReader().getHeader() == null) return null; 28 | return player.getReader().getHeader().getLocalPlayerID(); 29 | } 30 | 31 | /** 32 | * If there is a client-capture playing, get the local player that it was 33 | * captured on. Should only be called when you don't have access to a 34 | * {@link ClientPlaybackContext}. 35 | * 36 | * @return The player who captured the replay. null if there is no 37 | * client-capture playing or the player could not be found. 38 | */ 39 | @SuppressWarnings("resource") 40 | public static PlayerEntity getCurrentPlaybackPlayer() { 41 | World world = MinecraftClient.getInstance().world; 42 | if (world == null) return null; 43 | 44 | Integer id = getCurrentPlaybackPlayerID(); 45 | if (id == null) return null; 46 | 47 | if (world.getEntityById(id) instanceof PlayerEntity player) { 48 | return player; 49 | } 50 | return null; 51 | } 52 | 53 | /** 54 | * Determine if the current camera entity is the player that recorded the 55 | * current client-capture. 56 | * 57 | * @return true if we're viewing from the perspective from the 58 | * original capture player. false if we're not playing a 59 | * client-capture or we're not looking through their perspective. 60 | */ 61 | @SuppressWarnings("resource") 62 | public static boolean isViewingPlaybackPlayer() { 63 | Entity camera = MinecraftClient.getInstance().cameraEntity; 64 | if (camera == null) return false; 65 | return Integer.valueOf(camera.getId()).equals(getCurrentPlaybackPlayerID()); 66 | } 67 | 68 | /** 69 | * If we're currently in the replay editor. 70 | * 71 | * @return true if we're in the replay editor or rendering. 72 | * false if we're in the main menu or in a regular game. 73 | */ 74 | public static boolean isPlayingReplay() { 75 | // TODO: Determine if there's something more reliable than this hack. 76 | return ReplayModSimplePathing.instance.getGuiPathing() != null; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/playback/UnserializedFrame.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.playback; 2 | 3 | import com.igrium.replayfps.core.channel.ChannelHandler; 4 | import com.igrium.replayfps.core.recording.ClientCapHeader; 5 | 6 | import java.util.AbstractMap; 7 | import java.util.AbstractSet; 8 | import java.util.Collection; 9 | import java.util.Iterator; 10 | import java.util.Set; 11 | import java.util.Map; 12 | 13 | /** 14 | * Represents the contents of a single frame before its written to disk. 15 | */ 16 | public record UnserializedFrame(ClientCapHeader header, Object[] values) { 17 | 18 | public UnserializedFrame(ClientCapHeader header, Object[] values) { 19 | if (values.length != header.numChannels()) { 20 | throw new IllegalArgumentException("Incorrect number of channels."); 21 | } 22 | 23 | this.header = header; 24 | this.values = values; 25 | } 26 | 27 | public UnserializedFrame(ClientCapHeader header) { 28 | this(header, new Object[header.numChannels()]); 29 | } 30 | 31 | /** 32 | * Get a map of all channels and their respective values. 33 | * @return Channel map. 34 | */ 35 | public Map, Object> getValues() { 36 | return new ChannelMap(); 37 | } 38 | 39 | /** 40 | * Get the value belonging to a particular channel. 41 | * 42 | * @param Channel type. 43 | * @param channel The channel. 44 | * @return The value. null if it does not exist for this frame. 45 | * @throws ClassCastException If the value exists but is of the wrong type. 46 | * Should not happen if deserialized correctly. 47 | */ 48 | public T getValue(ChannelHandler channel) throws ClassCastException { 49 | Object value = getValues().get(channel); 50 | if (value == null) return null; 51 | 52 | return channel.getType().cast(value); 53 | } 54 | 55 | private class ChannelMap extends AbstractMap, Object> { 56 | private ChannelEntrySet entrySet = new ChannelEntrySet(); 57 | 58 | @Override 59 | public Set, Object>> entrySet() { 60 | return entrySet; 61 | } 62 | } 63 | 64 | private class ChannelEntrySet extends AbstractSet, Object>> { 65 | 66 | @Override 67 | public int size() { 68 | return values.length; 69 | } 70 | 71 | @Override 72 | public Iterator, Object>> iterator() { 73 | return new ChannelIterator(); 74 | } 75 | 76 | @Override 77 | public boolean add(Map.Entry, Object> e) { 78 | throw new UnsupportedOperationException("Unimplemented method 'add'"); 79 | } 80 | 81 | @Override 82 | public boolean addAll(Collection, Object>> c) { 83 | throw new UnsupportedOperationException("Unimplemented method 'addAll'"); 84 | } 85 | 86 | } 87 | 88 | private class ChannelIterator implements Iterator, Object>> { 89 | 90 | int currentIndex; 91 | 92 | @Override 93 | public boolean hasNext() { 94 | return currentIndex < values.length; 95 | } 96 | 97 | @Override 98 | public Map.Entry, Object> next() { 99 | ChannelHandler key = header.getChannels().get(currentIndex); 100 | Object value = values[currentIndex]; 101 | currentIndex++; 102 | return new AbstractMap.SimpleEntry<>(key, value); 103 | } 104 | 105 | } 106 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Replay FPS 2 | 3 | *Which should really be called "Replay FPV" but I was thinking of first-person shooters at the time* 4 | 5 | ## About 6 | 7 | The [Replay Mod](https://www.replaymod.com/) is a popular modification for Minecraft: Java Edition which allows you to capture gameplay for subsequent playback. While the Replay Mod is very powerful, it's severely lacking when it comes to first-person gameplay. 8 | 9 | By default, when you render a replay from the first-person perspective of a player, the view appears extremely sluggish. This is due to the fact that Minecraft only properly updates player positions 10 times per second, so the game must interpolate the missing data. 10 | 11 | This addon aims to fix this. 12 | 13 | By storing client-side camera data alongside the primary replay data, the movements of the local client can be captured in greater detail, leading to a much better first-person playback experience. 14 | 15 | ## Usage 16 | 17 | This addon doesn't contain any user-facing usage beyond the normal Replay Mod stuff. Simply install it alongside the replay mod and use it as normal. Keep in mind, however, that the enhancements provided by this addon will only ever apply to the player that recorded the replay, and only if that player had this addon installed while recording. 18 | 19 | > Note: In the current build (0.2.0), Mod Menu must be installed to access the config menu. 20 | 21 | ## Technical 22 | 23 | ### Client-Capture 24 | 25 | The reason that first-person movement looks so bad normally is that the Replay Mod only records the game packets communicated between the server and client. In most cases, this looks identical to observing the game in real-time. However, when it comes to first-person views, the server doesn't send enough data to accurately recreate the original movement. In fact, this is why spectating another player in Vanilla results in the same type of slushiness. 26 | 27 | The solution is store an additional stream of data alongside the packet stream. This stream is comprised of a series of channels, each holding a continuous value at a constant sample rate. These channels are written to directly by the client at capture time, bypassing the server-client packet system entirely. Not only does this allow for samples to be captured at a much higher rate than would be possible with packets, but it also enables data to be captured that isn't normally synced with the server in the first place. 28 | 29 | In order to store this sizeable data in a realistic way, a custom binary format was developed. When a replay is captured using this addon, a "client-capture" (`ccap`) file is inserted into the replay archive alongside the packet data. The specification of this file can be found in this repo. 30 | 31 | ### Packets 32 | 33 | Some data, however, doesn't make sense to store in a continuous stream. Discrete forms of data, as changes to the hotbar contents, are stored as custom packets that are injected into the replay packet stream. These so called "fake packets" get serialized to a `PacketByteBuf` at recording time, but instead of being sent to the server, they get saved into the replay file locally. During playback, the Replay Mod then serves these fake packets back to Minecraft as a normal packet, where a custom handler can parse and apply them to the world. 34 | 35 | In addition to fake packets, "packet redirectors" can be registered to override how a vanilla packet gets applied during playback. This allows numerous packets, which are normally unusable during a replay, to be parsed and applied towards reconstructing the original client experience. 36 | 37 | ## Building 38 | 39 | Like most mods, to build this project, simply open a console to the root directory and enter `./gradlew build`. However, it's possible that the build will fail due to a missing dependency called 'CraftFX'. This is a dependency of the `ccap_viewer` subproject which, as of now, is built alongside the main project. To fix this, clone and build [CraftFX](https://github.com/Igrium/CraftFX), and publish it to Maven Local. 40 | 41 | 42 | -------------------------------------------------------------------------------- /ccap_viewer/src/main/resources/assets/replayfps_viewer/ui/header.fxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 36 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /ccap_spec.md: -------------------------------------------------------------------------------- 1 | # Client-Capture File Specification 2 | 3 | ## Storage 4 | 5 | A client-capture, or `.ccap` file, is stored within the replay archive (`.mcpr`) alongside the packet data. The zip entry it is stored under must be named `client.ccap`. These files are accessed from within the replay archive, and they should generally only be extracted for debugging purposes. 6 | 7 | If the file is missing, or named something other than `client.ccap`, the Replay FPS addon will be disabled and replays will be played using only packet data. 8 | 9 | ## Header 10 | 11 | Every client-capture begins with a header declaring vital metadata pertaining to the file. The rest of the file is unreadable without the header. 12 | 13 | The header is made of an *uncompressed* [Binary NBT](https://minecraft.wiki/w/NBT_format#Binary_format) tag. As the `mcpr` file is already compressed, any additional compression at this level is unnecessary. The NBT schema is as follows: 14 | 15 | - `[root]: TAG_Compound` 16 | 17 | - `channels: TAG_List`: All the data channels within the file (see below). 18 | 19 | - `TAG_Compound`: A channel declaration. 20 | 21 | - `id: TAG_String`: The namespaced identifier of the channel type. (ex. "`replayfps:player_pos`") 22 | 23 | - `size: TAG_Int`: The number of bytes this channel will use in every frame. 24 | 25 | - `framerate: TAG_Int` The numerator of the file's frame rate. 26 | 27 | - `framerateBase: TAG_Int` The denominator of the file's frame rate. 28 | 29 | - `localPlayerID: TAG_Int` The network id of the player that recorded this replay. 30 | 31 | The byte length of the header should be recorded, as it is used while determining frame offsets. 32 | 33 | ### Channels 34 | 35 | Every client-capture has a set number of "channels", declared in a specific order. Each channel defines a specific attribute of the local player that will be captured and replayed each frame. For instance `replayfps:player_pos` could save the player's world position as 3 doubles. 36 | 37 | A channel's behavior is determined by its *type*, defined by the `id` tag in its declaration. A channel type controls how the data in a channel is written, and subsequently read and applied to the game. Without the type, a data is a meaningless set of bytes. This file specification defines no individual channel types, as it pertains only to the overall file structure. 38 | 39 | *Only one channel of a given type may be defined per-file.* 40 | 41 | Every channel declaration also contains a `size` tag. While each channel type should know how many bytes it ought to read, in the case that a given type is not found, the program must know how many bytes to discard. If the `size` tag does not match the channel type's expected size, the channel is discarded and treated as if the type was not found. 42 | 43 | Every channel must be present on every frame. 44 | 45 | ## Frame Data 46 | 47 | The rest of the file holds the actual data stream. It is designed such that frames can be streamed & buffered from disk on an as-needed basis, without the need to hold the entire file in memory. 48 | 49 | After the header, the file is comprised of serialized frame data, one after the other for as many frames as there are. There is no delimiter notating the start or end of each frame, however, the length of each frame (which is consistent throughout the entire file) can be calculated by taking the sum of every channel's size. 50 | 51 | The data within the frame itself is comprised of the raw bytes written by each channel, in the order in which they were declared in the header. Like with frames, there is no delimiter notating where each channel starts or ends, hence the need for every channel to declare its size ahead of time. 52 | 53 | A simple client-capture with 3 channels would look something like: 54 | 55 | ``` 56 | header | [channel 1] [channel 2] [channel 3] | [channel 1] [channel 2] [channel 3] | ... 57 | ``` 58 | 59 | Due to the intentional lack of delimiters and consistent frame size, the byte offset of any given frame can be calculated with: 60 | 61 | ``` 62 | offset = index * frame_size + header_length 63 | ``` 64 | 65 | This allows for easy frame seeking without the need to index the file. 66 | 67 | The major drawback of this approach, however, is that there could be wasted space when channels go unused for a given frame. But as long as the "default" value remains consistent, the zip compression of the `mcpr` file should mitigate that. 68 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/util/DataReader.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.util; 2 | 3 | import java.io.DataInputStream; 4 | import java.io.EOFException; 5 | import java.io.IOException; 6 | import java.io.InputStream; 7 | import java.io.UTFDataFormatException; 8 | import java.util.Objects; 9 | 10 | /** 11 | * A re-implementation of {@link DataInputStream} that doesn't require 12 | * per-stream instantiation. 13 | */ 14 | public class DataReader { 15 | 16 | public final void readFully(InputStream in, byte b[], int off, int len) throws EOFException, IOException { 17 | Objects.checkFromIndexSize(off, len, b.length); 18 | int n = 0; 19 | while (n < len) { 20 | int count = in.read(b, off + n, len - n); 21 | if (count < 0) 22 | throw new EOFException(); 23 | n += count; 24 | } 25 | } 26 | 27 | /** 28 | * See the general contract of the {@code readBoolean} 29 | * method of {@code DataInput}. 30 | *

31 | * Bytes for this operation are read from the contained 32 | * input stream. 33 | * @param in Stream to read from. 34 | * @return The value read. 35 | * @throws EOFException If the end of the stream has been reached. 36 | * @throws IOException If an IO exception occurs. 37 | */ 38 | public final boolean readBoolean(InputStream in) throws EOFException, IOException { 39 | int ch = in.read(); 40 | if (ch < 0) 41 | throw new EOFException(); 42 | return (ch != 0); 43 | } 44 | 45 | /** 46 | * See the general contract of the {@code readUnsignedByte} 47 | * method of {@code DataInput}. 48 | *

49 | * Bytes for this operation are read from the contained input stream. 50 | * @param in Stream to read from. 51 | * @return The value read. 52 | * @throws EOFException If the end of the stream has been reached. 53 | * @throws IOException If an IO exception occurs. 54 | */ 55 | public final int readUnsignedByte(InputStream in) throws EOFException, IOException { 56 | int ch = in.read(); 57 | if (ch < 0) 58 | throw new EOFException(); 59 | return ch; 60 | } 61 | 62 | public final short readShort(InputStream in) throws EOFException, IOException { 63 | int ch1 = in.read(); 64 | int ch2 = in.read(); 65 | if ((ch1 | ch2) < 0) 66 | throw new EOFException(); 67 | return (short)((ch1 << 8) + (ch2 << 0)); 68 | } 69 | 70 | public final char readChar(InputStream in) throws EOFException, IOException { 71 | int ch1 = in.read(); 72 | int ch2 = in.read(); 73 | if ((ch1 | ch2) < 0) 74 | throw new EOFException(); 75 | return (char)((ch1 << 8) + (ch2 << 0)); 76 | } 77 | 78 | public final int readInt(InputStream in) throws EOFException, IOException { 79 | int ch1 = in.read(); 80 | int ch2 = in.read(); 81 | int ch3 = in.read(); 82 | int ch4 = in.read(); 83 | if ((ch1 | ch2 | ch3 | ch4) < 0) 84 | throw new EOFException(); 85 | return ((ch1 << 24) + (ch2 << 16) + (ch3 << 8) + (ch4 << 0)); 86 | } 87 | 88 | private byte readBuffer[] = new byte[8]; 89 | 90 | public final long readLong(InputStream in) throws IOException { 91 | readFully(in, readBuffer, 0, 8); 92 | return (((long)readBuffer[0] << 56) + 93 | ((long)(readBuffer[1] & 255) << 48) + 94 | ((long)(readBuffer[2] & 255) << 40) + 95 | ((long)(readBuffer[3] & 255) << 32) + 96 | ((long)(readBuffer[4] & 255) << 24) + 97 | ((readBuffer[5] & 255) << 16) + 98 | ((readBuffer[6] & 255) << 8) + 99 | ((readBuffer[7] & 255) << 0)); 100 | } 101 | 102 | public final float readFloat(InputStream in) throws EOFException, IOException { 103 | return Float.intBitsToFloat(readInt(in)); 104 | } 105 | 106 | public final double readDouble(InputStream in) throws EOFException, IOException { 107 | return Double.longBitsToDouble(readLong(in)); 108 | } 109 | 110 | public final String readUTF(InputStream in) throws EOFException, IOException, UTFDataFormatException { 111 | return new DataInputStream(in).readUTF(); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /ccap_viewer/src/main/java/com/igrium/replayfps_viewer/ui/HeaderUI.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps_viewer.ui; 2 | 3 | import com.igrium.replayfps.core.channel.ChannelHandler; 4 | import com.igrium.replayfps.core.channel.ChannelHandlers; 5 | import com.igrium.replayfps.core.recording.ClientCapHeader; 6 | import com.igrium.replayfps.core.util.AnimationUtils; 7 | 8 | import javafx.fxml.FXML; 9 | import javafx.scene.control.SelectionMode; 10 | import javafx.scene.control.TableView; 11 | import javafx.scene.control.TextField; 12 | import javafx.scene.control.TitledPane; 13 | import javafx.scene.control.cell.PropertyValueFactory; 14 | import net.minecraft.util.Identifier; 15 | 16 | public class HeaderUI { 17 | 18 | @FXML 19 | private TextField framerateField; 20 | 21 | @FXML 22 | private TextField framerateBaseField; 23 | 24 | @FXML 25 | private TextField finalFramerateField; 26 | 27 | @FXML 28 | private TextField lengthField; 29 | 30 | @FXML 31 | private TextField playerIDField; 32 | 33 | @FXML 34 | private TitledPane channelsPane; 35 | 36 | @FXML 37 | private TableView channelsTable; 38 | 39 | public TableView getChannelsTable() { 40 | return channelsTable; 41 | } 42 | 43 | @FXML 44 | public void initialize() { 45 | setColumnName(0, "index"); 46 | setColumnName(1, "id"); 47 | setColumnName(2, "type"); 48 | setColumnName(3, "length"); 49 | 50 | channelsTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE); 51 | } 52 | 53 | private void setColumnName(int index, String name) { 54 | channelsTable.getColumns().get(index).setCellValueFactory(new PropertyValueFactory<>(name)); 55 | } 56 | 57 | public void loadHeader(ClientCapHeader header, int numFrames) { 58 | if (header == null) { 59 | clear(); 60 | return; 61 | } 62 | 63 | framerateField.setText(Integer.toString(header.getFramerate())); 64 | framerateBaseField.setText(Integer.toString(header.getFramerateBase())); 65 | finalFramerateField.setText(Float.toString(header.getFramerateFloat())); 66 | 67 | float length = AnimationUtils.getDurationSeconds(numFrames, header.getFramerateFloat()); 68 | lengthField.setText(String.format("%.1f", length)); 69 | 70 | playerIDField.setText(Integer.toString(header.getLocalPlayerID())); 71 | 72 | channelsPane.setText("Channels (%d)".formatted(header.numChannels())); 73 | 74 | channelsTable.getItems().clear(); 75 | int i = 0; 76 | for (ChannelHandler handler : header.getChannels()) { 77 | channelsTable.getItems().add(new ChannelEntry().apply(handler, i)); 78 | i++; 79 | } 80 | } 81 | 82 | public void clear() { 83 | framerateField.clear(); 84 | framerateBaseField.clear(); 85 | finalFramerateField.clear(); 86 | playerIDField.clear(); 87 | channelsTable.getItems().clear(); 88 | 89 | channelsPane.setText("Channels"); 90 | } 91 | 92 | public static class ChannelEntry { 93 | private int index; 94 | 95 | public int getIndex() { 96 | return index; 97 | } 98 | 99 | public void setIndex(int index) { 100 | this.index = index; 101 | } 102 | 103 | private String id; 104 | 105 | public String getId() { 106 | return id; 107 | } 108 | 109 | public void setId(String id) { 110 | this.id = id; 111 | } 112 | 113 | private String type; 114 | 115 | public String getType() { 116 | return type; 117 | } 118 | 119 | public void setType(String type) { 120 | this.type = type; 121 | } 122 | 123 | private int length; 124 | 125 | public int getLength() { 126 | return length; 127 | } 128 | 129 | public void setLength(int length) { 130 | this.length = length; 131 | } 132 | 133 | public ChannelEntry apply(ChannelHandler handler, int index) { 134 | this.index = index; 135 | 136 | Identifier id = ChannelHandlers.REGISTRY.inverse().get(handler); 137 | this.id = id != null ? id.toString() : "[unknown]"; 138 | 139 | this.type = handler.getChannelType().getName(); 140 | this.length = handler.getChannelType().getSize(); 141 | 142 | return this; 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/util/AnimationUtils.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.util; 2 | 3 | public final class AnimationUtils { 4 | private AnimationUtils() {} 5 | 6 | /** 7 | * Count the number of frames that have elapsed in a given amount of time. 8 | * @param time Time in milliseconds. 9 | * @param frameInterval Milliseconds between frames. 10 | * @return Number of frames. 11 | */ 12 | public static long countFrames(long time, long frameInterval) { 13 | return Math.floorDiv(time, frameInterval); 14 | } 15 | 16 | /** 17 | * Count the number of frames that have elapsed in a given amount of time. 18 | * @param time Time in milliseconds. 19 | * @param frameInterval Milliseconds between frames. 20 | * @return Number of frames. 21 | */ 22 | public static int countFrames(long time, int frameInterval) { 23 | return (int) Math.floorDiv(time, frameInterval); 24 | } 25 | 26 | /** 27 | * Count the number of frames that have elapsed in a given amount of time. 28 | * 29 | * @param time Time in milliseconds. 30 | * @param framerate Framerate numerator. 31 | * @param framerateBase Framerate denominator. 32 | * @return Number of frames. 33 | */ 34 | public static int countFrames(long time, int framerate, int framerateBase) { 35 | // Technically the equation is (time / 1000) * (framerate / framerateBase), but 36 | // this form is equivalent and it avoids needing to use floats. 37 | return (int) ((time * framerate) / (framerateBase * 1000)); 38 | } 39 | 40 | /** 41 | * Count the number of frames that have elapsed in a given amount of time. 42 | * 43 | * @param time Time in milliseconds. 44 | * @param framerate Framerate numerator. 45 | * @param framerateBase Framerate denominator. 46 | * @return Number of frames. 47 | */ 48 | public static int countFrames(int time, int framerate, int framerateBase) { 49 | // Technically the equation is (time / 1000) * (framerate / framerateBase), but 50 | // this form is equivalent and it avoids needing to use floats. 51 | return (time * framerate) / (framerateBase * 1000); 52 | } 53 | 54 | /** 55 | * Count the number of frames that have elapsed in a given amount of time. 56 | * @param time Time in milliseconds. 57 | * @param framerate The framerate. 58 | * @return Number of frames. 59 | */ 60 | public static int countFrames(long time, float framerate) { 61 | return (int) ((time * framerate) / 1000); 62 | } 63 | 64 | /** 65 | * Count the number of frames that have elapsed in a given amount of time. 66 | * @param time Time in seconds. 67 | * @param framerate Framerate numerator. 68 | * @param framerateBase Framerate denominator. 69 | * @return 70 | */ 71 | public static int countFrames(float time, int framerate, int framerateBase) { 72 | return (int) (time * framerate / framerateBase); 73 | } 74 | 75 | /** 76 | * Count the number of frames that have elapsed in a given amount of time. 77 | * @param time Time in seconds. 78 | * @param framerate Framerate. 79 | * @return Number of frames. 80 | */ 81 | public static int countFrames(float time, float framerate) { 82 | return (int) (time * framerate); 83 | } 84 | 85 | /** 86 | * Calculate the amount of time it should take for a given amount of frames to run. 87 | * @param numFrames Number of frames. 88 | * @param framerate Framerate numerator. 89 | * @param framerateBase Framerate denominator. 90 | * @return Time in milliseconds. 91 | */ 92 | public static long getDuration(int numFrames, int framerate, int framerateBase) { 93 | return (numFrames * framerateBase * 1000) / framerate; 94 | } 95 | 96 | /** 97 | * Calculate the amount of time it should take for a given amount of frames to run. 98 | * @param numFrames Number of frames. 99 | * @param framerate Framerate. 100 | * @return Time in milliseconds. 101 | */ 102 | public static long getDuration(int numFrames, float framerate) { 103 | return (long) ((1000 * numFrames) / framerate); 104 | } 105 | 106 | /** 107 | * Calculate the amount of time it should take for a given amount of frames to run. 108 | * @param numFrames Number of frames. 109 | * @param framerate Framerate. 110 | * @return Time in seconds. 111 | */ 112 | public static float getDurationSeconds(int numFrames, float framerate) { 113 | return numFrames / framerate; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/config/ReplayFPSConfig.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.config; 2 | 3 | import java.io.BufferedReader; 4 | import java.io.BufferedWriter; 5 | import java.io.File; 6 | import java.io.FileReader; 7 | import java.io.FileWriter; 8 | 9 | import com.google.gson.Gson; 10 | import com.google.gson.GsonBuilder; 11 | import com.igrium.replayfps.ReplayFPS; 12 | 13 | import me.shedaniel.clothconfig2.api.ConfigBuilder; 14 | import me.shedaniel.clothconfig2.api.ConfigCategory; 15 | import net.minecraft.client.MinecraftClient; 16 | import net.minecraft.client.gui.screen.Screen; 17 | import net.minecraft.text.Text; 18 | 19 | public final class ReplayFPSConfig { 20 | 21 | private static Gson gson = new GsonBuilder().setPrettyPrinting().create(); 22 | public static final String CONFIG_FILE = "config/replayfps.json"; 23 | 24 | private boolean playClientCap = true; 25 | 26 | /** 27 | * Whether to use the client-cap system in the first place. 28 | */ 29 | public boolean shouldPlayClientCap() { 30 | return playClientCap; 31 | } 32 | 33 | /** 34 | * Whether to use the client-cap system in the first place. 35 | */ 36 | public void setPlayClientCap(boolean playClientCap) { 37 | this.playClientCap = playClientCap; 38 | } 39 | 40 | private boolean drawHud = false; 41 | 42 | /** 43 | * Whether the HUD will be rendered in first-person replays. 44 | */ 45 | public boolean shouldDrawHud() { 46 | return drawHud; 47 | } 48 | 49 | /** 50 | * Whether the HUD will be rendered in first-person replays. 51 | */ 52 | public void setDrawHud(boolean drawHud) { 53 | this.drawHud = drawHud; 54 | } 55 | 56 | private boolean drawHotbar = true; 57 | 58 | public boolean shouldDrawHotbar() { 59 | return drawHotbar; 60 | } 61 | 62 | public void setDrawHotbar(boolean drawHotbar) { 63 | this.drawHotbar = drawHotbar; 64 | } 65 | 66 | public Screen getScreen(Screen parent) { 67 | ConfigBuilder builder = ConfigBuilder.create() 68 | .setParentScreen(parent) 69 | .setTitle(Text.translatable("title.replayfps.config")); 70 | 71 | 72 | ConfigCategory general = builder.getOrCreateCategory(Text.translatable("category.replayfps.general")); 73 | general.addEntry(builder.entryBuilder().startBooleanToggle(Text.translatable("option.replayfps.use_clientcap"), playClientCap) 74 | .setDefaultValue(true) 75 | .setTooltip(Text.translatable("option.replayfps.use_clientcap.tooltip")) 76 | .setSaveConsumer(val -> this.setPlayClientCap(val)) 77 | .build()); 78 | 79 | ConfigCategory hud = builder.getOrCreateCategory(Text.translatable("category.replayfps.hud")); 80 | hud.addEntry(builder.entryBuilder().startBooleanToggle(Text.translatable("option.replayfps.drawhud"), drawHud) 81 | .setDefaultValue(false) 82 | .setTooltip(Text.of("option.replayfps.drawhud.tooltip")) 83 | .setSaveConsumer(val -> setDrawHud(val)) 84 | .build()); 85 | 86 | hud.addEntry(builder.entryBuilder().startBooleanToggle(Text.translatable("option.replayfps.drawhotbar"), drawHotbar) 87 | .setDefaultValue(true) 88 | .setTooltip(Text.translatable("option.replayfps.drawhotbar.tooltip")) 89 | .setSaveConsumer(val -> setDrawHotbar(val)) 90 | .build()); 91 | 92 | builder.setSavingRunnable(this::save); 93 | 94 | return builder.build(); 95 | } 96 | 97 | public static ReplayFPSConfig load() { 98 | MinecraftClient client = MinecraftClient.getInstance(); 99 | File configFile = new File(client.runDirectory, CONFIG_FILE); 100 | 101 | if (configFile.exists()) { 102 | try(BufferedReader reader = new BufferedReader(new FileReader(configFile))) { 103 | return gson.fromJson(reader, ReplayFPSConfig.class); 104 | } catch (Exception e) { 105 | ReplayFPS.LOGGER.error("Unable to load Replay FPS config!", e); 106 | } 107 | } 108 | 109 | return new ReplayFPSConfig(); 110 | } 111 | 112 | public void save() { 113 | MinecraftClient client = MinecraftClient.getInstance(); 114 | File configFile = new File(client.runDirectory, CONFIG_FILE); 115 | 116 | try(BufferedWriter writer = new BufferedWriter(new FileWriter(configFile))) { 117 | writer.write(gson.toJson(this)); 118 | } catch (Exception e) { 119 | ReplayFPS.LOGGER.error("Error saving Replay FPS config!", e); 120 | } 121 | 122 | ReplayFPS.LOGGER.info("Saved config to " + configFile); 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /ccap_viewer/src/main/java/com/igrium/replayfps_viewer/ClientCapViewer.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps_viewer; 2 | 3 | import java.io.BufferedInputStream; 4 | import java.io.BufferedOutputStream; 5 | import java.io.File; 6 | import java.io.FileInputStream; 7 | import java.io.FileOutputStream; 8 | import java.io.IOException; 9 | import java.util.concurrent.CompletableFuture; 10 | import java.util.zip.ZipEntry; 11 | import java.util.zip.ZipInputStream; 12 | 13 | import org.apache.commons.io.FilenameUtils; 14 | 15 | import com.igrium.craftfx.application.ApplicationType; 16 | import com.igrium.craftfx.application.CraftApplication; 17 | import com.igrium.replayfps_viewer.ui.LoadingPopup; 18 | import com.igrium.replayfps_viewer.ui.MainUI; 19 | import com.mojang.logging.LogUtils; 20 | 21 | import javafx.application.Application; 22 | import javafx.application.Platform; 23 | import javafx.fxml.FXMLLoader; 24 | import javafx.scene.Parent; 25 | import javafx.scene.Scene; 26 | import javafx.stage.Stage; 27 | import net.minecraft.client.MinecraftClient; 28 | import net.minecraft.util.Util; 29 | 30 | public class ClientCapViewer extends CraftApplication { 31 | 32 | protected MainUI mainUI; 33 | 34 | public ClientCapViewer(ApplicationType type, MinecraftClient client) { 35 | super(type, client); 36 | } 37 | 38 | @Override 39 | public void start(Stage primaryStage, Application parent) throws Exception { 40 | FXMLLoader loader = new FXMLLoader(getClass().getResource(MainUI.FXML_PATH)); 41 | Parent root = loader.load(); 42 | mainUI = loader.getController(); 43 | mainUI.setAppInstance(this); 44 | 45 | Scene scene = new Scene(root); 46 | primaryStage.setScene(scene); 47 | primaryStage.show(); 48 | } 49 | 50 | public MainUI getMainUI() { 51 | return mainUI; 52 | } 53 | 54 | protected File currentFile; 55 | 56 | public File getCurrentFile() { 57 | return currentFile; 58 | } 59 | 60 | public void loadFile(File file) { 61 | if (file.equals(currentFile)) return; 62 | if (mainUI.getLoadedFile() != null) { 63 | try { 64 | mainUI.getLoadedFile().close(); 65 | } catch (IOException e) { 66 | LogUtils.getLogger().error("Error closing file.", e); 67 | } 68 | } 69 | 70 | if (FilenameUtils.getExtension(file.getName()).equals("mcpr")) { 71 | extractZipAsync(file); 72 | return; 73 | } 74 | 75 | try { 76 | mainUI.setLoadedFile(file != null ? new LoadedClientCap(file) : null); 77 | } catch (IOException e) { 78 | LogUtils.getLogger().error("Unable to load " + file, e); 79 | mainUI.setLoadedFile(null); 80 | } 81 | LogUtils.getLogger().info("Opened file " + file); 82 | } 83 | 84 | private void extractZipAsync(File file) { 85 | LoadingPopup popup = mainUI.getLoadingPopup(); 86 | popup.getLabel().setText("Extracting client-cap"); 87 | popup.getStage().show(); 88 | CompletableFuture.supplyAsync(() -> { 89 | try { 90 | return extractZip(file); 91 | } catch (IOException e) { 92 | throw new RuntimeException(e); 93 | } 94 | }, Util.getMainWorkerExecutor()).handleAsync((extracted, e) -> { 95 | popup.getStage().close(); 96 | if (extracted != null) { 97 | loadFile(extracted); 98 | } 99 | if (e != null) { 100 | LogUtils.getLogger().error("Error extracting client-cap", e); 101 | } 102 | return null; 103 | }, Platform::runLater); 104 | } 105 | 106 | 107 | private File extractZip(File file) throws IOException { 108 | File dest = File.createTempFile("extracted", ".ccap"); 109 | try (ZipInputStream in = new ZipInputStream(new BufferedInputStream(new FileInputStream(file))); 110 | BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(dest))) { 111 | 112 | ZipEntry entry; 113 | boolean success = false; 114 | while ((entry = in.getNextEntry()) != null) { 115 | if (entry.getName().equals("client.ccap")) { 116 | in.transferTo(out); 117 | success = true; 118 | break; 119 | } 120 | in.closeEntry(); 121 | } 122 | 123 | if (!success) { 124 | throw new IOException("No 'client.ccap' found!"); 125 | } 126 | } 127 | dest.deleteOnExit(); 128 | return dest; 129 | } 130 | 131 | @Override 132 | protected void onClosed() { 133 | if (mainUI.getLoadedFile() != null) { 134 | try { 135 | mainUI.getLoadedFile().close(); 136 | } catch (IOException e) { 137 | LogUtils.getLogger().error("Error closing file.", e); 138 | } 139 | } 140 | } 141 | } -------------------------------------------------------------------------------- /ccap_viewer/src/main/java/com/igrium/replayfps_viewer/ui/MainUI.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps_viewer.ui; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.util.List; 6 | import java.util.concurrent.CompletableFuture; 7 | 8 | import org.jetbrains.annotations.Nullable; 9 | 10 | import com.igrium.replayfps_viewer.ClientCapViewer; 11 | import com.igrium.replayfps_viewer.LoadedClientCap; 12 | import com.igrium.replayfps_viewer.util.GraphedChannel; 13 | import com.mojang.logging.LogUtils; 14 | 15 | import javafx.application.Platform; 16 | import javafx.fxml.FXML; 17 | import javafx.scene.chart.LineChart; 18 | import javafx.scene.chart.XYChart.Series; 19 | import javafx.scene.control.SplitPane; 20 | import javafx.stage.FileChooser; 21 | import javafx.stage.FileChooser.ExtensionFilter; 22 | import net.minecraft.util.Util; 23 | 24 | public class MainUI { 25 | public static final String FXML_PATH = "/assets/replayfps_viewer/ui/main.fxml"; 26 | 27 | @FXML 28 | private SplitPane mainPanel; 29 | private ClientCapViewer appInstance; 30 | 31 | @FXML 32 | private HeaderUI headerViewController; 33 | 34 | public SplitPane getMainPanel() { 35 | return mainPanel; 36 | } 37 | 38 | public HeaderUI getHeaderViewController() { 39 | return headerViewController; 40 | } 41 | 42 | @FXML 43 | private LineChart channelGraph; 44 | @FXML 45 | private ChannelGraph channelGraphController; 46 | 47 | private LoadedClientCap loadedFile; 48 | 49 | public LoadedClientCap getLoadedFile() { 50 | return loadedFile; 51 | } 52 | 53 | private LoadingPopup loadingPopup; 54 | 55 | public LoadingPopup getLoadingPopup() { 56 | if (loadingPopup == null) { 57 | loadingPopup = LoadingPopup.createLoadingPopup(mainPanel.getScene().getWindow()); 58 | } 59 | return loadingPopup; 60 | } 61 | 62 | @FXML 63 | protected void initialize() throws Exception { 64 | headerViewController.getChannelsTable().getSelectionModel().selectedItemProperty().addListener((prop, oldVal, newVal) -> { 65 | if (newVal == null) { 66 | channelGraph.getData().clear(); 67 | return; 68 | } 69 | 70 | loadChannelGraph(newVal.getIndex()); 71 | if (oldVal == null) { 72 | channelGraphController.fitGraph(); 73 | } 74 | }); 75 | 76 | } 77 | 78 | public void setLoadedFile(@Nullable LoadedClientCap loadedFile) { 79 | if (this.loadedFile == loadedFile) return; 80 | this.loadedFile = loadedFile; 81 | 82 | headerViewController.getChannelsTable().getSelectionModel().clearSelection(); 83 | channelGraph.getData().clear(); 84 | 85 | 86 | if (loadedFile != null) { 87 | headerViewController.loadHeader(loadedFile.getHeader(), loadedFile.getLength()); 88 | } else { 89 | headerViewController.clear(); 90 | } 91 | } 92 | 93 | private void loadChannelGraph(int index) { 94 | var channel = loadedFile.getHeader().getChannels().get(index); 95 | LoadingPopup loadingPopup = getLoadingPopup(); 96 | 97 | loadingPopup.getLabel().setText("Loading channel..."); 98 | loadingPopup.getStage().show(); 99 | CompletableFuture.supplyAsync(() -> { 100 | try { 101 | return GraphedChannel.create(loadedFile.getReader(), channel); 102 | } catch (IOException e) { 103 | throw new RuntimeException("Unable to load channel", e); 104 | } 105 | }, Util.getMainWorkerExecutor()).handleAsync(this::onChannelLoaded, Platform::runLater); 106 | 107 | } 108 | 109 | private Object onChannelLoaded(List> seriesList, Throwable e) { 110 | if (seriesList != null) { 111 | channelGraph.getData().clear(); 112 | channelGraph.getData().addAll(seriesList); 113 | } 114 | if (e != null) { 115 | LogUtils.getLogger().error("Error loading channel graph.", e); 116 | } 117 | loadingPopup.getStage().hide(); 118 | return null; 119 | } 120 | 121 | public void setAppInstance(ClientCapViewer appInstance) { 122 | this.appInstance = appInstance; 123 | } 124 | 125 | public ClientCapViewer getAppInstance() { 126 | return appInstance; 127 | } 128 | 129 | /** 130 | * Open the file selection screen. 131 | */ 132 | @FXML 133 | public void openFile() { 134 | FileChooser fileChooser = new FileChooser(); 135 | fileChooser.setTitle("Open client-cap file"); 136 | fileChooser.setSelectedExtensionFilter(new ExtensionFilter("Client-cap files", ".ccap")); 137 | 138 | File file = fileChooser.showOpenDialog(mainPanel.getScene().getWindow()); 139 | if (file == null) return; 140 | 141 | appInstance.loadFile(file); 142 | } 143 | 144 | @FXML 145 | public void fitGraph() { 146 | channelGraphController.fitGraph(); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /logo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/util/SeekableInputStream.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.util; 2 | 3 | import java.io.BufferedInputStream; 4 | import java.io.IOException; 5 | import java.io.InputStream; 6 | 7 | public class SeekableInputStream extends InputStream { 8 | 9 | public interface InputStreamSupplier { 10 | InputStream get() throws IOException; 11 | } 12 | 13 | protected final InputStreamSupplier supplier; 14 | 15 | private long head; 16 | private long mark; 17 | private long prevMark; 18 | private InputStream inputStream; 19 | private int bufferSize = 0x10000; 20 | 21 | /** 22 | * Create a managed input stream. 23 | * 24 | * @param supplier A supplier of a base input stream. This supplier may be 25 | * called any number of times, and it must return an input 26 | * stream with its head at the start of the file every time. 27 | * @throws IOException If an I/O exception occurs while loading the stream. 28 | */ 29 | public SeekableInputStream(InputStreamSupplier supplier) throws IOException { 30 | this.supplier = supplier; 31 | genStream(); 32 | } 33 | 34 | public SeekableInputStream(InputStreamSupplier supplier, int bufferSize) throws IOException { 35 | this.supplier = supplier; 36 | this.bufferSize = bufferSize; 37 | genStream(); 38 | } 39 | 40 | private void genStream() throws IOException { 41 | if (inputStream != null) inputStream.close(); 42 | inputStream = new BufferedInputStream(inputStream, bufferSize); 43 | prevMark = -1; 44 | } 45 | 46 | @Override 47 | public synchronized int read() throws IOException { 48 | int result = inputStream.read(); 49 | if (result != -1) { 50 | head++; 51 | } 52 | return result; 53 | } 54 | 55 | @Override 56 | public synchronized int read(byte[] b, int off, int len) throws IOException { 57 | int result = inputStream.read(b, off, len); 58 | head += result; 59 | return result; 60 | } 61 | 62 | @Override 63 | public synchronized long skip(long n) throws IOException { 64 | long result = inputStream.skip(n); 65 | head += result; 66 | return result; 67 | } 68 | 69 | /** 70 | * Get the current head of the stream. 71 | */ 72 | public long getHead() { 73 | return head; 74 | } 75 | 76 | @Override 77 | public boolean markSupported() { 78 | return true; 79 | } 80 | 81 | /** 82 | *

83 | * Marks the current position in this input stream. A subsequent call to the 84 | * reset method repositions this stream at the last marked position so that 85 | * subsequent reads re-read the same bytes. 86 | *

87 | *

88 | * Because ManagedInputStream can always jump to anywhere in a 89 | * file, this method works slightly different than normal. If the underlying 90 | * input stream doesn't support mark/reset, this simply sets a flag internally so 91 | * reset() knows where to jump to. However, if the underlying input 92 | * stream DOES support mark/reset, the underlying stream is marked as well, and 93 | * jumpTo is made slightly more efficient by utilizing the reset 94 | * capabilities of the underlying stream rather than constructing a new one. 95 | *

96 | */ 97 | @Override 98 | public synchronized void mark(int readlimit) { 99 | mark = head; 100 | if (inputStream.markSupported()) { 101 | inputStream.mark(readlimit); 102 | prevMark = head; 103 | } 104 | } 105 | 106 | @Override 107 | public synchronized void reset() throws IOException { 108 | jumpTo(mark); 109 | } 110 | 111 | /** 112 | * Attempt to jump to a particular address in the file. 113 | * @param address Byte address to jump to. 114 | * @return The actual address that was reached. 115 | * @throws IOException If an I/O exception occurs. 116 | */ 117 | public synchronized long jumpTo(long address) throws IOException { 118 | if (address == head) return head; 119 | else if (address > head) { 120 | skip(address - head); 121 | return head; 122 | } else { 123 | boolean resetSuccess = false; 124 | // Attempt to use mark before creating a whole new stream. 125 | if (inputStream.markSupported() && prevMark >= 0 && address >= prevMark) { 126 | try { 127 | inputStream.reset(); 128 | head = prevMark + inputStream.skip(address - prevMark); 129 | resetSuccess = true; 130 | } catch (IOException e) {} 131 | } 132 | 133 | if (!resetSuccess) { 134 | genStream(); 135 | head = inputStream.skip(address); 136 | } 137 | return head; 138 | } 139 | } 140 | 141 | @Override 142 | public void close() throws IOException { 143 | if (inputStream != null) inputStream.close(); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/util/DataWriter.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.util; 2 | 3 | import java.io.DataOutputStream; 4 | import java.io.IOException; 5 | import java.io.OutputStream; 6 | import java.io.UTFDataFormatException; 7 | 8 | /** 9 | * A re-implementation of {@link DataOutputStream} that doesn't require 10 | * per-stream instantiation. 11 | */ 12 | public class DataWriter { 13 | private final byte[] writeBuffer = new byte[8]; 14 | 15 | /** 16 | * Writes a {@code boolean} to the underlying output stream as 17 | * a 1-byte value. The value {@code true} is written out as the 18 | * value {@code (byte)1}; the value {@code false} is 19 | * written out as the value {@code (byte)0}. 20 | * 21 | * @param out Stream to write to. 22 | * @param v Value to write. 23 | * @throws IOException If an IO exception occurs. 24 | */ 25 | public final void writeBoolean(OutputStream out, boolean v) throws IOException { 26 | out.write(v ? 1 : 0); 27 | } 28 | 29 | /** 30 | * Writes a {@code short} to the underlying output stream as two 31 | * bytes, high byte first. 32 | * 33 | * @param out Stream to write to. 34 | * @param v Value to write. 35 | * @throws IOException If an IO exception occurs. 36 | */ 37 | public final void writeShort(OutputStream out, int v) throws IOException { 38 | writeBuffer[0] = (byte) (v >>> 8); 39 | writeBuffer[1] = (byte) (v >>> 0); 40 | out.write(writeBuffer, 0, 2); 41 | } 42 | 43 | /** 44 | * Writes a {@code char} to the underlying output stream as a 45 | * 2-byte value, high byte first. 46 | * 47 | * @param out Stream to write to. 48 | * @param v Value to write. 49 | * @throws IOException If an IO exception occurs. 50 | */ 51 | public final void writeChar(OutputStream out, int v) throws IOException { 52 | writeBuffer[0] = (byte) (v >>> 8); 53 | writeBuffer[1] = (byte) (v >>> 0); 54 | out.write(writeBuffer, 0, 2); 55 | } 56 | 57 | /** 58 | * Writes an {@code int} to the underlying output stream as four 59 | * bytes, high byte first. 60 | * 61 | * @param out Stream to write to. 62 | * @param v Value to write. 63 | * @throws IOException If an IO exception occurs. 64 | */ 65 | public final void writeInt(OutputStream out, int v) throws IOException { 66 | writeBuffer[0] = (byte) (v >>> 24); 67 | writeBuffer[1] = (byte) (v >>> 16); 68 | writeBuffer[2] = (byte) (v >>> 8); 69 | writeBuffer[3] = (byte) (v >>> 0); 70 | out.write(writeBuffer, 0, 4); 71 | } 72 | 73 | /** 74 | * Writes a {@code long} to the underlying output stream as eight 75 | * bytes, high byte first. 76 | * 77 | * @param out Stream to write to. 78 | * @param v Value to write. 79 | * @throws IOException If an IO exception occurs. 80 | */ 81 | public final void writeLong(OutputStream out, long v) throws IOException { 82 | writeBuffer[0] = (byte) (v >>> 56); 83 | writeBuffer[1] = (byte) (v >>> 48); 84 | writeBuffer[2] = (byte) (v >>> 40); 85 | writeBuffer[3] = (byte) (v >>> 32); 86 | writeBuffer[4] = (byte) (v >>> 24); 87 | writeBuffer[5] = (byte) (v >>> 16); 88 | writeBuffer[6] = (byte) (v >>> 8); 89 | writeBuffer[7] = (byte) (v >>> 0); 90 | out.write(writeBuffer, 0, 8); 91 | } 92 | 93 | /** 94 | * Converts the float argument to an {@code int} using the 95 | * {@code floatToIntBits} method in class {@code Float}, 96 | * and then writes that {@code int} value to the underlying 97 | * output stream as a 4-byte quantity, high byte first. 98 | * 99 | * @param out Stream to write to. 100 | * @param v Value to write. 101 | * @throws IOException If an IO exception occurs. 102 | */ 103 | public final void writeFloat(OutputStream out, float v) throws IOException { 104 | writeInt(out, Float.floatToIntBits(v)); 105 | } 106 | 107 | /** 108 | * Converts the double argument to a {@code long} using the 109 | * {@code doubleToLongBits} method in class {@code Double}, 110 | * and then writes that {@code long} value to the underlying 111 | * output stream as an 8-byte quantity, high byte first. 112 | * 113 | * @param out Stream to write to. 114 | * @param v Value to write. 115 | * @throws IOException If an IO exception occurs. 116 | */ 117 | public final void writeDouble(OutputStream out, double v) throws IOException { 118 | writeLong(out, Double.doubleToLongBits(v)); 119 | } 120 | 121 | /** 122 | * Writes out the string to the underlying output stream as a 123 | * sequence of bytes. Each character in the string is written out, in 124 | * sequence, by discarding its high eight bits. 125 | * 126 | * @param out Stream to write to. 127 | * @param s A string of bytes to write. 128 | * @throws IOException If an IO exception occurs. 129 | */ 130 | public final void writeBytes(OutputStream out, String s) throws IOException { 131 | int len = s.length(); 132 | for (int i = 0; i < len; i++) { 133 | out.write((byte) s.charAt(i)); 134 | } 135 | } 136 | 137 | /** 138 | * Writes a string to the underlying output stream as a sequence of 139 | * characters. Each character is written to the data output stream as 140 | * if by the {@code writeChar} method. 141 | * 142 | * @param out Stream to write to. 143 | * @param s Value to write. 144 | * @throws IOException If an IO exception occurs. 145 | */ 146 | public final void writeChars(OutputStream out, String s) throws IOException { 147 | int len = s.length(); 148 | for (int i = 0; i < len; i++) { 149 | int v = s.charAt(i); 150 | writeBuffer[0] = (byte) (v >>> 8); 151 | writeBuffer[1] = (byte) (v >>> 0); 152 | out.write(writeBuffer, 0, 2); 153 | } 154 | } 155 | 156 | /** 157 | * Writes a string to the specified DataOutput using 158 | * modified UTF-8 159 | * encoding in a machine-independent manner. 160 | *

161 | * First, two bytes are written to out as if by the {@code writeShort} 162 | * method giving the number of bytes to follow. This value is the number of 163 | * bytes actually written out, not the length of the string. Following the 164 | * length, each character of the string is output, in sequence, using the 165 | * modified UTF-8 encoding for the character. 166 | * 167 | * @param out Stream to write to. 168 | * @param str A string to be written. 169 | * @throws UTFDataFormatException if the modified UTF-8 encoding of 170 | * {@code str} would exceed 65535 bytes in length 171 | * @throws IOException If an IO exception occurs. 172 | */ 173 | public final void writeUTF(OutputStream out, String str) throws IOException { 174 | // This is a real cheap way of doing it, but it's used infrequently enough not 175 | // to worry about memory allocatinon overhead. 176 | new DataOutputStream(out).writeUTF(str); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/recording/ClientCapHeader.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.recording; 2 | 3 | import java.io.DataInputStream; 4 | import java.io.DataOutputStream; 5 | import java.io.IOException; 6 | import java.io.InputStream; 7 | import java.io.OutputStream; 8 | import java.util.ArrayList; 9 | import java.util.List; 10 | 11 | import org.slf4j.Logger; 12 | 13 | import com.igrium.replayfps.core.channel.ChannelHandler; 14 | import com.igrium.replayfps.core.channel.ChannelHandlers; 15 | import com.igrium.replayfps.core.channel.ChannelHandlers.PlaceholderChannelHandler; 16 | import com.mojang.logging.LogUtils; 17 | 18 | import net.minecraft.nbt.NbtCompound; 19 | import net.minecraft.nbt.NbtElement; 20 | import net.minecraft.nbt.NbtIo; 21 | import net.minecraft.nbt.NbtList; 22 | import net.minecraft.util.Identifier; 23 | import net.minecraft.util.InvalidIdentifierException; 24 | 25 | public class ClientCapHeader { 26 | 27 | public static class HeaderFormatException extends IOException { 28 | public HeaderFormatException() { 29 | 30 | } 31 | 32 | public HeaderFormatException(String message) { 33 | super(message); 34 | } 35 | } 36 | 37 | private static final Identifier INVALID_IDENTIFIER = new Identifier("replayfps:invalid"); 38 | private Logger logger = LogUtils.getLogger(); 39 | 40 | private List> channels; 41 | 42 | private int framerate = 40; 43 | private int framerateBase = 1; 44 | private int localPlayerID = -1; 45 | 46 | public ClientCapHeader(List> channels) { 47 | this.channels = new ArrayList<>(channels); 48 | } 49 | 50 | public ClientCapHeader() { 51 | this.channels = new ArrayList<>(); 52 | } 53 | 54 | public final List> getChannels() { 55 | return channels; 56 | } 57 | 58 | public int numChannels() { 59 | return channels.size(); 60 | } 61 | 62 | public final int getLocalPlayerID() { 63 | return localPlayerID; 64 | } 65 | 66 | public void setLocalPlayerID(int localPlayerID) { 67 | this.localPlayerID = localPlayerID; 68 | } 69 | 70 | public final int getFramerate() { 71 | return framerate; 72 | } 73 | 74 | public final int getFramerateBase() { 75 | return framerateBase; 76 | } 77 | 78 | public void setFramerate(int framerate) { 79 | if (framerate < 1) { 80 | throw new IllegalArgumentException("Framerate must be at least 1."); 81 | } 82 | this.framerate = framerate; 83 | } 84 | 85 | public void setFramerateBase(int framerateBase) { 86 | if (framerateBase < 1) { 87 | throw new IllegalArgumentException("Framerate base must be at least 1."); 88 | } 89 | this.framerateBase = framerateBase; 90 | } 91 | 92 | public final void setFramerate(int framerate, int framerateBase) { 93 | setFramerate(framerateBase); 94 | setFramerateBase(framerateBase); 95 | } 96 | 97 | public float getFramerateFloat() { 98 | return ((float) framerate) / ((float) framerateBase); 99 | } 100 | 101 | public float getFrameInterval() { 102 | return ((float) framerateBase) / ((float) framerate); 103 | } 104 | 105 | public int getFrameIntervalMillis() { 106 | return (framerateBase * 1000) / framerate; 107 | } 108 | 109 | public NbtCompound writeNBT(NbtCompound nbt) { 110 | if (localPlayerID == -1) { 111 | throw new IllegalStateException("Local player ID has not been set!"); 112 | } 113 | nbt.putInt("framerate", framerate); 114 | nbt.putInt("framerateBase", framerateBase); 115 | nbt.putInt("localPlayerID", localPlayerID); 116 | 117 | NbtList channels = new NbtList(); 118 | for (ChannelHandler handler : this.channels) { 119 | channels.add(writeChannelDeclaration(handler, new NbtCompound())); 120 | } 121 | nbt.put("channels", channels); 122 | 123 | return nbt; 124 | } 125 | 126 | private NbtCompound writeChannelDeclaration(ChannelHandler channel, NbtCompound nbt) { 127 | Identifier id = ChannelHandlers.REGISTRY.inverse().get(channel); 128 | if (id == null) id = INVALID_IDENTIFIER; 129 | 130 | nbt.putString("id", id.toString()); 131 | nbt.putInt("size", channel.getChannelType().getSize()); 132 | return nbt; 133 | } 134 | 135 | public void readNBT(NbtCompound nbt) throws HeaderFormatException { 136 | if (nbt.contains("framerate", NbtElement.INT_TYPE)) { 137 | setFramerate(nbt.getInt("framerate")); 138 | } 139 | 140 | if (nbt.contains("framerateBase", NbtElement.INT_TYPE)) { 141 | setFramerateBase(nbt.getInt("framerateBase")); 142 | } 143 | 144 | if (!nbt.contains("channels", NbtElement.LIST_TYPE)) { 145 | throw new HeaderFormatException("No channel declaration found."); 146 | } 147 | 148 | NbtList channels = nbt.getList("channels", NbtElement.COMPOUND_TYPE); 149 | 150 | for (NbtElement element : channels) { 151 | this.channels.add(readChannelDeclaration((NbtCompound) element)); 152 | } 153 | 154 | if (!nbt.contains("localPlayerID", NbtElement.INT_TYPE)) { 155 | throw new HeaderFormatException("No local player ID found."); 156 | } 157 | localPlayerID = nbt.getInt("localPlayerID"); 158 | } 159 | 160 | private ChannelHandler readChannelDeclaration(NbtCompound nbt) throws HeaderFormatException { 161 | String name = nbt.getString("id"); 162 | Identifier id; 163 | try { 164 | id = new Identifier(name); 165 | } catch (InvalidIdentifierException e) { 166 | throw new HeaderFormatException("Invalid channel id: " + name); 167 | } 168 | 169 | if (!nbt.contains("size", NbtElement.INT_TYPE)) { 170 | throw new HeaderFormatException("Channel must specify a size."); 171 | } 172 | 173 | int size = nbt.getInt("size"); 174 | 175 | ChannelHandler handler = ChannelHandlers.REGISTRY.get(id); 176 | if (handler == null) { 177 | logger.warn("Unknown channel type: " + id); 178 | handler = new PlaceholderChannelHandler(size); 179 | } 180 | 181 | if (handler.getChannelType().getSize() != size) { 182 | logger.error("Improper channel size for handler type '%s'! (%d != %d)".formatted(id, size, handler.getChannelType().getSize())); 183 | handler = new PlaceholderChannelHandler(size); 184 | } 185 | 186 | return handler; 187 | } 188 | 189 | public void writeHeader(OutputStream out) throws IOException { 190 | NbtIo.writeCompound(writeNBT(new NbtCompound()), new DataOutputStream(out)); 191 | } 192 | 193 | public void readHeader(InputStream in) throws IOException { 194 | NbtCompound nbt = NbtIo.readCompound(new DataInputStream(in)); 195 | readNBT(nbt); 196 | } 197 | 198 | /** 199 | * Calculate the length of one frame. 200 | * @return Number of bytes in a frame. 201 | */ 202 | public int calculateFrameLength() { 203 | int length = 0; 204 | for (ChannelHandler handler : channels) { 205 | length += handler.getChannelType().getSize(); 206 | } 207 | return length; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/recording/ClientCapRecorder.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.recording; 2 | 3 | import java.io.BufferedOutputStream; 4 | import java.io.Closeable; 5 | import java.io.IOException; 6 | import java.io.OutputStream; 7 | import java.util.Optional; 8 | 9 | import org.jetbrains.annotations.Nullable; 10 | import org.slf4j.Logger; 11 | 12 | import com.igrium.replayfps.core.channel.ChannelHandler; 13 | import com.igrium.replayfps.core.playback.UnserializedFrame; 14 | import com.igrium.replayfps.core.util.AnimationUtils; 15 | import com.igrium.replayfps.core.util.NoHeaderException; 16 | import com.igrium.replayfps.core.util.TimecodeProvider; 17 | import com.mojang.logging.LogUtils; 18 | import com.replaymod.recording.packet.PacketListener; 19 | 20 | /** 21 | * Captures and saves frames to a file. 22 | */ 23 | public class ClientCapRecorder implements Closeable { 24 | private static final Logger LOGGER = LogUtils.getLogger(); 25 | 26 | private final BufferedOutputStream out; 27 | private final ClientCapWriter writer; 28 | 29 | private final PacketListener packetListener; 30 | 31 | @Nullable 32 | private ClientCapHeader header; 33 | 34 | private int saveInterval = 512; 35 | 36 | public int getSaveInterval() { 37 | return saveInterval; 38 | } 39 | 40 | public void setSaveInterval(int saveInterval) { 41 | this.saveInterval = saveInterval; 42 | } 43 | 44 | public ClientCapRecorder(OutputStream out, PacketListener packetListener) { 45 | this.out = new BufferedOutputStream(out); 46 | this.writer = new ClientCapWriter(out); 47 | this.packetListener = packetListener; 48 | } 49 | 50 | public PacketListener getPacketListener() { 51 | return packetListener; 52 | } 53 | 54 | @Nullable 55 | public ClientCapHeader getHeader() { 56 | return header; 57 | } 58 | 59 | public ClientCapWriter getWriter() { 60 | return writer; 61 | } 62 | 63 | /** 64 | * Write the file header. 65 | * @param header Header to write. 66 | * @throws IllegalStateException If the header has already been written. 67 | */ 68 | public void writeHeader(ClientCapHeader header) throws IllegalStateException { 69 | if (this.header != null) { 70 | throw new IllegalStateException("Header has already been written."); 71 | } 72 | 73 | this.header = header; 74 | try { 75 | header.writeHeader(out); 76 | out.flush(); 77 | } catch (IOException e) { 78 | LOGGER.error("Error writing clientcap header. Recording will be aborted.", e); 79 | this.error = Optional.of(e); 80 | return; 81 | } 82 | 83 | } 84 | 85 | /* FRAME CAPTURE */ 86 | 87 | /** 88 | * Capture a frame. 89 | * @param context Capture context. 90 | * @return The frame. 91 | * @throws Exception If the frame capture fails. 92 | */ 93 | public UnserializedFrame captureFrame(ClientCaptureContext context) throws Exception { 94 | assertHeaderWritten(); 95 | Object[] values = new Object[header.numChannels()]; 96 | 97 | int i = 0; 98 | for (ChannelHandler handler : header.getChannels()) { 99 | values[i] = handler.capture(context); 100 | i++; 101 | } 102 | 103 | return new UnserializedFrame(header, values); 104 | } 105 | 106 | private int framesSinceLastSave; 107 | 108 | protected UnserializedFrame writeFrame(ClientCaptureContext context) throws Exception { 109 | assertHeaderWritten(); 110 | 111 | UnserializedFrame frame = captureFrame(context); 112 | writer.writeFrame(frame); 113 | 114 | framesSinceLastSave++; 115 | if (framesSinceLastSave > saveInterval) { 116 | out.flush(); 117 | framesSinceLastSave = 0; 118 | } 119 | 120 | return frame; 121 | } 122 | 123 | /* RECORDING */ 124 | 125 | private boolean isRecording; 126 | public final boolean isRecording() { 127 | return isRecording; 128 | } 129 | 130 | public void startRecording() throws IllegalStateException { 131 | if (isRecording) throw new IllegalStateException("We are already recording."); 132 | isRecording = true; 133 | } 134 | 135 | private Optional error = Optional.empty(); 136 | public Optional getError() { 137 | return error; 138 | } 139 | 140 | public boolean hasErrored() { 141 | return error.isPresent(); 142 | } 143 | 144 | /** 145 | * Called every frame wile capturing. 146 | * @param context The render context. 147 | */ 148 | public void tick(ClientCaptureContext context) { 149 | if (header == null || !isRecording) return; 150 | if (hasErrored()) return; 151 | 152 | // long now = Util.getMeasuringTimeMs(); 153 | // // Real time since recording started. 154 | // long timeRecording = now - startTime; 155 | 156 | // // This math makes sense to me now, but don't ask me to explain it later. 157 | // // UPDATE: It's later and I don't understand it. 158 | // if (serverWasPaused) { 159 | // timePassedWhilePaused = timeRecording - lastTimestamp; 160 | // serverWasPaused = false; 161 | // } 162 | // long timestamp = timeRecording - timePassedWhilePaused; 163 | // lastTimestamp = timestamp; 164 | 165 | // We can't use Util.getMeasuringTimeMillis because packetListener.getStartTime returns in terms of global unix time. 166 | if (((TimecodeProvider) packetListener).getServerWasPaused()) { 167 | return; 168 | } 169 | 170 | long timeRecording = System.currentTimeMillis() - ((TimecodeProvider) packetListener).getStartTime(); 171 | long timestamp = timeRecording - ((TimecodeProvider) packetListener).getTimePassedWhilePaused(); 172 | 173 | int currentFrame = AnimationUtils.countFrames((int) timestamp, header.getFramerate(), header.getFramerateBase()); 174 | // It doesn't matter if this is negative because we're only using it for a for loop. 175 | int framesToCapture = currentFrame - writer.getWrittenFrames(); 176 | 177 | if (framesToCapture > 100) { 178 | LOGGER.warn("%d frames have been captured on this tick. This might be a mistake.".formatted(framesToCapture)); 179 | } 180 | 181 | if (framesToCapture < 0) { 182 | LOGGER.warn(String.format("More frames have been captured than the current timestamp suggests. (%d > %d)", 183 | writer.getWrittenFrames(), currentFrame)); 184 | } 185 | 186 | for (int i = 0; i < framesToCapture; i++) { 187 | try { 188 | writeFrame(context); 189 | } catch (Exception e) { 190 | LOGGER.error(String.format( 191 | "Error capturing frame %d. Capture will be aborted.", writer.getWrittenFrames()), e); 192 | this.error = Optional.of(e); 193 | return; 194 | } 195 | } 196 | } 197 | 198 | private void assertHeaderWritten() throws NoHeaderException { 199 | if (header == null) 200 | throw new NoHeaderException("Header has not been written."); 201 | } 202 | 203 | @Override 204 | public void close() throws IOException { 205 | out.flush(); 206 | out.close(); 207 | } 208 | 209 | } 210 | -------------------------------------------------------------------------------- /src/main/java/com/igrium/replayfps/core/recording/ClientRecordingModule.java: -------------------------------------------------------------------------------- 1 | package com.igrium.replayfps.core.recording; 2 | 3 | import java.io.IOException; 4 | import java.io.OutputStream; 5 | import java.util.LinkedList; 6 | import java.util.List; 7 | import java.util.Optional; 8 | 9 | import com.igrium.replayfps.core.channel.ChannelHandler; 10 | import com.igrium.replayfps.core.channel.ChannelHandlers; 11 | import com.igrium.replayfps.core.events.ChannelRegistrationCallback; 12 | import com.igrium.replayfps.core.events.RecordingEvents; 13 | import com.mojang.logging.LogUtils; 14 | import com.replaymod.core.Module; 15 | import com.replaymod.core.ReplayMod; 16 | import com.replaymod.lib.de.johni0702.minecraft.gui.utils.EventRegistrations; 17 | import com.replaymod.recording.packet.PacketListener; 18 | import com.replaymod.replaystudio.replay.ReplayFile; 19 | 20 | import net.fabricmc.api.EnvType; 21 | import net.fabricmc.api.Environment; 22 | import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext; 23 | import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents; 24 | import net.minecraft.client.MinecraftClient; 25 | import net.minecraft.client.network.ClientPlayerEntity; 26 | import net.minecraft.client.render.Camera; 27 | import net.minecraft.client.render.GameRenderer; 28 | import net.minecraft.client.world.ClientWorld; 29 | import net.minecraft.entity.Entity; 30 | 31 | @Environment(EnvType.CLIENT) 32 | public class ClientRecordingModule extends EventRegistrations implements Module { 33 | 34 | public static final String ENTRY = "client.ccap"; 35 | 36 | private static ClientRecordingModule instance; 37 | 38 | public static ClientRecordingModule getInstance() { 39 | return instance; 40 | } 41 | 42 | private final ReplayMod replayMod; 43 | 44 | private Optional activeRecording = Optional.empty(); 45 | 46 | public ClientRecordingModule(ReplayMod replayMod) { 47 | this.replayMod = replayMod; 48 | } 49 | 50 | public ReplayMod getReplayMod() { 51 | return replayMod; 52 | } 53 | 54 | @Override 55 | public void initCommon() { 56 | instance = this; 57 | } 58 | 59 | @Override 60 | public void register() { 61 | super.register(); 62 | WorldRenderEvents.END.register(this::onFrame); 63 | } 64 | 65 | private ClientCapHeader queuedHeader; 66 | 67 | { on(RecordingEvents.STARTED_RECORDING, this::onStartedRecording); } 68 | protected void onStartedRecording(PacketListener listener, ReplayFile file) { 69 | List> channels = new LinkedList<>(); 70 | 71 | ChannelRegistrationCallback.EVENT.invoker().createChannels(handler -> { 72 | if (!ChannelHandlers.REGISTRY.inverse().containsKey(handler)) { 73 | throw new IllegalArgumentException("The supplied channel handler has not been registered!"); 74 | } 75 | channels.add(handler); 76 | }); 77 | LogUtils.getLogger().info("Starting client-cap recording!"); 78 | ClientCapHeader header = new ClientCapHeader(channels); 79 | try { 80 | OutputStream out = file.write(ENTRY); 81 | ClientCapRecorder recorder = new ClientCapRecorder(out, listener); 82 | activeRecording = Optional.of(recorder); 83 | queuedHeader = header; 84 | LogUtils.getLogger().info("Header has %d channels".formatted(channels.size())); 85 | 86 | } catch (Exception e) { 87 | LogUtils.getLogger().error("Unable to initialize client-cap recording.", e); 88 | } 89 | } 90 | 91 | { on(RecordingEvents.STOP_RECORDING, this::onStoppingRecording); } 92 | protected void onStoppingRecording(PacketListener listener, ReplayFile file) { 93 | if (isRecording()) stopRecording(); 94 | } 95 | 96 | // { on(PreRenderCallback.EVENT, this::checkForGamePaused); } 97 | // protected void checkForGamePaused() { 98 | // MinecraftClient client = replayMod.getMinecraft(); 99 | // if (activeRecording.isPresent() && client.isIntegratedServerRunning()) { 100 | // IntegratedServer server = client.getServer(); 101 | // if (((IntegratedServerAccessor) server).isGamePaused()) { 102 | // activeRecording.get().setServerWasPaused(); 103 | // } 104 | // } 105 | // } 106 | 107 | protected void onFrame(WorldRenderContext context) { 108 | if (activeRecording.isPresent()) { 109 | ClientCapRecorder recording = activeRecording.get(); 110 | ClientCaptureContext clientContext = new ClientCaptureContextImpl(context, MinecraftClient.getInstance()); 111 | 112 | if (recording.getHeader() == null) { 113 | initRecording(recording, clientContext.localPlayer().getId()); 114 | } 115 | recording.tick(clientContext); 116 | } 117 | } 118 | 119 | private void initRecording(ClientCapRecorder recording, int localPlayerId) { 120 | queuedHeader.setLocalPlayerID(localPlayerId); 121 | recording.writeHeader(queuedHeader); 122 | recording.startRecording(); 123 | } 124 | 125 | public Optional getActiveRecording() { 126 | return activeRecording; 127 | } 128 | 129 | public boolean isRecording() { 130 | return activeRecording.isPresent(); 131 | } 132 | 133 | /** 134 | * Stop recording the client-cap. 135 | * @throws IllegalStateException If we're not currently recording. 136 | */ 137 | public void stopRecording() throws IllegalStateException { 138 | if (!isRecording()) { 139 | throw new IllegalStateException("We are not recording."); 140 | } 141 | 142 | try { 143 | activeRecording.get().close(); 144 | } catch (IOException e) { 145 | LogUtils.getLogger().error("Error closing recording stream.", e); 146 | } 147 | activeRecording = Optional.empty(); 148 | } 149 | 150 | private static class ClientCaptureContextImpl implements ClientCaptureContext { 151 | 152 | private final WorldRenderContext renderContext; 153 | private final MinecraftClient client; 154 | 155 | public ClientCaptureContextImpl(WorldRenderContext renderContext, MinecraftClient client) { 156 | this.renderContext = renderContext; 157 | this.client = client; 158 | } 159 | 160 | @Override 161 | public MinecraftClient client() { 162 | return client; 163 | } 164 | 165 | @Override 166 | public float tickDelta() { 167 | return renderContext.tickDelta(); 168 | } 169 | 170 | @Override 171 | public Entity cameraEntity() { 172 | return client.cameraEntity; 173 | } 174 | 175 | @Override 176 | public Camera camera() { 177 | return renderContext.camera(); 178 | } 179 | 180 | @Override 181 | public ClientPlayerEntity localPlayer() { 182 | return client.player; 183 | } 184 | 185 | @Override 186 | public GameRenderer gameRenderer() { 187 | return renderContext.gameRenderer(); 188 | } 189 | 190 | @Override 191 | public ClientWorld world() { 192 | return renderContext.world(); 193 | } 194 | 195 | @Override 196 | public WorldRenderContext renderContext() { 197 | return renderContext; 198 | } 199 | 200 | } 201 | } 202 | 203 | 204 | --------------------------------------------------------------------------------