Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@ idea {
lombok {
version = "1.18.34"
}

if (providers.gradleProperty('structureTest').isPresent()) {
apply from: 'gradle/scripts/structure-test.gradle'
}
13 changes: 13 additions & 0 deletions dependencies.gradle
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions docs/en/10-structure-preview-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

17 changes: 17 additions & 0 deletions docs/zh/10-structure-preview-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
- 自动测试不替代实际手册按钮布局、视觉效果和远程多人服务器的人工验收。

3 changes: 2 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 3 additions & 1 deletion gradle/scripts/repositories.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
repositories {
mavenLocal()
mavenLocal {
content { excludeGroup "dev.anvilcraft.lib" }
}
mavenCentral()
maven { // Anvil Lib
name = "Cjsah Maven"
Expand Down
30 changes: 30 additions & 0 deletions gradle/scripts/structure-test.gradle
Original file line number Diff line number Diff line change
@@ -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')
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Void> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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 = "<init>", 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);
}
}
15 changes: 15 additions & 0 deletions src/clientTest/resources/META-INF/neoforge.mods.toml
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 6 additions & 0 deletions src/clientTest/resources/ageratum_structure_test.mixins.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"required": true,
"package": "dev.anvilcraft.resource.ageratum.test.mixin",
"compatibilityLevel": "JAVA_21",
"client": ["HiddenWindowMixin"]
}
6 changes: 6 additions & 0 deletions src/generated/resources/assets/ageratum/lang/en_us.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading
Loading