diff --git a/build.gradle b/build.gradle index 6b451bc..ea5ad29 100644 --- a/build.gradle +++ b/build.gradle @@ -42,3 +42,7 @@ idea { lombok { version = "1.18.34" } + +if (providers.gradleProperty('structureTest').isPresent()) { + apply from: 'gradle/scripts/structure-test.gradle' +} diff --git a/dependencies.gradle b/dependencies.gradle index c5cf2cf..b6ca3da 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -1,10 +1,23 @@ dependencies { implementation libs.anvillib.config jarJar libs.anvillib.config + implementation libs.anvillib.renderer + jarJar libs.anvillib.renderer implementation(libs.jhighlight) { transitive = false } additionalRuntimeClasspath libs.jhighlight jarJar libs.jhighlight implementation(libs.jsoup) { transitive = false } additionalRuntimeClasspath libs.jsoup jarJar(libs.jsoup) + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test', Test) { + useJUnitPlatform() +} + +sourceSets.test { + compileClasspath += sourceSets.main.compileClasspath + runtimeClasspath += sourceSets.main.runtimeClasspath } diff --git a/docs/en/10-structure-preview-rendering.md b/docs/en/10-structure-preview-rendering.md index 7a96909..74a6963 100644 --- a/docs/en/10-structure-preview-rendering.md +++ b/docs/en/10-structure-preview-rendering.md @@ -66,7 +66,24 @@ This order follows vanilla `LevelRenderer` behavior closely to reduce visual art ## Notes +### World projections and structure exports + +- World projections use `ProjectionScene` / `ProjectionRenderer` from AnvilLib's `renderer` module, version `2.0.0+snapshot.534`. +- Following the view, click-to-pin, Ctrl+scroll movement and layer controls are preserved. Meshes rebuild on layer changes or resource reload and are released when the projection closes or the world unloads. +- An export button appears below the projection button in the upper-right corner of each manual structure preview. It exports the complete template, including all layers, block entity NBT and entities, regardless of the visible layers. +- Single-player, LAN and multiplayer exports are written by the server to `data/ageratum` under the current world root. The directory is created automatically. Multiplayer files are stored on the server. +- Exports use standard compressed `.nbt`, named `namespace_structure.nbt`. Existing files are preserved, with `_1`, `_2`, etc. using the first available suffix. NBT, SNBT and preview workspace structures are supported. +- Exports have a two-second cooldown, an 8 MiB compressed transfer limit and a 64 MiB server NBT allocation quota. Chat messages report success or failure. + +### Preview implementation + - The preview level is not a full gameplay world and should not host game logic ticks. - Lighting priming depends on `refreshLightingAround`; keep calling it when adding new placement paths. - If new render layers/post effects are introduced, re-check buffer flush ordering. +## Verification + +- `./gradlew test`: checks NBT preservation, directory creation, filename collisions and path boundaries. +- `./gradlew runStructureTest -PstructureTest`: runs an isolated hidden client under `build/structure-test` to verify projections, resource reload, cleanup and chunked exports. Test sources are excluded from release JARs. +- Automated tests do not replace manual acceptance of the guide button layout, visual quality or remote multiplayer servers. + diff --git a/docs/zh/10-structure-preview-rendering.md b/docs/zh/10-structure-preview-rendering.md index fb0870b..99de196 100644 --- a/docs/zh/10-structure-preview-rendering.md +++ b/docs/zh/10-structure-preview-rendering.md @@ -66,7 +66,24 @@ ## 注意事项 +### 世界投影与结构导出 + +- 世界内的半透明投影使用砧库 `renderer` 模块的 `ProjectionScene` / `ProjectionRenderer`,依赖版本为 `2.0.0+snapshot.534`。 +- 投影保留跟随视线、点击固定、Ctrl+滚轮移动和分层控制;仅在层数变化或资源重载后重新构建网格,关闭投影或退出世界时释放缓存。 +- 手册结构预览右上角的投影按钮下方新增导出按钮。导出完整模板,包含全部层、方块实体 NBT 和实体,不受当前显示层数影响。 +- 单人、局域网和多人服务器均由服务端写入当前存档根目录的 `data/ageratum`,目录不存在时自动创建。多人游戏的文件保存在服务器上。 +- 文件格式为标准压缩 `.nbt`,命名为 `命名空间_结构名.nbt`,重名依次使用 `_1`、`_2`,不覆盖原文件。NBT、SNBT 和 preview 工作区的结构均可导出。 +- 导出间隔为两秒,压缩传输上限为 8 MiB,服务端 NBT 读取配额为 64 MiB;成功或失败会显示聊天提示。 + +### 预览实现 + - 该预览世界不是完整游戏世界,不应承载游戏逻辑 tick。 - 光照预热依赖 `refreshLightingAround`,新增放置逻辑时应保持调用。 - 若引入新的渲染层或后处理,需要同步评估 buffer flush 顺序。 +## 验证 + +- `./gradlew test`:导出文件的 NBT 保留、目录创建、重名与路径边界测试。 +- `./gradlew runStructureTest -PstructureTest`:在 `build/structure-test` 的隔离隐藏客户端中验证投影、资源重载、清理及分片导出。测试源不会进入发布 JAR。 +- 自动测试不替代实际手册按钮布局、视觉效果和远程多人服务器的人工验收。 + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9bebe00..3bccaf3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,12 +3,13 @@ minecraft = "1.21.1" neoForge = "21.1.220" modDevGradle = "2.0.78" lombok = "8.7.1" -anvillib = "2.0.0+snapshot.222" +anvillib = "2.0.0+snapshot.534" jhighlight = "1.1.1" jsoup = "1.18.3" [libraries] anvillib-config = { group = "dev.anvilcraft.lib", name = "anvillib-config-neoforge-1.21.1", version.ref = "anvillib" } +anvillib-renderer = { group = "dev.anvilcraft.lib", name = "anvillib-renderer-neoforge-1.21.1", version.ref = "anvillib" } jhighlight = { group = "org.codelibs", name = "jhighlight", version.ref = "jhighlight" } jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } [plugins] diff --git a/gradle/scripts/repositories.gradle b/gradle/scripts/repositories.gradle index a1238ba..a9acbf4 100644 --- a/gradle/scripts/repositories.gradle +++ b/gradle/scripts/repositories.gradle @@ -1,5 +1,7 @@ repositories { - mavenLocal() + mavenLocal { + content { excludeGroup "dev.anvilcraft.lib" } + } mavenCentral() maven { // Anvil Lib name = "Cjsah Maven" diff --git a/gradle/scripts/structure-test.gradle b/gradle/scripts/structure-test.gradle new file mode 100644 index 0000000..eae4f7d --- /dev/null +++ b/gradle/scripts/structure-test.gradle @@ -0,0 +1,30 @@ +sourceSets { + clientTest { + compileClasspath += sourceSets.main.output + sourceSets.main.compileClasspath + runtimeClasspath += sourceSets.main.output + sourceSets.main.runtimeClasspath + } +} + +neoForge { + mods { + ageratum_structure_test { sourceSet sourceSets.clientTest } + } + runs { + structureTest { + client() + gameDirectory = project.file('build/structure-test/run') + programArguments.addAll '--width', '800', '--height', '600' + } + } +} + +def structureTestLog = layout.buildDirectory.file('structure-test/run/logs/latest.log') +tasks.named('runStructureTest') { + notCompatibleWithConfigurationCache('Opt-in client regression checks the isolated game log') + doLast { + def log = structureTestLog.get().asFile.getText('UTF-8') + if (!log.contains('AGERATUM_STRUCTURE_TEST PASS') || log.contains('AGERATUM_STRUCTURE_TEST FAILED')) { + throw new GradleException('Structure regression failed; inspect the isolated client log') + } + } +} diff --git a/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/StructureTest.java b/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/StructureTest.java new file mode 100644 index 0000000..480c06b --- /dev/null +++ b/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/StructureTest.java @@ -0,0 +1,150 @@ +package dev.anvilcraft.resource.ageratum.test; + +import com.mojang.logging.LogUtils; +import dev.anvilcraft.lib.v2.renderer.projection.ProjectionRenderer; +import dev.anvilcraft.resource.ageratum.client.feat.structure.StructureExportClient; +import dev.anvilcraft.resource.ageratum.client.feat.structure.StructureProjectionApi; +import dev.anvilcraft.resource.ageratum.client.feat.structure.StructureProjectionManager; +import net.minecraft.client.Minecraft; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.TagParser; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.Difficulty; +import net.minecraft.world.level.GameRules; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.LevelSettings; +import net.minecraft.world.level.WorldDataConfiguration; +import net.minecraft.world.level.levelgen.WorldOptions; +import net.minecraft.world.level.levelgen.presets.WorldPresets; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import net.minecraft.world.level.storage.LevelResource; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.ModelEvent; +import net.neoforged.neoforge.client.event.RenderLevelStageEvent; +import net.neoforged.neoforge.common.NeoForge; +import org.lwjgl.glfw.GLFW; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Random; +import java.util.concurrent.CompletableFuture; + +/** Opt-in real client, renderer and client-to-server export regression. */ +@Mod(value = "ageratum_structure_test", dist = Dist.CLIENT) +public final class StructureTest { + private boolean ready; + private int stage; + private int frames; + private long started = System.nanoTime(); + private CompletableFuture reload; + private ProjectionRenderer originalRenderer; + private StructureTemplate template; + private CompoundTag expected; + private Path export; + + public StructureTest(IEventBus bus) { + bus.addListener((ModelEvent.BakingCompleted event) -> this.ready = true); + NeoForge.EVENT_BUS.addListener(this::tick); + NeoForge.EVENT_BUS.addListener(this::render); + } + + private void tick(ClientTickEvent.Post event) { + Minecraft mc = Minecraft.getInstance(); + mc.options.pauseOnLostFocus = false; + GLFW.glfwHideWindow(mc.getWindow().getWindow()); + if (this.stage == 5) return; + if (System.nanoTime() - this.started > 180_000_000_000L) { + fail(new IllegalStateException("Timed out")); + return; + } + if (mc.getOverlay() != null || this.stage != 0 || !this.ready) return; + this.stage = 1; + GameRules rules = new GameRules(); + rules.getRule(GameRules.RULE_SPAWN_CHUNK_RADIUS).set(0, null); + LevelSettings settings = new LevelSettings("Structure regression", GameType.CREATIVE, + false, Difficulty.PEACEFUL, false, rules, WorldDataConfiguration.DEFAULT); + mc.createWorldOpenFlows().createFreshLevel("structure-" + System.currentTimeMillis(), settings, + new WorldOptions(17, false, false), + access -> access.registryOrThrow(Registries.WORLD_PRESET).getHolderOrThrow(WorldPresets.FLAT) + .value().createWorldDimensions(), mc.screen); + } + + private void render(RenderLevelStageEvent event) { + Minecraft mc = Minecraft.getInstance(); + if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_TRANSLUCENT_BLOCKS || mc.level == null + || mc.player == null || mc.screen != null || mc.getOverlay() != null || this.stage == 5) return; + try { + if (this.stage == 1) { + CompoundTag root = TagParser.parseTag(""" + {size:[2,2,2],palette:[{Name:"minecraft:stone"},{Name:"minecraft:glass"}, + {Name:"minecraft:water"},{Name:"minecraft:chest"}],blocks:[ + {pos:[0,0,0],state:0},{pos:[0,1,0],state:1},{pos:[1,0,0],state:2}, + {pos:[1,1,1],state:3,nbt:{id:"minecraft:chest"}}],entities:[]} + """); + byte[] extra = new byte[70_000]; + new Random(17).nextBytes(extra); + root.getList("blocks", 10).getCompound(3).getCompound("nbt").putByteArray("export_test", extra); + this.template = new StructureTemplate(); + this.template.load(mc.level.registryAccess().lookupOrThrow(Registries.BLOCK), root); + this.expected = this.template.save(new CompoundTag()); + check(StructureProjectionApi.show(this.template, mc.player.blockPosition()), "fixed projection opens"); + this.originalRenderer = renderer(); + this.export = mc.getSingleplayerServer().getWorldPath(LevelResource.ROOT) + .resolve("data/ageratum/test_fixture.nbt"); + StructureExportClient.export(ResourceLocation.parse("test:fixture.snbt"), this.template); + this.stage = 2; + } else if (this.stage == 2 && ++this.frames > 3 && Files.isRegularFile(this.export)) { + check(this.originalRenderer.isValid(), "projection mesh rendered"); + check(this.expected.equals(NbtIo.readCompressed(this.export, NbtAccounter.unlimitedHeap())), + "multi-chunk client/server export preserves NBT"); + this.reload = mc.reloadResourcePacks(); + this.stage = 3; + } else if (this.stage == 3 && this.reload.isDone()) { + this.reload.join(); + // Call the public render event handler to avoid event registration order assumptions. + StructureProjectionManager.onRenderLevelStage(event); + check(this.originalRenderer.isValid(), "projection rebuilds after resource reload"); + check(StructureProjectionApi.showFloating(this.template, mc.player.blockPosition()), "replacement opens"); + check(!this.originalRenderer.isValid(), "replacement frees previous mesh"); + this.stage = 4; + this.frames = 0; + } else if (this.stage == 4 && ++this.frames > 3) { + ProjectionRenderer renderer = renderer(); + check(renderer.isValid(), "floating projection renders"); + StructureProjectionApi.clear(); + check(!StructureProjectionApi.hasActiveProjection() && !renderer.isValid(), "clear releases mesh"); + LogUtils.getLogger().info("AGERATUM_STRUCTURE_TEST PASS: fixed/floating rendering, replacement, reload, cleanup, multi-chunk export"); + this.stage = 5; + mc.stop(); + } + } catch (Throwable exception) { + fail(exception); + } + } + + private static ProjectionRenderer renderer() throws Exception { + var active = StructureProjectionManager.class.getDeclaredField("activeProjection"); + active.setAccessible(true); + Object projection = active.get(null); + var field = projection.getClass().getDeclaredField("renderer"); + field.setAccessible(true); + return (ProjectionRenderer) field.get(projection); + } + + private void fail(Throwable exception) { + LogUtils.getLogger().error("AGERATUM_STRUCTURE_TEST FAILED", exception); + this.stage = 5; + Minecraft.getInstance().stop(); + } + + private static void check(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/mixin/HiddenWindowMixin.java b/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/mixin/HiddenWindowMixin.java new file mode 100644 index 0000000..155afb0 --- /dev/null +++ b/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/mixin/HiddenWindowMixin.java @@ -0,0 +1,17 @@ +package dev.anvilcraft.resource.ageratum.test.mixin; + +import com.mojang.blaze3d.platform.Window; +import org.lwjgl.glfw.GLFW; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Window.class) +abstract class HiddenWindowMixin { + @Inject(method = "", at = @At(value = "INVOKE", + target = "Lorg/lwjgl/glfw/GLFW;glfwDefaultWindowHints()V", shift = At.Shift.AFTER)) + private void hide(CallbackInfo ci) { + GLFW.glfwWindowHint(GLFW.GLFW_VISIBLE, GLFW.GLFW_FALSE); + } +} diff --git a/src/clientTest/resources/META-INF/neoforge.mods.toml b/src/clientTest/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..3195a6b --- /dev/null +++ b/src/clientTest/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,15 @@ +modLoader="javafml" +loaderVersion="[4,)" +license="MIT" +[[mods]] +modId="ageratum_structure_test" +version="1.0" +displayName="Ageratum structure regression" +[[dependencies.ageratum_structure_test]] +modId="ageratum" +type="required" +versionRange="[0,)" +ordering="AFTER" +side="CLIENT" +[[mixins]] +config="ageratum_structure_test.mixins.json" diff --git a/src/clientTest/resources/ageratum_structure_test.mixins.json b/src/clientTest/resources/ageratum_structure_test.mixins.json new file mode 100644 index 0000000..bd879e4 --- /dev/null +++ b/src/clientTest/resources/ageratum_structure_test.mixins.json @@ -0,0 +1,6 @@ +{ + "required": true, + "package": "dev.anvilcraft.resource.ageratum.test.mixin", + "compatibilityLevel": "JAVA_21", + "client": ["HiddenWindowMixin"] +} diff --git a/src/generated/resources/assets/ageratum/lang/en_us.json b/src/generated/resources/assets/ageratum/lang/en_us.json index 8967858..ee72719 100644 --- a/src/generated/resources/assets/ageratum/lang/en_us.json +++ b/src/generated/resources/assets/ageratum/lang/en_us.json @@ -31,7 +31,13 @@ "key.categories.ageratum": "Ageratum", "system.ageratum.share.button": "[CLICK TO OPEN]", "system.ageratum.share.tip": "Player %s has shared a guide with you:", + "system.ageratum.structure_export.busy": "Please wait two seconds between structure exports.", + "system.ageratum.structure_export.failed": "Structure export failed. Check the game/server log.", + "system.ageratum.structure_export.sending": "Sending structure export to the server...", + "system.ageratum.structure_export.success": "Structure exported to the current world: %s", + "system.ageratum.structure_export.too_large": "Structure export exceeds the 8 MiB compressed size limit.", "tooltip.ageratum.bind_item_hold": "Hold %s to get more info", + "tooltip.ageratum.structure_export": "Export the full structure to the current server world's data/ageratum folder", "tooltip.ageratum.structure_projection.layer_shortcut": "Press %s / %s to adjust projection layers", "tooltip.ageratum.structure_projection.remove_shortcut": "Press %s to close projection" } \ No newline at end of file diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDNBTStructureComponent.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDNBTStructureComponent.java index 2f89c7c..8a9e766 100644 --- a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDNBTStructureComponent.java +++ b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDNBTStructureComponent.java @@ -7,6 +7,7 @@ import dev.anvilcraft.resource.ageratum.client.feat.markdown.MDRenderContext; import dev.anvilcraft.resource.ageratum.client.feat.markdown.component.MDComponent; import dev.anvilcraft.resource.ageratum.client.feat.markdown.component.MDTextComponent; +import dev.anvilcraft.resource.ageratum.client.feat.structure.StructureExportClient; import dev.anvilcraft.resource.ageratum.client.feat.structure.StructureProjectionApi; import dev.anvilcraft.resource.ageratum.client.util.RelativePathResolver; import dev.anvilcraft.resource.ageratum.client.util.ViewportCameraRig; @@ -147,6 +148,10 @@ private boolean isHoverProjectionButton(int maxX, float mouseX, float mouseY) { return isHover(maxX - 21, 5, 16, 16, mouseX, mouseY); } + private boolean isHoverExportButton(int maxX, float mouseX, float mouseY) { + return isHover(maxX - 21, 25, 16, 16, mouseX, mouseY); + } + /** * 计算层数指示器区域中 [+] 按钮的 X 坐标。 */ @@ -165,6 +170,18 @@ private int getLayerButtonSize(MDRenderContext context) { private void renderButton(MDRenderContext context) { GuiGraphics graphics = context.graphics(); + int exportX = context.maxX() - 21; + boolean exportHover = isHoverExportButton(context.maxX(), context.mouseX(), context.mouseY()); + graphics.fill(exportX, 25, exportX + 16, 41, exportHover ? 0xBBAAAAAA : 0x88444444); + // Download arrow and tray, using pixel geometry so no external texture is required. + graphics.fill(exportX + 7, 28, exportX + 9, 34, 0xFFFFFFFF); + graphics.fill(exportX + 5, 32, exportX + 11, 34, 0xFFFFFFFF); + graphics.fill(exportX + 6, 34, exportX + 10, 35, 0xFFFFFFFF); + graphics.fill(exportX + 7, 35, exportX + 9, 36, 0xFFFFFFFF); + graphics.fill(exportX + 4, 37, exportX + 12, 39, 0xFFFFFFFF); + if (exportHover) { + context.addTooltip(Component.translatable("tooltip.ageratum.structure_export")); + } boolean isHover = isHoverProjectionButton(context.maxX(), context.mouseX(), context.mouseY()); graphics.blit( BUTTON_PROJECTION_LOCATION, @@ -242,6 +259,11 @@ public boolean mouseClicked(Minecraft minecraft, double mouseX, double mouseY, i if (button != 0 && button != 1) { return false; } + if (button == 0 && this.structureTemplateCache != null + && this.isHoverExportButton(maxX, (float) mouseX, (float) mouseY)) { + StructureExportClient.export(this.target.location(), this.structureTemplateCache); + return true; + } // 层数调节按钮 if (button == 0 && this.previewLevel != null) { int padding = AgeratumConstants.GuideScreenUI.Positions.LAYER_INDICATOR_PADDING; @@ -414,7 +436,8 @@ public int getHeight(Minecraft minecraft, int maxX, int maxY) { template.load(blocks, root); Vec3i size = template.getSize(); BlockPos pos = StructureSandboxFactory.centeredPlacement(template); - this.contentHeight = (int) (AgeratumConstants.Structure.Render.CONTENT_HEIGHT_FACTOR * Math.sqrt(BlockPos.ZERO.distSqr(size))); + this.contentHeight = Math.max(46, + (int) (AgeratumConstants.Structure.Render.CONTENT_HEIGHT_FACTOR * Math.sqrt(BlockPos.ZERO.distSqr(size)))); this.bottomHeight = (int) (AgeratumConstants.Structure.Render.BOTTOM_HEIGHT_FACTOR * Math.sqrt(BlockPos.ZERO.distSqr(pos))); this.structureTemplateCache = template; return StructureSandboxFactory.create(clientLevel, template, pos); diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/structure/StructureExportClient.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/structure/StructureExportClient.java new file mode 100644 index 0000000..2507fc2 --- /dev/null +++ b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/structure/StructureExportClient.java @@ -0,0 +1,42 @@ +package dev.anvilcraft.resource.ageratum.client.feat.structure; + +import dev.anvilcraft.resource.ageratum.network.ExportStructurePayload; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.client.Minecraft; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtIo; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import net.neoforged.neoforge.network.PacketDistributor; + +import java.io.ByteArrayOutputStream; +import java.util.Arrays; + +@Slf4j +public final class StructureExportClient { + private StructureExportClient() { + } + + public static void export(ResourceLocation location, StructureTemplate template) { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.player == null || minecraft.getConnection() == null) return; + try { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + NbtIo.writeCompressed(template.save(new CompoundTag()), output); + byte[] data = output.toByteArray(); + if (data.length > ExportStructurePayload.MAX_BYTES) { + minecraft.player.displayClientMessage(Component.translatable("system.ageratum.structure_export.too_large"), false); + return; + } + for (int offset = 0; offset < data.length; offset += ExportStructurePayload.CHUNK_BYTES) { + byte[] chunk = Arrays.copyOfRange(data, offset, Math.min(data.length, offset + ExportStructurePayload.CHUNK_BYTES)); + PacketDistributor.sendToServer(new ExportStructurePayload(location, data.length, offset, chunk)); + } + minecraft.player.displayClientMessage(Component.translatable("system.ageratum.structure_export.sending"), false); + } catch (Exception exception) { + log.warn("Failed to send structure export {}", location, exception); + minecraft.player.displayClientMessage(Component.translatable("system.ageratum.structure_export.failed"), false); + } + } +} diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/structure/StructureProjectionManager.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/structure/StructureProjectionManager.java index 506e9da..43e002a 100644 --- a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/structure/StructureProjectionManager.java +++ b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/structure/StructureProjectionManager.java @@ -1,10 +1,11 @@ package dev.anvilcraft.resource.ageratum.client.feat.structure; import com.mojang.blaze3d.vertex.PoseStack; +import dev.anvilcraft.lib.v2.renderer.projection.ProjectionRenderer; +import dev.anvilcraft.lib.v2.renderer.projection.ProjectionScene; import dev.anvilcraft.resource.ageratum.Ageratum; import dev.anvilcraft.resource.ageratum.client.AgeratumKeyMappings; import dev.anvilcraft.resource.ageratum.client.util.level.SandboxRenderLevel; -import dev.anvilcraft.resource.ageratum.client.util.level.StructurePreviewRenderer; import dev.anvilcraft.resource.ageratum.client.util.level.StructureSandboxFactory; import dev.anvilcraft.resource.ageratum.util.ReferenceHolder; import net.minecraft.client.Minecraft; @@ -23,8 +24,8 @@ import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.neoforge.client.event.ClientTickEvent; import net.neoforged.neoforge.client.event.InputEvent; -import net.neoforged.neoforge.client.event.ClientTickEvent; import net.neoforged.neoforge.client.event.RenderLevelStageEvent; +import net.neoforged.neoforge.event.level.LevelEvent; import org.jetbrains.annotations.Nullable; import java.util.Collection; @@ -60,6 +61,7 @@ public static boolean showProjection(StructureTemplate template, BlockPos origin return false; } + clearProjection(); activeProjection = ActiveProjection.create(previewLevel, origin, clientLevel.dimension(), Set.copyOf(moveControlItems)); return true; } @@ -80,14 +82,25 @@ public static boolean showFloatingProjection(StructureTemplate template, BlockPo return false; } + clearProjection(); activeProjection = ActiveProjection.createFloating(previewLevel, origin, clientLevel.dimension(), Set.copyOf(moveControlItems)); return true; } public static void clearProjection() { + if (activeProjection != null) { + activeProjection.renderer.close(); + } activeProjection = null; } + @SubscribeEvent + public static void onLevelUnload(LevelEvent.Unload event) { + if (event.getLevel() instanceof ClientLevel) { + clearProjection(); + } + } + public static boolean hasActiveProjection() { return activeProjection != null; } @@ -145,7 +158,7 @@ public static void onInteractionKeyMappingTriggered(InputEvent.InteractionKeyMap public static void onClientTick(ClientTickEvent.Post event) { Minecraft minecraft = Minecraft.getInstance(); if (minecraft.level == null || minecraft.player == null) { - activeProjection = null; + clearProjection(); return; } @@ -154,7 +167,7 @@ public static void onClientTick(ClientTickEvent.Post event) { return; } if (projection.isNotInLevel(minecraft.level)) { - activeProjection = null; + clearProjection(); return; } if (minecraft.screen != null) { @@ -171,7 +184,7 @@ public static void onClientTick(ClientTickEvent.Post event) { } if (AgeratumKeyMappings.REMOVE_KEY.consumeClick()) { - activeProjection = null; + clearProjection(); return; } while (AgeratumKeyMappings.LAYER_UP_KEY.consumeClick()) { @@ -194,20 +207,22 @@ public static void onRenderLevelStage(RenderLevelStageEvent event) { } PoseStack poseStack = event.getPoseStack(); - StructurePreviewRenderer.getInstance().renderWorldProjection( - projection.level, - poseStack, - minecraft.renderBuffers().bufferSource(), - minecraft.gameRenderer.getMainCamera().getPosition(), - projection.origin, - projection.visibleMinY, - projection.visibleMinY + projection.visibleLayerCount, - PROJECTION_ALPHA - ); + projection.ensureMesh(); + Vec3 cameraPos = event.getCamera().getPosition(); + poseStack.pushPose(); + try { + poseStack.translate(projection.origin.getX() - cameraPos.x, + projection.origin.getY() - cameraPos.y, projection.origin.getZ() - cameraPos.z); + projection.renderer.render(poseStack, event.getProjectionMatrix(), minecraft.renderBuffers().bufferSource()); + } finally { + poseStack.popPose(); + } } private static final class ActiveProjection { private final SandboxRenderLevel level; + private final ProjectionRenderer renderer = new ProjectionRenderer(); + private int bakedLayerCount = -1; private final ResourceKey dimension; private final Set moveControlItems; private boolean floating; @@ -266,6 +281,24 @@ private boolean isNotInLevel(Level level) { return !Objects.equals(level.dimension(), this.dimension); } + private void ensureMesh() { + if (this.renderer.isValid() && this.bakedLayerCount == this.visibleLayerCount) { + return; + } + ProjectionScene scene = new ProjectionScene(this.level, BlockPos.ZERO); + int maxYExclusive = this.visibleMinY + this.visibleLayerCount; + this.level.getFilledBlocks() + .filter(pos -> pos.getY() >= this.visibleMinY && pos.getY() < maxYExclusive) + .forEach(pos -> scene.put(pos, this.level.getBlockState(pos), this.level.getBlockEntity(pos))); + for (var entity : this.level.getEntitiesForRendering()) { + if (entity.getBoundingBox().maxY > this.visibleMinY && entity.getBoundingBox().minY < maxYExclusive) { + scene.addEntity(entity); + } + } + this.renderer.rebuild(scene, Math.round(PROJECTION_ALPHA * 255)); + this.bakedLayerCount = this.visibleLayerCount; + } + private boolean canMoveWith(ItemStack mainHandItem, ItemStack offhandItem) { return this.moveControlItems.contains(mainHandItem.getItem()) || this.moveControlItems.contains(offhandItem.getItem()); } diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/util/AlphaVertexConsumer.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/util/AlphaVertexConsumer.java deleted file mode 100644 index 8cf29dc..0000000 --- a/src/main/java/dev/anvilcraft/resource/ageratum/client/util/AlphaVertexConsumer.java +++ /dev/null @@ -1,172 +0,0 @@ -package dev.anvilcraft.resource.ageratum.client.util; - -import com.mojang.blaze3d.vertex.PoseStack; -import com.mojang.blaze3d.vertex.VertexConsumer; -import net.minecraft.client.renderer.block.model.BakedQuad; -import net.minecraft.util.Mth; -import org.joml.Matrix4f; -import org.joml.Vector3f; - -/** - * 为顶点流统一缩放透明度的包装器。 - */ -public class AlphaVertexConsumer implements VertexConsumer { - private final VertexConsumer delegate; - private final float alphaMultiplier; - - public AlphaVertexConsumer(VertexConsumer delegate, float alphaMultiplier) { - this.delegate = delegate; - this.alphaMultiplier = Mth.clamp(alphaMultiplier, 0.0f, 1.0f); - } - - private int scaleAlpha(int alpha) { - return Mth.clamp(Math.round(alpha * this.alphaMultiplier), 0, 255); - } - - private float scaleAlpha(float alpha) { - return Mth.clamp(alpha * this.alphaMultiplier, 0.0f, 1.0f); - } - - @Override - public VertexConsumer addVertex(float x, float y, float z) { - return this.delegate.addVertex(x, y, z); - } - - @Override - public VertexConsumer setColor(int red, int green, int blue, int alpha) { - return this.delegate.setColor(red, green, blue, this.scaleAlpha(alpha)); - } - - @Override - public VertexConsumer setUv(float u, float v) { - return this.delegate.setUv(u, v); - } - - @Override - public VertexConsumer setUv1(int u, int v) { - return this.delegate.setUv1(u, v); - } - - @Override - public VertexConsumer setUv2(int u, int v) { - return this.delegate.setUv2(u, v); - } - - @Override - public VertexConsumer setNormal(float x, float y, float z) { - return this.delegate.setNormal(x, y, z); - } - - @Override - public void addVertex( - float x, - float y, - float z, - int color, - float u, - float v, - int overlay, - int light, - float normalX, - float normalY, - float normalZ - ) { - int alpha = this.scaleAlpha((color >>> 24) & 0xFF); - int adjustedColor = (alpha << 24) | (color & 0x00FFFFFF); - this.delegate.addVertex(x, y, z, adjustedColor, u, v, overlay, light, normalX, normalY, normalZ); - } - - @Override - public VertexConsumer setColor(float red, float green, float blue, float alpha) { - return this.delegate.setColor(red, green, blue, this.scaleAlpha(alpha)); - } - - @Override - public VertexConsumer setColor(int color) { - int alpha = this.scaleAlpha((color >>> 24) & 0xFF); - int adjustedColor = (alpha << 24) | (color & 0x00FFFFFF); - return this.delegate.setColor(adjustedColor); - } - - @Override - public VertexConsumer setWhiteAlpha(int alpha) { - return this.delegate.setWhiteAlpha(this.scaleAlpha(alpha)); - } - - @Override - public VertexConsumer setLight(int light) { - return this.delegate.setLight(light); - } - - @Override - public VertexConsumer setOverlay(int overlay) { - return this.delegate.setOverlay(overlay); - } - - @Override - public void putBulkData( - PoseStack.Pose pose, - BakedQuad quad, - float red, - float green, - float blue, - float alpha, - int packedLight, - int packedOverlay - ) { - this.delegate.putBulkData(pose, quad, red, green, blue, this.scaleAlpha(alpha), packedLight, packedOverlay); - } - - @Override - public void putBulkData( - PoseStack.Pose pose, - BakedQuad quad, - float[] brightness, - float red, - float green, - float blue, - float alpha, - int[] lightmap, - int packedOverlay, - boolean useQuadColorData - ) { - this.delegate.putBulkData( - pose, - quad, - brightness, - red, - green, - blue, - this.scaleAlpha(alpha), - lightmap, - packedOverlay, - useQuadColorData - ); - } - - @Override - public VertexConsumer addVertex(Vector3f vector) { - return this.delegate.addVertex(vector); - } - - @Override - public VertexConsumer addVertex(PoseStack.Pose pose, Vector3f vector) { - return this.delegate.addVertex(pose, vector); - } - - @Override - public VertexConsumer addVertex(PoseStack.Pose pose, float x, float y, float z) { - return this.delegate.addVertex(pose, x, y, z); - } - - @Override - public VertexConsumer addVertex(Matrix4f matrix, float x, float y, float z) { - return this.delegate.addVertex(matrix, x, y, z); - } - - @Override - public VertexConsumer setNormal(PoseStack.Pose pose, float x, float y, float z) { - return this.delegate.setNormal(pose, x, y, z); - } -} - diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/util/OffsetVertexConsumer.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/util/OffsetVertexConsumer.java deleted file mode 100644 index 4ef95c3..0000000 --- a/src/main/java/dev/anvilcraft/resource/ageratum/client/util/OffsetVertexConsumer.java +++ /dev/null @@ -1,175 +0,0 @@ -package dev.anvilcraft.resource.ageratum.client.util; - -import com.mojang.blaze3d.vertex.PoseStack; -import com.mojang.blaze3d.vertex.VertexConsumer; -import net.minecraft.client.renderer.block.model.BakedQuad; -import org.joml.Matrix4f; -import org.joml.Vector3f; - -/** - * 为顶点坐标追加固定平移量的包装器。 - */ -public class OffsetVertexConsumer implements VertexConsumer { - private final VertexConsumer delegate; - private final float offsetX; - private final float offsetY; - private final float offsetZ; - - public OffsetVertexConsumer(VertexConsumer delegate, float offsetX, float offsetY, float offsetZ) { - this.delegate = delegate; - this.offsetX = offsetX; - this.offsetY = offsetY; - this.offsetZ = offsetZ; - } - - @Override - public VertexConsumer addVertex(float x, float y, float z) { - return this.delegate.addVertex(x + this.offsetX, y + this.offsetY, z + this.offsetZ); - } - - @Override - public VertexConsumer setColor(int red, int green, int blue, int alpha) { - return this.delegate.setColor(red, green, blue, alpha); - } - - @Override - public VertexConsumer setUv(float u, float v) { - return this.delegate.setUv(u, v); - } - - @Override - public VertexConsumer setUv1(int u, int v) { - return this.delegate.setUv1(u, v); - } - - @Override - public VertexConsumer setUv2(int u, int v) { - return this.delegate.setUv2(u, v); - } - - @Override - public VertexConsumer setNormal(float x, float y, float z) { - return this.delegate.setNormal(x, y, z); - } - - @Override - public void addVertex( - float x, - float y, - float z, - int color, - float u, - float v, - int overlay, - int light, - float normalX, - float normalY, - float normalZ - ) { - this.delegate.addVertex( - x + this.offsetX, - y + this.offsetY, - z + this.offsetZ, - color, - u, - v, - overlay, - light, - normalX, - normalY, - normalZ - ); - } - - @Override - public VertexConsumer setColor(float red, float green, float blue, float alpha) { - return this.delegate.setColor(red, green, blue, alpha); - } - - @Override - public VertexConsumer setColor(int color) { - return this.delegate.setColor(color); - } - - @Override - public VertexConsumer setWhiteAlpha(int alpha) { - return this.delegate.setWhiteAlpha(alpha); - } - - @Override - public VertexConsumer setLight(int light) { - return this.delegate.setLight(light); - } - - @Override - public VertexConsumer setOverlay(int overlay) { - return this.delegate.setOverlay(overlay); - } - - @Override - public void putBulkData( - PoseStack.Pose pose, - BakedQuad quad, - float red, - float green, - float blue, - float alpha, - int packedLight, - int packedOverlay - ) { - this.delegate.putBulkData(pose, quad, red, green, blue, alpha, packedLight, packedOverlay); - } - - @Override - public void putBulkData( - PoseStack.Pose pose, - BakedQuad quad, - float[] brightness, - float red, - float green, - float blue, - float alpha, - int[] lightmap, - int packedOverlay, - boolean useQuadColorData - ) { - this.delegate.putBulkData( - pose, - quad, - brightness, - red, - green, - blue, - alpha, - lightmap, - packedOverlay, - useQuadColorData - ); - } - - @Override - public VertexConsumer addVertex(Vector3f vector) { - return this.delegate.addVertex(new Vector3f(vector).add(this.offsetX, this.offsetY, this.offsetZ)); - } - - @Override - public VertexConsumer addVertex(PoseStack.Pose pose, Vector3f vector) { - return this.delegate.addVertex(pose, new Vector3f(vector).add(this.offsetX, this.offsetY, this.offsetZ)); - } - - @Override - public VertexConsumer addVertex(PoseStack.Pose pose, float x, float y, float z) { - return this.delegate.addVertex(pose, x + this.offsetX, y + this.offsetY, z + this.offsetZ); - } - - @Override - public VertexConsumer addVertex(Matrix4f matrix, float x, float y, float z) { - return this.delegate.addVertex(matrix, x + this.offsetX, y + this.offsetY, z + this.offsetZ); - } - - @Override - public VertexConsumer setNormal(PoseStack.Pose pose, float x, float y, float z) { - return this.delegate.setNormal(pose, x, y, z); - } -} - diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/util/level/StructurePreviewRenderer.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/util/level/StructurePreviewRenderer.java index b8fee9a..0f29fce 100644 --- a/src/main/java/dev/anvilcraft/resource/ageratum/client/util/level/StructurePreviewRenderer.java +++ b/src/main/java/dev/anvilcraft/resource/ageratum/client/util/level/StructurePreviewRenderer.java @@ -6,8 +6,6 @@ import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.blaze3d.vertex.VertexSorting; -import dev.anvilcraft.resource.ageratum.client.util.AlphaVertexConsumer; -import dev.anvilcraft.resource.ageratum.client.util.OffsetVertexConsumer; import dev.anvilcraft.resource.ageratum.client.util.SectionOffsetVertexConsumer; import dev.anvilcraft.resource.ageratum.client.util.SodiumSpriteBridge; import dev.anvilcraft.resource.ageratum.client.util.ViewportCameraRig; @@ -200,33 +198,6 @@ public void renderContent( }); } - /** - * 在主世界视图中以半透明形式渲染结构投影。 - */ - @SuppressWarnings("deprecation") - public void renderWorldProjection( - SandboxRenderLevel level, - PoseStack poseStack, - MultiBufferSource.BufferSource buffers, - Vec3 cameraPos, - BlockPos origin, - int visibleMinY, - int visibleMaxYExclusive, - float alpha - ) { - lightmap.update(level); - - var lightEngine = level.getLightEngine(); - while (lightEngine.hasLightWork()) { - lightEngine.runLightUpdates(); - } - - RenderSystem.runAsFancy(() -> { - this.renderProjectionBlocks(level, poseStack, buffers, cameraPos, origin, visibleMinY, visibleMaxYExclusive, alpha); - buffers.endBatch(RenderType.translucent()); - }); - } - private void renderBlocks( SandboxRenderLevel level, MultiBufferSource buffers, @@ -294,69 +265,6 @@ private void renderBlocks( }); } - private void renderProjectionBlocks( - SandboxRenderLevel level, - PoseStack poseStack, - MultiBufferSource buffers, - Vec3 cameraPos, - BlockPos origin, - int visibleMinY, - int visibleMaxYExclusive, - float alpha - ) { - var randomSource = level.random; - var blockRenderDispatcher = Minecraft.getInstance().getBlockRenderer(); - var layerView = new VisibleLayerBlockAndTintGetter(level, visibleMinY, visibleMaxYExclusive); - float offsetX = (float) (origin.getX() - cameraPos.x()); - float offsetY = (float) (origin.getY() - cameraPos.y()); - float offsetZ = (float) (origin.getZ() - cameraPos.z()); - - level.getFilledBlocks().forEach(pos -> { - if (!isVisibleLayer(pos.getY(), visibleMinY, visibleMaxYExclusive)) { - return; - } - - var blockState = level.getBlockState(pos); - var fluidState = blockState.getFluidState(); - if (!fluidState.isEmpty()) { - var baseBuffer = new AlphaVertexConsumer(buffers.getBuffer(RenderType.translucent()), alpha); - var sectionOffsetWriter = new SectionOffsetVertexConsumer(baseBuffer, SectionPos.of(pos)); - var projectionWriter = new OffsetVertexConsumer(sectionOffsetWriter, offsetX, offsetY, offsetZ); - blockRenderDispatcher.renderLiquid(pos, layerView, projectionWriter, blockState, fluidState); - markFluidSpritesActive(fluidState); - } - - if (blockState.getRenderShape() == RenderShape.INVISIBLE) { - return; - } - - var blockEntity = level.getBlockEntity(pos); - ModelData modelData = blockEntity != null ? blockEntity.getModelData() : ModelData.EMPTY; - var model = blockRenderDispatcher.getBlockModel(blockState); - modelData = model.getModelData(layerView, pos, blockState, modelData); - var renderTypes = model.getRenderTypes(blockState, randomSource, modelData); - - for (var renderType : renderTypes) { - var bufferBuilder = new AlphaVertexConsumer(buffers.getBuffer(RenderType.translucent()), alpha); - - poseStack.pushPose(); - poseStack.translate(offsetX + pos.getX(), offsetY + pos.getY(), offsetZ + pos.getZ()); - blockRenderDispatcher.renderBatched( - blockState, - pos, - layerView, - poseStack, - bufferBuilder, - true, - randomSource, - modelData, - renderType - ); - poseStack.popPose(); - } - }); - } - private void renderBlockEntities( SandboxRenderLevel level, MultiBufferSource buffers, diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/data/AgeratumLanguageProvider.java b/src/main/java/dev/anvilcraft/resource/ageratum/data/AgeratumLanguageProvider.java index ad0d32b..16f8f20 100644 --- a/src/main/java/dev/anvilcraft/resource/ageratum/data/AgeratumLanguageProvider.java +++ b/src/main/java/dev/anvilcraft/resource/ageratum/data/AgeratumLanguageProvider.java @@ -24,6 +24,12 @@ protected void addTranslations() { this.add("tooltip.ageratum.bind_item_hold", "Hold %s to get more info"); this.add("tooltip.ageratum.structure_projection.layer_shortcut", "Press %s / %s to adjust projection layers"); this.add("tooltip.ageratum.structure_projection.remove_shortcut", "Press %s to close projection"); + this.add("tooltip.ageratum.structure_export", "Export the full structure to the current server world's data/ageratum folder"); + this.add("system.ageratum.structure_export.sending", "Sending structure export to the server..."); + this.add("system.ageratum.structure_export.success", "Structure exported to the current world: %s"); + this.add("system.ageratum.structure_export.failed", "Structure export failed. Check the game/server log."); + this.add("system.ageratum.structure_export.too_large", "Structure export exceeds the 8 MiB compressed size limit."); + this.add("system.ageratum.structure_export.busy", "Please wait two seconds between structure exports."); this.add("key.ageratum.more_info", "Get More Info"); this.add("key.ageratum.structure_projection.layer_up", "Increase Projection Layers"); this.add("key.ageratum.structure_projection.layer_down", "Decrease Projection Layers"); diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/network/AgeratumNetwork.java b/src/main/java/dev/anvilcraft/resource/ageratum/network/AgeratumNetwork.java index 620e28a..2859b98 100644 --- a/src/main/java/dev/anvilcraft/resource/ageratum/network/AgeratumNetwork.java +++ b/src/main/java/dev/anvilcraft/resource/ageratum/network/AgeratumNetwork.java @@ -32,6 +32,11 @@ public static void register(RegisterPayloadHandlersEvent event) { OpenGuidePayload.STREAM_CODEC, ClientPayloadHandler::handleOpenGuide ); + registrar.playToServer( + ExportStructurePayload.TYPE, + ExportStructurePayload.STREAM_CODEC, + StructureExportHandler::handle + ); registrar.playToServer( ShareGuidePayload.TYPE, ShareGuidePayload.STREAM_CODEC, diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/network/ExportStructurePayload.java b/src/main/java/dev/anvilcraft/resource/ageratum/network/ExportStructurePayload.java new file mode 100644 index 0000000..d067bc0 --- /dev/null +++ b/src/main/java/dev/anvilcraft/resource/ageratum/network/ExportStructurePayload.java @@ -0,0 +1,30 @@ +package dev.anvilcraft.resource.ageratum.network; + +import dev.anvilcraft.resource.ageratum.Ageratum; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +/** Bounded chunks keep structure exports below the serverbound custom payload limit. */ +public record ExportStructurePayload(ResourceLocation location, int totalBytes, int offset, byte[] data) + implements CustomPacketPayload { + public static final int CHUNK_BYTES = 24 * 1024; + public static final int MAX_BYTES = 8 * 1024 * 1024; + public static final Type TYPE = new Type<>(Ageratum.location("export_structure")); + public static final StreamCodec STREAM_CODEC = StreamCodec.of( + (buffer, payload) -> { + buffer.writeResourceLocation(payload.location()); + buffer.writeVarInt(payload.totalBytes()); + buffer.writeVarInt(payload.offset()); + buffer.writeByteArray(payload.data()); + }, + buffer -> new ExportStructurePayload(buffer.readResourceLocation(), buffer.readVarInt(), buffer.readVarInt(), + buffer.readByteArray(CHUNK_BYTES)) + ); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/network/StructureExportHandler.java b/src/main/java/dev/anvilcraft/resource/ageratum/network/StructureExportHandler.java new file mode 100644 index 0000000..eb0564a --- /dev/null +++ b/src/main/java/dev/anvilcraft/resource/ageratum/network/StructureExportHandler.java @@ -0,0 +1,105 @@ +package dev.anvilcraft.resource.ageratum.network; + +import dev.anvilcraft.resource.ageratum.Ageratum; +import dev.anvilcraft.resource.ageratum.structure.StructureExporter; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.storage.LevelResource; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; +import net.neoforged.neoforge.network.handling.IPayloadContext; + +import java.io.ByteArrayOutputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Slf4j +@EventBusSubscriber(modid = Ageratum.MOD_ID) +public final class StructureExportHandler { + private static final Map TRANSFERS = new HashMap<>(); + private static final Map LAST_REQUEST = new HashMap<>(); + private static final long TIMEOUT_NANOS = 30_000_000_000L; + + private StructureExportHandler() { + } + + public static void handle(ExportStructurePayload payload, IPayloadContext context) { + context.enqueueWork(() -> { + if (!(context.player() instanceof ServerPlayer player)) return; + UUID id = player.getUUID(); + long now = System.nanoTime(); + if (payload.offset() == 0) { + Long last = LAST_REQUEST.get(id); + if (last != null && now - last < 2_000_000_000L) { + player.sendSystemMessage(Component.translatable("system.ageratum.structure_export.busy")); + return; + } + LAST_REQUEST.put(id, now); + TRANSFERS.remove(id); + if (payload.totalBytes() <= 0 || payload.totalBytes() > ExportStructurePayload.MAX_BYTES + || payload.location().toString().length() > 256) { + fail(player); + return; + } + TRANSFERS.put(id, new Transfer(payload.location(), payload.totalBytes(), now)); + } + Transfer transfer = TRANSFERS.get(id); + if (transfer == null) return; + if (now - transfer.started > TIMEOUT_NANOS || !transfer.location.equals(payload.location()) + || transfer.totalBytes != payload.totalBytes() || transfer.bytes.size() != payload.offset() + || payload.data().length == 0 || payload.data().length > ExportStructurePayload.CHUNK_BYTES + || payload.data().length > transfer.totalBytes - transfer.bytes.size()) { + TRANSFERS.remove(id); + fail(player); + return; + } + transfer.bytes.writeBytes(payload.data()); + if (transfer.bytes.size() != transfer.totalBytes) return; + TRANSFERS.remove(id); + try { + var root = StructureExporter.read(transfer.bytes.toByteArray()); + var output = StructureExporter.write(player.server.getWorldPath(LevelResource.ROOT), transfer.location, root); + player.sendSystemMessage(Component.translatable("system.ageratum.structure_export.success", + "data/ageratum/" + output.getFileName())); + } catch (Exception exception) { + log.warn("Failed to export structure {} for {}", transfer.location, player.getGameProfile().getName(), exception); + fail(player); + } + }); + } + + private static void fail(ServerPlayer player) { + player.sendSystemMessage(Component.translatable("system.ageratum.structure_export.failed")); + } + + @SubscribeEvent + public static void onLogout(PlayerEvent.PlayerLoggedOutEvent event) { + TRANSFERS.remove(event.getEntity().getUUID()); + LAST_REQUEST.remove(event.getEntity().getUUID()); + } + + @SubscribeEvent + public static void onServerTick(ServerTickEvent.Post event) { + long now = System.nanoTime(); + TRANSFERS.entrySet().removeIf(entry -> now - entry.getValue().started > TIMEOUT_NANOS); + LAST_REQUEST.entrySet().removeIf(entry -> now - entry.getValue() > TIMEOUT_NANOS); + } + + private static final class Transfer { + private final ResourceLocation location; + private final int totalBytes; + private final long started; + private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + private Transfer(ResourceLocation location, int totalBytes, long started) { + this.location = location; + this.totalBytes = totalBytes; + this.started = started; + } + } +} diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/structure/StructureExporter.java b/src/main/java/dev/anvilcraft/resource/ageratum/structure/StructureExporter.java new file mode 100644 index 0000000..66b1c19 --- /dev/null +++ b/src/main/java/dev/anvilcraft/resource/ageratum/structure/StructureExporter.java @@ -0,0 +1,62 @@ +package dev.anvilcraft.resource.ageratum.structure; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.ResourceLocation; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +/** Writes complete vanilla structure NBT without replacing existing exports. */ +public final class StructureExporter { + private StructureExporter() { + } + + public static CompoundTag read(byte[] compressed) throws IOException { + CompoundTag root = NbtIo.readCompressed(new ByteArrayInputStream(compressed), NbtAccounter.create(64L * 1024 * 1024)); + if (!root.contains("size", Tag.TAG_LIST) || root.getList("size", Tag.TAG_INT).size() != 3 + || !root.contains("blocks", Tag.TAG_LIST) + || (!root.contains("palette", Tag.TAG_LIST) && !root.contains("palettes", Tag.TAG_LIST))) { + throw new IOException("Not a structure template"); + } + return root; + } + + public static Path write(Path worldRoot, ResourceLocation location, CompoundTag root) throws IOException { + Path directory = worldRoot.resolve("data").resolve("ageratum"); + Files.createDirectories(directory); + String path = location.getPath(); + String name = path.substring(path.lastIndexOf('/') + 1).replaceFirst("\\.(snbt|nbt)$", ""); + // Prefixing with the namespace also avoids Windows reserved device filenames. + String baseName = location.getNamespace() + "_" + name; + for (int index = 0; ; index++) { + Path output = directory.resolve(baseName + (index == 0 ? "" : "_" + index) + ".nbt"); + boolean created = false; + try { + try (var stream = Files.newOutputStream(output, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + created = true; + NbtIo.writeCompressed(root, stream); + } + return output; + } catch (FileAlreadyExistsException ignored) { + // Another export already owns this name. Try the next suffix. + } catch (IOException exception) { + if (created) { + // The stream is closed before cleanup, including on Windows. + try { + Files.deleteIfExists(output); + } catch (IOException cleanupFailure) { + exception.addSuppressed(cleanupFailure); + } + } + throw exception; + } + } + } +} diff --git a/src/main/resources/assets/ageratum/lang/zh_cn.json b/src/main/resources/assets/ageratum/lang/zh_cn.json index 132f89f..9a5874a 100644 --- a/src/main/resources/assets/ageratum/lang/zh_cn.json +++ b/src/main/resources/assets/ageratum/lang/zh_cn.json @@ -33,5 +33,11 @@ "system.ageratum.share.tip": "玩家 %s 向您分享了一份指南:", "tooltip.ageratum.bind_item_hold": "按住 %s 寻思", "tooltip.ageratum.structure_projection.layer_shortcut": "按下 %s / %s 调整投影层数", - "tooltip.ageratum.structure_projection.remove_shortcut": "按下 %s 关闭投影" + "tooltip.ageratum.structure_projection.remove_shortcut": "按下 %s 关闭投影", + "tooltip.ageratum.structure_export": "导出完整结构到当前服务端存档的 data/ageratum 文件夹", + "system.ageratum.structure_export.sending": "正在向服务端发送结构导出请求……", + "system.ageratum.structure_export.success": "结构已导出到当前存档:%s", + "system.ageratum.structure_export.failed": "结构导出失败,请查看游戏或服务端日志。", + "system.ageratum.structure_export.too_large": "结构导出的压缩大小超过 8 MiB 限制。", + "system.ageratum.structure_export.busy": "请等待两秒后再导出结构。" } diff --git a/src/main/templates/META-INF/neoforge.mods.toml b/src/main/templates/META-INF/neoforge.mods.toml index 8154705..62a53c3 100644 --- a/src/main/templates/META-INF/neoforge.mods.toml +++ b/src/main/templates/META-INF/neoforge.mods.toml @@ -72,6 +72,20 @@ versionRange = "${minecraft_version_range}" ordering = "NONE" side = "BOTH" +[[dependencies."${mod_id}"]] +modId = "anvillib_config" +type = "required" +versionRange = "[2.0.0+snapshot.534,)" +ordering = "NONE" +side = "BOTH" + +[[dependencies."${mod_id}"]] +modId = "anvillib_renderer" +type = "required" +versionRange = "[2.0.0+snapshot.534,)" +ordering = "NONE" +side = "CLIENT" + # Features are specific properties of the game environment, that you may want to declare you require. This example declares # that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't # stop your mod loading on the server for example. diff --git a/src/test/java/dev/anvilcraft/resource/ageratum/structure/StructureExporterTest.java b/src/test/java/dev/anvilcraft/resource/ageratum/structure/StructureExporterTest.java new file mode 100644 index 0000000..e24c6a2 --- /dev/null +++ b/src/test/java/dev/anvilcraft/resource/ageratum/structure/StructureExporterTest.java @@ -0,0 +1,67 @@ +package dev.anvilcraft.resource.ageratum.structure; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.TagParser; +import net.minecraft.resources.ResourceLocation; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class StructureExporterTest { + @TempDir + Path world; + + private static CompoundTag structure() throws Exception { + return TagParser.parseTag(""" + {size:[1,2,1],palette:[{Name:"minecraft:chest"}], + blocks:[{pos:[0,0,0],state:0,nbt:{CustomName:'{"text":"Saved chest"}'}}], + entities:[{pos:[0.5d,1.0d,0.5d],blockPos:[0,1,0],nbt:{id:"minecraft:armor_stand"}}]} + """); + } + + @Test + void createsWorldDirectoryAndPreservesFullNbt() throws Exception { + CompoundTag root = structure(); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + NbtIo.writeCompressed(root, bytes); + CompoundTag received = StructureExporter.read(bytes.toByteArray()); + Path output = StructureExporter.write(world, ResourceLocation.parse("manual:structures/example.snbt"), received); + assertEquals(world.resolve("data/ageratum/manual_example.nbt"), output); + assertEquals(root, NbtIo.readCompressed(output, NbtAccounter.unlimitedHeap())); + } + + @Test + void usesFirstFreeSuffixWithoutOverwriting() throws Exception { + Path directory = Files.createDirectories(world.resolve("data/ageratum")); + Files.writeString(directory.resolve("manual_example.nbt"), "existing"); + Files.writeString(directory.resolve("manual_example_2.nbt"), "also existing"); + ResourceLocation location = ResourceLocation.parse("manual:example.nbt"); + assertEquals("manual_example_1.nbt", StructureExporter.write(world, location, structure()).getFileName().toString()); + assertEquals("manual_example_3.nbt", StructureExporter.write(world, location, structure()).getFileName().toString()); + assertEquals("existing", Files.readString(directory.resolve("manual_example.nbt"))); + assertEquals("also existing", Files.readString(directory.resolve("manual_example_2.nbt"))); + } + + @Test + void resourcePathsCannotEscapeExportDirectory() throws Exception { + Path output = StructureExporter.write(world, ResourceLocation.parse("manual:../../con.nbt"), structure()); + assertEquals(world.resolve("data/ageratum/manual_con.nbt"), output); + assertTrue(Files.isRegularFile(output)); + } + + @Test + void rejectsCorruptOrNonStructurePayloads() throws Exception { + assertThrows(IOException.class, () -> StructureExporter.read(new byte[]{1, 2, 3})); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + NbtIo.writeCompressed(new CompoundTag(), bytes); + assertThrows(IOException.class, () -> StructureExporter.read(bytes.toByteArray())); + } +}