diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f9c6fb..07cb2ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,9 +56,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/SkysGrassSlabs-1.1.0.112021.jar - build/libs/SkysGrassSlabs-1.1.0.112021-sources.jar - build/libs/SkysGrassSlabs-1.1.0.112021-javadoc.jar + build/libs/SkysGrassSlabs-1.1.1.112021.jar + build/libs/SkysGrassSlabs-1.1.1.112021-sources.jar + build/libs/SkysGrassSlabs-1.1.1.112021-javadoc.jar build/release/SHA256SUMS CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bc85bfd..4c78aa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.1.1.112021 - Minecraft 1.12.2 + +- Added a Config screen with help for each setting. Save your changes and + restart Minecraft to apply them. +- Fixed gaps in new grass slopes where neighbouring decoration had already + placed grass or flowers. Trees, crops and occupied blocks stay protected. +- Kept exposed grass beneath Building Pieces steps and corners grassy. + Full-cover slabs keep their usual dirt support. +- Replaced the warnings for intentional legacy slab registrations with one + clear recovery notice. Saved IDs and recovery rules are unchanged. +- Fixed the Mods page, Config button and launch variables after Eclipse rebuilds. + ## 1.1.0.112021 - Minecraft 1.12.2 - Added optional shared grass behaviour with Sky's Building Pieces. diff --git a/README.md b/README.md index 6ca33a2..8dd2b9a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ turf recipe in the recipe book. ## Smoother terrain By default, grass slabs soften suitable one-block slopes in new Overworld -chunks. Existing terrain stays as it is. You can turn generation off in +chunks. Existing terrain stays as it is. To turn generation off, select the mod +in **Mods → Config**, save your choice and restart Minecraft. You can also edit `config/skysgrassslabs.cfg`; see [configuration](docs/CONFIGURATION.md). The mod works on its own. If you install diff --git a/build.gradle b/build.gradle index 67ac76d..0244cbc 100644 --- a/build.gradle +++ b/build.gradle @@ -222,6 +222,8 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' } +apply from: 'gradle/runtime-log-checks.gradle' + def integrationTestClasses = layout.buildDirectory.dir('integration-test-mod/classes') def compileIntegrationTestMod = tasks.register('compileIntegrationTestMod', JavaCompile) { group = 'verification' @@ -278,11 +280,16 @@ def prepareIntegrationTest = tasks.register('prepareIntegrationTest') { into mods } new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + ['banned-players.json', 'banned-ips.json', 'ops.json', 'whitelist.json'].each { name -> + new File(runDirectory, name).setText('[]\n', 'UTF-8') + } new File(runDirectory, 'server.properties').setText('''\ level-name=world level-seed=skys-grass-slabs-integration level-type=default online-mode=false +server-ip=127.0.0.1 +server-port=0 generate-structures=false spawn-animals=false spawn-monsters=false @@ -298,6 +305,7 @@ def createIntegrationProcess = { String phase, Object dependency -> dependsOn dependency workingDir integrationRunDirectory systemProperty 'skysgrassslabs.integrationPhase', phase.toLowerCase(Locale.ROOT) + systemProperty 'log4j.configurationFile', file('src/integrationTest/resources/log4j2.xml').toURI().toString() configureFromForgeRun(delegate, 'runServer') doLast { File marker = new File(integrationRunDirectory.get().asFile, @@ -310,6 +318,14 @@ def createIntegrationProcess = { String phase, Object dependency -> if (values.getProperty("${phase.toLowerCase(Locale.ROOT)}_complete") != 'true') { throw new GradleException("${phase} integration did not complete: ${marker}") } + File log = new File(integrationRunDirectory.get().asFile, 'logs/latest.log') + File archived = layout.buildDirectory.file("integration-logs/${phase.toLowerCase(Locale.ROOT)}.log").get().asFile + archived.parentFile.mkdirs() + archived.bytes = log.bytes + project.ext.auditRuntimeLog(archived, [ + kind: 'development', side: 'server', + directory: integrationRunDirectory.get().asFile + ]) } } } @@ -389,6 +405,7 @@ def clientIntegrationProcess = tasks.register('clientIntegrationProcess', JavaEx dependsOn prepareClientIntegrationTest workingDir clientIntegrationRunDirectory systemProperty 'skysgrassslabs.clientProbe', 'true' + systemProperty 'log4j.configurationFile', file('src/integrationTest/resources/log4j2.xml').toURI().toString() configureFromForgeRun(delegate, 'runClient') doLast { File runDirectory = clientIntegrationRunDirectory.get().asFile @@ -398,12 +415,14 @@ def clientIntegrationProcess = tasks.register('clientIntegrationProcess', JavaEx } Properties values = new Properties() marker.withInputStream { values.load(it) } - ['models_verified', 'recipe_book_verified', 'first_world_rendered', + ['models_verified', 'recipe_book_verified', 'config_screen_verified', 'first_world_rendered', 'reload_rendered'].each { key -> if (values.getProperty(key) != 'true') { throw new GradleException("Development client failed ${key}: ${values}") } } + project.ext.auditRuntimeLog(new File(runDirectory, 'logs/latest.log'), + [kind: 'development', side: 'client', directory: runDirectory]) } } @@ -478,6 +497,9 @@ def preparePackagedRuntime = { String taskName, String directoryName, Object fix outputs.dir(runDirectory) outputs.upToDateWhen { false } doLast { + if (fixtureArchive != null) { + project.ext.verifyRuntimeFixture(file(fixtureArchive)) + } project.delete(runDirectory) project.copy { from packagedServerBase @@ -497,6 +519,7 @@ def preparePackagedRuntime = { String taskName, String directoryName, Object fix from zipTree(file(fixtureArchive)) into new File(runDirectory, 'world') } + project.ext.writeRuntimeFixtureContext(runDirectory, file(fixtureArchive)) } new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') new File(runDirectory, 'server.properties').setText('''\ @@ -504,6 +527,8 @@ level-name=world level-seed=skys-grass-slabs-packaged level-type=default online-mode=false +server-ip=127.0.0.1 +server-port=0 generate-structures=false spawn-animals=false spawn-monsters=false @@ -522,12 +547,20 @@ def createPackagedServerProcess = { String taskName, String phase, String direct File runDirectory = layout.buildDirectory.dir(directoryName).get().asFile workingDir runDirectory doFirst { + ['banned-players.json', 'banned-ips.json', 'ops.json', 'whitelist.json'].each { name -> + File list = new File(runDirectory, name) + if (!list.exists()) { + list.setText('[]\n', 'UTF-8') + } + } List command = [ java8Launcher.get().executablePath.asFile.absolutePath, '-Djava.net.preferIPv4Stack=true', '-Dforge.logging.console.level=info', "-Dskysgrassslabs.integrationPhase=${phase}" ] + command.add('-Dlog4j.configurationFile=' + + file('src/integrationTest/resources/log4j2.xml').toURI().toString()) if (phase.startsWith('forward-sylvester')) { command.addAll(['-Xms1G', '-Xmx8G']) } @@ -549,6 +582,12 @@ def createPackagedServerProcess = { String taskName, String phase, String direct if (values.getProperty(key) != 'true') { throw new GradleException("Packaged ${phase} did not complete: ${marker}") } + File log = new File(runDirectory, 'logs/latest.log') + File archived = layout.buildDirectory.file("runtime-logs/${taskName}.log").get().asFile + archived.parentFile.mkdirs() + archived.bytes = log.bytes + project.ext.auditRuntimeLog(archived, + [kind: 'packaged', side: 'server', directory: runDirectory]) } } } @@ -632,6 +671,8 @@ level-name=world level-seed=skys-grass-slabs-orespawn-mineralogy level-type=default online-mode=false +server-ip=127.0.0.1 +server-port=0 generate-structures=false spawn-animals=false spawn-monsters=false @@ -766,6 +807,8 @@ def prepareSylvesterForwardUpgradeTest = tasks.register( new File(runDirectory, 'server.properties').setText('''\ level-name=world online-mode=false +server-ip=127.0.0.1 +server-port=0 generate-structures=false spawn-animals=false spawn-monsters=false @@ -944,6 +987,7 @@ def packagedClientProcess = tasks.register('packagedClientProcess', Exec) { ] commandLine java8Launcher.get().executablePath.asFile.absolutePath, '-Xms512m', '-Xmx2g', '-Dskysgrassslabs.clientProbe=true', + '-Dlog4j.configurationFile=' + file('src/integrationTest/resources/log4j2.xml').toURI().toString(), "-Djava.library.path=${natives.absolutePath}", '-Dminecraft.launcher.brand=skysgrassslabs-validation', '-Dminecraft.launcher.version=1', @@ -965,13 +1009,15 @@ tasks.register('packagedClientIntegrationTest') { } Properties values = new Properties() marker.withInputStream { values.load(it) } - ['models_verified', 'recipe_book_verified', 'first_world_rendered', + ['models_verified', 'recipe_book_verified', 'config_screen_verified', 'first_world_rendered', 'reload_rendered'].each { key -> if (values.getProperty(key) != 'true') { throw new GradleException("Packaged client failed ${key}: ${values}") } } verifyPackagedRun(runDirectory, 'Packaged client') + project.ext.auditRuntimeLog(new File(runDirectory, 'logs/latest.log'), + [kind: 'packaged', side: 'client', directory: runDirectory]) } } @@ -1081,6 +1127,7 @@ tasks.register('writeReleaseChecksums') { group = 'verification' dependsOn tasks.named('verifyReleaseArtifacts') def output = layout.buildDirectory.file('release/SHA256SUMS') + inputs.files(releaseFiles.map { names -> names.collect { layout.buildDirectory.file("libs/${it}") } }) outputs.file(output) doLast { File libs = layout.buildDirectory.dir('libs').get().asFile @@ -1194,8 +1241,27 @@ idea { module { downloadSources = true; downloadJavadoc = true } } eclipse { - classpath { downloadSources = true; downloadJavadoc = true } - synchronizationTasks 'genEclipseRuns' + classpath { + downloadSources = true + downloadJavadoc = true + file.whenMerged { classpath -> + // Eclipse copies these files on every build. Use Gradle's expanded metadata, never the template. + def resources = classpath.entries.findAll { + it instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder && + it.path in ['src/main/resources', 'build/resources/main'] + } + // Keep one entry when Gradle merges an existing Eclipse classpath. + resources.drop(1).each { classpath.entries.remove(it) } + resources.take(1).each { entry -> + entry.path = 'build/resources/main' + } + } + } + synchronizationTasks 'processResources', 'genEclipseRuns' +} + +tasks.named('eclipseClasspath') { + dependsOn tasks.named('processResources') } def prepareEclipseResources = tasks.register('prepareEclipseResources') { @@ -1239,6 +1305,7 @@ tasks.register('isolateEclipseProductionRuns') { include 'run*.launch' }.files.each { File launch -> String contents = launch.getText('UTF-8') + contents = contents.replace('${MC_VERSION}', project.minecraft_version.toString()) contents = contents.replace( 'key="MC_VERSION" value="${MC_VERSION}"', "key=\"MC_VERSION\" value=\"${minecraft_version}\"") @@ -1288,6 +1355,12 @@ tasks.register('verifyEclipseProductionClasspath') { throw new GradleException('Eclipse Buildship uses the wrong Gradle home') } File metadata = file('bin/main/mcmod.info') + def eclipseClasspath = new groovy.xml.XmlSlurper().parse(file('.classpath')) + def resourceFolders = eclipseClasspath.classpathentry.findAll { it.@kind == 'src' }*.@path*.text() + if (resourceFolders.contains('src/main/resources') || + resourceFolders.count { it == 'build/resources/main' } != 1) { + throw new GradleException('Eclipse must copy processed resources, not overwrite them with templates') + } def parsed = new JsonSlurper().parseText(metadata.getText('UTF-8')) if (parsed[0].version != project.mod_version || parsed[0].mcversion != project.minecraft_version) { throw new GradleException('Eclipse output contains incorrect mcmod.info') @@ -1295,6 +1368,7 @@ tasks.register('verifyEclipseProductionClasspath') { ['runClient.launch', 'runServer.launch'].each { name -> String contents = file(name).getText('UTF-8').replace('\\', '/') if (!contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE') || + contents.contains('${MC_VERSION}') || contents.contains('src/test') || contents.contains('bin/test') || contents.contains('junit-')) { throw new GradleException("${name} exposes test code or dependencies") } diff --git a/docs/BUILDING.md b/docs/BUILDING.md new file mode 100644 index 0000000..2f8bccb --- /dev/null +++ b/docs/BUILDING.md @@ -0,0 +1,37 @@ +# Building and checking this branch + +Use the Gradle wrapper with Java 17 and set `SGS_JAVA8_HOME` to the Java 8 +installation used for Minecraft. Run these commands from the checkout: + +```text +gradlew clean check build javadoc genEclipseRuns verifyEclipseProductionClasspath verifyReleaseArtifacts writeReleaseChecksums +``` + +The checks include unit tests and disposable fresh/reload servers. Test mods +are not included in the main jar or ordinary Eclipse launches. + +## Runtime logs + +Each integration server log is saved and checked before the next process can +overwrite it. An unexpected warning, error, crash, missing recovery notice or +remaining legacy-prefix warning fails the check. + +To check another captured verification log: + +```text +gradlew verifyRuntimeLog -PruntimeLog= -PruntimeDirectory= -PruntimeKind=packaged -PruntimeSide=server +``` + +Use `development` only for a mapped development launch and `client` for a +client log. Offline-server warnings are accepted only when that test folder +is configured for loopback networking on an automatically allocated port. +Use `-PrecoveryMessage=false` when the original legacy mod is installed. + +The development allowances cover specific old-Forge launcher and class-scanner +diagnostics, with their expected file names and paired messages. They do not +permit arbitrary corrupt jars or missing classes. Packaged runs do not inherit +those allowances. Unrelated warnings remain visible in the game logs. + +Cross-version fixtures have separate, reviewed diagnostics tied to their archive +checksums. These cover the test-only generator mod and exact vanilla numeric-ID +changes. They are not applied to ordinary worlds or used to silence the game. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b6e1cb1..67d97c8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,9 +1,12 @@ # Configuration -Forge creates `config/skysgrassslabs.cfg` after the mod is started for the -first time. Stop the game or server before editing it, then restart to apply -changes. On a dedicated server, the server's configuration controls world -generation and world upgrades. +Open **Mods**, select **Sky's Grass Slabs** and choose **Config** to change +world generation and the legacy replacement option. Save with **Done**, then +restart Minecraft to apply your choices. Settings are not changed mid-world. + +You can also edit `config/skysgrassslabs.cfg` while the game or server is +stopped. On a dedicated server, edit the server's file and restart it; the +client's Config screen does not change a remote server. ## World smoothing diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index e3583b2..4c0913b 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -1,5 +1,10 @@ # Optional content integration +`coversSupport(state, world, pos)` reports whether a piece covers the whole +horizontal footprint. Support repair leaves grass beneath narrower steps and +corners intact, and that exposed soil can regrow. Full-cover slabs and turf +retain their previous support rules. + Grass Slabs works on its own. Content and terrain mods can use version 1 of `zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi` without changing its saved IDs. diff --git a/docs/RELEASE-1.1.1.112021.md b/docs/RELEASE-1.1.1.112021.md new file mode 100644 index 0000000..250f87d --- /dev/null +++ b/docs/RELEASE-1.1.1.112021.md @@ -0,0 +1,20 @@ +# Sky's Grass Slabs 1.1.1 for Minecraft 1.12.2 + +You can now change the world-generation and legacy replacement settings from +**Mods → Sky's Grass Slabs → Config**. Each setting has help text. Save your +changes and restart Minecraft to apply them. + +Grass slab smoothing now handles small plants that neighbouring decoration has +already placed on a slope. Trees, crops and occupied blocks stay protected, +and existing terrain is unchanged. + +With Building Pieces installed, exposed grass beneath steps and corners stays +grassy. Full-cover slabs keep their usual dirt support. Grass Slabs still works +on its own, with the same blocks, recipes, configuration defaults and saved +migration progress. + +Legacy recovery now explains its intentional registrations with one startup +notice. The historical IDs and recovery rules have not changed. + +Requires Minecraft 1.12.2, Forge 14.23.5.2859 and Java 8. Back up your world before +updating mods or moving a world from an older Minecraft version. diff --git a/docs/WORLD-UPGRADES.md b/docs/WORLD-UPGRADES.md index aa13c11..e1e712f 100644 --- a/docs/WORLD-UPGRADES.md +++ b/docs/WORLD-UPGRADES.md @@ -6,7 +6,7 @@ world has been checked in game. ## Sky's Grass Slabs content -Install `SkysGrassSlabs-1.1.0.112021.jar` before opening a world that used the +Install `SkysGrassSlabs-1.1.1.112021.jar` before opening a world that used the Minecraft 1.10.2 or 1.11.2 release. The permanent dirt slab, grass slab, path slab and turf IDs are unchanged. diff --git a/gradle.properties b/gradle.properties index e4a2d2c..1bea53f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,7 +23,7 @@ curseforge_project_id=1677588 mod_id=skysgrassslabs mod_name=Sky's Grass Slabs mod_license=LGPL-2.1-only -mod_version=1.1.0.112021 +mod_version=1.1.1.112021 mod_group=zone.moddev.mc.skysgrassslabs mod_authors=SkyBlade1978 mod_description=Adds dirt, grass and path slabs, turf, natural grass spreading and smoother Overworld terrain. diff --git a/gradle/runtime-fixture-diagnostics.json b/gradle/runtime-fixture-diagnostics.json new file mode 100644 index 0000000..188b1c5 --- /dev/null +++ b/gradle/runtime-fixture-diagnostics.json @@ -0,0 +1,692 @@ +{ + "fixtures": { + "skysgrassslabs-1.11.2-forward-world.zip": { + "path": "src/test/resources/fixtures/skysgrassslabs-1.11.2-forward-world.zip", + "sha256": "E6ABAECBC818C4EB28D3324ACC27DCCCD30247B4268879D7A789F0E1F55028F5", + "generator": "skysgrassslabsadjacentfixture", + "blocks": { + "minecraft:white_glazed_terracotta": [ + 235, + 242 + ], + "minecraft:yellow_glazed_terracotta": [ + 239, + 243 + ], + "minecraft:pink_glazed_terracotta": [ + 241, + 244 + ], + "minecraft:cyan_glazed_terracotta": [ + 244, + 246 + ], + "minecraft:orange_glazed_terracotta": [ + 236, + 247 + ], + "minecraft:lime_glazed_terracotta": [ + 240, + 253 + ], + "minecraft:gray_glazed_terracotta": [ + 242, + 254 + ], + "minecraft:silver_glazed_terracotta": [ + 243, + 256 + ], + "minecraft:magenta_glazed_terracotta": [ + 237, + 257 + ], + "minecraft:light_blue_glazed_terracotta": [ + 238, + 258 + ], + "minecraft:blue_glazed_terracotta": [ + 246, + 259 + ], + "minecraft:brown_glazed_terracotta": [ + 247, + 260 + ] + }, + "sounds": { + "minecraft:entity.parrot.imitate.ghast": [ + 331, + 493 + ], + "minecraft:entity.parrot.imitate.blaze": [ + 324, + 494 + ], + "minecraft:entity.boat.paddle_water": [ + 151, + 495 + ], + "minecraft:entity.illusion_illager.hurt": [ + 277, + 496 + ], + "minecraft:entity.parrot.imitate.stray": [ + 341, + 497 + ], + "minecraft:entity.parrot.imitate.zombie": [ + 348, + 498 + ], + "minecraft:entity.illusion_illager.ambient": [ + 274, + 499 + ], + "minecraft:entity.parrot.imitate.polar_bear": [ + 335, + 500 + ], + "minecraft:entity.parrot.imitate.husk": [ + 332, + 501 + ], + "minecraft:block.note.chime": [ + 73, + 502 + ], + "minecraft:entity.parrot.imitate.witch": [ + 344, + 503 + ], + "minecraft:entity.parrot.hurt": [ + 323, + 504 + ], + "minecraft:entity.endereye.death": [ + 195, + 505 + ], + "minecraft:entity.parrot.imitate.enderman": [ + 328, + 506 + ], + "minecraft:block.note.guitar": [ + 75, + 507 + ], + "minecraft:entity.parrot.imitate.creeper": [ + 325, + 508 + ], + "minecraft:entity.parrot.imitate.skeleton": [ + 338, + 509 + ], + "minecraft:entity.parrot.imitate.wither_skeleton": [ + 346, + 510 + ], + "minecraft:entity.illusion_illager.mirror_move": [ + 278, + 511 + ], + "minecraft:entity.parrot.imitate.wolf": [ + 347, + 512 + ], + "minecraft:entity.parrot.death": [ + 320, + 513 + ], + "minecraft:entity.player.hurt_drown": [ + 368, + 514 + ], + "minecraft:entity.illusion_illager.prepare_blindness": [ + 279, + 515 + ], + "minecraft:entity.parrot.imitate.vex": [ + 342, + 516 + ], + "minecraft:entity.player.hurt_on_fire": [ + 369, + 517 + ], + "minecraft:entity.parrot.imitate.illusion_illager": [ + 333, + 518 + ], + "minecraft:entity.parrot.imitate.zombie_pigman": [ + 349, + 519 + ], + "minecraft:entity.parrot.ambient": [ + 319, + 520 + ], + "minecraft:entity.parrot.imitate.vindication_illager": [ + 343, + 521 + ], + "minecraft:entity.parrot.imitate.silverfish": [ + 337, + 522 + ], + "minecraft:block.note.bell": [ + 72, + 523 + ], + "minecraft:block.end_portal_frame.fill": [ + 27, + 524 + ], + "minecraft:entity.parrot.eat": [ + 321, + 525 + ], + "minecraft:entity.parrot.imitate.evocation_illager": [ + 330, + 526 + ], + "minecraft:entity.illusion_illager.prepare_mirror": [ + 280, + 527 + ], + "minecraft:entity.parrot.imitate.magmacube": [ + 334, + 528 + ], + "minecraft:block.note.xylophone": [ + 80, + 529 + ], + "minecraft:entity.parrot.imitate.elder_guardian": [ + 326, + 530 + ], + "minecraft:entity.parrot.imitate.endermite": [ + 329, + 531 + ], + "minecraft:block.end_portal.spawn": [ + 26, + 532 + ], + "minecraft:entity.illusion_illager.cast_spell": [ + 275, + 533 + ], + "minecraft:block.note.flute": [ + 74, + 534 + ], + "minecraft:entity.parrot.imitate.enderdragon": [ + 327, + 535 + ], + "minecraft:entity.parrot.imitate.shulker": [ + 336, + 536 + ], + "minecraft:entity.parrot.imitate.slime": [ + 339, + 537 + ], + "minecraft:entity.bobber.retrieve": [ + 152, + 538 + ], + "minecraft:entity.parrot.imitate.spider": [ + 340, + 539 + ], + "minecraft:entity.parrot.fly": [ + 322, + 540 + ], + "minecraft:entity.boat.paddle_land": [ + 150, + 541 + ], + "minecraft:entity.illusion_illager.death": [ + 276, + 542 + ], + "minecraft:entity.parrot.imitate.wither": [ + 345, + 543 + ], + "minecraft:entity.parrot.imitate.zombie_villager": [ + 350, + 544 + ], + "minecraft:ui.toast.in": [ + 544, + 547 + ], + "minecraft:entity.parrot.step": [ + 351, + 548 + ] + } + }, + "skysgrassslabs-1.10.2-forward-world.zip": { + "path": "src/test/resources/fixtures/skysgrassslabs-1.10.2-forward-world.zip", + "sha256": "D6923BFFE062C1F0C454190AB11F031825949DF8080D8000133A723DEC2770BF", + "generator": "skysgrassslabsforwardfixture", + "blocks": { + "minecraft:observer": [ + 218, + 222 + ], + "minecraft:white_shulker_box": [ + 219, + 223 + ], + "minecraft:magenta_shulker_box": [ + 221, + 225 + ], + "minecraft:pink_shulker_box": [ + 225, + 226 + ], + "minecraft:yellow_shulker_box": [ + 223, + 227 + ], + "minecraft:orange_shulker_box": [ + 220, + 232 + ], + "minecraft:green_shulker_box": [ + 232, + 233 + ], + "minecraft:silver_shulker_box": [ + 227, + 234 + ], + "minecraft:black_shulker_box": [ + 234, + 243 + ], + "minecraft:gray_shulker_box": [ + 226, + 246 + ], + "minecraft:silver_glazed_terracotta": [ + 243, + 247 + ], + "minecraft:light_blue_shulker_box": [ + 222, + 253 + ], + "minecraft:red_shulker_box": [ + 233, + 254 + ], + "minecraft:blue_glazed_terracotta": [ + 246, + 256 + ], + "minecraft:brown_glazed_terracotta": [ + 247, + 257 + ] + }, + "sounds": { + "minecraft:entity.llama.chest": [ + 299, + 463 + ], + "minecraft:entity.vindication_illager.death": [ + 454, + 464 + ], + "minecraft:entity.parrot.imitate.ghast": [ + 331, + 465 + ], + "minecraft:entity.vex.charge": [ + 444, + 466 + ], + "minecraft:entity.parrot.imitate.blaze": [ + 324, + 467 + ], + "minecraft:entity.boat.paddle_water": [ + 151, + 468 + ], + "minecraft:entity.mule.chest": [ + 314, + 469 + ], + "minecraft:entity.illusion_illager.hurt": [ + 277, + 470 + ], + "minecraft:entity.parrot.imitate.stray": [ + 341, + 471 + ], + "minecraft:entity.llama.step": [ + 304, + 472 + ], + "minecraft:entity.parrot.imitate.zombie": [ + 348, + 473 + ], + "minecraft:entity.illusion_illager.ambient": [ + 274, + 474 + ], + "minecraft:entity.parrot.imitate.polar_bear": [ + 335, + 475 + ], + "minecraft:entity.parrot.imitate.husk": [ + 332, + 476 + ], + "minecraft:entity.evocation_illager.death": [ + 211, + 477 + ], + "minecraft:block.note.chime": [ + 73, + 478 + ], + "minecraft:entity.evocation_illager.prepare_summon": [ + 214, + 479 + ], + "minecraft:entity.llama.death": [ + 300, + 480 + ], + "minecraft:entity.parrot.imitate.witch": [ + 344, + 481 + ], + "minecraft:entity.parrot.hurt": [ + 323, + 482 + ], + "minecraft:entity.endereye.death": [ + 195, + 483 + ], + "minecraft:entity.parrot.imitate.enderman": [ + 328, + 484 + ], + "minecraft:entity.vindication_illager.hurt": [ + 455, + 485 + ], + "minecraft:block.note.guitar": [ + 75, + 486 + ], + "minecraft:entity.llama.ambient": [ + 297, + 487 + ], + "minecraft:entity.parrot.imitate.creeper": [ + 325, + 488 + ], + "minecraft:entity.parrot.imitate.skeleton": [ + 338, + 489 + ], + "minecraft:entity.parrot.imitate.wither_skeleton": [ + 346, + 490 + ], + "minecraft:entity.illusion_illager.mirror_move": [ + 278, + 491 + ], + "minecraft:entity.vex.ambient": [ + 443, + 492 + ], + "minecraft:entity.evocation_illager.ambient": [ + 209, + 493 + ], + "minecraft:entity.parrot.imitate.wolf": [ + 347, + 494 + ], + "minecraft:entity.parrot.death": [ + 320, + 495 + ], + "minecraft:entity.player.hurt_drown": [ + 368, + 496 + ], + "minecraft:entity.illusion_illager.prepare_blindness": [ + 279, + 497 + ], + "minecraft:entity.evocation_illager.cast_spell": [ + 210, + 498 + ], + "minecraft:entity.parrot.imitate.vex": [ + 342, + 499 + ], + "minecraft:entity.player.hurt_on_fire": [ + 369, + 500 + ], + "minecraft:entity.vex.death": [ + 445, + 501 + ], + "minecraft:entity.parrot.imitate.illusion_illager": [ + 333, + 502 + ], + "minecraft:entity.parrot.imitate.zombie_pigman": [ + 349, + 504 + ], + "minecraft:block.shulker_box.close": [ + 92, + 505 + ], + "minecraft:entity.parrot.ambient": [ + 319, + 506 + ], + "minecraft:entity.parrot.imitate.vindication_illager": [ + 343, + 507 + ], + "minecraft:entity.parrot.imitate.silverfish": [ + 337, + 509 + ], + "minecraft:block.note.bell": [ + 72, + 510 + ], + "minecraft:entity.llama.angry": [ + 298, + 511 + ], + "minecraft:block.end_portal_frame.fill": [ + 27, + 512 + ], + "minecraft:entity.parrot.eat": [ + 321, + 513 + ], + "minecraft:entity.parrot.imitate.evocation_illager": [ + 330, + 514 + ], + "minecraft:entity.llama.swag": [ + 305, + 515 + ], + "minecraft:entity.illusion_illager.prepare_mirror": [ + 280, + 516 + ], + "minecraft:entity.llama.hurt": [ + 302, + 517 + ], + "minecraft:entity.parrot.imitate.magmacube": [ + 334, + 518 + ], + "minecraft:block.note.xylophone": [ + 80, + 519 + ], + "minecraft:entity.parrot.imitate.elder_guardian": [ + 326, + 520 + ], + "minecraft:entity.parrot.imitate.endermite": [ + 329, + 521 + ], + "minecraft:block.end_portal.spawn": [ + 26, + 522 + ], + "minecraft:entity.illusion_illager.cast_spell": [ + 275, + 524 + ], + "minecraft:block.note.flute": [ + 74, + 525 + ], + "minecraft:entity.evocation_illager.prepare_wololo": [ + 215, + 526 + ], + "minecraft:entity.evocation_illager.hurt": [ + 212, + 527 + ], + "minecraft:entity.parrot.imitate.enderdragon": [ + 327, + 528 + ], + "minecraft:entity.parrot.imitate.shulker": [ + 336, + 529 + ], + "minecraft:entity.elder_guardian.flop": [ + 185, + 530 + ], + "minecraft:entity.parrot.imitate.slime": [ + 339, + 531 + ], + "minecraft:entity.bobber.retrieve": [ + 152, + 532 + ], + "minecraft:entity.parrot.imitate.spider": [ + 340, + 533 + ], + "minecraft:entity.parrot.fly": [ + 322, + 534 + ], + "minecraft:entity.boat.paddle_land": [ + 150, + 535 + ], + "minecraft:block.shulker_box.open": [ + 93, + 536 + ], + "minecraft:entity.illusion_illager.death": [ + 276, + 537 + ], + "minecraft:entity.parrot.imitate.wither": [ + 345, + 538 + ], + "minecraft:entity.llama.spit": [ + 303, + 539 + ], + "minecraft:entity.vindication_illager.ambient": [ + 453, + 540 + ], + "minecraft:entity.evocation_fangs.attack": [ + 208, + 541 + ], + "minecraft:entity.llama.eat": [ + 301, + 542 + ], + "minecraft:entity.parrot.imitate.zombie_villager": [ + 350, + 543 + ], + "minecraft:entity.evocation_illager.prepare_attack": [ + 213, + 547 + ], + "minecraft:entity.parrot.step": [ + 351, + 548 + ], + "minecraft:entity.vex.hurt": [ + 446, + 549 + ] + }, + "extraDiagnostics": [ + { + "level": "ERROR", + "logger": "FML.ModTracker", + "message": "This world was saved with mod Forge which appears to be missing, things may not work well" + }, + { + "level": "ERROR", + "logger": "FML", + "message": "Unidentified mapping from registry minecraft:soundevents" + }, + { + "level": "ERROR", + "logger": "FML", + "message": " minecraft:entity.experience_orb.touch: 196" + }, + { + "level": "ERROR", + "logger": "FML", + "message": "There are unidentified mappings in this world - we are going to attempt to process anyway" + } + ] + } + } +} diff --git a/gradle/runtime-log-checks.gradle b/gradle/runtime-log-checks.gradle new file mode 100644 index 0000000..47de32a --- /dev/null +++ b/gradle/runtime-log-checks.gradle @@ -0,0 +1,342 @@ +// Audit whole runtime logs, not just the final pass marker. +def recoveryNotice = 'Legacy slab recovery enabled for buildingbricks:grass_slab, buildingbricks:dirt_slab and buildingbrickscompatvanilla:grass_slab. Saved blocks and items will be converted as they load.' +def scanEntries = [ + 'slime-launcher-0.2.2.jar': 'META-INF/versions/11/net/minecraftforge/launcher/shadow/util/download/DownloadUtilsImpl.class' +] +if (project.minecraft_version == '1.12.2') { + ['asm', 'asm-analysis', 'asm-tree', 'asm-util'].each { + scanEntries[it + '-6.2.jar'] = 'module-info.class' + } + scanEntries['slf4j-api-1.8.0-beta4.jar'] = 'META-INF/versions/9/module-info.class' +} + +ext.auditRuntimeLogText = { String text, Map options -> + if (!(options.kind in ['development', 'packaged']) || !(options.side in ['client', 'server'])) { + throw new GradleException('Runtime kind and side must be explicit') + } + boolean development = options.kind == 'development' + boolean client = options.side == 'client' + List records = [] + def header = ~/^\[[^\]]+\] \[([^\]]+)\/(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\] \[([^\]]+)\]: (.*)$/ + text.readLines().each { line -> + def match = line =~ header + if (match.matches()) { + records.add([level: match[0][2], logger: match[0][3], + message: match[0][4], body: '']) + } else { + def shortHeader = line =~ /^\[[^\]]+\] \[[^\]]+\/(WARN|ERROR|FATAL)\]: (.*)$/ + def status = line =~ /^(?:\d{4}-\d\d-\d\d [0-9:,.]+ (?:[A-Za-z0-9 #._-]+ )?)?(WARN|ERROR|FATAL) (.*)$/ + if (shortHeader.matches()) { + records.add([level: shortHeader[0][1], logger: 'Unlabelled', + message: shortHeader[0][2], body: '']) + } else if (status.matches()) { + records.add([level: status[0][1], logger: 'Log4jStatus', + message: status[0][2], body: '']) + } else if (!records.isEmpty()) { + records.last().body += line + '\n' + } + } + } + if (records.isEmpty()) { + throw new GradleException('Runtime log contains no recognised log records') + } + + Set knownDiagnostics = [] as Set + if (development) { + // Old Forge cannot scan these exact Java 9/11 launcher descriptors. + // Require the paired class-reader failure and entry, not "corrupt zip" generally. + for (int index = 0; index + 2 < records.size(); index++) { + def failure = records[index] + def entry = records[index + 1] + def warning = records[index + 2] + if (failure.logger == 'FML' && failure.level == 'ERROR' && + failure.message == 'Unable to read a class file correctly' && + failure.body.contains('org.objectweb.asm.ClassReader') && + failure.body.contains('java.lang.IllegalArgumentException')) { + scanEntries.each { jar, classEntry -> + String prefix = 'There was a problem reading the entry ' + classEntry + ' in the jar ' + if (entry.logger == 'FML' && entry.level == 'ERROR' && + entry.message.startsWith(prefix) && + entry.message.replace('\\', '/').endsWith('/' + jar + ' - probably a corrupt zip') && + warning.logger == 'FML' && warning.level == 'WARN' && + warning.message == 'Zip file ' + jar + ' failed to read properly, it will be ignored') { + knownDiagnostics.addAll([index, index + 1, index + 2]) + } + } + } + + // Forge 1.12 expects Maven's flat layout, not Gradle's hash directory. + if (project.minecraft_version == '1.12.2' && failure.logger == 'FML' && + failure.level == 'ERROR' && + failure.message == 'Apache Maven library folder was not in the format expected. Using default libraries directory.') { + String full = entry.message.replace('\\', '/') + def expectedJar = full =~ /^Full: (.+\/org\.apache\.maven\/maven-artifact\/3\.5\.3\/)[a-f0-9]{40}\/maven-artifact-3\.5\.3\.jar$/ + if (expectedJar.matches() && entry.logger == 'FML' && entry.level == 'ERROR' && + warning.logger == 'FML' && warning.level == 'ERROR' && + warning.message.replace('\\', '/') == 'Trimmed: ' + expectedJar[0][1].toLowerCase(Locale.ROOT)) { + knownDiagnostics.addAll([index, index + 1, index + 2]) + } + } + } + } + + List offlineMessages = [ + '**** SERVER IS RUNNING IN OFFLINE/INSECURE MODE!', + 'The server will make no attempt to authenticate usernames. Beware.', + 'While this makes the game possible to play without internet access, it also opens up the ability for hackers to connect with any username they choose.', + 'To change this, set "online-mode" to "true" in the server.properties file.' + ] + List unexpected = [] + int reviewed = 0 + records.eachWithIndex { record, index -> + if (!(record.level in ['WARN', 'ERROR', 'FATAL'])) { + return + } + boolean allowed = knownDiagnostics.contains(index) + if (record.logger == 'FML' && record.body.trim().isEmpty()) { + allowed |= record.level == 'ERROR' && + record.message == 'FML appears to be missing any signature data. This is not a good thing' && + (development || project.minecraft_version == '1.10.2') + allowed |= development && record.level == 'ERROR' && + record.message == 'The binary patch set is missing. Either you are in a development environment, or things are not going to work!' + allowed |= project.minecraft_version == '1.12.2' && record.level == 'WARN' && + record.message == 'Missing English translation for FML: assets/fml/lang/en_us.lang' + } + allowed |= options.offlineTestServer && !client && record.level == 'WARN' && + record.logger == 'net.minecraft.server.dedicated.DedicatedServer' && + offlineMessages.contains(record.message) && record.body.trim().isEmpty() + allowed |= client && record.level == 'WARN' && + record.logger == 'net.minecraft.client.audio.SoundManager' && + record.message == 'Author: Paul Lamb, www.paulscode.com' && record.body.trim().isEmpty() + allowed |= development && record.level == 'WARN' && record.logger == 'Log4jStatus' && + record.message == 'Unable to instantiate org.fusesource.jansi.WindowsAnsiOutputStream' + // This unsigned local Mineralogy build has a retained upstream fingerprint. + // Its warning stays in the log and is accepted only after checking the jar. + allowed |= options.verifiedLocalMineralogy && record.level == 'WARN' && + record.logger == 'mineralogy' && record.message == 'Invalid fingerprint detected!' && + record.body.trim().isEmpty() + allowed |= !development && record.body.trim().isEmpty() && (options.reviewedFixtureMessages ?: []).any { + it.level == record.level && it.logger == record.logger && it.message == record.message + } + if (!allowed) { + unexpected.add(record.level + ' [' + record.logger + '] ' + record.message) + } else { + reviewed++ + } + } + if (!unexpected.isEmpty()) { + throw new GradleException('Unexpected runtime diagnostics:\n' + unexpected.join('\n')) + } + if (text =~ /(?i)dangerous alternative prefix|Encountered an unexpected exception|OutOfMemoryError|AssertionError|Exception in thread|MissingModsException|cascading worldgen lag|IllegalAccessError|NoClassDefFoundError|NoSuchMethodError|VerifyError|UnsatisfiedLinkError|StackOverflowError|ExceptionInInitializerError|ClassCastException/) { + throw new GradleException('Runtime log contains an unsuppressed registry warning, crash or world-generation failure') + } + int notices = records.count { + it.level == 'INFO' && it.logger == 'skysgrassslabs' && it.message == recoveryNotice + } + int expectedNotices = options.recoveryMessage == false ? 0 : 1 + if (notices != expectedNotices) { + throw new GradleException('Expected ' + expectedNotices + ' legacy recovery notices, found ' + notices) + } + if (options.marker && !text.contains(options.marker.toString())) { + throw new GradleException('Runtime log is missing its completion marker: ' + options.marker) + } + [records: records.size(), reviewedDiagnostics: reviewed] +} + +def fixturePolicies = file('gradle/runtime-fixture-diagnostics.json').isFile() ? + new groovy.json.JsonSlurper().parse(file('gradle/runtime-fixture-diagnostics.json')).fixtures : [:] + +ext.verifyRuntimeFixture = { File archive -> + def policy = fixturePolicies[archive.name] + if (policy == null || file(policy.path).canonicalFile != archive.canonicalFile) { + throw new GradleException('No reviewed log policy for this upgrade fixture: ' + archive.name) + } + String actual = java.security.MessageDigest.getInstance('SHA-256').digest(archive.bytes) + .encodeHex().toString().toUpperCase(Locale.ROOT) + if (actual != policy.sha256) { + throw new GradleException('Upgrade fixture does not match its reviewed log policy: ' + archive.name) + } + [policy.blocks, policy.sounds].each { entries -> + entries.each { name, ids -> + if (!name.startsWith('minecraft:') || ids.size() != 2 || ids.any { it < 0 }) { + throw new GradleException('Fixture allowances must name exact vanilla numeric reassignments') + } + } + } + policy +} + +ext.writeRuntimeFixtureContext = { File directory, File archive -> + def policy = project.ext.verifyRuntimeFixture(archive) + new File(directory, 'verification-fixture.json').setText( + groovy.json.JsonOutput.toJson([fixture: archive.name, sha256: policy.sha256]) + '\n', 'UTF-8') +} + +def reviewedFixtureMessages = { File directory -> + File contextFile = new File(directory, 'verification-fixture.json') + if (!contextFile.isFile()) { + return [] + } + def context = new groovy.json.JsonSlurper().parse(contextFile) + def policy = fixturePolicies[context.fixture] + if (policy == null || policy.sha256 != context.sha256) { + throw new GradleException('Unrecognised upgrade-fixture verification context') + } + project.ext.verifyRuntimeFixture(file(policy.path)) + List messages = [[level: 'ERROR', logger: 'FML.ModTracker', + message: 'This world was saved with mod ' + policy.generator + + ' which appears to be missing, things may not work well']] + messages.addAll(policy.extraDiagnostics ?: []) + [Block: policy.blocks, SoundEvent: policy.sounds].each { type, entries -> + entries.each { name, ids -> + messages.add([level: 'WARN', logger: 'FML', + message: 'Registry ' + type + ': Object did not get ID it asked for. Name: ' + + name + ' Expected: ' + ids[0] + ' Got: ' + ids[1]]) + } + } + messages +} + +ext.auditRuntimeLog = { File log, Map options = [:] -> + if (!log.isFile()) { + throw new GradleException('Runtime log is missing: ' + log) + } + File directory = options.directory ? file(options.directory) : log.parentFile.parentFile + Properties settings = new Properties() + File serverProperties = new File(directory, 'server.properties') + if (serverProperties.isFile()) { + serverProperties.withInputStream { settings.load(it) } + } + Map verified = new LinkedHashMap(options) + verified.reviewedFixtureMessages = reviewedFixtureMessages(directory) + verified.verifiedLocalMineralogy = false + if (providers.gradleProperty('runtimeLocalMineralogy').getOrElse('false').toBoolean()) { + File mineralogy = new File(directory, 'mods/Mineralogy-6.0.2.110021.jar') + String expectedHash = '48841ABD9517127D1F0D17960AAD86FEFB251D340B006872ACECADD01A4B43F9' + if (project.minecraft_version != '1.10.2' || !mineralogy.isFile() || + java.security.MessageDigest.getInstance('SHA-256').digest(mineralogy.bytes) + .encodeHex().toString().toUpperCase(Locale.ROOT) != expectedHash) { + throw new GradleException('The reviewed local Mineralogy jar is missing or has changed') + } + verified.verifiedLocalMineralogy = true + } + verified.offlineTestServer = settings.getProperty('online-mode') == 'false' && + settings.getProperty('server-ip') == '127.0.0.1' && settings.getProperty('server-port') == '0' + def result = project.ext.auditRuntimeLogText(log.getText('UTF-8'), verified) + logger.lifecycle('Runtime log audit passed: ' + log.name + ' (' + result.records + + ' records; ' + result.reviewedDiagnostics + ' reviewed diagnostics)') +} + +tasks.register('verifyRuntimeLog') { + group = 'verification' + description = 'Checks a complete development or packaged runtime log for unexpected warnings and errors.' + doLast { + if (!providers.gradleProperty('runtimeLog').isPresent()) { + throw new GradleException('Supply -PruntimeLog=, -PruntimeKind=development|packaged and -PruntimeSide=client|server') + } + project.ext.auditRuntimeLog(file(providers.gradleProperty('runtimeLog').get()), [ + kind: providers.gradleProperty('runtimeKind').getOrElse('packaged'), + side: providers.gradleProperty('runtimeSide').getOrElse('server'), + recoveryMessage: providers.gradleProperty('recoveryMessage').getOrElse('true').toBoolean(), + directory: providers.gradleProperty('runtimeDirectory').orNull, + marker: providers.gradleProperty('runtimeMarker').orNull + ]) + } +} + +tasks.register('verifyRuntimeLogPolicy') { + group = 'verification' + description = 'Tests strict warning/error classification and the recovery startup notice.' + doLast { + String notice = '[00:00:00] [main/INFO] [skysgrassslabs]: ' + recoveryNotice + '\n' + Map options = [kind: 'packaged', side: 'server'] + project.ext.auditRuntimeLogText(notice, options) + fixturePolicies.each { name, policy -> + project.ext.verifyRuntimeFixture(file(policy.path)) + String originalHash = policy.sha256 + try { + policy.sha256 = '0' * 64 + try { + project.ext.verifyRuntimeFixture(file(policy.path)) + throw new IllegalStateException('A changed fixture hash was accepted') + } catch (GradleException expected) { + // A reviewed warning list must never authorise another input world. + } + } finally { + policy.sha256 = originalHash + } + } + int rejected = 0 + List badLogs = [ + '', + '[00:00:00] [main/INFO] [FML]: Ordinary startup without a recovery notice\n', + notice + '[00:00:01] [main/WARN] [FML]: An unexpected warning\n', + notice + '[00:00:01] [main/ERROR] [FML]: An unexpected error\n', + notice + '[00:00:01] [main/FATAL] [FML]: A fatal error\n', + notice + '[00:00:01] [main/WARN] [FML]: Dangerous alternative prefix buildingbricks for grass_slab\n', + notice + '[00:00:01] [main/WARN] [FML]: Zip file unknown.jar failed to read properly, it will be ignored\n', + notice + '[00:00:01] [main/ERROR] [FML]: Unable to read a class file correctly\n', + notice + '[00:00:01] [main/ERROR] [FML]: The binary patch set is missing. Either you are in a development environment, or things are not going to work!\n', + notice + '[00:00:01] [main/WARN] [FML]: Author: Paul Lamb, www.paulscode.com\n', + notice + '2026-10-23 00:00:01,000 ERROR Logging failed\n', + notice + '2026-10-23 00:00:01,000 main WARN Unexpected status warning\n', + notice + '[00:00:01] [main/WARN]: Unlabelled warning\n', + notice + '[00:00:01] [main/WARN] [mineralogy]: Invalid fingerprint detected!\n', + notice + notice + ] + badLogs.each { text -> + try { + project.ext.auditRuntimeLogText(text, options) + throw new IllegalStateException('An unexpected log was accepted') + } catch (GradleException expected) { + rejected++ + } + } + String scan = '[00:00:01] [main/ERROR] [FML]: Unable to read a class file correctly\n' + + 'java.lang.IllegalArgumentException\n at org.objectweb.asm.ClassReader.read(Unknown Source)\n' + + '[00:00:01] [main/ERROR] [FML]: There was a problem reading the entry ' + + scanEntries['slime-launcher-0.2.2.jar'] + ' in the jar /cache/slime-launcher-0.2.2.jar - probably a corrupt zip\n' + + '[00:00:01] [main/WARN] [FML]: Zip file slime-launcher-0.2.2.jar failed to read properly, it will be ignored\n' + Map development = options + [kind: 'development'] + project.ext.auditRuntimeLogText(notice + + '[00:00:01] [main/WARN] [mineralogy]: Invalid fingerprint detected!\n', + options + [verifiedLocalMineralogy: true]) + project.ext.auditRuntimeLogText(notice + scan, development) + [ + scan.replace('DownloadUtilsImpl.class', 'AnUnrecognisedClass.class'), + scan.replace('slime-launcher-0.2.2.jar', 'unknown-library.jar'), + scan + 'java.lang.IllegalAccessError: A real launch failure\n' + ].each { altered -> + try { + project.ext.auditRuntimeLogText(notice + altered, development) + throw new IllegalStateException('An unrecognised scanner failure was accepted') + } catch (GradleException expected) { + rejected++ + } + } + String offline = '[00:00:01] [Server thread/WARN] [net.minecraft.server.dedicated.DedicatedServer]: ' + + '**** SERVER IS RUNNING IN OFFLINE/INSECURE MODE!\n' + project.ext.auditRuntimeLogText(notice + offline, options + [offlineTestServer: true]) + try { + project.ext.auditRuntimeLogText(notice + offline, options) + throw new IllegalStateException('Offline warnings were accepted without a disposable server configuration') + } catch (GradleException expected) { + rejected++ + } + Map fixture = options + [reviewedFixtureMessages: [ + [level: 'WARN', logger: 'FML', message: 'Reviewed fixture diagnostic']]] + String fixtureWarning = '[00:00:01] [main/WARN] [FML]: Reviewed fixture diagnostic\n' + project.ext.auditRuntimeLogText(notice + fixtureWarning, fixture) + try { + project.ext.auditRuntimeLogText(notice + fixtureWarning + + 'java.io.IOException: Unexpected fixture failure\n', fixture) + throw new IllegalStateException('A reviewed fixture message concealed an exception') + } catch (GradleException expected) { + rejected++ + } + logger.lifecycle('Runtime log policy passed: ' + rejected + ' unexpected-log cases rejected') + } +} +tasks.named('check') { + dependsOn 'verifyRuntimeLogPolicy' +} diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/skysgrassslabs/clienttest/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/skysgrassslabs/clienttest/ClientProbeTestMod.java index 428b437..d6c251c 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/skysgrassslabs/clienttest/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/skysgrassslabs/clienttest/ClientProbeTestMod.java @@ -42,6 +42,7 @@ public final class ClientProbeTestMod { private int reloadWorldFrames; private boolean modelsVerified; private boolean recipeBookVerified; + private boolean configScreenVerified; @Mod.EventHandler public void initialize(FMLInitializationEvent event) { @@ -85,6 +86,8 @@ public void onClientTick(TickEvent.ClientTickEvent event) { && stateTicks >= 100) { verifyModels(minecraft); verifyRecipeBook(); + ConfigScreenChecks.run(minecraft, "first"); + configScreenVerified = true; stopIntegratedServer(minecraft); nextState(2); } @@ -101,6 +104,7 @@ public void onClientTick(TickEvent.ClientTickEvent event) { && stateTicks >= 100) { verifyModels(minecraft); verifyRecipeBook(); + ConfigScreenChecks.run(minecraft, "reload"); stopIntegratedServer(minecraft); nextState(4); } @@ -178,6 +182,7 @@ private void writeMarker() throws IOException { values.setProperty("models_verified", Boolean.toString(modelsVerified)); values.setProperty("recipe_book_verified", Boolean.toString(recipeBookVerified)); + values.setProperty("config_screen_verified", Boolean.toString(configScreenVerified)); values.setProperty("first_world_rendered", Boolean.toString(firstWorldFrames >= 8)); values.setProperty("reload_rendered", Boolean.toString(reloadWorldFrames >= 8)); values.setProperty("world_directory", WORLD_DIRECTORY); diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/skysgrassslabs/clienttest/ConfigScreenChecks.java b/src/clientIntegrationTest/java/zone/moddev/mc/skysgrassslabs/clienttest/ConfigScreenChecks.java new file mode 100644 index 0000000..03620aa --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/skysgrassslabs/clienttest/ConfigScreenChecks.java @@ -0,0 +1,97 @@ +package zone.moddev.mc.skysgrassslabs.clienttest; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.ScaledResolution; +import net.minecraft.client.resources.I18n; +import net.minecraft.util.ScreenShotHelper; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.common.config.Configuration; +import net.minecraftforge.common.config.Property; +import net.minecraftforge.fml.client.FMLClientHandler; +import net.minecraftforge.fml.client.IModGuiFactory; +import net.minecraftforge.fml.client.config.GuiConfig; +import net.minecraftforge.fml.client.config.IConfigElement; +import net.minecraftforge.fml.client.event.ConfigChangedEvent; +import net.minecraftforge.fml.common.Loader; +import net.minecraftforge.fml.common.ModContainer; +import zone.moddev.mc.skysgrassslabs.SkysGrassSlabs; +import zone.moddev.mc.skysgrassslabs.config.SkysGrassSlabsConfig; + +/** Checks the actual registered screen and saved settings inside the disposable client. */ +final class ConfigScreenChecks { + private ConfigScreenChecks() { + } + + static void run(Minecraft minecraft, String phase) { + ModContainer mod = Loader.instance().getIndexedModList().get(SkysGrassSlabs.MOD_ID); + + require(!mod.getMetadata().autogenerated && !mod.getMetadata().description.isEmpty(), + "The Mods page has processed metadata"); + require(SkysGrassSlabs.VERSION.equals(mod.getVersion()), "The loaded candidate version is correct"); + + IModGuiFactory factory = FMLClientHandler.instance().getGuiFactoryFor(mod); + + require(factory != null && factory.hasConfigGui(), "The registered Config button is enabled"); + + GuiConfig screen = (GuiConfig) factory.createConfigGui(minecraft.currentScreen); + ScaledResolution resolution = new ScaledResolution(minecraft); + + screen.setWorldAndResolution(minecraft, resolution.getScaledWidth(), resolution.getScaledHeight()); + require(screen.configElements.size() == 2 && screen.entryList.listEntries.size() == 2 + && screen.allRequireMcRestart, "Both settings have widgets and require a restart"); + + for (IConfigElement element : screen.configElements) { + String labelKey = element.getLanguageKey(); + String tooltipKey = labelKey + ".tooltip"; + + require(!I18n.format(labelKey).equals(labelKey), "The setting has a translated label"); + require(!I18n.format(tooltipKey).equals(tooltipKey), "The setting has translated help"); + require(element.getComment() != null && !element.getComment().trim().isEmpty(), + "The setting has help text"); + } + + minecraft.getFramebuffer().bindFramebuffer(true); + minecraft.entityRenderer.setupOverlayRendering(); + screen.drawScreen(0, 0, 0); + ScreenShotHelper.saveScreenshot(minecraft.gameDir, "grass-slabs-config-" + phase + ".png", + minecraft.displayWidth, minecraft.displayHeight, minecraft.getFramebuffer()); + screen.onGuiClosed(); + + Configuration configuration = SkysGrassSlabsConfig.configuration(); + Property generation = configuration.getCategory("worldgen").get("generateGrassSlabs"); + boolean originalChoice = generation.getBoolean(); + boolean activeChoice = SkysGrassSlabsConfig.generateGrassSlabs(); + + try { + generation.set(!originalChoice); + MinecraftForge.EVENT_BUS.post(new ConfigChangedEvent.OnConfigChangedEvent("unrelated", null, false, true)); + require(read(configuration).get("worldgen", "generateGrassSlabs", true).getBoolean() == originalChoice, + "Another mod's event does not save Grass Slabs settings"); + + MinecraftForge.EVENT_BUS.post(new ConfigChangedEvent.OnConfigChangedEvent(SkysGrassSlabs.MOD_ID, + null, false, true)); + require(read(configuration).get("worldgen", "generateGrassSlabs", true).getBoolean() != originalChoice, + "The Config event saves the selected value"); + require(SkysGrassSlabsConfig.generateGrassSlabs() == activeChoice, + "Saving a setting does not change generation before restarting"); + } finally { + generation.set(originalChoice); + SkysGrassSlabsConfig.saveChanges(); + } + + System.out.println("GRASS_SLABS_CONFIG_SCREEN_PASS phase=" + phase + " widgets=2 restart_safe=true"); + } + + private static Configuration read(Configuration source) { + Configuration saved = new Configuration(source.getConfigFile()); + + saved.load(); + return saved; + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalStateException(message); + } + } +} diff --git a/src/clientIntegrationTest/resources/assets/skysgrassslabsclienttest/lang/en_us.lang b/src/clientIntegrationTest/resources/assets/skysgrassslabsclienttest/lang/en_us.lang new file mode 100644 index 0000000..fbd0b04 --- /dev/null +++ b/src/clientIntegrationTest/resources/assets/skysgrassslabsclienttest/lang/en_us.lang @@ -0,0 +1 @@ +# These rendered runtime checks add no player-facing content. diff --git a/src/clientIntegrationTest/resources/mcmod.info b/src/clientIntegrationTest/resources/mcmod.info index 28703a8..21b6552 100644 --- a/src/clientIntegrationTest/resources/mcmod.info +++ b/src/clientIntegrationTest/resources/mcmod.info @@ -1 +1 @@ -[{"modid":"skysgrassslabsclienttest","name":"Sky's Grass Slabs Client Probe","description":"Build-only rendered client fixture.","version":"1","mcversion":"1.12.2","authorList":["MMD"],"dependencies":["required-after:skysgrassslabs@[1.1.0.112021]"]}] +[{"modid":"skysgrassslabsclienttest","name":"Sky's Grass Slabs Client Probe","description":"Build-only rendered client fixture.","version":"1","mcversion":"1.12.2","authorList":["MMD"],"dependencies":["required-after:skysgrassslabs@[1.1.1.112021]"]}] diff --git a/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/FoliageChecks.java b/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/FoliageChecks.java new file mode 100644 index 0000000..601be65 --- /dev/null +++ b/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/FoliageChecks.java @@ -0,0 +1,89 @@ +package zone.moddev.mc.skysgrassslabs.integrationtest; + +import java.util.Random; +import net.minecraft.block.BlockDoublePlant; +import net.minecraft.block.BlockTallGrass; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraftforge.event.terraingen.DecorateBiomeEvent; +import zone.moddev.mc.skysgrassslabs.init.ModBlocks; +import zone.moddev.mc.skysgrassslabs.world.GrassSlabSmoothingHandler; + +/** Covers plants present before this owner's decoration, not a second generation pass. */ +final class FoliageChecks { + static int run(WorldServer world) { + BlockPos floor = new BlockPos(3528, 210, 3528); + BlockPos origin = new BlockPos(floor.getX() & ~15, 0, floor.getZ() & ~15); + GrassSlabSmoothingHandler handler = new GrassSlabSmoothingHandler(); + int checks = 0; + + for (IBlockState plant : new IBlockState[] { Blocks.TALLGRASS.getDefaultState(), + Blocks.TALLGRASS.getDefaultState().withProperty(BlockTallGrass.TYPE, BlockTallGrass.EnumType.FERN), + Blocks.YELLOW_FLOWER.getDefaultState(), Blocks.RED_FLOWER.getDefaultState(), + Blocks.DEADBUSH.getDefaultState() }) { + for (int arrangement = 0; arrangement < 3; arrangement++) { + prepare(world, floor); + if (arrangement != 1) { + world.setBlockState(floor.up(), plant, 2); + } + if (arrangement != 0) { + world.setBlockState(floor.east().up(2), plant, 2); + } + handler.beforeDecoration(new DecorateBiomeEvent.Pre(world, new Random(1), origin)); + require(world.getBlockState(floor.up()).getBlock() == ModBlocks.GRASS_SLAB + && world.getBlockState(floor).getBlock() == Blocks.DIRT, + "plants on either side must not hide a valid slope"); + checks++; + } + } + + prepare(world, floor); + world.setBlockState(floor.up(), Blocks.DOUBLE_PLANT.getDefaultState() + .withProperty(BlockDoublePlant.HALF, BlockDoublePlant.EnumBlockHalf.LOWER), 2); + world.setBlockState(floor.up(2), Blocks.DOUBLE_PLANT.getDefaultState() + .withProperty(BlockDoublePlant.HALF, BlockDoublePlant.EnumBlockHalf.UPPER), 2); + handler.beforeDecoration(new DecorateBiomeEvent.Pre(world, new Random(1), origin)); + require(world.getBlockState(floor.up()).getBlock() == ModBlocks.GRASS_SLAB + && world.isAirBlock(floor.up(2)), "tall plants leave no orphaned upper half"); + checks++; + + for (IBlockState obstacle : new IBlockState[] { Blocks.LEAVES.getDefaultState(), Blocks.LOG.getDefaultState(), + Blocks.WHEAT.getDefaultState(), Blocks.SAPLING.getDefaultState(), Blocks.VINE.getDefaultState(), + Blocks.CHEST.getDefaultState(), Blocks.WATER.getDefaultState() }) { + prepare(world, floor); + world.setBlockState(floor.up(), obstacle, 2); + handler.beforeDecoration(new DecorateBiomeEvent.Pre(world, new Random(1), origin)); + require(world.getBlockState(floor.up()).equals(obstacle) + && world.getBlockState(floor).getBlock() == Blocks.GRASS, + "trees, crops, vines, fluids and block entities remain intact"); + checks++; + } + + System.out.println("GRASS_SLAB_FOLIAGE_PASS checks=" + checks); + return checks; + } + + private static void prepare(WorldServer world, BlockPos floor) { + for (int offsetZ = -2; offsetZ <= 2; offsetZ++) { + for (int offsetX = -2; offsetX <= 2; offsetX++) { + BlockPos support = floor.add(offsetX, 0, offsetZ); + for (int height = 1; height <= 6; height++) { + world.setBlockState(support.up(height), Blocks.AIR.getDefaultState(), 2); + } + world.setBlockState(support, Blocks.GRASS.getDefaultState(), 2); + } + } + world.setBlockState(floor.east().up(), Blocks.GRASS.getDefaultState(), 2); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalStateException(message); + } + } + + private FoliageChecks() { + } +} diff --git a/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/IntegrationTestMod.java b/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/IntegrationTestMod.java index 6ec0f39..66f0b5b 100644 --- a/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/IntegrationTestMod.java +++ b/src/integrationTest/java/zone/moddev/mc/skysgrassslabs/integrationtest/IntegrationTestMod.java @@ -1904,7 +1904,7 @@ private static void verifyRecipes(World world) { } private static int verifyWorldgen(WorldServer world) { - int checks = 0; + int checks = FoliageChecks.run(world); int chunkX = (world.getSpawnPoint().getX() >> 4) + 4; int chunkZ = (world.getSpawnPoint().getZ() >> 4) + 4; @@ -1951,8 +1951,8 @@ && metadata(ModBlocks.GRASS_SLAB, world.getBlockState(lower.up())) == 1 prepareGrassSurface(world, replaceable.east().up()); world.setBlockState(replaceable.up(), Blocks.TALLGRASS.getDefaultState(), 3); handler.beforeDecoration(new DecorateBiomeEvent.Pre(world, new Random(5), chunkStart)); - require(world.getBlockState(replaceable.up()).getBlock() == Blocks.TALLGRASS, - "Smoothing overwrote replaceable provider decoration"); + require(world.getBlockState(replaceable.up()).getBlock() == ModBlocks.GRASS_SLAB, + "Grass placed by a neighbouring decorator blocked a valid transition"); checks++; BlockPos wet = new BlockPos((chunkX << 4) + 12, 192, (chunkZ << 4) + 12); diff --git a/src/integrationTest/resources/assets/skysgrassslabsintegrationtest/lang/en_us.lang b/src/integrationTest/resources/assets/skysgrassslabsintegrationtest/lang/en_us.lang new file mode 100644 index 0000000..b7d74c6 --- /dev/null +++ b/src/integrationTest/resources/assets/skysgrassslabsintegrationtest/lang/en_us.lang @@ -0,0 +1 @@ +# These runtime checks add no player-facing content. diff --git a/src/integrationTest/resources/log4j2.xml b/src/integrationTest/resources/log4j2.xml new file mode 100644 index 0000000..43b0e55 --- /dev/null +++ b/src/integrationTest/resources/log4j2.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/SkysGrassSlabs.java b/src/main/java/zone/moddev/mc/skysgrassslabs/SkysGrassSlabs.java index c024844..76fae4e 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/SkysGrassSlabs.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/SkysGrassSlabs.java @@ -22,11 +22,12 @@ modid = SkysGrassSlabs.MOD_ID, name = SkysGrassSlabs.NAME, version = SkysGrassSlabs.VERSION, - dependencies = "before:buildingbricks") + dependencies = "before:buildingbricks", + guiFactory = "zone.moddev.mc.skysgrassslabs.client.GrassSlabsGuiFactory") public final class SkysGrassSlabs { public static final String MOD_ID = "skysgrassslabs"; public static final String NAME = "Sky's Grass Slabs"; - public static final String VERSION = "1.1.0.112021"; + public static final String VERSION = "1.1.1.112021"; @SidedProxy( clientSide = "zone.moddev.mc.skysgrassslabs.proxy.ClientProxy", diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/api/GrassSlabsApi.java b/src/main/java/zone/moddev/mc/skysgrassslabs/api/GrassSlabsApi.java index b4c59b5..f6604db 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/api/GrassSlabsApi.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/api/GrassSlabsApi.java @@ -6,6 +6,8 @@ import net.minecraft.block.state.IBlockState; import net.minecraft.init.Blocks; import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.world.IBlockAccess; import net.minecraft.world.World; import zone.moddev.mc.skysgrassslabs.block.GrassSpread; import zone.moddev.mc.skysgrassslabs.config.SkysGrassSlabsConfig; @@ -175,12 +177,13 @@ public static void tickGrass(World world, BlockPos pos, IBlockState state, Rando world.setBlockState(pos, dirt, 3); } } else if (isSpreadingGrassForm(state)) { - GrassSpread.spreadFrom(world, pos, random, pos.down()); + GrassSpread.spreadFrom(world, pos, random, coversSupport(state, world, pos) ? pos.down() : null); } } public static void repairSupport(World world, BlockPos pos) { - if (world.isRemote || !world.isBlockLoaded(pos.down())) { + if (world.isRemote || !world.isBlockLoaded(pos.down()) + || !coversSupport(world.getBlockState(pos), world, pos)) { return; } @@ -193,6 +196,12 @@ public static void repairSupport(World world, BlockPos pos) { } } + /** Steps and corners leave some ground exposed; only a full footprint hides all of its grass. */ + public static boolean coversSupport(IBlockState piece, IBlockAccess world, BlockPos pos) { + AxisAlignedBB bounds = piece.getBoundingBox(world, pos); + return bounds != null && bounds.minX == 0 && bounds.maxX == 1 && bounds.minZ == 0 && bounds.maxZ == 1; + } + /** Ignore derived visual properties while retaining the material and saved orientation. */ private static IBlockState canonical(IBlockState state) { return state.getBlock().getStateFromMeta(state.getBlock().getMetaFromState(state)); diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/block/GrassSpread.java b/src/main/java/zone/moddev/mc/skysgrassslabs/block/GrassSpread.java index 0ac50a2..ed9d1ca 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/block/GrassSpread.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/block/GrassSpread.java @@ -116,7 +116,10 @@ private static boolean targetIsViable(World world, BlockPos target) { BlockPos above = target.up(); IBlockState cover = world.getBlockState(above); - if (cover.getBlock() == ModBlocks.TURF || GrassSlabsApi.isGrassForm(cover)) { + // Keep covered-piece rejection; only full ground can regrow beside a narrower grass piece. + if (cover.getBlock() == ModBlocks.TURF + || GrassSlabsApi.isGrassForm(cover) + && (!state.isFullCube() || GrassSlabsApi.coversSupport(cover, world, above))) { return false; } diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/client/GrassSlabsConfigScreen.java b/src/main/java/zone/moddev/mc/skysgrassslabs/client/GrassSlabsConfigScreen.java new file mode 100644 index 0000000..5b2feaa --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/client/GrassSlabsConfigScreen.java @@ -0,0 +1,23 @@ +package zone.moddev.mc.skysgrassslabs.client; + +import java.util.ArrayList; +import java.util.List; +import net.minecraft.client.gui.GuiScreen; +import net.minecraftforge.common.config.ConfigElement; +import net.minecraftforge.fml.client.config.GuiConfig; +import net.minecraftforge.fml.client.config.IConfigElement; +import zone.moddev.mc.skysgrassslabs.config.SkysGrassSlabsConfig; + +/** These generation and migration choices take effect after restarting Minecraft. */ +public final class GrassSlabsConfigScreen extends GuiConfig { + public GrassSlabsConfigScreen(GuiScreen parent) { + super(parent, elements(), "skysgrassslabs", false, true, "Sky's Grass Slabs"); + } + + private static List elements() { + List elements = new ArrayList<>(); + elements.addAll(new ConfigElement(SkysGrassSlabsConfig.configuration().getCategory("worldgen")).getChildElements()); + elements.addAll(new ConfigElement(SkysGrassSlabsConfig.configuration().getCategory("compat")).getChildElements()); + return elements; + } +} diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/client/GrassSlabsGuiFactory.java b/src/main/java/zone/moddev/mc/skysgrassslabs/client/GrassSlabsGuiFactory.java new file mode 100644 index 0000000..30b3ccf --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/client/GrassSlabsGuiFactory.java @@ -0,0 +1,42 @@ +package zone.moddev.mc.skysgrassslabs.client; + +import java.util.Collections; +import java.util.Set; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiScreen; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.client.IModGuiFactory; +import net.minecraftforge.fml.client.event.ConfigChangedEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import zone.moddev.mc.skysgrassslabs.config.SkysGrassSlabsConfig; + +/** Connects the Mods list's Config button to Forge's standard settings screen. */ +public final class GrassSlabsGuiFactory implements IModGuiFactory { + @Override + public void initialize(Minecraft minecraftInstance) { + MinecraftForge.EVENT_BUS.register(this); + } + + @Override + public boolean hasConfigGui() { + return true; + } + + @Override + public GuiScreen createConfigGui(GuiScreen parentScreen) { + return new GrassSlabsConfigScreen(parentScreen); + } + + @Override + public Set runtimeGuiCategories() { + return Collections.emptySet(); + } + + @SubscribeEvent + public void configChanged(ConfigChangedEvent.OnConfigChangedEvent event) { + if ("skysgrassslabs".equals(event.getModID())) { + // Save for the next restart; do not rerun generation or compatibility arbitration. + SkysGrassSlabsConfig.saveChanges(); + } + } +} diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/compat/BuildingBricksCompat.java b/src/main/java/zone/moddev/mc/skysgrassslabs/compat/BuildingBricksCompat.java index 887ad5c..38cbb0a 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/compat/BuildingBricksCompat.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/compat/BuildingBricksCompat.java @@ -158,6 +158,9 @@ public static void registerLegacyAliasBlocks(IForgeRegistry registry) { historicalGrassSlab = legacyAlias(HISTORICAL_GRASS_SLAB_ID, true); registry.registerAll(grassSlab, dirtSlab, historicalGrassSlab); legacyAliasesRegistered = true; + SkysGrassSlabs.logger.info("Legacy slab recovery enabled for {}, {} and {}. " + + "Saved blocks and items will be converted as they load.", + GRASS_SLAB_ID, DIRT_SLAB_ID, HISTORICAL_GRASS_SLAB_ID); } public static void registerLegacyAliasItems(IForgeRegistry registry) { @@ -171,7 +174,8 @@ public static void registerLegacyAliasItems(IForgeRegistry registry) { private static Block legacyAlias(ResourceLocation registryId, boolean grass) { LegacySlabAliasBlock block = new LegacySlabAliasBlock(grass); - block.setRegistryName(registryId).setTranslationKey(SkysGrassSlabs.MOD_ID + ".legacy_" + registryId.getPath()); + LegacyRegistryWarnings.setName(block, registryId); + block.setTranslationKey(SkysGrassSlabs.MOD_ID + ".legacy_" + registryId.getPath()); return block; } @@ -184,7 +188,8 @@ public int getMetadata(int damage) { }; item.setHasSubtypes(true); - return item.setRegistryName(block.getRegistryName()); + LegacyRegistryWarnings.setName(item, block.getRegistryName()); + return item; } private BuildingBricksCompat() { diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/compat/LegacyRegistryWarnings.java b/src/main/java/zone/moddev/mc/skysgrassslabs/compat/LegacyRegistryWarnings.java new file mode 100644 index 0000000..151f5db --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/compat/LegacyRegistryWarnings.java @@ -0,0 +1,147 @@ +package zone.moddev.mc.skysgrassslabs.compat; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.registries.IForgeRegistryEntry; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.message.Message; +import org.apache.logging.log4j.core.Filter; +import org.apache.logging.log4j.core.config.Configuration; +import org.apache.logging.log4j.core.filter.AbstractFilter; +import org.apache.logging.log4j.core.filter.CompositeFilter; + +/** Keeps intentional recovery names readable without hiding other registry problems. */ +final class LegacyRegistryWarnings { + private static final Set RECOVERY_IDS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + "buildingbricks:grass_slab", + "buildingbricks:dirt_slab", + "buildingbrickscompatvanilla:grass_slab"))); + private static final ThreadLocal CURRENT_NAME = new ThreadLocal<>(); + private static final Filter FILTER = new RegistrationFilter(); + + private LegacyRegistryWarnings() { + } + + static void setName(IForgeRegistryEntry entry, ResourceLocation name) { + String identity = name.toString(); + if (!RECOVERY_IDS.contains(identity)) { + throw new IllegalArgumentException("Not a supported legacy recovery name: " + identity); + } + + installFilter(); + // The installed filter is neutral everywhere except this thread's naming call. + String previous = CURRENT_NAME.get(); + CURRENT_NAME.set(identity); + try { + entry.setRegistryName(name); + } finally { + if (previous == null) { + CURRENT_NAME.remove(); + } else { + CURRENT_NAME.set(previous); + } + } + } + + private static synchronized void installFilter() { + // Forge's logger can belong to a different context from the mod's logger. + org.apache.logging.log4j.Logger logger = net.minecraftforge.fml.common.FMLLog.log; + if (logger == null) { + logger = LogManager.getLogger("FML"); + } + if (!(logger instanceof org.apache.logging.log4j.core.Logger)) { + // An unfamiliar logging backend keeps Forge's original warning. + return; + } + + org.apache.logging.log4j.core.Logger forgeLogger = (org.apache.logging.log4j.core.Logger) logger; + Configuration configuration = forgeLogger.getContext().getConfiguration(); + // Configuration filters run before a message reaches an asynchronous queue. + if (!containsFilter(configuration.getFilter())) { + configuration.addFilter(FILTER); + } + } + + private static boolean containsFilter(Filter filter) { + if (filter == FILTER) { + return true; + } + if (filter instanceof CompositeFilter) { + for (Filter child : (CompositeFilter) filter) { + if (containsFilter(child)) { + return true; + } + } + } + return false; + } + + static boolean hideForCurrentRegistration(String logger, Level level, String message, + Throwable thrown, StackTraceElement[] stack) { + return matches(CURRENT_NAME.get(), logger, level, message, thrown, stack); + } + + static boolean matches(String identity, String logger, Level level, String message, + Throwable thrown, StackTraceElement[] stack) { + if (!RECOVERY_IDS.contains(identity) || !"FML".equals(logger) || !Level.WARN.equals(level) + || message == null || thrown != null || stack == null) { + return false; + } + + boolean namingEntry = false; + boolean checkingPrefix = false; + for (StackTraceElement frame : stack) { + namingEntry |= "net.minecraftforge.registries.IForgeRegistryEntry$Impl".equals(frame.getClassName()) + && "setRegistryName".equals(frame.getMethodName()); + checkingPrefix |= "net.minecraftforge.registries.GameData".equals(frame.getClassName()) + && "checkPrefix".equals(frame.getMethodName()); + } + if (!namingEntry || !checkingPrefix) { + return false; + } + + String[] parts = identity.split(":", 2); + String expected = "Potentially Dangerous alternative prefix `" + parts[0] + "` for name `" + parts[1] + + "`, expected `skysgrassslabs`. This could be a intended override, but in most cases indicates a broken mod."; + return expected.equals(message); + } + + private static final class RegistrationFilter extends AbstractFilter { + @Override + public Result filter(org.apache.logging.log4j.core.Logger logger, Level level, + Marker marker, Message message, Throwable thrown) { + if (CURRENT_NAME.get() == null || !Level.WARN.equals(level) + || !"FML".equals(logger.getName()) || thrown != null || message == null + || message.getThrowable() != null) { + return Result.NEUTRAL; + } + return hideForCurrentRegistration(logger.getName(), level, message.getFormattedMessage(), null, + Thread.currentThread().getStackTrace()) ? Result.DENY : Result.NEUTRAL; + } + + @Override + public Result filter(org.apache.logging.log4j.core.Logger logger, Level level, + Marker marker, String message, Object... parameters) { + if (CURRENT_NAME.get() == null || !Level.WARN.equals(level) || !"FML".equals(logger.getName())) { + return Result.NEUTRAL; + } + return filter(logger, level, marker, logger.getMessageFactory().newMessage(message, parameters), null); + } + + @Override + public Result filter(org.apache.logging.log4j.core.Logger logger, Level level, + Marker marker, Object message, Throwable thrown) { + if (CURRENT_NAME.get() == null || !Level.WARN.equals(level) + || !"FML".equals(logger.getName()) || thrown != null) { + return Result.NEUTRAL; + } + return filter(logger, level, marker, + logger.getMessageFactory().newMessage(message), thrown); + } + } +} diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/config/SkysGrassSlabsConfig.java b/src/main/java/zone/moddev/mc/skysgrassslabs/config/SkysGrassSlabsConfig.java index 216879d..676bb8d 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/config/SkysGrassSlabsConfig.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/config/SkysGrassSlabsConfig.java @@ -10,6 +10,7 @@ public final class SkysGrassSlabsConfig { public static final String WORLDGEN_CATEGORY = "worldgen"; public static final String GENERATE_GRASS_SLABS = "generateGrassSlabs"; + private static Configuration configuration; private static boolean forceReplaceBuildingBricksSlabs; private static boolean generateGrassSlabs = true; private static boolean compatibilitySuppressed; @@ -17,7 +18,8 @@ public final class SkysGrassSlabsConfig { public static void load(File file) { compatibilitySuppressed = false; - Configuration config = new Configuration(file); + configuration = new Configuration(file); + Configuration config = configuration; config.load(); forceReplaceBuildingBricksSlabs = config.getBoolean(FORCE_REPLACE_BUILDINGBRICKS_SLABS, COMPAT_CATEGORY, false, @@ -26,6 +28,14 @@ public static void load(File file) { generateGrassSlabs = config.getBoolean(GENERATE_GRASS_SLABS, WORLDGEN_CATEGORY, true, "Place grass slabs on suitable one-block slopes in newly generated Overworld chunks."); + config.setCategoryRequiresMcRestart(WORLDGEN_CATEGORY, true); + config.setCategoryRequiresMcRestart(COMPAT_CATEGORY, true); + // Keep the comments created above: calling the generic getter again clears Forge's help text. + config.getCategory(WORLDGEN_CATEGORY).get(GENERATE_GRASS_SLABS) + .setLanguageKey("config.skysgrassslabs.generateGrassSlabs"); + config.getCategory(COMPAT_CATEGORY).get(FORCE_REPLACE_BUILDINGBRICKS_SLABS) + .setLanguageKey("config.skysgrassslabs.forceReplaceBuildingBricksSlabs"); + if (config.hasChanged()) { config.save(); } @@ -33,6 +43,21 @@ public static void load(File file) { GrassSlabsApi.configurationLoaded(); } + /** The config screen edits pending settings, not the active generation policy. */ + public static Configuration configuration() { + if (configuration == null) { + throw new IllegalStateException("Grass Slabs configuration has not loaded"); + } + + return configuration; + } + + public static void saveChanges() { + if (configuration().hasChanged()) { + configuration.save(); + } + } + public static boolean generateGrassSlabs() { return generateGrassSlabs; } diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/world/GrassSlabSmoothingHandler.java b/src/main/java/zone/moddev/mc/skysgrassslabs/world/GrassSlabSmoothingHandler.java index 374c307..d4056f6 100644 --- a/src/main/java/zone/moddev/mc/skysgrassslabs/world/GrassSlabSmoothingHandler.java +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/world/GrassSlabSmoothingHandler.java @@ -18,12 +18,7 @@ /** Plans a chunk's grass caps before writing any, without loading neighbouring chunks. */ public final class GrassSlabSmoothingHandler { public static final String FEATURE_ID = "skysgrassslabs:grass_slab_smoothing"; - private static final ThreadLocal DECISIONS = new ThreadLocal() { - @Override - protected boolean[] initialValue() { - return new boolean[256]; - } - }; + private static final ThreadLocal DECISIONS = ThreadLocal.withInitial(Decisions::new); @SubscribeEvent( priority = EventPriority.LOWEST) @@ -41,11 +36,10 @@ public void beforeDecoration(DecorateBiomeEvent.Pre event) { return; } - boolean[] decisions = DECISIONS.get(); + Decisions decisions = DECISIONS.get(); - for (int index = 0; index < decisions.length; ++index) { - decisions[index] = false; - } + java.util.Arrays.fill(decisions.targetHeights, -1); + java.util.Arrays.fill(decisions.originalTargets, null); int startX = chunkPos.getXStart(); int startZ = chunkPos.getZStart(); @@ -54,7 +48,7 @@ public void beforeDecoration(DecorateBiomeEvent.Pre event) { for (int localX = 0; localX < 16; ++localX) { int worldX = startX + localX; int worldZ = startZ + localZ; - int surfaceY = owner.getHeightValue(localX, localZ) - 1; + int surfaceY = SmoothingFoliage.surfaceHeight(owner, localX, localZ, startX, startZ); if (surfaceY < 0 || surfaceY >= 255) { continue; @@ -65,7 +59,7 @@ public void beforeDecoration(DecorateBiomeEvent.Pre event) { IBlockState lower = owner.getBlockState(surface); IBlockState targetState = owner.getBlockState(target); boolean lowerGrass = lower.getBlock() == Blocks.GRASS; - boolean clear = targetState.getBlock().isAir(targetState, world, target); + boolean clear = SmoothingFoliage.canReplace(world, owner, target); boolean dry = targetState.getMaterial() != Material.WATER && targetState.getMaterial() != Material.LAVA; boolean supported = lower.isSideSolid(world, surface, EnumFacing.UP); boolean hasBlockEntity = owner.getTileEntity(target, Chunk.EnumCreateEntityType.CHECK) != null; @@ -75,24 +69,35 @@ public void beforeDecoration(DecorateBiomeEvent.Pre event) { } if (hasHigherGrassNeighbour(world, chunkPos, localX, localZ, surfaceY)) { - decisions[(localZ << 4) | localX] = true; + int index = (localZ << 4) | localX; + + decisions.targetHeights[index] = surfaceY + 1; + decisions.originalTargets[index] = targetState; } } } boolean changed = false; - for (int index = 0; index < decisions.length; ++index) { - if (!decisions[index]) { + for (int index = 0; index < decisions.targetHeights.length; ++index) { + if (decisions.targetHeights[index] < 0) { continue; } int localX = index & 15; int localZ = index >>> 4; - int surfaceY = owner.getHeightValue(localX, localZ) - 1; - BlockPos target = new BlockPos(startX + localX, surfaceY + 1, startZ + localZ); + // Retain the snapshot height: earlier writes must not move a later target. + BlockPos target = new BlockPos(startX + localX, decisions.targetHeights[index], startZ + localZ); + + if (!owner.getBlockState(target).equals(decisions.originalTargets[index]) + || !SmoothingFoliage.canReplace(world, owner, target)) { + continue; + } setGenerationState(owner, target, ModBlocks.GRASS_SLAB.getDefaultState()); + if (decisions.originalTargets[index].getBlock() == Blocks.DOUBLE_PLANT) { + setGenerationState(owner, target.up(), Blocks.AIR.getDefaultState()); + } BlockPos support = target.down(); @@ -153,7 +158,7 @@ private static boolean isHigherGrass(World world, int chunkX, int chunkZ, int lo return false; } - int neighbourSurfaceY = chunk.getHeightValue(localX, localZ) - 1; + int neighbourSurfaceY = SmoothingFoliage.surfaceHeight(chunk, localX, localZ, chunkX << 4, chunkZ << 4); BlockPos neighbour = new BlockPos((chunkX << 4) + localX, neighbourSurfaceY, (chunkZ << 4) + localZ); boolean neighbourGrass = neighbourSurfaceY >= 0 && chunk.getBlockState(neighbour).getBlock() == Blocks.GRASS; @@ -161,6 +166,11 @@ private static boolean isHigherGrass(World world, int chunkX, int chunkZ, int lo false); } + private static final class Decisions { + final int[] targetHeights = new int[256]; + final IBlockState[] originalTargets = new IBlockState[256]; + } + private static Chunk loadedChunk(World world, int chunkX, int chunkZ) { if (!(world.getChunkProvider() instanceof ChunkProviderServer)) { return null; diff --git a/src/main/java/zone/moddev/mc/skysgrassslabs/world/SmoothingFoliage.java b/src/main/java/zone/moddev/mc/skysgrassslabs/world/SmoothingFoliage.java new file mode 100644 index 0000000..f798669 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysgrassslabs/world/SmoothingFoliage.java @@ -0,0 +1,60 @@ +package zone.moddev.mc.skysgrassslabs.world; + +import net.minecraft.block.BlockDoublePlant; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraft.world.chunk.Chunk; + +/** Only small natural plants may give way to a transition in a fresh chunk. */ +final class SmoothingFoliage { + static boolean isPlant(IBlockState state) { + return state.getBlock() == Blocks.TALLGRASS || state.getBlock() == Blocks.YELLOW_FLOWER + || state.getBlock() == Blocks.RED_FLOWER || state.getBlock() == Blocks.DEADBUSH + || state.getBlock() == Blocks.DOUBLE_PLANT; + } + + static boolean canReplace(World world, Chunk chunk, BlockPos target) { + IBlockState state = chunk.getBlockState(target); + + if (state.getBlock().hasTileEntity(state) + || chunk.getTileEntity(target, Chunk.EnumCreateEntityType.CHECK) != null) { + return false; + } + if (state.getBlock().isAir(state, world, target)) { + return true; + } + if (!isPlant(state)) { + return false; + } + if (state.getBlock() != Blocks.DOUBLE_PLANT) { + return true; + } + if (target.getY() >= 255 + || state.getValue(BlockDoublePlant.HALF) != BlockDoublePlant.EnumBlockHalf.LOWER) { + return false; + } + + IBlockState upper = chunk.getBlockState(target.up()); + + return upper.getBlock() == Blocks.DOUBLE_PLANT + && upper.getValue(BlockDoublePlant.HALF) == BlockDoublePlant.EnumBlockHalf.UPPER + && chunk.getTileEntity(target.up(), Chunk.EnumCreateEntityType.CHECK) == null; + } + + static int surfaceHeight(Chunk chunk, int localX, int localZ, int startX, int startZ) { + int surfaceY = chunk.getHeightValue(localX, localZ) - 1; + + // A decorator may already have reached here from a neighbouring chunk. + while (surfaceY >= 0 && isPlant(chunk.getBlockState( + new BlockPos(startX + localX, surfaceY, startZ + localZ)))) { + surfaceY--; + } + + return surfaceY; + } + + private SmoothingFoliage() { + } +} diff --git a/src/main/resources/assets/skysgrassslabs/lang/de_at.lang b/src/main/resources/assets/skysgrassslabs/lang/de_at.lang index 830b439..15290fd 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/de_at.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/de_at.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Eanstufn tile.skysgrassslabs.grass_slab.name=Grosstufn tile.skysgrassslabs.path_slab.name=Steigstufn tile.skysgrassslabs.turf.name=Grassodn +config.skysgrassslabs.generateGrassSlabs=Grasstufen erzeugen +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Alte Stufen ersetzen +config.skysgrassslabs.generateGrassSlabs.tooltip=Fügt Grasplatten an neuen Hängen der Oberwelt hinzu. Mit Terrain Smoother steuert dies nur Gras. Bestehende Chunks bleiben unverändert. Minecraft neu starten. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Kompatibilitätseinstellung für alte Welten. In normalen 1.12.2-Modpacks ausgeschaltet lassen. Alte Platten werden automatisch wiederhergestellt. Minecraft neu starten. diff --git a/src/main/resources/assets/skysgrassslabs/lang/de_au.lang b/src/main/resources/assets/skysgrassslabs/lang/de_au.lang index df80ecc..68e4d6a 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/de_au.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/de_au.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Erdstufe tile.skysgrassslabs.grass_slab.name=Grasblockstufe tile.skysgrassslabs.path_slab.name=Trampelpfadstufe tile.skysgrassslabs.turf.name=Grassode +config.skysgrassslabs.generateGrassSlabs=Grasstufen erzeugen +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Alte Stufen ersetzen +config.skysgrassslabs.generateGrassSlabs.tooltip=Fügt Grasplatten an neuen Hängen der Oberwelt hinzu. Mit Terrain Smoother steuert dies nur Gras. Bestehende Chunks bleiben unverändert. Minecraft neu starten. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Kompatibilitätseinstellung für alte Welten. In normalen 1.12.2-Modpacks ausgeschaltet lassen. Alte Platten werden automatisch wiederhergestellt. Minecraft neu starten. diff --git a/src/main/resources/assets/skysgrassslabs/lang/de_de.lang b/src/main/resources/assets/skysgrassslabs/lang/de_de.lang index df80ecc..68e4d6a 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/de_de.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/de_de.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Erdstufe tile.skysgrassslabs.grass_slab.name=Grasblockstufe tile.skysgrassslabs.path_slab.name=Trampelpfadstufe tile.skysgrassslabs.turf.name=Grassode +config.skysgrassslabs.generateGrassSlabs=Grasstufen erzeugen +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Alte Stufen ersetzen +config.skysgrassslabs.generateGrassSlabs.tooltip=Fügt Grasplatten an neuen Hängen der Oberwelt hinzu. Mit Terrain Smoother steuert dies nur Gras. Bestehende Chunks bleiben unverändert. Minecraft neu starten. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Kompatibilitätseinstellung für alte Welten. In normalen 1.12.2-Modpacks ausgeschaltet lassen. Alte Platten werden automatisch wiederhergestellt. Minecraft neu starten. diff --git a/src/main/resources/assets/skysgrassslabs/lang/en_ca.lang b/src/main/resources/assets/skysgrassslabs/lang/en_ca.lang index e83efd3..22b5abb 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/en_ca.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/en_ca.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Dirt Slab tile.skysgrassslabs.grass_slab.name=Grass Slab tile.skysgrassslabs.path_slab.name=Grass Path Slab tile.skysgrassslabs.turf.name=Turf +config.skysgrassslabs.generateGrassSlabs=Generate grass slabs +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Replace legacy slabs +config.skysgrassslabs.generateGrassSlabs.tooltip=Add grass slabs to new Overworld slopes. With Terrain Smoother, this controls grass only. Existing chunks stay unchanged. Restart Minecraft. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Legacy compatibility setting. Leave off in normal Minecraft 1.12.2 packs. Recovery of old slabs is automatic. Restart Minecraft. diff --git a/src/main/resources/assets/skysgrassslabs/lang/en_en.lang b/src/main/resources/assets/skysgrassslabs/lang/en_en.lang index e83efd3..22b5abb 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/en_en.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/en_en.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Dirt Slab tile.skysgrassslabs.grass_slab.name=Grass Slab tile.skysgrassslabs.path_slab.name=Grass Path Slab tile.skysgrassslabs.turf.name=Turf +config.skysgrassslabs.generateGrassSlabs=Generate grass slabs +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Replace legacy slabs +config.skysgrassslabs.generateGrassSlabs.tooltip=Add grass slabs to new Overworld slopes. With Terrain Smoother, this controls grass only. Existing chunks stay unchanged. Restart Minecraft. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Legacy compatibility setting. Leave off in normal Minecraft 1.12.2 packs. Recovery of old slabs is automatic. Restart Minecraft. diff --git a/src/main/resources/assets/skysgrassslabs/lang/en_gb.lang b/src/main/resources/assets/skysgrassslabs/lang/en_gb.lang index e83efd3..22b5abb 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/en_gb.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/en_gb.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Dirt Slab tile.skysgrassslabs.grass_slab.name=Grass Slab tile.skysgrassslabs.path_slab.name=Grass Path Slab tile.skysgrassslabs.turf.name=Turf +config.skysgrassslabs.generateGrassSlabs=Generate grass slabs +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Replace legacy slabs +config.skysgrassslabs.generateGrassSlabs.tooltip=Add grass slabs to new Overworld slopes. With Terrain Smoother, this controls grass only. Existing chunks stay unchanged. Restart Minecraft. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Legacy compatibility setting. Leave off in normal Minecraft 1.12.2 packs. Recovery of old slabs is automatic. Restart Minecraft. diff --git a/src/main/resources/assets/skysgrassslabs/lang/en_pt.lang b/src/main/resources/assets/skysgrassslabs/lang/en_pt.lang index e83efd3..22b5abb 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/en_pt.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/en_pt.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Dirt Slab tile.skysgrassslabs.grass_slab.name=Grass Slab tile.skysgrassslabs.path_slab.name=Grass Path Slab tile.skysgrassslabs.turf.name=Turf +config.skysgrassslabs.generateGrassSlabs=Generate grass slabs +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Replace legacy slabs +config.skysgrassslabs.generateGrassSlabs.tooltip=Add grass slabs to new Overworld slopes. With Terrain Smoother, this controls grass only. Existing chunks stay unchanged. Restart Minecraft. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Legacy compatibility setting. Leave off in normal Minecraft 1.12.2 packs. Recovery of old slabs is automatic. Restart Minecraft. diff --git a/src/main/resources/assets/skysgrassslabs/lang/en_us.lang b/src/main/resources/assets/skysgrassslabs/lang/en_us.lang index e83efd3..22b5abb 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/en_us.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/en_us.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Dirt Slab tile.skysgrassslabs.grass_slab.name=Grass Slab tile.skysgrassslabs.path_slab.name=Grass Path Slab tile.skysgrassslabs.turf.name=Turf +config.skysgrassslabs.generateGrassSlabs=Generate grass slabs +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Replace legacy slabs +config.skysgrassslabs.generateGrassSlabs.tooltip=Add grass slabs to new Overworld slopes. With Terrain Smoother, this controls grass only. Existing chunks stay unchanged. Restart Minecraft. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Legacy compatibility setting. Leave off in normal Minecraft 1.12.2 packs. Recovery of old slabs is automatic. Restart Minecraft. diff --git a/src/main/resources/assets/skysgrassslabs/lang/es_es.lang b/src/main/resources/assets/skysgrassslabs/lang/es_es.lang index 5071e06..f1346c0 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/es_es.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/es_es.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Losa de tierra tile.skysgrassslabs.grass_slab.name=Losa de césped tile.skysgrassslabs.path_slab.name=Losa de camino de hierba tile.skysgrassslabs.turf.name=Tepe de césped +config.skysgrassslabs.generateGrassSlabs=Generar losas de césped +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Reemplazar losas antiguas +config.skysgrassslabs.generateGrassSlabs.tooltip=Añade losas de hierba a pendientes nuevas del mundo normal. Con Terrain Smoother, solo controla la hierba. Los chunks existentes no cambian. Reinicia Minecraft. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Opción de compatibilidad con mundos antiguos. Déjala desactivada en packs normales de Minecraft 1.12.2. Las losas antiguas se recuperan automáticamente. Reinicia Minecraft. diff --git a/src/main/resources/assets/skysgrassslabs/lang/es_mx.lang b/src/main/resources/assets/skysgrassslabs/lang/es_mx.lang index c371835..e205139 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/es_mx.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/es_mx.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Losa de tierra tile.skysgrassslabs.grass_slab.name=Losa de pasto tile.skysgrassslabs.path_slab.name=Losa de sendero de pasto tile.skysgrassslabs.turf.name=Tapete de pasto +config.skysgrassslabs.generateGrassSlabs=Generar losas de césped +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Reemplazar losas antiguas +config.skysgrassslabs.generateGrassSlabs.tooltip=Añade losas de hierba a pendientes nuevas del mundo normal. Con Terrain Smoother, solo controla la hierba. Los chunks existentes no cambian. Reinicia Minecraft. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Opción de compatibilidad con mundos antiguos. Déjala desactivada en packs normales de Minecraft 1.12.2. Las losas antiguas se recuperan automáticamente. Reinicia Minecraft. diff --git a/src/main/resources/assets/skysgrassslabs/lang/fr_ca.lang b/src/main/resources/assets/skysgrassslabs/lang/fr_ca.lang index bef1dba..3a03b7e 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/fr_ca.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/fr_ca.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Dalle de terre tile.skysgrassslabs.grass_slab.name=Dalle de gazon tile.skysgrassslabs.path_slab.name=Dalle de sentier de gazon tile.skysgrassslabs.turf.name=Plaque de gazon +config.skysgrassslabs.generateGrassSlabs=Générer des dalles d'herbe +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Remplacer les anciennes dalles +config.skysgrassslabs.generateGrassSlabs.tooltip=Ajoute des dalles d'herbe aux nouvelles pentes du monde normal. Avec Terrain Smoother, cette option ne contrôle que l'herbe. Les chunks existants ne changent pas. Redémarrez Minecraft. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Option de compatibilité avec les anciens mondes. Laissez-la désactivée dans les packs 1.12.2 habituels. Les anciennes dalles sont récupérées automatiquement. Redémarrez Minecraft. diff --git a/src/main/resources/assets/skysgrassslabs/lang/fr_fr.lang b/src/main/resources/assets/skysgrassslabs/lang/fr_fr.lang index 668594a..a40e179 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/fr_fr.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/fr_fr.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Dalle de terre tile.skysgrassslabs.grass_slab.name=Dalle d'herbe tile.skysgrassslabs.path_slab.name=Dalle de chemin d'herbe tile.skysgrassslabs.turf.name=Plaque de gazon +config.skysgrassslabs.generateGrassSlabs=Générer des dalles d'herbe +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Remplacer les anciennes dalles +config.skysgrassslabs.generateGrassSlabs.tooltip=Ajoute des dalles d'herbe aux nouvelles pentes du monde normal. Avec Terrain Smoother, cette option ne contrôle que l'herbe. Les chunks existants ne changent pas. Redémarrez Minecraft. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Option de compatibilité avec les anciens mondes. Laissez-la désactivée dans les packs 1.12.2 habituels. Les anciennes dalles sont récupérées automatiquement. Redémarrez Minecraft. diff --git a/src/main/resources/assets/skysgrassslabs/lang/ja_jp.lang b/src/main/resources/assets/skysgrassslabs/lang/ja_jp.lang index 3cbc383..339e91a 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/ja_jp.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/ja_jp.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=土のハーフブロック tile.skysgrassslabs.grass_slab.name=草ブロックのハーフブロック tile.skysgrassslabs.path_slab.name=草の道のハーフブロック tile.skysgrassslabs.turf.name=芝生 +config.skysgrassslabs.generateGrassSlabs=草のハーフブロックを生成 +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=旧MODのハーフブロックを置換 +config.skysgrassslabs.generateGrassSlabs.tooltip=新しく生成されるオーバーワールドの斜面に草のハーフブロックを置きます。Terrain Smoother導入時は草だけを設定します。既存のチャンクは変わりません。Minecraftの再起動が必要です。 +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=古いワールド用の互換設定です。通常のMinecraft 1.12.2環境では無効のままにしてください。古いハーフブロックは自動で復元されます。再起動が必要です。 diff --git a/src/main/resources/assets/skysgrassslabs/lang/ko_kr.lang b/src/main/resources/assets/skysgrassslabs/lang/ko_kr.lang index 136d120..b8e2f80 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/ko_kr.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/ko_kr.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=흙 반 블록 tile.skysgrassslabs.grass_slab.name=잔디 블록 반 블록 tile.skysgrassslabs.path_slab.name=잔디 길 반 블록 tile.skysgrassslabs.turf.name=잔디 +config.skysgrassslabs.generateGrassSlabs=잔디 반 블록 생성 +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=이전 모드 반 블록 교체 +config.skysgrassslabs.generateGrassSlabs.tooltip=새로 생성되는 오버월드 경사면에 잔디 반 블록을 놓습니다. Terrain Smoother가 있으면 잔디에만 적용됩니다. 기존 청크는 바뀌지 않습니다. Minecraft를 다시 시작해야 합니다. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=이전 월드용 호환 설정입니다. 일반적인 Minecraft 1.12.2 팩에서는 꺼 두세요. 이전 반 블록은 자동으로 복구됩니다. 다시 시작해야 합니다. diff --git a/src/main/resources/assets/skysgrassslabs/lang/pt_br.lang b/src/main/resources/assets/skysgrassslabs/lang/pt_br.lang index 98e2bf8..6f17a9c 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/pt_br.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/pt_br.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Laje de Terra tile.skysgrassslabs.grass_slab.name=Laje de Bloco de Grama tile.skysgrassslabs.path_slab.name=Laje de Caminho de Grama tile.skysgrassslabs.turf.name=Placa de Grama +config.skysgrassslabs.generateGrassSlabs=Gerar lajes de relva +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Substituir lajes antigas +config.skysgrassslabs.generateGrassSlabs.tooltip=Adiciona lajes de grama às novas encostas do mundo normal. Com Terrain Smoother, controla apenas a grama. Os chunks existentes não mudam. Reinicie o Minecraft. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Opção de compatibilidade com mundos antigos. Deixe desativada em pacotes normais do Minecraft 1.12.2. As lajes antigas são recuperadas automaticamente. Reinicie o Minecraft. diff --git a/src/main/resources/assets/skysgrassslabs/lang/pt_pt.lang b/src/main/resources/assets/skysgrassslabs/lang/pt_pt.lang index 3729a8b..b446c4a 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/pt_pt.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/pt_pt.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Degrau de Terra tile.skysgrassslabs.grass_slab.name=Degrau de Bloco de Relva tile.skysgrassslabs.path_slab.name=Degrau de Caminho de Relva tile.skysgrassslabs.turf.name=Placa de Relva +config.skysgrassslabs.generateGrassSlabs=Gerar lajes de relva +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Substituir lajes antigas +config.skysgrassslabs.generateGrassSlabs.tooltip=Acrescenta degraus de relva às novas encostas do mundo normal. Com Terrain Smoother, controla apenas a relva. Os chunks existentes não mudam. Reinicie o Minecraft. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Opção de compatibilidade com mundos antigos. Deixe desativada nos packs normais do Minecraft 1.12.2. Os degraus antigos são recuperados automaticamente. Reinicie o Minecraft. diff --git a/src/main/resources/assets/skysgrassslabs/lang/ru_ru.lang b/src/main/resources/assets/skysgrassslabs/lang/ru_ru.lang index ec5357d..a3083b3 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/ru_ru.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/ru_ru.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=Земляная плита tile.skysgrassslabs.grass_slab.name=Дёрновая плита tile.skysgrassslabs.path_slab.name=Плита тропы tile.skysgrassslabs.turf.name=Дёрн +config.skysgrassslabs.generateGrassSlabs=Генерировать дёрновые плиты +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=Заменять старые плиты +config.skysgrassslabs.generateGrassSlabs.tooltip=Добавляет дёрновые плиты на новые склоны обычного мира. С Terrain Smoother настройка управляет только травой. Существующие чанки не меняются. Перезапустите Minecraft. +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=Совместимость со старыми мирами. В обычных сборках Minecraft 1.12.2 оставьте выключенной. Старые плиты восстанавливаются автоматически. Требуется перезапуск. diff --git a/src/main/resources/assets/skysgrassslabs/lang/zh_cn.lang b/src/main/resources/assets/skysgrassslabs/lang/zh_cn.lang index 5d604b1..5f0e901 100644 --- a/src/main/resources/assets/skysgrassslabs/lang/zh_cn.lang +++ b/src/main/resources/assets/skysgrassslabs/lang/zh_cn.lang @@ -2,3 +2,7 @@ tile.skysgrassslabs.dirt_slab.name=泥土台阶 tile.skysgrassslabs.grass_slab.name=草方块台阶 tile.skysgrassslabs.path_slab.name=草径台阶 tile.skysgrassslabs.turf.name=草皮 +config.skysgrassslabs.generateGrassSlabs=生成草方块台阶 +config.skysgrassslabs.forceReplaceBuildingBricksSlabs=替换旧版台阶 +config.skysgrassslabs.generateGrassSlabs.tooltip=在新生成的主世界斜坡上放置草方块台阶。安装Terrain Smoother后,此选项只控制草地。已有区块不会改变。需要重启Minecraft。 +config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip=旧世界兼容设置。普通Minecraft 1.12.2整合包请保持关闭。旧台阶会自动恢复。需要重启Minecraft。 diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/BuildArtifactContractTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/BuildArtifactContractTest.java new file mode 100644 index 0000000..43718cb --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/BuildArtifactContractTest.java @@ -0,0 +1,17 @@ +package zone.moddev.mc.skysgrassslabs; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BuildArtifactContractTest { + @Test + void checksumTaskDeclaresItsArtifactInputs() throws Exception { + String build = new String(Files.readAllBytes(Paths.get("build.gradle")), StandardCharsets.UTF_8); + String checksumTask = build.substring(build.indexOf("tasks.register('writeReleaseChecksums')")); + assertTrue(checksumTask.contains("inputs.files(releaseFiles.map"), + "Incremental builds must regenerate checksums when a release jar changes"); + } +} diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/LocalizationContractTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/LocalizationContractTest.java index c56d4d7..025214a 100644 --- a/src/test/java/zone/moddev/mc/skysgrassslabs/LocalizationContractTest.java +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/LocalizationContractTest.java @@ -26,7 +26,10 @@ class LocalizationContractTest { private static final File LANG_DIR = new File("src/main/resources/assets/skysgrassslabs/lang"); private static final List KEYS = Arrays.asList("tile.skysgrassslabs.dirt_slab.name", "tile.skysgrassslabs.grass_slab.name", "tile.skysgrassslabs.path_slab.name", - "tile.skysgrassslabs.turf.name"); + "tile.skysgrassslabs.turf.name", "config.skysgrassslabs.generateGrassSlabs", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs", + "config.skysgrassslabs.generateGrassSlabs.tooltip", + "config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip"); private static final List LOCALES = Arrays.asList("de_at", "de_au", "de_de", "en_ca", "en_en", "en_gb", "en_pt", "en_us", "es_es", "es_mx", "fr_ca", "fr_fr", "ja_jp", "ko_kr", "pt_br", "pt_pt", "ru_ru", "zh_cn"); @@ -48,7 +51,7 @@ void allEighteenLocalesHaveExactOrderedKeyParity() throws Exception { Map translations = read(locale); assertEquals(KEYS, new ArrayList(translations.keySet()), locale); - assertEquals(4, translations.size(), locale); + assertEquals(8, translations.size(), locale); } } @@ -103,7 +106,7 @@ void translationsAndRegionalDifferencesAreLocked() throws Exception { } private static void assertValues(String locale, String... values) throws Exception { - assertEquals(Arrays.asList(values), new ArrayList(read(locale).values()), locale); + assertEquals(Arrays.asList(values), new ArrayList(read(locale).values()).subList(0, 4), locale); } private static Map read(String locale) throws Exception { diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/ProjectContractTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/ProjectContractTest.java index 8daccec..5ded2c0 100644 --- a/src/test/java/zone/moddev/mc/skysgrassslabs/ProjectContractTest.java +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/ProjectContractTest.java @@ -21,7 +21,7 @@ void releaseIdentityAndToolchainArePinned() throws Exception { assertTrue(properties.contains("minecraft_version=1.12.2")); assertTrue(properties.contains("forge_version=14.23.5.2859")); assertTrue(properties.contains("mapping_version=39-1.12")); - assertTrue(properties.contains("mod_version=1.1.0.112021")); + assertTrue(properties.contains("mod_version=1.1.1.112021")); assertTrue(properties.contains("curseforge_project_id=1677588")); assertTrue(properties.contains("java_toolchain_version=8.0.502+7")); } @@ -124,7 +124,7 @@ void releaseTagWorkflowPointsAtTheManualDispatcherInputs() throws Exception { assertFalse(workflow.contains("release_ref:")); assertFalse(workflow.contains("curseforge_channel:")); assertFalse(workflow.contains("confirm_version:")); - assertTrue(new File("docs/RELEASE-1.1.0.112021.md").isFile()); + assertTrue(new File("docs/RELEASE-1.1.1.112021.md").isFile()); } @Test diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/block/GrassFormsContractTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/block/GrassFormsContractTest.java index 1eeb4fb..4949f37 100644 --- a/src/test/java/zone/moddev/mc/skysgrassslabs/block/GrassFormsContractTest.java +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/block/GrassFormsContractTest.java @@ -1,8 +1,13 @@ package zone.moddev.mc.skysgrassslabs.block; import net.minecraft.block.BlockGrass; +import net.minecraft.block.Block; import net.minecraft.block.BlockSlab; +import net.minecraft.block.material.Material; import net.minecraft.block.state.IBlockState; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.IBlockAccess; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import zone.moddev.mc.skysgrassslabs.MinecraftTestBootstrap; @@ -45,4 +50,30 @@ void duplicateOrInvalidMappingsAreRejected() { assertThrows(IllegalArgumentException.class, () -> GrassSlabsApi .registerGrassForm(ModBlocks.DIRT_SLAB.getDefaultState(), ModBlocks.DIRT_SLAB.getDefaultState())); } + + @Test + void onlyACompleteFootprintCoversTheGrassBelow() { + for (int metadata = 0; metadata < 2; metadata++) { + assertTrue(GrassSlabsApi.coversSupport(ModBlocks.GRASS_SLAB.getStateFromMeta(metadata), + null, BlockPos.ORIGIN)); + } + assertTrue(GrassSlabsApi.coversSupport(ModBlocks.TURF.getDefaultState(), + null, BlockPos.ORIGIN)); + + for (AxisAlignedBB bounds : new AxisAlignedBB[] { + new AxisAlignedBB(0, 0, 0, 1, .5, .5), + new AxisAlignedBB(.5, 0, 0, 1, .5, 1), + new AxisAlignedBB(0, 0, .5, 1, .5, 1), + new AxisAlignedBB(0, 0, 0, .5, .5, 1), + new AxisAlignedBB(0, 0, 0, .5, .5, .5) }) { + Block partial = new Block(Material.GRASS) { + @Override + public AxisAlignedBB getBoundingBox(IBlockState state, IBlockAccess world, BlockPos pos) { + return bounds; + } + }; + assertFalse(GrassSlabsApi.coversSupport(partial.getDefaultState(), + null, BlockPos.ORIGIN)); + } + } } diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/compat/LegacyRegistryWarningsTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/compat/LegacyRegistryWarningsTest.java new file mode 100644 index 0000000..4c1b588 --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/compat/LegacyRegistryWarningsTest.java @@ -0,0 +1,189 @@ +package zone.moddev.mc.skysgrassslabs.compat; + +import java.util.concurrent.atomic.AtomicBoolean; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.registries.IForgeRegistryEntry; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class LegacyRegistryWarningsTest { + private static final String GRASS = "buildingbricks:grass_slab"; + private static final StackTraceElement[] FORGE_ORIGIN = { + new StackTraceElement("net.minecraftforge.registries.GameData", "checkPrefix", "Forge.java", 82), + new StackTraceElement("net.minecraftforge.registries.IForgeRegistryEntry$Impl", "setRegistryName", "IForgeRegistryEntry.java", 90) + }; + + @Test + void realLoggingKeepsUnrelatedWarningsErrorsAndOutOfScopeMessages() { + org.apache.logging.log4j.Logger apiLogger = net.minecraftforge.fml.common.FMLLog.log; + if (apiLogger == null) { + apiLogger = LogManager.getLogger("FML"); + } + org.apache.logging.log4j.core.Logger logger = (org.apache.logging.log4j.core.Logger) apiLogger; + java.util.List captured = new java.util.ArrayList<>(); + AbstractAppender appender = new AbstractAppender("LegacyRegistryWarningTest", null, null) { + @Override + public void append(LogEvent event) { + captured.add(event.getMessage().getFormattedMessage()); + } + }; + Level previousLevel = logger.getLevel(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.ALL); + try { + Entry entry = new Entry(() -> { + logger.warn("Unexpected registration warning"); + logger.error("Actual registration error"); + logger.warn(message(GRASS), new IllegalStateException("A real exception")); + }); + LegacyRegistryWarnings.setName(entry, new ResourceLocation(GRASS)); + logger.warn(message(GRASS)); + assertEquals(4, captured.size()); + assertEquals("Unexpected registration warning", captured.get(0)); + assertEquals("Actual registration error", captured.get(1)); + assertEquals(message(GRASS), captured.get(2)); + assertEquals(message(GRASS), captured.get(3)); + } finally { + logger.removeAppender(appender); + logger.setLevel(previousLevel); + appender.stop(); + } + } + + @Test + void onlyTheThreeExactRecoveryNamesQualify() { + for (String identity : new String[] { + GRASS, "buildingbricks:dirt_slab", "buildingbrickscompatvanilla:grass_slab" }) { + assertTrue(matches(identity, message(identity)), identity); + } + assertFalse(matches("buildingbricks:stone_slab", message("buildingbricks:stone_slab"))); + assertFalse(matches("othermod:grass_slab", message("othermod:grass_slab"))); + assertFalse(matches(null, message(GRASS))); + } + + @Test + void anotherNameOrRegisteringModIsNotHidden() { + assertFalse(matches(GRASS, message("buildingbricks:dirt_slab"))); + assertFalse(matches(GRASS, message(GRASS).replace("skysgrassslabs", "anothermod"))); + assertFalse(matches(GRASS, message(GRASS) + " Additional registry error")); + assertFalse(matches(GRASS, "A different warning")); + } + + @Test + void otherLevelsLoggersExceptionsAndCallSitesStayVisible() { + assertFalse(LegacyRegistryWarnings.matches(GRASS, "anothermod", Level.WARN, + message(GRASS), null, FORGE_ORIGIN)); + for (Level level : new Level[] { Level.INFO, Level.ERROR, Level.FATAL }) { + assertFalse(LegacyRegistryWarnings.matches(GRASS, "FML", level, + message(GRASS), null, FORGE_ORIGIN)); + } + assertFalse(LegacyRegistryWarnings.matches(GRASS, "FML", Level.WARN, + message(GRASS), new IllegalStateException("real failure"), FORGE_ORIGIN)); + assertFalse(LegacyRegistryWarnings.matches(GRASS, "FML", Level.WARN, + message(GRASS), null, new StackTraceElement[0])); + assertFalse(LegacyRegistryWarnings.matches(GRASS, "FML", Level.WARN, + message(GRASS), null, new StackTraceElement[] { FORGE_ORIGIN[1] })); + } + + @Test + void theScopeEndsAfterSuccessOrFailure() { + Entry successful = new Entry(() -> assertTrue(active(GRASS))); + LegacyRegistryWarnings.setName(successful, new ResourceLocation(GRASS)); + assertEquals(GRASS, successful.getRegistryName().toString()); + assertFalse(active(GRASS)); + + Entry failing = new Entry(() -> { + assertTrue(active(GRASS)); + throw new IllegalStateException("registration failed"); + }); + assertThrows(IllegalStateException.class, + () -> LegacyRegistryWarnings.setName(failing, new ResourceLocation(GRASS))); + assertFalse(active(GRASS)); + } + + @Test + void nestedNamesRestoreTheOuterScopeAndOtherThreadsAreUnaffected() { + String dirt = "buildingbricks:dirt_slab"; + Entry outer = new Entry(() -> { + LegacyRegistryWarnings.setName(new Entry(() -> assertTrue(active(dirt))), + new ResourceLocation(dirt)); + assertTrue(active(GRASS)); + AtomicBoolean hiddenElsewhere = new AtomicBoolean(true); + Thread worker = new Thread(() -> hiddenElsewhere.set(active(GRASS))); + worker.start(); + try { + worker.join(); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + assertFalse(hiddenElsewhere.get()); + }); + LegacyRegistryWarnings.setName(outer, new ResourceLocation(GRASS)); + assertFalse(active(GRASS)); + } + + @Test + void unknownNamesNeverEnterTheFilterScope() { + Entry entry = new Entry(() -> fail("Unknown registration must not be invoked")); + assertThrows(IllegalArgumentException.class, + () -> LegacyRegistryWarnings.setName(entry, new ResourceLocation("buildingbricks:stone_slab"))); + assertNull(entry.getRegistryName()); + } + + @Test + void diagnosticRowsAreOnlyHiddenAtTheKnownForgeCallSite() { + assertFalse(matches(GRASS, "****************************************")); + assertFalse(matches(GRASS, + "* at net.minecraftforge.fml.common.registry.IForgeRegistryEntry$Impl.setRegistryName(Forge.java:82)")); + assertFalse(matches(GRASS, "* A real unrelated warning")); + assertFalse(LegacyRegistryWarnings.matches(GRASS, "FML", Level.WARN, + "****************************************", null, new StackTraceElement[0])); + } + + private static boolean matches(String identity, String text) { + return LegacyRegistryWarnings.matches(identity, "FML", Level.WARN, text, null, FORGE_ORIGIN); + } + + private static boolean active(String identity) { + return LegacyRegistryWarnings.hideForCurrentRegistration( + "FML", Level.WARN, message(identity), null, FORGE_ORIGIN); + } + + private static String message(String identity) { + String[] parts = identity.split(":", 2); + return "Potentially Dangerous alternative prefix `" + parts[0] + "` for name `" + parts[1] + + "`, expected `skysgrassslabs`. This could be a intended override, but in most cases indicates a broken mod."; + } + + private static final class Entry implements IForgeRegistryEntry { + private final Runnable duringRegistration; + private ResourceLocation name; + + private Entry(Runnable duringRegistration) { + this.duringRegistration = duringRegistration; + } + + @Override + public Entry setRegistryName(ResourceLocation name) { + duringRegistration.run(); + this.name = name; + return this; + } + + @Override + public ResourceLocation getRegistryName() { + return name; + } + + @Override + public Class getRegistryType() { + return Entry.class; + } + } +} diff --git a/src/test/java/zone/moddev/mc/skysgrassslabs/config/ConfigScreenContractTest.java b/src/test/java/zone/moddev/mc/skysgrassslabs/config/ConfigScreenContractTest.java new file mode 100644 index 0000000..ae288f1 --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysgrassslabs/config/ConfigScreenContractTest.java @@ -0,0 +1,82 @@ +package zone.moddev.mc.skysgrassslabs.config; + +import java.io.File; +import java.nio.file.Path; +import net.minecraftforge.common.config.Configuration; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.relauncher.FMLInjectionData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import zone.moddev.mc.skysgrassslabs.SkysGrassSlabs; +import static org.junit.jupiter.api.Assertions.*; + +class ConfigScreenContractTest { + @TempDir + Path temporaryDirectory; + + @BeforeEach + void provideMinecraftHome() throws Exception { + java.lang.reflect.Field home = FMLInjectionData.class.getDeclaredField("minecraftHome"); + home.setAccessible(true); + home.set(null, temporaryDirectory.toFile()); + } + + @Test + void configButtonHasAClientFactory() { + assertEquals("zone.moddev.mc.skysgrassslabs.client.GrassSlabsGuiFactory", + SkysGrassSlabs.class.getAnnotation(Mod.class).guiFactory()); + } + + @Test + void loadingKeepsTheHelpTextOnEveryProperty() { + SkysGrassSlabsConfig.load(temporaryDirectory.resolve("help.cfg").toFile()); + Configuration pending = SkysGrassSlabsConfig.configuration(); + assertFalse(pending.getCategory("worldgen").get("generateGrassSlabs").getComment().trim().isEmpty()); + assertFalse(pending.getCategory("compat").get("forceReplaceBuildingBricksSlabs").getComment().trim().isEmpty()); + } + + @Test + void allLanguagesExplainEachConfigEntry() throws Exception { + try (java.util.stream.Stream languages = java.nio.file.Files.list( + java.nio.file.Paths.get("src/main/resources/assets/skysgrassslabs/lang"))) { + for (Path language : languages.collect(java.util.stream.Collectors.toList())) { + java.util.Properties translations = new java.util.Properties(); + try (java.io.Reader reader = java.nio.file.Files.newBufferedReader(language, + java.nio.charset.StandardCharsets.UTF_8)) { + translations.load(reader); + } + assertFalse(translations.getProperty("config.skysgrassslabs.generateGrassSlabs.tooltip", "").trim().isEmpty(), + language + " needs help for generateGrassSlabs"); + assertFalse(translations.getProperty("config.skysgrassslabs.forceReplaceBuildingBricksSlabs.tooltip", "").trim().isEmpty(), + language + " needs help for forceReplaceBuildingBricksSlabs"); + } + } + } + + @Test + void savingChoicesDoesNotChangeTheRunningSession() { + File file = temporaryDirectory.resolve("settings.cfg").toFile(); + SkysGrassSlabsConfig.load(file); + Configuration pending = SkysGrassSlabsConfig.configuration(); + + assertTrue(pending.getCategory("worldgen").requiresMcRestart()); + assertTrue(pending.getCategory("compat").requiresMcRestart()); + pending.get("worldgen", "generateGrassSlabs", true).set(false); + pending.get("compat", "forceReplaceBuildingBricksSlabs", false).set(true); + SkysGrassSlabsConfig.saveChanges(); + + // Saving the GUI must not re-arbitrate generation or turn on migration mid-world. + assertTrue(SkysGrassSlabsConfig.generateGrassSlabs()); + assertFalse(SkysGrassSlabsConfig.forceReplaceBuildingBricksSlabs()); + Configuration written = new Configuration(file); + written.load(); + assertFalse(written.get("worldgen", "generateGrassSlabs", true).getBoolean()); + assertTrue(written.get("compat", "forceReplaceBuildingBricksSlabs", false).getBoolean()); + + SkysGrassSlabsConfig.load(file); + assertFalse(SkysGrassSlabsConfig.generateGrassSlabs()); + assertTrue(SkysGrassSlabsConfig.forceReplaceBuildingBricksSlabs()); + } + +}