├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── build.gradle.kts ├── common ├── build.gradle.kts └── src │ └── main │ ├── java │ └── org │ │ └── waste │ │ └── of │ │ └── time │ │ ├── LoaderInfo.java │ │ ├── extension │ │ └── IPalettedContainerExtension.java │ │ └── mixin │ │ ├── BossBarHudMixin.java │ │ ├── ClientPlayInteractionManagerMixin.java │ │ ├── ClientPlayNetworkHandlerMixin.java │ │ ├── ClientWorldMixin.java │ │ ├── DebugRendererMixin.java │ │ ├── GameMenuScreenMixin.java │ │ ├── IntegratedServerLoaderMixin.java │ │ └── PalettedContainerMixin.java │ ├── kotlin │ └── org │ │ └── waste │ │ └── of │ │ └── time │ │ ├── Events.kt │ │ ├── Utils.kt │ │ ├── WorldTools.kt │ │ ├── config │ │ └── WorldToolsConfig.kt │ │ ├── gui │ │ ├── BrowseDownloadsScreen.kt │ │ ├── EnterTextField.kt │ │ └── ManagerScreen.kt │ │ ├── manager │ │ ├── BarManager.kt │ │ ├── CaptureManager.kt │ │ ├── MessageManager.kt │ │ └── StatisticManager.kt │ │ └── storage │ │ ├── Cacheable.kt │ │ ├── CustomRegionBasedStorage.kt │ │ ├── PathTreeNode.kt │ │ ├── RegionBased.kt │ │ ├── StorageFlow.kt │ │ ├── Storeable.kt │ │ ├── cache │ │ ├── DataInjectionHandler.kt │ │ ├── EntityCacheable.kt │ │ ├── HotCache.kt │ │ └── LazyUpdatingDelegate.kt │ │ └── serializable │ │ ├── AdvancementsStoreable.kt │ │ ├── BlockEntityLoadable.kt │ │ ├── CompressLevelStoreable.kt │ │ ├── EndFlow.kt │ │ ├── LevelDataStoreable.kt │ │ ├── MapDataStoreable.kt │ │ ├── MetadataStoreable.kt │ │ ├── PlayerStoreable.kt │ │ ├── RegionBasedChunk.kt │ │ ├── RegionBasedEntities.kt │ │ └── StatisticStoreable.kt │ └── resources │ ├── assets │ └── worldtools │ │ ├── WorldTools.png │ │ └── lang │ │ ├── de_de.json │ │ ├── en_pt.json │ │ ├── en_us.json │ │ ├── fr_ca.json │ │ ├── fr_fr.json │ │ ├── nl_be.json │ │ ├── nl_nl.json │ │ ├── nn_no.json │ │ ├── no_no.json │ │ ├── pt_br.json │ │ ├── pt_pt.json │ │ ├── ru_ru.json │ │ ├── zh_cn.json │ │ └── zh_tw.json │ ├── worldtools.accesswidener │ └── worldtools.mixins.common.json ├── fabric ├── build.gradle.kts ├── gradle.properties └── src │ └── main │ ├── java │ └── org │ │ └── waste │ │ └── of │ │ └── time │ │ └── fabric │ │ └── LoaderInfoImpl.java │ ├── kotlin │ └── org │ │ └── waste │ │ └── of │ │ └── time │ │ └── fabric │ │ ├── WorldToolsFabric.kt │ │ └── WorldToolsModMenuIntegration.kt │ └── resources │ ├── fabric.mod.json │ └── worldtools.mixins.fabric.json ├── forge ├── build.gradle.kts ├── gradle.properties └── src │ └── main │ ├── java │ └── org │ │ └── waste │ │ └── of │ │ └── time │ │ └── forge │ │ └── LoaderInfoImpl.java │ ├── kotlin │ └── org │ │ └── waste │ │ └── of │ │ └── time │ │ └── forge │ │ └── WorldToolsForge.kt │ └── resources │ ├── META-INF │ └── mods.toml │ └── pack.mcmeta ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: WorldTools 1.21.1 Build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - "1.21.4" 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Gradle Wrapper Verification 17 | uses: gradle/wrapper-validation-action@v3 18 | 19 | - name: Setup JDK 20 | uses: actions/setup-java@v4 21 | with: 22 | java-version: '21' 23 | distribution: 'temurin' 24 | 25 | - name: Elevate wrapper permissions 26 | run: chmod +x ./gradlew 27 | 28 | - name: Build WorldTools 29 | uses: gradle/actions/setup-gradle@v3 30 | with: 31 | arguments: build 32 | 33 | - name: Upload Fabric Artifacts 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: WorldTools-fabric 37 | path: fabric/build/libs/*.jar 38 | 39 | - name: Upload Forge Artifacts 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: WorldTools-forge 43 | path: forge/build/libs/*.jar 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Modrinth and CurseForge Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+\+1.21.4' 7 | 8 | env: 9 | MINECRAFT_VERSION: "1.21.4" 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Gradle Wrapper Verification 19 | uses: gradle/wrapper-validation-action@v3 20 | 21 | - name: Setup JDK 22 | uses: actions/setup-java@v4 23 | with: 24 | java-version: '21' 25 | distribution: 'temurin' 26 | 27 | - name: Elevate wrapper permissions 28 | run: chmod +x ./gradlew 29 | 30 | - name: Build WorldTools 31 | uses: gradle/actions/setup-gradle@v3 32 | with: 33 | arguments: build 34 | 35 | - name: Upload Fabric Artifact 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: WorldTools-fabric-${{ github.ref_name }} 39 | path: fabric/build/libs/WorldTools-fabric-${{ github.ref_name }}.jar 40 | 41 | - name: Upload Forge Artifact 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: WorldTools-forge-${{ github.ref_name }} 45 | path: forge/build/libs/WorldTools-forge-${{ github.ref_name }}.jar 46 | 47 | - name: Generate Changelog 48 | id: changelog 49 | uses: mikepenz/release-changelog-builder-action@v5 50 | with: 51 | commitMode: true 52 | configurationJson: | 53 | { 54 | "pr_template": "- #{{TITLE}} [#{{AUTHOR}}](#{{URL}})" 55 | } 56 | 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | - name: Release Fabric 61 | uses: Kir-Antipov/mc-publish@v3.3 62 | with: 63 | changelog: ${{steps.changelog.outputs.changelog}} 64 | curseforge-token: ${{ secrets.CURSEFORGE_TOKEN }} 65 | curseforge-id: 909868 66 | 67 | modrinth-token: ${{ secrets.MODRINTH_TOKEN }} 68 | modrinth-id: FlFKBOIX 69 | 70 | files: | 71 | fabric/build/libs/WorldTools-fabric-${{ github.ref_name }}.jar 72 | name: WorldTools ${{ github.ref_name }} (Fabric) 73 | version: ${{ github.ref_name }} 74 | loaders: | 75 | fabric 76 | game-versions: | 77 | ${{ env.MINECRAFT_VERSION }} 78 | dependencies: | 79 | fabric-api(required){modrinth:P7dR8mSH}{curseforge:306612} 80 | fabric-language-kotlin(required){modrinth:Ha28R6CL}{curseforge:351264} 81 | cloth-config(required){modrinth:9s6osm5g}{curseforge:348521} 82 | 83 | - name: Release Forge 84 | uses: Kir-Antipov/mc-publish@v3.3 85 | with: 86 | changelog: ${{steps.changelog.outputs.changelog}} 87 | curseforge-token: ${{ secrets.CURSEFORGE_TOKEN }} 88 | curseforge-id: 909868 89 | 90 | modrinth-token: ${{ secrets.MODRINTH_TOKEN }} 91 | modrinth-id: FlFKBOIX 92 | 93 | files: | 94 | forge/build/libs/WorldTools-forge-${{ github.ref_name }}.jar 95 | name: WorldTools ${{ github.ref_name }} (Forge) 96 | version: ${{ github.ref_name }} 97 | loaders: | 98 | forge 99 | game-versions: | 100 | ${{ env.MINECRAFT_VERSION }} 101 | dependencies: | 102 | kotlinforforge(required){modrinth:ordsPcFz}{curseforge:351264} 103 | cloth-config(required){modrinth:9s6osm5g}{curseforge:348521} 104 | 105 | # TODO: FIX ACTION PERMS ON REPO SETTINGS 106 | # - name: Release Github 107 | # uses: Kir-Antipov/mc-publish@v3.3 108 | # with: 109 | # changelog: ${{steps.changelog.outputs.changelog}} 110 | # name: WorldTools-${{ github.ref_name }} 111 | # version: ${{ github.ref_name }} 112 | # github-token: ${{ secrets.GITHUB_TOKEN }} 113 | # github-files: | 114 | # fabric/build/libs/WorldTools-fabric-${{ github.ref_name }}.jar 115 | # forge/build/libs/WorldTools-forge-${{ github.ref_name }}.jar 116 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .gradle/ 3 | 4 | build/ 5 | run/ 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # WorldTools: World Downloader (Fabric / Forge) 6 | 7 | [![CurseForge Downloads](https://cf.way2muchnoise.eu/worldtools.svg?badge_style=for_the_badge)](https://www.curseforge.com/minecraft/mc-mods/worldtools) 8 | [![Modrinth Downloads](https://img.shields.io/modrinth/dt/FlFKBOIX?style=for-the-badge&logo=modrinth&label=Modrinth&color=00AF5C)](https://modrinth.com/mod/worldtools) 9 | [![Minecraft](https://img.shields.io/badge/Minecraft-1.20.1-yellow?style=for-the-badge&link=https://www.minecraft.net/)](https://www.minecraft.net/) 10 | [![Minecraft](https://img.shields.io/badge/Minecraft-1.20.2-green?style=for-the-badge&link=https://www.minecraft.net/)](https://www.minecraft.net/) 11 | [![Minecraft](https://img.shields.io/badge/Minecraft-1.20.4-lime?style=for-the-badge&link=https://www.minecraft.net/)](https://www.minecraft.net/) 12 | [![Minecraft](https://img.shields.io/badge/Minecraft-1.21.1-lime?style=for-the-badge&link=https://www.minecraft.net/)](https://www.minecraft.net/) 13 | [![License](https://img.shields.io/badge/License-GPL%20v3-blue?style=for-the-badge&link=https://www.gnu.org/licenses/gpl-3.0.en.html)](https://www.gnu.org/licenses/gpl-3.0.en.html) 14 | 15 | WorldTools is a powerful Minecraft mod that allows you to capture and save high-detail snapshots of server worlds 16 | locally. 17 | It empowers you to download comprehensive information, including chunks, entities, 18 | chests, players, statistics, advancements, and detailed metadata. 19 | WorldTools ensures that you can retain an accurate and unaltered representation of the server's world for analysis, 20 | sharing, or backup purposes on your local machine. 21 | 22 |

23 | Fabric Supported 24 | Forge Supported 25 |

26 | 27 |
28 | Link to the lambda discord server https://discord.gg/3y3ah5BtjB 29 |
30 | 31 | ## Features 32 | 33 | - **World Download (_default keybind:_ `F12`)**: 34 | Initiate a quick download by hitting the `F12` key, which can be altered in the keybind settings. 35 | Alternatively, you can access the GUI (_default keybind:_ `F10`) via the escape menu. 36 | The GUI allows you to tailor the capture process according to your requirements. 37 | WorldTools facilitates the capture of a wide range of crucial elements, ensuring no detail is missed. 38 | - Chunks: Terrain, biomes and structures 39 | - Entities: Inventories and attributes of most entities 40 | - Containers: Contents of all tile entities like chests, shulkers, hoppers, furnaces, brewing stands, droppers, 41 | dispensers etc... 42 | - Players: Player positions and inventories 43 | - Statistics: Full personal player statistics 44 | - Advancements: Player advancements and progress 45 | - Special Objects: Maps, Lecterns and Banners 46 | - Detailed Metadata: Exhaustive capture details like modt, server version, timestamps, and more 47 | 48 | - **Easy Access to Saved Worlds**: Your locally captured world save can be found in the single-player worlds list, 49 | allowing you to load and explore it conveniently. 50 | 51 | - **Advanced Configuration**: WorldTools provides a wide range of settings to customize the capture process to your 52 | needs. 53 | Select elements to capture, modify game rules, alter entity NBT data, and configure the capture process in detail. 54 | 55 | ## Getting Started 56 | 57 | ### Fabric 58 | 59 |

60 | 61 | 62 | 63 | 64 | 65 | 66 |

67 | 68 | 1. **Installation**: 69 | - Install Fabric by following the [Fabric Installation Guide](https://fabricmc.net/wiki/install). 70 | - Download the latest Fabric version of WorldTools from 71 | the [releases page](https://github.com/Avanatiker/WorldTools/releases) 72 | - Place the WorldTools Fabric mod JAR file in the "mods" folder of your Fabric installation. 73 | 74 | 2. **Prerequisites**: Make sure you have the following mods installed: 75 | - [Fabric API](https://www.curseforge.com/minecraft/mc-mods/fabric-api) 76 | - [fabric-language-kotlin](https://www.curseforge.com/minecraft/mc-mods/fabric-language-kotlin) 77 | - [Cloth Config API](https://www.curseforge.com/minecraft/mc-mods/cloth-config) 78 | - [Mod Menu](https://modrinth.com/mod/modmenu) 79 | 80 | ### Forge 81 | 82 | 1. **Installation**: 83 | - Install Forge by following the [Forge Download Link](https://files.minecraftforge.net/net/minecraftforge/forge/). 84 | - Download the latest Forge version of WorldTools from 85 | the [releases page](https://github.com/Avanatiker/WorldTools/releases) 86 | - Place the WorldTools Forge mod JAR file in the "mods" folder of your Forge installation. 87 | 88 | 2. **Prerequisites**: Make sure you have the following mods installed: 89 | - [Kotlin For Forge](https://www.curseforge.com/minecraft/mc-mods/kotlin-for-forge) 90 | - [Cloth Config API](https://www.curseforge.com/minecraft/mc-mods/cloth-config) 91 | 92 | ### Usage 93 | 94 | 1. **Download**: 95 | - Enable capture mode: Hit `F12` the GUI (on ESC menu) or `/worldtools capture` to start capturing data. 96 | - Play the game normally while WorldTools downloads the all data. You need to open containers like chests to capture 97 | their contents. 98 | - Save captured data: Hit `F12` the GUI (on ESC menu) or `/worldtools capture` again to stop capturing data and save the world. 99 | 2. **Access Downloaded World**: Your downloaded world can be found in the single-player worlds list. 100 | 101 | ### File Structure 102 | 103 | After capturing data, WorldTools creates the following files in the world directory's folder: 104 | 105 | - `Capture Metadata.md`: Contains detailed information about the capture process itself. 106 | 107 | - `Dimension Tree.txt`: Provides a tree of all dimension folder paths of the server, not just the downloaded ones. 108 | 109 | - `Player Entry List.csv`: Lists all players that were online during the capture including all known metadata. 110 | 111 | ## Supported Languages 112 | 113 | For the best user experience, WorldTools is available in the following languages: 114 | 115 | - German 116 | - English (Pirate) 117 | - English (United States) 118 | - French (Canada) 119 | - French (France) 120 | - Dutch (Belgium) 121 | - Dutch (Netherlands) 122 | - Nynorsk (Norwegian) 123 | - Norwegian (Norway) 124 | - Portuguese (Brazil) 125 | - Portuguese (Portugal) 126 | - Russian 127 | 128 | ## Contributing 129 | 130 | Contributions are welcome! 131 | Please read our [Code of Conduct](https://github.com/Avanatiker/WorldTools/blob/master/CODE_OF_CONDUCT.md) 132 | and [Contributing Guidelines](https://github.com/Avanatiker/WorldTools/blob/master/CONTRIBUTING.md) before submitting a 133 | Pull Request. 134 | 135 | 1. Fork the repository and clone it to your local machine. 136 | `git clone https://github.com/Avanatiker/WorldTools` 137 | 2. Create a new branch for your feature. 138 | `git checkout -b my-new-feature` 139 | 3. Make your changes and commit them to your branch. 140 | `git commit -am 'Add some feature'` 141 | 4. Push your changes to your fork. 142 | `git push origin my-new-feature` 143 | 5. Open a Pull Request in this repository. 144 | 6. Your Pull Request will be reviewed and merged as soon as possible. 145 | 7. Wait for the next release to see your changes in action! 146 | 147 | ## Building 148 | 149 | 1. Once forked and cloned, run `./gradlew build` to build the mod for both mod loaders. 150 | 2. IntelliJ IDEA will generate run configurations for both mod loaders that can be used to run the mod in a test 151 | environment. 152 | 3. The Fabric mod JAR file can be found in `fabric/build/libs` and the Forge mod JAR file in `forge/build/libs`. 153 | 154 | ## ToDo 155 | 156 | ### Fixes 157 | - Fix statistics not updated on stop because the packet answer is not received before the world is saved 158 | - Fix on capture switch config button functionality in capture gui 159 | - Dimension, XP, selected item slot, player game type, is not saved to player nbt in level.dat 160 | - EntityLoadable 161 | - Better rendering 162 | 163 | ### Features 164 | - Capture Mode: Choose between two capture modes: Full and Incremental. The Full mode captures all data from the server, while the Incremental mode only captures data that has changed since the last capture. 165 | - Save server datapack to the downloaded world 166 | - Save more entity data (NBT) like trades etc. 167 | - Live statistics: Data usage, time elapsed, etc. 168 | 169 | ## License 170 | 171 | WorldTools is distributed under 172 | the [GNU General Public License v3.0](https://github.com/Avanatiker/WorldTools/blob/master/LICENSE.md). 173 | 174 | --- 175 | 176 | If you have any questions, concerns, or suggestions, 177 | you can visit our [official Discord server](https://discord.gg/3y3ah5BtjB). 178 | 179 | **Disclaimer:** WorldTools is not affiliated with Mojang Studios. Minecraft is a registered trademark of Mojang Studios. 180 | Use of the WorldTools software is subject to the terms outlined in the license agreement. 181 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar 2 | import net.fabricmc.loom.task.RemapJarTask 3 | 4 | plugins { 5 | kotlin("jvm") version ("2.1.0") 6 | id("architectury-plugin") version "3.4-SNAPSHOT" 7 | id("dev.architectury.loom") version "1.9-SNAPSHOT" apply false 8 | id("com.github.johnrengelman.shadow") version "8.1.1" apply false 9 | } 10 | 11 | architectury { 12 | minecraft = project.properties["minecraft_version"]!! as String 13 | } 14 | 15 | subprojects { 16 | apply(plugin = "dev.architectury.loom") 17 | dependencies { 18 | "minecraft"("com.mojang:minecraft:${project.properties["minecraft_version"]!!}") 19 | "mappings"("net.fabricmc:yarn:${project.properties["yarn_mappings"]}:v2") 20 | } 21 | if (path != ":common") { 22 | apply(plugin = "com.github.johnrengelman.shadow") 23 | 24 | val shadowCommon by configurations.creating { 25 | isCanBeConsumed = false 26 | isCanBeResolved = true 27 | } 28 | val versionWithMCVersion = "${project.properties["mod_version"]!!}+${project.properties["minecraft_version"]!!}" 29 | 30 | tasks.withType { 31 | options.encoding = "UTF-8" 32 | options.release = 21 33 | } 34 | 35 | tasks { 36 | val shadowJarTask = named("shadowJar", ShadowJar::class) 37 | shadowJarTask { 38 | archiveVersion = versionWithMCVersion 39 | archiveClassifier.set("shadow") 40 | configurations = listOf(shadowCommon) 41 | } 42 | 43 | "remapJar"(RemapJarTask::class) { 44 | dependsOn(shadowJarTask) 45 | inputFile = shadowJarTask.flatMap { it.archiveFile } 46 | archiveVersion = versionWithMCVersion 47 | archiveClassifier = "" 48 | } 49 | jar { 50 | enabled = false 51 | } 52 | } 53 | } 54 | } 55 | 56 | allprojects { 57 | apply(plugin = "java") 58 | apply(plugin = "architectury-plugin") 59 | apply(plugin = "maven-publish") 60 | apply(plugin = "org.jetbrains.kotlin.jvm") 61 | base.archivesName.set(project.properties["archives_base_name"]!! as String) 62 | group = project.properties["maven_group"]!! 63 | version = project.properties["mod_version"]!! 64 | 65 | repositories { 66 | maven("https://api.modrinth.com/maven") 67 | maven("https://jitpack.io") 68 | maven("https://server.bbkr.space/artifactory/libs-release") { 69 | name = "CottonMC" 70 | } 71 | maven("https://maven.shedaniel.me/") 72 | maven("https://maven.terraformersmc.com/releases/") 73 | } 74 | 75 | tasks { 76 | compileKotlin { 77 | kotlinOptions.jvmTarget = "21" 78 | } 79 | } 80 | 81 | tasks.withType(JavaCompile::class.java) { 82 | options.encoding = "UTF-8" 83 | options.release = 21 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | architectury { common("fabric", "forge") } 2 | 3 | loom { 4 | accessWidenerPath.set(File("src/main/resources/worldtools.accesswidener")) 5 | } 6 | 7 | repositories { 8 | maven("https://maven.fabricmc.net/") { 9 | name = "Fabric" 10 | } 11 | maven("https://jitpack.io") 12 | mavenCentral() 13 | mavenLocal() 14 | } 15 | 16 | dependencies { 17 | // We depend on fabric loader here to use the fabric @Environment annotations and get the mixin dependencies 18 | // Do NOT use other classes from fabric loader 19 | modImplementation("net.fabricmc:fabric-loader:${project.properties["fabric_loader_version"]!!}") 20 | // Add dependencies on the required Kotlin modules. 21 | modImplementation("net.fabricmc:fabric-language-kotlin:${project.properties["fabric_kotlin_version"]!!}") 22 | implementation(annotationProcessor("io.github.llamalad7:mixinextras-common:${project.properties["mixinextras_version"]}")!!) 23 | modCompileOnly("me.shedaniel.cloth:cloth-config-fabric:${project.properties["cloth_config_version"]}") { 24 | exclude(group = "net.fabricmc.fabric-api", module = "fabric-api") 25 | } 26 | } 27 | 28 | tasks.named("remapJar") { 29 | enabled = false 30 | } 31 | 32 | -------------------------------------------------------------------------------- /common/src/main/java/org/waste/of/time/LoaderInfo.java: -------------------------------------------------------------------------------- 1 | package org.waste.of.time; 2 | 3 | import dev.architectury.injectables.annotations.ExpectPlatform; 4 | import org.jetbrains.annotations.Contract; 5 | import org.jetbrains.annotations.NotNull; 6 | 7 | public class LoaderInfo { 8 | @Contract(pure = true) 9 | @ExpectPlatform 10 | public static @NotNull String getVersion() { 11 | return "DEV"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /common/src/main/java/org/waste/of/time/extension/IPalettedContainerExtension.java: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.extension; 2 | 3 | public interface IPalettedContainerExtension { 4 | void setWTIgnoreLock(boolean ignoreLock); 5 | } 6 | -------------------------------------------------------------------------------- /common/src/main/java/org/waste/of/time/mixin/BossBarHudMixin.java: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.mixin; 2 | 3 | import net.minecraft.client.gui.hud.BossBarHud; 4 | import net.minecraft.client.gui.hud.ClientBossBar; 5 | import org.spongepowered.asm.mixin.Mixin; 6 | import org.spongepowered.asm.mixin.injection.At; 7 | import org.spongepowered.asm.mixin.injection.Redirect; 8 | import org.waste.of.time.manager.BarManager; 9 | import org.waste.of.time.manager.CaptureManager; 10 | 11 | import java.util.*; 12 | 13 | @Mixin(BossBarHud.class) 14 | public class BossBarHudMixin { 15 | 16 | /** 17 | * This mixin is used to add the capture bar and the progress bar to the boss bar overlay and to make sure that 18 | * it is always rendered on top of the other boss bars. 19 | * 20 | * @param bossBars The boss bars that are currently being rendered 21 | * @return A collection of boss bars that includes the capture bar and the progress bar 22 | */ 23 | 24 | // todo: remove redirects to avoid mod conflicts 25 | // either replace them with injects or use MixinExtras 26 | @Redirect(method = "render", at = @At(value = "INVOKE", target = "Ljava/util/Map;values()Ljava/util/Collection;")) 27 | public Collection modifyValues(Map bossBars) { 28 | if (!CaptureManager.INSTANCE.getCapturing()) return bossBars.values(); 29 | List newBossBars = new ArrayList<>(bossBars.size() + 2); 30 | BarManager.INSTANCE.getCaptureBar().ifPresent(newBossBars::add); 31 | BarManager.INSTANCE.progressBar().ifPresent(newBossBars::add); 32 | newBossBars.addAll(bossBars.values()); 33 | return newBossBars; 34 | } 35 | 36 | @Redirect(method = "render", at = @At(value = "INVOKE", target = "Ljava/util/Map;isEmpty()Z")) 37 | public boolean modifyIsEmpty(Map bossBars) { 38 | if (!CaptureManager.INSTANCE.getCapturing()) return bossBars.isEmpty(); 39 | return bossBars.isEmpty() 40 | && BarManager.INSTANCE.getCaptureBar().isEmpty() 41 | && BarManager.INSTANCE.progressBar().isEmpty(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /common/src/main/java/org/waste/of/time/mixin/ClientPlayInteractionManagerMixin.java: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.mixin; 2 | 3 | import net.minecraft.client.MinecraftClient; 4 | import net.minecraft.client.network.ClientPlayerEntity; 5 | import net.minecraft.client.network.ClientPlayerInteractionManager; 6 | import net.minecraft.entity.Entity; 7 | import net.minecraft.entity.player.PlayerEntity; 8 | import net.minecraft.util.ActionResult; 9 | import net.minecraft.util.Hand; 10 | import net.minecraft.util.hit.BlockHitResult; 11 | import org.spongepowered.asm.mixin.Final; 12 | import org.spongepowered.asm.mixin.Mixin; 13 | import org.spongepowered.asm.mixin.Shadow; 14 | import org.spongepowered.asm.mixin.injection.At; 15 | import org.spongepowered.asm.mixin.injection.Inject; 16 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 17 | import org.waste.of.time.Events; 18 | 19 | @Mixin(ClientPlayerInteractionManager.class) 20 | public class ClientPlayInteractionManagerMixin { 21 | 22 | @Final 23 | @Shadow 24 | private MinecraftClient client; 25 | 26 | @Inject(method = "interactBlock", at = @At("HEAD")) 27 | public void interactBlockHead(final ClientPlayerEntity player, final Hand hand, final BlockHitResult hitResult, final CallbackInfoReturnable cir) { 28 | if (client.world == null) return; 29 | Events.INSTANCE.onInteractBlock(client.world, hitResult); 30 | } 31 | 32 | @Inject(method = "interactEntity", at = @At("HEAD")) 33 | public void interactEntityHead(PlayerEntity player, Entity entity, Hand hand, CallbackInfoReturnable cir) { 34 | if (client.world == null) return; 35 | Events.INSTANCE.onInteractEntity(entity); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /common/src/main/java/org/waste/of/time/mixin/ClientPlayNetworkHandlerMixin.java: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.mixin; 2 | 3 | import net.minecraft.client.network.ClientPlayNetworkHandler; 4 | import net.minecraft.network.packet.s2c.play.StatisticsS2CPacket; 5 | import org.spongepowered.asm.mixin.Mixin; 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 | import org.waste.of.time.manager.CaptureManager; 10 | import org.waste.of.time.storage.serializable.StatisticStoreable; 11 | 12 | @Mixin(ClientPlayNetworkHandler.class) 13 | public class ClientPlayNetworkHandlerMixin { 14 | @Inject(method = "onStatistics", at = @At("RETURN")) 15 | private void onStatistics(StatisticsS2CPacket packet, CallbackInfo ci) { 16 | if (!CaptureManager.INSTANCE.getCapturing()) return; 17 | new StatisticStoreable().emit(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /common/src/main/java/org/waste/of/time/mixin/ClientWorldMixin.java: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.mixin; 2 | 3 | import com.llamalad7.mixinextras.sugar.Local; 4 | import net.minecraft.client.world.ClientWorld; 5 | import net.minecraft.component.type.MapIdComponent; 6 | import net.minecraft.entity.Entity; 7 | import net.minecraft.item.map.MapState; 8 | import org.spongepowered.asm.mixin.Mixin; 9 | import org.spongepowered.asm.mixin.injection.At; 10 | import org.spongepowered.asm.mixin.injection.Inject; 11 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 12 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 13 | import org.waste.of.time.Events; 14 | 15 | @Mixin(ClientWorld.class) 16 | public class ClientWorldMixin { 17 | 18 | @Inject(method = "removeEntity", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/Entity;onRemoved()V", shift = At.Shift.AFTER)) 19 | public void onEntityRemovedInject(final int entityId, final Entity.RemovalReason removalReason, final CallbackInfo ci, 20 | @Local Entity entity) { 21 | Events.INSTANCE.onEntityRemoved(entity, removalReason); 22 | } 23 | 24 | @Inject(method = "getMapState", at = @At("HEAD")) 25 | public void getMapStateInject(MapIdComponent id, CallbackInfoReturnable cir) { 26 | Events.INSTANCE.onMapStateGet(id); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /common/src/main/java/org/waste/of/time/mixin/DebugRendererMixin.java: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.mixin; 2 | 3 | import net.minecraft.client.render.Frustum; 4 | import net.minecraft.client.render.VertexConsumerProvider; 5 | import net.minecraft.client.render.debug.DebugRenderer; 6 | import net.minecraft.client.util.math.MatrixStack; 7 | import org.spongepowered.asm.mixin.Mixin; 8 | import org.spongepowered.asm.mixin.injection.At; 9 | import org.spongepowered.asm.mixin.injection.Inject; 10 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 11 | import org.waste.of.time.Events; 12 | 13 | @Mixin(DebugRenderer.class) 14 | public class DebugRendererMixin { 15 | @Inject(method = "render", at = @At("HEAD")) 16 | public void renderInject( 17 | MatrixStack matrices, 18 | Frustum frustum, 19 | VertexConsumerProvider.Immediate vertexConsumers, 20 | double cameraX, 21 | double cameraY, 22 | double cameraZ, 23 | CallbackInfo ci 24 | ) { 25 | Events.INSTANCE.onDebugRenderStart(matrices, vertexConsumers, cameraX, cameraY, cameraZ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /common/src/main/java/org/waste/of/time/mixin/GameMenuScreenMixin.java: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.mixin; 2 | 3 | import com.llamalad7.mixinextras.sugar.Local; 4 | import net.minecraft.client.gui.screen.GameMenuScreen; 5 | import net.minecraft.client.gui.widget.GridWidget; 6 | import org.spongepowered.asm.mixin.Mixin; 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 | import org.waste.of.time.Events; 11 | 12 | @Mixin(GameMenuScreen.class) 13 | public class GameMenuScreenMixin { 14 | 15 | @Inject(method = "initWidgets", at = @At( 16 | value = "INVOKE", 17 | target = "Lnet/minecraft/client/MinecraftClient;isInSingleplayer()Z" 18 | )) 19 | public void onInitWidgets(final CallbackInfo ci, 20 | @Local GridWidget.Adder adder) { 21 | Events.INSTANCE.onGameMenuScreenInitWidgets(adder); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /common/src/main/java/org/waste/of/time/mixin/IntegratedServerLoaderMixin.java: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.mixin; 2 | 3 | import com.llamalad7.mixinextras.injector.wrapoperation.Operation; 4 | import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; 5 | import net.minecraft.server.integrated.IntegratedServerLoader; 6 | import net.minecraft.world.level.storage.LevelStorage; 7 | import org.spongepowered.asm.mixin.Mixin; 8 | import org.spongepowered.asm.mixin.injection.At; 9 | import org.waste.of.time.WorldTools; 10 | 11 | @Mixin(IntegratedServerLoader.class) 12 | public class IntegratedServerLoaderMixin { 13 | 14 | @WrapOperation(method = "checkBackupAndStart", 15 | at = @At( 16 | value = "INVOKE", 17 | target = "Lnet/minecraft/server/integrated/IntegratedServerLoader;showBackupPromptScreen(Lnet/minecraft/world/level/storage/LevelStorage$Session;ZLjava/lang/Runnable;Ljava/lang/Runnable;)V" 18 | )) 19 | public void disableExperimentalWorldSettingsScreen(IntegratedServerLoader instance, LevelStorage.Session session, boolean customized, Runnable callback, Runnable onCancel, Operation original) { 20 | if (WorldTools.INSTANCE.getConfig().getAdvanced().getHideExperimentalWorldGui()) { 21 | callback.run(); 22 | } else { 23 | original.call(instance, session, customized, callback, onCancel); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /common/src/main/java/org/waste/of/time/mixin/PalettedContainerMixin.java: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.mixin; 2 | 3 | import net.minecraft.world.chunk.PalettedContainer; 4 | import org.spongepowered.asm.mixin.Mixin; 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 | import org.waste.of.time.extension.IPalettedContainerExtension; 10 | 11 | @Mixin(PalettedContainer.class) 12 | public class PalettedContainerMixin implements IPalettedContainerExtension { 13 | @Unique 14 | private boolean ignoreLock = false; 15 | 16 | @Inject(method = "lock", at = @At("HEAD"), cancellable = true) 17 | public void disableChunkContainerLock(CallbackInfo ci) { 18 | if (ignoreLock) ci.cancel(); 19 | } 20 | 21 | @Inject(method = "unlock", at = @At("HEAD"), cancellable = true) 22 | public void disableChunkContainerUnLock(CallbackInfo ci) { 23 | if (ignoreLock) ci.cancel(); 24 | } 25 | 26 | @Override 27 | public void setWTIgnoreLock(boolean b) { 28 | ignoreLock = b; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/Events.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time 2 | 3 | import net.minecraft.client.MinecraftClient 4 | import net.minecraft.client.gui.screen.Screen 5 | import net.minecraft.client.gui.widget.ButtonWidget 6 | import net.minecraft.client.gui.widget.GridWidget 7 | import net.minecraft.client.render.RenderLayer 8 | import net.minecraft.client.render.VertexConsumer 9 | import net.minecraft.client.render.VertexConsumerProvider 10 | import net.minecraft.client.util.math.MatrixStack 11 | import net.minecraft.component.type.MapIdComponent 12 | import net.minecraft.entity.Entity 13 | import net.minecraft.entity.LivingEntity 14 | import net.minecraft.entity.player.PlayerEntity 15 | import net.minecraft.util.hit.BlockHitResult 16 | import net.minecraft.util.math.BlockPos 17 | import net.minecraft.util.math.Vec3d 18 | import net.minecraft.world.World 19 | import net.minecraft.world.chunk.WorldChunk 20 | import org.waste.of.time.Utils.manhattanDistance2d 21 | import org.waste.of.time.WorldTools.CAPTURE_KEY 22 | import org.waste.of.time.WorldTools.CONFIG_KEY 23 | import org.waste.of.time.WorldTools.config 24 | import org.waste.of.time.WorldTools.mc 25 | import org.waste.of.time.gui.ManagerScreen 26 | import org.waste.of.time.manager.BarManager.updateCapture 27 | import org.waste.of.time.manager.CaptureManager 28 | import org.waste.of.time.manager.CaptureManager.capturing 29 | import org.waste.of.time.manager.CaptureManager.currentLevelName 30 | import org.waste.of.time.manager.MessageManager 31 | import org.waste.of.time.manager.MessageManager.translateHighlight 32 | import org.waste.of.time.manager.StatisticManager 33 | import org.waste.of.time.storage.StorageFlow 34 | import org.waste.of.time.storage.cache.EntityCacheable 35 | import org.waste.of.time.storage.cache.HotCache 36 | import org.waste.of.time.storage.cache.DataInjectionHandler 37 | import org.waste.of.time.storage.serializable.BlockEntityLoadable 38 | import org.waste.of.time.storage.serializable.PlayerStoreable 39 | import org.waste.of.time.storage.serializable.RegionBasedChunk 40 | import java.awt.Color 41 | 42 | object Events { 43 | fun onChunkLoad(chunk: WorldChunk) { 44 | if (!capturing) return 45 | RegionBasedChunk(chunk).cache() 46 | BlockEntityLoadable(chunk).emit() 47 | } 48 | 49 | fun onChunkUnload(chunk: WorldChunk) { 50 | if (!capturing) return 51 | (HotCache.chunks[chunk.pos] ?: RegionBasedChunk(chunk)).apply { 52 | emit() 53 | flush() 54 | } 55 | } 56 | 57 | fun onEntityLoad(entity: Entity) { 58 | if (!capturing) return 59 | if (entity is PlayerEntity) { 60 | PlayerStoreable(entity).cache() 61 | } else { 62 | EntityCacheable(entity).cache() 63 | } 64 | } 65 | 66 | fun onEntityUnload(entity: Entity) { 67 | if (!capturing) return 68 | if (entity !is PlayerEntity) return 69 | PlayerStoreable(entity).apply { 70 | emit() 71 | flush() 72 | } 73 | } 74 | 75 | fun onClientTickStart() { 76 | if (CAPTURE_KEY.wasPressed() && mc.world != null && mc.currentScreen == null) { 77 | CaptureManager.toggleCapture() 78 | } 79 | 80 | if (CONFIG_KEY.wasPressed() && mc.world != null && mc.currentScreen == null) { 81 | mc.setScreen(ManagerScreen) 82 | } 83 | 84 | if (!capturing) return 85 | updateCapture() 86 | } 87 | 88 | fun onClientJoin() { 89 | HotCache.clear() 90 | StorageFlow.lastStored = null 91 | StatisticManager.reset() 92 | if (config.general.autoDownload) CaptureManager.start() 93 | } 94 | 95 | fun onClientDisconnect() { 96 | if (!capturing) return 97 | CaptureManager.stop() 98 | } 99 | 100 | fun onInteractBlock(world: World, hitResult: BlockHitResult) { 101 | if (!capturing) return 102 | val blockEntity = world.getBlockEntity(hitResult.blockPos) 103 | HotCache.lastInteractedBlockEntity = blockEntity 104 | HotCache.lastInteractedEntity = null 105 | } 106 | 107 | fun onInteractEntity(entity: Entity) { 108 | if (!capturing) return 109 | HotCache.lastInteractedEntity = entity 110 | HotCache.lastInteractedBlockEntity = null 111 | } 112 | 113 | fun onDebugRenderStart( 114 | matrices: MatrixStack, 115 | vertexConsumers: VertexConsumerProvider.Immediate, 116 | cameraX: Double, 117 | cameraY: Double, 118 | cameraZ: Double 119 | ) { 120 | if (!capturing || !config.render.renderNotYetCachedContainers) return 121 | 122 | val vertexConsumer = vertexConsumers.getBuffer(RenderLayer.getLines()) ?: return 123 | 124 | HotCache.unscannedBlockEntities 125 | .forEach { render(it.pos.vec, cameraX, cameraY, cameraZ, matrices, vertexConsumer, Color(config.render.unscannedContainerColor)) } 126 | 127 | HotCache.loadedBlockEntities 128 | .forEach { render(it.value.pos.vec, cameraX, cameraY, cameraZ, matrices, vertexConsumer, Color(config.render.fromCacheLoadedContainerColor)) } 129 | 130 | HotCache.unscannedEntities 131 | .forEach { render(it.entity.pos.add(-.5, .0, -.5), cameraX, cameraY, cameraZ, matrices, vertexConsumer, Color(config.render.unscannedEntityColor)) } 132 | } 133 | 134 | private val BlockPos.vec get() = Vec3d(x.toDouble(), y.toDouble(), z.toDouble()) 135 | 136 | private fun render( 137 | vec: Vec3d, 138 | cameraX: Double, 139 | cameraY: Double, 140 | cameraZ: Double, 141 | matrices: MatrixStack, 142 | vertexConsumer: VertexConsumer, 143 | color: Color 144 | ) { 145 | val x1 = (vec.x - cameraX).toFloat() 146 | val y1 = (vec.y - cameraY).toFloat() 147 | val z1 = (vec.z - cameraZ).toFloat() 148 | val x2 = x1 + 1 149 | val z2 = z1 + 1 150 | val r = color.red / 255.0f 151 | val g = color.green / 255.0f 152 | val b = color.blue / 255.0f 153 | val a = 1.0f 154 | val positionMat = matrices.peek().positionMatrix 155 | val normMat = matrices.peek() 156 | vertexConsumer.vertex(positionMat, x1, y1, z1).color(r, g, b, a).normal(normMat, 1.0f, 0.0f, 0.0f) 157 | vertexConsumer.vertex(positionMat, x2, y1, z1).color(r, g, b, a).normal(normMat, 1.0f, 0.0f, 0.0f) 158 | vertexConsumer.vertex(positionMat, x1, y1, z1).color(r, g, b, a).normal(normMat, 0.0f, 0.0f, 1.0f) 159 | vertexConsumer.vertex(positionMat, x1, y1, z2).color(r, g, b, a).normal(normMat, 0.0f, 0.0f, 1.0f) 160 | vertexConsumer.vertex(positionMat, x1, y1, z2).color(r, g, b, a).normal(normMat, 1.0f, 0.0f, 0.0f) 161 | vertexConsumer.vertex(positionMat, x2, y1, z2).color(r, g, b, a).normal(normMat, 1.0f, 0.0f, 0.0f) 162 | vertexConsumer.vertex(positionMat, x2, y1, z2).color(r, g, b, a).normal(normMat, 0.0f, 0.0f, -1.0f) 163 | vertexConsumer.vertex(positionMat, x2, y1, z1).color(r, g, b, a).normal(normMat, 0.0f, 0.0f, -1.0f) 164 | } 165 | 166 | fun onGameMenuScreenInitWidgets(adder: GridWidget.Adder) { 167 | val widget = if (capturing) { 168 | val label = translateHighlight("worldtools.gui.escape.button.finish_download", currentLevelName) 169 | ButtonWidget.builder(label) { 170 | CaptureManager.stop() 171 | mc.setScreen(null) 172 | }.width(204).build() 173 | } else { 174 | ButtonWidget.builder(MessageManager.brand) { 175 | MinecraftClient.getInstance().setScreen(ManagerScreen) 176 | }.width(204).build() 177 | } 178 | 179 | adder.add(widget, 2) 180 | } 181 | 182 | fun onScreenRemoved(screen: Screen) { 183 | if (!capturing) return 184 | DataInjectionHandler.onScreenRemoved(screen) 185 | HotCache.lastInteractedBlockEntity = null 186 | } 187 | 188 | fun onEntityRemoved(entity: Entity, reason: Entity.RemovalReason) { 189 | if (!capturing) return 190 | if (reason != Entity.RemovalReason.KILLED && reason != Entity.RemovalReason.DISCARDED) return 191 | 192 | if (entity is LivingEntity) { 193 | if (!entity.isDead) return 194 | 195 | val cacheable = EntityCacheable(entity) 196 | HotCache.entities.entries.find { (_, entities) -> 197 | entities.contains(cacheable) 198 | }?.value?.remove(cacheable) 199 | } else { 200 | // todo: its actually a bit tricky to differentiate the entity being removed from our world or the server world 201 | // need to find a reliable way to determine it 202 | // if chunk is loaded, remove the entity? -> doesn't seem to work because server will remove entity before chunk is unloaded 203 | mc.player?.let { player -> 204 | if (entity.pos.manhattanDistance2d(player.pos) < 32) { // todo: configurable distance, this should be small enough to be safe for most cases 205 | val cacheable = EntityCacheable(entity) 206 | HotCache.entities[entity.chunkPos]?.remove(cacheable) 207 | } 208 | } 209 | } 210 | } 211 | 212 | fun onMapStateGet(id: MapIdComponent) { 213 | if (!capturing) return 214 | // todo: looks like the server does not send a map update packet for container 215 | HotCache.mapIDs.add(id.id) 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/Utils.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time 2 | 3 | import net.minecraft.block.entity.BlockEntity 4 | import net.minecraft.registry.Registries 5 | import net.minecraft.util.math.Vec3d 6 | import java.time.LocalDateTime 7 | import java.time.ZoneId 8 | import java.time.ZonedDateTime 9 | import java.time.format.DateTimeFormatter 10 | import kotlin.math.abs 11 | import kotlin.math.ln 12 | import kotlin.math.pow 13 | 14 | object Utils { 15 | // Why cant I use the std lib? 16 | fun Boolean.toByte(): Byte = if (this) 1 else 0 17 | fun Vec3d.asString() = "(%.2f, %.2f, %.2f)".format(x, y, z) 18 | 19 | fun getTime(): String { 20 | val localDateTime = LocalDateTime.now() 21 | val zoneId = ZoneId.systemDefault() 22 | 23 | val zonedDateTime = ZonedDateTime.of(localDateTime, zoneId) 24 | val formatter = DateTimeFormatter.RFC_1123_DATE_TIME 25 | 26 | return zonedDateTime.format(formatter) 27 | } 28 | 29 | fun Vec3d.manhattanDistance2d(other: Vec3d) = 30 | abs(this.x - other.x) + abs(this.z - other.z) 31 | 32 | fun Long.toReadableByteCount(si: Boolean = true): String { 33 | val unit = if (si) 1000 else 1024 34 | if (this < unit) return "$this B" 35 | val exp = (ln(toDouble()) / ln(unit.toDouble())).toInt() 36 | val pre = (if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i" 37 | return String.format("%.1f %sB", this / unit.toDouble().pow(exp.toDouble()), pre) 38 | } 39 | 40 | val BlockEntity.typeName: String 41 | get() = Registries.BLOCK_ENTITY_TYPE.getId(type)?.path ?: "unknown" 42 | } 43 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/WorldTools.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time 2 | 3 | import com.google.gson.Gson 4 | import com.google.gson.GsonBuilder 5 | import me.shedaniel.autoconfig.AutoConfig 6 | import me.shedaniel.autoconfig.serializer.GsonConfigSerializer 7 | import net.minecraft.SharedConstants 8 | import net.minecraft.client.MinecraftClient 9 | import net.minecraft.client.option.KeyBinding 10 | import net.minecraft.client.util.InputUtil 11 | import org.apache.logging.log4j.LogManager 12 | import org.apache.logging.log4j.Logger 13 | import org.lwjgl.glfw.GLFW 14 | import org.waste.of.time.config.WorldToolsConfig 15 | 16 | object WorldTools { 17 | const val MOD_NAME = "WorldTools" 18 | const val MOD_ID = "worldtools" 19 | private const val URL = "https://github.com/Avanatiker/WorldTools/" 20 | const val MCA_EXTENSION = ".mca" 21 | const val DAT_EXTENSION = ".dat" 22 | const val MAX_LEVEL_NAME_LENGTH = 64 23 | const val TIMESTAMP_KEY = "CaptureTimestamp" 24 | val GSON: Gson = GsonBuilder().setPrettyPrinting().create() 25 | val CURRENT_VERSION = SharedConstants.getGameVersion().saveVersion.id 26 | private val VERSION: String = LoaderInfo.getVersion() 27 | val CREDIT_MESSAGE = "This file was created by $MOD_NAME $VERSION ($URL)" 28 | val CREDIT_MESSAGE_MD = "This file was created by [$MOD_NAME $VERSION]($URL)" 29 | val LOG: Logger = LogManager.getLogger() 30 | var CAPTURE_KEY = KeyBinding( 31 | "$MOD_ID.key.toggle_capture", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_F12, 32 | "$MOD_ID.key.categories" 33 | ) 34 | var CONFIG_KEY = KeyBinding( 35 | "$MOD_ID.key.open_config", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_F10, 36 | "$MOD_ID.key.categories" 37 | ) 38 | 39 | val mc: MinecraftClient = MinecraftClient.getInstance() 40 | lateinit var config: WorldToolsConfig; private set 41 | 42 | fun initialize() { 43 | LOG.info("Initializing $MOD_NAME $VERSION") 44 | AutoConfig.register(WorldToolsConfig::class.java, ::GsonConfigSerializer) 45 | config = AutoConfig.getConfigHolder(WorldToolsConfig::class.java).config 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/config/WorldToolsConfig.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.config 2 | 3 | import me.shedaniel.autoconfig.ConfigData 4 | import me.shedaniel.autoconfig.annotation.Config 5 | import me.shedaniel.autoconfig.annotation.ConfigEntry 6 | import me.shedaniel.autoconfig.annotation.ConfigEntry.Gui.TransitiveObject 7 | import me.shedaniel.autoconfig.annotation.ConfigEntry.Gui.CollapsibleObject 8 | import me.shedaniel.autoconfig.annotation.ConfigEntry.Gui.Tooltip 9 | import me.shedaniel.autoconfig.annotation.ConfigEntry.Category 10 | import me.shedaniel.autoconfig.annotation.ConfigEntry.Gui.Excluded 11 | import net.minecraft.entity.boss.BossBar 12 | 13 | /** 14 | * See [Cloth Config Documentation](https://shedaniel.gitbook.io/cloth-config/auto-config/creating-a-config-class) 15 | * for more information on how to create a config class 16 | */ 17 | @Config(name = "worldtools") 18 | class WorldToolsConfig : ConfigData { 19 | @TransitiveObject 20 | @Category("General") 21 | val general = General() 22 | 23 | @TransitiveObject 24 | @Category("World") 25 | val world = World() 26 | 27 | @TransitiveObject 28 | @Category("Entity") 29 | val entity = Entity() 30 | 31 | @TransitiveObject 32 | @Category("Render") 33 | val render = Render() 34 | 35 | @TransitiveObject 36 | @Category("Advanced") 37 | val advanced = Advanced() 38 | 39 | @TransitiveObject 40 | @Category("Debug") 41 | @ConfigEntry.Gui.PrefixText 42 | val debug = Debug() 43 | 44 | class General { 45 | @Tooltip 46 | var autoDownload = false 47 | @Tooltip 48 | var compressLevel = true 49 | @Excluded 50 | var reloadBlockEntities = true 51 | 52 | @CollapsibleObject(startExpanded = true) 53 | @Tooltip 54 | var capture = Capture() 55 | 56 | class Capture { 57 | var chunks = true 58 | var entities = true 59 | var players = true 60 | var statistics = true 61 | var levelData = true 62 | var advancements = true 63 | var metadata = true 64 | var maps = true 65 | } 66 | } 67 | 68 | class World { 69 | @CollapsibleObject(startExpanded = true) 70 | val worldGenerator = WorldGenerator() 71 | 72 | @CollapsibleObject(startExpanded = true) 73 | val gameRules = GameRules() 74 | 75 | @CollapsibleObject(startExpanded = true) 76 | val metadata = Metadata() 77 | 78 | @CollapsibleObject(startExpanded = true) 79 | @Excluded 80 | val censor = Censor() 81 | 82 | class WorldGenerator { 83 | @Tooltip 84 | var type = GeneratorType.VOID 85 | val bonusChest = false 86 | val generateFeatures = false 87 | var seed = 0L 88 | 89 | enum class GeneratorType { 90 | VOID, 91 | DEFAULT, 92 | FLAT 93 | } 94 | } 95 | 96 | class GameRules { 97 | @Tooltip 98 | var modifyGameRules = true 99 | 100 | var doWardenSpawning = false 101 | var doFireTick = false 102 | var doVinesSpread = false 103 | var doMobSpawning = false 104 | var doDaylightCycle = false 105 | var doWeatherCycle = false 106 | var keepInventory = true 107 | var doMobGriefing = false 108 | var doTraderSpawning = false 109 | var doPatrolSpawning = false 110 | } 111 | 112 | class Metadata { 113 | var captureTimestamp = true 114 | } 115 | 116 | class Censor { 117 | // ToDo: Add censoring options 118 | } 119 | } 120 | 121 | class Entity { 122 | @CollapsibleObject(startExpanded = true) 123 | val behavior = Behavior() 124 | 125 | @CollapsibleObject(startExpanded = true) 126 | val metadata = Metadata() 127 | 128 | @CollapsibleObject(startExpanded = true) 129 | val censor = Censor() 130 | 131 | class Behavior { 132 | @Tooltip 133 | var modifyEntityBehavior = false 134 | 135 | var noAI = true 136 | var noGravity = true 137 | var invulnerable = true 138 | var silent = true 139 | } 140 | 141 | class Metadata { 142 | var captureTimestamp = true 143 | } 144 | 145 | class Censor { 146 | // ToDo: Add censoring options 147 | 148 | @Excluded 149 | var names = false 150 | @Excluded 151 | var owner = false 152 | @Tooltip 153 | var lastDeathLocation = true 154 | } 155 | } 156 | 157 | class Render { 158 | var renderNotYetCachedContainers = true 159 | @ConfigEntry.ColorPicker 160 | var unscannedContainerColor = 0xDE0000 161 | @ConfigEntry.ColorPicker 162 | var fromCacheLoadedContainerColor = 0xFFA500 163 | @ConfigEntry.ColorPicker 164 | var unscannedEntityColor = 0xDE0000 165 | @ConfigEntry.ColorPicker 166 | var fromCacheLoadedEntityColor = 0xFFA500 167 | @ConfigEntry.ColorPicker 168 | var accentColor = 0xA2FF4C 169 | var captureBarColor = BossBar.Color.PINK 170 | var captureBarStyle = BossBar.Style.NOTCHED_10 171 | var progressBarColor = BossBar.Color.GREEN 172 | var progressBarStyle = BossBar.Style.PROGRESS 173 | @ConfigEntry.BoundedDiscrete(min = 50, max = 60000) 174 | var progressBarTimeout = 3000L 175 | } 176 | 177 | class Advanced { 178 | @Tooltip 179 | var anonymousMode = false 180 | @Tooltip 181 | var hideExperimentalWorldGui = true // See IntegratedServerLoaderMixin 182 | var showToasts = true 183 | var showChatMessages = true 184 | var keepEnderChestContents = false 185 | } 186 | 187 | class Debug { 188 | var logSettings = false 189 | var logSavedChunks = false 190 | var logSavedEntities = false 191 | var logSavedContainers = false 192 | var logSavedMaps = false 193 | var logZippingProgress = false 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/gui/BrowseDownloadsScreen.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.gui 2 | 3 | import net.minecraft.client.MinecraftClient 4 | import net.minecraft.client.gui.DrawContext 5 | import net.minecraft.client.gui.screen.Screen 6 | import net.minecraft.client.gui.widget.AlwaysSelectedEntryListWidget 7 | import net.minecraft.text.Text 8 | 9 | // todo: we need a local database of downloads like original wdl mod 10 | object BrowseDownloadsScreen : Screen(Text.translatable("worldtools.gui.browser.title")) { 11 | override fun init() { 12 | 13 | } 14 | 15 | object DownloadListWidget : AlwaysSelectedEntryListWidget( 16 | MinecraftClient.getInstance(), 17 | width, 18 | height, 19 | 20, 20 | height - 30 21 | ) { 22 | 23 | 24 | } 25 | 26 | class WorldDownloadEntry : AlwaysSelectedEntryListWidget.Entry() { 27 | override fun render( 28 | context: DrawContext?, 29 | index: Int, 30 | y: Int, 31 | x: Int, 32 | entryWidth: Int, 33 | entryHeight: Int, 34 | mouseX: Int, 35 | mouseY: Int, 36 | hovered: Boolean, 37 | tickDelta: Float 38 | ) { 39 | TODO("Not yet implemented") 40 | } 41 | 42 | override fun getNarration(): Text { 43 | TODO("Not yet implemented") 44 | } 45 | 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/gui/EnterTextField.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.gui 2 | 3 | import net.minecraft.client.MinecraftClient 4 | import net.minecraft.client.font.TextRenderer 5 | import net.minecraft.client.gui.widget.TextFieldWidget 6 | import net.minecraft.text.Text 7 | import org.lwjgl.glfw.GLFW 8 | import org.waste.of.time.manager.CaptureManager 9 | 10 | class EnterTextField( 11 | textRenderer: TextRenderer, x: Int, y: Int, width: Int, height: Int, message: Text, val client: MinecraftClient? 12 | ) : TextFieldWidget(textRenderer, x, y, width, height, message) { 13 | 14 | override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { 15 | if (keyCode == GLFW.GLFW_KEY_ENTER) { 16 | if (CaptureManager.capturing) { 17 | client?.setScreen(null) 18 | CaptureManager.stop() 19 | } else { 20 | client?.setScreen(null) 21 | CaptureManager.start(text) 22 | } 23 | return true 24 | } 25 | return super.keyPressed(keyCode, scanCode, modifiers) 26 | } 27 | } -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/gui/ManagerScreen.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.gui 2 | 3 | import me.shedaniel.autoconfig.AutoConfig 4 | import net.minecraft.client.gui.screen.Screen 5 | import net.minecraft.client.gui.widget.* 6 | import net.minecraft.text.Text 7 | import org.waste.of.time.WorldTools.MAX_LEVEL_NAME_LENGTH 8 | import org.waste.of.time.config.WorldToolsConfig 9 | import org.waste.of.time.manager.CaptureManager 10 | import org.waste.of.time.manager.CaptureManager.currentLevelName 11 | import org.waste.of.time.manager.CaptureManager.levelName 12 | 13 | object ManagerScreen : Screen(Text.translatable("worldtools.gui.manager.title")) { 14 | private lateinit var worldNameTextEntryWidget: TextFieldWidget 15 | private lateinit var titleWidget: TextWidget 16 | private lateinit var downloadButton: ButtonWidget 17 | private lateinit var configButton: ButtonWidget 18 | private lateinit var cancelButton: ButtonWidget 19 | private const val BUTTON_WIDTH = 90 20 | 21 | override fun init() { 22 | setupTitle() 23 | setupEntryGrid() 24 | setupBottomGrid() 25 | } 26 | 27 | override fun tick() { 28 | if (CaptureManager.capturing) { 29 | downloadButton.message = Text.translatable("worldtools.gui.manager.button.stop_download") 30 | worldNameTextEntryWidget.setPlaceholder(Text.of(currentLevelName)) 31 | worldNameTextEntryWidget.setEditable(false) 32 | } else { 33 | downloadButton.message = Text.translatable("worldtools.gui.manager.button.start_download") 34 | worldNameTextEntryWidget.setEditable(true) 35 | } 36 | super.tick() 37 | } 38 | 39 | private fun setupTitle() { 40 | titleWidget = TextWidget(Text.translatable("worldtools.gui.manager.title"), textRenderer) 41 | SimplePositioningWidget.setPos(titleWidget, 0, 0, width, height, 0.5f, 0.01f) 42 | addDrawableChild(titleWidget) 43 | } 44 | 45 | private fun setupEntryGrid() { 46 | val entryGridWidget = createGridWidget() 47 | val adder = entryGridWidget.createAdder(3) 48 | 49 | worldNameTextEntryWidget = EnterTextField( 50 | textRenderer, 0, 0, 250, 20, Text.of(levelName), client 51 | ).apply { 52 | setPlaceholder(Text.translatable("worldtools.gui.manager.world_name_placeholder", levelName)) 53 | setMaxLength(MAX_LEVEL_NAME_LENGTH) 54 | } 55 | downloadButton = createButton("worldtools.gui.manager.button.start_download") { 56 | if (CaptureManager.capturing) { 57 | client?.setScreen(null) 58 | CaptureManager.stop() 59 | } else { 60 | client?.setScreen(null) 61 | CaptureManager.start(worldNameTextEntryWidget.text) 62 | } 63 | } 64 | 65 | adder.add(worldNameTextEntryWidget, 2) 66 | adder.add(downloadButton, 1) 67 | 68 | entryGridWidget.refreshPositions() 69 | SimplePositioningWidget.setPos(entryGridWidget, 0, titleWidget.y, width, height, 0.5f, 0.05f) 70 | entryGridWidget.forEachChild(this::addDrawableChild) 71 | } 72 | 73 | private fun setupBottomGrid() { 74 | val bottomGridWidget = createGridWidget() 75 | val bottomAdder = bottomGridWidget.createAdder(2) 76 | configButton = createButton("worldtools.gui.manager.button.config") { 77 | client?.setScreen(AutoConfig.getConfigScreen(WorldToolsConfig::class.java, this).get()) 78 | } 79 | cancelButton = createButton("worldtools.gui.manager.button.cancel") { 80 | client?.setScreen(null) 81 | } 82 | 83 | bottomAdder.add(configButton, 1) 84 | bottomAdder.add(cancelButton, 1) 85 | 86 | bottomGridWidget.refreshPositions() 87 | SimplePositioningWidget.setPos(bottomGridWidget, 0, 0, width, height, 0.5f, .95f) 88 | bottomGridWidget.forEachChild(this::addDrawableChild) 89 | } 90 | 91 | private fun createGridWidget() = GridWidget().apply { 92 | mainPositioner.margin(4, 4, 4, 4) 93 | } 94 | 95 | private fun createButton(textKey: String, onClick: (ButtonWidget) -> Unit) = 96 | ButtonWidget.Builder(Text.translatable(textKey), onClick).width(BUTTON_WIDTH).build() 97 | } 98 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/manager/BarManager.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.manager 2 | 3 | 4 | import net.minecraft.client.gui.hud.ClientBossBar 5 | import net.minecraft.text.Text 6 | import org.waste.of.time.manager.CaptureManager.capturing 7 | import org.waste.of.time.WorldTools.config 8 | import org.waste.of.time.manager.MessageManager.info 9 | import org.waste.of.time.storage.StorageFlow 10 | import java.util.* 11 | 12 | object BarManager { 13 | 14 | val progressBar = 15 | ClientBossBar( 16 | UUID.randomUUID(), 17 | Text.of(""), 18 | 0f, 19 | config.render.progressBarColor, 20 | config.render.progressBarStyle, 21 | false, 22 | false, 23 | false 24 | ) 25 | 26 | private val captureInfoBar = 27 | ClientBossBar( 28 | UUID.randomUUID(), 29 | Text.of(""), 30 | 1.0f, 31 | config.render.captureBarColor, 32 | config.render.captureBarStyle, 33 | false, 34 | false, 35 | false 36 | ) 37 | 38 | fun progressBar() = if (StorageFlow.lastStored != null) { 39 | Optional.of(progressBar) 40 | } else { 41 | Optional.empty() 42 | } 43 | 44 | fun getCaptureBar() = if (capturing) { 45 | Optional.of(captureInfoBar) 46 | } else { 47 | Optional.empty() 48 | } 49 | 50 | fun updateCapture() { 51 | captureInfoBar.name = StatisticManager.infoMessage 52 | captureInfoBar.color = config.render.captureBarColor 53 | captureInfoBar.style = config.render.captureBarStyle 54 | progressBar.color = config.render.progressBarColor 55 | progressBar.percent = 0f 56 | 57 | StorageFlow.lastStored?.let { 58 | val elapsed = System.currentTimeMillis() - StorageFlow.lastStoredTimestamp 59 | val timeout = config.render.progressBarTimeout 60 | val progress = (elapsed.toFloat() / timeout).coerceAtMost(1f) 61 | 62 | progressBar.percent = progress 63 | progressBar.name = it.formattedInfo 64 | 65 | if (elapsed >= timeout) { 66 | StorageFlow.lastStored = null 67 | progressBar.percent = 0f 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/manager/CaptureManager.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.manager 2 | 3 | import kotlinx.coroutines.* 4 | import net.minecraft.client.gui.screen.ConfirmScreen 5 | import net.minecraft.client.network.ClientPlayerEntity 6 | import net.minecraft.entity.player.PlayerEntity 7 | import net.minecraft.network.packet.c2s.play.ClientStatusC2SPacket 8 | import net.minecraft.registry.RegistryKey 9 | import net.minecraft.registry.RegistryKeys 10 | import net.minecraft.text.Text 11 | import net.minecraft.world.World 12 | import org.waste.of.time.WorldTools 13 | import org.waste.of.time.WorldTools.LOG 14 | import org.waste.of.time.WorldTools.config 15 | import org.waste.of.time.WorldTools.mc 16 | import org.waste.of.time.config.WorldToolsConfig 17 | import org.waste.of.time.manager.MessageManager.info 18 | import org.waste.of.time.storage.StorageFlow 19 | import org.waste.of.time.storage.cache.EntityCacheable 20 | import org.waste.of.time.storage.cache.HotCache 21 | import org.waste.of.time.storage.serializable.* 22 | 23 | object CaptureManager { 24 | private const val MAX_WORLD_NAME_LENGTH = 64 25 | var capturing = false 26 | private var storeJob: Job? = null 27 | var currentLevelName: String = "Not yet initialized" 28 | var lastPlayer: ClientPlayerEntity? = null 29 | var lastWorldKeys = mutableSetOf>() 30 | 31 | val levelName: String 32 | get() = if (mc.isInSingleplayer) { 33 | mc.server?.serverMotd?.substringAfter(" - ")?.sanitizeWorldName() ?: "Singleplayer" 34 | } else { 35 | mc.networkHandler?.serverInfo?.address?.sanitizeWorldName() ?: "Multiplayer" 36 | } 37 | 38 | fun toggleCapture() { 39 | if (capturing) stop() else start() 40 | } 41 | 42 | fun start(customName: String? = null, confirmed: Boolean = false) { 43 | if (capturing) { 44 | MessageManager.sendError("worldtools.log.error.already_capturing", currentLevelName) 45 | return 46 | } 47 | 48 | if (mc.isInSingleplayer) { 49 | MessageManager.sendInfo("worldtools.log.info.singleplayer_capture") 50 | } 51 | 52 | val potentialName = customName?.let { potentialName -> 53 | if (potentialName.length > MAX_WORLD_NAME_LENGTH) { 54 | MessageManager.sendError( 55 | "worldtools.log.error.world_name_too_long", 56 | potentialName, 57 | MAX_WORLD_NAME_LENGTH 58 | ) 59 | return 60 | } 61 | 62 | potentialName.ifBlank { levelName } 63 | } ?: levelName 64 | 65 | val worldExists = mc.levelStorage.savesDirectory.resolve(potentialName).toFile().exists() 66 | if (worldExists && !confirmed) { 67 | mc.setScreen(ConfirmScreen( 68 | { yes -> 69 | if (yes) start(potentialName, true) 70 | mc.setScreen(null) 71 | }, 72 | Text.translatable("worldtools.gui.capture.existing_world_confirm.title"), 73 | Text.translatable("worldtools.gui.capture.existing_world_confirm.message", potentialName) 74 | )) 75 | return 76 | } 77 | 78 | HotCache.clear() 79 | currentLevelName = potentialName 80 | lastPlayer = mc.player 81 | lastWorldKeys.addAll(mc.networkHandler?.worldKeys ?: emptySet()) 82 | MessageManager.sendInfo("worldtools.log.info.started_capture", potentialName) 83 | if (config.debug.logSettings) logCaptureSettingsState() 84 | storeJob = StorageFlow.launch(potentialName) 85 | mc.networkHandler?.sendPacket(ClientStatusC2SPacket(ClientStatusC2SPacket.Mode.REQUEST_STATS)) 86 | capturing = true 87 | 88 | // Need to wait until the storage flow is running before syncing the cache 89 | CoroutineScope(Dispatchers.IO).launch { 90 | delay(100L) 91 | mc.execute { 92 | syncCacheFromWorldState() 93 | } 94 | } 95 | } 96 | 97 | private fun logCaptureSettingsState() { 98 | WorldTools.GSON.toJson(config, WorldToolsConfig::class.java).let { configJson -> 99 | LOG.info("Launching capture with settings:") 100 | LOG.info(configJson) 101 | } 102 | } 103 | 104 | fun stop() { 105 | if (!capturing) { 106 | MessageManager.sendError("worldtools.log.error.not_capturing") 107 | return 108 | } 109 | 110 | MessageManager.sendInfo("worldtools.log.info.stopping_capture", currentLevelName) 111 | 112 | HotCache.chunks.values.forEach { chunk -> 113 | chunk.emit() // will also write entities in the chunks 114 | } 115 | 116 | HotCache.players.forEach { player -> 117 | player.emit() 118 | } 119 | 120 | MapDataStoreable().emit() 121 | LevelDataStoreable().emit() 122 | AdvancementsStoreable().emit() 123 | MetadataStoreable().emit() 124 | CompressLevelStoreable().emit() 125 | EndFlow().emit() 126 | } 127 | 128 | private fun syncCacheFromWorldState() { 129 | val world = mc.world ?: return 130 | val diameter = world.chunkManager.chunks.diameter 131 | 132 | repeat(diameter * diameter) { i -> 133 | world.chunkManager.chunks.getChunk(i)?.let { chunk -> 134 | RegionBasedChunk(chunk).cache() 135 | BlockEntityLoadable(chunk).emit() 136 | } 137 | } 138 | 139 | world.entities.forEach { 140 | if (it is PlayerEntity) { 141 | PlayerStoreable(it).cache() 142 | } else { 143 | EntityCacheable(it).cache() 144 | } 145 | } 146 | } 147 | 148 | private fun String.sanitizeWorldName() = replace(":", "_") 149 | } 150 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/manager/MessageManager.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.manager 2 | 3 | import net.minecraft.client.toast.SystemToast 4 | import net.minecraft.client.toast.Toast 5 | import net.minecraft.text.MutableText 6 | import net.minecraft.text.Text 7 | import net.minecraft.text.TextColor 8 | import org.waste.of.time.WorldTools.LOG 9 | import org.waste.of.time.WorldTools.config 10 | import org.waste.of.time.WorldTools.mc 11 | 12 | object MessageManager { 13 | private const val ERROR_COLOR = 0xff3333 14 | 15 | val brand: Text = Text.empty() 16 | .append( 17 | Text.literal("W").styled { 18 | it.withColor(TextColor.fromRgb(config.render.accentColor)) 19 | } 20 | ).append( 21 | Text.literal("orld") 22 | ).append( 23 | Text.literal("T").styled { 24 | it.withColor(TextColor.fromRgb(config.render.accentColor)) 25 | } 26 | ).append( 27 | Text.literal("ools") 28 | ) 29 | private val converted by lazy { 30 | Text.literal("[").append(brand).append(Text.of("] ")) 31 | } 32 | private val fullBrand: MutableText 33 | get() = converted.copy() 34 | 35 | fun String.info() = 36 | Text.of(this).sendInfo() 37 | 38 | fun sendInfo(translateKey: String, vararg args: Any) = translateHighlight(translateKey, *args).sendInfo() 39 | 40 | fun sendError(translateKey: String, vararg args: Any) = Text.translatable(translateKey, *args).sendError() 41 | 42 | fun Text.infoToast() { 43 | SystemToast.create( 44 | mc, 45 | SystemToast.Type.WORLD_BACKUP, 46 | brand, 47 | this 48 | ).addToast() 49 | } 50 | 51 | private fun Text.errorToast() { 52 | SystemToast.create( 53 | mc, 54 | SystemToast.Type.WORLD_ACCESS_FAILURE, 55 | brand, 56 | this 57 | ).addToast() 58 | } 59 | 60 | fun Text.sendInfo() = 61 | fullBrand.append(this).addMessage() 62 | 63 | private fun Text.sendError() { 64 | LOG.error(string) 65 | val errorText = copy().styled { 66 | it.withColor(ERROR_COLOR) 67 | } 68 | 69 | fullBrand.append(errorText).addMessage() 70 | errorText.errorToast() 71 | } 72 | 73 | private fun Text.addMessage() { 74 | if (!config.advanced.showChatMessages) return 75 | 76 | mc.execute { 77 | mc.inGameHud.chatHud.addMessage(this) 78 | } 79 | } 80 | 81 | private fun Toast.addToast() { 82 | if (!config.advanced.showToasts) return 83 | 84 | mc.execute { 85 | mc.toastManager.add(this) 86 | } 87 | } 88 | 89 | fun translateHighlight(key: String, vararg args: Any): MutableText = 90 | args.map { element -> 91 | val secondaryColor = TextColor.fromRgb(config.render.accentColor) 92 | if (element is Text) { 93 | if (element.style.color != null) { 94 | element 95 | } else { 96 | element.copy().styled { style -> 97 | style.withColor(secondaryColor) 98 | } 99 | } 100 | } else { 101 | Text.literal(element.toString()).styled { style -> 102 | style.withColor(secondaryColor) 103 | } 104 | } 105 | }.toTypedArray().let { 106 | Text.translatable(key, *it) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/manager/StatisticManager.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.manager 2 | 3 | import net.minecraft.text.MutableText 4 | import net.minecraft.text.Text 5 | import net.minecraft.text.TextColor 6 | import org.waste.of.time.manager.CaptureManager.currentLevelName 7 | import org.waste.of.time.manager.MessageManager.translateHighlight 8 | import org.waste.of.time.WorldTools.config 9 | import org.waste.of.time.storage.cache.HotCache 10 | 11 | object StatisticManager { 12 | var chunks = 0 13 | var entities = 0 14 | var players = 0 15 | private val containers get(): Int = 16 | HotCache.scannedBlockEntities.size + HotCache.loadedBlockEntities.size 17 | val dimensions = mutableSetOf() 18 | 19 | fun reset() { 20 | chunks = 0 21 | entities = 0 22 | players = 0 23 | dimensions.clear() 24 | } 25 | 26 | val infoMessage: Text 27 | get() { 28 | val savedElements = mutableListOf().apply { 29 | if (chunks == 1) { 30 | add(translateHighlight("worldtools.capture.chunk", chunks)) 31 | } 32 | if (chunks > 1) { 33 | add(translateHighlight("worldtools.capture.chunks", "%,d".format(chunks))) 34 | } 35 | 36 | if (entities == 1) { 37 | add(translateHighlight("worldtools.capture.entity", entities)) 38 | } 39 | if (entities > 1) { 40 | add(translateHighlight("worldtools.capture.entities", "%,d".format(entities))) 41 | } 42 | 43 | if (players == 1) { 44 | add(translateHighlight("worldtools.capture.player", players)) 45 | } 46 | if (players > 1) { 47 | add(translateHighlight("worldtools.capture.players", "%,d".format(players))) 48 | } 49 | 50 | if (containers == 1) { 51 | add(translateHighlight("worldtools.capture.container", containers)) 52 | } 53 | if (containers > 1) { 54 | add(translateHighlight("worldtools.capture.containers", "%,d".format(containers))) 55 | } 56 | } 57 | 58 | return if (savedElements.isEmpty()) { 59 | translateHighlight("worldtools.capture.nothing_saved_yet", currentLevelName) 60 | } else { 61 | val dimensionsFormatted = dimensions.map { 62 | Text.literal(it).styled { text -> 63 | text.withColor(TextColor.fromRgb(config.render.accentColor)) 64 | } 65 | }.joinWithAnd() 66 | Text.translatable("worldtools.capture.saved").copy() 67 | .append(savedElements.joinWithAnd()) 68 | .append(Text.translatable("worldtools.capture.in_dimension")) 69 | .append(dimensionsFormatted) 70 | } 71 | } 72 | 73 | fun List.joinWithAnd(): Text { 74 | val and = Text.translatable("worldtools.capture.and") 75 | return when (size) { 76 | 0 -> Text.of("") 77 | 1 -> this[0] 78 | 2 -> this[0].copy().append(and).append(this[1]) 79 | else -> dropLast(1).join().append(and).append(last()) 80 | } 81 | } 82 | 83 | private fun List.join(): MutableText { 84 | val comma = Text.of(", ") 85 | return foldIndexed(Text.literal("")) { index, acc, text -> 86 | if (index == 0) return@foldIndexed text.copy() 87 | acc.append(comma).append(text) 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/Cacheable.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage 2 | 3 | interface Cacheable { 4 | fun cache() 5 | 6 | fun flush() 7 | } -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/CustomRegionBasedStorage.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage 2 | 3 | import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap 4 | import net.minecraft.block.entity.BlockEntity 5 | import net.minecraft.nbt.NbtCompound 6 | import net.minecraft.nbt.NbtIo 7 | import net.minecraft.registry.Registries 8 | import net.minecraft.util.Identifier 9 | import net.minecraft.util.PathUtil 10 | import net.minecraft.util.ThrowableDeliverer 11 | import net.minecraft.util.math.BlockPos 12 | import net.minecraft.util.math.ChunkPos 13 | import net.minecraft.world.World 14 | import net.minecraft.world.storage.RegionFile 15 | import net.minecraft.world.storage.StorageKey 16 | import org.waste.of.time.WorldTools.MCA_EXTENSION 17 | import org.waste.of.time.WorldTools.MOD_NAME 18 | import org.waste.of.time.WorldTools.mc 19 | import java.io.DataOutput 20 | import java.io.IOException 21 | import java.nio.file.Path 22 | 23 | 24 | open class CustomRegionBasedStorage internal constructor( 25 | private val directory: Path, 26 | private val dsync: Boolean 27 | ) : AutoCloseable { 28 | private val cachedRegionFiles: Long2ObjectLinkedOpenHashMap = Long2ObjectLinkedOpenHashMap() 29 | 30 | companion object { 31 | // Seems to only be used for MC's profiler 32 | // simpler to just use a default key instead of wiring this all in here 33 | val defaultStorageKey: StorageKey = StorageKey(MOD_NAME, World.OVERWORLD, "chunk") 34 | } 35 | 36 | @Throws(IOException::class) 37 | fun getRegionFile(pos: ChunkPos): RegionFile { 38 | val longPos = ChunkPos.toLong(pos.regionX, pos.regionZ) 39 | cachedRegionFiles.getAndMoveToFirst(longPos)?.let { return it } 40 | 41 | if (cachedRegionFiles.size >= 256) { 42 | cachedRegionFiles.removeLast()?.close() 43 | } 44 | 45 | PathUtil.createDirectories(directory) 46 | val path = directory.resolve("r." + pos.regionX + "." + pos.regionZ + MCA_EXTENSION) 47 | val regionFile = RegionFile(defaultStorageKey, path, directory, dsync) 48 | cachedRegionFiles.putAndMoveToFirst(longPos, regionFile) 49 | return regionFile 50 | } 51 | 52 | @Throws(IOException::class) 53 | fun write(pos: ChunkPos, nbt: NbtCompound?) { 54 | val regionFile = getRegionFile(pos) 55 | if (nbt == null) { 56 | regionFile.delete(pos) 57 | } else { 58 | regionFile.getChunkOutputStream(pos).use { dataOutputStream -> 59 | NbtIo.write(nbt, dataOutputStream as DataOutput) 60 | } 61 | } 62 | } 63 | 64 | private fun getNbtAt(chunkPos: ChunkPos) = 65 | getRegionFile(chunkPos).getChunkInputStream(chunkPos)?.use { dataInputStream -> 66 | NbtIo.readCompound(dataInputStream) 67 | } 68 | 69 | fun getBlockEntities(chunkPos: ChunkPos): List = 70 | getNbtAt(chunkPos) 71 | ?.getList("block_entities", 10) 72 | ?.filterIsInstance() 73 | ?.mapNotNull { compoundTag -> 74 | val blockPos = BlockPos(compoundTag.getInt("x"), compoundTag.getInt("y"), compoundTag.getInt("z")) 75 | val blockStateIdentifier = Identifier.of(compoundTag.getString("id")) 76 | val world = mc.world ?: return@mapNotNull null 77 | 78 | runCatching { 79 | val block = Registries.BLOCK.get(blockStateIdentifier) 80 | Registries.BLOCK_ENTITY_TYPE 81 | .getOptionalValue(blockStateIdentifier) 82 | .orElse(null) 83 | ?.instantiate(blockPos, block.defaultState)?.apply { 84 | read(compoundTag, world.registryManager) 85 | } 86 | }.getOrNull() 87 | } ?: emptyList() 88 | 89 | @Throws(IOException::class) 90 | override fun close() { 91 | val throwableDeliverer = ThrowableDeliverer() 92 | 93 | cachedRegionFiles.values.filterNotNull().forEach { regionFile -> 94 | try { 95 | regionFile.close() 96 | } catch (iOException: IOException) { 97 | throwableDeliverer.add(iOException) 98 | } 99 | } 100 | 101 | throwableDeliverer.deliver() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/PathTreeNode.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage 2 | 3 | class PathTreeNode(private val name: String) { 4 | private val children = mutableListOf() 5 | 6 | fun getOrCreateChild(name: String): PathTreeNode { 7 | children.find { it.name == name }?.let { return it } 8 | val newChild = PathTreeNode(name) 9 | children.add(newChild) 10 | return newChild 11 | } 12 | 13 | fun buildTreeString(stringBuilder: StringBuilder, prefix: String, isLast: Boolean) { 14 | stringBuilder.apply { 15 | if (prefix.isNotEmpty()) { 16 | append(prefix) 17 | append(if (isLast) "└─ " else "├─ ") 18 | append(name) 19 | append("\n") 20 | } else { 21 | append(name) 22 | append("\n") 23 | } 24 | 25 | children.forEachIndexed { index, treeNode -> 26 | val isLastChild = index == children.size - 1 27 | val childPrefix = if (isLast) "$prefix " else "$prefix│ " 28 | treeNode.buildTreeString(this, childPrefix, isLastChild) 29 | } 30 | } 31 | } 32 | 33 | companion object { 34 | fun buildTree(paths: List): String { 35 | val root = PathTreeNode("minecraft") 36 | paths.forEach { path -> 37 | var currentNode = root 38 | path.split("/").forEach { 39 | currentNode = currentNode.getOrCreateChild(it) 40 | } 41 | } 42 | val stringBuilder = StringBuilder() 43 | root.buildTreeString(stringBuilder, "", true) 44 | return stringBuilder.toString() 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/RegionBased.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage 2 | 3 | import net.minecraft.nbt.NbtCompound 4 | import net.minecraft.util.WorldSavePath 5 | import net.minecraft.util.math.ChunkPos 6 | import net.minecraft.world.World 7 | import net.minecraft.world.level.storage.LevelStorage 8 | import org.waste.of.time.WorldTools.LOG 9 | 10 | abstract class RegionBased( 11 | val chunkPos: ChunkPos, 12 | val world: World, 13 | private val suffix: String 14 | ) : Storeable() { 15 | val dimension: String = world.registryKey.value.path 16 | 17 | private val dimensionPath 18 | get() = when (dimension) { 19 | "overworld" -> "" 20 | "the_nether" -> "DIM-1/" 21 | "the_end" -> "DIM1/" 22 | else -> "dimensions/minecraft/$dimension/" 23 | } 24 | 25 | abstract fun compound(): NbtCompound 26 | 27 | abstract fun incrementStats() 28 | 29 | // can be overridden but super should be called after 30 | open fun writeToStorage( 31 | session: LevelStorage.Session, 32 | storage: CustomRegionBasedStorage, 33 | cachedStorages: MutableMap 34 | ) { 35 | try { 36 | storage.write( 37 | chunkPos, 38 | compound() 39 | ) 40 | incrementStats() 41 | } catch (e: Exception) { 42 | LOG.error("Failed to store $this", e) 43 | } 44 | } 45 | 46 | override fun store( 47 | session: LevelStorage.Session, 48 | cachedStorages: MutableMap 49 | ) { 50 | if (!shouldStore()) return 51 | val storage = generateStorage(session, cachedStorages) 52 | writeToStorage(session, storage, cachedStorages) 53 | } 54 | 55 | fun generateStorage( 56 | session: LevelStorage.Session, 57 | cachedStorages: MutableMap 58 | ): CustomRegionBasedStorage { 59 | val path = session.getDirectory(WorldSavePath.ROOT) 60 | .resolve(dimensionPath) 61 | .resolve(suffix) 62 | return cachedStorages.getOrPut(path.toString()) { 63 | CustomRegionBasedStorage(path, false) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/StorageFlow.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.channels.BufferOverflow 6 | import kotlinx.coroutines.flow.MutableSharedFlow 7 | import kotlinx.coroutines.launch 8 | import net.minecraft.util.path.SymlinkValidationException 9 | import org.waste.of.time.WorldTools 10 | import org.waste.of.time.WorldTools.LOG 11 | import org.waste.of.time.WorldTools.mc 12 | import org.waste.of.time.manager.CaptureManager 13 | import org.waste.of.time.manager.MessageManager 14 | import org.waste.of.time.manager.StatisticManager 15 | import org.waste.of.time.storage.cache.HotCache 16 | import org.waste.of.time.storage.serializable.BlockEntityLoadable 17 | import org.waste.of.time.storage.serializable.EndFlow 18 | import java.io.IOException 19 | import java.util.concurrent.CancellationException 20 | import kotlin.time.Duration 21 | import kotlin.time.measureTime 22 | 23 | object StorageFlow { 24 | private const val MAX_BUFFER_SIZE = 10000 25 | var lastStoredTimestamp: Long = 0 26 | var lastStored: Storeable? = null 27 | var lastStoredTimeNeeded: Duration = Duration.ZERO 28 | 29 | private val sharedFlow = MutableSharedFlow(extraBufferCapacity = MAX_BUFFER_SIZE) 30 | 31 | fun emit(storeable: Storeable) { 32 | if (sharedFlow.tryEmit(storeable)) return 33 | 34 | LOG.warn("Buffer overflow: Unable to emit \"${storeable.formattedInfo.string}\" to storage flow") 35 | } 36 | 37 | fun launch(levelName: String) = CoroutineScope(Dispatchers.IO).launch { 38 | StatisticManager.reset() 39 | val cachedStorages = mutableMapOf() 40 | 41 | try { 42 | LOG.info("Started caching") 43 | mc.levelStorage.createSession(levelName).use { openSession -> 44 | sharedFlow.collect { storeable -> 45 | if (!storeable.shouldStore()) { 46 | return@collect 47 | } 48 | 49 | val shouldSaveLastStored: Boolean 50 | val time = measureTime { 51 | shouldSaveLastStored = (storeable as? BlockEntityLoadable)?.load(openSession, cachedStorages) ?: true 52 | storeable.store(openSession, cachedStorages) 53 | } 54 | 55 | if (shouldSaveLastStored) { 56 | lastStored = storeable 57 | lastStoredTimestamp = System.currentTimeMillis() 58 | lastStoredTimeNeeded = time 59 | } 60 | 61 | if (storeable is EndFlow) { 62 | throw StopCollectingException() 63 | } 64 | } 65 | } 66 | } catch (e: StopCollectingException) { 67 | LOG.info("Canceled caching flow") 68 | } catch (e: IOException) { 69 | LOG.error("IOException: Failed to create session for $levelName", e) 70 | MessageManager.sendError("worldtools.log.error.failed_to_create_session", levelName, e.localizedMessage) 71 | } catch (e: SymlinkValidationException) { 72 | LOG.error("SymlinkValidationException: Failed to create session for $levelName", e) 73 | MessageManager.sendError("worldtools.log.error.failed_to_create_session", levelName, e.localizedMessage) 74 | } catch (e: CancellationException) { 75 | LOG.info("Canceled caching thread") 76 | } catch (e: Throwable) { 77 | LOG.error("Unhandled storage flow error", e) 78 | } 79 | 80 | cachedStorages.values.forEach { it.close() } 81 | HotCache.clear() 82 | CaptureManager.capturing = false 83 | LOG.info("Finished caching") 84 | } 85 | 86 | class StopCollectingException : Exception() 87 | } 88 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/Storeable.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage 2 | 3 | import net.minecraft.text.MutableText 4 | import net.minecraft.text.Text 5 | import net.minecraft.world.level.storage.LevelStorage.Session 6 | import org.waste.of.time.manager.MessageManager.translateHighlight 7 | import org.waste.of.time.WorldTools.config 8 | 9 | abstract class Storeable { 10 | abstract fun store( 11 | session: Session, 12 | cachedStorages: MutableMap 13 | ) 14 | 15 | abstract fun shouldStore(): Boolean 16 | 17 | abstract val verboseInfo: MutableText 18 | 19 | abstract val anonymizedInfo: MutableText 20 | 21 | fun emit() = StorageFlow.emit(this) 22 | 23 | val formattedInfo: Text by lazy { 24 | if (config.advanced.anonymousMode) { 25 | anonymizedInfo 26 | } else { 27 | verboseInfo 28 | }.append(translateHighlight("worldtools.capture.took", StorageFlow.lastStoredTimeNeeded)) 29 | } 30 | } -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/cache/DataInjectionHandler.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.cache 2 | 3 | import net.minecraft.block.ChestBlock 4 | import net.minecraft.block.entity.* 5 | import net.minecraft.block.enums.ChestType 6 | import net.minecraft.client.gui.screen.Screen 7 | import net.minecraft.client.gui.screen.ingame.* 8 | import net.minecraft.entity.Entity 9 | import net.minecraft.entity.player.PlayerInventory 10 | import net.minecraft.entity.vehicle.HopperMinecartEntity 11 | import net.minecraft.entity.vehicle.VehicleInventory 12 | import net.minecraft.inventory.EnderChestInventory 13 | import net.minecraft.inventory.SimpleInventory 14 | import org.waste.of.time.WorldTools.mc 15 | import org.waste.of.time.storage.cache.HotCache.markScanned 16 | import org.waste.of.time.storage.cache.HotCache.scannedBlockEntities 17 | 18 | object DataInjectionHandler { 19 | fun onScreenRemoved(screen: Screen) { 20 | HotCache.lastInteractedBlockEntity?.let { 21 | handleBlockEntity(screen, it) 22 | } 23 | HotCache.lastInteractedEntity?.let { 24 | handleEntity(screen, it) 25 | } 26 | } 27 | 28 | private fun handleEntity(screen: Screen, entity: Entity) { 29 | when (screen) { 30 | is GenericContainerScreen -> { 31 | (entity as? VehicleInventory)?.dataToVehicle(screen) 32 | } 33 | is HopperScreen -> { 34 | (entity as? HopperMinecartEntity)?.dataToHopperMinecart(screen) 35 | } 36 | } 37 | 38 | entity.markScanned() 39 | } 40 | 41 | private fun VehicleInventory.dataToVehicle(screen: GenericContainerScreen) { 42 | screen.getContainerSlots().forEach { 43 | setStack(it.index, it.stack) 44 | } 45 | } 46 | 47 | private fun HopperMinecartEntity.dataToHopperMinecart(screen: HopperScreen) { 48 | screen.getContainerSlots().forEach { 49 | setStack(it.index, it.stack) 50 | } 51 | } 52 | 53 | private fun handleBlockEntity(screen: Screen, blockEntity: BlockEntity, ) { 54 | when (screen) { 55 | is GenericContainerScreen -> { 56 | when (blockEntity) { 57 | is ChestBlockEntity -> blockEntity.dataToChest(screen) 58 | is BarrelBlockEntity -> blockEntity.dataToBarrelBlock(screen) 59 | is EnderChestBlockEntity -> dataToEnderChest(screen) 60 | } 61 | } 62 | 63 | is Generic3x3ContainerScreen -> { 64 | (blockEntity as? DispenserBlockEntity)?.dataToDispenserOrDropper(screen) 65 | } 66 | 67 | is AbstractFurnaceScreen<*> -> { 68 | (blockEntity as? AbstractFurnaceBlockEntity)?.dataToFurnace(screen) 69 | } 70 | 71 | is BrewingStandScreen -> { 72 | (blockEntity as? BrewingStandBlockEntity)?.dataToBrewingStand(screen) 73 | } 74 | 75 | is HopperScreen -> { 76 | (blockEntity as? HopperBlockEntity)?.dataToHopper(screen) 77 | } 78 | 79 | is ShulkerBoxScreen -> { 80 | (blockEntity as? ShulkerBoxBlockEntity)?.dataToShulkerBox(screen) 81 | } 82 | 83 | is LecternScreen -> { 84 | (blockEntity as? LecternBlockEntity)?.dataToLectern(screen) 85 | } 86 | 87 | is CrafterScreen -> { 88 | (blockEntity as? CrafterBlockEntity)?.dataToCrafter(screen) 89 | } 90 | } 91 | 92 | // ToDo: Add support for entity containers like chest boat and minecart 93 | 94 | // ToDo: Find out if its possible to get the map state update (currently has no effect) 95 | // screen.getContainerSlots().filter { 96 | // it.stack.item == Items.FILLED_MAP 97 | // }.forEach { 98 | // it.stack.components.get(DataComponentTypes.MAP_ID)?.let { id -> 99 | // HotCache.mapIDs.add(id.id) 100 | // } 101 | // } 102 | 103 | blockEntity.markScanned() 104 | } 105 | 106 | private fun dataToEnderChest(screen: GenericContainerScreen) { 107 | if (mc.isInSingleplayer) return 108 | val inventory = screen.screenHandler.inventory as? SimpleInventory ?: return 109 | if (inventory.size() != 27) return 110 | mc.player?.enderChestInventory = EnderChestInventory().apply { 111 | repeat(inventory.size()) { i -> 112 | setStack(i, inventory.getStack(i)) 113 | } 114 | } 115 | } 116 | 117 | private fun AbstractFurnaceBlockEntity.dataToFurnace(screen: AbstractFurnaceScreen<*>) { 118 | screen.getContainerSlots().forEach { 119 | setStack(it.index, it.stack) 120 | } 121 | } 122 | 123 | private fun BarrelBlockEntity.dataToBarrelBlock(screen: GenericContainerScreen) { 124 | screen.getContainerSlots().forEach { 125 | setStack(it.index, it.stack) 126 | } 127 | } 128 | 129 | private fun BrewingStandBlockEntity.dataToBrewingStand(screen: BrewingStandScreen) { 130 | screen.getContainerSlots().forEach { 131 | setStack(it.index, it.stack) 132 | } 133 | } 134 | 135 | private fun ChestBlockEntity.dataToChest(screen: GenericContainerScreen) { 136 | val facing = cachedState[ChestBlock.FACING] ?: return 137 | val chestType = cachedState[ChestBlock.CHEST_TYPE] ?: return 138 | val containerSlots = screen.getContainerSlots() 139 | val inventories = containerSlots.partition { it.index < 27 } 140 | 141 | when (chestType) { 142 | ChestType.SINGLE -> { 143 | containerSlots.forEach { 144 | setStack(it.index, it.stack) 145 | } 146 | } 147 | 148 | ChestType.LEFT -> { 149 | val pos = pos.offset(facing.rotateYClockwise()) 150 | val otherChest = world?.getBlockEntity(pos) 151 | if (otherChest !is ChestBlockEntity) return 152 | 153 | inventories.first.forEach { 154 | otherChest.setStack(it.index, it.stack) 155 | } 156 | inventories.second.forEach { 157 | setStack(it.index - 27, it.stack) 158 | } 159 | 160 | scannedBlockEntities[otherChest.pos] = otherChest 161 | } 162 | 163 | ChestType.RIGHT -> { 164 | val pos = pos.offset(facing.rotateYCounterclockwise()) 165 | val otherChest = world?.getBlockEntity(pos) 166 | if (otherChest !is ChestBlockEntity) return 167 | 168 | inventories.first.forEach { 169 | setStack(it.index, it.stack) 170 | } 171 | inventories.second.forEach { 172 | otherChest.setStack(it.index - 27, it.stack) 173 | } 174 | 175 | scannedBlockEntities[otherChest.pos] = otherChest 176 | } 177 | } 178 | } 179 | 180 | private fun DispenserBlockEntity.dataToDispenserOrDropper(screen: Generic3x3ContainerScreen) { 181 | screen.getContainerSlots().forEach { 182 | setStack(it.index, it.stack) 183 | } 184 | } 185 | 186 | private fun HopperBlockEntity.dataToHopper(screen: HopperScreen) { 187 | screen.getContainerSlots().forEach { 188 | setStack(it.index, it.stack) 189 | } 190 | } 191 | 192 | private fun ShulkerBoxBlockEntity.dataToShulkerBox(screen: ShulkerBoxScreen) { 193 | screen.getContainerSlots().forEach { 194 | setStack(it.index, it.stack) 195 | } 196 | } 197 | 198 | private fun LecternBlockEntity.dataToLectern(screen: LecternScreen) { 199 | book = screen.screenHandler.bookItem 200 | } 201 | 202 | private fun CrafterBlockEntity.dataToCrafter(screen: CrafterScreen) { 203 | screen.getContainerSlots().forEach { 204 | setStack(it.index, it.stack) 205 | setSlotEnabled(it.index, !isSlotDisabled(it.index)) 206 | } 207 | } 208 | 209 | private fun HandledScreen<*>.getContainerSlots() = screenHandler.slots.filter { it.inventory !is PlayerInventory } 210 | } 211 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/cache/EntityCacheable.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.cache 2 | 3 | import net.minecraft.entity.Entity 4 | import net.minecraft.entity.EntityType 5 | import net.minecraft.nbt.NbtCompound 6 | import org.waste.of.time.Utils.toByte 7 | import org.waste.of.time.WorldTools.TIMESTAMP_KEY 8 | import org.waste.of.time.WorldTools.config 9 | import org.waste.of.time.storage.Cacheable 10 | 11 | data class EntityCacheable( 12 | val entity: Entity 13 | ) : Cacheable { 14 | fun compound() = NbtCompound().apply { 15 | // saveSelfNbt has a check for RemovalReason.DISCARDED 16 | EntityType.getId(entity.type)?.let { putString(Entity.ID_KEY, it.toString()) } 17 | entity.writeNbt(this) 18 | 19 | if (config.entity.behavior.modifyEntityBehavior) { 20 | putByte("NoAI", config.entity.behavior.noAI.toByte()) 21 | putByte("NoGravity", config.entity.behavior.noGravity.toByte()) 22 | putByte("Invulnerable", config.entity.behavior.invulnerable.toByte()) 23 | putByte("Silent", config.entity.behavior.silent.toByte()) 24 | } 25 | 26 | if (config.entity.metadata.captureTimestamp) { 27 | putLong(TIMESTAMP_KEY, System.currentTimeMillis()) 28 | } 29 | } 30 | 31 | override fun cache() { 32 | HotCache.entities.computeIfAbsent(entity.chunkPos) { mutableSetOf() }.apply { 33 | // Remove the entity if it already exists to update it 34 | removeIf { it.entity.uuid == entity.uuid } 35 | add(this@EntityCacheable) 36 | } 37 | } 38 | 39 | override fun flush() { 40 | val chunkPos = entity.chunkPos 41 | HotCache.entities[chunkPos]?.let { list -> 42 | list.remove(this) 43 | if (list.isEmpty()) { 44 | HotCache.entities.remove(chunkPos) 45 | } 46 | } 47 | } 48 | 49 | override fun equals(other: Any?): Boolean { 50 | if (other !is EntityCacheable) return super.equals(other) 51 | return entity.uuid == other.entity.uuid 52 | } 53 | 54 | override fun hashCode() = entity.uuid.hashCode() 55 | } 56 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/cache/HotCache.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.cache 2 | 3 | import it.unimi.dsi.fastutil.longs.LongOpenHashSet 4 | import net.minecraft.block.entity.BlockEntity 5 | import net.minecraft.block.entity.LecternBlockEntity 6 | import net.minecraft.block.entity.LockableContainerBlockEntity 7 | import net.minecraft.entity.Entity 8 | import net.minecraft.entity.vehicle.VehicleInventory 9 | import net.minecraft.inventory.EnderChestInventory 10 | import net.minecraft.registry.Registries 11 | import net.minecraft.util.math.BlockPos 12 | import net.minecraft.util.math.ChunkPos 13 | import net.minecraft.world.World 14 | import org.waste.of.time.WorldTools.LOG 15 | import org.waste.of.time.WorldTools.config 16 | import org.waste.of.time.WorldTools.mc 17 | import org.waste.of.time.manager.StatisticManager 18 | import org.waste.of.time.storage.serializable.PlayerStoreable 19 | import org.waste.of.time.storage.serializable.RegionBasedChunk 20 | import org.waste.of.time.storage.serializable.RegionBasedEntities 21 | import java.util.concurrent.ConcurrentHashMap 22 | 23 | /** 24 | * [HotCache] that caches all currently loaded objects in the world that are needed for the world download. 25 | * It will be maintained until the user stops the capture process. 26 | * Then the data will be released into the storage data flow to be serialized in the storage thread. 27 | * This is needed because objects won't be saved to disk until they are unloaded from the world. 28 | */ 29 | object HotCache { 30 | val chunks = ConcurrentHashMap() 31 | internal val savedChunks = LongOpenHashSet() 32 | val entities = ConcurrentHashMap>() 33 | val players: ConcurrentHashMap.KeySetView = ConcurrentHashMap.newKeySet() 34 | val scannedBlockEntities = ConcurrentHashMap() 35 | private val scannedEntities: ConcurrentHashMap.KeySetView = ConcurrentHashMap.newKeySet() 36 | val loadedBlockEntities = ConcurrentHashMap() 37 | var lastInteractedBlockEntity: BlockEntity? = null 38 | var lastInteractedEntity: Entity? = null 39 | val unscannedBlockEntities by LazyUpdatingDelegate(100) { 40 | chunks.values 41 | .flatMap { it.chunk.blockEntities.values } 42 | .filter { it.isSupported } 43 | .filterNot { scannedBlockEntities.containsKey(it.pos) } 44 | } 45 | val unscannedEntities by LazyUpdatingDelegate(100) { 46 | entities.values 47 | .flatten() 48 | .filter { it.entity.isSupported } 49 | .filterNot { it.entity in scannedEntities } 50 | } 51 | // map id's of maps that we've seen during the capture 52 | val mapIDs = mutableSetOf() 53 | val BlockEntity.isSupported get() = 54 | this is LockableContainerBlockEntity 55 | || this is LecternBlockEntity 56 | val Entity.isSupported get() = this is VehicleInventory 57 | 58 | fun getEntitySerializableForChunk(chunkPos: ChunkPos, world: World) = 59 | entities[chunkPos]?.let { entities -> 60 | RegionBasedEntities(chunkPos, entities, world) 61 | } 62 | 63 | /** 64 | * Used as a public API for external mods like [XaeroPlus](https://github.com/rfresh2/XaeroPlus), change carefully. 65 | * 66 | * @param chunkX The X coordinate of the chunk. 67 | * @param chunkZ The Z coordinate of the chunk. 68 | * @return True if the chunk is saved, false otherwise. 69 | */ 70 | @Suppress("unused") 71 | fun isChunkSaved(chunkX: Int, chunkZ: Int) = savedChunks.contains(ChunkPos.toLong(chunkX, chunkZ)) 72 | 73 | fun clear() { 74 | chunks.clear() 75 | savedChunks.clear() 76 | entities.clear() 77 | players.clear() 78 | scannedBlockEntities.clear() 79 | loadedBlockEntities.clear() 80 | mapIDs.clear() 81 | 82 | // failing to reset this could cause users to accidentally save their echest contents on subsequent captures 83 | if (!mc.isInSingleplayer && !config.advanced.keepEnderChestContents) { 84 | mc.player?.enderChestInventory = EnderChestInventory() 85 | } 86 | lastInteractedBlockEntity = null 87 | LOG.info("Cleared hot cache") 88 | } 89 | 90 | fun BlockEntity.markScanned(fromCache: Boolean = false) { 91 | if (fromCache) { 92 | loadedBlockEntities[pos] = this 93 | } else { 94 | scannedBlockEntities[pos] = this 95 | loadedBlockEntities.remove(pos) 96 | } 97 | 98 | world?.registryKey?.value?.path?.let { 99 | StatisticManager.dimensions.add(it) 100 | } 101 | if (config.debug.logSavedContainers) { 102 | LOG.info("Saved block entity: ${Registries.BLOCK_ENTITY_TYPE.getId(type)?.path} at $pos") 103 | } 104 | } 105 | 106 | fun Entity.markScanned() { 107 | scannedEntities.add(this) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/cache/LazyUpdatingDelegate.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.cache 2 | 3 | import kotlin.properties.ReadOnlyProperty 4 | import kotlin.reflect.KProperty 5 | 6 | class LazyUpdatingDelegate(private val timeout: Long, private val block: () -> T) : ReadOnlyProperty { 7 | private var value: T = block() 8 | private var lastUpdateTime: Long = System.currentTimeMillis() 9 | 10 | override fun getValue(thisRef: Any?, property: KProperty<*>): T { 11 | val currentTime = System.currentTimeMillis() 12 | if (currentTime - lastUpdateTime >= timeout) { 13 | value = block() 14 | lastUpdateTime = currentTime 15 | } 16 | return value 17 | } 18 | } -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/serializable/AdvancementsStoreable.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.serializable 2 | 3 | import com.google.gson.JsonElement 4 | import com.mojang.serialization.JsonOps 5 | import net.minecraft.advancement.PlayerAdvancementTracker 6 | import net.minecraft.datafixer.DataFixTypes 7 | import net.minecraft.text.MutableText 8 | import net.minecraft.util.PathUtil 9 | import net.minecraft.util.WorldSavePath 10 | import net.minecraft.world.level.storage.LevelStorage 11 | import org.waste.of.time.WorldTools.CURRENT_VERSION 12 | import org.waste.of.time.WorldTools.GSON 13 | import org.waste.of.time.WorldTools.LOG 14 | import org.waste.of.time.WorldTools.config 15 | import org.waste.of.time.WorldTools.mc 16 | import org.waste.of.time.manager.MessageManager.translateHighlight 17 | import org.waste.of.time.storage.CustomRegionBasedStorage 18 | import org.waste.of.time.storage.Storeable 19 | import java.nio.charset.StandardCharsets 20 | import java.nio.file.Files 21 | 22 | class AdvancementsStoreable : Storeable() { 23 | override fun shouldStore() = config.general.capture.advancements 24 | 25 | override val verboseInfo: MutableText 26 | get() = translateHighlight( 27 | "worldtools.capture.saved.advancements", 28 | mc.player?.name ?: "Unknown" 29 | ) 30 | 31 | override val anonymizedInfo: MutableText 32 | get() = verboseInfo 33 | 34 | private val progressMapCodec = 35 | DataFixTypes.ADVANCEMENTS.createDataFixingCodec( 36 | PlayerAdvancementTracker.ProgressMap.CODEC, mc.dataFixer, CURRENT_VERSION 37 | ) 38 | 39 | override fun store( 40 | session: LevelStorage.Session, 41 | cachedStorages: MutableMap 42 | ) { 43 | val uuid = mc.player?.uuid ?: return 44 | val progress = mc.player 45 | ?.networkHandler 46 | ?.advancementHandler 47 | ?.advancementProgresses ?: return 48 | val progressMap = progress.entries 49 | .filter { it.value.isAnyObtained } 50 | .associate { 51 | it.key.id to it.value 52 | } 53 | val jsonElement = 54 | progressMapCodec.encodeStart( 55 | JsonOps.INSTANCE, 56 | PlayerAdvancementTracker.ProgressMap(progressMap) 57 | ).getOrThrow() as JsonElement 58 | 59 | 60 | val advancements = session.getDirectory(WorldSavePath.ADVANCEMENTS) 61 | PathUtil.createDirectories(advancements) 62 | Files.newBufferedWriter( 63 | advancements.resolve("$uuid.json"), 64 | StandardCharsets.UTF_8 65 | ).use { writer -> 66 | GSON.toJson(jsonElement, writer) 67 | } 68 | 69 | LOG.info("Saved ${progressMap.size} advancements.") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/serializable/BlockEntityLoadable.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.serializable 2 | 3 | import net.minecraft.block.entity.BlockEntity 4 | import net.minecraft.block.entity.LecternBlockEntity 5 | import net.minecraft.block.entity.LockableContainerBlockEntity 6 | import net.minecraft.world.chunk.WorldChunk 7 | import net.minecraft.world.level.storage.LevelStorage 8 | import org.waste.of.time.WorldTools.config 9 | import org.waste.of.time.manager.MessageManager.translateHighlight 10 | import org.waste.of.time.storage.CustomRegionBasedStorage 11 | import org.waste.of.time.storage.cache.HotCache 12 | import org.waste.of.time.storage.cache.HotCache.isSupported 13 | import org.waste.of.time.storage.cache.HotCache.markScanned 14 | 15 | class BlockEntityLoadable( 16 | chunk: WorldChunk 17 | ) : RegionBasedChunk(chunk) { 18 | private var migrated = false 19 | override fun shouldStore() = 20 | config.general.reloadBlockEntities && chunk.blockEntities.isNotEmpty() 21 | 22 | override val verboseInfo = translateHighlight( 23 | "worldtools.capture.loaded.block_entities", 24 | chunk.pos, 25 | chunk.world.registryKey.value.path 26 | ) 27 | 28 | override val anonymizedInfo = translateHighlight( 29 | "worldtools.capture.loaded.block_entities.anonymized", 30 | chunk.world.registryKey.value.path 31 | ) 32 | 33 | fun load( 34 | session: LevelStorage.Session, 35 | cachedStorages: MutableMap 36 | ): Boolean { 37 | generateStorage(session, cachedStorages) 38 | .getBlockEntities(chunkPos) 39 | .filter { it.isSupported } 40 | .forEach { existing -> 41 | HotCache.chunks[chunkPos] 42 | ?.cachedBlockEntities 43 | ?.get(existing.pos) 44 | ?.let { blockEntity -> 45 | when (blockEntity) { 46 | is LockableContainerBlockEntity -> blockEntity.migrateData(existing) 47 | is LecternBlockEntity -> blockEntity.migrateData(existing) 48 | } 49 | } 50 | } 51 | return migrated 52 | } 53 | 54 | private fun LockableContainerBlockEntity.migrateData(existing: BlockEntity) { 55 | if (existing !is LockableContainerBlockEntity) return 56 | if (!isEmpty) return 57 | heldStacks = existing.heldStacks 58 | markScanned(true) 59 | migrated = true 60 | } 61 | 62 | private fun LecternBlockEntity.migrateData(existing: BlockEntity) { 63 | if (existing !is LecternBlockEntity) return 64 | if (!book.isEmpty) return 65 | book = existing.book 66 | markScanned(true) 67 | migrated = true 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/serializable/CompressLevelStoreable.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.serializable 2 | 3 | import net.minecraft.text.MutableText 4 | import net.minecraft.util.WorldSavePath 5 | import net.minecraft.world.level.storage.LevelStorage 6 | import org.waste.of.time.Utils.toReadableByteCount 7 | import org.waste.of.time.WorldTools.LOG 8 | import org.waste.of.time.WorldTools.config 9 | import org.waste.of.time.WorldTools.mc 10 | import org.waste.of.time.manager.BarManager 11 | import org.waste.of.time.manager.CaptureManager.currentLevelName 12 | import org.waste.of.time.manager.MessageManager 13 | import org.waste.of.time.manager.MessageManager.translateHighlight 14 | import org.waste.of.time.storage.CustomRegionBasedStorage 15 | import org.waste.of.time.storage.StorageFlow 16 | import org.waste.of.time.storage.Storeable 17 | import java.io.IOException 18 | import java.nio.file.* 19 | import java.nio.file.attribute.BasicFileAttributes 20 | import java.util.zip.ZipEntry 21 | import java.util.zip.ZipOutputStream 22 | import kotlin.io.path.name 23 | 24 | class CompressLevelStoreable : Storeable() { 25 | private val zipName: String get() = "$currentLevelName-${System.currentTimeMillis()}.zip" 26 | 27 | override fun shouldStore() = config.general.compressLevel 28 | 29 | override val verboseInfo: MutableText 30 | get() = translateHighlight("worldtools.capture.saved.compressed", zipName) 31 | 32 | override val anonymizedInfo: MutableText 33 | get() = verboseInfo 34 | 35 | override fun store( 36 | session: LevelStorage.Session, 37 | cachedStorages: MutableMap 38 | ) { 39 | val rootPath = session.getDirectory(WorldSavePath.ROOT) 40 | val zipPath = mc.levelStorage.savesDirectory.resolve(zipName) 41 | LOG.info("Zipping $rootPath to $zipPath") 42 | 43 | val totalSize = Files.walk(rootPath).filter { Files.isRegularFile(it) }.mapToLong { Files.size(it) }.sum() 44 | 45 | try { 46 | var totalZippedSize = 0L 47 | 48 | Files.newOutputStream(zipPath).use { outStream -> 49 | ZipOutputStream(outStream).use { zipOut -> 50 | Files.walkFileTree(rootPath, object : SimpleFileVisitor() { 51 | override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { 52 | zipFile(file, rootPath, zipOut) 53 | 54 | totalZippedSize += Files.size(file) 55 | val progress = totalZippedSize.toDouble() / totalSize 56 | StorageFlow.lastStoredTimestamp = System.currentTimeMillis() 57 | BarManager.progressBar.percent = progress.toFloat() 58 | if (config.debug.logZippingProgress) { 59 | LOG.info("${"%.2f".format(progress * 100)}% (${totalZippedSize.toReadableByteCount()}/${totalSize.toReadableByteCount()}) Zipping file ${file.name} with size ${Files.size(file).toReadableByteCount()}") 60 | } 61 | return FileVisitResult.CONTINUE 62 | } 63 | 64 | override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { 65 | MessageManager.sendError("worldtools.log.error.failed_to_visit_file", file, exc.localizedMessage) 66 | return FileVisitResult.CONTINUE 67 | } 68 | }) 69 | } 70 | } 71 | LOG.info("Finished zipping $rootPath with size ${totalZippedSize.toReadableByteCount()} to ${zipPath.toAbsolutePath()} with size ${Files.size(zipPath).toReadableByteCount()}") 72 | } catch (e: IOException) { 73 | MessageManager.sendError("worldtools.log.error.failed_to_zip", rootPath, e.localizedMessage) 74 | } 75 | } 76 | 77 | @Throws(IOException::class) 78 | private fun zipFile(fileToZip: Path, rootPath: Path, zipOut: ZipOutputStream) { 79 | if (fileToZip.fileName.toString().contains("session.lock")) return 80 | val entryName = rootPath.relativize(fileToZip).toString().replace('\\', '/') 81 | when { 82 | Files.isHidden(fileToZip) -> return 83 | Files.isDirectory(fileToZip) -> { 84 | zipOut.putNextEntry(ZipEntry("$entryName/")) 85 | zipOut.closeEntry() 86 | } 87 | else -> { 88 | Files.newInputStream(fileToZip).use { inputStream -> 89 | zipOut.putNextEntry(ZipEntry(entryName)) 90 | inputStream.copyTo(zipOut) 91 | zipOut.closeEntry() 92 | } 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/serializable/EndFlow.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.serializable 2 | 3 | import net.minecraft.text.ClickEvent 4 | import net.minecraft.text.MutableText 5 | import net.minecraft.text.Text 6 | import net.minecraft.util.WorldSavePath 7 | import net.minecraft.world.level.storage.LevelStorage 8 | import org.waste.of.time.manager.CaptureManager.currentLevelName 9 | import org.waste.of.time.manager.MessageManager.infoToast 10 | import org.waste.of.time.manager.MessageManager.sendInfo 11 | import org.waste.of.time.manager.MessageManager.translateHighlight 12 | import org.waste.of.time.manager.StatisticManager 13 | import org.waste.of.time.storage.CustomRegionBasedStorage 14 | import org.waste.of.time.storage.Storeable 15 | 16 | class EndFlow : Storeable() { 17 | override fun shouldStore() = true 18 | 19 | override val verboseInfo: MutableText 20 | get() = translateHighlight( 21 | "worldtools.capture.saved.end_flow", 22 | currentLevelName 23 | ) 24 | 25 | override val anonymizedInfo: MutableText 26 | get() = verboseInfo 27 | 28 | override fun store( 29 | session: LevelStorage.Session, 30 | cachedStorages: MutableMap 31 | ) { 32 | StatisticManager.infoMessage.apply { 33 | infoToast() 34 | 35 | val directory = Text.translatable("worldtools.capture.to_directory") 36 | val clickToOpen = translateHighlight( 37 | "worldtools.capture.click_to_open", 38 | currentLevelName 39 | ).copy().styled { 40 | it.withClickEvent( 41 | ClickEvent( 42 | ClickEvent.Action.OPEN_FILE, 43 | session.getDirectory(WorldSavePath.ROOT).toFile().path 44 | ) 45 | ) 46 | } 47 | 48 | copy().append(directory).append(clickToOpen).sendInfo() 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/serializable/LevelDataStoreable.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.serializable 2 | 3 | import net.minecraft.SharedConstants 4 | import net.minecraft.nbt.* 5 | import net.minecraft.text.MutableText 6 | import net.minecraft.util.Util 7 | import net.minecraft.util.WorldSavePath 8 | import net.minecraft.world.GameRules 9 | import net.minecraft.world.level.storage.LevelStorage.Session 10 | import org.waste.of.time.Utils.toByte 11 | import org.waste.of.time.WorldTools.DAT_EXTENSION 12 | import org.waste.of.time.WorldTools.LOG 13 | import org.waste.of.time.WorldTools.config 14 | import org.waste.of.time.WorldTools.mc 15 | import org.waste.of.time.config.WorldToolsConfig.World.WorldGenerator.GeneratorType 16 | import org.waste.of.time.manager.CaptureManager 17 | import org.waste.of.time.manager.CaptureManager.currentLevelName 18 | import org.waste.of.time.manager.MessageManager 19 | import org.waste.of.time.manager.MessageManager.translateHighlight 20 | import org.waste.of.time.storage.CustomRegionBasedStorage 21 | import org.waste.of.time.storage.Storeable 22 | import java.io.File 23 | import java.io.IOException 24 | 25 | class LevelDataStoreable : Storeable() { 26 | override fun shouldStore() = config.general.capture.levelData 27 | 28 | override val verboseInfo: MutableText 29 | get() = translateHighlight( 30 | "worldtools.capture.saved.levelData", 31 | currentLevelName, 32 | "level${DAT_EXTENSION}" 33 | ) 34 | 35 | override val anonymizedInfo: MutableText 36 | get() = verboseInfo 37 | 38 | /** 39 | * See [net.minecraft.world.level.storage.LevelStorage.Session.backupLevelDataFile] 40 | */ 41 | override fun store( 42 | session: Session, 43 | cachedStorages: MutableMap 44 | ) { 45 | val resultingFile = session.getDirectory(WorldSavePath.ROOT).toFile() 46 | val dataNbt = serializeLevelData() 47 | // if we save an empty level.dat, clients will crash when opening the SP worlds screen 48 | if (dataNbt.isEmpty) throw RuntimeException("Failed to serialize level data") 49 | val levelNbt = NbtCompound().apply { 50 | put("Data", dataNbt) 51 | } 52 | 53 | try { 54 | val newFile = File.createTempFile("level", DAT_EXTENSION, resultingFile).toPath() 55 | NbtIo.writeCompressed(levelNbt, newFile) 56 | val backup = session.getDirectory(WorldSavePath.LEVEL_DAT_OLD) 57 | val current = session.getDirectory(WorldSavePath.LEVEL_DAT) 58 | Util.backupAndReplace(current, newFile, backup) 59 | LOG.info("Saved level data.") 60 | } catch (exception: IOException) { 61 | MessageManager.sendError( 62 | "worldtools.log.error.failed_to_save_level", 63 | resultingFile.path, 64 | exception.localizedMessage 65 | ) 66 | } 67 | } 68 | 69 | /** 70 | * See [net.minecraft.world.level.LevelProperties.updateProperties] 71 | */ 72 | private fun serializeLevelData() = NbtCompound().apply { 73 | val player = CaptureManager.lastPlayer ?: mc.player ?: return@apply 74 | 75 | mc.networkHandler?.brand?.let { 76 | put("ServerBrands", NbtList().apply { 77 | add(NbtString.of(it)) 78 | }) 79 | } 80 | 81 | putBoolean("WasModded", false) 82 | 83 | // skip removed features 84 | 85 | put("Version", NbtCompound().apply { 86 | putString("Name", SharedConstants.getGameVersion().name) 87 | putInt("Id", SharedConstants.getGameVersion().saveVersion.id) 88 | putBoolean("Snapshot", !SharedConstants.getGameVersion().isStable) 89 | putString("Series", SharedConstants.getGameVersion().saveVersion.series) 90 | }) 91 | 92 | NbtHelper.putDataVersion(this) 93 | 94 | put("WorldGenSettings", generatorMockNbt()) 95 | mc.networkHandler?.listedPlayerListEntries?.find { 96 | it.profile.id == player.uuid 97 | }?.let { 98 | putInt("GameType", it.gameMode.id) 99 | } ?: putInt("GameType", player.server?.defaultGameMode?.id ?: 0) 100 | 101 | putInt("SpawnX", player.world.levelProperties.spawnPos.x) 102 | putInt("SpawnY", player.world.levelProperties.spawnPos.y) 103 | putInt("SpawnZ", player.world.levelProperties.spawnPos.z) 104 | putFloat("SpawnAngle", player.world.levelProperties.spawnAngle) 105 | putLong("Time", player.world.time) 106 | putLong("DayTime", player.world.timeOfDay) 107 | putLong("LastPlayed", System.currentTimeMillis()) 108 | putString("LevelName", currentLevelName) 109 | putInt("version", 19133) 110 | putInt("clearWeatherTime", 0) // not sure 111 | putInt("rainTime", 0) // not sure 112 | putBoolean("raining", player.world.isRaining) 113 | putBoolean("thundering", player.world.isThundering) 114 | putBoolean("hardcore", player.server?.isHardcore ?: false) 115 | putInt("thunderTime", 0) // not sure 116 | putBoolean("allowCommands", true) // not sure 117 | putBoolean("initialized", true) // not sure 118 | 119 | player.world.worldBorder.write().writeNbt(this) 120 | 121 | putByte("Difficulty", player.world.levelProperties.difficulty.id.toByte()) 122 | putBoolean("DifficultyLocked", false) // not sure 123 | 124 | // ToDo: Seems that the client side game rules were removed. Now only works for single player :/ 125 | val rules = player.world?.server?.gameRules?.genGameRules() ?: NbtCompound() 126 | put("GameRules", rules) 127 | put("Player", NbtCompound().apply { 128 | player.writeNbt(this) 129 | remove("LastDeathLocation") // can contain sensitive information 130 | putString("Dimension", "minecraft:${player.world.registryKey.value.path}") 131 | }) 132 | 133 | put("DragonFight", NbtCompound()) // not sure 134 | put("CustomBossEvents", NbtCompound()) // not sure 135 | put("ScheduledEvents", NbtList()) // not sure 136 | putInt("WanderingTraderSpawnDelay", 0) // not sure 137 | putInt("WanderingTraderSpawnChance", 0) // not sure 138 | 139 | // skip wandering trader id 140 | } 141 | 142 | private fun GameRules.genGameRules() = toNbt().apply { 143 | val setting = config.world.gameRules 144 | if (!setting.modifyGameRules) return@apply 145 | 146 | putString(GameRules.DO_WARDEN_SPAWNING.name, setting.doWardenSpawning.toString()) 147 | putString(GameRules.DO_FIRE_TICK.name, setting.doFireTick.toString()) 148 | putString(GameRules.DO_VINES_SPREAD.name, setting.doVinesSpread.toString()) 149 | putString(GameRules.DO_MOB_SPAWNING.name, setting.doMobSpawning.toString()) 150 | putString(GameRules.DO_DAYLIGHT_CYCLE.name, setting.doDaylightCycle.toString()) 151 | putString(GameRules.KEEP_INVENTORY.name, setting.keepInventory.toString()) 152 | putString(GameRules.DO_MOB_GRIEFING.name, setting.doMobGriefing.toString()) 153 | putString(GameRules.DO_TRADER_SPAWNING.name, setting.doTraderSpawning.toString()) 154 | putString(GameRules.DO_PATROL_SPAWNING.name, setting.doPatrolSpawning.toString()) 155 | putString(GameRules.DO_WEATHER_CYCLE.name, setting.doWeatherCycle.toString()) 156 | } 157 | 158 | private fun generatorMockNbt() = NbtCompound().apply { 159 | putByte("bonus_chest", config.world.worldGenerator.bonusChest.toByte()) 160 | putLong("seed", config.world.worldGenerator.seed) 161 | putByte("generate_features", config.world.worldGenerator.generateFeatures.toByte()) 162 | 163 | put("dimensions", NbtCompound().apply { 164 | CaptureManager.lastWorldKeys.forEach { key -> 165 | put("minecraft:${key.value.path}", NbtCompound().apply { 166 | put("generator", generateGenerator(key.value.path)) 167 | 168 | when (key.value.path) { 169 | "the_nether" -> { 170 | putString("type", "minecraft:the_nether") 171 | } 172 | "the_end" -> { 173 | putString("type", "minecraft:the_end") 174 | } 175 | else -> { 176 | putString("type", "minecraft:overworld") 177 | } 178 | } 179 | }) 180 | } 181 | }) 182 | } 183 | 184 | private fun generateGenerator(path: String) = NbtCompound().apply { 185 | when (config.world.worldGenerator.type) { 186 | GeneratorType.VOID -> voidGenerator() 187 | GeneratorType.DEFAULT -> defaultGenerator(path) 188 | GeneratorType.FLAT -> flatGenerator() 189 | } 190 | } 191 | 192 | private fun NbtCompound.voidGenerator() { 193 | put("settings", NbtCompound().apply { 194 | putByte("features", 1) 195 | putString("biome", "minecraft:the_void") 196 | put("layers", NbtList().apply { 197 | add(NbtCompound().apply { 198 | putString("block", "minecraft:air") 199 | putInt("height", 1) 200 | }) 201 | }) 202 | put("structure_overrides", NbtList()) 203 | putByte("lakes", 0) 204 | }) 205 | putString("type", "minecraft:flat") 206 | } 207 | 208 | private fun NbtCompound.defaultGenerator(path: String) { 209 | when (path) { 210 | "the_nether" -> { 211 | put("biome_source", NbtCompound().apply { 212 | putString("preset", "minecraft:nether") 213 | putString("type", "minecraft:multi_noise") 214 | }) 215 | putString("settings", "minecraft:nether") 216 | putString("type", "minecraft:noise") 217 | } 218 | "the_end" -> { 219 | put("biome_source", NbtCompound().apply { 220 | putString("type", "minecraft:the_end") 221 | }) 222 | putString("settings", "minecraft:end") 223 | putString("type", "minecraft:noise") 224 | } 225 | else -> { 226 | put("biome_source", NbtCompound().apply { 227 | putString("preset", "minecraft:overworld") 228 | putString("type", "minecraft:multi_noise") 229 | }) 230 | putString("settings", "minecraft:overworld") 231 | putString("type", "minecraft:noise") 232 | } 233 | } 234 | } 235 | 236 | private fun NbtCompound.flatGenerator() { 237 | put("settings", NbtCompound().apply { 238 | putString("biome", "minecraft:plains") 239 | putByte("features", 0) 240 | putByte("lakes", 0) 241 | put("layers", NbtList().apply { 242 | add(NbtCompound().apply { 243 | putString("block", "minecraft:bedrock") 244 | putInt("height", 1) 245 | }) 246 | add(NbtCompound().apply { 247 | putString("block", "minecraft:dirt") 248 | putInt("height", 2) 249 | }) 250 | add(NbtCompound().apply { 251 | putString("block", "minecraft:grass_block") 252 | putInt("height", 1) 253 | }) 254 | }) 255 | put("structure_overrides", NbtList().apply { 256 | add(NbtString.of("minecraft:strongholds")) 257 | add(NbtString.of("minecraft:villages")) 258 | }) 259 | }) 260 | putString("type", "minecraft:flat") 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/serializable/MapDataStoreable.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.serializable 2 | 3 | import net.minecraft.nbt.NbtCompound 4 | import net.minecraft.nbt.NbtHelper 5 | import net.minecraft.nbt.NbtIo 6 | import net.minecraft.text.MutableText 7 | import net.minecraft.util.WorldSavePath 8 | import net.minecraft.world.level.storage.LevelStorage 9 | import org.waste.of.time.WorldTools 10 | import org.waste.of.time.WorldTools.LOG 11 | import org.waste.of.time.WorldTools.config 12 | import org.waste.of.time.WorldTools.mc 13 | import org.waste.of.time.manager.CaptureManager 14 | import org.waste.of.time.manager.MessageManager 15 | import org.waste.of.time.storage.CustomRegionBasedStorage 16 | import org.waste.of.time.storage.Storeable 17 | import org.waste.of.time.storage.cache.HotCache 18 | import kotlin.io.path.exists 19 | 20 | class MapDataStoreable : Storeable() { 21 | override fun shouldStore() = config.general.capture.maps 22 | override val verboseInfo: MutableText 23 | get() = MessageManager.translateHighlight( 24 | "worldtools.capture.saved.mapData", 25 | CaptureManager.currentLevelName 26 | ) 27 | override val anonymizedInfo: MutableText 28 | get() = verboseInfo 29 | 30 | override fun store( 31 | session: LevelStorage.Session, 32 | cachedStorages: MutableMap 33 | ) { 34 | // this map doesn't seem to be cleared until the world closes 35 | val dataDirectory = session.getDirectory(WorldSavePath.ROOT).resolve("data") 36 | if (!dataDirectory.toFile().exists()) { 37 | dataDirectory.toFile().mkdirs() 38 | } 39 | 40 | mc.world?.let { world -> 41 | world.mapStates?.filter { (component, _) -> 42 | HotCache.mapIDs.contains(component.id) 43 | }?.forEach { (component, mapState) -> 44 | val id = component.id 45 | NbtCompound().apply { 46 | put("data", mapState.writeNbt(NbtCompound(), world.registryManager)) 47 | NbtHelper.putDataVersion(this) 48 | val mapFile = dataDirectory.resolve("map_$id${WorldTools.DAT_EXTENSION}") 49 | if (!mapFile.exists()) { 50 | mapFile.toFile().createNewFile() 51 | } 52 | NbtIo.writeCompressed(this, mapFile) 53 | if (config.debug.logSavedMaps) { 54 | LOG.info("Map data saved: $id") 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/serializable/MetadataStoreable.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.serializable 2 | 3 | import net.minecraft.client.network.PlayerListEntry 4 | import net.minecraft.text.MutableText 5 | import net.minecraft.util.PathUtil 6 | import net.minecraft.util.WorldSavePath 7 | import net.minecraft.world.level.storage.LevelStorage.Session 8 | import org.waste.of.time.Utils 9 | import org.waste.of.time.WorldTools.CREDIT_MESSAGE_MD 10 | import org.waste.of.time.WorldTools.LOG 11 | import org.waste.of.time.WorldTools.MOD_NAME 12 | import org.waste.of.time.WorldTools.config 13 | import org.waste.of.time.WorldTools.mc 14 | import org.waste.of.time.manager.BarManager 15 | import org.waste.of.time.manager.CaptureManager.currentLevelName 16 | import org.waste.of.time.manager.CaptureManager.levelName 17 | import org.waste.of.time.manager.MessageManager.translateHighlight 18 | import org.waste.of.time.storage.CustomRegionBasedStorage 19 | import org.waste.of.time.storage.PathTreeNode 20 | import org.waste.of.time.storage.StorageFlow 21 | import org.waste.of.time.storage.Storeable 22 | import java.net.InetSocketAddress 23 | import java.nio.file.Path 24 | import kotlin.io.path.writeBytes 25 | 26 | class MetadataStoreable : Storeable() { 27 | override fun shouldStore() = config.general.capture.metadata 28 | 29 | override val verboseInfo: MutableText 30 | get() = translateHighlight( 31 | "worldtools.capture.saved.metadata", 32 | currentLevelName 33 | ) 34 | 35 | override val anonymizedInfo: MutableText 36 | get() = verboseInfo 37 | 38 | override fun store(session: Session, cachedStorages: MutableMap) { 39 | session.writeIconFile() 40 | 41 | session.getDirectory(WorldSavePath.ROOT).resolve(MOD_NAME).apply { 42 | PathUtil.createDirectories(this) 43 | 44 | writePlayerEntryList() 45 | writeDimensionTree() 46 | writeMetadata() 47 | } 48 | } 49 | 50 | private fun Path.writeMetadata() { 51 | resolve("Capture Metadata.md") 52 | .toFile() 53 | .writeText(createMetadata()) 54 | 55 | LOG.info("Saved capture metadata.") 56 | } 57 | 58 | private fun Path.writePlayerEntryList() { 59 | if (mc.isInSingleplayer) return 60 | 61 | mc.networkHandler?.playerList?.let { playerList -> 62 | if (playerList.isEmpty()) return@let 63 | resolve("Player Entry List.csv").toFile() 64 | .writeText(createPlayerEntryList(playerList.toList())) 65 | LOG.info("Saved ${playerList.size} player entry list entries.") 66 | } 67 | } 68 | 69 | private fun Path.writeDimensionTree() { 70 | mc.networkHandler?.worldKeys?.let { keys -> 71 | if (keys.isEmpty()) return@let 72 | resolve("Dimension Tree.txt").toFile() 73 | .writeText(PathTreeNode.buildTree(keys.map { it.value.path })) 74 | LOG.info("Saved ${keys.size} dimensions in tree.") 75 | } 76 | } 77 | 78 | private fun Session.writeIconFile() { 79 | mc.networkHandler?.serverInfo?.favicon?.let { favicon -> 80 | iconFile.ifPresent { 81 | it.writeBytes(favicon) 82 | } 83 | } ?: mc.server?.iconFile?.ifPresent { spIconPath -> 84 | iconFile.ifPresent { 85 | it.writeBytes(spIconPath.toFile().readBytes()) 86 | } 87 | } 88 | LOG.info("Saved favicon.") 89 | } 90 | 91 | private fun createMetadata() = StringBuilder().apply { 92 | if (currentLevelName != levelName) { 93 | appendLine("# $currentLevelName ($levelName) World Save - Snapshot Details") 94 | } else { 95 | appendLine("# $currentLevelName World Save - Snapshot Details") 96 | } 97 | 98 | if (mc.isInSingleplayer) { 99 | appendLine("![World Icon](../icon.png)") 100 | } else { 101 | appendLine("![Server Icon](../icon.png)") 102 | } 103 | 104 | appendLine() 105 | appendLine("- **Time**: `${Utils.getTime()}` (Timestamp: `${System.currentTimeMillis()}`)") 106 | appendLine("- **Captured By**: `${mc.player?.name?.string}`") 107 | 108 | appendLine() 109 | 110 | mc.networkHandler?.serverInfo?.let { info -> 111 | appendLine("## Server") 112 | if (info.name != "Minecraft Server") { 113 | appendLine("- **List Entry Name**: `${info.name}`") 114 | } 115 | appendLine("- **IP**: `${info.address}`") 116 | if (info.playerCountLabel.string.isNotBlank()) { 117 | appendLine("- **Capacity**: `${info.playerCountLabel.string}`") 118 | } 119 | mc.networkHandler?.let { 120 | appendLine("- **Brand**: `${it.brand}`") 121 | } 122 | appendLine("- **MOTD**: `${info.label.string.split("\n").joinToString(" ")}`") 123 | appendLine("- **Version**: `${info.version.string}`") 124 | appendLine("- **Protocol Version**: `${info.protocolVersion}`") 125 | appendLine("- **Server Type**: `${info.serverType}`") 126 | 127 | info.players?.sample?.let l@ { sample -> 128 | if (sample.isEmpty()) return@l 129 | appendLine("- **Short Label**: `${sample.joinToString { it.name }}`") 130 | } 131 | info.playerListSummary?.let l@ { 132 | if (it.isEmpty()) return@l 133 | appendLine("- **Full Label**: `${it.joinToString(" ") { str -> str.string }}`") 134 | } 135 | 136 | appendLine() 137 | appendLine("## Connection") 138 | (mc.networkHandler?.connection?.address as? InetSocketAddress)?.let { 139 | appendLine("- **Host Name**: `${it.address.canonicalHostName}`") 140 | appendLine("- **Port**: `${it.port}`") 141 | } 142 | } ?: run { 143 | appendLine("## Singleplayer Capture") 144 | appendLine("- **Source World Name**: `${mc.server?.name}`") 145 | appendLine("- **Version**: `${mc.server?.version}`") 146 | } 147 | 148 | mc.networkHandler?.sessionId?.let { id -> 149 | appendLine("- **Session ID**: `$id`") 150 | } 151 | 152 | appendLine() 153 | appendLine(CREDIT_MESSAGE_MD) 154 | }.toString() 155 | 156 | private fun createPlayerEntryList(listEntries: List) = StringBuilder().apply { 157 | appendLine("Name, ID, Game Mode, Latency, Scoreboard Team, Model Type, Session ID, Public Key") 158 | 159 | listEntries.forEachIndexed { i, entry -> 160 | StorageFlow.lastStoredTimestamp = System.currentTimeMillis() 161 | BarManager.progressBar.percent = i.toFloat() / listEntries.size 162 | serializePlayerListEntry(entry) 163 | } 164 | }.toString() 165 | 166 | private fun StringBuilder.serializePlayerListEntry(entry: PlayerListEntry) { 167 | append("${entry.profile.name}, ") 168 | append("${entry.profile.id}, ") 169 | append("${entry.gameMode.name}, ") 170 | append("${entry.latency}, ") 171 | append("${entry.scoreboardTeam?.name}, ") 172 | appendLine(entry.skinTextures.model) 173 | entry.session?.let { 174 | append("${it.sessionId}, ") 175 | append("${it.publicKeyData?.data}, ") 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/serializable/PlayerStoreable.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.serializable 2 | 3 | import net.minecraft.entity.player.PlayerEntity 4 | import net.minecraft.nbt.NbtCompound 5 | import net.minecraft.nbt.NbtIo 6 | import net.minecraft.text.MutableText 7 | import net.minecraft.util.Util 8 | import net.minecraft.util.WorldSavePath 9 | import net.minecraft.world.level.storage.LevelStorage.Session 10 | import org.waste.of.time.Utils.asString 11 | import org.waste.of.time.WorldTools 12 | import org.waste.of.time.WorldTools.config 13 | import org.waste.of.time.manager.MessageManager.translateHighlight 14 | import org.waste.of.time.manager.StatisticManager 15 | import org.waste.of.time.storage.Cacheable 16 | import org.waste.of.time.storage.CustomRegionBasedStorage 17 | import org.waste.of.time.storage.Storeable 18 | import org.waste.of.time.storage.cache.HotCache 19 | import java.io.File 20 | import java.nio.file.Files 21 | import java.nio.file.Path 22 | 23 | data class PlayerStoreable( 24 | val player: PlayerEntity 25 | ) : Cacheable, Storeable() { 26 | override fun shouldStore() = config.general.capture.players 27 | 28 | override val verboseInfo: MutableText 29 | get() = translateHighlight( 30 | "worldtools.capture.saved.player", 31 | player.name, 32 | player.pos.asString(), 33 | player.world.registryKey.value.path 34 | ) 35 | 36 | override val anonymizedInfo: MutableText 37 | get() = translateHighlight( 38 | "worldtools.capture.saved.player.anonymized", 39 | player.name, 40 | player.world.registryKey.value.path 41 | ) 42 | 43 | override fun cache() { 44 | HotCache.players.add(this) 45 | } 46 | 47 | override fun flush() { 48 | HotCache.players.remove(this) 49 | } 50 | 51 | override fun store(session: Session, cachedStorages: MutableMap) { 52 | savePlayerData(player, session) 53 | session.createSaveHandler() 54 | StatisticManager.players++ 55 | StatisticManager.dimensions.add(player.world.registryKey.value.path) 56 | } 57 | 58 | private fun savePlayerData(player: PlayerEntity, session: Session) { 59 | try { 60 | val playerDataDir = session.getDirectory(WorldSavePath.PLAYERDATA).toFile() 61 | playerDataDir.mkdirs() 62 | 63 | val newPlayerFile = File.createTempFile(player.uuidAsString + "-", ".dat", playerDataDir).toPath() 64 | NbtIo.writeCompressed(player.writeNbt(NbtCompound()).apply { 65 | if (config.entity.censor.lastDeathLocation) { 66 | remove("LastDeathLocation") 67 | } 68 | }, newPlayerFile) 69 | val currentFile = File(playerDataDir, player.uuidAsString + ".dat").toPath() 70 | val backupFile = File(playerDataDir, player.uuidAsString + ".dat_old").toPath() 71 | Util.backupAndReplace(currentFile, newPlayerFile, backupFile) 72 | } catch (e: Exception) { 73 | WorldTools.LOG.warn("Failed to save player data for {}", player.name.string) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/serializable/RegionBasedChunk.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.serializable 2 | 3 | import net.minecraft.SharedConstants 4 | import net.minecraft.block.Block 5 | import net.minecraft.block.BlockState 6 | import net.minecraft.block.Blocks 7 | import net.minecraft.block.entity.BlockEntity 8 | import net.minecraft.fluid.Fluid 9 | import net.minecraft.nbt.NbtCompound 10 | import net.minecraft.nbt.NbtList 11 | import net.minecraft.nbt.NbtLongArray 12 | import net.minecraft.nbt.NbtOps 13 | import net.minecraft.registry.Registries 14 | import net.minecraft.registry.RegistryKeys 15 | import net.minecraft.text.MutableText 16 | import net.minecraft.util.math.BlockPos 17 | import net.minecraft.util.math.ChunkSectionPos 18 | import net.minecraft.world.LightType 19 | import net.minecraft.world.biome.BiomeKeys 20 | import net.minecraft.world.chunk.BelowZeroRetrogen 21 | import net.minecraft.world.chunk.PalettedContainer 22 | import net.minecraft.world.chunk.SerializedChunk 23 | import net.minecraft.world.chunk.WorldChunk 24 | import net.minecraft.world.gen.chunk.BlendingData 25 | import net.minecraft.world.level.storage.LevelStorage 26 | import org.waste.of.time.WorldTools.LOG 27 | import org.waste.of.time.WorldTools.TIMESTAMP_KEY 28 | import org.waste.of.time.WorldTools.config 29 | import org.waste.of.time.extension.IPalettedContainerExtension 30 | import org.waste.of.time.manager.MessageManager.translateHighlight 31 | import org.waste.of.time.manager.StatisticManager 32 | import org.waste.of.time.storage.Cacheable 33 | import org.waste.of.time.storage.CustomRegionBasedStorage 34 | import org.waste.of.time.storage.RegionBased 35 | import org.waste.of.time.storage.cache.HotCache 36 | 37 | open class RegionBasedChunk( 38 | val chunk: WorldChunk, 39 | ) : RegionBased(chunk.pos, chunk.world, "region"), Cacheable { 40 | // storing a reference to the block entities in the chunk to prevent them from being unloaded 41 | val cachedBlockEntities = mutableMapOf() 42 | 43 | init { 44 | cachedBlockEntities.putAll(chunk.blockEntities) 45 | 46 | cachedBlockEntities.values.associateWith { fresh -> 47 | HotCache.scannedBlockEntities[fresh.pos] 48 | }.forEach { (fresh, cached) -> 49 | if (cached == null) return@forEach 50 | cachedBlockEntities[fresh.pos] = cached 51 | } 52 | } 53 | 54 | override fun shouldStore() = config.general.capture.chunks 55 | 56 | override val verboseInfo: MutableText 57 | get() = translateHighlight( 58 | "worldtools.capture.saved.chunks", 59 | chunkPos, 60 | dimension 61 | ) 62 | 63 | override val anonymizedInfo: MutableText 64 | get() = translateHighlight( 65 | "worldtools.capture.saved.chunks.anonymized", 66 | dimension 67 | ) 68 | 69 | private val stateIdContainer = PalettedContainer.createPalettedContainerCodec( 70 | Block.STATE_IDS, 71 | BlockState.CODEC, 72 | PalettedContainer.PaletteProvider.BLOCK_STATE, 73 | Blocks.AIR.defaultState 74 | ) 75 | 76 | override fun cache() { 77 | HotCache.chunks[chunkPos] = this 78 | HotCache.savedChunks.add(chunkPos.toLong()) 79 | } 80 | 81 | override fun flush() { 82 | HotCache.chunks.remove(chunkPos) 83 | } 84 | 85 | override fun incrementStats() { 86 | StatisticManager.chunks++ 87 | StatisticManager.dimensions.add(dimension) 88 | } 89 | 90 | override fun writeToStorage( 91 | session: LevelStorage.Session, 92 | storage: CustomRegionBasedStorage, 93 | cachedStorages: MutableMap 94 | ) { 95 | // avoiding `emit` here due to flow order issues when capture is stopped 96 | // i.e., if EndFlow is emitted before this, 97 | // these are not written because they're behind it in the flow 98 | HotCache.getEntitySerializableForChunk(chunkPos, world) 99 | ?.store(session, cachedStorages) 100 | ?: run { 101 | // remove any previously stored entities in this chunk in case there are no entities to store 102 | RegionBasedEntities(chunkPos, emptySet(), world).store(session, cachedStorages) 103 | } 104 | if (chunk.isEmpty) return 105 | super.writeToStorage(session, storage, cachedStorages) 106 | } 107 | 108 | /** 109 | * See [net.minecraft.world.ChunkSerializer.serialize] 110 | */ 111 | override fun compound() = NbtCompound().apply { 112 | if (config.world.metadata.captureTimestamp) { 113 | putLong(TIMESTAMP_KEY, System.currentTimeMillis()) 114 | } 115 | 116 | putInt("DataVersion", SharedConstants.getGameVersion().saveVersion.id) 117 | putInt(SerializedChunk.X_POS_KEY, chunk.pos.x) 118 | putInt("yPos", chunk.bottomSectionCoord) 119 | putInt(SerializedChunk.Z_POS_KEY, chunk.pos.z) 120 | putLong("LastUpdate", chunk.world.time) 121 | putLong("InhabitedTime", chunk.inhabitedTime) 122 | putString("Status", Registries.CHUNK_STATUS.getId(chunk.status).toString()) 123 | 124 | genBackwardsCompat(chunk) 125 | 126 | if (!chunk.upgradeData.isDone) { 127 | put("UpgradeData", chunk.upgradeData.toNbt()) 128 | } 129 | 130 | put(SerializedChunk.SECTIONS_KEY, generateSections(chunk)) 131 | 132 | if (chunk.isLightOn) { 133 | putBoolean(SerializedChunk.IS_LIGHT_ON_KEY, true) 134 | } 135 | 136 | put("block_entities", NbtList().apply { 137 | upsertBlockEntities() 138 | }) 139 | 140 | getTickSchedulers(chunk) 141 | genPostProcessing(chunk) 142 | 143 | // skip structures 144 | if (config.debug.logSavedChunks) 145 | LOG.info("Chunk saved: $chunkPos ($dimension)") 146 | } 147 | 148 | private fun NbtList.upsertBlockEntities() { 149 | cachedBlockEntities.entries.map { (_, blockEntity) -> 150 | blockEntity.createNbtWithIdentifyingData(world.registryManager).apply { 151 | putBoolean("keepPacked", false) 152 | } 153 | }.apply { 154 | addAll(this) 155 | } 156 | } 157 | 158 | private fun generateSections(chunk: WorldChunk) = NbtList().apply { 159 | val biomeRegistry = chunk.world.registryManager.getOptional(RegistryKeys.BIOME).orElse(null) ?: return@apply 160 | val defaultValue = biomeRegistry.getOptional(BiomeKeys.PLAINS).orElse(null) ?: return@apply 161 | val biomeCodec = PalettedContainer.createReadableContainerCodec( 162 | biomeRegistry.indexedEntries, 163 | biomeRegistry.entryCodec, 164 | PalettedContainer.PaletteProvider.BIOME, 165 | defaultValue 166 | ) 167 | val lightingProvider = chunk.world.chunkManager.lightingProvider 168 | 169 | (lightingProvider.bottomY until lightingProvider.topY).forEach { y -> 170 | val sectionCoord = chunk.sectionCoordToIndex(y) 171 | val inSection = sectionCoord in (0 until chunk.sectionArray.size) 172 | val blockLightSection = 173 | lightingProvider[LightType.BLOCK].getLightSection(ChunkSectionPos.from(chunk.pos, y)) 174 | val skyLightSection = 175 | lightingProvider[LightType.SKY].getLightSection(ChunkSectionPos.from(chunk.pos, y)) 176 | 177 | if (!inSection && blockLightSection == null && skyLightSection == null) return@forEach 178 | 179 | add(NbtCompound().apply { 180 | if (inSection) { 181 | val chunkSection = chunk.sectionArray[sectionCoord] 182 | /** 183 | * Mods like Bobby may also try serializing chunk data concurrently on separate threads 184 | * PalettedContainer contains a lock that is acquired during read/write operations 185 | * 186 | * Force disabling checking the lock's status here as it should be safe to 187 | * read here, no write operations should happen after the chunk is unloaded 188 | */ 189 | (chunkSection.blockStateContainer as IPalettedContainerExtension).setWTIgnoreLock(true) 190 | (chunkSection.biomeContainer as IPalettedContainerExtension).setWTIgnoreLock(true) 191 | put( 192 | "block_states", 193 | stateIdContainer.encodeStart(NbtOps.INSTANCE, chunkSection.blockStateContainer).getOrThrow() 194 | ) 195 | put( 196 | "biomes", 197 | biomeCodec.encodeStart(NbtOps.INSTANCE, chunkSection.biomeContainer).getOrThrow() 198 | ) 199 | (chunkSection.blockStateContainer as IPalettedContainerExtension).setWTIgnoreLock(false) 200 | (chunkSection.biomeContainer as IPalettedContainerExtension).setWTIgnoreLock(false) 201 | } 202 | if (blockLightSection != null && !blockLightSection.isUninitialized) { 203 | putByteArray(SerializedChunk.BLOCK_LIGHT_KEY, blockLightSection.asByteArray()) 204 | } 205 | if (skyLightSection != null && !skyLightSection.isUninitialized) { 206 | putByteArray(SerializedChunk.SKY_LIGHT_KEY, skyLightSection.asByteArray()) 207 | } 208 | if (isEmpty) return@forEach 209 | putByte("Y", y.toByte()) 210 | }) 211 | } 212 | } 213 | 214 | private fun NbtCompound.genBackwardsCompat(chunk: WorldChunk) { 215 | chunk.blendingData?.let { bleedingData -> 216 | BlendingData.Serialized.CODEC.encodeStart(NbtOps.INSTANCE, bleedingData.toSerialized()).resultOrPartial { 217 | LOG.error(it) 218 | }.ifPresent { 219 | put("blending_data", it) 220 | } 221 | } 222 | 223 | chunk.belowZeroRetrogen?.let { belowZeroRetrogen -> 224 | BelowZeroRetrogen.CODEC.encodeStart(NbtOps.INSTANCE, belowZeroRetrogen).resultOrPartial { 225 | LOG.error(it) 226 | }.ifPresent { 227 | put("below_zero_retrogen", it) 228 | } 229 | } 230 | } 231 | 232 | private fun NbtCompound.getTickSchedulers(chunk: WorldChunk) { 233 | val time = chunk.world.levelProperties.time 234 | val tickSchedulers = chunk.getTickSchedulers(time) 235 | 236 | val blockTickSchedulers = tickSchedulers.blocks.map { ticker -> 237 | ticker.toNbt { Registries.BLOCK.getId(it).toString()} 238 | } 239 | put("block_ticks", NbtList().apply { addAll(blockTickSchedulers) }) 240 | val fluidTickSchedulers = tickSchedulers.fluids.map { ticker -> 241 | ticker.toNbt { Registries.FLUID.getId(it).toString()} 242 | } 243 | put("fluid_ticks", NbtList().apply { addAll(fluidTickSchedulers) }) 244 | } 245 | 246 | private fun NbtCompound.genPostProcessing(chunk: WorldChunk) { 247 | put("PostProcessing", SerializedChunk.toNbt(chunk.postProcessingLists)) 248 | 249 | put(SerializedChunk.HEIGHTMAPS_KEY, NbtCompound().apply { 250 | chunk.heightmaps.filter { 251 | chunk.status.heightmapTypes.contains(it.key) 252 | }.forEach { (key, value) -> 253 | put(key.getName(), NbtLongArray(value.asLongArray())) 254 | } 255 | }) 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/serializable/RegionBasedEntities.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.serializable 2 | 3 | import net.minecraft.SharedConstants 4 | import net.minecraft.nbt.NbtCompound 5 | import net.minecraft.nbt.NbtIntArray 6 | import net.minecraft.nbt.NbtList 7 | import net.minecraft.text.MutableText 8 | import net.minecraft.util.math.ChunkPos 9 | import net.minecraft.world.World 10 | import net.minecraft.world.level.storage.LevelStorage 11 | import org.waste.of.time.WorldTools.LOG 12 | import org.waste.of.time.WorldTools.config 13 | import org.waste.of.time.manager.MessageManager.translateHighlight 14 | import org.waste.of.time.manager.StatisticManager 15 | import org.waste.of.time.manager.StatisticManager.joinWithAnd 16 | import org.waste.of.time.storage.CustomRegionBasedStorage 17 | import org.waste.of.time.storage.RegionBased 18 | import org.waste.of.time.storage.cache.EntityCacheable 19 | 20 | class RegionBasedEntities( 21 | chunkPos: ChunkPos, 22 | val entities: Set, // can be empty, signifies we should clear any previously saved entities 23 | world: World 24 | ) : RegionBased(chunkPos, world, "entities") { 25 | override fun shouldStore() = config.general.capture.entities 26 | 27 | override val verboseInfo: MutableText 28 | get() = translateHighlight( 29 | "worldtools.capture.saved.entities", 30 | stackEntities(), 31 | chunkPos, 32 | dimension 33 | ) 34 | 35 | override val anonymizedInfo: MutableText 36 | get() = translateHighlight( 37 | "worldtools.capture.saved.entities.anonymized", 38 | stackEntities(), 39 | dimension 40 | ) 41 | 42 | override fun compound() = NbtCompound().apply { 43 | put("Entities", NbtList().apply { 44 | entities.forEach { entity -> 45 | add(entity.compound()) 46 | } 47 | }) 48 | 49 | putInt("DataVersion", SharedConstants.getGameVersion().saveVersion.id) 50 | put("Position", NbtIntArray(intArrayOf(chunkPos.x, chunkPos.z))) 51 | if (config.debug.logSavedEntities) { 52 | entities.forEach { entity -> LOG.info("Entity saved: $entity (Chunk: $chunkPos)") } 53 | } 54 | } 55 | 56 | override fun writeToStorage( 57 | session: LevelStorage.Session, 58 | storage: CustomRegionBasedStorage, 59 | cachedStorages: MutableMap 60 | ) { 61 | if (entities.isEmpty()) { 62 | // remove any previously stored entities in this chunk 63 | if (config.debug.logSavedEntities) { 64 | LOG.info("Removing any previously saved entities from chunk: {}", chunkPos) 65 | } 66 | storage.write(chunkPos, null) 67 | return 68 | } 69 | super.writeToStorage(session, storage, cachedStorages) 70 | } 71 | 72 | override fun incrementStats() { 73 | // todo: entities count becomes completely wrong when entities are removed or are loaded twice during the capture 74 | // i.e. the player moves away, unloads them, and then comes back to load them again 75 | StatisticManager.entities += entities.size 76 | StatisticManager.dimensions.add(dimension) 77 | } 78 | 79 | private fun stackEntities() = entities.groupBy { 80 | it.entity.name 81 | }.map { 82 | val count = if (it.value.size > 1) { 83 | " (${it.value.size})" 84 | } else "" 85 | it.key.copy().append(count) 86 | }.joinWithAnd() 87 | } 88 | -------------------------------------------------------------------------------- /common/src/main/kotlin/org/waste/of/time/storage/serializable/StatisticStoreable.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.storage.serializable 2 | 3 | import com.google.gson.JsonObject 4 | import net.minecraft.registry.Registries 5 | import net.minecraft.text.MutableText 6 | import net.minecraft.util.PathUtil 7 | import net.minecraft.util.WorldSavePath 8 | import net.minecraft.world.level.storage.LevelStorage 9 | import org.waste.of.time.manager.MessageManager.translateHighlight 10 | import org.waste.of.time.WorldTools 11 | import org.waste.of.time.WorldTools.CURRENT_VERSION 12 | import org.waste.of.time.WorldTools.GSON 13 | import org.waste.of.time.WorldTools.config 14 | import org.waste.of.time.WorldTools.mc 15 | import org.waste.of.time.storage.Storeable 16 | import org.waste.of.time.storage.CustomRegionBasedStorage 17 | import java.nio.charset.StandardCharsets 18 | import java.nio.file.Files 19 | 20 | class StatisticStoreable : Storeable() { 21 | override fun shouldStore() = config.general.capture.statistics 22 | 23 | override val verboseInfo: MutableText 24 | get() = translateHighlight( 25 | "worldtools.capture.saved.statistics", 26 | mc.player?.name ?: "Unknown" 27 | ) 28 | 29 | override val anonymizedInfo: MutableText 30 | get() = verboseInfo 31 | 32 | override fun store(session: LevelStorage.Session, cachedStorages: MutableMap) { 33 | // we need to get the stat map from the player's stat handler instead of the packet because the packet only 34 | // contains the stats that have changed since the last time the packet was sent 35 | val completeStatMap = mc.player?.statHandler?.statMap?.toMap() ?: return 36 | val uuid = mc.player?.uuid ?: return 37 | val statDirectory = session.getDirectory(WorldSavePath.STATS) 38 | 39 | val json = JsonObject().apply { 40 | addProperty("Author", WorldTools.CREDIT_MESSAGE) 41 | add("stats", JsonObject().apply { 42 | completeStatMap.entries.groupBy { it.key.type }.forEach { (type, entries) -> 43 | val typeObject = JsonObject() 44 | entries.forEach { (stat, value) -> 45 | stat.name.split(":").getOrNull(1)?.replace('.', ':')?.let { 46 | typeObject.addProperty(it, value) 47 | } 48 | } 49 | add(Registries.STAT_TYPE.getId(type).toString(), typeObject) 50 | } 51 | }) 52 | addProperty("DataVersion", CURRENT_VERSION) 53 | } 54 | 55 | PathUtil.createDirectories(statDirectory) 56 | Files.newBufferedWriter( 57 | statDirectory.resolve("$uuid.json"), 58 | StandardCharsets.UTF_8 59 | ).use { writer -> 60 | GSON.toJson(json, writer) 61 | } 62 | 63 | WorldTools.LOG.info("Saved ${completeStatMap.entries.size} stats.") 64 | } 65 | } -------------------------------------------------------------------------------- /common/src/main/resources/assets/worldtools/WorldTools.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Avanatiker/WorldTools/f82b7415c5a5fab619cc8f537f36defdea09c546/common/src/main/resources/assets/worldtools/WorldTools.png -------------------------------------------------------------------------------- /common/src/main/resources/assets/worldtools/lang/en_pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "text.autoconfig.worldtools.category.Advanced": "Ol’ Coat", 3 | "text.autoconfig.worldtools.category.Debug": "Fixin' the Sails", 4 | "text.autoconfig.worldtools.category.Entity": "Sharks", 5 | "text.autoconfig.worldtools.category.General": "On Deck", 6 | "text.autoconfig.worldtools.category.Render": "Yo Ho!", 7 | "text.autoconfig.worldtools.category.World": "Sea", 8 | "text.autoconfig.worldtools.option.advanced": "Ol’ Coat", 9 | "text.autoconfig.worldtools.option.advanced.anonymousMode": "Hide yo booty!", 10 | "text.autoconfig.worldtools.option.advanced.anonymousMode.@Tooltip": "Rid leakage o' yer ship. Ye Be Warned: This does not affect the actual data!", 11 | "text.autoconfig.worldtools.option.advanced.hideExperimentalWorldGui": "Hide Exploratory Sea GUI", 12 | "text.autoconfig.worldtools.option.advanced.hideExperimentalWorldGui.@Tooltip": "Lonely pirates be no longer warned.", 13 | "text.autoconfig.worldtools.option.advanced.keepEnderChestContents": "Keep ye ol’ Ender Chest Contents between Captures", 14 | "text.autoconfig.worldtools.option.advanced.showChatMessages": "Hidden Banter", 15 | "text.autoconfig.worldtools.option.advanced.showToasts": "Show yer Toast", 16 | "text.autoconfig.worldtools.option.debug": "Repairs", 17 | "text.autoconfig.worldtools.option.debug.@PrefixText": "Enabling repairs options will make it easier to find issues, using the log file located at .minecraft/logs/latest.log", 18 | "text.autoconfig.worldtools.option.debug.logSavedChunks": "Map ye Waves", 19 | "text.autoconfig.worldtools.option.debug.logSavedContainers": "Map ye Treasure Chests", 20 | "text.autoconfig.worldtools.option.debug.logSavedEntities": "Map ye Saved Sharks", 21 | "text.autoconfig.worldtools.option.debug.logSavedMaps": "Map the Maps", 22 | "text.autoconfig.worldtools.option.debug.logSettings": "Map Scribble", 23 | "text.autoconfig.worldtools.option.debug.logZippingProgress": "Map Booty Locking Progress", 24 | "text.autoconfig.worldtools.option.entity": "Sharks", 25 | "text.autoconfig.worldtools.option.entity.behavior": "Shark Behavior", 26 | "text.autoconfig.worldtools.option.entity.behavior.invulnerable": "Dead Men Tell Tales", 27 | "text.autoconfig.worldtools.option.entity.behavior.modifyEntityBehavior": "Modify Shark Behavior", 28 | "text.autoconfig.worldtools.option.entity.behavior.modifyEntityBehavior.@Tooltip": "When enabled, the enabled tags will be added t’ the NBT. Otherwise, the shark behavior will be left to the fishes.", 29 | "text.autoconfig.worldtools.option.entity.behavior.noAI": "Shark be without mind", 30 | "text.autoconfig.worldtools.option.entity.behavior.noGravity": "FLOATING SHARKS", 31 | "text.autoconfig.worldtools.option.entity.behavior.silent": "Belay yer mouth", 32 | "text.autoconfig.worldtools.option.entity.censor": "Belay", 33 | "text.autoconfig.worldtools.option.entity.censor.censorNames": "Belay Names", 34 | "text.autoconfig.worldtools.option.entity.censor.lastDeathLocation": "Belay last death location", 35 | "text.autoconfig.worldtools.option.entity.censor.lastDeathLocation.@Tooltip": "When enabled, the last death location o’ the player will not be saved t’ disk. Prevents leaking of coordinates.", 36 | "text.autoconfig.worldtools.option.entity.censor.names": "Belay Shark Name", 37 | "text.autoconfig.worldtools.option.entity.censor.owner": "Belay Shark Owner", 38 | "text.autoconfig.worldtools.option.entity.metadata": "Maps of maps", 39 | "text.autoconfig.worldtools.option.entity.metadata.captureTimestamp": "Note the sun", 40 | "text.autoconfig.worldtools.option.entity.metadata.waterMark": "Raise the Sails!", 41 | "text.autoconfig.worldtools.option.general.autoDownload": "Automatically map as ye start ye voyage", 42 | "text.autoconfig.worldtools.option.general.autoDownload.@Tooltip": "As ye embark on the sea, a capture’ll commence", 43 | "text.autoconfig.worldtools.option.general.capture": "What be mapped", 44 | "text.autoconfig.worldtools.option.general.capture.@Tooltip": "When be disabled, the corresponding data will not be saved t’ disk. This could result in incomplete captures!", 45 | "text.autoconfig.worldtools.option.general.capture.advancements": "Accomplishments", 46 | "text.autoconfig.worldtools.option.general.capture.chunks": "Waves", 47 | "text.autoconfig.worldtools.option.general.capture.entities": "Sharks", 48 | "text.autoconfig.worldtools.option.general.capture.levelData": "Level.dat", 49 | "text.autoconfig.worldtools.option.general.capture.maps": "Ye ol' Maps", 50 | "text.autoconfig.worldtools.option.general.capture.metadata": "Maps of maps", 51 | "text.autoconfig.worldtools.option.general.capture.players": "Pirates", 52 | "text.autoconfig.worldtools.option.general.capture.statistics": "Ship’s manifest", 53 | "text.autoconfig.worldtools.option.general.compressLevel": "Store capture in Armory", 54 | "text.autoconfig.worldtools.option.general.compressLevel.@Tooltip": "When a capture be at thar end, it will be archived in ye armory", 55 | "text.autoconfig.worldtools.option.render": "Yo Ho!", 56 | "text.autoconfig.worldtools.option.render.accentColor": "Accent Color (AaaaarGB Hex)", 57 | "text.autoconfig.worldtools.option.render.captureBarColor": "Capture Info Bar Color (AaaaarGB Hex)", 58 | "text.autoconfig.worldtools.option.render.captureBarStyle": "Capture Info B’aaaaar Style", 59 | "text.autoconfig.worldtools.option.render.containerColor": "Missing Treasure Chest Color (AaaaarGB Hex)", 60 | "text.autoconfig.worldtools.option.render.progressBarColor": "Progress B’aaaaar Color", 61 | "text.autoconfig.worldtools.option.render.progressBarStyle": "Progress B’aaaaar Style", 62 | "text.autoconfig.worldtools.option.render.progressBarTimeout": "Progress B’aaaaar Timeout (ms)", 63 | "text.autoconfig.worldtools.option.render.renderNotYetCachedContainers": "Treasure unchecked", 64 | "text.autoconfig.worldtools.option.world.censor": "Belay", 65 | "text.autoconfig.worldtools.option.world.gameRules": "There be Rules", 66 | "text.autoconfig.worldtools.option.world.gameRules.doDaylightCycle": "Day by Day", 67 | "text.autoconfig.worldtools.option.world.gameRules.doFireTick": "Fire on the Ship", 68 | "text.autoconfig.worldtools.option.world.gameRules.doMobGriefing": "Mob Piracy", 69 | "text.autoconfig.worldtools.option.world.gameRules.doMobSpawning": "Mob’s Aaaaar Here", 70 | "text.autoconfig.worldtools.option.world.gameRules.doPatrolSpawning": "Patrol’s Aaaaar Here", 71 | "text.autoconfig.worldtools.option.world.gameRules.doTraderSpawning": "Trader Aaaaar Here", 72 | "text.autoconfig.worldtools.option.world.gameRules.doVinesSpread": "Barnacle Spread", 73 | "text.autoconfig.worldtools.option.world.gameRules.doWardenSpawning": "Kraken Spawning", 74 | "text.autoconfig.worldtools.option.world.gameRules.doWeatherCycle": "Storms n Clear", 75 | "text.autoconfig.worldtools.option.world.gameRules.keepInventory": "Keep ye Loot", 76 | "text.autoconfig.worldtools.option.world.gameRules.modifyGameRules": "Modify Ship Rules", 77 | "text.autoconfig.worldtools.option.world.gameRules.modifyGameRules.@Tooltip": "When enabled, ye ship rules will be changed to the values set in this config. Otherwise, the ship rules will be left untouched.", 78 | "text.autoconfig.worldtools.option.world.metadata": "Maps of maps", 79 | "text.autoconfig.worldtools.option.world.metadata.captureTimestamp": "Note Timestamp o’ Capture", 80 | "text.autoconfig.worldtools.option.world.metadata.waterMark": "Watermark Waves", 81 | "text.autoconfig.worldtools.option.world.worldGenerator": "Sea Generator", 82 | "text.autoconfig.worldtools.option.world.worldGenerator.bonusChest": "Bonus Booty", 83 | "text.autoconfig.worldtools.option.world.worldGenerator.generateFeatures": "Generate ye Features", 84 | "text.autoconfig.worldtools.option.world.worldGenerator.seed": "Sea Chart", 85 | "text.autoconfig.worldtools.option.world.worldGenerator.type": "Sea Generate Type", 86 | "text.autoconfig.worldtools.option.world.worldGenerator.type.@Tooltip": "For inference: VOID: Will generate an empty sea. FLAT: Will generate a flat sea. DEFAULT: Will generate a normal sea.", 87 | "text.autoconfig.worldtools.title": "SeaTools Config", 88 | "worldtools.capture.and": " n’ ", 89 | "worldtools.capture.chunk": "%s wave", 90 | "worldtools.capture.chunks": "%s waves", 91 | "worldtools.capture.click_to_open": "%s (click t’ open)", 92 | "worldtools.capture.container": "%s Treasure Chests", 93 | "worldtools.capture.containers": "%s Treasure Chests", 94 | "worldtools.capture.entities": "%s sharks", 95 | "worldtools.capture.entity": "%s shark", 96 | "worldtools.capture.in_dimension": " in width n’ length ", 97 | "worldtools.capture.nothing_saved_yet": "Nothing mapped yet for %s", 98 | "worldtools.capture.player": "%s pirates", 99 | "worldtools.capture.players": "%s pirates", 100 | "worldtools.capture.saved": "Mapped ", 101 | "worldtools.capture.saved.advancements": "Saved Accomplishments o’ %s", 102 | "worldtools.capture.saved.chunks": "Saved wave at %s in dimension %s", 103 | "worldtools.capture.saved.chunks.anonymized": "Saved wave in dimension %s", 104 | "worldtools.capture.saved.compressed": "Saved Booty Lock o’ capture as %s", 105 | "worldtools.capture.saved.end_flow": "Finished capture o’ %s", 106 | "worldtools.capture.saved.entities": "Saved %s in wave %s in ye dimension %s", 107 | "worldtools.capture.saved.entities.anonymized": "Saved %s in ye dimension %s", 108 | "worldtools.capture.saved.levelData": "Saved %s o’ %s", 109 | "worldtools.capture.saved.mapData": "Saved maps in ye dimension %s", 110 | "worldtools.capture.saved.metadata": "Saved maps of maps o’ %s", 111 | "worldtools.capture.saved.player": "Saved pirate %s at %s in dimension %s", 112 | "worldtools.capture.saved.player.anonymized": "Saved pirate %s in dimension %s", 113 | "worldtools.capture.saved.statistics": "Saved Ship’s manifest o’ %s", 114 | "worldtools.capture.to_directory": " t’ saves log ", 115 | "worldtools.capture.took": " (plundered %s)", 116 | "worldtools.gui.browser.title": "SeaTools Browser", 117 | "worldtools.gui.capture.existing_world_confirm.message": "Ye already made: \"%s\". Write over it? (Will merge with existing data)", 118 | "worldtools.gui.capture.existing_world_confirm.title": "Already ventured that Sea", 119 | "worldtools.gui.escape.button.finish_download": "Save capture o’ %s", 120 | "worldtools.gui.manager.button.cancel": "Avast", 121 | "worldtools.gui.manager.button.config": "Scribble", 122 | "worldtools.gui.manager.button.start_download": "Start the mappin’ ", 123 | "worldtools.gui.manager.button.stop_download": "Stop the mappin’ ", 124 | "worldtools.gui.manager.title": "SeaTools Manager", 125 | "worldtools.gui.manager.world_name_placeholder": "Enter sea name... (default: %s)", 126 | "worldtools.key.categories": "SeaTools", 127 | "worldtools.key.open_config": "Open Scribble", 128 | "worldtools.key.toggle_capture": "Quick mappin’", 129 | "worldtools.log.error.already_capturing": "A capture for %s has already set sail!", 130 | "worldtools.log.error.failed_to_create_session": "Failed t’ create session for \"%s\"! (error: %s)", 131 | "worldtools.log.error.failed_to_save_level": "Failed t’ save level.dat for \"%s\"! (error: %s)", 132 | "worldtools.log.error.failed_to_visit_file": "Failed accessing file %s t’ create a Booty Lock! (error: %s)", 133 | "worldtools.log.error.failed_to_zip": "Failed t’ create session t’ Booty Lock capture o’ %s! (error: %s)", 134 | "worldtools.log.error.not_capturing": "No capture be running yet!", 135 | "worldtools.log.error.world_name_too_long": "Sea name \"%s\" is too long! (max %d characters)", 136 | "worldtools.log.info.singleplayer_capture": "EXPLORATORY SHIPS: Capturing in single-pirate is experimental and may not work as expected!", 137 | "worldtools.log.info.started_capture": "Started mappin’ %s...", 138 | "worldtools.log.info.stopping_capture": "Stopping mappin’ %s..." 139 | } 140 | -------------------------------------------------------------------------------- /common/src/main/resources/assets/worldtools/lang/zh_cn.json: -------------------------------------------------------------------------------- 1 | { 2 | "text.autoconfig.worldtools.category.Advanced": "高级", 3 | "text.autoconfig.worldtools.category.Debug": "调试", 4 | "text.autoconfig.worldtools.category.Entity": "实体", 5 | "text.autoconfig.worldtools.category.General": "通用", 6 | "text.autoconfig.worldtools.category.Render": "渲染", 7 | "text.autoconfig.worldtools.category.World": "世界", 8 | "text.autoconfig.worldtools.option.advanced": "高级", 9 | "text.autoconfig.worldtools.option.advanced.anonymousMode": "在界面中隐藏坐标", 10 | "text.autoconfig.worldtools.option.advanced.anonymousMode.@Tooltip": "启用后,捕获的坐标将在界面中隐藏。注意:这不会影响实际数据!", 11 | "text.autoconfig.worldtools.option.advanced.hideExperimentalWorldGui": "隐藏实验性世界GUI", 12 | "text.autoconfig.worldtools.option.advanced.hideExperimentalWorldGui.@Tooltip": "启用后,在单人游戏中加载世界后烦人的弹出窗口将被隐藏。", 13 | "text.autoconfig.worldtools.option.advanced.keepEnderChestContents": "在捕获之间保留末影箱内容", 14 | "text.autoconfig.worldtools.option.advanced.showChatMessages": "在聊天栏中显示信息", 15 | "text.autoconfig.worldtools.option.advanced.showToasts": "展示Toasts", 16 | "text.autoconfig.worldtools.option.debug": "调试", 17 | "text.autoconfig.worldtools.option.debug.@PrefixText": "启用调试选项将使日志文件更易找到问题", 18 | "text.autoconfig.worldtools.option.debug.logSavedChunks": "记录保存的区块", 19 | "text.autoconfig.worldtools.option.debug.logSavedContainers": "记录保存的容器", 20 | "text.autoconfig.worldtools.option.debug.logSavedEntities": "记录保存的实体", 21 | "text.autoconfig.worldtools.option.debug.logSavedMaps": "记录保存的地图", 22 | "text.autoconfig.worldtools.option.debug.logSettings": "记录设置", 23 | "text.autoconfig.worldtools.option.debug.logZippingProgress": "记录压缩进度", 24 | "text.autoconfig.worldtools.option.entity": "实体", 25 | "text.autoconfig.worldtools.option.entity.behavior": "行为", 26 | "text.autoconfig.worldtools.option.entity.behavior.invulnerable": "无敌", 27 | "text.autoconfig.worldtools.option.entity.behavior.modifyEntityBehavior": "修改实体行为", 28 | "text.autoconfig.worldtools.option.entity.behavior.modifyEntityBehavior.@Tooltip": "启用后,启用的标签将添加到 NBT。否则,实体行为将保持不变。", 29 | "text.autoconfig.worldtools.option.entity.behavior.noAI": "无 AI", 30 | "text.autoconfig.worldtools.option.entity.behavior.noGravity": "无重力", 31 | "text.autoconfig.worldtools.option.entity.behavior.silent": "无声音", 32 | "text.autoconfig.worldtools.option.entity.censor": "隐藏", 33 | "text.autoconfig.worldtools.option.entity.censor.censorNames": "隐藏名称", 34 | "text.autoconfig.worldtools.option.entity.censor.lastDeathLocation": "隐藏最后死亡坐标", 35 | "text.autoconfig.worldtools.option.entity.censor.lastDeathLocation.@Tooltip": "启用后,玩家的最后死亡位置将不会保存到磁盘上。防止坐标泄露。", 36 | "text.autoconfig.worldtools.option.entity.censor.names": "隐藏实体名称", 37 | "text.autoconfig.worldtools.option.entity.censor.owner": "隐藏实体主人", 38 | "text.autoconfig.worldtools.option.entity.metadata": "元数据", 39 | "text.autoconfig.worldtools.option.entity.metadata.captureTimestamp": "捕获时间戳", 40 | "text.autoconfig.worldtools.option.entity.metadata.waterMark": "水印", 41 | "text.autoconfig.worldtools.option.general.autoDownload": "在加入游戏时自动开始捕获", 42 | "text.autoconfig.worldtools.option.general.autoDownload.@Tooltip": "当你加入一个世界,就开始捕获", 43 | "text.autoconfig.worldtools.option.general.capture": "什么应该被捕获", 44 | "text.autoconfig.worldtools.option.general.capture.@Tooltip": "禁用后,相应的数据将不会保存到本地。这可能会导致捕获不完整!", 45 | "text.autoconfig.worldtools.option.general.capture.advancements": "成就", 46 | "text.autoconfig.worldtools.option.general.capture.chunks": "区块", 47 | "text.autoconfig.worldtools.option.general.capture.entities": "实体", 48 | "text.autoconfig.worldtools.option.general.capture.levelData": "Level.dat", 49 | "text.autoconfig.worldtools.option.general.capture.maps": "地图", 50 | "text.autoconfig.worldtools.option.general.capture.metadata": "元数据", 51 | "text.autoconfig.worldtools.option.general.capture.players": "玩家", 52 | "text.autoconfig.worldtools.option.general.capture.statistics": "统计信息", 53 | "text.autoconfig.worldtools.option.general.compressLevel": "将捕获存档保存为ZIP压缩文件", 54 | "text.autoconfig.worldtools.option.general.compressLevel.@Tooltip": "捕获完成后,它将作为ZIP压缩文件存档", 55 | "text.autoconfig.worldtools.option.render": "渲染", 56 | "text.autoconfig.worldtools.option.render.accentColor": "主题色(RGB 十六进制)", 57 | "text.autoconfig.worldtools.option.render.captureBarColor": "捕获信息栏颜色(RGB 十六进制)", 58 | "text.autoconfig.worldtools.option.render.captureBarStyle": "捕获信息栏样式", 59 | "text.autoconfig.worldtools.option.render.unscannedContainerColor": "缺失容器颜色(RGB 十六进制)", 60 | "text.autoconfig.worldtools.option.render.fromCacheLoadedContainerColor": "从缓存加载的容器颜色(RGB 十六进制)", 61 | "text.autoconfig.worldtools.option.render.unscannedEntityColor": "缺失实体颜色(RGB 十六进制)", 62 | "text.autoconfig.worldtools.option.render.fromCacheLoadedEntityColor": "从缓存加载的实体颜色(RGB 十六进制)", 63 | "text.autoconfig.worldtools.option.render.progressBarColor": "进度条颜色", 64 | "text.autoconfig.worldtools.option.render.progressBarStyle": "进度条样式", 65 | "text.autoconfig.worldtools.option.render.progressBarTimeout": "进度条耗时(毫秒)", 66 | "text.autoconfig.worldtools.option.render.renderNotYetCachedContainers": "呈现尚未缓存的容器", 67 | "text.autoconfig.worldtools.option.world.censor": "隐藏", 68 | "text.autoconfig.worldtools.option.world.gameRules": "游戏规则", 69 | "text.autoconfig.worldtools.option.world.gameRules.doDaylightCycle": "时间流逝", 70 | "text.autoconfig.worldtools.option.world.gameRules.doFireTick": "火势蔓延", 71 | "text.autoconfig.worldtools.option.world.gameRules.doMobGriefing": "生物破坏", 72 | "text.autoconfig.worldtools.option.world.gameRules.doMobSpawning": "生物生成", 73 | "text.autoconfig.worldtools.option.world.gameRules.doPatrolSpawning": "灾厄巡逻队生成", 74 | "text.autoconfig.worldtools.option.world.gameRules.doTraderSpawning": "流浪商人生成", 75 | "text.autoconfig.worldtools.option.world.gameRules.doVinesSpread": "藤蔓生长", 76 | "text.autoconfig.worldtools.option.world.gameRules.doWardenSpawning": "撅首者生成", 77 | "text.autoconfig.worldtools.option.world.gameRules.doWeatherCycle": "天气更替", 78 | "text.autoconfig.worldtools.option.world.gameRules.keepInventory": "保留物品栏", 79 | "text.autoconfig.worldtools.option.world.gameRules.modifyGameRules": "修改游戏规则", 80 | "text.autoconfig.worldtools.option.world.gameRules.modifyGameRules.@Tooltip": "启用后,游戏规则将修改为此配置中设置的值。否则,游戏规则将保持不变。", 81 | "text.autoconfig.worldtools.option.world.metadata": "元数据", 82 | "text.autoconfig.worldtools.option.world.metadata.captureTimestamp": "存储捕获的时间戳", 83 | "text.autoconfig.worldtools.option.world.metadata.waterMark": "区块水印", 84 | "text.autoconfig.worldtools.option.world.worldGenerator": "世界生成", 85 | "text.autoconfig.worldtools.option.world.worldGenerator.bonusChest": "奖励箱", 86 | "text.autoconfig.worldtools.option.world.worldGenerator.generateFeatures": "生成结构", 87 | "text.autoconfig.worldtools.option.world.worldGenerator.seed": "种子", 88 | "text.autoconfig.worldtools.option.world.worldGenerator.type": "世界生成类型", 89 | "text.autoconfig.worldtools.option.world.worldGenerator.type.@Tooltip": "VOID: 将生成一个空世界。 FLAT: 将生成一个超平坦世界 DEFAULT: 生成一个正常的世界。", 90 | "text.autoconfig.worldtools.title": "WorldTools 配置", 91 | "worldtools.capture.and": " 和 ", 92 | "worldtools.capture.chunk": "%s 区块", 93 | "worldtools.capture.chunks": "%s 区块", 94 | "worldtools.capture.click_to_open": "%s (点击打开)", 95 | "worldtools.capture.container": "%s 容器", 96 | "worldtools.capture.containers": "%s 容器", 97 | "worldtools.capture.entities": "%s 实体", 98 | "worldtools.capture.entity": "%s 实体", 99 | "worldtools.capture.in_dimension": " 在维度 ", 100 | "worldtools.capture.loaded.block_entities": "从缓存中加载了维度 %s 中区块 %s 的方块实体", 101 | "worldtools.capture.loaded.block_entities.anonymized": "从缓存中加载了维度 %s 中的方块实体", 102 | "worldtools.capture.nothing_saved_yet": "对于 %s 目前没有保存任何内容", 103 | "worldtools.capture.player": "%s 玩家", 104 | "worldtools.capture.players": "%s 玩家", 105 | "worldtools.capture.saved": "保存 ", 106 | "worldtools.capture.saved.advancements": "保存了 %s 的成就", 107 | "worldtools.capture.saved.chunks": "保存了区块 %s 在 %s 维度", 108 | "worldtools.capture.saved.chunks.anonymized": "在 %s 维度保存区块", 109 | "worldtools.capture.saved.compressed": "将捕获保存为ZIP压缩文件 %s", 110 | "worldtools.capture.saved.end_flow": "完成了 %s 的捕获", 111 | "worldtools.capture.saved.entities": "保存了 %s 在 %s 区块 在 %s 维度", 112 | "worldtools.capture.saved.entities.anonymized": "保存了 %s 在 %s 维度", 113 | "worldtools.capture.saved.levelData": "保存了 %s 关于 %s", 114 | "worldtools.capture.saved.mapData": "在 %s 维度保存了地图", 115 | "worldtools.capture.saved.metadata": "保存了 %s 的元数据", 116 | "worldtools.capture.saved.player": "保存了玩家 %s 在 %s 在 %s 维度", 117 | "worldtools.capture.saved.player.anonymized": "保存了玩家 %s 在 %s 在 %s 维度", 118 | "worldtools.capture.saved.statistics": "保存了 %s 的统计信息", 119 | "worldtools.capture.to_directory": " 到 saves 目录 ", 120 | "worldtools.capture.took": " (花费 %s)", 121 | "worldtools.gui.browser.title": "WorldTools 浏览器", 122 | "worldtools.gui.capture.existing_world_confirm.message": "名为: \"%s\" 的存档已存在。是否覆盖? (将合并已存在的数据)", 123 | "worldtools.gui.capture.existing_world_confirm.title": "发现已存在的世界", 124 | "worldtools.gui.escape.button.finish_download": "保存了 %s 的捕获", 125 | "worldtools.gui.manager.button.cancel": "取消", 126 | "worldtools.gui.manager.button.config": "配置", 127 | "worldtools.gui.manager.button.start_download": "开始下载", 128 | "worldtools.gui.manager.button.stop_download": "停止下载", 129 | "worldtools.gui.manager.title": "WorldTools 管理", 130 | "worldtools.gui.manager.world_name_placeholder": "输入世界名称… (默认: %s)", 131 | "worldtools.key.categories": "WorldTools", 132 | "worldtools.key.open_config": "打开配置", 133 | "worldtools.key.toggle_capture": "快速下载", 134 | "worldtools.log.error.already_capturing": "关于 %s 的捕获正在进行中!", 135 | "worldtools.log.error.failed_to_create_session": "无法为 \"%s\" 创建会话! (错误: %s)", 136 | "worldtools.log.error.failed_to_save_level": "无法为 \"%s\" 保存level.dat! (错误: %s)", 137 | "worldtools.log.error.failed_to_visit_file": "无法访问 %s 文件以创建ZIP压缩文件! (错误: %s)", 138 | "worldtools.log.error.failed_to_zip": "无法为会话创建压缩文件 %s! (错误: %s)", 139 | "worldtools.log.error.not_capturing": "目前没有捕获正在进行!", 140 | "worldtools.log.error.world_name_too_long": "世界名称 \"%s\" 太长了! (最长 %d 字符)", 141 | "worldtools.log.info.singleplayer_capture": "实验性:单人游戏中的捕获是实验性的,可能无法按预期工作!", 142 | "worldtools.log.info.started_capture": "开始捕获 %s ...", 143 | "worldtools.log.info.stopping_capture": "停止捕获 %s..." 144 | } -------------------------------------------------------------------------------- /common/src/main/resources/assets/worldtools/lang/zh_tw.json: -------------------------------------------------------------------------------- 1 | { 2 | "text.autoconfig.worldtools.category.Advanced": "進階", 3 | "text.autoconfig.worldtools.category.Debug": "除錯", 4 | "text.autoconfig.worldtools.category.Entity": "實體", 5 | "text.autoconfig.worldtools.category.General": "一般", 6 | "text.autoconfig.worldtools.category.Render": "繪製", 7 | "text.autoconfig.worldtools.category.World": "世界", 8 | "text.autoconfig.worldtools.option.advanced": "進階", 9 | "text.autoconfig.worldtools.option.advanced.anonymousMode": "在介面中匿名化座標", 10 | "text.autoconfig.worldtools.option.advanced.anonymousMode.@Tooltip": "啟用後,擷取的座標將在介面中匿名化。注意:這不會影響實際資料!", 11 | "text.autoconfig.worldtools.option.advanced.hideExperimentalWorldGui": "隱藏實驗性世界 GUI", 12 | "text.autoconfig.worldtools.option.advanced.hideExperimentalWorldGui.@Tooltip": "啟用後,在單人遊戲中載入世界後出現的惱人彈出視窗將會被隱藏。", 13 | "text.autoconfig.worldtools.option.advanced.keepEnderChestContents": "在擷取之間保留終界箱內容物", 14 | "text.autoconfig.worldtools.option.advanced.showChatMessages": "資訊聊天訊息", 15 | "text.autoconfig.worldtools.option.advanced.showToasts": "顯示通知訊息", 16 | "text.autoconfig.worldtools.option.debug": "除錯", 17 | "text.autoconfig.worldtools.option.debug.@PrefixText": "啟用除錯選項將會更容易找到問題,使用位於 .minecraft/logs/latest.log 的記錄檔。", 18 | "text.autoconfig.worldtools.option.debug.logSavedChunks": "記錄已儲存的區塊", 19 | "text.autoconfig.worldtools.option.debug.logSavedContainers": "記錄已儲存的容器", 20 | "text.autoconfig.worldtools.option.debug.logSavedEntities": "記錄已儲存的實體", 21 | "text.autoconfig.worldtools.option.debug.logSavedMaps": "記錄已儲存的地圖", 22 | "text.autoconfig.worldtools.option.debug.logSettings": "記錄設定", 23 | "text.autoconfig.worldtools.option.debug.logZippingProgress": "記錄壓縮進度", 24 | "text.autoconfig.worldtools.option.entity": "實體", 25 | "text.autoconfig.worldtools.option.entity.behavior": "行為", 26 | "text.autoconfig.worldtools.option.entity.behavior.invulnerable": "無敵", 27 | "text.autoconfig.worldtools.option.entity.behavior.modifyEntityBehavior": "修改實體行為", 28 | "text.autoconfig.worldtools.option.entity.behavior.modifyEntityBehavior.@Tooltip": "啟用後,已啟用的標籤將會被加入到 NBT。否則,實體行為將會保持不變。", 29 | "text.autoconfig.worldtools.option.entity.behavior.noAI": "無 AI", 30 | "text.autoconfig.worldtools.option.entity.behavior.noGravity": "無重力", 31 | "text.autoconfig.worldtools.option.entity.behavior.silent": "靜默", 32 | "text.autoconfig.worldtools.option.entity.censor": "隱藏", 33 | "text.autoconfig.worldtools.option.entity.censor.censorNames": "隱藏名稱", 34 | "text.autoconfig.worldtools.option.entity.censor.lastDeathLocation": "隱藏最後死亡位置", 35 | "text.autoconfig.worldtools.option.entity.censor.lastDeathLocation.@Tooltip": "啟用後,玩家的最後死亡位置將不會被儲存到硬碟。防止座標洩漏。", 36 | "text.autoconfig.worldtools.option.entity.censor.names": "隱藏實體名稱", 37 | "text.autoconfig.worldtools.option.entity.censor.owner": "隱藏實體擁有者", 38 | "text.autoconfig.worldtools.option.entity.metadata": "中繼資料", 39 | "text.autoconfig.worldtools.option.entity.metadata.captureTimestamp": "擷取時間戳記", 40 | "text.autoconfig.worldtools.option.entity.metadata.waterMark": "浮水印", 41 | "text.autoconfig.worldtools.option.general.autoDownload": "加入時自動開始下載", 42 | "text.autoconfig.worldtools.option.general.autoDownload.@Tooltip": "當您加入世界時,將會開始擷取。", 43 | "text.autoconfig.worldtools.option.general.capture": "應該擷取什麼", 44 | "text.autoconfig.worldtools.option.general.capture.@Tooltip": "停用後,相應的資料將不會被儲存到硬碟。這可能會導致擷取不完整!", 45 | "text.autoconfig.worldtools.option.general.capture.advancements": "進度", 46 | "text.autoconfig.worldtools.option.general.capture.chunks": "區塊", 47 | "text.autoconfig.worldtools.option.general.capture.entities": "實體", 48 | "text.autoconfig.worldtools.option.general.capture.levelData": "Level.dat", 49 | "text.autoconfig.worldtools.option.general.capture.maps": "地圖", 50 | "text.autoconfig.worldtools.option.general.capture.metadata": "中繼資料", 51 | "text.autoconfig.worldtools.option.general.capture.players": "玩家", 52 | "text.autoconfig.worldtools.option.general.capture.statistics": "統計資料", 53 | "text.autoconfig.worldtools.option.general.compressLevel": "將擷取存檔為 zip 檔案", 54 | "text.autoconfig.worldtools.option.general.compressLevel.@Tooltip": "當擷取完成後,它將會被存檔為 zip 檔案。", 55 | "text.autoconfig.worldtools.option.render": "繪製", 56 | "text.autoconfig.worldtools.option.render.accentColor": "強調色(RGB 十六進位)", 57 | "text.autoconfig.worldtools.option.render.captureBarColor": "擷取資訊列顏色(RGB 十六進位)", 58 | "text.autoconfig.worldtools.option.render.captureBarStyle": "擷取資訊列樣式", 59 | "text.autoconfig.worldtools.option.render.unscannedContainerColor": "遺漏容器顏色(RGB 十六進位)", 60 | "text.autoconfig.worldtools.option.render.fromCacheLoadedContainerColor": "從快取載入的容器顏色 (RGB 十六進位)", 61 | "text.autoconfig.worldtools.option.render.unscannedEntityColor": "遺漏實體顏色(RGB 十六進位)", 62 | "text.autoconfig.worldtools.option.render.fromCacheLoadedEntityColor": "從快取載入的實體顏色(RGB 十六進位)", 63 | "text.autoconfig.worldtools.option.render.progressBarColor": "進度條顏色", 64 | "text.autoconfig.worldtools.option.render.progressBarStyle": "進度條樣式", 65 | "text.autoconfig.worldtools.option.render.progressBarTimeout": "進度條逾時(毫秒)", 66 | "text.autoconfig.worldtools.option.render.renderNotYetCachedContainers": "繪製尚未快取的容器", 67 | "text.autoconfig.worldtools.option.world.censor": "隱藏", 68 | "text.autoconfig.worldtools.option.world.gameRules": "遊戲規則", 69 | "text.autoconfig.worldtools.option.world.gameRules.doDaylightCycle": "晝夜更替", 70 | "text.autoconfig.worldtools.option.world.gameRules.doFireTick": "更新火焰", 71 | "text.autoconfig.worldtools.option.world.gameRules.doMobGriefing": "生物破壞", 72 | "text.autoconfig.worldtools.option.world.gameRules.doMobSpawning": "生物生成", 73 | "text.autoconfig.worldtools.option.world.gameRules.doPatrolSpawning": "巡邏隊生成", 74 | "text.autoconfig.worldtools.option.world.gameRules.doTraderSpawning": "流浪商人生成", 75 | "text.autoconfig.worldtools.option.world.gameRules.doVinesSpread": "藤蔓蔓延", 76 | "text.autoconfig.worldtools.option.world.gameRules.doWardenSpawning": "伏守者生成", 77 | "text.autoconfig.worldtools.option.world.gameRules.doWeatherCycle": "天氣循環", 78 | "text.autoconfig.worldtools.option.world.gameRules.keepInventory": "保留物品欄", 79 | "text.autoconfig.worldtools.option.world.gameRules.modifyGameRules": "修改遊戲規則", 80 | "text.autoconfig.worldtools.option.world.gameRules.modifyGameRules.@Tooltip": "啟用後,遊戲規則將會被修改為此設定中設定的值。否則,遊戲規則將會保持不變。", 81 | "text.autoconfig.worldtools.option.world.metadata": "中繼資料", 82 | "text.autoconfig.worldtools.option.world.metadata.captureTimestamp": "儲存擷取的時間戳記", 83 | "text.autoconfig.worldtools.option.world.metadata.waterMark": "浮水印區塊", 84 | "text.autoconfig.worldtools.option.world.worldGenerator": "世界生成器", 85 | "text.autoconfig.worldtools.option.world.worldGenerator.bonusChest": "額外獎勵箱", 86 | "text.autoconfig.worldtools.option.world.worldGenerator.generateFeatures": "生成結構", 87 | "text.autoconfig.worldtools.option.world.worldGenerator.seed": "種子碼", 88 | "text.autoconfig.worldtools.option.world.worldGenerator.type": "世界生成類型", 89 | "text.autoconfig.worldtools.option.world.worldGenerator.type.@Tooltip": "VOID:將會生成一個虛空世界。FLAT:將會生成一個平坦的世界。DEFAULT:將會生成一個普通的世界。", 90 | "text.autoconfig.worldtools.title": "WorldTools 設定", 91 | "worldtools.capture.and": " 和 ", 92 | "worldtools.capture.chunk": "%s 個區塊", 93 | "worldtools.capture.chunks": "%s 個區塊", 94 | "worldtools.capture.click_to_open": "%s(按這裡開啟)", 95 | "worldtools.capture.container": "%s 個容器", 96 | "worldtools.capture.containers": "%s 個容器", 97 | "worldtools.capture.entities": "%s 個實體", 98 | "worldtools.capture.entity": "%s 個實體", 99 | "worldtools.capture.in_dimension": " 在維度 ", 100 | "worldtools.capture.loaded.block_entities": "從快取載入區塊 %s 在維度 %s 的方塊實體", 101 | "worldtools.capture.loaded.block_entities.anonymized": "從快取載入維度 %s 的方塊實體", 102 | "worldtools.capture.nothing_saved_yet": "%s 尚未儲存任何內容", 103 | "worldtools.capture.player": "%s 個玩家", 104 | "worldtools.capture.players": "%s 個玩家", 105 | "worldtools.capture.saved": "已儲存 ", 106 | "worldtools.capture.saved.advancements": "已儲存 %s 的進度", 107 | "worldtools.capture.saved.chunks": "已儲存維度 %s 中 %s 的區塊", 108 | "worldtools.capture.saved.chunks.anonymized": "已儲存維度 %s 中的區塊", 109 | "worldtools.capture.saved.compressed": "已將擷取的 ZIP 檔案儲存為 %s", 110 | "worldtools.capture.saved.end_flow": "已完成 %s 的擷取", 111 | "worldtools.capture.saved.entities": "已儲存維度 %s 中區塊 %s 的 %s", 112 | "worldtools.capture.saved.entities.anonymized": "已儲存維度 %s 中的 %s", 113 | "worldtools.capture.saved.levelData": "已儲存 %s 的 %s", 114 | "worldtools.capture.saved.mapData": "已儲存維度 %s 中的地圖", 115 | "worldtools.capture.saved.metadata": "已儲存 %s 的中繼資料", 116 | "worldtools.capture.saved.player": "已儲存維度 %s 中 %s 的玩家 %s", 117 | "worldtools.capture.saved.player.anonymized": "已儲存維度 %s 中的玩家 %s", 118 | "worldtools.capture.saved.statistics": "已儲存 %s 的統計資料", 119 | "worldtools.capture.to_directory": " 到儲存資料夾 ", 120 | "worldtools.capture.took": "(耗時 %s)", 121 | "worldtools.gui.browser.title": "WorldTools 瀏覽器", 122 | "worldtools.gui.capture.existing_world_confirm.message": "已存在名為「%s」的儲存檔案。要覆蓋它嗎?(將與現有資料合併)", 123 | "worldtools.gui.capture.existing_world_confirm.title": "找到現有世界", 124 | "worldtools.gui.escape.button.finish_download": "儲存 %s 的擷取", 125 | "worldtools.gui.manager.button.cancel": "取消", 126 | "worldtools.gui.manager.button.config": "設定", 127 | "worldtools.gui.manager.button.start_download": "開始下載", 128 | "worldtools.gui.manager.button.stop_download": "停止下載", 129 | "worldtools.gui.manager.title": "WorldTools 管理器", 130 | "worldtools.gui.manager.world_name_placeholder": "輸入世界名稱...(預設:%s)", 131 | "worldtools.key.categories": "WorldTools", 132 | "worldtools.key.open_config": "開啟設定", 133 | "worldtools.key.toggle_capture": "快速下載", 134 | "worldtools.log.error.already_capturing": "%s 的擷取已在執行!", 135 | "worldtools.log.error.failed_to_create_session": "無法為「%s」建立工作階段!(錯誤:%s)", 136 | "worldtools.log.error.failed_to_save_level": "無法儲存「%s」的 level.dat!(錯誤:%s)", 137 | "worldtools.log.error.failed_to_visit_file": "無法存取檔案 %s 以建立 zip 檔案!(錯誤:%s)", 138 | "worldtools.log.error.failed_to_zip": "無法建立工作階段以壓縮 %s 的擷取!(錯誤:%s)", 139 | "worldtools.log.error.not_capturing": "尚未執行任何擷取!", 140 | "worldtools.log.error.world_name_too_long": "世界名稱「%s」太長!(最多 %d 個字元)", 141 | "worldtools.log.info.singleplayer_capture": "實驗性:在單人遊戲中擷取是實驗性的,可能無法按預期運作!", 142 | "worldtools.log.info.started_capture": "開始擷取 %s...", 143 | "worldtools.log.info.stopping_capture": "正在停止擷取 %s..." 144 | } -------------------------------------------------------------------------------- /common/src/main/resources/worldtools.accesswidener: -------------------------------------------------------------------------------- 1 | accessWidener v2 named 2 | 3 | accessible class net/minecraft/client/world/ClientChunkManager$ClientChunkMap 4 | accessible method net/minecraft/client/world/ClientChunkManager$ClientChunkMap isInRadius (II)Z 5 | accessible class net/minecraft/advancement/PlayerAdvancementTracker$ProgressMap 6 | accessible method net/minecraft/advancement/PlayerAdvancementTracker$ProgressMap (Ljava/util/Map;)V 7 | accessible field net/minecraft/client/network/ClientAdvancementManager advancementProgresses Ljava/util/Map; 8 | accessible class net/minecraft/stat/StatHandler 9 | accessible field net/minecraft/stat/StatHandler statMap Lit/unimi/dsi/fastutil/objects/Object2IntMap; 10 | accessible field net/minecraft/client/world/ClientChunkManager chunks Lnet/minecraft/client/world/ClientChunkManager$ClientChunkMap; 11 | accessible method net/minecraft/client/world/ClientChunkManager$ClientChunkMap getChunk (I)Lnet/minecraft/world/chunk/WorldChunk; 12 | accessible field net/minecraft/client/world/ClientChunkManager$ClientChunkMap diameter I 13 | accessible method net/minecraft/client/world/ClientWorld getMapStates ()Ljava/util/Map; 14 | accessible field net/minecraft/entity/player/PlayerEntity enderChestInventory Lnet/minecraft/inventory/EnderChestInventory; 15 | accessible method net/minecraft/block/entity/LockableContainerBlockEntity getHeldStacks ()Lnet/minecraft/util/collection/DefaultedList; 16 | accessible method net/minecraft/block/entity/LockableContainerBlockEntity setHeldStacks (Lnet/minecraft/util/collection/DefaultedList;)V 17 | accessible method net/minecraft/world/chunk/SerializedChunk toNbt ([Lit/unimi/dsi/fastutil/shorts/ShortList;)Lnet/minecraft/nbt/NbtList; -------------------------------------------------------------------------------- /common/src/main/resources/worldtools.mixins.common.json: -------------------------------------------------------------------------------- 1 | { 2 | "required": true, 3 | "minVersion": "0.8", 4 | "package": "org.waste.of.time.mixin", 5 | "compatibilityLevel": "JAVA_21", 6 | "client": [ 7 | "BossBarHudMixin", 8 | "ClientPlayInteractionManagerMixin", 9 | "ClientPlayNetworkHandlerMixin", 10 | "ClientWorldMixin", 11 | "DebugRendererMixin", 12 | "GameMenuScreenMixin", 13 | "IntegratedServerLoaderMixin", 14 | "PalettedContainerMixin" 15 | ], 16 | "injectors": { 17 | "defaultRequire": 1 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /fabric/build.gradle.kts: -------------------------------------------------------------------------------- 1 | architectury { 2 | platformSetupLoomIde() 3 | fabric() 4 | } 5 | 6 | base.archivesName.set("${base.archivesName.get()}-fabric") 7 | 8 | loom { 9 | accessWidenerPath.set(project(":common").loom.accessWidenerPath) 10 | enableTransitiveAccessWideners.set(true) 11 | runs { 12 | getByName("client") { 13 | ideConfigGenerated(true) 14 | } 15 | } 16 | } 17 | 18 | val common: Configuration by configurations.creating { 19 | configurations.compileClasspath.get().extendsFrom(this) 20 | configurations.runtimeClasspath.get().extendsFrom(this) 21 | configurations["developmentFabric"].extendsFrom(this) 22 | } 23 | 24 | dependencies { 25 | common(project(":common", configuration = "namedElements")) { isTransitive = false } 26 | shadowCommon(project(path = ":common", configuration = "transformProductionFabric")) { isTransitive = false } 27 | modImplementation("net.fabricmc:fabric-loader:${project.properties["fabric_loader_version"]!!}") 28 | modImplementation("net.fabricmc.fabric-api:fabric-api:${project.properties["fabric_api_version"]!!}") 29 | modImplementation("net.fabricmc:fabric-language-kotlin:${project.properties["fabric_kotlin_version"]!!}") 30 | modApi("me.shedaniel.cloth:cloth-config-fabric:${project.properties["cloth_config_version"]}") 31 | modApi("com.terraformersmc:modmenu:${project.properties["mod_menu_version"]}") 32 | } 33 | 34 | tasks { 35 | processResources { 36 | inputs.property("version", project.version) 37 | filesMatching("fabric.mod.json") { 38 | expand(getProperties()) 39 | expand(mutableMapOf("version" to project.version)) 40 | } 41 | } 42 | 43 | remapJar { 44 | injectAccessWidener.set(true) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /fabric/gradle.properties: -------------------------------------------------------------------------------- 1 | loom.platform=fabric 2 | -------------------------------------------------------------------------------- /fabric/src/main/java/org/waste/of/time/fabric/LoaderInfoImpl.java: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.fabric; 2 | 3 | import net.fabricmc.loader.api.FabricLoader; 4 | 5 | public class LoaderInfoImpl { 6 | public static String getVersion() { 7 | return FabricLoader.getInstance().getModContainer("worldtools").orElseThrow().getMetadata().getVersion().getFriendlyString(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /fabric/src/main/kotlin/org/waste/of/time/fabric/WorldToolsFabric.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.fabric 2 | 3 | import com.mojang.brigadier.CommandDispatcher 4 | import com.mojang.brigadier.arguments.StringArgumentType 5 | import com.mojang.brigadier.arguments.StringArgumentType.string 6 | import net.fabricmc.api.ClientModInitializer 7 | import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument 8 | import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal 9 | import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback 10 | import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource 11 | import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientChunkEvents 12 | import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientEntityEvents 13 | import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents 14 | import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper 15 | import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents 16 | import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents 17 | import org.waste.of.time.Events 18 | import org.waste.of.time.WorldTools 19 | import org.waste.of.time.WorldTools.LOG 20 | import org.waste.of.time.manager.CaptureManager 21 | 22 | object WorldToolsFabric : ClientModInitializer { 23 | override fun onInitializeClient() { 24 | WorldTools.initialize() 25 | 26 | KeyBindingHelper.registerKeyBinding(WorldTools.CAPTURE_KEY) 27 | KeyBindingHelper.registerKeyBinding(WorldTools.CONFIG_KEY) 28 | 29 | ClientCommandRegistrationCallback.EVENT.register(ClientCommandRegistrationCallback { dispatcher, _ -> 30 | dispatcher.register() 31 | }) 32 | ClientEntityEvents.ENTITY_LOAD.register(ClientEntityEvents.Load { entity, _ -> 33 | Events.onEntityLoad(entity) 34 | }) 35 | ClientEntityEvents.ENTITY_UNLOAD.register(ClientEntityEvents.Unload { entity, _ -> 36 | Events.onEntityUnload(entity) 37 | }) 38 | ClientPlayConnectionEvents.JOIN.register(ClientPlayConnectionEvents.Join { _, _, _ -> 39 | Events.onClientJoin() 40 | }) 41 | ClientPlayConnectionEvents.DISCONNECT.register(ClientPlayConnectionEvents.Disconnect { _, _ -> 42 | Events.onClientDisconnect() 43 | }) 44 | ClientTickEvents.START_CLIENT_TICK.register(ClientTickEvents.StartTick { 45 | Events.onClientTickStart() 46 | }) 47 | ScreenEvents.AFTER_INIT.register(ScreenEvents.AfterInit { _, screen, _, _ -> 48 | ScreenEvents.remove(screen).register(ScreenEvents.Remove { screen -> 49 | Events.onScreenRemoved(screen) 50 | }) 51 | }) 52 | ClientChunkEvents.CHUNK_LOAD.register(ClientChunkEvents.Load { world, chunk -> 53 | Events.onChunkLoad(chunk) 54 | }) 55 | ClientChunkEvents.CHUNK_UNLOAD.register(ClientChunkEvents.Unload { world, chunk -> 56 | if (chunk == null) return@Unload 57 | Events.onChunkUnload(chunk) 58 | }) 59 | 60 | LOG.info("WorldTools Fabric initialized") 61 | } 62 | 63 | private fun CommandDispatcher.register() { 64 | register( 65 | literal("worldtools") 66 | .then( 67 | literal("capture") 68 | .then( 69 | literal("start") 70 | .executes { CaptureManager.start(); 0 } 71 | .then( 72 | argument("name", string()) 73 | .executes {CaptureManager.start(it.getArgument("name", String::class.java)); 0 } 74 | ) 75 | ) 76 | .then( 77 | literal("stop") 78 | .executes { CaptureManager.stop(); 0 } 79 | ) 80 | .executes { CaptureManager.toggleCapture(); 0 } 81 | ) 82 | ) 83 | 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /fabric/src/main/kotlin/org/waste/of/time/fabric/WorldToolsModMenuIntegration.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.fabric 2 | 3 | import com.terraformersmc.modmenu.api.ConfigScreenFactory 4 | import com.terraformersmc.modmenu.api.ModMenuApi 5 | import me.shedaniel.autoconfig.AutoConfig 6 | import net.fabricmc.api.EnvType 7 | import net.fabricmc.api.Environment 8 | import net.minecraft.client.gui.screen.Screen 9 | import org.waste.of.time.config.WorldToolsConfig 10 | 11 | @Environment(EnvType.CLIENT) 12 | class WorldToolsModMenuIntegration : ModMenuApi { 13 | 14 | override fun getModConfigScreenFactory() = 15 | ConfigScreenFactory { parent: Screen? -> 16 | AutoConfig.getConfigScreen(WorldToolsConfig::class.java, parent).get() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /fabric/src/main/resources/fabric.mod.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 1, 3 | "id": "worldtools", 4 | "version": "${version}", 5 | "name": "WorldTools", 6 | "description": "A powerful Minecraft mod that captures high-detail snapshots of server worlds locally.", 7 | "authors": [ 8 | "Constructor", 9 | "P529", 10 | "rfresh" 11 | ], 12 | "contact": { 13 | "homepage": "https://github.com/Avanatiker/WorldTools/", 14 | "sources": "https://github.com/Avanatiker/WorldTools/" 15 | }, 16 | "license": "GNU General Public License v3.0", 17 | "icon": "assets/worldtools/WorldTools.png", 18 | "environment": "*", 19 | "entrypoints": { 20 | "client": [ 21 | { 22 | "adapter": "kotlin", 23 | "value": "org.waste.of.time.fabric.WorldToolsFabric" 24 | } 25 | ], 26 | "modmenu": [ 27 | "org.waste.of.time.fabric.WorldToolsModMenuIntegration" 28 | ] 29 | }, 30 | "mixins": [ 31 | "worldtools.mixins.common.json", 32 | "worldtools.mixins.fabric.json" 33 | ], 34 | "depends": { 35 | "fabricloader": ">=${fabric_loader_version}", 36 | "fabric-api": "*", 37 | "minecraft": ["1.21.4"], 38 | "java": ">=21", 39 | "fabric-language-kotlin": ">=${fabric_kotlin_version}", 40 | "cloth-config": ">=15" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /fabric/src/main/resources/worldtools.mixins.fabric.json: -------------------------------------------------------------------------------- 1 | { 2 | "required": true, 3 | "minVersion": "0.8", 4 | "package": "org.waste.of.time.fabric.mixin", 5 | "compatibilityLevel": "JAVA_21", 6 | "client": [ 7 | ], 8 | "injectors": { 9 | "defaultRequire": 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /forge/build.gradle.kts: -------------------------------------------------------------------------------- 1 | architectury { 2 | platformSetupLoomIde() 3 | forge() 4 | } 5 | 6 | base.archivesName.set("${base.archivesName.get()}-forge") 7 | 8 | loom { 9 | accessWidenerPath.set(project(":common").loom.accessWidenerPath) 10 | forge { 11 | convertAccessWideners = true 12 | extraAccessWideners.add(loom.accessWidenerPath.get().asFile.name) 13 | mixinConfig("worldtools.mixins.common.json") 14 | } 15 | } 16 | 17 | repositories { 18 | maven("https://thedarkcolour.github.io/KotlinForForge/") { 19 | name = "KotlinForForge" 20 | } 21 | maven("https://cursemaven.com") { 22 | name = "Curse" 23 | } 24 | } 25 | 26 | val common: Configuration by configurations.creating { 27 | configurations.compileClasspath.get().extendsFrom(this) 28 | configurations.runtimeClasspath.get().extendsFrom(this) 29 | configurations["developmentForge"].extendsFrom(this) 30 | } 31 | 32 | dependencies { 33 | forge("net.minecraftforge:forge:${project.properties["forge_version"]!!}") 34 | implementation("thedarkcolour:kotlinforforge:${project.properties["kotlin_forge_version"]!!}") 35 | common(project(":common", configuration = "namedElements")) { isTransitive = false } 36 | shadowCommon(project(path = ":common", configuration = "transformProductionForge")) { isTransitive = false } 37 | implementation(annotationProcessor("io.github.llamalad7:mixinextras-common:${project.properties["mixinextras_version"]}")!!) 38 | implementation(include("io.github.llamalad7:mixinextras-forge:${project.properties["mixinextras_version"]}")!!) 39 | modApi("me.shedaniel.cloth:cloth-config-forge:${project.properties["cloth_config_version"]}") 40 | } 41 | 42 | tasks { 43 | processResources { 44 | inputs.property("version", project.version) 45 | 46 | filesMatching("META-INF/mods.toml") { 47 | expand(getProperties()) 48 | expand(mutableMapOf("version" to project.version)) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /forge/gradle.properties: -------------------------------------------------------------------------------- 1 | loom.platform=forge 2 | -------------------------------------------------------------------------------- /forge/src/main/java/org/waste/of/time/forge/LoaderInfoImpl.java: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.forge; 2 | 3 | import net.minecraftforge.fml.loading.FMLLoader; 4 | 5 | public class LoaderInfoImpl { 6 | public static String getVersion() { 7 | return FMLLoader.getLoadingModList().getModFileById("worldtools").versionString(); 8 | } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /forge/src/main/kotlin/org/waste/of/time/forge/WorldToolsForge.kt: -------------------------------------------------------------------------------- 1 | package org.waste.of.time.forge 2 | 3 | import com.mojang.brigadier.CommandDispatcher 4 | import com.mojang.brigadier.arguments.StringArgumentType 5 | import net.minecraft.server.command.CommandManager.argument 6 | import net.minecraft.server.command.CommandManager.literal 7 | import net.minecraft.server.command.ServerCommandSource 8 | import net.minecraft.world.chunk.WorldChunk 9 | import net.minecraftforge.client.event.ClientPlayerNetworkEvent 10 | import net.minecraftforge.client.event.RegisterClientCommandsEvent 11 | import net.minecraftforge.client.event.RegisterKeyMappingsEvent 12 | import net.minecraftforge.client.event.ScreenEvent 13 | import net.minecraftforge.event.TickEvent 14 | import net.minecraftforge.event.TickEvent.ClientTickEvent 15 | import net.minecraftforge.event.entity.EntityJoinLevelEvent 16 | import net.minecraftforge.event.entity.EntityLeaveLevelEvent 17 | import net.minecraftforge.event.level.ChunkEvent 18 | import net.minecraftforge.fml.common.Mod 19 | import org.waste.of.time.Events 20 | import org.waste.of.time.WorldTools 21 | import org.waste.of.time.WorldTools.LOG 22 | import org.waste.of.time.manager.CaptureManager 23 | import thedarkcolour.kotlinforforge.forge.FORGE_BUS 24 | 25 | @Mod(WorldTools.MOD_ID) 26 | object WorldToolsForge { 27 | init { 28 | WorldTools.initialize() 29 | FORGE_BUS.addListener { 30 | it.register(WorldTools.CAPTURE_KEY) 31 | it.register(WorldTools.CONFIG_KEY) 32 | } 33 | FORGE_BUS.addListener { 34 | it.dispatcher.register() 35 | } 36 | FORGE_BUS.addListener { 37 | Events.onClientJoin() 38 | } 39 | FORGE_BUS.addListener { 40 | Events.onClientDisconnect() 41 | } 42 | FORGE_BUS.addListener { 43 | Events.onEntityLoad(it.entity) 44 | } 45 | FORGE_BUS.addListener { 46 | Events.onEntityUnload(it.entity) 47 | } 48 | FORGE_BUS.addListener { 49 | if (it.phase == TickEvent.Phase.START) Events.onClientTickStart() 50 | } 51 | FORGE_BUS.addListener { 52 | if (it.chunk is WorldChunk) Events.onChunkLoad(it.chunk as WorldChunk) 53 | } 54 | FORGE_BUS.addListener { 55 | if (it.chunk is WorldChunk) Events.onChunkUnload(it.chunk as WorldChunk) 56 | } 57 | FORGE_BUS.addListener { 58 | Events.onScreenRemoved(it.screen) 59 | } 60 | 61 | LOG.info("WorldTools Forge initialized") 62 | } 63 | 64 | private fun CommandDispatcher.register() { 65 | register( 66 | literal("worldtools") 67 | .then(literal("capture") 68 | .then(argument("name", StringArgumentType.string()).executes { 69 | CaptureManager.start(it.getArgument("name", String::class.java)) 70 | 0 71 | }) 72 | .then(literal("start").executes { 73 | CaptureManager.start() 74 | 0 75 | }) 76 | .then(literal("stop").executes { 77 | CaptureManager.stop() 78 | 0 79 | }) 80 | .executes { 81 | CaptureManager.toggleCapture() 82 | 0 83 | } 84 | ) 85 | ) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /forge/src/main/resources/META-INF/mods.toml: -------------------------------------------------------------------------------- 1 | modLoader = "kotlinforforge" 2 | loaderVersion = "[4,)" 3 | license = "GNU General Public License v3.0" 4 | 5 | [[mods]] 6 | modId = "worldtools" 7 | version = "${version}" 8 | displayName = "WorldTools" 9 | authors = "Constructor, P529, rfresh2" 10 | description = ''' 11 | A powerful Minecraft mod that captures high-detail snapshots of server worlds locally. 12 | ''' 13 | logoFile = "assets/worldtools/WorldTools.png" 14 | displayTest = "IGNORE_ALL_VERSION" 15 | 16 | [[dependencies.worldtools]] 17 | modId = "forge" 18 | mandatory = true 19 | versionRange = "[47,)" 20 | ordering = "NONE" 21 | side = "CLIENT" 22 | 23 | [[dependencies.worldtools]] 24 | modId = "minecraft" 25 | mandatory = true 26 | versionRange = "[1.21,)" 27 | ordering = "NONE" 28 | side = "CLIENT" 29 | 30 | [[dependencies.worldtools]] 31 | modId = "kotlinforforge" 32 | mandatory = true 33 | versionRange = "[4,)" 34 | ordering = "AFTER" 35 | side = "CLIENT" 36 | 37 | [[dependencies.worldtools]] 38 | modId = "cloth_config" 39 | mandatory = true 40 | versionRange = "[15,)" 41 | ordering = "AFTER" 42 | side = "CLIENT" 43 | -------------------------------------------------------------------------------- /forge/src/main/resources/pack.mcmeta: -------------------------------------------------------------------------------- 1 | { 2 | "pack": { 3 | "description": "worldtools", 4 | "pack_format": 9 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | org.gradle.jvmargs=-Xmx2048M 3 | 4 | archives_base_name=WorldTools 5 | maven_group=org.waste.of.time 6 | mod_version=1.2.8 7 | 8 | minecraft_version=1.21.4 9 | enabled_platforms=fabric,forge 10 | architectury_version=15.0.1 11 | mixinextras_version=0.4.1 12 | cloth_config_version=17.0.144 13 | mod_menu_version=13.0.0 14 | 15 | # Fabric https://fabricmc.net/develop/ 16 | fabric_loader_version=0.16.10 17 | yarn_mappings=1.21.4+build.8 18 | fabric_api_version=0.114.3+1.21.4 19 | fabric_kotlin_version=1.13.0+kotlin.2.1.0 20 | 21 | # Forge 22 | forge_version=1.21.4-54.0.17 23 | kotlin_forge_version=5.7.0 24 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Avanatiker/WorldTools/f82b7415c5a5fab619cc8f537f36defdea09c546/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /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. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 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. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 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 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "WorldTools" 2 | pluginManagement { 3 | repositories { 4 | maven("https://maven.fabricmc.net/") { 5 | name = "Fabric" 6 | } 7 | maven("https://maven.architectury.dev/") 8 | maven("https://maven.minecraftforge.net/") 9 | mavenCentral() 10 | gradlePluginPortal() 11 | } 12 | } 13 | 14 | include("common") 15 | include("fabric") 16 | include("forge") 17 | --------------------------------------------------------------------------------