From f62a08d1afffb443214551c9396880b8a8ecc126 Mon Sep 17 00:00:00 2001
From: TheMeinerLP
+ * A policy only decides. It does not count and it does not log: the loader catches the failure,
+ * records it in its {@link AnvilDiagnostics} and writes the log line, so every diagnostic of a load
+ * stays in one place and this contract stays free of the loader's infrastructure.
+ *
+ * Returning an id substitutes it; throwing {@link AnvilChunkException} fails the chunk. Substituting
+ * is right for a server that wants a world to stay loadable and wrong for a tool that converts one,
+ * which is why this is a policy and not a constant.
+ *
+ * A caller either configures an instance explicitly, or asks for classpath discovery, but not both
+ * at once: {@link #choose(Class, Object, boolean)} refuses the combination outright rather than
+ * silently preferring one side. Discovery itself, in {@link #discover(Class)}, refuses to guess
+ * between more than one registered provider.
+ *
+ * The dummy providers this test resolves are registered through the test resources, under
+ * {@code META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$Dummy}, so the main
+ * module registers nothing extra.
+ *
+ * A policy only decides. It does not count and it does not log: the loader catches the failure,
+ * records it in its {@link AnvilDiagnostics} and writes the log line, so every diagnostic of a load
+ * stays in one place and this contract stays free of the loader's infrastructure.
+ *
+ * This is the guard against a specific piece of data loss: before snapshot {@code 21w43a}, a
+ * chunk's block data lived under a {@code Level} compound instead of {@code sections} on the root.
+ * Reading such a chunk with a loader that only looks for {@code sections} on the root does not
+ * fail, it silently decodes to an empty section list — a chunk of air. This policy is what turns
+ * that silent data loss into a thrown exception.
+ *
+ * This type is experimental, like everything else in this package.
+ *
+ * The layout is checked before the version, because a version number is a claim about the data
+ * while the layout is the data: a chunk may carry no version at all, and one that carries a
+ * version may not hold what that version promises. A root compound without {@code sections} but
+ * with a {@code Level} compound is the pre-1.18 shape, which would otherwise decode to an empty
+ * section list and reach the caller as a chunk of air.
+ *
+ * A missing {@code DataVersion} is the one case that is not a rejection: a tool which writes
+ * {@code sections} on the root but never learned to stamp a version has to keep loading, or a
+ * whole category of externally-written world becomes unreadable. A key that is present but is not
+ * the number it claims to be, and a key that holds a negative number, are both a different
+ * situation from absent: something wrote a value there and it does not describe a version this
+ * loader can trust, so both are refused rather than waved through the same path as "nothing was
+ * ever written".
+ *
+ * Resolved once, in the constructor, through {@link ServiceResolution#choose(Class, Object, + * boolean)}. A builder which never touches {@link Builder#versionPolicy(ChunkVersionPolicy)} or + * {@link Builder#discoverVersionPolicy()} discovers {@link DefaultChunkVersionPolicy} through the + * classpath by default, which is what keeps every loader built the way earlier versions built + * one refusing the same chunks it always refused. Calling {@code versionPolicy(null)} is the + * only way to leave this field null, and {@link #checkVersion(CompoundBinaryTag)} treats that as + * "check nothing" rather than substituting the default itself. + *
+ * + * @since 1.2.0 + */ + private final @Nullable ChunkVersionPolicy versionPolicy; + /** * Where failures are reported, or null for the exception manager of the running server. *
@@ -219,6 +233,8 @@ private FalcoAnvilLoader(Path worldRoot, Key dimension, Builder settings) {
this.dataVersion = settings.dataVersion;
this.minimumDataVersion = settings.minimumDataVersion;
this.exceptionHandler = settings.exceptionHandler;
+ this.versionPolicy = ServiceResolution.choose(
+ ChunkVersionPolicy.class, settings.versionPolicy, settings.discoverVersionPolicy);
this.closeLock = new ReentrantLock();
// Which directory was chosen, and how many region files are in it, is the first thing
@@ -226,12 +242,13 @@ private FalcoAnvilLoader(Path worldRoot, Key dimension, Builder settings) {
// two layouts happens invisibly, and a world whose files sit in the other one looks exactly
// like a world which is empty.
LOGGER.info(
- "Opening the anvil loader for region={} layout={} exists={} regionFiles={} dim={}",
+ "Opening the anvil loader for region={} layout={} exists={} regionFiles={} dim={} versionPolicy={}",
this.regionDirectory,
this.legacyLayout ? "legacy
* The builder reaches the values the constructors set for themselves — the compression level,
- * the diagnostics, both palette resolvers, the save parallelism and the data version. The world
- * directory and the dimension are not among them: they are required, so they sit in
+ * the diagnostics, both palette resolvers, the save parallelism and the data version. It also
+ * defaults to discovering a {@link ChunkVersionPolicy} from the classpath, which is what keeps
+ * every loader built through a constructor rather than an explicit
+ * {@link Builder#versionPolicy(ChunkVersionPolicy)} call refusing the chunks it always refused.
+ * The world directory and the dimension are not among them: they are required, so they sit in
* {@link Builder#build(Path, Key)} rather than in a slot.
*
+ * Calling this slot always turns discovery off, whatever value is passed: an explicit + * decision about the policy — including the explicit decision "none" — has to win over the + * classpath without {@link ServiceResolution#choose(Class, Object, boolean)} seeing both set + * at once and refusing to guess between them. A builder that never calls this slot keeps + * discovering the default instead, which is what {@link #builder()} starts with. + *
+ *+ * Passing {@code null} is not "use the default": it is "check nothing". A pre-{@code + * 21w43a} chunk that would otherwise be refused loads as a chunk of air instead, exactly as + * this loader read one before {@link ChunkVersionPolicy} existed. That cost is deliberate — + * see {@code testWithoutAnyPolicyALegacyChunkIsNotChecked} in the loader's integration + * tests for the case that documents it. + *
+ * + * @param versionPolicy the policy to consult before a chunk is decoded, or null to consult + * none + * @return a new builder with this value + * @since 1.2.0 + */ + @Contract(value = "_ -> new", pure = true) + public Builder versionPolicy(@Nullable ChunkVersionPolicy versionPolicy) { + return new Builder(this.openRegionLimit, + this.compressionLevel, + this.saveParallelism, + this.dataVersion, + this.minimumDataVersion, + this.diagnostics, + this.blockResolver, + this.biomeResolver, + this.exceptionHandler, + versionPolicy, + false); + } + + /** + * Asks the loader to discover its {@link ChunkVersionPolicy} from the classpath instead of + * using an explicit instance. + *+ * This is what {@link #builder()} already defaults to, so calling it only matters after an + * earlier {@link #versionPolicy(ChunkVersionPolicy)} call on the same chain, to undo it. A + * classpath that registers more than one provider makes {@link Builder#build(Path, Key)} + * throw {@link IllegalStateException} rather than guess between them. + *
+ * + * @return a new builder with this value + * @since 1.2.0 + */ + @Contract(value = "-> new", pure = true) + public Builder discoverVersionPolicy() { + return new Builder(this.openRegionLimit, + this.compressionLevel, + this.saveParallelism, + this.dataVersion, + this.minimumDataVersion, + this.diagnostics, + this.blockResolver, + this.biomeResolver, + this.exceptionHandler, + null, + true); } /** @@ -706,7 +815,9 @@ private record ResolvedRegionDirectory(Path directory, boolean legacyLayout) { } CompoundBinaryTag data = TAG_READER.read(new ByteArrayInputStream(raw.decompress()), BinaryTagIO.Compression.NONE); - requireReadableVersion(data); + if (this.versionPolicy != null) { + checkVersion(data); + } String status = chunkStatus(data); if (!isFullyGenerated(status)) { @@ -1411,61 +1522,54 @@ public int openRegionCount() { } /** - * Refuses a chunk which comes from a version this loader cannot read. + * Consults {@link #versionPolicy} about a chunk, and reports and counts a refusal before it + * reaches the caller. *- * The layout is checked before the version, because a version number is a claim about the data - * while the layout is the data: a chunk may carry no version at all, and one that carries a - * version may not hold what that version promises. A root compound without {@code sections} but - * with a {@code Level} compound is the pre-1.18 shape, which would otherwise decode to an empty - * section list and reach the caller as a chunk of air. - *
- *- * A missing {@code DataVersion} is the one case that is not a rejection: a tool which writes - * {@code sections} on the root but never learned to stamp a version has to keep loading, or a - * whole category of externally-written world becomes unreadable. A key that is present but is not - * the number it claims to be, and a key that holds a negative number, are both a different - * situation from absent: something wrote a value there and it does not describe a version this - * loader can trust, so both are refused rather than waved through the same path as "nothing was - * ever written". + * The policy only decides and throws; it does not know about {@link AnvilDiagnostics} or the + * logger, on purpose — see {@link ChunkVersionPolicy}. Counting and logging the refusal is + * therefore the loader's job, done here rather than duplicated at every call site, and done + * only when {@link #versionPolicy} is not null: the caller at {@link #loadChunk(Instance, int, + * int)} already guards the call for that reason. *
* * @param data the root compound of the chunk - * @throws ChunkDataException if the chunk cannot be read + * @throws ChunkDataException if the policy refuses the chunk */ - private void requireReadableVersion(CompoundBinaryTag data) throws ChunkDataException { - boolean versionMissing = data.get(DATA_VERSION_KEY) == null; - // A stored value that is not a number falls back to the same -1 as an absent key, but the - // two are not the same failure: this flag is what lets the exception below say "not a - // number" instead of misreporting a value ("-1") that was never actually stored. - boolean versionMistyped = !versionMissing && !(data.get(DATA_VERSION_KEY) instanceof NumberBinaryTag); - int version = NbtReads.optionalInteger(data, DATA_VERSION_KEY, -1); - String reported = versionMissing ? AnvilDiagnostics.UNKNOWN_DATA_VERSION : Integer.toString(version); - boolean legacyChunkLayout = !(data.get(SECTIONS_KEY) instanceof ListBinaryTag) - && NbtReads.optionalCompound(data, LEGACY_LEVEL_KEY) != null; - - if (!legacyChunkLayout && (versionMissing || version >= this.minimumDataVersion)) { - return; - } - - if (this.diagnostics.reportUnsupportedChunkVersion(reported)) { - LOGGER.warn( - "Refusing a chunk from data version {} in {}: {}", - reported, this.regionDirectory, - legacyChunkLayout - ? "the chunk data sits under Level, which this loader does not read" - : "the loader accepts " + this.minimumDataVersion + " and above" - ); + private void checkVersion(CompoundBinaryTag data) throws ChunkDataException { + try { + this.versionPolicy.check(data, this.minimumDataVersion); + } catch (ChunkDataException failure) { + String reported = reportedDataVersion(data); + + if (this.diagnostics.reportUnsupportedChunkVersion(reported)) { + LOGGER.warn( + "Refusing a chunk from data version {} in {}: {}", + reported, this.regionDirectory, failure.getMessage() + ); + } + throw failure; } + } - throw new ChunkDataException( - ChunkDataException.Reason.UNSUPPORTED_CHUNK_VERSION, - legacyChunkLayout - ? "The chunk stores its data under Level, which means a version before 1.18" - : versionMistyped - ? "The chunk does not store its DataVersion as a number" - : "The chunk stores data version " + version - + " but the loader accepts " + this.minimumDataVersion + " and above" - ); + /** + * Renders the stored {@code DataVersion} of a chunk the way the version breakdown of + * {@link AnvilDiagnostics} groups it: by the number it holds, or by + * {@link AnvilDiagnostics#UNKNOWN_DATA_VERSION} for a chunk which stores none. + *+ * This mirrors only the presentation the guard used before it became a policy, not its + * decision: a key that is present but not a number renders as {@code "-1"} here exactly as it + * always did, because {@link NbtReads#optionalInteger(CompoundBinaryTag, String, int)} falls + * back to that default for a mistyped value the same way it does for a missing one. + *
+ * + * @param data the root compound of the chunk + * @return the data version to report, or {@link AnvilDiagnostics#UNKNOWN_DATA_VERSION} + */ + @Contract(pure = true) + private static String reportedDataVersion(CompoundBinaryTag data) { + return data.get(DATA_VERSION_KEY) == null + ? AnvilDiagnostics.UNKNOWN_DATA_VERSION + : Integer.toString(NbtReads.optionalInteger(data, DATA_VERSION_KEY, -1)); } /** diff --git a/falco-anvil/src/main/resources/META-INF/services/net.onelitefeather.falco.anvil.ChunkVersionPolicy b/falco-anvil/src/main/resources/META-INF/services/net.onelitefeather.falco.anvil.ChunkVersionPolicy new file mode 100644 index 0000000..67b2e89 --- /dev/null +++ b/falco-anvil/src/main/resources/META-INF/services/net.onelitefeather.falco.anvil.ChunkVersionPolicy @@ -0,0 +1 @@ +net.onelitefeather.falco.anvil.DefaultChunkVersionPolicy diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/ChunkVersionPolicyTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/ChunkVersionPolicyTest.java new file mode 100644 index 0000000..ebeada7 --- /dev/null +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/ChunkVersionPolicyTest.java @@ -0,0 +1,50 @@ +package net.onelitefeather.falco.anvil; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.ListBinaryTag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests {@link DefaultChunkVersionPolicy} directly, against chunk data built by hand rather than + * through the loader. What used to be the loader's private {@code requireReadableVersion} guard now + * lives here, unchanged in its decision logic and reachable without a running Minestom environment. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.2.0 + */ +class ChunkVersionPolicyTest { + + @Test + void testTheDefaultPolicyRefusesALevelLayout() { + CompoundBinaryTag legacy = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder().put("Sections", ListBinaryTag.empty()).build()) + .build(); + + ChunkDataException failure = assertThrows(ChunkDataException.class, + () -> new DefaultChunkVersionPolicy().check(legacy, 2844)); + assertEquals(ChunkDataException.Reason.UNSUPPORTED_CHUNK_VERSION, failure.reason()); + } + + @Test + void testTheDefaultPolicyAcceptsAChunkWithoutAStoredVersion() throws Exception { + CompoundBinaryTag toolWritten = CompoundBinaryTag.builder() + .put("sections", ListBinaryTag.empty()) + .build(); + + new DefaultChunkVersionPolicy().check(toolWritten, 2844); + } + + @Test + void testTheDefaultPolicyRefusesAMistypedVersion() { + CompoundBinaryTag broken = CompoundBinaryTag.builder() + .putString("DataVersion", "not-a-number") + .put("sections", ListBinaryTag.empty()) + .build(); + + assertThrows(ChunkDataException.class, () -> new DefaultChunkVersionPolicy().check(broken, 2844)); + } +} diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderIntegrationTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderIntegrationTest.java index 6a3d097..10bb7ab 100644 --- a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderIntegrationTest.java +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderIntegrationTest.java @@ -784,6 +784,48 @@ void testASectionsKeyStoredAsTheWrongTypeWithLevelIsRefused(Env env) throws Exce } } + @Test + void testAPolicyThatAllowsEverythingLetsALegacyChunkThrough(Env env) throws Exception { + CompoundBinaryTag legacy = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder().put("Sections", ListBinaryTag.empty()).build()) + .putString("Status", "minecraft:full") + .build(); + writeRawChunk(11, 11, legacy); + + try (FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .versionPolicy((data, minimum) -> { }) + .build(this.worldRoot, OVERWORLD)) { + Instance instance = env.createEmptyInstance(loader); + + assertNotNull(loader.loadChunk(instance, 11, 11)); + } + } + + /** + * Documents the cost of the loader's default: without any {@link ChunkVersionPolicy}, nothing + * checks a chunk's data version at all, so a pre-{@code 21w43a} chunk which stores its data + * under {@code Level} is not refused. It loads exactly as it did before this loader ever gained + * a version guard — as a chunk of air, because {@code sections} is absent from the root. This is + * a deliberate choice of the project, not a regression: a caller has to opt out of the check + * explicitly, with {@code versionPolicy(null)}, to reach this behaviour. + */ + @Test + void testWithoutAnyPolicyALegacyChunkIsNotChecked(Env env) throws Exception { + CompoundBinaryTag legacy = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder().put("Sections", ListBinaryTag.empty()).build()) + .putString("Status", "minecraft:full") + .build(); + writeRawChunk(12, 12, legacy); + + try (FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .versionPolicy(null) + .build(this.worldRoot, OVERWORLD)) { + Instance instance = env.createEmptyInstance(loader); + + assertNotNull(loader.loadChunk(instance, 12, 12)); + } + } + @Test void testUnloadingAForeignChunkIsIgnored(Env env) throws Exception { Instance instance = env.createEmptyInstance(loader()); From b32c4ba8c6a2add5b996602b1ad457453e104484 Mon Sep 17 00:00:00 2001 From: TheMeinerLP* Resolved once, in the constructor, through {@link ServiceResolution#choose(Class, Object, - * boolean)}. A builder which never touches {@link Builder#versionPolicy(ChunkVersionPolicy)} or - * {@link Builder#discoverVersionPolicy()} discovers {@link DefaultChunkVersionPolicy} through the - * classpath by default, which is what keeps every loader built the way earlier versions built - * one refusing the same chunks it always refused. Calling {@code versionPolicy(null)} is the - * only way to leave this field null, and {@link #checkVersion(CompoundBinaryTag)} treats that as - * "check nothing" rather than substituting the default itself. + * boolean, Class)}, naming {@link DefaultChunkVersionPolicy} as the shipped default — so a + * foreign {@link ChunkVersionPolicy} registered through {@code META-INF/services} is chosen over + * it, and only a second foreign provider is refused as ambiguous. A builder which never touches + * {@link Builder#versionPolicy(ChunkVersionPolicy)} or {@link Builder#discoverVersionPolicy()} + * discovers a policy through the classpath by default, which is what keeps every loader built + * the way earlier versions built one refusing the same chunks it always refused. Calling + * {@code versionPolicy(null)} is the only way to leave this field null, and + * {@link #checkVersion(CompoundBinaryTag)} treats that as "check nothing" rather than + * substituting the default itself. *
* * @since 1.2.0 @@ -172,6 +175,8 @@ public final class FalcoAnvilLoader implements ChunkLoader, AutoCloseable { * * @param worldRoot the root directory of the world * @param dimension the key of the dimension the loader reads and writes + * @throws IllegalStateException if the classpath registers more than one foreign + * {@link ChunkVersionPolicy} */ public FalcoAnvilLoader(Path worldRoot, Key dimension) { this(worldRoot, dimension, DEFAULT_OPEN_REGION_LIMIT); @@ -190,6 +195,8 @@ public FalcoAnvilLoader(Path worldRoot, Key dimension) { * @param dimension the key of the dimension the loader reads and writes * @param openRegionLimit the amount of region files the loader keeps open * @throws IllegalArgumentException if the limit is not positive + * @throws IllegalStateException if the classpath registers more than one foreign + * {@link ChunkVersionPolicy} */ public FalcoAnvilLoader(Path worldRoot, Key dimension, int openRegionLimit) { this(worldRoot, dimension, builder().openRegionLimit(openRegionLimit)); @@ -234,7 +241,8 @@ private FalcoAnvilLoader(Path worldRoot, Key dimension, Builder settings) { this.minimumDataVersion = settings.minimumDataVersion; this.exceptionHandler = settings.exceptionHandler; this.versionPolicy = ServiceResolution.choose( - ChunkVersionPolicy.class, settings.versionPolicy, settings.discoverVersionPolicy); + ChunkVersionPolicy.class, settings.versionPolicy, settings.discoverVersionPolicy, + DefaultChunkVersionPolicy.class); this.closeLock = new ReentrantLock(); // Which directory was chosen, and how many region files are in it, is the first thing @@ -628,9 +636,9 @@ public Builder exceptionHandler(Consumer* Calling this slot always turns discovery off, whatever value is passed: an explicit * decision about the policy — including the explicit decision "none" — has to win over the - * classpath without {@link ServiceResolution#choose(Class, Object, boolean)} seeing both set - * at once and refusing to guess between them. A builder that never calls this slot keeps - * discovering the default instead, which is what {@link #builder()} starts with. + * classpath without {@link ServiceResolution#choose(Class, Object, boolean, Class)} seeing + * both set at once and refusing to guess between them. A builder that never calls this slot + * keeps discovering the default instead, which is what {@link #builder()} starts with. *
** Passing {@code null} is not "use the default": it is "check nothing". A pre-{@code @@ -665,9 +673,11 @@ public Builder versionPolicy(@Nullable ChunkVersionPolicy versionPolicy) { * using an explicit instance. *
* This is what {@link #builder()} already defaults to, so calling it only matters after an - * earlier {@link #versionPolicy(ChunkVersionPolicy)} call on the same chain, to undo it. A - * classpath that registers more than one provider makes {@link Builder#build(Path, Key)} - * throw {@link IllegalStateException} rather than guess between them. + * earlier {@link #versionPolicy(ChunkVersionPolicy)} call on the same chain, to undo it. The + * shipped {@link DefaultChunkVersionPolicy} steps aside for a single registered foreign + * provider; a classpath that registers more than one foreign provider makes + * {@link Builder#build(Path, Key)} throw {@link IllegalStateException} rather than guess + * between them. *
* * @return a new builder with this value @@ -699,6 +709,9 @@ public Builder discoverVersionPolicy() { * @param worldRoot the root directory of the world * @param dimension the key of the dimension the loader reads and writes * @return a new loader, independent of every other loader from this builder + * @throws IllegalStateException if this builder was left on classpath discovery and the + * classpath registers more than one foreign + * {@link ChunkVersionPolicy} */ @Contract(value = "_, _ -> new", pure = true) public FalcoAnvilLoader build(Path worldRoot, Key dimension) { @@ -1179,6 +1192,21 @@ int minimumDataVersion() { return this.minimumDataVersion; } + /** + * Returns the policy this loader resolved, or null if it checks nothing. + *+ * Package-private for the same reason as {@link #minimumDataVersion()}: this exists for the + * builder's own pass-through tests, not for a caller outside this package. + *
+ * + * @return the resolved policy, or null if the loader checks no chunk version at all + * @since 1.2.0 + */ + @Contract(pure = true) + @Nullable ChunkVersionPolicy versionPolicy() { + return this.versionPolicy; + } + /** * Closes every region file the loader opened and reports a summary of its work. *diff --git a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/ServiceResolution.java b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/ServiceResolution.java index 6592233..b6c28ca 100644 --- a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/ServiceResolution.java +++ b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/ServiceResolution.java @@ -14,11 +14,13 @@ * A caller either configures an instance explicitly, or asks for classpath discovery, but not both * at once: {@link #choose(Class, Object, boolean)} refuses the combination outright rather than * silently preferring one side. Discovery itself, in {@link #discover(Class)}, refuses to guess - * between more than one registered provider. + * between more than one registered provider — except that a module's own shipped default, named + * through {@link #discover(Class, Class)}, always steps aside for a foreign one rather than + * counting as a second vote. Two foreign providers still refuse each other. *
* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 1.0.0 */ final class ServiceResolution { @@ -35,12 +37,56 @@ private ServiceResolution() { * @throws IllegalStateException if more than one provider is registered */ static+ * This is not the "no silent choice between two providers" rule weakening: a module's own + * default is a known quantity, registered by the module itself, not a second undocumented + * opinion on the classpath. A caller who registers one foreign provider has stated an + * unambiguous intent, and that intent should not be refused just because the module also ships + * its own fallback. Two foreign providers are a different situation — neither of them + * is the known default, so the classpath genuinely holds two competing, undocumented opinions, + * and this still refuses to guess between them exactly as {@link #discover(Class)} does. + *
+ * + * @param service the service interface + * @param shippedDefault the class of the module's own default implementation, which yields to + * any other registered provider, or null if the service has no such + * default + * @param* The dummy providers this test resolves are registered through the test resources, under - * {@code META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$Dummy}, so the main + * {@code META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$Dummy} and two more + * files named after {@link DefaultAware} and {@link DefaultAwareWithTwoForeign} below, so the main * module registers nothing extra. *
* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 1.0.0 */ class ServiceResolutionTest { @@ -50,6 +51,73 @@ public String name() { interface Absent { } + /** + * A service with a "shipped default" registered next to a single foreign provider, for the two + * {@link ServiceResolution#discover(Class, Class)} cases below. + */ + interface DefaultAware { + String name(); + } + + public static final class ShippedDefault implements DefaultAware { + public ShippedDefault() { + } + + @Override + public String name() { + return "shipped"; + } + } + + public static final class ForeignProvider implements DefaultAware { + public ForeignProvider() { + } + + @Override + public String name() { + return "foreign"; + } + } + + /** + * A separate service, registered with a shipped default and two foreign providers, so + * that "the default steps aside" and "two foreign providers still refuse each other" can be + * pinned down independently of one another. + */ + interface DefaultAwareWithTwoForeign { + String name(); + } + + public static final class AnotherShippedDefault implements DefaultAwareWithTwoForeign { + public AnotherShippedDefault() { + } + + @Override + public String name() { + return "shipped"; + } + } + + public static final class FirstForeignProvider implements DefaultAwareWithTwoForeign { + public FirstForeignProvider() { + } + + @Override + public String name() { + return "first-foreign"; + } + } + + public static final class SecondForeignProvider implements DefaultAwareWithTwoForeign { + public SecondForeignProvider() { + } + + @Override + public String name() { + return "second-foreign"; + } + } + @Test void testAServiceWithNoProviderResolvesToNothing() { assertNull(ServiceResolution.discover(Absent.class)); @@ -83,4 +151,20 @@ void testAnExplicitInstanceIsUsedWithoutTouchingTheClasspath() { void testNeitherExplicitNorDiscoveredResolvesToNothing() { assertNull(ServiceResolution.choose(Dummy.class, null, false)); } + + @Test + void testAForeignProviderWinsOverTheShippedDefault() { + DefaultAware resolved = ServiceResolution.discover(DefaultAware.class, ShippedDefault.class); + + assertEquals("foreign", resolved.name()); + } + + @Test + void testTwoForeignProvidersAreStillRefusedEvenWithAShippedDefaultRegistered() { + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> ServiceResolution.discover(DefaultAwareWithTwoForeign.class, AnotherShippedDefault.class)); + + assertTrue(failure.getMessage().contains("FirstForeignProvider"), failure.getMessage()); + assertTrue(failure.getMessage().contains("SecondForeignProvider"), failure.getMessage()); + } } diff --git a/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$DefaultAware b/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$DefaultAware new file mode 100644 index 0000000..2f4677d --- /dev/null +++ b/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$DefaultAware @@ -0,0 +1,2 @@ +net.onelitefeather.falco.anvil.ServiceResolutionTest$ShippedDefault +net.onelitefeather.falco.anvil.ServiceResolutionTest$ForeignProvider diff --git a/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$DefaultAwareWithTwoForeign b/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$DefaultAwareWithTwoForeign new file mode 100644 index 0000000..e6193dc --- /dev/null +++ b/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$DefaultAwareWithTwoForeign @@ -0,0 +1,3 @@ +net.onelitefeather.falco.anvil.ServiceResolutionTest$AnotherShippedDefault +net.onelitefeather.falco.anvil.ServiceResolutionTest$FirstForeignProvider +net.onelitefeather.falco.anvil.ServiceResolutionTest$SecondForeignProvider From a7b9c041eaa83becca7c053a4baff3d4b96b4205 Mon Sep 17 00:00:00 2001 From: TheMeinerLP- * A biome the registry does not know is replaced with the plains biome instead of failing, which - * follows the behaviour of the built-in loader. Every replaced name is reported once through the - * diagnostics. + * A biome the registry does not know is handed to an {@link UnknownEntryPolicy} instead of failing + * outright: the default substitutes plains, which follows the behaviour of the built-in loader, but + * a caller that converts or checks a world can configure a policy which refuses it instead. Every + * unknown name is reported once through the diagnostics, regardless of what the policy does with it. *
** The registry is resolved on the first use instead of in a static initializer or in the @@ -32,7 +33,7 @@ *
* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 0.1.0 */ @ApiStatus.Experimental @@ -43,17 +44,31 @@ public final class BiomePaletteResolver implements PaletteEntryResolver { private static final String NAME_KEY = "Name"; private final AnvilDiagnostics diagnostics; + private final UnknownEntryPolicy policy; private final Supplier- * An entry the server does not know is replaced with air instead of failing. A world can hold - * blocks of a mod or of a newer game version and rejecting the whole chunk over a single unknown - * block would lose far more data than it protects. Every replaced name is reported once through - * the diagnostics so the problem stays visible without flooding the log. + * An entry the server does not know is handed to an {@link UnknownEntryPolicy} instead of failing + * outright: the default substitutes air, which keeps a world holding blocks of a mod or of a newer + * game version loadable instead of losing a whole chunk over a single unknown block, but a caller + * that converts or checks a world can configure a policy which refuses it instead. Every unknown + * name is reported once through the diagnostics so the problem stays visible without flooding the + * log, regardless of what the policy does with it. *
* *@@ -28,7 +30,7 @@ *
* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 0.1.0 */ @ApiStatus.Experimental @@ -40,18 +42,35 @@ public final class BlockPaletteResolver implements PaletteEntryResolver { private static final String PROPERTIES_KEY = "Properties"; private final AnvilDiagnostics diagnostics; + private final UnknownEntryPolicy policy; /** - * Creates a new resolver which reports unknown blocks to the given diagnostics. + * Creates a new resolver which reports unknown blocks to the given diagnostics and replaces + * them following {@link DefaultUnknownEntryPolicy}. * * @param diagnostics the diagnostics which throttle the reports */ public BlockPaletteResolver(AnvilDiagnostics diagnostics) { + this(diagnostics, new DefaultUnknownEntryPolicy()); + } + + /** + * Creates a new resolver which reports unknown blocks to the given diagnostics and decides what + * they become through the given policy. + * + * @param diagnostics the diagnostics which throttle the reports + * @param policy the policy consulted for a block the server does not know + * @since 1.2.0 + */ + public BlockPaletteResolver(AnvilDiagnostics diagnostics, UnknownEntryPolicy policy) { this.diagnostics = diagnostics; + this.policy = policy; } /** * {@inheritDoc} + * + * @throws AnvilChunkException if the configured policy refuses an unknown block */ @Override public int toId(String name, @Nullable CompoundBinaryTag properties) { @@ -59,9 +78,9 @@ public int toId(String name, @Nullable CompoundBinaryTag properties) { if (block == null) { if (this.diagnostics.reportUnknownBlock(name)) { - LOGGER.warn("The block '{}' is unknown and is replaced with air, further chunks with it are not reported", name); + LOGGER.warn("The block '{}' is unknown, further chunks with it are not reported", name); } - return Block.AIR.stateId(); + return this.policy.onUnknownBlock(name, properties); } if (properties == null || properties.size() == 0) { return block.stateId(); diff --git a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/DefaultUnknownEntryPolicy.java b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/DefaultUnknownEntryPolicy.java new file mode 100644 index 0000000..1eac789 --- /dev/null +++ b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/DefaultUnknownEntryPolicy.java @@ -0,0 +1,97 @@ +package net.onelitefeather.falco.anvil; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.minestom.server.MinecraftServer; +import net.minestom.server.instance.block.Block; +import net.minestom.server.registry.DynamicRegistry; +import net.minestom.server.world.biome.Biome; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Supplier; + +/** + * The {@link UnknownEntryPolicy} a resolver falls back to when nothing else was configured: + * substitutes air for an unknown block and plains for an unknown biome, exactly the values + * {@link BlockPaletteResolver} and {@link BiomePaletteResolver} hard-coded before this policy + * existed. + *+ * The plains id is resolved from the biome registry lazily rather than in the constructor, for the + * same reason {@link BiomePaletteResolver} resolves its registry lazily: a policy is often built + * while the server is still starting, and reading the registry too early would fail before the + * policy ever meets a chunk. + *
+ *+ * This type is experimental, like everything else in this package. + *
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.2.0 + */ +@ApiStatus.Experimental +public final class DefaultUnknownEntryPolicy implements UnknownEntryPolicy { + + private final Supplier+ * The registry is resolved on the first use instead of in the constructor. A policy is often + * built while the server is still starting and reading the registry too early would fail before + * the policy ever meets a chunk. + *
+ * + * @param registrySupplier the supplier which provides the registry of the known biomes + */ + public DefaultUnknownEntryPolicy(Supplier+ * Always returns {@link Block#AIR}'s state id. + *
+ */ + @Override + public int onUnknownBlock(String name, @Nullable CompoundBinaryTag properties) { + return Block.AIR.stateId(); + } + + /** + * {@inheritDoc} + *+ * Always returns the id of {@link Biome#PLAINS} in the resolved registry. + *
+ */ + @Override + public int onUnknownBiome(String name) { + Integer current = this.resolvedFallbackBiomeId; + + if (current != null) { + return current; + } + + synchronized (this) { + Integer created = this.resolvedFallbackBiomeId; + + if (created == null) { + created = this.registrySupplier.get().getId(Biome.PLAINS); + this.resolvedFallbackBiomeId = created; + } + return created; + } + } +} diff --git a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/FalcoAnvilLoader.java b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/FalcoAnvilLoader.java index b41daa1..72b89ee 100644 --- a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/FalcoAnvilLoader.java +++ b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/FalcoAnvilLoader.java @@ -78,7 +78,7 @@ * * * @author TheMeinerLP - * @version 1.2.0 + * @version 1.3.0 * @since 0.1.0 */ @ApiStatus.Experimental @@ -147,6 +147,25 @@ public final class FalcoAnvilLoader implements ChunkLoader, AutoCloseable { */ private final @Nullable ChunkVersionPolicy versionPolicy; + /** + * The policy consulted for a palette entry the running server does not know, resolved once in + * the constructor the same way {@link #versionPolicy} is: through {@link + * ServiceResolution#choose(Class, Object, boolean, Class)}, naming {@link + * DefaultUnknownEntryPolicy} as the shipped default. + *+ * Unlike {@link #versionPolicy}, this field is never null. A builder which never touches + * {@link Builder#unknownEntryPolicy(UnknownEntryPolicy)} or + * {@link Builder#discoverUnknownEntryPolicy()} discovers a policy from the classpath by default, + * falling back to {@link DefaultUnknownEntryPolicy} when the classpath registers none — there is + * no "consult nothing" state for this decision the way {@code versionPolicy(null)} lets a caller + * skip the version check entirely, because an id is always required for the loader to keep + * decoding. + *
+ * + * @since 1.2.0 + */ + private final UnknownEntryPolicy unknownEntryPolicy; + /** * Where failures are reported, or null for the exception manager of the running server. *@@ -228,11 +247,17 @@ private FalcoAnvilLoader(Path worldRoot, Key dimension, Builder settings) { this.legacyLayout = resolved.legacyLayout(); this.dimensionLabel = dimension.asString(); this.diagnostics = effective; + UnknownEntryPolicy resolvedUnknownEntryPolicy = ServiceResolution.choose( + UnknownEntryPolicy.class, settings.unknownEntryPolicy, settings.discoverUnknownEntryPolicy, + DefaultUnknownEntryPolicy.class); + this.unknownEntryPolicy = resolvedUnknownEntryPolicy == null + ? new DefaultUnknownEntryPolicy() + : resolvedUnknownEntryPolicy; this.blockResolver = settings.blockResolver == null - ? new BlockPaletteResolver(effective) + ? new BlockPaletteResolver(effective, this.unknownEntryPolicy) : settings.blockResolver; this.biomeResolver = settings.biomeResolver == null - ? new BiomePaletteResolver(effective) + ? new BiomePaletteResolver(effective, this.unknownEntryPolicy) : settings.biomeResolver; this.regions = new ConcurrentHashMap<>(); this.trackedChunks = new ConcurrentHashMap<>(); @@ -284,9 +309,10 @@ private void reportException(Throwable exception) { *
* The builder reaches the values the constructors set for themselves — the compression level, * the diagnostics, both palette resolvers, the save parallelism and the data version. It also - * defaults to discovering a {@link ChunkVersionPolicy} from the classpath, which is what keeps - * every loader built through a constructor rather than an explicit - * {@link Builder#versionPolicy(ChunkVersionPolicy)} call refusing the chunks it always refused. + * defaults to discovering a {@link ChunkVersionPolicy} and an {@link UnknownEntryPolicy} from + * the classpath, which is what keeps every loader built through a constructor rather than an + * explicit {@link Builder#versionPolicy(ChunkVersionPolicy)} or + * {@link Builder#unknownEntryPolicy(UnknownEntryPolicy)} call behaving the way it always did. * The world directory and the dimension are not among them: they are required, so they sit in * {@link Builder#build(Path, Key)} rather than in a slot. *
@@ -297,7 +323,7 @@ private void reportException(Throwable exception) { public static Builder builder() { return new Builder(DEFAULT_OPEN_REGION_LIMIT, ChunkCompression.DEFAULT_LEVEL, Math.max(Runtime.getRuntime().availableProcessors(), 2), MinecraftServer.DATA_VERSION, - DEFAULT_MINIMUM_DATA_VERSION, null, null, null, null, null, true); + DEFAULT_MINIMUM_DATA_VERSION, null, null, null, null, null, true, null, true); } /** @@ -326,7 +352,7 @@ public static Builder builder() { * * * @author TheMeinerLP - * @version 1.2.0 + * @version 1.3.0 * @since 0.4.0 */ @ApiStatus.Experimental @@ -343,6 +369,8 @@ public static final class Builder { private final @Nullable Consumer+ * Calling this slot always turns discovery off, whatever value is passed, for the same + * reason {@link #versionPolicy(ChunkVersionPolicy)} does: an explicit decision has to win + * over the classpath without {@link ServiceResolution#choose(Class, Object, boolean, Class)} + * seeing both set at once and refusing to guess between them. A builder that never calls + * this slot keeps discovering the default instead, which is what {@link #builder()} starts + * with. + *
+ *+ * Unlike {@link #versionPolicy(ChunkVersionPolicy)}, passing {@code null} here is not "check + * nothing": {@link #build(Path, Key)} always resolves a usable policy, falling back to + * {@link DefaultUnknownEntryPolicy} when neither an explicit instance nor a foreign + * classpath provider is found, because a resolver always needs an id for the entry it could + * not otherwise decode. + *
+ * + * @param unknownEntryPolicy the policy to consult for an unknown palette entry, or null to + * fall back to the classpath default + * @return a new builder with this value + * @since 1.2.0 + */ + @Contract(value = "_ -> new", pure = true) + public Builder unknownEntryPolicy(@Nullable UnknownEntryPolicy unknownEntryPolicy) { + return new Builder(this.openRegionLimit, + this.compressionLevel, + this.saveParallelism, + this.dataVersion, + this.minimumDataVersion, + this.diagnostics, + this.blockResolver, + this.biomeResolver, + this.exceptionHandler, + this.versionPolicy, + this.discoverVersionPolicy, + unknownEntryPolicy, + false); + } + + /** + * Asks the loader to discover its {@link UnknownEntryPolicy} from the classpath instead of + * using an explicit instance. + *+ * This is what {@link #builder()} already defaults to, so calling it only matters after an + * earlier {@link #unknownEntryPolicy(UnknownEntryPolicy)} call on the same chain, to undo it. + * The shipped {@link DefaultUnknownEntryPolicy} steps aside for a single registered foreign + * provider; a classpath that registers more than one foreign provider makes + * {@link Builder#build(Path, Key)} throw {@link IllegalStateException} rather than guess + * between them. + *
+ * + * @return a new builder with this value + * @since 1.2.0 + */ + @Contract(value = "-> new", pure = true) + public Builder discoverUnknownEntryPolicy() { + return new Builder(this.openRegionLimit, + this.compressionLevel, + this.saveParallelism, + this.dataVersion, + this.minimumDataVersion, + this.diagnostics, + this.blockResolver, + this.biomeResolver, + this.exceptionHandler, + this.versionPolicy, + this.discoverVersionPolicy, + null, true); } @@ -711,7 +838,8 @@ public Builder discoverVersionPolicy() { * @return a new loader, independent of every other loader from this builder * @throws IllegalStateException if this builder was left on classpath discovery and the * classpath registers more than one foreign - * {@link ChunkVersionPolicy} + * {@link ChunkVersionPolicy} or more than one foreign + * {@link UnknownEntryPolicy} */ @Contract(value = "_, _ -> new", pure = true) public FalcoAnvilLoader build(Path worldRoot, Key dimension) { @@ -1207,6 +1335,21 @@ int minimumDataVersion() { return this.versionPolicy; } + /** + * Returns the policy this loader resolved for an unknown palette entry. + *+ * Package-private for the same reason as {@link #versionPolicy()}: this exists for the + * builder's own pass-through tests, not for a caller outside this package. + *
+ * + * @return the resolved policy, never null + * @since 1.2.0 + */ + @Contract(pure = true) + UnknownEntryPolicy unknownEntryPolicy() { + return this.unknownEntryPolicy; + } + /** * Closes every region file the loader opened and reports a summary of its work. *diff --git a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/UnknownEntryPolicy.java b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/UnknownEntryPolicy.java new file mode 100644 index 0000000..c33beb7 --- /dev/null +++ b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/UnknownEntryPolicy.java @@ -0,0 +1,45 @@ +package net.onelitefeather.falco.anvil; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +/** + * Decides what becomes of a palette entry the running server does not know. + *
+ * Returning an id substitutes it; throwing {@link AnvilChunkException} fails the chunk. Substituting + * is right for a server that wants a world to stay loadable and wrong for a tool that converts one, + * which is why this is a policy and not a constant. + *
+ *+ * A policy only decides. It does not count and it does not log: the resolver which consults it keeps + * reporting the name to its {@link AnvilDiagnostics} and writing the log line regardless of what the + * policy does with the entry, so a substituting run stays as visible as a refusing one. + *
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.2.0 + */ +@ApiStatus.Experimental +public interface UnknownEntryPolicy { + + /** + * Decides what an unknown block becomes. + * + * @param name the block name stored in the palette + * @param properties the stored properties, or null if the entry carries none + * @return the state id to use instead + * @throws AnvilChunkException if the chunk should fail rather than carry a substitute + */ + int onUnknownBlock(String name, @Nullable CompoundBinaryTag properties); + + /** + * Decides what an unknown biome becomes. + * + * @param name the biome name stored in the palette + * @return the id to use instead + * @throws AnvilChunkException if the chunk should fail rather than carry a substitute + */ + int onUnknownBiome(String name); +} diff --git a/falco-anvil/src/main/resources/META-INF/services/net.onelitefeather.falco.anvil.UnknownEntryPolicy b/falco-anvil/src/main/resources/META-INF/services/net.onelitefeather.falco.anvil.UnknownEntryPolicy new file mode 100644 index 0000000..1d14ce4 --- /dev/null +++ b/falco-anvil/src/main/resources/META-INF/services/net.onelitefeather.falco.anvil.UnknownEntryPolicy @@ -0,0 +1 @@ +net.onelitefeather.falco.anvil.DefaultUnknownEntryPolicy diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderBuilderTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderBuilderTest.java index f339bb2..ffe1ebf 100644 --- a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderBuilderTest.java +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderBuilderTest.java @@ -351,6 +351,35 @@ void testDiscoverVersionPolicySurvivesEveryOtherSetterAfterClearingAnExplicitOne } } + @Test + void testAnExplicitUnknownEntryPolicySurvivesEveryOtherSetter(@TempDir Path worldRoot) throws Exception { + UnknownEntryPolicy policy = new DefaultUnknownEntryPolicy(); + + try (FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .unknownEntryPolicy(policy) + .openRegionLimit(4) + .compressionLevel(3) + .saveParallelism(2) + .build(worldRoot, OVERWORLD)) { + + assertSame(policy, loader.unknownEntryPolicy()); + } + } + + @Test + void testDiscoverUnknownEntryPolicySurvivesEveryOtherSetterAfterClearingAnExplicitOne(@TempDir Path worldRoot) throws Exception { + try (FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .unknownEntryPolicy(new DefaultUnknownEntryPolicy()) + .discoverUnknownEntryPolicy() + .openRegionLimit(4) + .compressionLevel(3) + .saveParallelism(2) + .build(worldRoot, OVERWORLD)) { + + assertInstanceOf(DefaultUnknownEntryPolicy.class, loader.unknownEntryPolicy()); + } + } + @Test void testTheBuilderCanBeReusedAfterASlotChanged() throws Exception { FalcoAnvilLoader.Builder builder = FalcoAnvilLoader.builder().openRegionLimit(8); diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java new file mode 100644 index 0000000..14c70d2 --- /dev/null +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java @@ -0,0 +1,59 @@ +package net.onelitefeather.falco.anvil; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.minestom.server.instance.block.Block; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link DefaultUnknownEntryPolicy} directly and pins how {@link BlockPaletteResolver} uses + * whichever {@link UnknownEntryPolicy} it was built with. + *+ * The third case is the one that matters most: counting an unknown entry is the resolver's job, not + * the policy's, and it has to keep happening even when the policy substitutes instead of throwing. + * Losing it would make a substituting run silent again, which is exactly what this whole extension + * point exists to prevent. + *
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.2.0 + */ +class UnknownEntryPolicyTest { + + @Test + void testTheDefaultPolicyReplacesAnUnknownBlockWithAir() { + assertEquals(Block.AIR.stateId(), new DefaultUnknownEntryPolicy().onUnknownBlock("falco:nope", null)); + } + + @Test + void testARefusingPolicyFailsTheChunkInsteadOfSubstituting() { + UnknownEntryPolicy refusing = new UnknownEntryPolicy() { + @Override + public int onUnknownBlock(String name, CompoundBinaryTag properties) { + throw new AnvilChunkException("The block " + name + " has no mapping"); + } + + @Override + public int onUnknownBiome(String name) { + throw new AnvilChunkException("The biome " + name + " has no mapping"); + } + }; + + AnvilChunkException failure = assertThrows(AnvilChunkException.class, + () -> new BlockPaletteResolver(new AnvilDiagnostics(), refusing).toId("falco:nope", null)); + assertTrue(failure.getMessage().contains("falco:nope"), failure.getMessage()); + } + + @Test + void testTheResolverStillCountsWhenThePolicySubstitutes() { + AnvilDiagnostics diagnostics = new AnvilDiagnostics(); + + new BlockPaletteResolver(diagnostics, new DefaultUnknownEntryPolicy()).toId("falco:nope", null); + + assertEquals(1, diagnostics.unknownBlockCount()); + } +} From c8d73cb9396b20355c9ae7823c31b8b5b4d3f969 Mon Sep 17 00:00:00 2001 From: TheMeinerLP+ * This is the same lazy, double-checked-locking derivation {@link DefaultUnknownEntryPolicy} uses + * to resolve its own plains fallback from the same kind of supplier. The two are not shared: a + * shared holder would need a third class injected into both, for roughly ten lines saved, and + * this resolver's copy also has to hand the result to {@link #toEntry(int)} for the id-to-name + * direction, which the policy has no reason to do. + *
* * @return the registry of the known biomes */ diff --git a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/DefaultUnknownEntryPolicy.java b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/DefaultUnknownEntryPolicy.java index 1eac789..e471dc4 100644 --- a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/DefaultUnknownEntryPolicy.java +++ b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/DefaultUnknownEntryPolicy.java @@ -84,6 +84,11 @@ public int onUnknownBiome(String name) { return current; } + // The same lazy, double-checked-locking derivation BiomePaletteResolver#registry() uses for + // its own resolution from the same kind of supplier, kept as its own copy rather than a + // shared holder: a shared holder would need a third class injected into both for roughly ten + // lines saved, and this method only ever needs an id, never the registry object itself the + // way the resolver does for its id-to-name direction. synchronized (this) { Integer created = this.resolvedFallbackBiomeId; diff --git a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/PaletteEntryResolver.java b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/PaletteEntryResolver.java index b78a4c3..11384fe 100644 --- a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/PaletteEntryResolver.java +++ b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/PaletteEntryResolver.java @@ -20,7 +20,7 @@ * * * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 0.1.0 */ @ApiStatus.Experimental @@ -29,15 +29,22 @@ public interface PaletteEntryResolver { /** * Resolves the id which belongs to the given palette entry. *- * An implementation must not fail for an unknown name. A world can hold entries of a mod or of - * a newer game version and losing a whole chunk over a single unknown entry would destroy more - * data than it protects. An implementation is expected to return a replacement id instead and - * to report the name to the caller. + * An implementation is allowed to fail for an unknown name — unchecked, since this method + * declares no {@code throws} clause. {@link BlockPaletteResolver} and + * {@link BiomePaletteResolver} both delegate that decision to a caller-supplied + * {@link UnknownEntryPolicy} rather than deciding it themselves: the shipped default + * substitutes a replacement id, which keeps a world holding entries of a mod or of a newer game + * version loadable instead of losing a whole chunk over a single unknown entry, but a policy + * configured to refuse instead throws {@link AnvilChunkException} from here. A caller of + * {@code toId} therefore has to be ready for either outcome, depending on how the resolver it + * holds was configured. *
* * @param name the name of the palette entry * @param properties the properties of the palette entry or null if it carries none * @return the id which belongs to the entry + * @throws AnvilChunkException if the implementation was configured to refuse an unknown name + * instead of substituting one */ int toId(String name, @Nullable CompoundBinaryTag properties); diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java index 14c70d2..b952ffd 100644 --- a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java @@ -2,6 +2,8 @@ import net.kyori.adventure.nbt.CompoundBinaryTag; import net.minestom.server.instance.block.Block; +import net.minestom.server.registry.DynamicRegistry; +import net.minestom.server.world.biome.Biome; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -9,17 +11,23 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Tests {@link DefaultUnknownEntryPolicy} directly and pins how {@link BlockPaletteResolver} uses - * whichever {@link UnknownEntryPolicy} it was built with. + * Tests {@link DefaultUnknownEntryPolicy} directly and pins how {@link BlockPaletteResolver} and + * {@link BiomePaletteResolver} each use whichever {@link UnknownEntryPolicy} they were built with. *- * The third case is the one that matters most: counting an unknown entry is the resolver's job, not - * the policy's, and it has to keep happening even when the policy substitutes instead of throwing. - * Losing it would make a substituting run silent again, which is exactly what this whole extension - * point exists to prevent. + * The counting cases are the ones that matter most: counting an unknown entry is the resolver's job, + * not the policy's, and it has to keep happening even when the policy substitutes instead of + * throwing. Losing it would make a substituting run silent again, which is exactly what this whole + * extension point exists to prevent. + *
+ *+ * The biome cases use {@link Biome#createDefaultRegistry()} rather than the biome registry of a + * running server, so this class needs no Minestom test environment: {@link BiomePaletteResolver}'s + * package-private three-argument constructor exists for exactly this, to inject both a policy and a + * registry supplier without starting a server. *
* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 1.2.0 */ class UnknownEntryPolicyTest { @@ -29,6 +37,14 @@ void testTheDefaultPolicyReplacesAnUnknownBlockWithAir() { assertEquals(Block.AIR.stateId(), new DefaultUnknownEntryPolicy().onUnknownBlock("falco:nope", null)); } + @Test + void testTheDefaultPolicyReplacesAnUnknownBiomeWithPlains() { + DynamicRegistry- * The plains id is resolved from the biome registry lazily rather than in the constructor, for the - * same reason {@link BiomePaletteResolver} resolves its registry lazily: a policy is often built - * while the server is still starting, and reading the registry too early would fail before the - * policy ever meets a chunk. + * Both names are plain string literals. This policy resolves nothing itself — {@link + * UnknownEntryPolicy} hands back a name, not an id, precisely so that the shipped default needs + * neither Minestom nor a registry to answer. The resolver that consults this policy already holds + * the registry it needs to turn {@code "minecraft:air"} or {@code "minecraft:plains"} into an id, + * because it just used that same registry to look the original, unknown name up. *
** This type is experimental, like everything else in this package. *
* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 2.1.0 */ @ApiStatus.Experimental public final class DefaultUnknownEntryPolicy implements UnknownEntryPolicy { - private final Supplier- * The registry is resolved on the first use instead of in the constructor. A policy is often - * built while the server is still starting and reading the registry too early would fail before - * the policy ever meets a chunk. - *
- * - * @param registrySupplier the supplier which provides the registry of the known biomes + * Creates the policy. It holds no state of its own, so every instance behaves the same way. */ - public DefaultUnknownEntryPolicy(Supplier- * Always returns {@link Block#AIR}'s state id. + * Always returns {@code "minecraft:air"}. *
*/ @Override - public int onUnknownBlock(String name, @Nullable CompoundBinaryTag properties) { - return Block.AIR.stateId(); + public String onUnknownBlock(String name, @Nullable CompoundBinaryTag properties) { + return AIR; } /** * {@inheritDoc} *- * Always returns the id of {@link Biome#PLAINS} in the resolved registry. + * Always returns {@code "minecraft:plains"}. *
*/ @Override - public int onUnknownBiome(String name) { - Integer current = this.resolvedFallbackBiomeId; - - if (current != null) { - return current; - } - - // The same lazy, double-checked-locking derivation BiomePaletteResolver#registry() uses for - // its own resolution from the same kind of supplier, kept as its own copy rather than a - // shared holder: a shared holder would need a third class injected into both for roughly ten - // lines saved, and this method only ever needs an id, never the registry object itself the - // way the resolver does for its id-to-name direction. - synchronized (this) { - Integer created = this.resolvedFallbackBiomeId; - - if (created == null) { - created = this.registrySupplier.get().getId(Biome.PLAINS); - this.resolvedFallbackBiomeId = created; - } - return created; - } + public String onUnknownBiome(String name) { + return PLAINS; } } diff --git a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/UnknownEntryPolicy.java b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/UnknownEntryPolicy.java index 1b89e19..2c37801 100644 --- a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/UnknownEntryPolicy.java +++ b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/UnknownEntryPolicy.java @@ -7,18 +7,28 @@ /** * Decides what becomes of a palette entry the running server does not know. *- * Returning an id substitutes it; throwing {@link AnvilChunkException} fails the chunk. Substituting - * is right for a server that wants a world to stay loadable and wrong for a tool that converts one, - * which is why this is a policy and not a constant. + * Returning a name substitutes it; throwing {@link AnvilChunkException} fails the chunk. + * Substituting is right for a server that wants a world to stay loadable and wrong for a tool that + * converts one, which is why this is a policy and not a constant. + *
+ *+ * A policy names a replacement; it does not resolve one. It returns a palette name such as + * {@code "minecraft:stone"}, not an id — the resolver that consults it owns the registry lookup + * that turns a name into an id, the same registry it already needed to look the original, unknown + * name up in the first place. That split keeps this interface, and any implementation of it, free of + * a dependency on Minestom or any registry: naming {@code "minecraft:air"} takes no more than a + * string literal. *
** A policy only decides. It does not count and it does not log: the resolver which consults it keeps * reporting the name to its {@link AnvilDiagnostics} and writing the log line regardless of what the - * policy does with the entry, so a substituting run stays as visible as a refusing one. + * policy does with the entry, so a substituting run stays as visible as a refusing one. Nor does a + * policy get asked twice: if the name it returns is itself unknown, the resolver fails the chunk + * instead of consulting the policy again, which would risk a loop. *
* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 2.1.0 */ @ApiStatus.Experimental @@ -29,17 +39,17 @@ public interface UnknownEntryPolicy { * * @param name the block name stored in the palette * @param properties the stored properties, or null if the entry carries none - * @return the state id to use instead + * @return the name of the block to use instead * @throws AnvilChunkException if the chunk should fail rather than carry a substitute */ - int onUnknownBlock(String name, @Nullable CompoundBinaryTag properties); + String onUnknownBlock(String name, @Nullable CompoundBinaryTag properties); /** * Decides what an unknown biome becomes. * * @param name the biome name stored in the palette - * @return the id to use instead + * @return the name of the biome to use instead * @throws AnvilChunkException if the chunk should fail rather than carry a substitute */ - int onUnknownBiome(String name); + String onUnknownBiome(String name); } diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java index 325eecc..36538a0 100644 --- a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java @@ -1,11 +1,11 @@ package net.onelitefeather.falco.anvil; import net.kyori.adventure.nbt.CompoundBinaryTag; -import net.minestom.server.instance.block.Block; -import net.minestom.server.registry.DynamicRegistry; import net.minestom.server.world.biome.Biome; import org.junit.jupiter.api.Test; +import java.util.concurrent.atomic.AtomicInteger; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -20,6 +20,15 @@ * extension point exists to prevent. * *+ * {@link UnknownEntryPolicy} hands back a name, not an id — the resolver owns the registry lookup + * that turns a name into one, using the same registry it already needed for the original, unknown + * name. That is what the "unusable substitute" cases pin: the resolver has to fail if the name a + * policy substitutes is itself unresolvable, and it must not ask the policy a second time to find + * out, which could loop. The failure surfaces as {@link IllegalStateException} rather than {@link + * AnvilChunkException} directly, because {@code FalcoAnvilLoader} is the only class that constructs + * the latter — it wraps this into one when a chunk is read through the loader. + *
+ ** The biome cases use {@link Biome#createDefaultRegistry()} rather than the biome registry of a * running server, so this class needs no Minestom test environment: {@link BiomePaletteResolver}'s * package-private three-argument constructor exists for exactly this, to inject both a policy and a @@ -27,34 +36,31 @@ *
* * @author TheMeinerLP - * @version 1.1.0 + * @version 2.0.0 * @since 2.1.0 */ class UnknownEntryPolicyTest { @Test void testTheDefaultPolicyReplacesAnUnknownBlockWithAir() { - assertEquals(Block.AIR.stateId(), new DefaultUnknownEntryPolicy().onUnknownBlock("falco:nope", null)); + assertEquals("minecraft:air", new DefaultUnknownEntryPolicy().onUnknownBlock("falco:nope", null)); } @Test void testTheDefaultPolicyReplacesAnUnknownBiomeWithPlains() { - DynamicRegistryPhrased as a complement rather than a hand-maintained allow list, so it covers - * {@code PaletteData}, {@code AnvilChunkException} and nested types such as - * {@code RegionFile$RawChunk} without maintenance when a class is added. + *
{@code ANVIL_NBT_LAYER} is a hand-maintained list, not a complement: it names the ten
+ * classes of the NBT layer as of this writing — {@code FalcoAnvilLoader},
+ * {@code SectionCodec}, {@code NbtReads}, {@code PaletteEntryResolver},
+ * {@code BlockPaletteResolver}, {@code BiomePaletteResolver}, {@code ChunkVersionPolicy},
+ * {@code DefaultChunkVersionPolicy}, {@code UnknownEntryPolicy} and
+ * {@code DefaultUnknownEntryPolicy} — and a class added to that layer later has to be
+ * added here too, the way the four policy classes were not when they were first written.
+ * Nested types need no separate entry; they come along through the {@code (\$.*)?} suffix.
*/
@ArchTest
static final ArchRule byteLayerKnowsNoNbt = noClasses()
From a7f7b574b5bc6a9f6afc0295b956e2c30c0428d4 Mon Sep 17 00:00:00 2001
From: TheMeinerLP
+ * Do not combine this with {@link #unknownEntryPolicy(UnknownEntryPolicy)} or + * {@link #discoverUnknownEntryPolicy()}. Whatever policy those slots resolve to is never + * handed to a resolver supplied here — {@link #build(Path, Key)} refuses the combination + * rather than build a loader whose configured policy is silently unreachable. Pass the + * policy into your own resolver instead, the same way the shipped resolvers accept one in + * their constructor. + *
* * @param blockResolver the resolver for block palette entries * @return a new builder with this value @@ -615,14 +653,17 @@ public Builder blockResolver(PaletteEntryResolver blockResolver) { this.versionPolicy, this.discoverVersionPolicy, this.unknownEntryPolicy, - this.discoverUnknownEntryPolicy); + this.discoverUnknownEntryPolicy, + this.unknownEntryPolicyConfigured); } /** * Sets the resolver which turns biome palette entries into ids. ** The same caveat as {@link #blockResolver(PaletteEntryResolver)}: a resolver of your own - * counts past the diagnostics of the loader. + * counts past the diagnostics of the loader, and the same restriction against combining it + * with {@link #unknownEntryPolicy(UnknownEntryPolicy)} or + * {@link #discoverUnknownEntryPolicy()} applies, for the same reason. *
* * @param biomeResolver the resolver for biome palette entries @@ -642,7 +683,8 @@ public Builder biomeResolver(PaletteEntryResolver biomeResolver) { this.versionPolicy, this.discoverVersionPolicy, this.unknownEntryPolicy, - this.discoverUnknownEntryPolicy); + this.discoverUnknownEntryPolicy, + this.unknownEntryPolicyConfigured); } /** @@ -678,7 +720,8 @@ public Builder exceptionHandler(Consumer+ * Do not combine this with {@link #blockResolver(PaletteEntryResolver)} or + * {@link #biomeResolver(PaletteEntryResolver)}. A resolver supplied through either of + * those slots is used exactly as given and is never rebuilt around this policy, so + * {@link #build(Path, Key)} refuses the combination instead of building a loader whose + * configured policy would never actually run. + *
+ * * @param unknownEntryPolicy the policy to consult for an unknown palette entry, or null to * fall back to the classpath default * @return a new builder with this value @@ -790,7 +843,8 @@ public Builder unknownEntryPolicy(@Nullable UnknownEntryPolicy unknownEntryPolic this.versionPolicy, this.discoverVersionPolicy, unknownEntryPolicy, - false); + false, + true); } /** @@ -804,6 +858,12 @@ public Builder unknownEntryPolicy(@Nullable UnknownEntryPolicy unknownEntryPolic * {@link Builder#build(Path, Key)} throw {@link IllegalStateException} rather than guess * between them. * + *+ * Calling this together with {@link #blockResolver(PaletteEntryResolver)} or + * {@link #biomeResolver(PaletteEntryResolver)} is refused by {@link #build(Path, Key)} for + * the same reason {@link #unknownEntryPolicy(UnknownEntryPolicy)} is: a resolver supplied + * through either of those slots never sees whatever this discovers. + *
* * @return a new builder with this value * @since 2.1.0 @@ -822,6 +882,7 @@ public Builder discoverUnknownEntryPolicy() { this.versionPolicy, this.discoverVersionPolicy, null, + true, true); } @@ -839,7 +900,13 @@ public Builder discoverUnknownEntryPolicy() { * @throws IllegalStateException if this builder was left on classpath discovery and the * classpath registers more than one foreign * {@link ChunkVersionPolicy} or more than one foreign - * {@link UnknownEntryPolicy} + * {@link UnknownEntryPolicy}, or if + * {@link #unknownEntryPolicy(UnknownEntryPolicy)} or + * {@link #discoverUnknownEntryPolicy()} was called on this + * builder together with {@link #blockResolver(PaletteEntryResolver)} + * or {@link #biomeResolver(PaletteEntryResolver)} — a custom + * resolver never sees the configured policy, so the combination + * would silently drop it instead of applying it */ @Contract(value = "_, _ -> new", pure = true) public FalcoAnvilLoader build(Path worldRoot, Key dimension) { diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderBuilderTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderBuilderTest.java index ffe1ebf..fd56a70 100644 --- a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderBuilderTest.java +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderBuilderTest.java @@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Pins down the builder of the loader. @@ -380,6 +381,60 @@ void testDiscoverUnknownEntryPolicySurvivesEveryOtherSetterAfterClearingAnExplic } } + /** + * The combination this class exists to refuse: an explicit {@link UnknownEntryPolicy} and a + * custom resolver together. A resolver supplied through {@link Builder#blockResolver} is used + * exactly as given, so the configured policy would never actually be consulted while + * {@link FalcoAnvilLoader#unknownEntryPolicy()} kept reporting it as active — the exact trap a + * caller who names both slots would otherwise fall into silently. + */ + @Test + void testCombiningAnExplicitUnknownEntryPolicyWithACustomBlockResolverIsRefused() { + FalcoAnvilLoader.Builder builder = FalcoAnvilLoader.builder() + .unknownEntryPolicy(new DefaultUnknownEntryPolicy()) + .blockResolver(new RefusingResolver()); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> builder.build(this.worldRoot, OVERWORLD)); + assertTrue(failure.getMessage().contains("UnknownEntryPolicy"), failure.getMessage()); + assertTrue(failure.getMessage().contains("blockResolver"), failure.getMessage()); + } + + /** + * The mirror of the block resolver case, and the other slot the guard has to consider: an + * explicit request for classpath discovery combined with a custom biome resolver is refused for + * the same reason a resolved instance is — a resolver supplied through + * {@link Builder#biomeResolver} never sees whatever discovery would have found. + */ + @Test + void testCombiningDiscoverUnknownEntryPolicyWithACustomBiomeResolverIsRefused() { + FalcoAnvilLoader.Builder builder = FalcoAnvilLoader.builder() + .discoverUnknownEntryPolicy() + .biomeResolver(new RefusingResolver()); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> builder.build(this.worldRoot, OVERWORLD)); + assertTrue(failure.getMessage().contains("UnknownEntryPolicy"), failure.getMessage()); + assertTrue(failure.getMessage().contains("biomeResolver"), failure.getMessage()); + } + + /** + * The existing case the guard must not break: a custom resolver on its own, without either + * {@link Builder#unknownEntryPolicy} or {@link Builder#discoverUnknownEntryPolicy} ever being + * called, still builds. {@link #testAGivenBlockResolverIsTheOneTheLoaderDecodesWith(Env)} already + * exercises this shape end to end; this test pins the construction itself so the guard's + * "configured" flag, not merely a null check, is what the refusal above keys on. + */ + @Test + void testACustomBlockResolverWithoutAnyPolicyConfigurationStillBuilds() throws Exception { + try (FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .blockResolver(new RefusingResolver()) + .build(this.worldRoot, OVERWORLD)) { + + assertNotNull(loader); + } + } + @Test void testTheBuilderCanBeReusedAfterASlotChanged() throws Exception { FalcoAnvilLoader.Builder builder = FalcoAnvilLoader.builder().openRegionLimit(8); From 54ab652361af27939759e4a246672e5d4d829c98 Mon Sep 17 00:00:00 2001 From: TheMeinerLP+ * Called from several threads at once. The policy is resolved once, when the loader is built, + * and every load after that consults the same instance — including every parallel load, since + * {@link FalcoAnvilLoader#supportsParallelLoading()} reports {@code true}. An implementation + * therefore has to be thread-safe on its own; the loader takes no lock around the call. + * {@link DefaultChunkVersionPolicy}, the shipped default, holds no state and needs none. + *
* * @author TheMeinerLP * @version 1.0.0 diff --git a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/FalcoAnvilLoader.java b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/FalcoAnvilLoader.java index a84b96b..0339d19 100644 --- a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/FalcoAnvilLoader.java +++ b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/FalcoAnvilLoader.java @@ -740,6 +740,12 @@ public Builder exceptionHandler(Consumer+ * An explicit instance passed here is shared by every loader this builder builds afterward, + * the same way an explicit {@link #diagnostics(AnvilDiagnostics)} instance is — and + * {@link ChunkVersionPolicy} is called from every one of those loaders' parallel loads at + * once, so it has to tolerate that sharing the way {@link AnvilDiagnostics} already does. + *
* * @param versionPolicy the policy to consult before a chunk is decoded, or null to consult * none @@ -823,6 +829,12 @@ public Builder discoverVersionPolicy() { * {@link #build(Path, Key)} refuses the combination instead of building a loader whose * configured policy would never actually run. * + *+ * An explicit instance passed here is shared by every loader this builder builds afterward, + * the same way an explicit {@link #diagnostics(AnvilDiagnostics)} instance is — and + * {@link UnknownEntryPolicy} is called from every one of those loaders' parallel loads at + * once, so it has to tolerate that sharing the way {@link AnvilDiagnostics} already does. + *
* * @param unknownEntryPolicy the policy to consult for an unknown palette entry, or null to * fall back to the classpath default diff --git a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/UnknownEntryPolicy.java b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/UnknownEntryPolicy.java index 2c37801..b36ec4e 100644 --- a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/UnknownEntryPolicy.java +++ b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/UnknownEntryPolicy.java @@ -26,6 +26,14 @@ * policy get asked twice: if the name it returns is itself unknown, the resolver fails the chunk * instead of consulting the policy again, which would risk a loop. * + *+ * Called from several threads at once. The policy is resolved once, when the loader is built, + * and both {@link BlockPaletteResolver} and {@link BiomePaletteResolver} keep consulting that same + * instance for as long as the loader lives — including every parallel load, since + * {@link FalcoAnvilLoader#supportsParallelLoading()} reports {@code true}. An implementation + * therefore has to be thread-safe on its own; neither resolver takes a lock around the call. + * {@link DefaultUnknownEntryPolicy}, the shipped default, holds no state and needs none. + *
* * @author TheMeinerLP * @version 1.1.0 From a0706db7bfb9ec20abaf26212ec7c5483455004c Mon Sep 17 00:00:00 2001 From: TheMeinerLP