Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f62a08d
docs: carry the extension-point spec and plan onto their own branch
TheMeinerLP Aug 4, 2026
a57f48d
feat(anvil): resolve a service once, and refuse to guess between two
TheMeinerLP Aug 4, 2026
ab88dd4
feat(anvil): make the version guard a service the caller can replace
TheMeinerLP Aug 4, 2026
b32c4ba
fix(anvil): let a foreign version policy win over the shipped default
TheMeinerLP Aug 4, 2026
a7b9c04
docs(spec): the shipped default has to step aside, or the seam is dec…
TheMeinerLP Aug 4, 2026
4767af3
feat(anvil): let a caller decide what an unknown palette entry becomes
TheMeinerLP Aug 4, 2026
c8d73cb
fix(anvil): address review findings on the unknown-entry policy
TheMeinerLP Aug 4, 2026
1664dcd
docs(anvil): date the new members to the release they will actually l…
TheMeinerLP Aug 4, 2026
de206f7
docs(anvil): record task 4 acceptance, including the archunit regress…
TheMeinerLP Aug 4, 2026
fe569f5
fix(anvil): let the unknown-entry policy name a substitute instead of…
TheMeinerLP Aug 4, 2026
4985431
test(archunit): widen byteLayerKnowsNoNbt for the anvil policy classes
TheMeinerLP Aug 4, 2026
a7f7b57
docs(anvil): note that the version guard and unknown-entry fallback a…
TheMeinerLP Aug 4, 2026
38d3b9b
docs(anvil): re-measure task 4 acceptance after the archunit fix clos…
TheMeinerLP Aug 4, 2026
6a238e7
fix(anvil): refuse a custom resolver configured together with an Unkn…
TheMeinerLP Aug 4, 2026
54ab652
docs(anvil): document that both policies are called concurrently and …
TheMeinerLP Aug 4, 2026
a0706db
test(anvil): assert the legacy no-policy chunk actually decodes to air
TheMeinerLP Aug 4, 2026
67f58ba
test(anvil): assert the shipped default's name is absent from the ref…
TheMeinerLP Aug 4, 2026
f41530b
docs(spec): pull the UnknownEntryPolicy signature in the design doc t…
TheMeinerLP Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,15 @@ diagnostics.reportUnknownBlock("mod:strange_block"); // true the first time, f
pre-26.1 layout, and `openRegionCount()` how many files are open right now. `close()` flushes every
one of them and is what `ownsLoader(true)` calls for you.

The version guard and the unknown-entry fallback above are both policies, not fixed behaviour:
`ChunkVersionPolicy` decides whether a chunk is readable at all, `UnknownEntryPolicy` decides what an
unknown block or biome becomes, and `falco-anvil` ships a default for each — `DefaultChunkVersionPolicy`
is the 21w43a guard, `DefaultUnknownEntryPolicy` is the air/plains substitution — discovered from the
classpath via `ServiceLoader` unless the builder's `versionPolicy()`/`unknownEntryPolicy()` slots are
used instead. The guard can be removed: `versionPolicy(null)` turns the check off, and nothing stands
in for it — a loader with no guard reads a pre-21w43a world as air again, with no error and no log
line.

### falco-light — block and sky light

Three entry points, in order of how much they do:
Expand Down
829 changes: 829 additions & 0 deletions docs/superpowers/plans/2026-08-04-anvil-extension-points.md

Large diffs are not rendered by default.

146 changes: 146 additions & 0 deletions docs/superpowers/specs/2026-08-04-anvil-extension-points-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Extension points for the Anvil loader

Design of 2026-08-04. `falco-anvil` turns three hard-wired decisions into services a caller can
replace: the version guard, the fallback for an unknown palette entry, and — already specified
separately — chunk migration. All three use the platform `ServiceLoader`.

Sits between [#45](https://github.com/OneLiteFeatherNET/Falco/pull/45), which built the guard, and
`falco-migration`, which needs the third of them. `2026-08-04-falco-migration-design.md` specifies
`ChunkMigrator` and is not repeated here.

## Why

Two behaviours in the loader are decisions the project made for its own use and then fixed in code:

1. **Which worlds are readable.** #45 refuses anything below `DEFAULT_MINIMUM_DATA_VERSION` or
carrying a `Level` compound. That is right for a server that only wants worlds it can read, and
wrong for a tool that wants to inspect one it cannot.
2. **What an unknown block or biome becomes.** `BlockPaletteResolver` substitutes air, and
`BiomePaletteResolver` substitutes plains, both counting the substitution in `AnvilDiagnostics`.
For a server that keeps a world loadable this is the reasonable last resort. For anything that
converts or audits a world it is the wrong reaction, because a substitution that is counted is
still a substitution written back on the next save.

Neither is wrong. Both are policy, and policy that only one consumer can choose is not policy.

`PaletteEntryResolver` already proves the shape: an interface in `falco-anvil`, handed in through the
builder. What it does not offer is discovery — a consumer must know the type exists and wire it. The
services below add that, and keep the builder slot as the explicit route.

## Decisions

| Question | Decision |
| --- | --- |
| Mechanism | Plain classpath `ServiceLoader`. No `module-info.java` exists in this project and none is added |
| Guard default | **None. No provider means no version check** |
| Guard shipped by `falco-anvil` | Yes, as a registered provider of its own |
| Fallback default | The current behaviour: air and plains, counted |
| Explicit route | Kept. A builder slot overrides discovery for every service |
| API compatibility | Additive only. No existing signature changes |

**The guard is fully optional, and that is the project owner's decision, taken with its consequence
stated.** The consequence: a loader with no guard provider reads a pre-21w43a world as air again,
with no error and no log line — the defect #45 exists to close. This spec does not soften that. What
it does is make the state visible rather than silent:

- `falco-anvil` registers its own guard in its `META-INF/services`, so a normal dependency on the
module has the guard. Losing it takes an exclusion somebody writes, not a classpath that happens to
be empty.
- The loader's existing startup line — which already reports the chosen region layout, and exists
because "without this line the choice between the two layouts happens invisibly" — gains the guard
it resolved, or the word `none`.

## The services

### `ChunkVersionPolicy`

```java
public interface ChunkVersionPolicy {

void check(CompoundBinaryTag data, int minimumDataVersion) throws ChunkDataException;
}
```

Called at the seam where `requireReadableVersion` is called today. The built-in implementation is the
body #45 wrote, moved rather than rewritten: layout first, version second, one `Reason`, the
diagnostics counter.

A policy that wants to allow everything implements an empty body. A policy that wants a different
floor reads its own configuration. The interface deliberately takes the whole compound, not a version
number, because the layout check does not rest on a version at all.

### `UnknownEntryPolicy`

```java
public interface UnknownEntryPolicy {

String onUnknownBlock(String name, @Nullable CompoundBinaryTag properties);

String onUnknownBiome(String name);
}
```

Consulted by `BlockPaletteResolver` and `BiomePaletteResolver` where they substitute today. Returning
a name substitutes it; throwing `AnvilChunkException` fails the chunk. Built as `String` rather than
`int` as this section originally specified: the id belongs to the registry lookup, and the project's
architecture rule keeps that lookup in exactly one adapter — the resolver that already owns it for the
entry it could not otherwise decode — not duplicated into every implementation of this interface. The
built-in implementation returns `"minecraft:air"` and `"minecraft:plains"` and keeps the existing
counting, so behaviour without a provider is byte-for-byte what it is now.

This is the seam `falco-migration` needs: a converter installs a policy that throws, because on the
upgrade path an unmappable block means the mapping data is incomplete and must be seen.

### Resolution rules, shared by all three services

Applied uniformly to every service here:

- **The shipped default steps aside for a foreign provider.** `falco-anvil` registers its own
implementations, so without this rule a third party taking the documented route would *always*
produce two providers and always hit the refusal below. Discovery could never return anything but
the default, and the extension point would be decoration. A named default is therefore removed from
the candidate set before the candidates are counted. **Added after the first implementation review
found the point unusable as this document originally specified it.**
- **More than one *foreign* provider throws**, naming them — the default is not among the names,
because by then it is not a candidate. Silent selection between two foreign providers is how a
world gets read under a policy nobody chose.
- **The builder slot and discovery are exclusive.** Setting both is a configuration error.
- **A builder slot always wins over the classpath** when only it is used — that is what "explicit"
means, and it short-circuits before the class path is consulted at all.
- **Discovery loads with the service's own class loader**, not the thread context loader. Under
CloudNet, extension or plugin class loaders the context loader may not see the `falco-anvil` jar;
discovery would then find nothing, resolve to no policy, and put the pre-21w43a air chunk back —
silently, which is the failure mode this whole line of work exists to end. The contract and its
shipped provider live in the same module, so that module's loader is the one to ask.

Discovery happens once, when the loader is built, not per chunk.

## What this does not do

- **No behaviour change with no provider present**, for the fallback. Air and plains, counted, exactly
as today.
- **A behaviour change for the guard**, and it is the point of the change: the guard becomes losable.
See the consequence above.
- **No new module.** All three interfaces live in `falco-anvil`.
- **No JPMS.** Plain classpath services.
- **`ChunkMigrator` is not specified here** — see the migration design.

## Evidence

- One case per service that the built-in provider is used when nothing is registered, and that
behaviour matches the current tree. For the fallback this is a regression guard on all of #45's
and the existing resolver tests; for the guard it is the assertion that `falco-anvil`'s own
registration is found.
- One case per service that a registered provider replaces the built-in one.
- One case that two providers throw, and one that builder slot plus discovery throws.
- **The Gegenprobe that matters:** remove `falco-anvil`'s own guard registration and assert that a
pre-21w43a world loads as air again. That test documents the cost of the chosen default in
executable form, so nobody has to take this document's word for it.

## Open for the plan

- Whether `ChunkVersionPolicy` and `UnknownEntryPolicy` are one service or two. They are written as
two here because they answer unrelated questions and a consumer will usually want one, not both.
- Where the built-in providers live: a package of their own, or beside the interfaces.
- Whether `AnvilDiagnostics` gains a counter for "policy replaced", so a run says it did not use the
defaults.
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
* The {@link BiomePaletteResolver} class translates between the biome entries of the Anvil format
* and the biome ids of the server registry.
* <p>
* 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.
* </p>
* <p>
* The registry is resolved on the first use instead of in a static initializer or in the
Expand All @@ -32,7 +33,7 @@
* </p>
*
* @author TheMeinerLP
* @version 1.0.0
* @version 1.2.0
* @since 0.1.0
*/
@ApiStatus.Experimental
Expand All @@ -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<DynamicRegistry<Biome>> registrySupplier;

private volatile @Nullable Registries resolved;
private volatile @Nullable DynamicRegistry<Biome> resolvedRegistry;

/**
* Creates a new resolver which uses the biome registry of the running server.
* Creates a new resolver which uses the biome registry of the running server and replaces an
* unknown biome following {@link DefaultUnknownEntryPolicy}.
*
* @param diagnostics the diagnostics which throttle the reports
*/
public BiomePaletteResolver(AnvilDiagnostics diagnostics) {
this(diagnostics, MinecraftServer::getBiomeRegistry);
this(diagnostics, new DefaultUnknownEntryPolicy(), MinecraftServer::getBiomeRegistry);
}

/**
* Creates a new resolver which uses the biome registry of the running server and decides what
* an unknown biome becomes through the given policy.
*
* @param diagnostics the diagnostics which throttle the reports
* @param policy the policy consulted for a biome the registry does not know
* @since 2.1.0
*/
public BiomePaletteResolver(AnvilDiagnostics diagnostics, UnknownEntryPolicy policy) {
this(diagnostics, policy, MinecraftServer::getBiomeRegistry);
}

/**
Expand All @@ -68,70 +83,93 @@ public BiomePaletteResolver(AnvilDiagnostics diagnostics) {
* @param registrySupplier the supplier which provides the registry of the known biomes
*/
public BiomePaletteResolver(AnvilDiagnostics diagnostics, Supplier<DynamicRegistry<Biome>> registrySupplier) {
this(diagnostics, new DefaultUnknownEntryPolicy(), registrySupplier);
}

/**
* Creates a new resolver which uses the registry the given supplier provides and decides what an
* unknown biome becomes through the given policy.
* <p>
* Package-private on purpose: nothing in this module needs both a custom policy and a custom
* registry at once, and there is no reason to promise that combination as public API before a
* caller actually needs it. It exists for this package's own tests, which use it to exercise
* {@link #toId(String, CompoundBinaryTag)} against a fake registry without a running server.
* </p>
*
* @param diagnostics the diagnostics which throttle the reports
* @param policy the policy consulted for a biome the registry does not know
* @param registrySupplier the supplier which provides the registry of the known biomes
* @since 2.1.0
*/
BiomePaletteResolver(AnvilDiagnostics diagnostics, UnknownEntryPolicy policy,
Supplier<DynamicRegistry<Biome>> registrySupplier) {
this.diagnostics = diagnostics;
this.policy = policy;
this.registrySupplier = registrySupplier;
}

/**
* Returns the registry of this resolver and resolves it on the first call.
*
* @return the registry and the id of the fallback biome
* @return the registry of the known biomes
*/
private Registries registries() {
Registries current = this.resolved;
private DynamicRegistry<Biome> registry() {
DynamicRegistry<Biome> current = this.resolvedRegistry;

if (current != null) {
return current;
}

synchronized (this) {
Registries created = this.resolved;
DynamicRegistry<Biome> created = this.resolvedRegistry;

if (created == null) {
DynamicRegistry<Biome> registry = this.registrySupplier.get();
created = new Registries(registry, registry.getId(Biome.PLAINS));
this.resolved = created;
created = this.registrySupplier.get();
this.resolvedRegistry = created;
}
return created;
}
}

/**
* The {@link Registries} record holds the resolved registry together with the id of the biome
* which replaces an unknown one.
*
* @param registry the registry which holds the known biomes
* @param fallbackId the id of the biome which replaces an unknown one
* @author TheMeinerLP
* @version 1.0.0
* @since 0.1.0
*/
private record Registries(DynamicRegistry<Biome> registry, int fallbackId) {
}

/**
* {@inheritDoc}
*
* @throws AnvilChunkException if the configured policy refuses an unknown biome
* @throws IllegalStateException if the name the policy substitutes is itself unknown; not an
* {@link AnvilChunkException}, because {@code FalcoAnvilLoader} is
* the only place that constructs one, and it wraps this into one
* when a chunk is read through the loader
*/
@Override
public int toId(String name, @Nullable CompoundBinaryTag properties) {
Registries registries = registries();
int id = registries.registry().getId(RegistryKey.unsafeOf(name));
DynamicRegistry<Biome> registry = registry();
int id = registry.getId(RegistryKey.unsafeOf(name));

if (id != -1) {
return id;
}
if (this.diagnostics.reportUnknownBiome(name)) {
LOGGER.warn("The biome '{}' is unknown and is replaced with plains, further chunks with it are not reported", name);
LOGGER.warn("The biome '{}' is unknown, further chunks with it are not reported", name);
}
String substituteName = this.policy.onUnknownBiome(name);
int substituteId = registry.getId(RegistryKey.unsafeOf(substituteName));

// The policy is not consulted a second time for its own substitute, for the same reason
// BlockPaletteResolver does not: a second call could loop, and a substitute the registry
// does not know either is a failure that has to reach the caller, not carry on silently.
if (substituteId == -1) {
throw new IllegalStateException("The biome '" + name + "' is unknown and its substitute '"
+ substituteName + "' is unknown too");
}
return registries.fallbackId();
return substituteId;
}

/**
* {@inheritDoc}
*/
@Override
public CompoundBinaryTag toEntry(int id) {
RegistryKey<Biome> key = registries().registry().getKey(id);
RegistryKey<Biome> key = registry().getKey(id);
String name = key == null ? Biome.PLAINS.key().asString() : key.key().asString();
return CompoundBinaryTag.builder().putString(NAME_KEY, name).build();
}
Expand Down
Loading
Loading