diff --git a/build.gradle.kts b/build.gradle.kts index 3f5e073..26cda38 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,7 +63,14 @@ project(":falco-bom") { apply(plugin = "java-platform") } -val publishedModules = listOf(project(":falco-anvil"), project(":falco-light"), project(":falco-instance"), project(":falco-bom")) +val publishedModules = listOf(project(":falco-anvil"), project(":falco-light"), project(":falco-instance"), project(":falco-migration"), project(":falco-bom")) + +// falco-migration has never been released, so no artefact exists for japicmp to compare against. +// The baseline check further down refuses an unresolvable baseline on purpose — a comparison against +// nothing passes no matter what changed — so the module stays out of that one check until its first +// release exists, and out of nothing else. Delete this list once falco-migration is on the +// repository; the module then joins the same API guarantee as its siblings. +val modulesWithoutAnApiBaseline = listOf(project(":falco-migration")) configure(publishedModules) { apply(plugin = "maven-publish") @@ -88,7 +95,7 @@ configure(publishedModules) { } } -configure(listOf(project(":falco-anvil"), project(":falco-light"), project(":falco-instance"))) { +configure(publishedModules - project(":falco-bom")) { extensions.configure { withJavadocJar() withSourcesJar() @@ -119,7 +126,7 @@ val apiBreaks: Map = if (!apiBreaksFile.exists()) emptyMap() els .entries .associate { it.key.toString() to it.value.toString().trim() } -configure(publishedModules - project(":falco-bom")) { +configure(publishedModules - project(":falco-bom") - modulesWithoutAnApiBaseline) { apply(plugin = "me.champeau.gradle.japicmp") val apiBaseline = configurations.detachedConfiguration( diff --git a/docs/superpowers/plans/2026-08-04-falco-migration-engine.md b/docs/superpowers/plans/2026-08-04-falco-migration-engine.md new file mode 100644 index 0000000..5f98493 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-falco-migration-engine.md @@ -0,0 +1,1038 @@ +# `falco-migration`, plan 1: the engine + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A library that lifts one Anvil chunk compound from Minecraft 1.13 to the version the server +writes — blocks, biomes and block entities — with no Minestom and no running server. + +**Architecture:** An ordered chain of steps, each declaring the version interval it applies to. A +chunk runs the steps whose interval its source version intersects. Block translation is a strategy +behind a versioned rule set keyed on the whole state. Directory discovery is separate from the chain +and belongs to the batch runner, which plan 2 builds — but the resolution itself lives here, because +it is pure logic and testable without files. + +**Tech Stack:** Java 25, Gradle, Adventure NBT (`net.kyori.adventure.nbt`), JUnit 5. No Minestom +anywhere in this module's main sources. + +**Specs:** `docs/superpowers/specs/2026-08-04-falco-migration-design.md` and the measurement it rests +on, `docs/superpowers/specs/2026-08-04-blockstate-property-research.md`. + +**Plan 2 (not this document)** builds the two front ends: the batch runner with its CLI, and the +`ChunkMigrator` implementation that plugs into `falco-anvil`'s service point. Neither is needed to +test anything here. + +## Global Constraints + +- New module `falco-migration`. It may depend on `falco-anvil` and on nothing else in this repository. +- **No Minestom in main sources.** Not `compileOnly`, not anywhere. The engine is pure NBT, and an ArchUnit rule enforces it. +- **The floor is DataVersion 1519 (1.13).** Below it the engine declines rather than guesses. +- Every public type and member carries `@ApiStatus.Experimental`, Javadoc with `@param`/`@return`/`@throws`, and `@since 2.1.0`. +- Javadoc runs under `-Werror`. +- Test method names read as sentences. Tests are package-private, plain JUnit assertions. +- Conventional Commits, lower case, scope `(migration)`. +- **No timing figure may be produced or quoted anywhere in this work.** Check `uptime` before test runs and record it. +- Counts come from the JUnit XML, never the console summary. + +## The facts this plan encodes, and where they came from + +Twenty-two facts, measured in `2026-08-04-blockstate-property-research.md`. Every rule the plan writes +carries its source in a comment — DataConverter's fix version, or the wiki page, or the computed +diff. **No rule is written from memory.** + +| # | Case | States | Kind | +| --- | --- | ---: | --- | +| 1 | `grass` → `short_grass` (1.20.3) | 1 | name | +| 2 | `grass_path` → `dirt_path` (1.17) | 1 | name | +| 3 | `sign` → `oak_sign` (V1802) | 32 | name | +| 4 | `wall_sign` → `oak_wall_sign` (V1802) | 8 | name | +| 5 | `stone_slab` → `smooth_stone_slab` (V1802) | 6 | name, **1.13→1.14 only** | +| 6 | `cobblestone_wall` / `mossy_cobblestone_wall`: `north/south/east/west` `false,true` → `none,low,tall` (V2503) | 128 | value | +| 7 | `cauldron[level]` → `cauldron` or `water_cauldron[level]` (V2679) | 4 | **name decided by a property** | +| 8 | `redstone_wire` direction values (V2531) | 144 | **whole-state, cross-property** | + +Zero property renames. 258 states with a missing property are **not** in this plan — Minestom fills +them from the target default, verified in all 30 cases, and Task 7 pins that with a test rather than +implementing it. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `falco-migration/build.gradle.kts` | Module, depends on `falco-anvil` and adventure-nbt | +| `…/migration/MigrationContext.java` | Source version, target version, a counter sink | +| `…/migration/MigrationStep.java` | One step: does it apply, and what it does | +| `…/migration/ChunkMigration.java` | The chain; the public entry point | +| `…/migration/BlockStateRule.java` | One versioned rule over a whole state | +| `…/migration/BlockStateRules.java` | The 22 facts, each with its source | +| `…/migration/BlockState.java` | Name plus properties, immutable | +| `…/migration/WorldLayout.java` | Source directory discovery and target mapping | +| `…/migration/steps/*.java` | One class per chain step | +| `falco-archunit/…/MigrationBoundaryTest.java` | The module sees no Minestom | + +--- + +### Task 1: The module, and the rule that keeps it honest + +**Files:** +- Create: `falco-migration/build.gradle.kts`, `falco-migration/src/main/java/net/onelitefeather/falco/migration/package-info.java` +- Modify: `settings.gradle.kts` — `include("falco-migration")` after `falco-archunit` +- Create: `falco-archunit/src/test/java/net/onelitefeather/falco/architecture/MigrationBoundaryTest.java` + +**Interfaces:** +- Consumes: nothing. +- Produces: the module and the package `net.onelitefeather.falco.migration`. + +- [ ] **Step 1: Write the failing rule** + +The rule comes before the code it guards, because it is the one thing that cannot be retrofitted once +an import slips in. In a new `MigrationBoundaryTest`: + +```java +class MigrationBoundaryTest { + + private static final String MIGRATION = "net.onelitefeather.falco.migration.."; + + @ArchTest + static final ArchRule migrationKnowsNoMinestom = noClasses() + .that().resideInAPackage(MIGRATION) + .should().dependOnClassesThat().resideInAnyPackage("net.minestom..") + .because("the engine converts stored NBT and must run without a server, which is what " + + "lets a world be converted before anything boots"); +} +``` + +Copy the `@AnalyzeClasses` annotation and its import scope from the existing `ModuleBoundaryTest` in +the same package — read it first, do not guess the scope. + +- [ ] **Step 2: Run it and watch it fail** + +Run: `./gradlew :falco-archunit:test --tests "*MigrationBoundaryTest*"` +Expected: failure — the package does not exist, so ArchUnit finds no classes. If ArchUnit instead +passes vacuously on an empty package, say so in the report: a rule that passes because it sees +nothing is worth nothing, and the next step is what gives it something to see. + +- [ ] **Step 3: Create the module** + +`settings.gradle.kts` gains `include("falco-migration")`. + +`falco-migration/build.gradle.kts`, modelled on `falco-anvil/build.gradle.kts` — read that file +first, it is 17 lines: + +```kotlin +description = "Converts stored Anvil chunk data from Minecraft 1.13 upwards" + +dependencies { + implementation(platform(libs.mycelium.bom)) + implementation(libs.slf4j.api) + implementation(project(":falco-anvil")) + + compileOnly(libs.adventure.nbt) + compileOnly(libs.annotations) + + testImplementation(libs.adventure.nbt) + testImplementation(libs.annotations) + testImplementation(libs.junit.jupiter) + testImplementation(libs.junit.platform.launcher) + testRuntimeOnly(libs.junit.jupiter.engine) +} +``` + +**No `libs.minestom` line, in any configuration.** That is the point of the module. + +`package-info.java` states what the package is: an engine over stored NBT that knows no server, with +the floor at DataVersion 1519 and the reason for it. + +- [ ] **Step 4: Give the rule something to see, and watch it pass** + +Add the smallest real type, so the rule analyses a non-empty package: + +```java +@ApiStatus.Experimental +public record BlockState(String name, @Unmodifiable Map properties) { + + public BlockState { + properties = Map.copyOf(properties); + } + + @Contract(pure = true) + public static BlockState of(String name) { + return new BlockState(name, Map.of()); + } +} +``` + +Run: `./gradlew :falco-archunit:test --tests "*MigrationBoundaryTest*" :falco-migration:build` +Expected: PASS. + +- [ ] **Step 5: Gegenprobe** + +Add `compileOnly(libs.minestom)` to the module and a single field of a Minestom type to `BlockState`. +`migrationKnowsNoMinestom` must go red and name that class. Revert both; verify `git status` is clean. +**Without this the rule is unproven** — an ArchUnit rule over a package that happens to have no +forbidden import yet passes for the wrong reason. + +- [ ] **Step 6: Commit** + +```bash +git add settings.gradle.kts falco-migration/ falco-archunit/ +git commit -m "feat(migration): a module that cannot see a server, and the rule that says so" +``` + +--- + +### Task 2: Where a world keeps its regions + +**Files:** +- Create: `…/migration/WorldLayout.java` +- Test: `…/migration/WorldLayoutTest.java` + +**Interfaces:** +- Consumes: nothing from Task 1 but the package. +- Produces: `record WorldLayout.Region(Path directory, String dimensionKey, boolean legacy)`; + `static List WorldLayout.discover(Path worldRoot) throws IOException`; + `static Path WorldLayout.targetDirectory(Path worldRoot, String dimensionKey)`. + +- [ ] **Step 1: Write the failing tests** + +The whole point is that a legacy world keeps two of its three dimensions somewhere the loader never +looks. Build the directories with `@TempDir`: + +```java +@Test +void testALegacyWorldYieldsAllThreeDimensions(@TempDir Path worldRoot) throws Exception { + Files.createDirectories(worldRoot.resolve("region")); + Files.createDirectories(worldRoot.resolve("DIM-1/region")); + Files.createDirectories(worldRoot.resolve("DIM1/region")); + + List found = WorldLayout.discover(worldRoot); + + assertEquals( + Set.of("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"), + found.stream().map(WorldLayout.Region::dimensionKey).collect(Collectors.toSet())); + assertTrue(found.stream().allMatch(WorldLayout.Region::legacy)); +} + +@Test +void testAModernWorldYieldsWhateverItContains(@TempDir Path worldRoot) throws Exception { + Files.createDirectories(worldRoot.resolve("dimensions/minecraft/overworld/region")); + Files.createDirectories(worldRoot.resolve("dimensions/mypack/mining/region")); + + List found = WorldLayout.discover(worldRoot); + + assertEquals( + Set.of("minecraft:overworld", "mypack:mining"), + found.stream().map(WorldLayout.Region::dimensionKey).collect(Collectors.toSet())); + assertFalse(found.stream().anyMatch(WorldLayout.Region::legacy)); +} + +@Test +void testADatapackDimensionIsNotHardCodedAway(@TempDir Path worldRoot) throws Exception { + Files.createDirectories(worldRoot.resolve("dimensions/mypack/mining/region")); + + assertEquals(1, WorldLayout.discover(worldRoot).size()); +} + +@Test +void testTheNetherLandsInItsModernPlace(@TempDir Path worldRoot) { + assertEquals( + worldRoot.resolve("dimensions/minecraft/the_nether/region"), + WorldLayout.targetDirectory(worldRoot, "minecraft:the_nether")); +} + +@Test +void testAWorldWithNoRegionsAtAllIsEmptyRatherThanAnError(@TempDir Path worldRoot) throws Exception { + assertTrue(WorldLayout.discover(worldRoot).isEmpty()); +} +``` + +The third case is the one that matters beyond the vanilla three: a data pack dimension appears under +`dimensions/` in both eras and must be enumerated, not matched against a list of three. + +- [ ] **Step 2: Run them and watch them fail** + +Run: `./gradlew :falco-migration:test --tests "*WorldLayoutTest*"` +Expected: compilation failure. + +- [ ] **Step 3: Implement it** + +`discover` looks in both shapes and returns everything it finds: + +- `/region` → `minecraft:overworld`, legacy +- `/DIM-1/region` → `minecraft:the_nether`, legacy +- `/DIM1/region` → `minecraft:the_end`, legacy +- `/dimensions///region` → `:`, modern — **enumerated by + walking the directory, not by checking three known names** + +`targetDirectory` always returns the modern shape, because that is what the target version reads. +`DIM-1` and `DIM1` are the only two fixed points in the mapping; everything under `dimensions/` +already carries its key in its path. + +Javadoc must state why this exists rather than reusing the loader's resolution: `FalcoAnvilLoader` +knows `/region` and the modern shape, and falls back to the former when the latter is absent. +For a legacy world that covers the overworld and **nothing else** — a converter reusing it would see +one third of a three-dimension world and report success. + +- [ ] **Step 4: Run them and watch them pass** + +Run: `./gradlew :falco-migration:test --tests "*WorldLayoutTest*"` +Expected: PASS, five cases. + +- [ ] **Step 5: Gegenprobe** + +Replace the `dimensions/` walk with a check against the three vanilla names. +`testADatapackDimensionIsNotHardCodedAway` must go red alone. Revert. + +- [ ] **Step 6: Commit** + +```bash +git add falco-migration/ +git commit -m "feat(migration): find every dimension a world has, not the one the loader looks at" +``` + +--- + +### Task 3: A rule keyed on the whole state, resolved by version + +**Files:** +- Create: `…/migration/BlockStateRule.java`, `…/migration/BlockStateRules.java` +- Test: `…/migration/BlockStateRulesTest.java` + +**Interfaces:** +- Consumes: `BlockState` from Task 1. +- Produces: `interface BlockStateRule { int since(); BlockState apply(BlockState state); boolean matches(BlockState state); }`; + `static BlockState BlockStateRules.translate(BlockState state, int sourceVersion)`. + +- [ ] **Step 1: Write the failing tests** + +Four cases, and each one exists because a specific fact forces it: + +```java +@Test +void testAPlainRenameIsApplied() { + assertEquals("minecraft:short_grass", + BlockStateRules.translate(BlockState.of("minecraft:grass"), 1519).name()); +} + +@Test +void testStoneSlabIsRenamedFromThirteenButNotFromSixteen() { + assertEquals("minecraft:smooth_stone_slab", + BlockStateRules.translate(BlockState.of("minecraft:stone_slab"), 1519).name()); + assertEquals("minecraft:stone_slab", + BlockStateRules.translate(BlockState.of("minecraft:stone_slab"), 2566).name()); +} + +@Test +void testACauldronsLevelDecidesItsName() { + BlockState empty = new BlockState("minecraft:cauldron", Map.of("level", "0")); + BlockState filled = new BlockState("minecraft:cauldron", Map.of("level", "2")); + + assertEquals("minecraft:cauldron", BlockStateRules.translate(empty, 1519).name()); + assertEquals(Map.of(), BlockStateRules.translate(empty, 1519).properties()); + + BlockState water = BlockStateRules.translate(filled, 1519); + assertEquals("minecraft:water_cauldron", water.name()); + assertEquals("2", water.properties().get("level")); +} + +@Test +void testAWallSideBecomesLowRatherThanTrue() { + BlockState wall = new BlockState("minecraft:cobblestone_wall", + Map.of("north", "true", "south", "false", "up", "true")); + + BlockState converted = BlockStateRules.translate(wall, 1519); + + assertEquals("low", converted.properties().get("north")); + assertEquals("none", converted.properties().get("south")); + assertEquals("true", converted.properties().get("up"), "up is not one of the four sides"); +} +``` + +The second case is the whole reason rules carry a version: `stone_slab` means one block in 1.13 and +another from 1.14, so translating it out of a 1.16 world would corrupt it. + +The fourth case pins that `up` is **not** among the four rewritten sides. `up` exists unchanged in +both versions and is carried through; the 20w06a render change concerns it, not the four directions. + +- [ ] **Step 2: Run them and watch them fail** + +Run: `./gradlew :falco-migration:test --tests "*BlockStateRulesTest*"` +Expected: compilation failure. + +- [ ] **Step 3: Implement the rule type and the resolution** + +`translate` applies every rule whose `since()` is **greater than the source version** — a rule dated +V1802 applies to a 1.13 world (1519 < 1802) and not to a 1.16 one (2566 > 1802). Rules apply in +ascending `since()` order, so a state can pass through several. + +Get that comparison right and write the reasoning into the Javadoc: the version on a rule is the +version *in which the change happened*, so it applies exactly to sources older than it. + +- [ ] **Step 4: Write the 22 facts, each with its source** + +`BlockStateRules` holds them. **Every entry carries a comment naming where it came from** — the +DataConverter fix version, or the computed diff. The five name rules, the shared wall table for both +wall blocks, the cauldron rule, and `redstone_wire`. + +For `redstone_wire`, read the rule from +`docs/superpowers/specs/2026-08-04-blockstate-property-research.md` and implement it as a +**whole-state** function: a direction's new value depends on the other three directions of the same +state. Implemented per property it is guaranteed wrong. If the research document does not carry +enough detail to implement it exactly, **stop and report that** rather than inventing the rule — +a wrong redstone rule is silent corruption, and the case is worth its own round. + +- [ ] **Step 5: Run them and watch them pass** + +Run: `./gradlew :falco-migration:test` +Expected: PASS. + +- [ ] **Step 6: Gegenprobe** + +Two defects, one at a time, each reverted: + +1. Change the version comparison from `since() > sourceVersion` to `since() >= sourceVersion`. + `testStoneSlabIsRenamedFromThirteenButNotFromSixteen` must go red. +2. Make the wall rule rewrite `up` along with the four sides. The fourth case must go red on its + third assertion. + +- [ ] **Step 7: Commit** + +```bash +git add falco-migration/ +git commit -m "feat(migration): versioned rules over whole block states, with their sources" +``` + +--- + +### Task 4: The chain, and the structural steps + +**Files:** +- Create: `…/migration/MigrationContext.java`, `…/migration/MigrationStep.java`, `…/migration/ChunkMigration.java` +- Create: `…/migration/steps/UnfoldLevel.java`, `…/migration/steps/NamespaceStatus.java`, `…/migration/steps/DiscardHeightmapsAndLight.java` +- Test: `…/migration/ChunkMigrationTest.java` + +**Interfaces:** +- Consumes: nothing from Task 3 yet — the chain is independent of the rules until Task 5. +- Produces: `record MigrationContext(int sourceVersion, int targetVersion)`; + `interface MigrationStep { boolean appliesTo(int sourceVersion); CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context); }`; + `static CompoundBinaryTag ChunkMigration.migrate(CompoundBinaryTag chunk, int targetVersion)` — unchecked, see Step 3. + +- [ ] **Step 1: Write the failing tests** + +```java +@Test +void testAPreEighteenChunkGetsItsFieldsOnTheRoot() throws Exception { + CompoundBinaryTag legacy = CompoundBinaryTag.builder() + .putInt("DataVersion", 2566) + .put("Level", CompoundBinaryTag.builder() + .putInt("xPos", 3) + .putInt("zPos", 4) + .putString("Status", "full") + .put("Sections", ListBinaryTag.empty()) + .build()) + .build(); + + CompoundBinaryTag migrated = ChunkMigration.migrate(legacy, 4790); + + assertNull(migrated.get("Level")); + assertEquals(3, migrated.getInt("xPos")); + assertEquals("minecraft:full", migrated.getString("Status")); + assertNotNull(migrated.get("sections")); +} + +@Test +void testAModernChunkIsLeftAloneExceptForItsVersion() throws Exception { + CompoundBinaryTag modern = CompoundBinaryTag.builder() + .putInt("DataVersion", 3700) + .putString("Status", "minecraft:full") + .put("sections", ListBinaryTag.empty()) + .build(); + + CompoundBinaryTag migrated = ChunkMigration.migrate(modern, 4790); + + assertEquals(4790, migrated.getInt("DataVersion")); + assertEquals("minecraft:full", migrated.getString("Status")); +} + +@Test +void testHeightmapsAndLightAreDroppedRatherThanConverted() throws Exception { + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putInt("DataVersion", 2566) + .put("Level", CompoundBinaryTag.builder() + .put("Heightmaps", CompoundBinaryTag.builder().putLongArray("WORLD_SURFACE", new long[]{1L}).build()) + .put("Sections", ListBinaryTag.empty()) + .build()) + .build(); + + CompoundBinaryTag migrated = ChunkMigration.migrate(chunk, 4790); + + assertNull(migrated.get("Heightmaps"), "a wrongly ported heightmap never announces itself"); +} + +@Test +void testAChunkBelowTheFloorIsDeclinedRatherThanGuessedAt() { + CompoundBinaryTag ancient = CompoundBinaryTag.builder().putInt("DataVersion", 1000).build(); + + assertThrows(MigrationException.class, () -> ChunkMigration.migrate(ancient, 4790)); +} +``` + +Check the exact Adventure NBT accessor names (`getInt`, `getString`, `get`) against the version this +project uses before writing these — `falco-anvil`'s `NbtReads` shows the idiom in use. + +- [ ] **Step 2: Run them and watch them fail** + +Run: `./gradlew :falco-migration:test --tests "*ChunkMigrationTest*"` +Expected: compilation failure. + +- [ ] **Step 3: Implement the chain** + +`ChunkMigration.migrate` reads `DataVersion`, declines below 1519 with `MigrationException`, runs +every step whose `appliesTo` is true in declared order, and stamps the target version at the end. + +**`MigrationException extends RuntimeException`, unchecked — corrected after Task 1's review.** This +plan first called for a checked type. That is impossible here, and reading the rules rather than +hitting them gives two reasons: + +- `ErrorHandlingTest.checkedFaultsStayInsideTheHierarchy` runs over the whole + `net.onelitefeather.falco..` tree, not only the published modules, and requires every **checked** + throwable in it to be assignable to `AnvilFormatException`. +- That hierarchy is `sealed … permits ChunkDataException, RegionFormatException` and lives in another + package, so it cannot be extended from here at all. + +Unchecked is the house style anyway, and it comes with a requirement: +`ErrorHandlingTest.ownExceptionsAreUncheckedAndCarryACause` demands every `RuntimeException` in the +tree be public **and** carry a public `(String, Throwable)` constructor. Give `MigrationException` +exactly that, or that rule fails instead. + +Drop `throws MigrationException` from the signature in the Interfaces block above accordingly. + +- [ ] **Step 4: Implement the three steps** + +- `UnfoldLevel` (below 2844): moves every child of `Level` onto the root, renames `Sections` to + `sections`, and adds `yPos`. Read the field list from the spec's step table; do not invent names. +- `NamespaceStatus` (any version): rewrites a status without a namespace, leaves a namespaced one + alone. **It tests no version at all** — the exact version that namespaced the status could not be + established, and this formulation is correct for every version in range without depending on a + number nobody has read. +- `DiscardHeightmapsAndLight` (any version): removes `Heightmaps`, `isLightOn`, and the per-section + `BlockLight`/`SkyLight`. Deliberate deletion: a wrongly ported heightmap never announces itself, + a missing one is rebuilt. + +- [ ] **Step 5: Run them and watch them pass** + +Run: `./gradlew :falco-migration:test` +Expected: PASS. + +- [ ] **Step 6: Gegenprobe** + +Make `NamespaceStatus` rewrite unconditionally, so `minecraft:full` becomes +`minecraft:minecraft:full`. `testAModernChunkIsLeftAloneExceptForItsVersion` must go red. Revert. + +- [ ] **Step 7: Commit** + +```bash +git add falco-migration/ +git commit -m "feat(migration): the step chain, and the three that only move things" +``` + +--- + +### Task 5: Sections — bit packing, biomes, and the Y range + +**Files:** +- Create: `…/migration/steps/NormaliseBitPacking.java`, `…/migration/steps/RebuildBiomes.java`, `…/migration/steps/TranslateBlockStates.java`, `…/migration/steps/SettleYRange.java` +- Create: `…/migration/LegacyBitReader.java` +- Modify: `…/migration/steps/UnfoldLevel.java` — hand `yPos` over to the new step +- Test: `…/migration/LegacyBitReaderTest.java`, `…/migration/steps/SectionStepsTest.java` + +**Corrected after Task 4's review: this task owns the Y range.** The plan's chain has a step 5, +"Widen the Y range", and neither Task 4 nor this one had been given it — it fell between them. Task 4 +consequently wrote `yPos = 0` inside `UnfoldLevel` as a stopgap, which is right for the *source* (a +pre-1.18 world is sections 0–15) and unproven for the *target*. That value moves here. + +**Settle the meaning of `yPos` before writing the step, and do it from a source.** The review found +the wiki's own wording ambiguous: it reads "Lowest Y section position **in the chunk** (e.g. `-4` in +1.18)", where the sentence argues for the chunk's own lowest section and the example argues for the +dimension's floor — vanilla writes every section of the range, so both readings coincide there and +diverge for a converted chunk that has no sections below 0. + +Establish which it is, then implement accordingly: + +- **If `yPos` is the chunk's own lowest section**, `0` is already correct and the step only has to + prove it, with a test and a comment naming the source. +- **If it anchors to the dimension**, the converted chunk needs `-4` for the overworld — and then the + spec's rule that empty sections are not invented has to be re-examined, because a chunk claiming a + floor it has no sections for is a second inconsistency, not a fix. + +The primary source is what actually reads it. `Chunk format` on minecraft.wiki settles the intent; +Minestom's own Anvil loader settles what Falco's target will do with it, and that one is in the +sources jar rather than the ten-month-old clone. **If the two disagree, say so and stop** — that is a +finding about the target platform, not a detail to decide in passing. + +**Interfaces:** +- Consumes: `BlockStateRules.translate` from Task 3, the chain from Task 4. +- Produces: `static int[] LegacyBitReader.unpack(long[] packed, int bitsPerEntry, int entryCount)`. + +- [ ] **Step 1: Write the failing test for the packing first** + +This is the one piece of real bit work in the plan, and it is where a silent defect would hide. +Pre-1.16 entries **span long boundaries**; `falco-anvil`'s `BitPacker` cannot read that — its `pack` +Javadoc says "without letting an entry span two longs" and `unpack` computes +`longIndex = index / entriesPerLong`. So this module needs its own reader. + +```java +@Test +void testAnEntryThatSpansTwoLongsIsReadWhole() { + // 5 bits per entry: entry 12 starts at bit 60 and runs into the next long. + long[] packed = { 0xF000_0000_0000_0000L, 0x0000_0000_0000_0001L }; + + int[] values = LegacyBitReader.unpack(packed, 5, 13); + + assertEquals(0b11111, values[12], "the entry crosses the long boundary and must be read whole"); +} + +@Test +void testTheModernReaderWouldGetThatWrong() { + long[] packed = { 0xF000_0000_0000_0000L, 0x0000_0000_0000_0001L }; + + assertNotEquals( + BitPacker.unpack(packed, 5, 13)[12], + LegacyBitReader.unpack(packed, 5, 13)[12], + "if these agree, the legacy reader is not doing anything and this module does not need it"); +} +``` + +Work out the expected value by hand before writing the assertion, and put the derivation in the test +comment. **If the two readers agree, the second test fails and that is the correct outcome** — it +would mean the legacy format is not what this plan assumes, which is a finding to report, not a test +to adjust. + +`BitPacker` is in `falco-anvil` and package-private-adjacent — check its visibility from this module +before relying on it in a test; if it is not reachable, assert against a hand-computed value instead +and say so in the report. + +- [ ] **Step 2: Run it and watch it fail** + +Run: `./gradlew :falco-migration:test --tests "*LegacyBitReaderTest*"` +Expected: compilation failure. + +- [ ] **Step 3: Implement the reader** + +A bit offset that walks continuously across the array, rather than restarting per long. + +- [ ] **Step 4: The three section steps** + +- `NormaliseBitPacking` (below 2566): re-packs every section's block state data from the spanning + layout into the long-aligned one. +- `RebuildBiomes` (below 2844): the biome array — 256 bytes before 1.15, 1024 ints from 1.15 — into a + palettised container per section. Read the exact shapes from the spec's step table. +- `TranslateBlockStates` (any version): walks each section's palette and puts every entry through + `BlockStateRules.translate`, carrying the source version. + +Each step's test builds a section by hand and asserts on the result. At least one test must carry a +`cobblestone_wall` with `north=true` through the **whole chain** and assert it comes out as +`north=low` — that is the case that would otherwise abort the chunk on load, and it is the proof that +the rules and the chain are actually wired together rather than merely both present. + +- [ ] **Step 5: Run them and watch them pass** + +Run: `./gradlew :falco-migration:test` +Expected: PASS. + +- [ ] **Step 6: Gegenprobe** + +Make `TranslateBlockStates` pass the target version to `translate` instead of the source version. +The wall case must go red — with the target version, no rule applies and `north=true` survives. +Revert. + +- [ ] **Step 7: Commit** + +```bash +git add falco-migration/ +git commit -m "feat(migration): read the packing 1.13 wrote, and translate what it held" +``` + +--- + +### Task 6: Block entities, and counting what is left behind + +**Files:** +- Create: `…/migration/steps/TranslateBlockEntities.java`, `…/migration/steps/CountEntities.java` +- Modify: `…/migration/MigrationContext.java` — the counter sink +- Test: `…/migration/steps/BlockEntityStepTest.java` + +- [ ] **Step 1: Write the failing tests** + +```java +@Test +void testABlockEntityIdIsRenamedLikeItsBlock() throws Exception { + // a 1.13 sign block entity keeps its coordinates and gains the renamed id + … +} + +@Test +void testTheEntitiesLeftInTheChunkAreCountedRatherThanMoved() throws Exception { + CompoundBinaryTag chunk = /* a 1.13 chunk with two entities in Level.Entities */; + + MigrationContext context = new MigrationContext(1519, 4790); + ChunkMigration.migrate(chunk, context); + + assertEquals(2, context.entitiesLeftBehind()); +} +``` + +Fill in the first fixture from the block entity list the plan's Task 6 research establishes — see the +next step. Do not invent a rename. + +- [ ] **Step 2: Establish the block entity renames exhaustively** + +The spec records this as work with no data source: the 1.13 mapping file carries no `blockentities` +list, so no diff settles it. The route it names is small enough to finish: read the target version's +list of 49 and check each against what a 1.13 world can contain. + +Do that, write the result into the report with a source per entry, and **encode only what you can +source**. Where a rename is uncertain, leave it out and list it in the report as unresolved rather +than guessing — an invented block entity rename silently rewrites a chest. + +- [ ] **Step 3: Implement both steps** + +`TranslateBlockEntities` renames the `id` and nothing else. Explicitly out of scope, and named in the +Javadoc: the **items inside** a block entity, and per-block-entity field changes such as the 1.20 +sign rework. + +`CountEntities` (below 2724) counts what it finds and moves nothing. Its Javadoc must state the +consequence plainly, in the spec's own words: the data stays in the chunk where the target version +will never look for it, so every mob, item frame and painting is effectively gone — counted, so it +cannot happen quietly. + +- [ ] **Step 4: Run, Gegenprobe, commit** + +Run `./gradlew :falco-migration:test`. Then remove the counter increment from `CountEntities` and +confirm the entity case goes red alone; revert. Commit as +`feat(migration): translate block entities, and count the entities this slice leaves behind`. + +--- + +### Task 7: Acceptance + +- [ ] **Step 1: Check the load, then run every module** + +`uptime` before and after, both into the report. + +```bash +./gradlew :falco-anvil:test :falco-light:test :falco-instance:test :falco-demo:test \ + :falco-benchmarks:test :falco-archunit:test :falco-migration:test --rerun-tasks +``` + +Counts from the JUnit XML. No count may fall against the baseline in +`docs/superpowers/plans/2026-08-04-anvil-extension-points.md`'s `## Result`. + +- [ ] **Step 2: Build with javadoc and the API check** + +```bash +./gradlew build -x test --rerun-tasks +``` + +`falco-migration` is new, so `checkApiCompatibility` has no baseline for it — confirm that is handled +rather than silently skipped, and record what the build actually does about it. + +- [ ] **Step 3: Pin the free half, do not implement it** + +One test that a 1.13 coral **without** `waterlogged` ends up `waterlogged=true` once loaded, and a +1.13 block whose new property defaults to false ends up false. This is the test the spec asks for +because the intuitive rule is wrong — the rule is "the target version's default", never "false", and +a coral written as `false` dries out every reef. + +This test needs Minestom to resolve a default, so it lives in **`falco-anvil`'s or the demo's** test +sources, never in `falco-migration` — the boundary rule from Task 1 forbids it here, and that rule is +worth more than the convenience. + +- [ ] **Step 4: Attack the gate** + +Re-inject Task 5's Gegenprobe (target version instead of source version) and confirm the **full** +suite catches it, not one class. Revert; `git status --short` empty. + +- [ ] **Step 5: Write the result into the plan and commit** + +Append `## Result`: cases added per task, which injected defect each caught, module counts from the +XML, both load figures, the block entity renames actually established with their sources and the ones +left unresolved, and explicitly what this plan does **not** deliver: no batch runner, no CLI, no +loader hook, no entity move, nothing outside `region/`, nothing below 1.13. + +--- + +## Self-Review + +**Spec coverage.** Directory structure → Task 2. Step chain steps 1 and 4 → Task 5; steps 2 and 6 → +Task 6 and Task 4; steps 3 and 5 → Task 4; step 7 → Tasks 3 and 5; step 8 → Task 4. The strategy and +its three load-bearing properties → Task 3. The floor → Task 4. The free 258 states → Task 7 Step 3, +pinned rather than implemented. The two front ends are explicitly plan 2. + +**Placeholders.** Two steps carry a deliberate "establish this, then encode it" instruction rather +than fixed content: the block entity renames in Task 6 Step 2, which the spec itself records as +having no data source, and the `redstone_wire` rule in Task 3 Step 4, which must be read from the +research document rather than from memory. Both say what to do when the answer cannot be sourced — +report it, do not invent it. Task 6 Step 1's first fixture is intentionally left to be filled from +Step 2's result, because writing a fixture before establishing the fact would be inventing one. + +**Type consistency.** `BlockState(String name, Map properties)` in Tasks 1, 3 and 5. +`MigrationContext(int sourceVersion, int targetVersion)` in Tasks 4 and 6, gaining the counter in 6. +`BlockStateRules.translate(BlockState, int sourceVersion)` — always the **source** version, which is +what Task 5's Gegenprobe attacks. `MigrationException` is this module's own **unchecked** type, never +`falco-anvil`'s sealed hierarchy. + +**One risk this plan cannot remove.** The `redstone_wire` rule is 144 states of cross-property logic +transcribed from a research document rather than derived. If it is wrong it is silent — the chunk +loads and the wiring looks subtly different. Task 3 says to stop and report rather than invent, and +the acceptance does not claim the rule is verified against a real world, because nothing here can. + +## Result + +Acceptance run against `999b9fc6` (branch tip, `feat/migration-engine`), worktree +`/mnt/projects/oss/onelitefeather/Falco-worktrees/migration-engine`, forked from `origin/main` at +`94dc7617` (#47, `feat(anvil)!: make the version guard and the unknown-entry fallback replaceable +services`). Two changes landed during this acceptance itself, both explicitly permitted by the task +brief; everything else below is measurement and two reverted attacks. + +### Cases added per task (from the JUnit XML, `falco-migration` — 43 total) + +| File | Cases | Task | +| --- | ---: | --- | +| `WorldLayoutTest` | 7 | Task 2 | +| `BlockStateRulesTest` | 10 | Task 3 | +| `ChunkMigrationTest` | 5 | Task 4 | +| `LegacyBitReaderTest` | 2 | Task 5 | +| `SectionStepsTest` | 13 | Tasks 5 + 6 (shared file; the wiring got contaminated across the two parallel worktrees, see the ledger) | +| `BlockEntityStepTest` | 6 | Task 6 | + +`7 + 10 + 5 + 2 + 13 + 6 = 43`, matching the module's own measured total exactly. Task 1 added no +test file of its own (`MigrationBoundaryTest` lives in `falco-archunit`, counted in that module's 48). + +### This acceptance's own additions + +- **`falco-anvil/src/test/java/net/onelitefeather/falco/anvil/MigrationEnginePropertyDefaultTest.java`** + (Step 3, the free half the plan pins rather than implements) — 3 cases: + `testAConvertedCoralWithoutWaterloggedEndsUpWaterloggedTrue`, + `testAConvertedConduitWithoutWaterloggedEndsUpWaterloggedTrue`, + `testAnOrdinaryWaterloggableBlockWithoutWaterloggedEndsUpWaterloggedFalse`. It exercises + `Block.fromKey(name).withProperties(...)`, the exact call `BlockPaletteResolver.toId` already uses + in production, and its defaults are cross-checked against `net.minestom:data:26.1.2-rv1`'s own + `block.json` rather than assumed: `defaultStateId` for `minecraft:tube_coral` and + `minecraft:conduit` both resolve to their `[waterlogged=true]` state; `minecraft:oak_fence`'s + resolves to `[...,waterlogged=false,...]`. Lives in `falco-anvil` rather than `falco-migration` + because resolving a default needs `Block`, which `migrationKnowsNoMinestom` forbids. +- **`falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/RebuildBiomes.java:117`** + — the dead `{@link NormaliseBitPacking#BLOCK_PALETTE_MIN_BITS}` (a field the same diff that added + it, `999b9fc6`, removed from that class) now reads `{@link TranslateBlockStates#BLOCK_PALETTE_MIN_BITS}`, + the field the pinned constant's comment was actually pointing at ("this module cannot depend on + Minestom" — true of both classes' own pinned constants, and `TranslateBlockStates.BLOCK_PALETTE_MIN_BITS` + is the one that still exists). No behavioural change; javadoc never rendered this link either way + because the target was always `private`. + +### Gate attacks (Task 7 Step 4 / brief item c, each run against the full seven-module suite, each reverted) + +**Attack 1 — `RebuildBiomes.discardSectionsOutsideTheFixedRange` neutralised to `return chunk;` +immediately.** This is the exact regression the previous acceptance round found in real world data +(ledger: "RebuildBiomes warf AIOOBE auf jedem echten Vanilla-Chunk vor 1.18") and Task 5+6's fix +round closed. Full suite run: `BUILD FAILED`, `:falco-migration:test FAILED`, two cases in +`SectionStepsTest` red, both `java.lang.ArrayIndexOutOfBoundsException: Index -64 out of bounds for +length 1024`: +- `testASectionBelowZeroDoesNotCrashRebuildBiomesAndIsDroppedRatherThanKept` +- `testSectionsAtYMinusOneAndYSixteenSurviveTheWholeChainDiscardedRatherThanCorruptingItOrYPos` + +No other module's tests moved. Reverted; `git diff` against the file was empty afterward. + +**Attack 2 — `TranslateBlockStates.apply` passed `context.targetVersion()` to `translate` instead of +`context.sourceVersion()`.** This is Task 5's own Gegenprobe, re-injected as the brief instructs. +Full suite run: `BUILD FAILED`, `:falco-migration:test FAILED`, three cases in `SectionStepsTest` +red: +- `testACobblestoneWallWithNorthTrueSurvivesTheWholeChainAsNorthLow` — `expected: but was: + `, exactly the wall case the brief predicted ("Der Wandfall muss rot werden"). +- `testALegacyTopLevelPaletteBecomesAModernBlockStatesContainer` and + `testAnOverWidthPackedLegacyPaletteIsDecodedAtItsActualWidthNotThePaletteMinimum` — both assert + `stone_slab` renamed to `smooth_stone_slab` below DataVersion 1901 and got `minecraft:stone_slab` + back unchanged, because with the target version (4790) substituted in, no rule's `since()` is ever + greater than the version handed to `translate`. + +Reverted; `git diff` against the file was empty afterward. Both attacks confirm what the brief asked +for: the full suite catches each defect, not a narrowly scoped test class — `SectionStepsTest` is the +one file exercising the whole chain end-to-end, and it is what goes red in both cases. + +### Module counts (`./gradlew :falco-anvil:test :falco-light:test :falco-instance:test :falco-demo:test :falco-benchmarks:test :falco-archunit:test :falco-migration:test --rerun-tasks`, counted from `` elements under `build/test-results/test/`) + +| Module | Baseline cited in `2026-08-04-anvil-extension-points.md`'s own `## Result` | Re-measured at the actual fork point `94dc7617` | Count now | Delta vs. fork point | +| --- | ---: | ---: | ---: | ---: | +| falco-anvil | 255 | 258 | 261 | +3 | +| falco-light | 223 | — | 223 | 0 | +| falco-instance | 259 | — | 259 | 0 | +| falco-demo | 167 | — | 167 | 0 | +| falco-benchmarks | 42 (1 skipped) | — | 42 (1 skipped) | 0 | +| falco-archunit | 47 | 48 | 48 | 0 (see below) | +| falco-migration | — (new module) | — | 43 | new | + +**A discrepancy worth recording, not smoothing over.** The brief points at the anvil-extension-points +document's baseline of 255 for `falco-anvil`. Re-running that document's own commit +(`94dc7617`, which is where `origin/main` — and this branch's fork point — actually sit; `git +merge-base HEAD origin/main` returns `94dc7617` exactly) with `:falco-anvil:test --rerun-tasks` +measures **258**, not 255, with zero other differences: `git diff 94dc7617 HEAD -- falco-anvil/src/test` +is empty apart from this acceptance's own new file. The most likely explanation is that the cited +255 was measured against `a7f7b574`, a pre-merge branch tip the document names explicitly, and three +more `falco-anvil` tests landed between that commit and the squash/merge that became `94dc7617` — +the document does not re-verify itself against the merged commit. Either way, **no count fell**: +261 (now) ≥ 258 (re-measured fork point) ≥ 255 (document's own baseline), and the module's real +growth this acceptance is the +3 pinning test from Step 3, not an unexplained gap. +`falco-archunit`'s own baseline in that document is 47; this branch's fork point already carries 48 +(one more than the document's post-fix figure), and it holds at 48 here too — `MigrationBoundaryTest` +existing without failing confirms Task 1's rule still sees the module it guards (matching the ledger: +"Task 1: complete ... Gegenprobe gefahren"). + +All seven modules: `BUILD SUCCESSFUL`, zero failures, zero errors, the one pre-existing skip in +`falco-benchmarks` unchanged. + +### Build with javadoc and the API check (`./gradlew build -x test --rerun-tasks`) + +`BUILD SUCCESSFUL`. Full `--rerun-tasks` output grepped case-insensitively for "warning": zero +matches. + +**`javadoc` genuinely executed** for `falco-anvil`, `falco-light`, `falco-instance` (the three +modules `withJavadocJar()` is applied to) and, separately, `falco-demo` — the latter is not a +published module either, but its own `build.gradle.kts` explicitly wires +`tasks.named("check") { dependsOn(tasks.named("javadoc")) }`. + +**`javadoc` did *not* run for `falco-migration` under this exact command — confirmed, not assumed.** +The full `--rerun-tasks` task graph for `build -x test` lists `:falco-migration:compileJava`, +`:falco-migration:classes`, `:falco-migration:jar`, `:falco-migration:assemble`, +`:falco-migration:check`, `:falco-migration:build` — no `:falco-migration:javadoc` anywhere in it. +`falco-migration/build.gradle.kts` has no `check`/`javadoc` wiring of its own (unlike `falco-demo`'s), +and the root build only wires `javadoc` into the build graph for the three modules that call +`withJavadocJar()`. The task itself exists (`./gradlew :falco-migration:tasks --all` lists a plain +`javadoc` task, inherited from the `java-library` plugin applied to every subproject) but nothing in +the `build`/`check` lifecycle ever asks for it. Invoked directly and in isolation, +`./gradlew :falco-migration:javadoc --rerun-tasks` does succeed cleanly under this project's +`-Werror` setting (`BUILD SUCCESSFUL`, no warnings) — so the content is fine, including the corrected +link above — but the acceptance's own `build -x test` step never exercises it. This is the same class +of gap the previous acceptance's `falco-archunit` regression was: a check that exists but that +nothing in this module's own build file asks the aggregate build to run. + +**`checkApiCompatibility` is not silently skipped for `falco-migration` — it is not registered at +all, and that is a structural fact, not a workaround.** `publishedModules` in the root +`build.gradle.kts` (line 66) is `listOf(falco-anvil, falco-light, falco-instance, falco-bom)`; +`falco-migration` is not in it. The `japicmp` plugin, `withJavadocJar()`/`withSourcesJar()`, and +`maven-publish` are all applied only to that list (lines 68–102, 122–171), so `falco-migration` gets +none of them — confirmed by `./gradlew :falco-migration:tasks --all`, which lists no +`checkApiCompatibility`, no `javadocJar`, no `sourcesJar`, no `publish` task whatsoever. There is +consequently no baseline lookup, no `net.onelitefeather:falco-migration:1.0.0` resolution attempt, +and nothing to fail or pass — the module is simply not part of the API-compatibility machinery yet, +the same way it is not yet part of publishing. For the three modules that are configured, +`checkApiCompatibility` ran and reported, verbatim, for all three: `Comparing binary compatibility of +-1.0.0.jar against -1.0.0.jar` / `No changes.` — a real (if trivial, same-version) +comparison, not a no-op. +This is worth a decision before `falco-migration` ships: its own source carries `@since 2.1.0` tags +throughout, which reads as an intent to publish it alongside the next release of `falco-anvil` et al., +but nothing in the build currently treats it that way. Adding it to `publishedModules` is a one-line +change with a real consequence — `checkApiCompatibility` would need a first baseline to compare +against, which for a module with no prior published jar is its own separate decision (compare against +nothing, or against its own first release once it exists) — and is explicitly not made here, because +touching `build.gradle.kts` beyond what the brief names was out of scope for this acceptance. + +### The missing third evidence stage (real old worlds) + +**Searched, not found.** `find /mnt/projects -type d -iname region` (156 unique world roots after +stripping `DIM-1`/`DIM1`/`dimensions//` suffixes and de-duplicating) turned up region +directories under dozens of unrelated projects on this machine — Minestom/Microtus test fixtures, +old plugin `run/` directories, CloudNet templates, PlotSquared/FastAsyncWorldEdit test servers. Every +`level.dat` found and readable (raw NBT parsed by hand — gzip envelope, then the root compound walked +for a `DataVersion` `TAG_Int` under `Data`, no external NBT library available in this environment) was +checked. **The oldest `DataVersion` found anywhere was 2975** (Minecraft 1.19), already above this +module's whole operating range of 1519 (1.13, the floor) through 2843 (below 1.18, the last version +`RebuildBiomes` still has to run for). Nothing in the 1.13–1.17 window, and nothing even in the +1.18–1.18.2 window `RebuildBiomes`'s own upper bound cares about, exists anywhere this search reached. +One `level.dat` (`.../oasisnetwork-master/.../Normallobby/level.dat`) failed to decompress as either +gzip or raw zlib and was left unread rather than guessed at — it is not old enough to matter even if +it were readable (nothing in that project predates 2019). No server jar was downloaded and no EULA +was touched, per instruction — this was a read-only survey of what already exists on disk. + +**What this stage would cost, and what it would prove, since it cannot be run.** Fixtures are written +by the person who also wrote the code they test, so all three of them encode the same set of +assumptions about what a chunk looks like — which is exactly the failure mode the previous round's +three real-world findings (Y=-1/Y=16 lighting sections, over-width packed palettes, the +`TileEntities` key) share: none of the three was reachable from a hand-built fixture, because building +the fixture means writing down what you already believe. A single real pre-1.18 region file, run +through `ChunkMigration.migrate` and then loaded by Minestom's own `AnvilLoader` without throwing, +would settle a materially larger set of assumptions in one pass than any number of additional +hand-built fixtures can: the true distribution of section `Y` values a real world writes (not just +the two boundary cases this acceptance's fixtures anticipate), whether a real chunk's `BlockStates` +array is ever packed at a width the palette-derived minimum would get wrong in a way no test has +tried yet, whether the legacy `Biomes` shape assumptions hold against terrain a human, not a test +author, generated, and — the one this project's own rules explicitly cannot verify any other way — +whether the 144-state `redstone_wire` gap (left unimplemented by Task 3, on record) actually shows up +as a visibly wrong wire in a real build rather than a value nobody happened to place. Acquiring one +would cost approximately: one real pre-1.18 Minecraft server run (a version this environment is +explicitly not authorized to download or accept the EULA for) or a donated/found world backup in that +DataVersion range, which this search did not find on this machine. Absent that, this plan's own +Self-Review already says it plainly and this acceptance can only confirm it remains true: "the +acceptance does not claim the rule is verified against a real world, because nothing here can." + +### Machine load + +`uptime` before the seven-module test run (09:46:10): `load average: 1,53, 2,54, 1,63` +`uptime` after the same run (09:48:31, `BUILD SUCCESSFUL`): `load average: 8,20, 5,85, 3,05` + +No timing figure is produced or quoted anywhere in this section, per this plan's own constraint. The +two gate-attack runs and the final clean re-verification ran later and are not used for the load +comparison above; all three completed with consistent, repeatable pass/fail outcomes matching what is +reported here. + +### Block entity renames (Task 6 Step 2 — established exhaustively, not partially) + +**Zero renames encoded, zero left unresolved.** All 49 entries of the target version's block-entity +registry were checked against what a 1.13 world can contain; two independent full histories were +cross-checked rather than one: ViaVersion's own registry diff across every version from 1.18 onward +(zero removals, ever) and PaperMC/DataConverter's `TileEntity`-rename register from `V99` to `V4661` +(exactly one rename in the entire range, `suspicious_sand` → `brushable_block`, introduced far above +this module's ceiling and irrelevant to a 1.13 source). `TranslateBlockEntities` therefore renames +only the `id` field's *value* when a `BlockStateRules` name-rename fires for the corresponding block +(the id and the block name are the same string) — it carries no rename table of its own, because +there is nothing to put in one. Separately, `UnfoldLevel` does rename the *container key* `Level.TileEntities` → `block_entities`, +sourced to the same snapshot that renamed `Sections` → `sections` — a key rename, not a +block-entity-id rename, and not part of this acceptance's own changes. It was fixed in Task 5+6's +review round (`999b9fc6`), before this acceptance began; it is restated here only because Task 6 +Step 2 asks specifically for this section to record the renames established, with their sources. + +### What this plan does not deliver + +Restated explicitly, as the brief requires: + +- **No batch runner and no CLI.** `ChunkMigration.migrate` converts one already-loaded chunk compound; + nothing here walks a `region/` directory, opens an `.mca` file, or is invoked from a command line. +- **No loader hook.** `ChunkMigrator`, the extension point that would let `falco-anvil`'s loader call + into this module automatically, is explicitly plan 2's, not this one's — `falco-migration` has no + dependency on `falco-anvil`'s service-resolution machinery at all beyond its plain library + dependency for `BitPacker`. +- **No entity move.** `CountEntities` counts what `Level.Entities`/`entities` still holds after + conversion and moves nothing; every mob, item frame, and painting in a converted chunk stays exactly + where 1.13 left it, in a place the target version's entity storage never looks. +- **Nothing outside `region/`.** Player data, `poi/`, `data/`, `advancements/`, `stats/` and every + other per-world directory are untouched; `WorldLayout` only discovers region directories. +- **Nothing below Minecraft 1.13 (DataVersion 1519).** `ChunkMigration.migrate` declines with + `MigrationException` rather than guessing, unchanged from Task 4. +- **No `redstone_wire` rule.** 144 states (9 of 81 direction combinations × 16 power values) pass + through unconverted, on record since Task 3 — the research document names the count but not which + nine combinations or what they become, and inventing them was refused as silent corruption. +- **No third evidence stage.** Covered above: no real pre-1.18 world was available to run through the + chain, and none was manufactured to paper over the gap. + +### Status + +**DONE.** All six acceptance parts (a–f) ran to completion against `999b9fc6`. Every module's test +count held or grew against every baseline consulted (the document's own, and the more precise +re-measurement at the actual fork commit); `falco-migration` adds 43 cases of its own and this +acceptance adds 3 more in `falco-anvil`. Javadoc is warning-free everywhere it runs, and this +acceptance identified — rather than silently accepted — that it does not yet run for `falco-migration` +under the standard build command, and that `checkApiCompatibility` is not yet wired up for it either, +both traced to the same root cause (`falco-migration` absent from `publishedModules`) rather than two +unrelated gaps. Both gate attacks were caught by the full suite, not a narrowed test selection, and +both reverted cleanly (`git status --short` empty afterward in each case). No usable real-world Anvil +data older than DataVersion 2975 exists anywhere this search reached on this machine, so the third +evidence stage the spec calls for remains unfilled — recorded here with its cost and what it would +prove, not silently dropped. diff --git a/docs/superpowers/specs/2026-08-04-blockstate-property-research.md b/docs/superpowers/specs/2026-08-04-blockstate-property-research.md new file mode 100644 index 0000000..5518c1a --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-blockstate-property-research.md @@ -0,0 +1,155 @@ +# How much of 1.13 to 26.x is block-state properties + +> ## ⚠ Every `V####` number below is unverified. Three were wrong. +> +> Implementing the rules meant checking each `DataVersion` against the snapshot this document names, +> and **three of its six `V####` citations did not survive**: +> +> | This document says | Actually | Snapshot | +> | --- | --- | --- | +> | V1802 | **1901** | 18w43a | +> | V2503 | **2504** | 20w06a | +> | V2679 | **2681** | 20w45a | +> +> All three too low, by 99, 1 and 2 — no fixed offset, so there is nothing to correct by, only a +> reason to distrust the integers. **The snapshot *names* went 5 for 5** and are a good starting +> point; the numbers beside them are not. +> +> One of the three mattered. `1802` made the rename rules fire too *late*: a chunk from DataVersion +> 1802–1900 still stores `sign`, the rule would not have touched it, and an unknown block name is a +> `NullPointerException` on Minestom — see the compatibility table further down, which this document +> got right. +> +> A separate error, not from here: `2724` for `grass_path` and `3698` for `grass` were **release** +> versions put where a snapshot was meant (correct: 2681 and 3693). Different direction, different +> cause — that one came from reading the design document's "1.17" and "1.20.3" and filling in the +> release. Worth knowing because it is the mistake to expect wherever a source names a version but no +> number. +> +> Everything structural here held up: which cases exist, that there are zero property renames, and +> that `stone_slab` and `redstone_wire` are invisible to a registry diff. + +Research of 2026-08-04, four independent routes: a computed diff over the ViaVersion block-state +lists, Chunker source, PaperMC/DataConverter fix classes, and the wiki history. It answers the +question the migration design names as the plan's first job. + +**A correction carried in from the session that commissioned this.** Earlier I computed the vanished +block names from the `blocks` list of the ViaVersion mappings and reported **two**, `grass` and +`grass_path`. That is the wrong list: `sign` and `wall_sign` appear in `blockstates` but not in +`blocks`, so the count is **four**, and the two I missed account for 40 of the 42 states lost to a +renamed name. Re-verified after the fact against `m-1.13.json` / `m-26.3.json` — over `blocks` the +difference is `[grass, grass_path]`, over `blockstates` it is +`[grass, grass_path, sign, wall_sign]`. + +--- + +# Blockstate-Properties 1.13 → 26.x: gebündelter Befund + +## A) Die Zahl + +Ich habe die Registry-Rechnung unabhängig gegen **26.3** nachvollzogen (nicht nur 26.1) und die Streitpunkte am Quelltext geprüft. Skript und Daten: `/tmp/claude-1000/-mnt-projects-oss-onelitefeather-Falco/0e269350-c9d9-4ac0-8181-5b2bd8271309/scratchpad/` (`m-1.13.json` … `m-26.3.json`). + +| Fall | Anzahl | betroffene 1.13-Zustände | Beleg | +|---|---|---|---| +| **(a) Property umbenannt** | **0** | 0 | Kein 1.13-Block verliert und gewinnt gleichzeitig einen Schlüssel. Alle 4 Umbenennungen der ganzen Kette betreffen Blöcke, die es 1.13 nicht gab: `jigsaw` facing→orientation (1.16), `creaking_heart` creaking→active→creaking_heart_state (1.21.4/1.21.5), `test_block` test_block_mode→mode (V4305). Drei Wege bestätigen das unabhängig. | +| **(b) Wert weggefallen/geändert** | **2 Blöcke × 4 Properties** | **128** | `cobblestone_wall` / `mossy_cobblestone_wall`: `north/south/east/west` `false,true` → `none,low,tall` (1.16, V2503). `note_block.instrument` wächst nur (10→27 Werte) → **0 betroffene Zustände**. | +| **(c) Property hinzugekommen** | **30 (Block,Key)-Paare über 30 Blöcke** | **258** | `waterlogged` 17×, `powered` 12×, `unstable` 1×. | +| **(c) Property weggefallen** | **1** | **4** | `cauldron` verliert `level` (1.17, V2679). | +| Blockname verschwunden | 4 | 42 | `grass`, `grass_path`, **`sign`**, **`wall_sign`** | +| **registry-sichtbar gesamt** | **37 von 593 Blocknamen** | **432 von 8582 (5,03 %)** | exakt reproduziert, 1.13↔26.1 **und** 1.13↔26.3 identisch | + +**Dazu zwei Fälle, die kein Registry-Diff finden kann** — nur DataConverter hat sie: + +| Fall | Zustände | Warum unsichtbar | +|---|---|---| +| `stone_slab` → `smooth_stone_slab` (V1802, 1.14) | **6** | Der Name existiert in 1.13 *und* 26.3, bezeichnet aber verschiedene Blöcke. Mengendifferenz zweier Registries ist hier prinzipiell blind. Verifiziert: 1.13 `stone_slab` = 6 Zustände, 26.3 hat `stone_slab` **und** `smooth_stone_slab` mit je 6. | +| `redstone_wire` (V2531, 20w17a) | **144** | Zustandsliste 1.13 und 26.3 **byte-identisch** (1296 = 1296), nur die Bedeutung änderte sich. Ich habe V2531s Logik brute-force nachgerechnet: 9 der 81 Richtungskombinationen ändern sich, × 16 `power` = **144 Zustände**. | + +**Endstand: 39 von 593 Blocknamen (6,6 %), 582 von 8582 Zuständen (6,8 %).** + +### Welcher Weg trägt — und die Auflösung der Widersprüche + +**Keiner allein. Zwei Wege arbeitsteilig:** + +- **PaperMC/DataConverter liefert die belastbare Regel*menge***, weil sie per Konstruktion vollständig ist: Mojang *muss* jeden Fix schreiben, sonst laden eigene Welten falsch. Oberhalb `V1_13 = 1519` gibt es abzählbar **6** `BLOCK_STATE.addStructureConverter`-Klassen und **12** Rename-Klassen — per grep abschließend, kein Schätzwert. Nur dieser Weg fand `stone_slab` und `redstone_wire`. +- **Der ViaVersion-Registry-Diff liefert die belastbaren Zustands*zahlen***, weil er alle 8582 Zustände zählt statt Regeln. Er ist aber blind für Semantikänderungen bei gleicher Signatur — nachweislich zweimal. +- **Chunker** ist die unabhängige Bestätigung (deckungsgleich, 22 Versionszweige in einer Datei), aber auf Chunkers Java↔Bedrock-Zwischenmodell zugeschnitten und ohne Vollständigkeitsgarantie — das räumt die Erhebung selbst ein. +- **minecraft.wiki** kann die Zahl nicht liefern (keine versionsübergreifende Blockstate-Seite, drei dokumentierte Lücken/Wartungsbanner), taugt nur zur snapshot-genauen Datierung. + +**Widerspruch 1 — `chain` → `iron_chain`: aufgelöst, irrelevant.** Chunker und DataConverter warnen, die Registry-Diffs fanden es nicht. Grund: `minecraft:chain` **existiert in 1.13 nicht** — geprüft, erstmals in `mapping-1.16.json`. Für eine 1.13-Quelle kein Fall. (Chunkers eigener Befund „CHAIN +axis ab 1.16.2" bestätigt es.) + +**Widerspruch 2 — verschwundene Blocknamen: die Prämisse ist falsch.** Es sind **vier**, nicht zwei: `sign`→`oak_sign` und `wall_sign`→`oak_wall_sign` (V1802, 18w43a) fehlen in eurer Liste und machen 40 der 42 namensbedingt verlorenen Zustände aus. Drei der vier Wege sagen das übereinstimmend. Plus `stone_slab` als fünften, stillen Fall. + +**Widerspruch 3 — brauchen Wände Nachbarschaftskontext? Nein, widerlegt.** Der Wiki-Weg behauptet es; DataConverter V2503 und Chunkers `BOOL_TO_WALL_HEIGHT` machen beide reines Lookup `true→low`, `false→none`. Ich habe geprüft: `up` existiert in 1.13 **und** 26.3 unverändert und wird 1:1 übernommen — die 20w06a-Renderänderung betrifft `up`, nicht die vier Richtungen. `tall` ist nicht rekonstruierbar und Mojang versucht es nicht. Verlustbehaftet, aber Lookup. + +**Kleinkorrektur:** Der Wiki-Weg nennt 254 Auffüllungs-Zustände, korrekt sind **258** (1 barrier + 1 conduit + 5 Korallen + 84 Blätter + 46 Schienen + 120 Köpfe + 1 tnt). 258 + 4 (cauldron) = 262, was der Rechenweg-Erhebung entspricht. + +--- + +## Der Befund, den keine der vier Erhebungen hatte: Minestom ist nicht Vanilla + +Die DataConverter-Erhebung markierte selbst als ungeprüft, ob Vanillas Toleranz für Minestom gilt. Ich habe es nachgesehen (`net.minestom:minestom:2026.06.05-26.1.2` Sources, `net.minestom:data:26.2-rv3`): + +- `AnvilLoader.loadBlockPalette` (Z. 254–286) macht `Block.fromKey(name)` → `withProperties(nbtProperties)`. **Kein try/catch.** +- `BlockImpl.withProperties` ruft `findKeyIndexThrow` / `findValueIndexThrow` — beide werfen `IllegalArgumentException` bei unbekanntem Schlüssel **oder** unbekanntem Wert (Z. 292–306). +- Unbekannter Blockname → `Objects.requireNonNull(..., "Unknown block " + blockName)` → NPE. + +Damit ist die Lage für Falco **umgekehrt zu Vanilla**: + +| Fall | Vanilla / DFU | Minestom-Loader | +|---|---|---| +| Property **fehlt** (30 Blöcke, 258 Zustände) | Default | **Default — identisch, gratis** | +| Property **überzählig** (`cauldron[level]`, 4 Zustände) | still ignoriert | **Exception, Chunk lädt nicht** | +| Wert unbekannt (Wände `north=true`, 128 Zustände) | — (DFU fixt vorher) | **Exception, Chunk lädt nicht** | +| Name unbekannt (4 Namen, 42 Zustände) | — | **NPE** | + +**Die gute Hälfte davon ist geschenkt:** Ich habe die 26.2-Defaults aller 30 Auffüllungs-Blöcke gegen Minestoms `block.json` geprüft. Minestom setzt sie automatisch, und in allen 30 Fällen ist der Ziel-Default auch semantisch richtig. **Aber die verkürzte Regel „Default ist immer `false`" aus zwei Erhebungen stimmt nicht**: Korallen und `conduit` haben `waterlogged=**true**` als Default — was für 1.13.0-Daten genau richtig ist (eine trockene lebende Koralle starb ab). Wer `false` hart einträgt, trocknet jedes Riff aus. Die richtige Regel heißt **„Default der Zielversion", nicht „false"**. + +--- + +## B) Tabelle oder Code + +**Kein einziger Fall braucht Nachbarschafts- oder Chunk-Kontext.** Der einzige Fix der ganzen DataConverter-Historie, den eine Tabelle prinzipiell nicht ausdrücken kann, ist V1496 (LeavesFix, Flood-Fill über den Chunk) — DataVersion 1496 < 1519, **außerhalb eurer Spanne**, eine 1.13-Release-Welt hat ihn hinter sich. + +Alle 8 Regeln sind als **Zustand→Zustand**-Abbildung darstellbar. Drei davon sind **nicht** als Property→Property-Tabelle darstellbar — das ist die architekturrelevante Unterscheidung: + +1. **`cauldron` (4 Zustände)** — ein Property-Wert entscheidet den *Blocknamen*: `level=0` → `cauldron`, `level=1..3` → `water_cauldron[level=n]`. Namens- und Property-Ebene gekoppelt. Genau deshalb war der Fall im Namensdiff unsichtbar. +2. **`redstone_wire` (144 Zustände)** — der neue Wert einer Richtung hängt von den *anderen drei Richtungen desselben Zustands* ab (`connectedX`/`connectedZ` in V2531). Pro Property implementiert wird es garantiert falsch; als Voll-State-Tabelle über 1296 Einträge korrekt. +3. **`stone_slab` (6 Zustände)** — reine Namensregel, aber **nur an der Schwelle 1.13→1.14 gültig**. Eine unversionierte Regel würde `stone_slab` aus einer 1.16-Welt fälschlich umbenennen. Der Beweis, dass Regeln versioniert aufgelöst werden müssen. + +Die restlichen 5 (4 Namensregeln, 2 Wandtabellen als eine geteilte Tabelle, 30 Auffüllungen) sind reine Daten — und die 30 Auffüllungen sind bei Minestom sogar **null Zeilen**, weil `withProperties` vom Default-State ausgeht. + +--- + +## C) Folge für den Plan + +**Randthema in der Menge, aber kein Fall für eine formlose Handtabelle.** Empfehlung, dreiteilig: + +1. **Nicht die volle Chunker-Strategie portieren.** Chunkers Wert (~2.000 Zeilen inkl. `VanillaBlockStates`-Vokabular, MIT) liegt in Java↔Bedrock-Übersetzung. Falco macht Java→Java und bräuchte `group(1.13)` invertiert und mit `group(26.x)` komponiert — Aufwand ohne Gegenwert. Die 22 Fakten selbst sind unter 100 Zeilen Nutzinformation und abschreibbar. + +2. **Aber die *Mechanik* sofort bauen, nicht die Tabelle allein.** Drei Eigenschaften sind von Anfang an nötig, jede durch je einen konkreten Fall erzwungen — nachrüsten heißt umbauen: + - **Regeln sind versioniert**, aufgelöst per „größte Regelversion ≤ Quellversion" (Chunkers `VersionedStateMappingGroup`, ~94 Zeilen). Erzwungen von `stone_slab`. + - **Der Schlüssel ist der ganze Zustand, nicht eine Property.** Erzwungen von `cauldron` und `redstone_wire`. Das ist DataConverters Modell (`BLOCK_STATE.addStructureConverter`) und kostet nichts extra. + - **Eine Regel darf den Blocknamen ändern.** Erzwungen von `cauldron`. + + Das sind grob 200–300 Zeilen Engine plus eine Regeldatei. **(Zeilenzahl: Schätzung.)** + +3. **Auffüllung fehlender Properties gar nicht implementieren.** Minestoms Loader erledigt sie über `defaultState`, korrekt in allen 30 Fällen (verifiziert gegen `data-26.2-rv3`). Das streicht 258 der 582 Zustände ersatzlos aus dem Plan. **Aber: einen Test schreiben, der genau das festnagelt** — insbesondere `waterlogged=true` bei Korallen/conduit, das der intuitiven Annahme widerspricht. + +**Priorität nach Schadensbild, nicht nach Zustandszahl.** Bei Minestom sind 178 Zustände (4 Namen + 128 Wände + 4 cauldron + 6 stone_slab, letztere semantisch) **ladeblockierend** — eine 1.13-Welt mit einer Kopfsteinmauer bricht den Chunk ab, nicht nur den Block. `redstone_wire` (144) lädt sauber und sieht falsch aus. Die Reihenfolge ist damit: Namen → Wände → cauldron → stone_slab → redstone_wire. + +**Der Hauptteil der Arbeit liegt woanders**, und darin sind alle vier Wege einig: BlockEntities (NBT-Struktur, nicht registry-diffbar), Biome, und vor allem das **Chunk-Containerformat** — DataConverters V2832 (1.18-Höhenerweiterung, Paletten, Bit-Storage, Heightmaps) ist mit 917 Zeilen allein größer als die gesamte Blockstate-Arbeit; 186 der 204 relevanten Fix-Klassen fassen Blockinhalte überhaupt nicht an. Blockstates sind **etwa 3 % der Konverterarbeit** — die Prozentzahl ist eine Schätzung, das Verhältnis 6/204 Fix-Klassen ist belegt. + +--- + +## D) Was unbelegt blieb + +| Aussage | Status | +|---|---| +| „~200–300 Zeilen Engine", „~2.000 Zeilen für die Chunker-Portierung", „Blockstates ≈ 3 % der Konverterarbeit", „in einem Tag erledigt" | **Schätzungen.** Kein Weg belegt Aufwand, nur Umfang. | +| Vollständigkeit **nach** DataVersion 4661 | **Offen.** Der DataConverter-Klon (Commit `dcde1f1f`, 2026-03-16) deckt bis V4661; die Registry-Diffs bis 26.3. Ob 26.4+ weitere Blockstate-Fixes bringt: ungeprüft. | +| Genauer Quellstand „1.13" | **Unbestimmt.** `waterlogged` an Korallen/conduit und `unstable` an tnt kamen in **1.13.1** (18w30a, per Wiki datiert). Ist die Quelle 1.13.2, entfallen 7 der 30 Auffüllungen. Folgenlos, weil Auffüllung ohnehin gratis ist. | +| Falcos genaue Zielversion (26.1 / 26.2 / 26.3) | **Nicht spezifiziert.** Ich habe 1.13↔26.1 und 1.13↔26.3 gerechnet: für 1.13-Blöcke **identisches Ergebnis** (432/37), die Wahl ist für diese Frage folgenlos. | +| Optische Auswirkung von `low` statt `tall` bei gestapelten Wänden | **Nicht gemessen.** Mojang und Chunker akzeptieren `low`; ob das in gebauten Welten stört, sagt keine Quelle. Ein nachgelagerter Nachbarschaftspass wäre optional möglich. | +| Ob Minestoms `defaultStateId`-Verhalten sich zwischen 26.1.2 und 26.2/26.3 ändert | **Ungeprüft.** Gelesen wurde `minestom 2026.06.05-26.1.2` + `data 26.2-rv3` aus dem lokalen Gradle-Cache. | +| Ob es außerhalb von Blöcken (Items in Truhen, BlockEntities) weitere stille Umdeutungen wie `stone_slab` gibt | **Außerhalb des Auftrags, ungeprüft.** V1802 fasst auch Items an. | \ No newline at end of file diff --git a/docs/superpowers/specs/2026-08-04-falco-migration-design.md b/docs/superpowers/specs/2026-08-04-falco-migration-design.md new file mode 100644 index 0000000..d2e26e7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-falco-migration-design.md @@ -0,0 +1,497 @@ +# `falco-migration`: raising a stored world to the version the server speaks + +Design of 2026-08-04. A module that converts Anvil chunk data from Minecraft 1.13 upwards to the +version the running server writes. [#45](https://github.com/OneLiteFeatherNET/Falco/pull/45) taught +the loader to *recognise* a world it cannot read; this is where the converting starts. + +**The goal is the whole world. This slice — the light edition — is blocks, biomes and block +entities, across the whole directory structure of a world.** Entities, everything outside `region/` +and anything below 1.13 come later and are recorded at the end of this document so the shape of the +whole is visible from here. Nothing in this spec should be read as a promise that a world converted +with it is complete; "The entity debt" and "What this does not do" say precisely what it is missing. + +**Depends on #45.** Without a source version read from the chunk, no mapping can be selected. This +spec assumes `DataVersion` is read at the loader's seam and that +`ChunkDataException.Reason.UNSUPPORTED_CHUNK_VERSION` exists. + +**Depends on the extension points**, specified in `2026-08-04-anvil-extension-points-design.md`, +which turns the guard and the unknown-entry fallback into services alongside the `ChunkMigrator` +described below. The converter needs the second of those: it installs a policy that **throws** on an +unmappable block, where the loader's default substitutes air. Without that seam, a conversion would +quietly write air into a world it was asked to preserve. + +## Why + +Minestom speaks exactly one Minecraft version. A world stored by an older one is not merely +inconvenient — since #45 it is refused outright, which is honest but not useful. Mojang's own +converter is not available to us: + +`com.mojang:datafixerupper` 6.0.6 contains 468 class files, of which **one** is a `Schema` — the base +class — and **none** is a `*Fix`. Even `NbtOps` is absent; only `JsonOps` ships. The 254 fixes and +115 schemas live in `net.minecraft.util.datafix.*` inside the proprietary Minecraft jar and are not +redistributable. Pulling DFU in as a dependency buys the machinery and no Minecraft knowledge at all. + +The only complete free rebuild is PaperMC/DataConverter: GPL-3.0, 351 files, ~2 MB of source, 265 +version classes from `V99` to `V4661`, maintained for years by a specialist. That is the size of the +thing DFU's approach implies. + +**So this module does not rebuild DFU.** There is no schema registry and no type-rewrite machinery. +There is an ordered chain of functions `CompoundBinaryTag → CompoundBinaryTag`, each attached to a +`DataVersion` threshold, and a body of renaming data that is read rather than compiled in. + +## Decisions + +| Question | Decision | +| --- | --- | +| Shape | Data-driven step chain, not a DFU-style schema chain | +| Lower bound | **1.13** (`DataVersion` 1519), after the flattening | +| Direction | Upgrade only; the seams are designed so a reverse step can be added, but none is built | +| Mapping data | A **strategy**, not a fixed source. Two are built: the module's own table, derived from vendored registry lists, and one carrying ported Chunker knowledge | +| Applications | One engine, two front ends: a loader hook and a batch runner (API with a thin CLI over it) | +| Runtime dependency | None on Minestom. The engine is pure NBT | +| Heightmaps and light | Discarded, not converted | +| **Converted in this slice** | **Blocks, biomes and block entities** | +| Directory layouts | Both, in both directions — including `DIM-1` and `DIM1` | +| Everything else in the chunk | Passed through untouched, **counted, and reported** | +| Entities in the chunk | Not moved yet — and the world is told it is incomplete because of it | + +**The goal is a whole world; this slice is the light edition.** That is a deliberate first cut, not +the end state. What it changes about the design is only where the line sits — the engine, the step +chain and both front ends are built as if the rest were coming, because it is. + +**Why block entities are in and entities are out**, when both are "not blocks": a block entity is +part of the chunk in every version in range and stays there, so translating it is a rename applied to +a tag that is already in the right place. An entity has to *move to another file* from 1.17 onwards, +which is a different piece of work — it touches the directory layout, not just the chunk. Cutting +between them is cutting along the seam that exists rather than through the middle of one. + +**Why 1.13 and not lower.** Below the flattening, blocks are numeric ids with four bits of metadata, +which is a second block model rather than another rename. That belongs to sub-project 3. + +**Why registry lists and not the rename diffs.** An earlier draft of this document said ViaVersion +supplies rename diffs in both directions and that the module would read them. **That was wrong, and +it was wrong because the claim was carried over from research instead of read out of the files.** +What the directory actually holds: + +- `diff/mapping-to.json` **upwards** (e.g. `1.20.5to1.21`) carries `sounds` and `tags`. No + blocks, no block entities. +- The same file **downwards** (e.g. `1.13to1.12`) carries `blockstates` and `items`, but as + *substitutions* for things the older version lacks — `acacia_button` → `oak_button` — not as + renames. +- `mapping-.json` is a set of **registry lists** whose index is the numeric protocol id: + `blocks`, `blockstates`, `items`, `blockentities`, `entities`. + +The reason is structural: ViaVersion translates a *protocol*, where things are numbers, so an old +client can talk to a new server. World data stores *names*. Most of that directory is the wrong tool +for this job. + +**What the registry lists do settle, and it is the more useful half.** Comparing the `blocks` list of +1.13 against 26.1 — 593 names against 1168 — leaves exactly **two** names that disappear: + +| Gone after 1.13 | Became | In | +| --- | --- | --- | +| `grass` | `short_grass` | 1.20.3 | +| `grass_path` | `dirt_path` | 1.17 | + +Everything else in that span is an addition, and an addition needs no migration. **The rename problem +is therefore nearly absent**, and the weight of this module sits entirely in the structural steps 1 +to 6, for which no data source exists in the first place — only code. + +So the module vendors the registry list of each version a step hangs on, not the whole directory, and +derives its rename table from the difference. The table itself is hand-written and carries a source +per entry, because a difference says *that* a name vanished, never *what it became*. A test +recomputes the difference from the vendored lists and fails when a version drops a name the table +does not know — which is what turns a hand-written table into a maintained one. + +The licence permits the vendoring explicitly: *"The files under `mappings/` are free to copy, use, and +expand upon in whatever way you like."* The commit hash and the licence text are archived beside the +data. + +**Block entities cannot be checked this way.** The 1.13 mapping file carries no `blockentities` list +at all (26.1 has 49). The difference that is conclusive for blocks cannot be computed for them, and +their renames have to come from elsewhere. Naming the gap is all this spec does about it; closing it +is the plan's first job. + +**Why upgrade only.** DFU cannot go backwards, structurally: `getRule()` returns `nop()` when +`version >= dataVersion` and `update()` passes the input through unchanged — silently, without an +error. That is not the reason this module skips downgrade; the reason is that a downgrade is a +projection, not a rewrite, and needs a substitution policy that is a product decision rather than a +technical one. The step interfaces take a direction so that the decision stays open. + +## How the loader lets migration in + +`falco-anvil` declares the contract and discovers implementations through the standard service +loader. It never depends on `falco-migration`, and **migration is off unless a caller turns it on**. + +### The contract, in `falco-anvil` + +```java +public interface ChunkMigrator { + + boolean supports(int dataVersion); + + CompoundBinaryTag migrate(CompoundBinaryTag data, int dataVersion) throws ChunkDataException; +} +``` + +`supports` is asked before `migrate`, so a migrator that only covers 1.13 upwards can decline a 1.8 +world and let the guard refuse it with the message it already has. `migrate` returns the chunk in the +loader's own version — everything downstream of the seam is unchanged and does not know a migration +happened. + +The project has no `module-info.java` and has never used `ServiceLoader`; this is the first. So it is +a plain classpath service: `META-INF/services/net.onelitefeather.falco.anvil.ChunkMigrator` in the +`falco-migration` jar, and no JPMS `provides` clause anywhere. + +### The fluent API, opt-in in both forms + +Two new builder slots, alongside the immutable pass-through every other setter already does: + +```java +FalcoAnvilLoader.builder() + .migrator(myMigrator) // explicit: this instance, no lookup + .build(worldRoot, OVERWORLD); + +FalcoAnvilLoader.builder() + .discoverMigrator() // service loader: whatever the classpath provides + .build(worldRoot, OVERWORLD); +``` + +**Neither is the default, and that is the point.** Without one of these calls the loader behaves +exactly as it does after #45: a world it cannot read is refused. Migration on load changes what a +server does with stored data and what a load costs, and a dependency appearing on the classpath is +not a decision — calling a builder method is. + +Three rules make the opt-in honest rather than convenient: + +- **`discoverMigrator()` with no provider on the classpath throws** at build time. The caller asked + for migration and would otherwise get silence — the same failure this whole effort exists to end. +- **More than one provider throws**, naming them. Picking one silently is how a world gets converted + by a migrator nobody chose. `migrator(...)` is the explicit way out. +- **`migrator(...)` and `discoverMigrator()` are exclusive.** Calling both is a configuration error, + not a precedence puzzle. + +A migrated chunk is counted in `AnvilDiagnostics`, apart from the refused ones, so a run says how +many chunks it converted and from which versions. + +## Module boundary + +`falco-archunit` states `anvilIsStandalone = isolated(ANVIL, LIGHT, INSTANCE, DEMO, BENCH)` +(`ModuleBoundaryTest.java:98`): `falco-anvil` may not import any sibling module. The `ChunkMigrator` +contract above satisfies that by construction, the way `PaletteEntryResolver` already does in that +module — an interface **in `falco-anvil`**, implemented **in `falco-migration`**, which depends on +`falco-anvil` and never the other way. The service loader changes nothing about it: a provider is +found at runtime by name, which is not a compile-time edge and cannot become one. + +`falco-migration` joins the rule matrix as a module that may see `falco-anvil` and nothing else. +The rule gains a companion: **nothing in `falco-anvil` may name a class from `falco-migration`**, +which is what a service contract is for and what a careless import would quietly undo. + +## The core + +Input: the root compound of one chunk plus its source `DataVersion`. Output: the same chunk in the +target version. No block registry, no running server — possible because `falco-anvil` carries +Minestom as `compileOnly` and `RegionFile` is a byte container by construction (`open`, `readRaw`, +`writeRaw`, `RawChunk.decompress` are all public). + +**Reused:** `RegionFile`, `ChunkCompression`, `RegionConstants`, `SectorAllocator`, `NbtReads`, +`PaletteData`. + +**Deliberately not reused:** `BlockPaletteResolver` and `BiomePaletteResolver`. They resolve against +the registries of a running server and substitute air or plains for anything unknown. In a loader +that is a reasonable last resort; in a converter it is the wrong reaction, because an unmappable +block must become visible, not invisible. + +## The directory structure + +A world is not one directory of region files, and the layout changed along with the chunk format. +The converter has to resolve the source layout and write the target one, or it converts the overworld +of a three-dimension world and reports success. + +`FalcoAnvilLoader.resolveRegionDirectory` (`:572-579`) knows two shapes today: + +| Layout | Path | +| --- | --- | +| modern | `/dimensions///region` | +| legacy | `/region` | + +It picks legacy when the modern directory is absent and `/region` exists. **That covers the +overworld and nothing else.** In the legacy layout the other two dimensions live in +`/DIM-1/region` (the nether) and `/DIM1/region` (the end) — names that do not appear in +that method at all. A converter that reuses the loader's resolution therefore sees one third of an +old world. + +So the converter carries its own resolution, and it is a first-class part of this slice: + +- **Source discovery** enumerates what a world root actually contains: `/region`, + `/DIM-1/region`, `/DIM1/region`, and any `/dimensions///region`. + Custom dimensions from data packs appear under `dimensions/` in both eras and are enumerated, not + hard-coded to the three vanilla ones. +- **Target layout** is the modern one, because that is what the target version reads: `DIM-1` becomes + `dimensions/minecraft/the_nether/region`, `DIM1` becomes `dimensions/minecraft/the_end/region`, and + `/region` becomes `dimensions/minecraft/overworld/region`. +- **The mapping from old directory to dimension key is data, not a guess.** `DIM-1` → `minecraft:the_nether` + and `DIM1` → `minecraft:the_end` are the two fixed points; anything else found under `dimensions/` + already carries its key in its path. +- **Nothing is deleted.** The converter writes the new layout and leaves the old directories in place. + Removing them is the operator's decision, taken after they have looked at the result. + +**One thing to check before implementing, not to assume:** whether the loader's fallback to +`/region` for a non-overworld dimension is a defect in its own right. Reading a nether with a +legacy world root would hand back overworld regions. It may equally be that the caller is expected to +pass `/DIM-1` as the root. The plan establishes which, and if it is a defect it belongs in its +own change and not in this module. + +## The step chain + +Each step declares the version interval it applies to. A chunk runs the steps whose interval its +source version intersects, in order. + +| # | Step | Applies below | Notes | +| --- | --- | --- | --- | +| 1 | Normalise the bit packing | 2529 (20w17a, pre-1.16) | Pre-1.16 entries span long boundaries. `BitPacker` cannot read that: `pack` is documented "without letting an entry span two longs" and `unpack` computes `longIndex = index / entriesPerLong`. A separate legacy unpack is required. This row previously named 2566, 1.16's *release* DataVersion; the change actually landed in the 20w17a snapshot, DataVersion 2529 — the implementation (`NormaliseBitPacking.APPLIES_BELOW`) already carried the corrected, sourced number, and this row is now brought in line with it | +| 2 | **Count** entities still in the chunk | 2681 (20w45a, pre-1.17) | Counted and reported, **not moved**. See "The entity debt". This row previously named 2724, 1.17's *release* DataVersion; the change actually landed in the 20w45a snapshot, DataVersion 2681 — the implementation (`CountEntities.APPLIES_BELOW`) already carried the corrected, sourced number, and this row is now brought in line with it | +| 3 | Unfold `Level` | 2844 | Fields onto the root, `Sections`→`sections`, `yPos` added | +| 4 | Rebuild biomes | 2844 | Array (256 bytes pre-1.15, 1024 ints from 1.15) → palettised container per section | +| 5 | Widen the Y range | 2844 | Existing sections keep their `Y`. **Empty sections are not invented** | +| 6 | Namespace the status | see note | Namespaces a bare status, and also renames 1.13's own terminal values (`fullchunk`, `postprocessed` — *not* `full`, which did not exist as a name until 1.14) to `full` before namespacing; see `NamespaceStatus`'s own javadoc for the sourced rename chain | +| 7 | Apply renames | throughout | Blocks **and block entities**, from the module's own table. Two block entries are known for the whole 1.13–26.1 span; the block entity entries have no comparable source | +| 8 | **Discard** heightmaps and light | always | See below | + +**Step 8 is a deletion on purpose.** A wrongly ported heightmap never announces itself; a missing one +is rebuilt. `falco-light` already computes light, and the server recomputes heightmaps. + +**Step 6 has no verified threshold.** The exact version that namespaced the chunk status could not be +established — the wiki's own history carries the notice that it is missing a significant number of +changes. The step therefore does not test a version at all: it rewrites a status that carries no +namespace, whatever the source version, and leaves a namespaced one alone. That is correct for every +version in range and does not depend on a number nobody has read. + +**An unmappable block fails its chunk.** The engine does not substitute air, and it does not silently +keep the unknown name. It throws, naming the block and the chunk, and the batch runner records it and +continues with the next chunk so that one bad block does not abort a world. A configurable +substitution policy is what a *downgrade* needs and is out of scope here — on the upgrade path an +unmappable block means the mapping data is incomplete, which is a defect to see rather than to paper +over. + +### The entity debt + +In a 1.13 world the entities live inside the chunk; from 1.17 the server reads them only from +separate `entities/` regions. The light edition **does not move them**. +The consequence has to be stated plainly, because it is the same failure mode #45 exists to end: + +**A world converted by this module keeps its entity data in the chunk, where the target version will +never look for it. Every mob, item frame, armour stand, painting and dropped item is effectively +gone.** The bytes are still there — nothing is deleted — but nothing reads them either. + +That is acceptable as a first slice only because the module refuses to let it happen quietly: + +- Step 2 counts the entities it finds per chunk and totals them for the run. +- The batch runner ends with that total in its report, as a **warning**, not a statistic: "this world + carried N entities in its chunks; they were not moved and the target version will not read them." +- The loader hook logs the same thing once per world, throttled like every other diagnostic here. +- The README and the wiki say it where a reader meets the tool, not in a footnote. + +Moving them is the first item of the next slice. The move itself is lossless; the work is that +individual entity fields were renamed between versions, which is step 7's data applied to a +different tag. + +**Block entities are converted, not carried.** Their `id` goes through the same renames as a block, +and the tag stays where it already is. Two things about them are explicitly *not* in this slice, and +both are counted rather than fixed: the **items inside** a block entity — a chest's contents carry +item ids that were renamed too — and per-block-entity field changes that are not a rename of the +`id`, of which the sign text rework in 1.20 is the largest. A converted chest is in the right place +with the right id; what is inside it has not been looked at. + +**Everything else in the chunk is passed through untouched** — `block_ticks`, `fluid_ticks`, +`structures` and any tag this list does not name. Untouched means the tag survives the round trip +byte for byte, not that it is correct afterwards. + +## The two applications + +**The loader hook.** `falco-anvil` asks its `ChunkMigrator` — set through `migrator(...)` or found +through `discoverMigrator()`, never by default — when it meets a chunk below its floor, and loads the +converted result instead of refusing it. The world on disk stays old; the cost is paid on every load. +This is the path that makes an old world playable without a separate step, and the whole of what +`falco-migration` contributes to it is one implementation of that interface. + +**The batch runner.** An API that walks the region files of a world root and rewrites them, with a +thin CLI over it — a `main` method that parses arguments and calls the API, carrying no logic of its +own. The API is the contract; the CLI is a convenience. + +Both front ends drive the same engine. Neither may contain a conversion rule. + +## What this does not do + +- **No downgrade.** The interfaces admit one later; nothing implements it. +- **Nothing below 1.13.** The flattening is a later slice. +- **No entities**, neither moved nor renamed, with the consequence spelled out under "The entity + debt". +- **No items**, anywhere — not in block entity inventories, not in `playerdata/`. +- **No per-block-entity field changes**, only the `id` rename. The 1.20 sign rework is the notable + one. +- **Ticks and structures** are carried byte for byte and not translated. +- **Nothing outside `region/`.** `entities/`, `playerdata/`, `poi/`, `data/` and `level.dat` are + untouched. A world converted by this module is therefore **not** fully consistent. +- **No claim that the result is lossless**, and none that a converted world will load. Neither can be + promised for a converter of this kind, and this slice cannot even promise the world is complete. + +## The later slices, recorded here so the whole is visible + +Each becomes its own spec when it is reached. Nothing below is designed yet; this is the running +order and the reason for it. + +1. **Blocks, biomes, block entities — the light edition.** This document. +2. **Entities.** The move from the chunk into `entities/` plus their renames, which is what makes a + converted world actually playable rather than merely loadable. It is first of the remaining ones + because it is the largest silent loss the light edition leaves behind. +3. **Items.** Block entity inventories and, with slice 4, player inventories — the same rename data + applied to a third kind of tag, plus the 1.20.5 component break. +4. **The world outside `region/`.** `playerdata/`, `poi/`, `data/`, `level.dat`. POI is derivable + from block data and may be discarded rather than converted; that is a decision for that spec. +5. **Below 1.13.** The flattening: numeric ids with four bits of metadata into block states. A second + block model rather than another rename, and the reason 1.13 is this module's floor. + +## Evidence + +A converter claims the world is still the same world afterwards, which is a stronger claim than #45's +and cannot rest on fixtures alone — my own picture of the old format was wrong twice during #45, and +a fixture built from a wrong picture agrees with the code that shares it. + +Three levels, in increasing strength: + +1. **Per-step fixtures with a Gegenprobe.** Hand-built NBT, one case per step, each proved to go red + when the step is removed. Catches logic errors in a step. +2. **Property tests across the whole chain.** Block count per type is preserved, no block becomes air, + every block entity keeps its coordinate, every entity survives the move. These catch losses without + requiring me to predict the target format correctly. +3. **Real old worlds.** These do not exist in this repository and are the evidence that would actually + settle the question. They can be produced by running official server jars headless to generate a + few chunks per version. That pulls foreign binaries into the test path and is therefore its own + decision, taken when the tests are written rather than here. + +**Level 3 is unresolved and is recorded as such.** Until it exists, no claim about real worlds may be +made in the README or the wiki — only about the cases levels 1 and 2 cover. + +## The translation is a strategy + +Step 7 does not own a table. It calls a strategy. + +The interface takes a block state whole and returns one, because a rename and a property change are +the same operation seen from different distances: + +- in: identifier plus its properties, the source version, the target version +- out: identifier plus its properties — or a refusal, which fails the chunk per the rule above + +Neither the step chain nor either front end knows which implementation it has, so an operator's own +overrides for a modded world cost nothing to add. That is why the interface is public API rather than +an internal detail. + +### What the measurement changed about this section + +An earlier draft said the strategy existed to defer a choice between a cheap rename table and a +ported Chunker table, because nobody knew how large the property problem was. +`2026-08-04-blockstate-property-research.md` measured it, and three of the assumptions here were +wrong. + +**There are zero property renames.** Not few — zero, for every block that exists in 1.13. All four +renames in the whole chain belong to blocks introduced later (`jigsaw`, `creaking_heart`, +`test_block`). What costs something is 39 of 593 block names and 582 of 8582 states, just under 7 %. + +**So the ported Chunker table is not built, now or later.** Its value is Java↔Bedrock translation; +Falco does Java→Java and would have to invert one mapping group and compose it with another — +effort without return. The 22 facts themselves are under 100 lines and can be written down directly, +with attribution where they came from Chunker or DataConverter. + +**But three properties of the mechanism are load-bearing from the first line, each forced by a +concrete case.** Retrofitting any of them means rebuilding: + +1. **Rules are versioned**, resolved as "the greatest rule version ≤ the source version". Forced by + `stone_slab`: the name means one block in 1.13 and another from 1.14, so an unversioned rule would + corrupt a 1.16 world. This is the case that proves rules cannot be a flat table. +2. **The key is the whole state, never a single property.** Forced by `redstone_wire`, where a + direction's new value depends on the *other three directions of the same state* — implemented per + property it is guaranteed wrong — and by `cauldron`. +3. **A rule may change the block name.** Forced by `cauldron`, where `level=0` becomes `cauldron` and + `level=1..3` becomes `water_cauldron[level=n]`. Name and property level are coupled, which is + exactly why that case is invisible to a name diff. + +**Two cases no registry comparison can find**, and only DataConverter had them: `stone_slab` keeps +its name while meaning a different block, and `redstone_wire`'s state list is byte-identical across +the whole span while its meaning changed underneath. A migration built on registry diffs alone would +have shipped both as silent corruption. + +### Filling in missing properties is not implemented + +Minestom's loader already does it. `withProperties` starts from the default state, so a 1.13 state +missing a property the target version expects gets that version's default — verified correct in all +30 cases. That removes 258 of the 582 states from this module's work entirely. + +**One test nails it down anyway**, because the intuitive rule is wrong: corals and `conduit` default +to `waterlogged=true`, not false, and that is what 1.13 data means — a dry living coral died. Writing +`false` there would dry out every reef. The rule is **"the target version's default"**, never +"false". + +## Order of work: by damage, not by count + +Minestom is not Vanilla here, and it changes which cases are urgent. +`BlockImpl.withProperties` throws on an unknown property key **or** an unknown value, and an unknown +block name is a `NullPointerException` — `AnvilLoader.loadBlockPalette` wraps none of it. Where +Vanilla silently tolerates a stale state, Falco's loader refuses the whole chunk. + +| Case | Vanilla | Falco on Minestom | States | +| --- | --- | --- | ---: | +| property missing | default | default — identical, free | 258 | +| property surplus (`cauldron[level]`) | ignored | **chunk fails** | 4 | +| value unknown (walls `north=true`) | — | **chunk fails** | 128 | +| name unknown | — | **NPE** | 42 | +| meaning changed, signature identical (`redstone_wire`) | — | loads, looks wrong | 144 | + +**178 states are load-blocking**: a 1.13 world with a single cobblestone wall aborts the chunk, not +just the block. `redstone_wire` loads cleanly and renders wrong — that was true when this section was +written and is why the priority order below put it last. It is no longer true of this module's +output: `redstone_wire` now has a rule too (`BlockStateRules`, DataVersion 2532, snapshot 20w18a), so +the "loads, looks wrong" outcome in the table is what happens without that rule, not what Falco does +today. The table and the priority order are kept as the historical record that justified doing walls +and cauldron first; they are not a statement of current coverage. So the order is **names → walls → +cauldron → stone_slab → redstone_wire**, which is damage order and not state-count order. + +## Where the work actually is + +The measurement settled something the earlier drafts guessed at. Of DataConverter's 204 relevant fix +classes above the 1.13 floor, **six** touch block states at all; 186 do not touch block content of +any kind. Its V2832 alone — the 1.18 height extension, palettes, bit storage, heightmaps — is 917 +lines, larger than the entire block-state problem. + +**Block states are a small part of this module.** The weight sits in the container format, which is +steps 1 to 6 of the chain above, and in block entities, whose NBT structure no registry diff can +describe. The plan budgets accordingly, and the ratio 6/204 is counted rather than estimated. (The +share of overall effort is not: any percentage figure here would be a guess.) + +## Two known limits of the mapping data + +**Block-state properties are not covered by the vendored data — and that turned out to cost almost +nothing.** The registry lists carry block *names*, so no comparison over them can derive a property +change. This was written as the plan's first and largest unknown; it has since been measured, and the +answer is in `2026-08-04-blockstate-property-research.md`: zero renames, 22 facts, under 100 lines +written down by hand. What the measurement did *not* dissolve are the two cases no diff can see — +`stone_slab` and `redstone_wire` — and those are the reason the rules are versioned and keyed on the +whole state. The gap is closed; the mechanism it forced remains. + +**Block entity renames have no source at all.** As noted above, the 1.13 file carries no +`blockentities` list, so the difference that settles the block question cannot be computed for them. +Since block entities are inside this slice, this is not a limit to note and move past — it is work +the plan has to find another route to. Reading the target version's list of 49 and checking each +against what a 1.13 world can contain is one such route; it is small enough to be done exhaustively. + +## Open for the plan + +One question genuinely remains, because it is about the build rather than the design: where the +vendored mappings live and whether the engine reads them from the classpath or from a path the caller +supplies. Both work; the choice affects packaging and the batch runner's startup, and belongs with +the plan's file structure. + +**Deliberately settled here, so the plan does not reopen them:** the loader hook converts on every +load and does **not** write the result back — that would turn a read path into a write path, and #45 +was written precisely because a read path that writes is how real data gets lost. Persisting a +conversion is the batch runner's job, which is why there is one. diff --git a/falco-anvil/build.gradle.kts b/falco-anvil/build.gradle.kts index cf88148..6500f80 100644 --- a/falco-anvil/build.gradle.kts +++ b/falco-anvil/build.gradle.kts @@ -12,6 +12,13 @@ dependencies { testImplementation(libs.annotations) testImplementation(libs.minestom) testImplementation(libs.cyano) + // Test-only, one-directional: falco-migration's own main sources already depend on this + // module's main sources, and this does not add a cycle, only a test-classpath dependency the + // other way. It exists for MigrationRoundTripTest, the round trip from ChunkMigration's output + // through a real region file into this module's own loader — the acceptance test the final + // review found missing, and which cannot live in falco-migration itself: falco-archunit's + // migrationKnowsNoMinestom rule forbids that module from depending on net.minestom at all. + testImplementation(project(":falco-migration")) testImplementation(libs.junit.jupiter) testImplementation(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.jupiter.engine) diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/MigrationEnginePropertyDefaultTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/MigrationEnginePropertyDefaultTest.java new file mode 100644 index 0000000..7cecfad --- /dev/null +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/MigrationEnginePropertyDefaultTest.java @@ -0,0 +1,75 @@ +package net.onelitefeather.falco.anvil; + +import net.minestom.server.instance.block.Block; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Pins the assumption {@code falco-migration}'s Task 7 (Step 3 of the acceptance plan) relies on but + * deliberately does not implement: for the 258 block states where a Minecraft 1.13 source omits a + * property the target block gained later, the correct value is the target version's own default + * for that block, resolved by Minestom itself — never a hardcoded {@code false}. + *

+ * This is not the obvious rule. Nearly every waterloggable block defaults {@code waterlogged} to + * {@code false} — fences, stairs, walls — which makes {@code false} look like a safe universal + * substitute. It is not: every coral variant and the conduit default to {@code waterlogged=true}, + * because they cannot exist outside water in the first place. A converter that filled the gap with + * {@code false} for every block would quietly dry out every reef and beach every conduit in a + * migrated world, and the chunk would still load without complaint. + *

+ *

+ * The exact call this test exercises, {@code Block.fromKey(name).withProperties(partialProperties)}, + * is the same one {@link BlockPaletteResolver#toId} already uses in production — so this pins + * Minestom's real resolution path, not a hypothetical one. + *

+ *

+ * This test lives in {@code falco-anvil} rather than {@code falco-migration} because resolving a + * default requires {@link Block}, and {@code falco-migration}'s own ArchUnit rule + * ({@code MigrationBoundaryTest.migrationKnowsNoMinestom}) forbids that module from depending on + * Minestom at all — the boundary is worth more than the convenience of putting this test next to the + * engine it documents. + *

+ *

+ * The defaults asserted below are cross-checked against {@code net.minestom:data}'s own + * {@code block.json} (the {@code defaultStateId} recorded for {@code minecraft:tube_coral}, + * {@code minecraft:conduit}, and {@code minecraft:oak_fence} each resolve to the state asserted + * here) rather than assumed from general Minecraft knowledge. + *

+ */ +@ExtendWith(MicrotusExtension.class) +class MigrationEnginePropertyDefaultTest { + + @Test + void testAConvertedCoralWithoutWaterloggedEndsUpWaterloggedTrue() { + Block coral = Block.fromKey("minecraft:tube_coral").withProperties(Map.of()); + + assertEquals("true", coral.properties().get("waterlogged"), + "a 1.13 coral entry that carries no waterlogged property must resolve to Minestom's " + + "own default for the target block, which is true for coral - not a " + + "hardcoded false"); + } + + @Test + void testAConvertedConduitWithoutWaterloggedEndsUpWaterloggedTrue() { + Block conduit = Block.fromKey("minecraft:conduit").withProperties(Map.of()); + + assertEquals("true", conduit.properties().get("waterlogged"), + "a conduit is not a coral subtype - this is a second, independently sourced fact, " + + "not a corollary of the coral case above"); + } + + @Test + void testAnOrdinaryWaterloggableBlockWithoutWaterloggedEndsUpWaterloggedFalse() { + Block fence = Block.fromKey("minecraft:oak_fence").withProperties(Map.of()); + + assertEquals("false", fence.properties().get("waterlogged"), + "the contrast case: for most blocks the target default really is false, so the rule " + + "a migrated chunk must follow is \"the target version's default\", never a " + + "rule that would look like \"always true\" if only coral were checked"); + } +} diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/MigrationRoundTripTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/MigrationRoundTripTest.java new file mode 100644 index 0000000..c5bf06d --- /dev/null +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/MigrationRoundTripTest.java @@ -0,0 +1,166 @@ +package net.onelitefeather.falco.anvil; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.nbt.BinaryTagIO; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.ListBinaryTag; +import net.minestom.server.MinecraftServer; +import net.minestom.server.instance.Chunk; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.falco.migration.ChunkMigration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * The acceptance test of {@code falco-migration}: a chunk {@link ChunkMigration} converts is only + * useful if {@link FalcoAnvilLoader} — the loader that actually has to read a converted world back — + * can load it. + *

+ * Every other test either module has asserts key by key against an in-memory {@link CompoundBinaryTag}. + * That is exactly the gap the final review found: {@code NamespaceStatus} namespaced a chunk status + * without translating its value, and nothing upstream of the loader noticed, because nothing exercised + * the loader at all. This test instead runs the whole pipeline a converted world actually goes + * through — a genuine 1.13 chunk, through the nine-step {@link ChunkMigration} chain, written into a + * real region file exactly {@link FalcoAnvilLoaderIntegrationTest}'s own {@code writeRawChunk} helper + * does, then loaded back through the production {@link FalcoAnvilLoader} — and checks that a block + * placed by the original 1.13 chunk actually arrives. Both the chunk's {@code Status} value + * ({@code postprocessed}, not {@code full} — see {@code NamespaceStatusTest} in {@code falco-migration} + * for why) and its packed block data (built by hand in the pre-1.16 boundary-spanning layout, the + * shape {@code NormaliseBitPacking} exists to re-pack) are the real, sourced 1.13 shapes, not stand-ins. + */ +@ExtendWith(MicrotusExtension.class) +class MigrationRoundTripTest { + + private static final Key OVERWORLD = Key.key("minecraft:overworld"); + private static final int SECTION_BLOCK_ENTRIES = 16 * 16 * 16; + + @TempDir + private Path worldRoot; + + @Test + void testANineteenThirteenChunkMigratedAndReloadedThroughTheProductionLoaderKeepsItsBlocks(Env env) throws Exception { + // A 2-entry palette (air, stone) packed at 4 bits per entry (BitPacker.bitsPerEntry(2, 4)), + // in the pre-1.16 boundary-spanning layout every DataVersion below 2529 actually wrote. Every + // block in the section is air (palette index 0) except one, index 0 of the packed array + // itself (local x=0, y=0, z=0), which is stone (palette index 1). + int[] values = new int[SECTION_BLOCK_ENTRIES]; + values[0] = 1; + long[] legacyPacked = legacyPack(values, 4); + + CompoundBinaryTag section = CompoundBinaryTag.builder() + .putByte("Y", (byte) 2) + .put("Palette", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putString("Name", "minecraft:air").build(), + CompoundBinaryTag.builder().putString("Name", "minecraft:stone").build()))) + .putLongArray("BlockStates", legacyPacked) + .build(); + + CompoundBinaryTag legacyChunk = CompoundBinaryTag.builder() + .putInt("DataVersion", 1519) // Minecraft 1.13 release, ChunkMigration's own floor + .put("Level", CompoundBinaryTag.builder() + .putInt("xPos", 5) + .putInt("zPos", 5) + .putString("Status", "postprocessed") // the real 1.13 terminal status + .put("Sections", ListBinaryTag.from(List.of(section))) + .build()) + .build(); + + CompoundBinaryTag migrated = ChunkMigration.migrate(legacyChunk, MinecraftServer.DATA_VERSION); + writeRawChunk(5, 5, migrated); + + try (FalcoAnvilLoader loader = new FalcoAnvilLoader(this.worldRoot, OVERWORLD)) { + Instance instance = env.createEmptyInstance(loader); + Chunk loaded = loader.loadChunk(instance, 5, 5); + + assertNotNull(loaded, "the loader's own status guard must accept the migrated chunk's " + + "translated status (minecraft:full), not just its namespace " + + "(minecraft:postprocessed would still be refused)"); + assertEquals(Block.STONE, blockAt(loaded, 0, 32, 0), + "the one block the 1.13 chunk actually placed must survive the whole round trip"); + assertEquals(Block.AIR, blockAt(loaded, 1, 32, 0), + "every other block in the section must stay air, proving the packed indices " + + "themselves, not just the palette, survived"); + } + } + + /** + * Writes chunk data straight into the region file of the temporary world, exactly + * {@link FalcoAnvilLoaderIntegrationTest}'s own private helper of the same name does — duplicated + * here rather than shared, since that helper is {@code private} to its own test class. + * + * @param chunkX the absolute chunk x coordinate + * @param chunkZ the absolute chunk z coordinate + * @param data the chunk data to store + * @throws Exception if the chunk cannot be written + */ + private void writeRawChunk(int chunkX, int chunkZ, CompoundBinaryTag data) throws Exception { + Path directory = this.worldRoot.resolve("dimensions/minecraft/overworld/region"); + Files.createDirectories(directory); + ByteArrayOutputStream target = new ByteArrayOutputStream(); + BinaryTagIO.writer().writeNamed( + Map.entry("", data), target, BinaryTagIO.Compression.NONE + ); + + try (RegionFile file = RegionFile.open(directory.resolve("r." + (chunkX >> 5) + "." + (chunkZ >> 5) + ".mca"))) { + file.writeRaw(chunkX, chunkZ, ChunkCompression.ZLIB, ChunkCompression.ZLIB.compress(target.toByteArray())); + } + } + + /** + * Reads a block of the given chunk while holding its read lock, exactly + * {@link FalcoAnvilLoaderIntegrationTest}'s own private helper of the same name does. + * + * @param chunk the chunk to read + * @param x the x coordinate inside the chunk + * @param y the y coordinate of the block + * @param z the z coordinate inside the chunk + * @return the block at the given position + */ + private static Block blockAt(Chunk chunk, int x, int y, int z) { + chunk.lockReadLock(); + try { + return chunk.getBlock(x, y, z); + } finally { + chunk.unlockReadLock(); + } + } + + /** + * Packs {@code values} using the pre-1.16 layout, in which an entry is allowed to span a long + * boundary — the same fixture helper {@code SectionStepsTest} uses in {@code falco-migration}, + * duplicated here rather than shared across modules for the same reason + * {@code FalcoAnvilLoaderIntegrationTest.writeRawChunk} is duplicated rather than exposed. + */ + private static long[] legacyPack(int[] values, int bitsPerEntry) { + long totalBits = (long) values.length * bitsPerEntry; + long[] packed = new long[(int) ((totalBits + 63) / 64)]; + long mask = (1L << bitsPerEntry) - 1L; + + for (int index = 0; index < values.length; index++) { + long bitOffset = (long) index * bitsPerEntry; + int longIndex = (int) (bitOffset / 64); + int bitInLong = (int) (bitOffset % 64); + long value = values[index] & mask; + + packed[longIndex] |= value << bitInLong; + int bitsWrittenInFirstLong = 64 - bitInLong; + if (bitsWrittenInFirstLong < bitsPerEntry) { + packed[longIndex + 1] |= value >>> bitsWrittenInFirstLong; + } + } + return packed; + } +} diff --git a/falco-archunit/build.gradle.kts b/falco-archunit/build.gradle.kts index 4b13966..3f6722e 100644 --- a/falco-archunit/build.gradle.kts +++ b/falco-archunit/build.gradle.kts @@ -6,6 +6,7 @@ dependencies { testImplementation(project(":falco-light")) testImplementation(project(":falco-instance")) testImplementation(project(":falco-demo")) + testImplementation(project(":falco-migration")) testImplementation(libs.minestom) testImplementation(libs.annotations) diff --git a/falco-archunit/src/test/java/net/onelitefeather/falco/architecture/MigrationBoundaryTest.java b/falco-archunit/src/test/java/net/onelitefeather/falco/architecture/MigrationBoundaryTest.java new file mode 100644 index 0000000..21b1453 --- /dev/null +++ b/falco-archunit/src/test/java/net/onelitefeather/falco/architecture/MigrationBoundaryTest.java @@ -0,0 +1,36 @@ +package net.onelitefeather.falco.architecture; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * Guards the promise that {@code falco-migration} converts stored NBT without a running server. + * + *

The engine has to be usable before anything boots: a world is upgraded on disk, offline, and + * only afterwards handed to a server that can load it. Nothing in the build enforces that today — a + * single {@code import net.minestom....} would compile happily, because the compiler cannot tell + * "runs on a server" from "runs on stored bytes" for us. This rule reads the bytecode instead and + * fails the moment that boundary is crossed. + */ +@AnalyzeClasses( + packages = "net.onelitefeather.falco", + importOptions = ImportOption.DoNotIncludeTests.class) +class MigrationBoundaryTest { + + private static final String MIGRATION = "net.onelitefeather.falco.migration.."; + + /** + * The engine converts stored NBT and must run without a server, which is what lets a world be + * converted before anything boots. + */ + @ArchTest + static final ArchRule migrationKnowsNoMinestom = noClasses() + .that().resideInAPackage(MIGRATION) + .should().dependOnClassesThat().resideInAnyPackage("net.minestom..") + .because("the engine converts stored NBT and must run without a server, which is what " + + "lets a world be converted before anything boots"); +} diff --git a/falco-migration/build.gradle.kts b/falco-migration/build.gradle.kts new file mode 100644 index 0000000..541e63c --- /dev/null +++ b/falco-migration/build.gradle.kts @@ -0,0 +1,26 @@ +description = "Converts stored Anvil chunk data from Minecraft 1.13 upwards" + +dependencies { + implementation(platform(libs.mycelium.bom)) + implementation(libs.slf4j.api) + implementation(project(":falco-anvil")) + + compileOnly(platform(libs.adventure.bom)) + compileOnly(libs.adventure.nbt) + compileOnly(libs.annotations) + + testImplementation(platform(libs.adventure.bom)) + testImplementation(libs.adventure.nbt) + testImplementation(libs.annotations) + testImplementation(libs.junit.jupiter) + testImplementation(libs.junit.platform.launcher) + testRuntimeOnly(libs.junit.jupiter.engine) +} + +// This module gets -Werror on javadoc from the root build, but nothing otherwise calls javadoc for +// it: unlike falco-anvil/-light/-instance it has no withJavadocJar(), on purpose (no publishing and +// no japicmp baseline for a module that has never been released — see the design doc). Without this, +// a broken javadoc comment would compile clean and pass check regardless. +tasks.named("check") { + dependsOn(tasks.named("javadoc")) +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockState.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockState.java new file mode 100644 index 0000000..45fe0b7 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockState.java @@ -0,0 +1,42 @@ +package net.onelitefeather.falco.migration; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.Map; + +/** + * A block state as stored in Anvil chunk NBT since Minecraft 1.13: a namespaced block name together + * with its property map. + * + * @param name the namespaced block identifier, for example {@code "minecraft:oak_log"} + * @param properties the block's properties, copied into an unmodifiable map by the canonical + * constructor + * @since 2.1.0 + */ +@ApiStatus.Experimental +public record BlockState(String name, @Unmodifiable Map properties) { + + /** + * Copies {@code properties} so that the record is immutable regardless of what the caller does + * with the map afterwards. + * + * @param name the namespaced block identifier + * @param properties the block's properties, copied rather than retained + */ + public BlockState { + properties = Map.copyOf(properties); + } + + /** + * Creates a block state with no properties. + * + * @param name the namespaced block identifier + * @return a block state named {@code name} with an empty property map + */ + @Contract(pure = true) + public static BlockState of(String name) { + return new BlockState(name, Map.of()); + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRule.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRule.java new file mode 100644 index 0000000..1db90d8 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRule.java @@ -0,0 +1,64 @@ +package net.onelitefeather.falco.migration; + +import org.jetbrains.annotations.ApiStatus; + +/** + * One versioned transformation from an older {@link BlockState} to its later form. + *

+ * A rule is keyed on the whole state, never on a single property: {@link #matches(BlockState)} and + * {@link #apply(BlockState)} both see the complete name-plus-properties pair, because some changes + * cannot be expressed any narrower. A cauldron's new block name is decided by its {@code level} + * property, and a wall's new value for one direction would need the direction alone in a + * property-by-property model — the whole state is the smallest unit that stays correct. + *

+ *

+ * A rule also carries the {@link #since()} version it belongs to, because the same block name can + * mean different things on either side of a version boundary. {@code stone_slab} is the case that + * forces this: unversioned, a rename rule for it would corrupt a world that already has the + * newer meaning. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public interface BlockStateRule { + + /** + * The {@code DataVersion} in which the change this rule encodes happened. + *

+ * This is the version a fix landed in, not the version the rule targets: a rule applies to a + * chunk's source version exactly when the source is older than this number, i.e. when + * {@code since() > sourceVersion}. A rule with {@code since() == 1901} — {@code stone_slab}'s own + * rule in {@link BlockStateRules}, DataVersion 1901, snapshot 18w43a — therefore applies to a + * 1.13 world ({@code 1519 < 1901}) and leaves a 1.16 world ({@code 2566 > 1901}) alone, because + * by 1.16 the change already happened and the state already carries its later meaning. + *

+ * + * @return the {@code DataVersion} the change happened in + */ + int since(); + + /** + * Whether this rule has anything to say about {@code state}. + *

+ * Called after every rule that ran before this one in version order, so {@code state} may + * already differ from the state a chunk originally stored. + *

+ * + * @param state the state to test + * @return {@code true} if {@link #apply(BlockState)} should run on {@code state} + */ + boolean matches(BlockState state); + + /** + * Transforms {@code state} into its later form. + *

+ * Only called for a state {@link #matches(BlockState)} accepted. May change the block name, the + * properties, or both — a rule is not required to keep the name stable. + *

+ * + * @param state a state for which {@link #matches(BlockState)} returned {@code true} + * @return the transformed state + */ + BlockState apply(BlockState state); +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java new file mode 100644 index 0000000..65e8c5c --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java @@ -0,0 +1,359 @@ +package net.onelitefeather.falco.migration; + +import org.jetbrains.annotations.ApiStatus; + +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; + +/** + * The block-state facts measured for the span from Minecraft 1.13 (DataVersion 1519) to today, and + * the rule that resolves them against a chunk's source version. + *

+ * Every rule below carries a comment naming where it came from — a PaperMC/DataConverter fix + * version, or a diff computed directly over the vendored registry lists — because none of them is + * written from memory; see + * {@code docs/superpowers/specs/2026-08-04-blockstate-property-research.md} for the full + * measurement. One fact from that measurement, {@code redstone_wire}, took a second pass beyond it + * to land here safely; see the note above {@link #RULES} for how. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class BlockStateRules { + + private static final Set WALL_BLOCKS = + Set.of("minecraft:cobblestone_wall", "minecraft:mossy_cobblestone_wall"); + + /** + * The four wall directions this module rewrites. {@code up} is deliberately excluded: it exists + * unchanged in both 1.13 and today, and the 20w06a render change concerns {@code up}, not these + * four. Confirmed against DataConverter V2503 and Chunker's equivalent lookup, both of which + * leave {@code up} untouched. + */ + private static final List WALL_SIDES = List.of("north", "south", "east", "west"); + + /** + * The versioned facts this module encodes, kept in ascending {@link BlockStateRule#since()} + * order for readability — {@link #translate(BlockState, int)} sorts its own copy regardless, so + * this ordering is not load-bearing. + *

+ * {@code redstone_wire} (DataVersion 2532, snapshot 20w18a, Minecraft 1.16, 144 of 1296 + * states) took a second pass to land in this list. The research document establishes only the + * shape of the change — a direction's new value depends on the other three directions of the + * same state, a per-property implementation is guaranteed wrong, and 9 of the 81 direction + * combinations change (times 16 {@code power} values = 144 states) — not which nine or what they + * become. That was not enough on its own: a guessed whole-state table for redstone wiring would + * have been exactly the silent corruption this module exists to avoid, the chunk still loads, the + * wiring looks subtly different, and nobody notices. The rule below closes that gap directly from + * the connection semantics: a direction becomes {@code side} exactly when it is itself + * unconnected AND the axis perpendicular to it has no connection either — {@code north}/ + * {@code south} are decided by whether {@code east}/{@code west} connects, {@code east}/ + * {@code west} by whether {@code north}/{@code south} connects. The axes are crossed on purpose, + * not mirrored, and getting that backwards produces a rule that looks plausible and is wrong; see + * {@code BlockStateRulesTest} for the nine affected combinations this guards against. It was + * cross-checked against DataConverter's V2531, Yarn's {@code RedstoneConnectionsFix}, and + * Chunker's {@code JavaLegacyRedstonePreTransformHandler} — three independently maintained + * reimplementations of the same 1.16 change — all of which agree with the derivation above. + * {@code power} is a plain multiplier and is neither read nor written by this rule. + *

+ *

+ * A state this module does not otherwise recognize passes through + * {@link #translate(BlockState, int)} unchanged; see + * {@code BlockStateRulesTest.testAStateNoRuleKnowsAboutPassesThroughUnchanged}. + *

+ */ + private static final List RULES = List.of( + // sign -> oak_sign. Oak was the only wood type a sign could be in 1.13, so this is a + // plain rename with no ambiguity. + // Source for the RENAME ITSELF: blockstate-property-research.md, "Widerspruch 2" (names + // the snapshot "18w43a", not a DataVersion integer — the research document's own "V1802" + // for this fact turned out to be wrong; see the NUMBER source below). + // Source for the NUMBER 1901: not the research document. minecraft.wiki's own changelog + // for snapshot 18w43a states the rename directly ("Renamed 'Sign' to 'Oak Sign'."), and + // that snapshot's infobox lists DataVersion 1901 — checked via two independent fetches, + // 2026-08-04. This replaces the research document's "V1802", which does not correspond to + // any named public snapshot or release — 1631 (1.13.2) and 1901 (18w43a) are the nearest + // named points, and 1802 sits in the unnamed gap between them. Unlike the grass/grass_path + // corrections, this error made the rule fire too LATE rather than too early: a source + // between 1802 and 1900 still stored the pre-rename name "sign" but the old, too-low + // threshold would have left it untranslated, and Minestom throws a NullPointerException on + // an unknown block name rather than tolerating it. + renameRule("minecraft:sign", "minecraft:oak_sign", 1901), + + // wall_sign -> oak_wall_sign, same reasoning and same NUMBER correction as sign above + // (1802 -> 1901). Unlike sign and stone_slab, no changelog text names "wall_sign" + // explicitly in 18w43a's patch notes — searched directly and found nothing. The snapshot + // attribution is inferred rather than directly quoted: wall_sign is the placement-derived + // variant of sign, both are part of the same wood-type ID-prefixing pass that shipped in + // one snapshot, and the research document groups the two facts under one citation even + // after its number turned out to be wrong. This is weaker sourcing than sign's and + // stone_slab's direct changelog quotes, and is flagged as such rather than presented as + // equally solid. + renameRule("minecraft:wall_sign", "minecraft:oak_wall_sign", 1901), + + // stone_slab -> smooth_stone_slab. The name is reused by a different block from 1.14 on + // (1.13's stone_slab and 26.x's stone_slab both have 6 states, but they mean different + // blocks), which is why this rule MUST be versioned: unversioned, it would rename a + // 1.16+ world's already-correct stone_slab and corrupt it. + // Source for the RENAME ITSELF: blockstate-property-research.md, section "A) Die Zahl", + // two-case table (names the snapshot "18w43a"; verified 1.13 stone_slab = 6 states, 26.3 + // stone_slab AND smooth_stone_slab each = 6 states). + // Source for the NUMBER 1901: same as sign above — minecraft.wiki's 18w43a changelog + // states "Stone slabs have been renamed to smooth stone slabs." directly, and the + // snapshot's infobox lists DataVersion 1901, replacing the research document's wrong + // "V1802". This is the case the whole versioning mechanism exists for, so its own boundary + // is pinned by a dedicated test rather than only the two far-apart sources 1519 and 2566; + // see BlockStateRulesTest.testARuleAppliesExactlyBelowItsOwnVersionAndNotAtOrAboveIt. + renameRule("minecraft:stone_slab", "minecraft:smooth_stone_slab", 1901), + + // cobblestone_wall / mossy_cobblestone_wall: north/south/east/west go from a boolean + // (false/true) to none/low/tall, as a pure lookup (true -> low, false -> none). `tall` + // is not reachable from a 1.13 state and Mojang's own fix does not attempt it either. + // Source for the RENAME ITSELF: blockstate-property-research.md, "Widerspruch 3" (names + // the snapshot family as "1.16"; confirmed no neighbor/chunk context is needed, contrary + // to what the wiki route alone suggested). + // Source for the NUMBER 2504: not the research document, whose "V2503" is off by one from + // any named snapshot. minecraft.wiki's own changelog for snapshot 20w06a states the change + // directly ("Block state now uses none, low, and tall for east, west, north, and south + // directional values."), and that snapshot's infobox lists DataVersion 2504 — checked via + // two independent fetches, 2026-08-04. + wallRule(2504), + + // redstone_wire: north/south/east/west each flip from their raw connection value to + // "side" exactly when two conditions both hold — the direction itself is unconnected + // ("none"), and the axis PERPENDICULAR to it has no connection of its own. north/south + // are decided by east/west; east/west are decided by north/south. Implementing this + // gleichachsig (north/south checked against north/south) produces a rule that reads fine + // and is wrong for every asymmetric case; see BlockStateRulesTest for the nine + // combinations that catch exactly that mistake. "up" counts as connected for this check + // but is itself never rewritten — it has no opposite in this scheme. "power" is a plain + // multiplier, read by nothing here and written by nothing here. + // Source for the RULE ITSELF: not the research document, whose measurement gave the size + // of the change (9 of 81 direction combinations, times 16 power values) but not which + // nine or what they become. The connection semantics above were derived directly and then + // cross-checked against DataConverter's V2531, Yarn's RedstoneConnectionsFix, and + // Chunker's JavaLegacyRedstonePreTransformHandler — three independently maintained + // reimplementations of the same 1.16 change — all of which agree. + // Source for the NUMBER 2532: not the research document, whose "V2531" names a fix + // version that lands between two public snapshots rather than at either one, and whose + // snapshot label "20w17a" (DataVersion 2529) turned out not to match: that snapshot's own + // changelog has nothing about redstone wire. 20w18a's changelog and its dedicated + // Redstone Dust history entry state the change directly — "Unconnected redstone dust now + // has all direction block states set to 'side'" and direction states are "properly set to + // 'side' at the end of a redstone wire on both ends, rather than only the one with other + // redstone besides it" — word for word the derivation above. minecraft.wiki's data-version + // table and PrismarineJS/minecraft-data's protocolVersions.json, two independent sources, + // both give 20w18a DataVersion 2532 — checked via independent fetches, 2026-08-05. This + // corrects the research document's guess by exactly one snapshot, the same + // release-vs-actual-snapshot class of error as grass_path and grass below, just one + // snapshot early rather than landing on the final release. + redstoneWireRule(2532), + + // cauldron[level]: level=0 -> cauldron (properties emptied, the property is dropped + // entirely); level=1..3 -> water_cauldron[level=n]. The block name is decided by a + // property value, which is why this rule changes the name rather than only a value — + // and exactly why the case was invisible to a plain name diff. + // Source for the RENAME ITSELF: blockstate-property-research.md, section "A) Die Zahl", + // row "(c) Property weggefallen" (names the snapshot family as "1.17"). + // Source for the NUMBER 2681: not the research document, whose "V2679" names neither the + // right number nor (per a table lookup that turned out to be unreliable) the right + // snapshot — 21w03a, DataVersion 2689, whose changelog has nothing about cauldrons beyond + // a subtitle capitalization fix. minecraft.wiki's own changelog for snapshot 20w45a states + // the split directly ("Have been split into normal, water and lava cauldrons."), and that + // snapshot's infobox lists DataVersion 2681 — checked via three independent fetches, + // 2026-08-04. This is the same snapshot as grass_path's rename below. + cauldronRule(2681), + + // grass_path -> dirt_path, Minecraft 1.17. + // Source for the RENAME ITSELF: falco-migration-design.md, "What the registry lists do + // settle" table (names the Minecraft version, "1.17", not a DataVersion integer). + // Source for the NUMBER 2681: not in this repository at all. minecraft.wiki's own + // changelog for snapshot 20w45a states the rename directly ("'Grass Path' was renamed to + // 'Dirt Path'"), and that snapshot's infobox lists DataVersion 2681 — checked via two + // independent fetches, 2026-08-04. This replaces an earlier, wrong value of 2724 (1.17's + // *final release* DataVersion): the rule must carry the version the change happened in, + // per since()'s contract, and the change happened 43 versions earlier, in the snapshot, + // not at release. Using 2724 caused no test failure here only because grass_path is never + // reused for anything else afterward, unlike stone_slab — but it was still the wrong + // number for what since() claims to mean. + renameRule("minecraft:grass_path", "minecraft:dirt_path", 2681), + + // grass -> short_grass, Minecraft 1.20.3. + // Source for the RENAME ITSELF: falco-migration-design.md, "What the registry lists do + // settle" table (names the Minecraft version, "1.20.3", not a DataVersion integer). + // Source for the NUMBER 3693: not in this repository at all. minecraft.wiki's own + // changelog for "Java Edition 1.20.3 Pre-Release 1" states the rename directly + // ("Renamed 'Grass' to 'Short Grass'. The ID has been changed from `grass` to + // `short_grass`."), and that pre-release's infobox lists DataVersion 3693 — checked via + // two independent fetches, 2026-08-04. This replaces an earlier, wrong value of 3698 + // (1.20.3's *final release* DataVersion), the same release-vs-snapshot mistake as + // grass_path above: the change happened 5 versions before the release it shipped in. + renameRule("minecraft:grass", "minecraft:short_grass", 3693) + ); + + private static final List RULES_BY_VERSION = + RULES.stream().sorted(Comparator.comparingInt(BlockStateRule::since)).toList(); + + private BlockStateRules() { + } + + /** + * Applies every known rule whose change happened after {@code sourceVersion}, in ascending + * version order, so a state may pass through more than one rule. + *

+ * A rule applies exactly when {@code rule.since() > sourceVersion}: {@link BlockStateRule#since()} + * names the {@code DataVersion} the change happened in, so a source strictly older than that + * version has not seen the change yet and needs it, while a source at or after that version + * already carries the change's meaning and must be left alone. A rule with + * {@code since() == 1901} therefore applies to a 1.13 world ({@code 1519 < 1901}) and not to a + * 1.16 world ({@code 2566 > 1901}). + *

+ *

+ * A state no rule recognizes passes through unchanged. + *

+ * + * @param state the state as read from the source chunk, or as already transformed by an + * earlier rule in this same call + * @param sourceVersion the chunk's {@code DataVersion} + * @return the translated state; {@code state} itself if no rule matched + */ + public static BlockState translate(BlockState state, int sourceVersion) { + BlockState current = state; + for (BlockStateRule rule : RULES_BY_VERSION) { + if (rule.since() > sourceVersion && rule.matches(current)) { + current = rule.apply(current); + } + } + return current; + } + + private static BlockStateRule renameRule(String from, String to, int since) { + return new Rule(since, state -> state.name().equals(from), + state -> new BlockState(to, state.properties())); + } + + private static BlockStateRule wallRule(int since) { + return new Rule(since, state -> WALL_BLOCKS.contains(state.name()), BlockStateRules::rewriteWallSides); + } + + private static BlockState rewriteWallSides(BlockState state) { + Map properties = new LinkedHashMap<>(state.properties()); + for (String side : WALL_SIDES) { + String value = properties.get(side); + if (value != null) { + properties.put(side, "true".equals(value) ? "low" : "none"); + } + } + return new BlockState(state.name(), properties); + } + + private static BlockStateRule redstoneWireRule(int since) { + return new Rule(since, + state -> state.name().equals("minecraft:redstone_wire"), + BlockStateRules::rewriteRedstoneWireConnections); + } + + /** + * Recomputes {@code redstone_wire}'s four direction properties from each other, crossing the + * axes on purpose: whether {@code north}/{@code south} collapse to {@code side} is decided by + * {@code east}/{@code west}'s connection state, and whether {@code east}/{@code west} collapse is + * decided by {@code north}/{@code south}'s. All four outputs are computed from the original, + * unmodified values first and only written afterward, so an earlier write in this same call can + * never leak into a later read — sequential writing gets the all-{@code none} case wrong. + * + * @param state a {@code redstone_wire} state as read from the source chunk + * @return {@code state} with its four direction properties resolved to their later meaning + */ + private static BlockState rewriteRedstoneWireConnections(BlockState state) { + Map properties = state.properties(); + String north = properties.get("north"); + String south = properties.get("south"); + String east = properties.get("east"); + String west = properties.get("west"); + + boolean eastWestConnected = isRedstoneConnected(east) || isRedstoneConnected(west); + boolean northSouthConnected = isRedstoneConnected(north) || isRedstoneConnected(south); + + Map rewritten = new LinkedHashMap<>(properties); + if (north != null) { + rewritten.put("north", resolveRedstoneSide(north, eastWestConnected)); + } + if (south != null) { + rewritten.put("south", resolveRedstoneSide(south, eastWestConnected)); + } + if (east != null) { + rewritten.put("east", resolveRedstoneSide(east, northSouthConnected)); + } + if (west != null) { + rewritten.put("west", resolveRedstoneSide(west, northSouthConnected)); + } + return new BlockState(state.name(), rewritten); + } + + /** + * Whether a {@code redstone_wire} direction counts as connected. {@code up} counts here — it is + * a connection, just one this rule never overwrites — and only {@code none} does not. + * + * @param value a direction's raw property value, or {@code null} if the state omits it + * @return {@code true} unless {@code value} is {@code null} or {@code "none"} + */ + private static boolean isRedstoneConnected(String value) { + return value != null && !"none".equals(value); + } + + /** + * Resolves one {@code redstone_wire} direction: it becomes {@code side} exactly when it is + * itself unconnected and the perpendicular axis carries no connection either, otherwise it keeps + * its original value — including {@code up}, which this never touches. + * + * @param value the direction's original value + * @param perpendicularAxisHasConnection whether the OTHER axis (not this direction's own) has a + * connection anywhere on it + * @return {@code "side"} or {@code value} unchanged + */ + private static String resolveRedstoneSide(String value, boolean perpendicularAxisHasConnection) { + return (!isRedstoneConnected(value) && !perpendicularAxisHasConnection) ? "side" : value; + } + + private static BlockStateRule cauldronRule(int since) { + return new Rule(since, + state -> state.name().equals("minecraft:cauldron") && state.properties().containsKey("level"), + BlockStateRules::rewriteCauldronLevel); + } + + private static BlockState rewriteCauldronLevel(BlockState state) { + String level = state.properties().get("level"); + if ("0".equals(level)) { + return new BlockState("minecraft:cauldron", Map.of()); + } + return new BlockState("minecraft:water_cauldron", Map.of("level", level)); + } + + /** + * A {@link BlockStateRule} built from a version, a match predicate and a transform, so the + * individual facts above can be written as data rather than as one class each. + * + * @param since the {@code DataVersion} the change happened in + * @param matcher decides whether {@code transform} applies to a given state + * @param transform the state transformation itself + */ + private record Rule(int since, Predicate matcher, UnaryOperator transform) + implements BlockStateRule { + + @Override + public boolean matches(BlockState state) { + return this.matcher.test(state); + } + + @Override + public BlockState apply(BlockState state) { + return this.transform.apply(state); + } + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/ChunkMigration.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/ChunkMigration.java new file mode 100644 index 0000000..8dbfdc5 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/ChunkMigration.java @@ -0,0 +1,124 @@ +package net.onelitefeather.falco.migration; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.onelitefeather.falco.migration.steps.CountEntities; +import net.onelitefeather.falco.migration.steps.DiscardHeightmapsAndLight; +import net.onelitefeather.falco.migration.steps.NamespaceStatus; +import net.onelitefeather.falco.migration.steps.NormaliseBitPacking; +import net.onelitefeather.falco.migration.steps.RebuildBiomes; +import net.onelitefeather.falco.migration.steps.SettleYRange; +import net.onelitefeather.falco.migration.steps.TranslateBlockEntities; +import net.onelitefeather.falco.migration.steps.TranslateBlockStates; +import net.onelitefeather.falco.migration.steps.UnfoldLevel; +import org.jetbrains.annotations.ApiStatus; + +import java.util.List; + +/** + * Runs the whole step chain over one chunk's root compound, from its stored {@code DataVersion} up to + * a target version. + *

+ * {@link #migrate(CompoundBinaryTag, int)} reads {@code DataVersion} off the chunk and delegates to + * {@link #migrate(CompoundBinaryTag, MigrationContext)}, which declines a chunk older than + * {@link #MINIMUM_SOURCE_VERSION} with a {@link MigrationException}, runs every {@link MigrationStep} + * whose {@link MigrationStep#appliesTo(int)} accepts the source version in the chain's declared order, + * and finally stamps the target version onto the result. + *

+ *

+ * This chain wires every step of the design's table: {@link NormaliseBitPacking} (step 1), + * {@link CountEntities} (step 2), {@link UnfoldLevel} (step 3), {@link RebuildBiomes} (step 4), + * {@link SettleYRange} (step 5), {@link NamespaceStatus} (step 6), {@link TranslateBlockStates} and + * {@link TranslateBlockEntities} (step 7's block and block-entity halves) and + * {@link DiscardHeightmapsAndLight} (step 8), in that order. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class ChunkMigration { + + /** + * The lowest {@code DataVersion} this engine accepts: 1519, the release version of Minecraft + * 1.13, the version that introduced the palette-based block state format every step in this + * module assumes. + */ + public static final int MINIMUM_SOURCE_VERSION = 1519; + + private static final String DATA_VERSION_KEY = "DataVersion"; + + private static final List STEPS = List.of( + new NormaliseBitPacking(), + new CountEntities(), + new UnfoldLevel(), + new RebuildBiomes(), + new SettleYRange(), + new NamespaceStatus(), + new TranslateBlockStates(), + new TranslateBlockEntities(), + new DiscardHeightmapsAndLight()); + + private ChunkMigration() { + } + + /** + * Converts one chunk's root compound from the {@code DataVersion} it was stored with to + * {@code targetVersion}. + * + * @param chunk the chunk's root compound, as read from a region file + * @param targetVersion the {@code DataVersion} the result should carry + * @return the converted chunk, stamped with {@code targetVersion} + * @throws MigrationException if {@code chunk} carries no {@code DataVersion} field at all, or if + * the value it does carry is older than + * {@link #MINIMUM_SOURCE_VERSION} + */ + public static CompoundBinaryTag migrate(CompoundBinaryTag chunk, int targetVersion) { + if (chunk.get(DATA_VERSION_KEY) == null) { + // Distinguished from "present but too old" on purpose: a missing field is a chunk this + // module was never told the age of, while a present-but-low one is a chunk this module + // knows for certain predates its floor. CompoundBinaryTag#getInt below would otherwise + // collapse both into the same misleading "DataVersion 0", a number no real chunk of any + // age actually carries. + throw new MigrationException( + "The chunk carries no DataVersion field at all, so its source version cannot be " + + "determined and this module cannot decide which steps would even apply"); + } + int sourceVersion = chunk.getInt(DATA_VERSION_KEY); + return migrate(chunk, new MigrationContext(sourceVersion, targetVersion)); + } + + /** + * Converts one chunk's root compound using an already-built {@code context}, instead of building + * a fresh one from the chunk's own {@code DataVersion}. + *

+ * This is the entry point a caller reaches for when it needs to read {@code context} back + * afterward — {@link MigrationContext#entitiesLeftBehind()} in particular, which only accumulates + * across chunks migrated through the exact same context instance. {@link #migrate(CompoundBinaryTag, int)} + * is the convenience wrapper every other caller uses; it builds a fresh, single-use context from + * the chunk itself and delegates here. This overload trusts {@code context.sourceVersion()} as + * given rather than re-reading the chunk's own {@code DataVersion} field, so a caller driving + * several chunks through one shared context is responsible for the source version actually + * matching each of them. + *

+ * + * @param chunk the chunk's root compound, as read from a region file + * @param context the source version, target version and entity counter to run the chain with + * @return the converted chunk, stamped with {@code context.targetVersion()} + * @throws MigrationException if {@code context.sourceVersion()} is older than + * {@link #MINIMUM_SOURCE_VERSION} + */ + public static CompoundBinaryTag migrate(CompoundBinaryTag chunk, MigrationContext context) { + if (context.sourceVersion() < MINIMUM_SOURCE_VERSION) { + throw new MigrationException("Chunk DataVersion " + context.sourceVersion() + " is older than the " + + "supported floor " + MINIMUM_SOURCE_VERSION + " (Minecraft 1.13); nothing below " + + "the flattening can be migrated by this module"); + } + + CompoundBinaryTag result = chunk; + for (MigrationStep step : STEPS) { + if (step.appliesTo(context.sourceVersion())) { + result = step.apply(result, context); + } + } + return result.putInt(DATA_VERSION_KEY, context.targetVersion()); + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/LegacyBitReader.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/LegacyBitReader.java new file mode 100644 index 0000000..abb28cd --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/LegacyBitReader.java @@ -0,0 +1,80 @@ +package net.onelitefeather.falco.migration; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; + +/** + * Unpacks the long-array bit packing every version below Minecraft 1.16 (DataVersion 2566) wrote, + * in which an entry is allowed to span the boundary between two longs. + *

+ * {@code falco-anvil}'s {@link net.onelitefeather.falco.anvil.BitPacker} cannot read that layout: + * its own {@code pack} is documented "without letting an entry span two longs", and its + * {@code unpack} computes {@code longIndex = index / entriesPerLong} — an offset that restarts at + * a long boundary for every entry rather than walking a single continuous bit stream. Below 1.16 the + * format instead lays entries out back to back with no padding at all, so an entry whose bit range + * crosses a 64-bit boundary is genuinely split across {@code packed[n]} and {@code packed[n + 1]}. + * This class reads exactly that: a continuous bit offset, {@code index * bitsPerEntry}, with no + * restart. + *

+ *

+ * {@link net.onelitefeather.falco.migration.steps.NormaliseBitPacking} is this reader's only caller: + * it reads a legacy section once with this class and re-packs the result with {@code BitPacker}, so + * every step downstream of it only ever sees the long-aligned layout {@code BitPacker} already + * understands. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class LegacyBitReader { + + private static final int BITS_PER_LONG = Long.SIZE; + + private LegacyBitReader() { + } + + /** + * Unpacks {@code entryCount} entries of {@code bitsPerEntry} bits each from a continuous, + * boundary-spanning bit stream. + * + * @param packed the packed representation to read + * @param bitsPerEntry the amount of bits a single entry occupies + * @param entryCount the amount of entries to read + * @return the unpacked entries, in order + * @throws IllegalArgumentException if {@code bitsPerEntry} is not within {@code [1, 64]}, or if + * {@code packed} does not hold enough bits for + * {@code entryCount} entries of {@code bitsPerEntry} bits each + */ + @Contract(pure = true) + public static int[] unpack(long[] packed, int bitsPerEntry, int entryCount) { + if (bitsPerEntry <= 0 || bitsPerEntry > BITS_PER_LONG) { + throw new IllegalArgumentException( + "The amount of bits per entry must be within [1, 64] but was " + bitsPerEntry); + } + + long totalBits = (long) entryCount * bitsPerEntry; + int requiredLongs = (int) ((totalBits + BITS_PER_LONG - 1) / BITS_PER_LONG); + if (packed.length < requiredLongs) { + throw new IllegalArgumentException( + "The packed data holds " + packed.length + " longs but " + requiredLongs + " are required"); + } + + long mask = bitsPerEntry == BITS_PER_LONG ? -1L : (1L << bitsPerEntry) - 1L; + int[] values = new int[entryCount]; + + for (int index = 0; index < entryCount; index++) { + long bitOffset = (long) index * bitsPerEntry; + int longIndex = (int) (bitOffset / BITS_PER_LONG); + int bitInLong = (int) (bitOffset % BITS_PER_LONG); + int bitsAvailable = BITS_PER_LONG - bitInLong; + + long low = packed[longIndex] >>> bitInLong; + long value = bitsAvailable >= bitsPerEntry + ? low + : low | (packed[longIndex + 1] << bitsAvailable); + + values[index] = (int) (value & mask); + } + return values; + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationContext.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationContext.java new file mode 100644 index 0000000..dcfceb6 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationContext.java @@ -0,0 +1,66 @@ +package net.onelitefeather.falco.migration; + +import org.jetbrains.annotations.ApiStatus; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The two {@code DataVersion}s a {@link MigrationStep} needs to know — the chunk's own, and the one + * {@link ChunkMigration#migrate(net.kyori.adventure.nbt.CompoundBinaryTag, int)} was asked to reach — + * and the running total of entities a chunk's own {@code Entities} list left behind rather than moved. + *

+ * A step decides whether it runs at all from {@code sourceVersion} alone, through + * {@link MigrationStep#appliesTo(int)}. {@code targetVersion} is carried for the steps that have to + * know how far a chunk is going, not only where it started — a step that only moves or deletes data + * (unfolding {@code Level}, discarding heightmaps and light) never needs it, but a step that resolves + * a rename table for a specific target does. + *

+ *

+ * {@code entityCounter} is this context's counting sink: {@code CountEntities} adds to it through + * {@link #countEntitiesLeftBehind(int)} once per chunk it finds still carrying entities, and a caller + * driving several chunks through the same context — a batch run over a whole world, in particular — + * reads {@link #entitiesLeftBehind()} afterwards for the total the design's "entity debt" warning + * requires. It is exposed as a plain record component rather than hidden behind this class alone + * because {@link MigrationContext} is already the one object every step in the chain receives and can + * write into; a second, parallel channel for this one count would only duplicate what the context + * already is. + *

+ * + * @param sourceVersion the chunk's own {@code DataVersion}, read before any step ran + * @param targetVersion the {@code DataVersion} the whole chain is converting towards + * @param entityCounter the counter {@link #countEntitiesLeftBehind(int)} adds to and + * {@link #entitiesLeftBehind()} reads + * @since 2.1.0 + */ +@ApiStatus.Experimental +public record MigrationContext(int sourceVersion, int targetVersion, AtomicInteger entityCounter) { + + /** + * Creates a context with a fresh counter starting at zero. + * + * @param sourceVersion the chunk's own {@code DataVersion}, read before any step ran + * @param targetVersion the {@code DataVersion} the whole chain is converting towards + */ + public MigrationContext(int sourceVersion, int targetVersion) { + this(sourceVersion, targetVersion, new AtomicInteger()); + } + + /** + * Adds {@code count} to the running total of entities left behind in a chunk's own storage rather + * than moved to where the target version reads them from. + * + * @param count how many entities one chunk's {@code Entities} list held; never negative + */ + public void countEntitiesLeftBehind(int count) { + entityCounter.addAndGet(count); + } + + /** + * The running total {@link #countEntitiesLeftBehind(int)} has accumulated so far. + * + * @return the total entity count counted through this context + */ + public int entitiesLeftBehind() { + return entityCounter.get(); + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationException.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationException.java new file mode 100644 index 0000000..47921b4 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationException.java @@ -0,0 +1,49 @@ +package net.onelitefeather.falco.migration; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +/** + * Thrown when {@link ChunkMigration} cannot convert a chunk. + *

+ * Unchecked, and deliberately so: an earlier draft of this module made this type checked, following + * {@code falco-anvil}'s {@code AnvilFormatException} hierarchy. That hierarchy is + * {@code sealed … permits ChunkDataException, RegionFormatException}, declared in another package, so + * it cannot be extended from here at all — and {@code falco-archunit}'s + * {@code ErrorHandlingTest.checkedFaultsStayInsideTheHierarchy} runs over the whole + * {@code net.onelitefeather.falco..} tree and requires every checked {@link Throwable} in it to be + * assignable to that sealed root. A second checked hierarchy is therefore not an option, and this + * module's own house style is unchecked anyway: {@code ErrorHandlingTest} asks every + * {@link RuntimeException} under {@code net.onelitefeather.falco..} to carry a public + * {@code (String, Throwable)} constructor, which this type has below. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public class MigrationException extends RuntimeException { + + /** + * The serialisation id, fixed so a failure which crosses a version boundary still deserialises. + */ + private static final long serialVersionUID = 1L; + + /** + * Creates a new exception with the given message and no cause. + * + * @param message the message which describes the failure + */ + public MigrationException(String message) { + this(message, null); + } + + /** + * Creates a new exception with the given message and cause. + * + * @param message the message which describes the failure + * @param cause the failure which caused this one, or null if there is none + */ + public MigrationException(String message, @Nullable Throwable cause) { + super(message, cause); + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationStep.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationStep.java new file mode 100644 index 0000000..acd08b0 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationStep.java @@ -0,0 +1,40 @@ +package net.onelitefeather.falco.migration; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import org.jetbrains.annotations.ApiStatus; + +/** + * One transformation in the chain {@link ChunkMigration} runs over a chunk's root compound. + *

+ * A step is attached to a {@code DataVersion} threshold through {@link #appliesTo(int)}, which + * {@link ChunkMigration#migrate(CompoundBinaryTag, int)} asks before calling {@link #apply}, so a step + * that only concerns pre-1.18 chunks never has to guard its own body against a chunk that already has + * the shape it would otherwise produce. + *

+ *

+ * Implementations do not mutate {@code chunk}; {@link CompoundBinaryTag} is immutable by construction, + * so {@link #apply} returns the chunk a step produced rather than changing the one it received. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public interface MigrationStep { + + /** + * Whether this step has anything to do for a chunk with the given source version. + * + * @param sourceVersion the chunk's {@code DataVersion}, read once before the chain starts + * @return {@code true} if {@link #apply(CompoundBinaryTag, MigrationContext)} should run + */ + boolean appliesTo(int sourceVersion); + + /** + * Transforms {@code chunk}. Only called for a chunk {@link #appliesTo(int)} accepted. + * + * @param chunk the chunk's root compound, as produced by every step that ran before this one + * @param context the source and target {@code DataVersion} of the whole chain + * @return the transformed chunk + */ + CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context); +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/WorldLayout.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/WorldLayout.java new file mode 100644 index 0000000..dd0909d --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/WorldLayout.java @@ -0,0 +1,142 @@ +package net.onelitefeather.falco.migration; + +import org.jetbrains.annotations.ApiStatus; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Finds every dimension a stored Anvil world holds region files for, across both directory layouts + * such a world has used since Minecraft 1.13. + *

+ * Worlds before Minecraft 1.16 kept the overworld directly under the world root in {@code region}, + * and put the nether and the end in the sibling directories {@code DIM-1} and {@code DIM1} + * respectively — names carried over from a numeric dimension id scheme that predates namespaced keys + * entirely. Since 1.16, every dimension, vanilla or added by a data pack, lives under + * {@code dimensions///region}, with no directory reserved for the overworld: it is + * simply {@code dimensions/minecraft/overworld}. A world converted by this engine may still be in the + * first shape, so both have to be understood at once. + *

+ *

+ * {@code FalcoAnvilLoader.resolveRegionDirectory} is not reused here because it solves a narrower + * problem: given a single dimension a caller already named, it builds that dimension's modern path + * and falls back to {@code /region} only when the modern path is absent. That fallback does + * not look at the dimension it was asked for at all — asking it to resolve + * {@code minecraft:the_nether} on a legacy world returns {@code /region}, the overworld's own + * directory, silently mislabeled as the nether's. It never inspects {@code DIM-1} or {@code DIM1}, so + * a converter built on top of it would read the overworld's blocks twice and never see the other two + * dimensions at all. This class instead enumerates every dimension a world actually has, in either + * layout, rather than resolving one the caller already knew to ask for. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class WorldLayout { + + private static final String OVERWORLD_KEY = "minecraft:overworld"; + private static final String NETHER_KEY = "minecraft:the_nether"; + private static final String END_KEY = "minecraft:the_end"; + private static final String DIMENSIONS_DIRECTORY = "dimensions"; + private static final String REGION_DIRECTORY = "region"; + + private WorldLayout() { + } + + /** + * Finds every region directory a world holds, in either the pre-1.16 layout or the modern + * {@code dimensions/} layout, or a mix of both when a world was only partially converted. + *

+ * The two layouts are read independently and neither is skipped because the other already + * produced a match, so the same {@code dimensionKey} can appear twice in the result: once with + * {@code legacy = true} from {@code /region} (or {@code DIM-1}/{@code DIM1}) and once with + * {@code legacy = false} from {@code dimensions/minecraft/overworld/region} (or the nether's or + * end's modern path), if a world was converted far enough to have created the modern directory + * but still has the old one sitting next to it. This method does not decide which of the two + * copies is authoritative — a caller that folds the result down to one entry per + * {@code dimensionKey} has to pick, and must not do so simply by keeping whichever appears last + * in iteration order, since that can silently discard an already-migrated modern copy in favor of + * the stale legacy source. + *

+ * + * @param worldRoot the root directory of the world, the directory that directly contains either + * {@code region} or {@code dimensions} + * @return every dimension for which a region directory was found; empty when the world has none; + * may contain two entries for the same {@code dimensionKey} as described above + * @throws IOException if a directory under {@code worldRoot} cannot be listed + */ + public static List discover(Path worldRoot) throws IOException { + List regions = new ArrayList<>(); + + Path legacyOverworld = worldRoot.resolve(REGION_DIRECTORY); + if (Files.isDirectory(legacyOverworld)) { + regions.add(new Region(legacyOverworld, OVERWORLD_KEY, true)); + } + Path legacyNether = worldRoot.resolve("DIM-1").resolve(REGION_DIRECTORY); + if (Files.isDirectory(legacyNether)) { + regions.add(new Region(legacyNether, NETHER_KEY, true)); + } + Path legacyEnd = worldRoot.resolve("DIM1").resolve(REGION_DIRECTORY); + if (Files.isDirectory(legacyEnd)) { + regions.add(new Region(legacyEnd, END_KEY, true)); + } + + Path dimensions = worldRoot.resolve(DIMENSIONS_DIRECTORY); + if (Files.isDirectory(dimensions)) { + try (DirectoryStream namespaces = Files.newDirectoryStream(dimensions, Files::isDirectory)) { + for (Path namespace : namespaces) { + try (DirectoryStream values = Files.newDirectoryStream(namespace, Files::isDirectory)) { + for (Path value : values) { + Path region = value.resolve(REGION_DIRECTORY); + if (Files.isDirectory(region)) { + String dimensionKey = namespace.getFileName() + ":" + value.getFileName(); + regions.add(new Region(region, dimensionKey, false)); + } + } + } + } + } + } + + return List.copyOf(regions); + } + + /** + * Returns where a dimension's region files belong once a world is converted, always in the + * modern {@code dimensions/} shape regardless of where {@code discover} found them, because that + * is the only layout the target version reads. + * + * @param worldRoot the root directory of the world + * @param dimensionKey the dimension's namespaced key, for example {@code "minecraft:the_nether"} + * or {@code "mypack:mining"} + * @return the region directory that dimension's converted files belong in + * @throws IllegalArgumentException if {@code dimensionKey} has no {@code ':'} separating a + * namespace from a value + */ + public static Path targetDirectory(Path worldRoot, String dimensionKey) { + int separator = dimensionKey.indexOf(':'); + if (separator < 0) { + throw new IllegalArgumentException( + "dimensionKey must be namespaced as ':', but was '" + dimensionKey + "'"); + } + String namespace = dimensionKey.substring(0, separator); + String value = dimensionKey.substring(separator + 1); + return worldRoot.resolve(DIMENSIONS_DIRECTORY).resolve(namespace).resolve(value).resolve(REGION_DIRECTORY); + } + + /** + * A single dimension's region directory, together with the key that names it and whether it was + * found in the pre-1.16 layout. + * + * @param directory the directory that holds that dimension's region files + * @param dimensionKey the dimension's namespaced key, for example {@code "minecraft:overworld"} + * @param legacy whether the directory came from the layout without a {@code dimensions/} + * tree + */ + public record Region(Path directory, String dimensionKey, boolean legacy) { + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/package-info.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/package-info.java new file mode 100644 index 0000000..00ef93d --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/package-info.java @@ -0,0 +1,26 @@ +/** + * An engine that upgrades stored Anvil chunk data — blocks, biomes, block entities — from Minecraft + * 1.13 upwards. + *

+ * This package works on NBT read from disk and nothing else. It does not import Minestom, does not + * start a server and does not need one running: a world has to be convertible before anything boots, + * because migration is a step a server takes before it ever loads the chunks it is about to serve. + * {@code falco-archunit} enforces that boundary; see {@code MigrationBoundaryTest} there for the rule + * and the reason behind it. + *

+ *

+ * The floor is DataVersion 1519, the release version of Minecraft 1.13. That release rewrote the + * chunk format from numeric block IDs plus damage values to the palette-based block state format + * still in use today, which is the format this engine's types speak. Anything older would need a + * translation this package does not attempt. + *

+ *

+ * Every public type here is experimental and may still change in a minor release. + *

+ * + * @since 2.1.0 + */ +@NotNullByDefault +package net.onelitefeather.falco.migration; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/CountEntities.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/CountEntities.java new file mode 100644 index 0000000..869c48b --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/CountEntities.java @@ -0,0 +1,90 @@ +package net.onelitefeather.falco.migration.steps; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.ListBinaryTag; +import net.onelitefeather.falco.migration.MigrationContext; +import net.onelitefeather.falco.migration.MigrationStep; +import org.jetbrains.annotations.ApiStatus; + +/** + * Counts the entities a chunk still carries in its own {@code Entities} list, and moves nothing. + *

+ * From Minecraft 1.17 the server reads entities only from separate {@code entities/} region files; a + * chunk below that version still carries them inline, under {@code Level.Entities}. This step only + * ever runs before {@link UnfoldLevel} in + * {@link net.onelitefeather.falco.migration.ChunkMigration}'s chain, so a chunk it applies to still + * has its {@code Level} compound intact when this step sees it — {@code Entities} is read from there, + * never from the chunk's root. + *

+ *

+ * This step does not move that list anywhere, and neither does anything else in this chain. + * The design's "entity debt" is stated plainly because it is the same failure mode the loader's + * version guard exists to end: a world converted by this module keeps its entity data in the chunk, + * where the target version will never look for it. Every mob, item frame, armour stand, painting and + * dropped item is effectively gone — the bytes are still there, nothing is deleted, but nothing reads + * them either. This step's only job is to make that consequence visible instead of silent: it adds + * the size of every chunk's own {@code Entities} list to + * {@link MigrationContext#countEntitiesLeftBehind(int)}, so a caller can report the total rather than + * discover a converted world's missing mobs the hard way. + *

+ *

+ * {@link #appliesTo(int)} accepts a source version strictly below {@value #APPLIES_BELOW} — + * {@code DataVersion} 2681, snapshot 20w45a, the snapshot that actually extracted entities into + * their own {@code entities/} region files. This is deliberately not 2724, the + * {@code DataVersion} of the 1.17 release that snapshot led up to: the design's own step table names + * 2724, which is the release number substituted for the snapshot number that actually carries the + * change — the exact mistake this module's block-state rules already found and corrected once for + * this identical snapshot (grass_path's rename also landed in 20w45a). Verified against two + * independent fetches of 20w45a's own wiki infobox and changelog text, 2026-08-04; see the task + * report. A chunk at or above {@value #APPLIES_BELOW} has already had its entities extracted by the + * time it reaches this module, so whatever remains of its own {@code Entities} list, if any, is not + * this step's concern. + *

+ *

+ * A chunk with no {@code Level} compound, or no {@code Entities} list within it, contributes nothing + * to the count and is returned unchanged either way. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class CountEntities implements MigrationStep { + + /** + * DataVersion 2681 is the 20w45a snapshot that extracted entities from the chunk into separate + * {@code entities/} region files. + */ + private static final int APPLIES_BELOW = 2681; + + private static final String LEVEL_KEY = "Level"; + private static final String ENTITIES_KEY = "Entities"; + + /** + * Creates a new instance of this stateless step. + */ + public CountEntities() { + } + + @Override + public boolean appliesTo(int sourceVersion) { + return sourceVersion < APPLIES_BELOW; + } + + /** + * {@inheritDoc} + * + * @param chunk {@inheritDoc} + * @param context {@inheritDoc} — {@link MigrationContext#countEntitiesLeftBehind(int)} receives + * the size of {@code chunk}'s own {@code Level.Entities} list, if present + * @return {@code chunk}, unchanged — this step counts, it never moves or deletes + */ + @Override + public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context) { + if (chunk.get(LEVEL_KEY) instanceof CompoundBinaryTag level + && level.get(ENTITIES_KEY) instanceof ListBinaryTag entities + && !entities.isEmpty()) { + context.countEntitiesLeftBehind(entities.size()); + } + return chunk; + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/DiscardHeightmapsAndLight.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/DiscardHeightmapsAndLight.java new file mode 100644 index 0000000..26ffb77 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/DiscardHeightmapsAndLight.java @@ -0,0 +1,62 @@ +package net.onelitefeather.falco.migration.steps; + +import net.kyori.adventure.nbt.BinaryTag; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.ListBinaryTag; +import net.onelitefeather.falco.migration.MigrationContext; +import net.onelitefeather.falco.migration.MigrationStep; +import org.jetbrains.annotations.ApiStatus; + +/** + * Deletes the per-chunk heightmaps and the per-section light data instead of converting them. + *

+ * This is a deliberate deletion, not a shortcut taken for lack of time. A wrongly ported heightmap + * never announces itself: it is a plain long array the server trusts on faith, so a bug in a bit-width + * or coordinate conversion here would sit silently in every converted world until something built on + * top of it looked wrong for an unrelated reason. A missing heightmap has no such failure mode — the + * server rebuilds it the moment it needs one. Light is discarded for the same reason and because + * {@code falco-light} already computes it from scratch; recomputing was never this module's job to + * begin with. Runs at every version, because a chunk from any point in this module's range can carry + * either field. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class DiscardHeightmapsAndLight implements MigrationStep { + + private static final String HEIGHTMAPS_KEY = "Heightmaps"; + private static final String IS_LIGHT_ON_KEY = "isLightOn"; + private static final String SECTIONS_KEY = "sections"; + private static final String BLOCK_LIGHT_KEY = "BlockLight"; + private static final String SKY_LIGHT_KEY = "SkyLight"; + + /** + * Creates a new instance of this stateless step. + */ + public DiscardHeightmapsAndLight() { + } + + @Override + public boolean appliesTo(int sourceVersion) { + return true; + } + + @Override + public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context) { + CompoundBinaryTag result = chunk.remove(HEIGHTMAPS_KEY).remove(IS_LIGHT_ON_KEY); + + if (!(result.get(SECTIONS_KEY) instanceof ListBinaryTag sections) || sections.isEmpty()) { + return result; + } + + ListBinaryTag cleanedSections = ListBinaryTag.empty(); + for (BinaryTag section : sections) { + BinaryTag cleaned = section instanceof CompoundBinaryTag sectionCompound + ? sectionCompound.remove(BLOCK_LIGHT_KEY).remove(SKY_LIGHT_KEY) + : section; + cleanedSections = cleanedSections.add(cleaned); + } + return result.put(SECTIONS_KEY, cleanedSections); + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/NamespaceStatus.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/NamespaceStatus.java new file mode 100644 index 0000000..902b3fc --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/NamespaceStatus.java @@ -0,0 +1,100 @@ +package net.onelitefeather.falco.migration.steps; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.StringBinaryTag; +import net.onelitefeather.falco.migration.MigrationContext; +import net.onelitefeather.falco.migration.MigrationStep; +import org.jetbrains.annotations.ApiStatus; + +import java.util.Map; + +/** + * Rewrites a chunk status without a namespace into its namespaced form, translating the handful of + * pre-1.14 values this module can prove a meaning for along the way — most importantly, a genuinely + * complete 1.13 chunk's own value, which is not {@code full}. + *

+ * This step tests no {@code DataVersion} at all — {@link #appliesTo(int)} always returns + * {@code true} — because the exact version that namespaced the chunk status could not be established; + * the wiki's own change history for the field carries a notice that it is missing a significant number + * of changes. Testing whether {@code Status} already carries a {@code ':'} is correct for every + * version in this module's range regardless, and does not depend on a number nobody has actually + * read: a status that already has a namespace is left alone, and a bare status is looked up in + * {@link #RENAMED_ON_NAMESPACE} before being prefixed with {@code minecraft:} — the value that lookup + * does not recognize is prefixed exactly as it was read, unchanged, because this module encodes only + * what it can source rather than guess a rename it cannot prove. + *

+ *

+ * A 1.13 chunk's own terminal status is not {@code full}. Namespaced status ids, and + * {@code full} as a name at all, did not exist until Minecraft 1.14's own development snapshots; + * DataVersion 1519 (this module's floor) writes one of the ten values 1.13.2's own + * {@code ChunkStatus} registers, in pipeline order: {@code empty}, {@code base}, {@code carved}, + * {@code liquid_carved}, {@code decorated}, {@code lighted}, {@code mobs_spawned}, {@code finalized}, + * {@code fullchunk}, {@code postprocessed} — confirmed directly against the decompiled 1.13.2 source, + * {@code Akarin-project/Minecraft}'s {@code 1.13.2/spigot/net/minecraft/server/ChunkStatus.java}, + * 2026-08-05. Both {@code fullchunk} (a proto-chunk that has become a full, loaded chunk but has + * never been postprocessed — generated terrain a player has simply never walked near) and + * {@code postprocessed} (a full chunk that has) describe a completely generated chunk; the game + * itself later folds the distinction away, confirmed by + * {@code PaperMC/DataConverter}'s own two-stage fix at commit {@code dcde1f1f89dd6882b56246fe60233ed6a1cb5abb}: + * {@code V1905.java} (DataVersion 1905, 18w43c+2) renames {@code postprocessed} to {@code fullchunk} + * outright, and {@code V1911.java} (DataVersion 1911, 18w46a+1) then maps {@code fullchunk} — by then + * the only surviving name for "complete" — to {@code full}, in the same table that renames every + * other pipeline stage to its 1.14-development-era name ({@code base} to {@code surface}, + * {@code carved} to {@code carvers}, and so on). Both fetches checked 2026-08-05. This step therefore + * maps {@code fullchunk} and {@code postprocessed} to {@code full} directly, the one edge this + * module's own loader ({@code FalcoAnvilLoader.isFullyGenerated}) actually gates a chunk's load on; + * see {@code NamespaceStatusTest.testAGenuineNineteenThirteenTerminalStatusBecomesMinecraftFull} for + * the sourced fixture. The eight remaining, non-terminal 1.13 values are left namespaced but + * otherwise unrenamed rather than guessed at: they describe an incomplete proto-chunk either way, and + * {@code FalcoAnvilLoader} skips anything that is not exactly {@code minecraft:full} regardless of + * which non-terminal name it carries, so a wrong (but still non-{@code full}) guess for one of them + * would not silently corrupt a load the way the {@code full} case would have. + *

+ *

+ * A chunk with no {@code Status} field at all is returned unchanged. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class NamespaceStatus implements MigrationStep { + + private static final String STATUS_KEY = "Status"; + private static final String MINECRAFT_NAMESPACE = "minecraft:"; + + /** + * The only two bare, pre-namespace {@code Status} values this module can source a modern meaning + * for — both the 1.13.2 terminal ("chunk is completely generated") status, under its two names + * across that version's own chunk-loading pipeline. See this class's own javadoc for the sourced + * chain ({@code fullchunk}/{@code postprocessed} to {@code full}) that justifies this table; every + * bare value not in it is namespaced without being renamed, rather than guessed at. + */ + private static final Map RENAMED_ON_NAMESPACE = Map.of( + "postprocessed", "full", + "fullchunk", "full"); + + /** + * Creates a new instance of this stateless step. + */ + public NamespaceStatus() { + } + + @Override + public boolean appliesTo(int sourceVersion) { + return true; + } + + @Override + public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context) { + if (!(chunk.get(STATUS_KEY) instanceof StringBinaryTag status)) { + return chunk; + } + + String value = status.value(); + if (value.indexOf(':') >= 0) { + return chunk; + } + String renamed = RENAMED_ON_NAMESPACE.getOrDefault(value, value); + return chunk.putString(STATUS_KEY, MINECRAFT_NAMESPACE + renamed); + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/NormaliseBitPacking.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/NormaliseBitPacking.java new file mode 100644 index 0000000..4d61113 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/NormaliseBitPacking.java @@ -0,0 +1,188 @@ +package net.onelitefeather.falco.migration.steps; + +import net.kyori.adventure.nbt.BinaryTag; +import net.kyori.adventure.nbt.BinaryTagTypes; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.ListBinaryTag; +import net.kyori.adventure.nbt.LongArrayBinaryTag; +import net.onelitefeather.falco.anvil.BitPacker; +import net.onelitefeather.falco.migration.LegacyBitReader; +import net.onelitefeather.falco.migration.MigrationContext; +import net.onelitefeather.falco.migration.MigrationException; +import net.onelitefeather.falco.migration.MigrationStep; +import org.jetbrains.annotations.ApiStatus; + +/** + * Re-packs every section's block-state data from the boundary-spanning layout every version below + * Minecraft 1.16 (DataVersion 2529, the 20w17a snapshot — see {@link #APPLIES_BELOW}'s own javadoc + * for why not the release number, 2566) wrote into the long-aligned layout {@code falco-anvil}'s + * {@link BitPacker} understands. + *

+ * This step runs before {@link UnfoldLevel} in {@code ChunkMigration}'s chain — its own threshold, + * 2529, is strictly below {@code UnfoldLevel}'s, 2844, so every chunk this step applies to still + * carries the pre-1.18 {@code Level} wrapper when this step sees it. It therefore reads + * {@code Level.Sections}, not the root {@code sections} list later steps use, and only touches each + * section's {@code BlockStates} long array — the key names and every other field are left exactly as + * they were; restructuring the container itself into the modern + * {@code block_states: \{palette, data\}} shape is + * {@link TranslateBlockStates}'s job, which runs after this step has made every section's packing + * long-aligned. + *

+ *

+ * A section whose palette holds a single entry carries no {@code BlockStates} array at all — the + * format omits it and lets the one palette entry fill the whole section — and is returned unchanged, + * as is a chunk with no {@code Level} compound. + *

+ *

+ * The width a section is re-packed at is not the width it was read at. An earlier version of + * this step preserved whatever width {@link #exactBitsPerEntry(int)} recovered from the legacy array, + * on the reasoning that the exact original width is always recoverable from the spanning format's own + * length with no ambiguity (unlike the long-aligned format, which pads). That is true, but it does not + * survive the round trip: {@link TranslateBlockStates}, reading the long-aligned array back, has only + * the array's length and the palette size to work from, and {@code BitPacker.expectedLongCount} maps + * more than one width to the same long count for {@link #BLOCK_ENTRIES} entries — 11 and 12 bits both + * produce 820 longs, for instance — so a width a writer over-provisioned (the format allows more bits + * than a palette strictly needs) is not always recoverable downstream, and a wrong guess corrupts the + * section silently rather than throwing. Repacking at the palette's own canonical minimum, + * {@code BitPacker.bitsPerEntry(paletteSize, 4)}, removes the ambiguity constructively instead of + * hoping the disambiguation heuristic guesses right: the indices this step unpacked are carried + * through exactly, only the width they are written back at changes, and every section this step + * touches is guaranteed to carry the one width {@link TranslateBlockStates} would derive from its + * palette alone. See {@code SectionStepsTest.testAnOverWidthPackedSectionIsCanonicalisedRatherThanPreservingTheAmbiguousWidth} + * for the measured 11/12-bit collision this closes. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class NormaliseBitPacking implements MigrationStep { + + /** + * DataVersion 2529, snapshot 20w17a — not 2566, the design document's own number, which + * is 1.16's release DataVersion rather than the snapshot the change actually landed in. + * Checked directly against 20w17a's own minecraft.wiki changelog and infobox, 2026-08-04: "Format + * in chunks has been slightly changed... {@code BlockStates} in {@code Sections} elements no + * longer contain values stretching over multiple 64-bit fields", DataVersion 2529. This is the + * same release-instead-of-snapshot mistake this module has already found and corrected several + * times over elsewhere — in the block-state rename table and in {@code CountEntities}'s own + * threshold — so it was checked here too rather than trusted. A chunk in the gap this correction + * closes, DataVersion + * 2529 up to but not including 2566 (20w17a's own pre-releases through 1.16 Release Candidate 1), + * already writes the non-spanning layout; treating it as spanning would have this step and + * {@link LegacyBitReader} misread already-correct data for any bits-per-entry that does not evenly + * divide 64, corrupting it on repacking. + */ + private static final int APPLIES_BELOW = 2529; + + private static final String LEVEL_KEY = "Level"; + private static final String SECTIONS_KEY = "Sections"; + private static final String BLOCK_STATES_KEY = "BlockStates"; + private static final String PALETTE_KEY = "Palette"; + + private static final int BLOCK_ENTRIES = 16 * 16 * 16; + private static final int BITS_PER_LONG = Long.SIZE; + + /** + * Minestom's {@code net.minestom.server.instance.palette.Palette.BLOCK_PALETTE_MIN_BITS} + * (checked in the sources jar of {@code net.minestom:minestom}), the same constant + * {@code TranslateBlockStates.BLOCK_PALETTE_MIN_BITS} pins for the same reason — duplicated + * rather than shared, because {@code falco-archunit}'s {@code migrationKnowsNoMinestom} forbids + * this module from depending on {@code net.minestom} even indirectly through a constant import. + * Used to compute the canonical, unambiguous width every section is re-packed at; see this + * class's own javadoc for why the width it reads is not reused for the width it writes. + */ + private static final int BLOCK_PALETTE_MIN_BITS = 4; + + /** + * Creates a new instance of this stateless step. + */ + public NormaliseBitPacking() { + } + + @Override + public boolean appliesTo(int sourceVersion) { + return sourceVersion < APPLIES_BELOW; + } + + @Override + public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context) { + if (!(chunk.get(LEVEL_KEY) instanceof CompoundBinaryTag level)) { + return chunk; + } + + ListBinaryTag sections = level.getList(SECTIONS_KEY, BinaryTagTypes.COMPOUND); + if (sections.size() == 0) { + return chunk; + } + + ListBinaryTag normalised = ListBinaryTag.empty(); + for (BinaryTag sectionTag : sections) { + normalised = normalised.add(sectionTag instanceof CompoundBinaryTag section + ? normalise(section) + : sectionTag); + } + + return chunk.put(LEVEL_KEY, level.put(SECTIONS_KEY, normalised)); + } + + private static CompoundBinaryTag normalise(CompoundBinaryTag section) { + if (!(section.get(BLOCK_STATES_KEY) instanceof LongArrayBinaryTag legacy)) { + return section; + } + + if (!(section.get(PALETTE_KEY) instanceof ListBinaryTag palette) || palette.size() == 0) { + throw new MigrationException( + "A section carries a legacy 'BlockStates' array but no (or an empty) 'Palette' to " + + "address, so the bits-per-entry it was packed with cannot be recovered"); + } + + long[] packed = legacy.value(); + int readBitsPerEntry = exactBitsPerEntry(packed.length); + int[] indices = LegacyBitReader.unpack(packed, readBitsPerEntry, BLOCK_ENTRIES); + + // Re-packed at the palette's own canonical minimum, not the (possibly wider) width the + // section was actually read at — see this class's own javadoc for why preserving the read + // width leaves TranslateBlockStates unable to always recover it downstream. + int canonicalBitsPerEntry = BitPacker.bitsPerEntry(palette.size(), BLOCK_PALETTE_MIN_BITS); + long[] repacked = BitPacker.pack(indices, canonicalBitsPerEntry); + + return section.putLongArray(BLOCK_STATES_KEY, repacked); + } + + /** + * Derives the bits-per-entry a legacy writer actually used from the packed array's own length. + *

+ * The format explicitly allows a writer to use more bits than a palette strictly needs — + * {@code falco-anvil}'s own {@code PaletteData.read} carries the same caveat for the modern + * layout — which is why this derivation reads the width the writer actually used rather than + * assuming the palette's own minimum. It is exact rather than a best guess: the legacy layout has + * no padding at all — every long is completely full except possibly the last few bits of the + * final one — and {@link #BLOCK_ENTRIES} (4096) is itself a multiple of 64, so + * {@code longCount * 64} is always evenly divisible by {@code BLOCK_ENTRIES} with no rounding, + * for any {@code bitsPerEntry} a real writer could have used. (What this method's result is then + * re-packed at is a separate decision — see {@link #normalise(CompoundBinaryTag)}, which does not + * reuse it.) + *

+ * + * @param longCount the length of the packed {@code BlockStates} array + * @return the exact bits-per-entry that array was packed with + * @throws MigrationException if {@code longCount} does not correspond to any whole + * bits-per-entry over {@link #BLOCK_ENTRIES} entries — either because + * it is not an exact multiple, or because it holds too few longs for + * even one bit per entry + */ + private static int exactBitsPerEntry(int longCount) { + long totalBits = (long) longCount * BITS_PER_LONG; + if (totalBits % BLOCK_ENTRIES != 0) { + throw new MigrationException("A section's legacy 'BlockStates' array holds " + longCount + + " longs, which is not an exact multiple of " + BLOCK_ENTRIES + + " block positions and therefore matches no whole bits-per-entry"); + } + int bitsPerEntry = (int) (totalBits / BLOCK_ENTRIES); + if (bitsPerEntry == 0) { + throw new MigrationException("A section's legacy 'BlockStates' array holds " + longCount + + " longs, too few for even one bit per entry over " + BLOCK_ENTRIES + " block positions"); + } + return bitsPerEntry; + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/RebuildBiomes.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/RebuildBiomes.java new file mode 100644 index 0000000..4bfdf12 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/RebuildBiomes.java @@ -0,0 +1,388 @@ +package net.onelitefeather.falco.migration.steps; + +import net.kyori.adventure.nbt.BinaryTag; +import net.kyori.adventure.nbt.BinaryTagTypes; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.IntArrayBinaryTag; +import net.kyori.adventure.nbt.ListBinaryTag; +import net.kyori.adventure.nbt.LongArrayBinaryTag; +import net.kyori.adventure.nbt.StringBinaryTag; +import net.onelitefeather.falco.anvil.BitPacker; +import net.onelitefeather.falco.migration.MigrationContext; +import net.onelitefeather.falco.migration.MigrationException; +import net.onelitefeather.falco.migration.MigrationStep; +import org.jetbrains.annotations.ApiStatus; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Rebuilds the whole-chunk {@code Biomes} array every version below Minecraft 1.18 (DataVersion 2844) + * wrote into the palettised, per-section {@code biomes} container the target format uses. + *

+ * Runs after {@link UnfoldLevel} in {@code ChunkMigration}'s chain, so it reads the root + * {@code sections} list and the root {@code Biomes} field {@code UnfoldLevel} already moved there + * for a pre-1.18 chunk; a chunk with no {@code Biomes} field is returned unchanged. + *

+ *

+ * Two source shapes. Below DataVersion 2203 (snapshot 19w36a — confirmed against that + * snapshot's own changelog and infobox on minecraft.wiki, 2026-08-04) {@code Biomes} holds 256 + * entries, one per column of the chunk's 16-by-16 footprint, with no variance by height. From 2203 + * up to 2844 it holds 1024 entries: the same footprint split into 4-by-4 columns, crossed with 16 + * four-block-tall layers, still stored for the whole chunk rather than per section. Both shapes are + * converted into the same per-section, 64-entry (4x4x4) form before palettising. + *

+ *
+ *

+ * Every entry, in either shape, is a legacy numeric biome id, not a name. String biome names + * only exist in chunk data from 1.18 onward. Resolving a numeric id therefore needs a per-version + * table, and — unlike blocks — this project's own vendored source for such tables, the ViaVersion + * {@code mapping-.json} registry lists {@link net.onelitefeather.falco.migration.BlockStateRules} + * is itself built from, does not carry a {@code biomes} list at all (see the design document's + * "registry lists" section). The table + * below is instead sourced from PaperMC/DataConverter's own {@code V2832} (commit {@code 0782df72}, + * GPL-3.0, DataVersion 2832 — the fix that performs this exact conversion for the real 1.18 upgrade), + * whose {@code BIOMES_BY_ID} array is reproduced here as a set of id-to-name facts, not copied as + * code. Two things about it matter for correctness: + *

+ *
    + *
  • The ids were allocated once and never reused — {@code V2832} itself leaves gaps in the + * table for ids that were never assigned rather than compacting them, so the same id names the + * same biome across every version in this module's 1.13-1.17 range.
  • + *
  • The table already carries each biome's final name (for example id {@code 8} is + * {@code minecraft:nether_wastes}, not the older {@code minecraft:nether} a 1.13-1.15 world's own + * files would have called it by name if names existed yet) — correct here because this module + * only ever converts up to a target version at or after 1.18, where the final name is the only + * one that is still valid.
  • + *
+ *

+ * An id this table does not know throws, rather than substituting {@code minecraft:plains} the + * way DataConverter itself does. That is a deliberate divergence from the upstream source of the + * table's facts, made to keep faith with this project's own stance on an unmappable value: silently + * inventing a biome is the same silent corruption an unmappable block is refused for elsewhere in + * this module. + *

+ *

+ * A section that carries no block data is discarded before anything else runs, regardless of its + * {@code Y}. Vanilla itself writes one extra section below and one above a chunk's real content + * purely to carry lighting data for the sections that border them — Minestom's own Anvil loader + * throws exactly these away on load, with the comment "Vanilla stores a section below and above the + * world for lighting, throw it out" (checked in the sources jar of {@code net.minestom:minestom}, + * {@code AnvilLoader.java:207-209}). An earlier version of this step told such a section apart from a + * real one by a fixed {@code Y} range, {@code 0} to {@code 15} — which only holds for a world whose + * height is the pre-1.18 default, {@code 0}..{@code 255}. Configurable world height is not a 1.18 + * feature: it landed three snapshots into 1.17's own cycle, 20w49a, DataVersion 2685 (confirmed + * against that snapshot's own minecraft.wiki changelog, fetched 2026-08-05: "Added {@code height} and + * {@code min_y} variables to dimension types, allowing for the height limit to be increased in custom + * world settings") — below every source version this step ever runs for. A world converted while + * using such a custom height genuinely stores content sections outside {@code 0}..{@code 15}; the + * fixed-range check silently discarded those instead of the lighting-only sections it meant to catch, + * for both a custom-height 1.17 world and a stock 1.18-generation Overworld caught mid-conversion + * (DataVersion 2836-2843) whose sections already span the new {@code -4}..{@code 19} range under the + * old {@code Level}-based layout. This step instead discards a section carrying none of + * {@code Palette}, {@code BlockStates}, or {@code block_states} — the only names a legacy or an + * already-normalised section's block data is ever stored under — which is exactly the lighting-only + * sections vanilla writes, independent of where they sit, and never a section that actually holds + * blocks. Nothing upstream of this step drops them, so without this check a lighting-only section at + * a negative {@code Y} would otherwise index this step's widened biome array at a negative offset and + * throw {@link ArrayIndexOutOfBoundsException} — an unchecked failure this module's fail-loud stance + * does not consider acceptable — and a border section that happened to unpack cleanly would otherwise + * survive into the target's own section range as a spurious empty section nobody asked for. + *

+ *

+ * Where the surviving sections actually start still has to be known to place biomes correctly, and + * this step refuses to guess it. {@link #biomeContainer} indexes the widened, whole-chunk biome + * array with {@code sectionY * SECTION_BIOME_ENTRIES}, which only lands on the right layer if the + * chunk's lowest surviving section is {@code Y = 0} — true for every fixed-height pre-1.18 world, but + * not for a world converted from a custom height range whose sections start below zero, which + * {@code Y} alone no longer distinguishes from the fixed-height case now that content is what decides + * a section's survival. Rather than writing a biome layer 64 blocks off from the section it actually + * belongs to, this step throws {@link MigrationException} the moment a whole-chunk {@code Biomes} + * array is present alongside any surviving section at {@code Y < 0}. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class RebuildBiomes implements MigrationStep { + + private static final int APPLIES_BELOW = 2844; + + private static final String SECTIONS_KEY = "sections"; + private static final String BIOMES_KEY = "Biomes"; + private static final String SECTION_BIOMES_KEY = "biomes"; + private static final String PALETTE_KEY = "palette"; + private static final String DATA_KEY = "data"; + private static final String SECTION_Y_KEY = "Y"; + + /** + * The three names a section's own block data is ever stored under in this module's source range — + * see this class's own javadoc for why their absence, rather than a fixed {@code Y} range, is what + * marks a lighting-only section. + */ + private static final String LEGACY_PALETTE_KEY = "Palette"; + private static final String LEGACY_BLOCK_STATES_KEY = "BlockStates"; + private static final String MODERN_BLOCK_STATES_KEY = "block_states"; + + private static final int PRE_WIDENING_ENTRIES = 256; + private static final int WIDENED_ENTRIES = 1024; + private static final int SECTION_BIOME_ENTRIES = 4 * 4 * 4; + + /** + * Minestom's {@code net.minestom.server.instance.palette.Palette.BIOME_PALETTE_MIN_BITS} + * (checked in the sources jar of {@code net.minestom:minestom}), pinned here for the same reason + * {@link TranslateBlockStates#BLOCK_PALETTE_MIN_BITS} is: this module cannot depend on + * {@code net.minestom}. + */ + private static final int BIOME_PALETTE_MIN_BITS = 1; + + private static final Map LEGACY_BIOME_NAMES = buildLegacyBiomeNames(); + + /** + * Creates a new instance of this stateless step. + */ + public RebuildBiomes() { + } + + @Override + public boolean appliesTo(int sourceVersion) { + return sourceVersion < APPLIES_BELOW; + } + + /** + * {@inheritDoc} + * + * @param chunk {@inheritDoc} + * @param context {@inheritDoc} + * @return {@inheritDoc} + * @throws MigrationException if {@code Biomes} holds neither 256 nor 1024 entries, holds a legacy + * numeric id this step's table does not know, or is present alongside + * a surviving section at {@code Y < 0} + */ + @Override + public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context) { + chunk = discardSectionsWithoutBlockData(chunk); + + if (!(chunk.get(BIOMES_KEY) instanceof IntArrayBinaryTag biomesTag)) { + return chunk; + } + + int[] legacy = biomesTag.value(); + int[] widened = legacy.length == PRE_WIDENING_ENTRIES ? widen(legacy) : legacy; + if (widened.length != WIDENED_ENTRIES) { + throw new MigrationException("The chunk's 'Biomes' array holds " + legacy.length + + " entries, which matches neither the pre-1.15 shape (" + PRE_WIDENING_ENTRIES + + ") nor the 1.15-1.17 shape (" + WIDENED_ENTRIES + ")"); + } + + ListBinaryTag sections = chunk.getList(SECTIONS_KEY, BinaryTagTypes.COMPOUND); + for (BinaryTag sectionTag : sections) { + if (sectionTag instanceof CompoundBinaryTag section && section.getInt(SECTION_Y_KEY) < 0) { + throw new MigrationException("The chunk carries a whole-chunk 'Biomes' array and a " + + "surviving section at Y = " + section.getInt(SECTION_Y_KEY) + "; this step's " + + "biome offset (Y * " + SECTION_BIOME_ENTRIES + ") assumes the chunk's lowest " + + "section is Y = 0, which does not hold for a world with a shifted " + + "(below-zero) height range, so the chunk is refused instead of silently " + + "writing every biome layer at the wrong height"); + } + } + + ListBinaryTag rebuilt = ListBinaryTag.empty(); + for (BinaryTag sectionTag : sections) { + if (!(sectionTag instanceof CompoundBinaryTag section)) { + rebuilt = rebuilt.add(sectionTag); + continue; + } + int sectionY = section.getInt(SECTION_Y_KEY); + rebuilt = rebuilt.add(section.put(SECTION_BIOMES_KEY, biomeContainer(widened, sectionY))); + } + + return chunk.remove(BIOMES_KEY).put(SECTIONS_KEY, rebuilt); + } + + /** + * Drops every section carrying none of {@link #LEGACY_PALETTE_KEY}, {@link #LEGACY_BLOCK_STATES_KEY} + * or {@link #MODERN_BLOCK_STATES_KEY} — see this class's own javadoc for why the absence of block + * data, rather than a fixed {@code Y} range, is what marks a lighting-only section. Runs first, and + * unconditionally, so it also protects a chunk that has no {@code Biomes} field at all (an early + * return further down would otherwise skip this check entirely for such a chunk). + */ + private static CompoundBinaryTag discardSectionsWithoutBlockData(CompoundBinaryTag chunk) { + ListBinaryTag sections = chunk.getList(SECTIONS_KEY, BinaryTagTypes.COMPOUND); + if (sections.size() == 0) { + return chunk; + } + + ListBinaryTag kept = ListBinaryTag.empty(); + boolean droppedAny = false; + for (BinaryTag sectionTag : sections) { + if (sectionTag instanceof CompoundBinaryTag section && !hasBlockData(section)) { + droppedAny = true; + continue; + } + kept = kept.add(sectionTag); + } + return droppedAny ? chunk.put(SECTIONS_KEY, kept) : chunk; + } + + private static boolean hasBlockData(CompoundBinaryTag section) { + return section.get(LEGACY_PALETTE_KEY) != null + || section.get(LEGACY_BLOCK_STATES_KEY) != null + || section.get(MODERN_BLOCK_STATES_KEY) != null; + } + + private static int[] widen(int[] legacy) { + int[] widened = new int[WIDENED_ENTRIES]; + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + int k = (j << 2) + 2; + int l = (i << 2) + 2; + widened[(i << 2) | j] = legacy[(l << 4) | k]; + } + } + for (int i = 1; i < 64; i++) { + System.arraycopy(widened, 0, widened, i * 16, 16); + } + return widened; + } + + private static CompoundBinaryTag biomeContainer(int[] widened, int sectionY) { + int offset = sectionY * SECTION_BIOME_ENTRIES; + int[] localIndices = new int[SECTION_BIOME_ENTRIES]; + Map firstSeenAt = new LinkedHashMap<>(); + + for (int cell = 0; cell < SECTION_BIOME_ENTRIES; cell++) { + int legacyId = widened[offset + cell]; + localIndices[cell] = firstSeenAt.computeIfAbsent(legacyId, id -> firstSeenAt.size()); + } + + ListBinaryTag palette = ListBinaryTag.empty(); + for (int legacyId : firstSeenAt.keySet()) { + palette = palette.add(StringBinaryTag.stringBinaryTag(nameOf(legacyId))); + } + + CompoundBinaryTag container = CompoundBinaryTag.builder().put(PALETTE_KEY, palette).build(); + if (firstSeenAt.size() > 1) { + int bitsPerEntry = BitPacker.bitsPerEntry(firstSeenAt.size(), BIOME_PALETTE_MIN_BITS); + long[] packed = BitPacker.pack(localIndices, bitsPerEntry); + container = container.put(DATA_KEY, LongArrayBinaryTag.longArrayBinaryTag(packed)); + } + return container; + } + + private static String nameOf(int legacyId) { + String name = LEGACY_BIOME_NAMES.get(legacyId); + if (name == null) { + throw new MigrationException("Legacy biome id " + legacyId + " has no known name in this " + + "module's sourced table (PaperMC/DataConverter's V2832, commit 0782df72); a " + + "converted chunk must not silently receive an invented biome"); + } + return name; + } + + /** + * The legacy numeric-id-to-name table, sourced from PaperMC/DataConverter's {@code V2832} + * (commit {@code 0782df72}, {@code BIOMES_BY_ID}). See this class's own javadoc for what the + * table means and why it, rather than a computed or vendored per-version list, is the source. + */ + private static Map buildLegacyBiomeNames() { + Map names = new HashMap<>(); + names.put(0, "minecraft:ocean"); + names.put(1, "minecraft:plains"); + names.put(2, "minecraft:desert"); + names.put(3, "minecraft:mountains"); + names.put(4, "minecraft:forest"); + names.put(5, "minecraft:taiga"); + names.put(6, "minecraft:swamp"); + names.put(7, "minecraft:river"); + names.put(8, "minecraft:nether_wastes"); + names.put(9, "minecraft:the_end"); + names.put(10, "minecraft:frozen_ocean"); + names.put(11, "minecraft:frozen_river"); + names.put(12, "minecraft:snowy_tundra"); + names.put(13, "minecraft:snowy_mountains"); + names.put(14, "minecraft:mushroom_fields"); + names.put(15, "minecraft:mushroom_field_shore"); + names.put(16, "minecraft:beach"); + names.put(17, "minecraft:desert_hills"); + names.put(18, "minecraft:wooded_hills"); + names.put(19, "minecraft:taiga_hills"); + names.put(20, "minecraft:mountain_edge"); + names.put(21, "minecraft:jungle"); + names.put(22, "minecraft:jungle_hills"); + names.put(23, "minecraft:jungle_edge"); + names.put(24, "minecraft:deep_ocean"); + names.put(25, "minecraft:stone_shore"); + names.put(26, "minecraft:snowy_beach"); + names.put(27, "minecraft:birch_forest"); + names.put(28, "minecraft:birch_forest_hills"); + names.put(29, "minecraft:dark_forest"); + names.put(30, "minecraft:snowy_taiga"); + names.put(31, "minecraft:snowy_taiga_hills"); + names.put(32, "minecraft:giant_tree_taiga"); + names.put(33, "minecraft:giant_tree_taiga_hills"); + names.put(34, "minecraft:wooded_mountains"); + names.put(35, "minecraft:savanna"); + names.put(36, "minecraft:savanna_plateau"); + names.put(37, "minecraft:badlands"); + names.put(38, "minecraft:wooded_badlands_plateau"); + names.put(39, "minecraft:badlands_plateau"); + names.put(40, "minecraft:small_end_islands"); + names.put(41, "minecraft:end_midlands"); + names.put(42, "minecraft:end_highlands"); + names.put(43, "minecraft:end_barrens"); + names.put(44, "minecraft:warm_ocean"); + names.put(45, "minecraft:lukewarm_ocean"); + names.put(46, "minecraft:cold_ocean"); + names.put(47, "minecraft:deep_warm_ocean"); + names.put(48, "minecraft:deep_lukewarm_ocean"); + names.put(49, "minecraft:deep_cold_ocean"); + names.put(50, "minecraft:deep_frozen_ocean"); + names.put(127, "minecraft:the_void"); + names.put(129, "minecraft:sunflower_plains"); + names.put(130, "minecraft:desert_lakes"); + names.put(131, "minecraft:gravelly_mountains"); + names.put(132, "minecraft:flower_forest"); + names.put(133, "minecraft:taiga_mountains"); + names.put(134, "minecraft:swamp_hills"); + names.put(140, "minecraft:ice_spikes"); + names.put(149, "minecraft:modified_jungle"); + names.put(151, "minecraft:modified_jungle_edge"); + names.put(155, "minecraft:tall_birch_forest"); + names.put(156, "minecraft:tall_birch_hills"); + names.put(157, "minecraft:dark_forest_hills"); + names.put(158, "minecraft:snowy_taiga_mountains"); + names.put(160, "minecraft:giant_spruce_taiga"); + names.put(161, "minecraft:giant_spruce_taiga_hills"); + names.put(162, "minecraft:modified_gravelly_mountains"); + names.put(163, "minecraft:shattered_savanna"); + names.put(164, "minecraft:shattered_savanna_plateau"); + names.put(165, "minecraft:eroded_badlands"); + names.put(166, "minecraft:modified_wooded_badlands_plateau"); + names.put(167, "minecraft:modified_badlands_plateau"); + names.put(168, "minecraft:bamboo_jungle"); + names.put(169, "minecraft:bamboo_jungle_hills"); + names.put(170, "minecraft:soul_sand_valley"); + names.put(171, "minecraft:crimson_forest"); + names.put(172, "minecraft:warped_forest"); + names.put(173, "minecraft:basalt_deltas"); + names.put(174, "minecraft:dripstone_caves"); + names.put(175, "minecraft:lush_caves"); + names.put(177, "minecraft:meadow"); + names.put(178, "minecraft:grove"); + names.put(179, "minecraft:snowy_slopes"); + names.put(180, "minecraft:snowcapped_peaks"); + names.put(181, "minecraft:lofty_peaks"); + names.put(182, "minecraft:stony_peaks"); + return Map.copyOf(names); + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/SettleYRange.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/SettleYRange.java new file mode 100644 index 0000000..ab03d64 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/SettleYRange.java @@ -0,0 +1,107 @@ +package net.onelitefeather.falco.migration.steps; + +import net.kyori.adventure.nbt.BinaryTag; +import net.kyori.adventure.nbt.BinaryTagTypes; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.ListBinaryTag; +import net.onelitefeather.falco.migration.MigrationContext; +import net.onelitefeather.falco.migration.MigrationStep; +import org.jetbrains.annotations.ApiStatus; + +/** + * Settles what {@code yPos} means for a converted chunk: the lowest {@code Y} a section already + * present in the chunk's own {@code sections} list actually carries — never an invented floor. + *

+ * The question this step answers, and the two sources that answer it. The field's own + * documentation on minecraft.wiki's {@code Chunk format} article (fetched 2026-08-04) reads + * "[Int] yPos: Lowest Y section position in the chunk (e.g. {@code -4} in 1.18)" — wording + * that, read alone, is ambiguous between "the lowest section this chunk itself stores" and "the + * bottom of the dimension's height range". The same article settles which reading is load-bearing, + * in its description of the {@code sections} list itself: "All sections in the world's height are + * present in this list, even those who are empty (filled with air)." Vanilla always writes every + * section down to the dimension floor, so for a vanilla file the two readings coincide by + * construction — but a chunk converted by this module does not invent sections it never had, which is + * exactly the case where they would split if the field meant the dimension floor. The field's own + * name and wording — "in the chunk" — settle it as the chunk's own lowest section, so this step + * computes {@code yPos} from what the chunk's {@code sections} list actually contains. + *

+ *

+ * The second source — what the target actually does with the value — confirms this carries no + * risk either way. Minestom's own {@code AnvilLoader} (checked in the sources jar of + * {@code net.minestom:minestom}, {@code loadSections}) never reads {@code yPos} at all: it derives + * the section range purely from {@code chunk.getMinSection()} / {@code chunk.getMaxSection()} — the + * running instance's own dimension type — and discards any section tag whose own {@code Y} falls + * outside that range, with the comment "Vanilla stores a section below and above the world for + * lighting, throw it out." It writes {@code yPos = chunk.getMinSection()} on save, but that is an + * echo of its own understanding for other readers, never something it reads back. Falco's own + * {@code FalcoAnvilLoader} mirrors this exactly. The two sources therefore do not disagree: the wiki + * states the field's declared meaning, and the loader that actually reads the file confirms that + * meaning is inert to it operationally, because it never consults the field at all. Writing anything + * other than the chunk's own true lowest section would misdescribe the chunk to every reader that + * does consult it, for zero benefit to the one reader this project controls. + *

+ *

+ * Runs after {@link UnfoldLevel}, so it reads the root {@code sections} list. A chunk whose + * {@code sections} list is empty, or absent, is returned unchanged rather than stamped with an + * invented {@code yPos} — there is no section in the chunk for any such value to describe. + *

+ *

+ * Every remaining section counts, without a fixed range filter. An earlier version of this + * step ignored a section outside a fixed {@code Y} range of {@code 0}..{@code 15} on the theory that + * only vanilla's own lighting-only border sections — one below and one above a chunk's real content, + * discarded by {@link RebuildBiomes} earlier in the chain — could ever fall outside it. That theory + * held only for a world at the pre-1.18 fixed height, {@code 0}..{@code 255}; see + * {@link RebuildBiomes}'s own javadoc for why a world converted from a custom height (possible from + * DataVersion 2685 onward, well below every source version this step runs for) can genuinely store + * real content sections outside that range, and for why {@code Y} alone can no longer be trusted to + * tell such a section apart from a lighting-only one. This step now trusts {@link RebuildBiomes} to + * have already removed every section that carries no block data, and simply takes the lowest + * {@code Y} among whatever sections remain — never filtering by, or falling back to, a fixed number + * that might not correspond to any section the chunk actually has. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class SettleYRange implements MigrationStep { + + /** + * DataVersion 2844 is the 21w43a snapshot that introduced {@code yPos} in the first place — the + * same threshold {@link UnfoldLevel} uses for the {@code Level} removal it happened alongside. + */ + private static final int APPLIES_BELOW = 2844; + + private static final String SECTIONS_KEY = "sections"; + private static final String SECTION_Y_KEY = "Y"; + private static final String Y_POS_KEY = "yPos"; + + /** + * Creates a new instance of this stateless step. + */ + public SettleYRange() { + } + + @Override + public boolean appliesTo(int sourceVersion) { + return sourceVersion < APPLIES_BELOW; + } + + @Override + public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context) { + ListBinaryTag sections = chunk.getList(SECTIONS_KEY, BinaryTagTypes.COMPOUND); + + int lowest = 0; + boolean any = false; + for (BinaryTag sectionTag : sections) { + if (sectionTag instanceof CompoundBinaryTag section) { + int sectionY = section.getInt(SECTION_Y_KEY); + if (!any || sectionY < lowest) { + lowest = sectionY; + any = true; + } + } + } + + return any ? chunk.putInt(Y_POS_KEY, lowest) : chunk; + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/TranslateBlockEntities.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/TranslateBlockEntities.java new file mode 100644 index 0000000..cd32b45 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/TranslateBlockEntities.java @@ -0,0 +1,133 @@ +package net.onelitefeather.falco.migration.steps; + +import net.kyori.adventure.nbt.BinaryTag; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.ListBinaryTag; +import net.kyori.adventure.nbt.StringBinaryTag; +import net.onelitefeather.falco.migration.MigrationContext; +import net.onelitefeather.falco.migration.MigrationStep; +import org.jetbrains.annotations.ApiStatus; + +import java.util.Map; + +/** + * Renames the {@code id} of every block entity a chunk carries, and nothing else. + *

+ * A block entity is part of the chunk in every version this module supports and stays there, so + * translating it is a rename applied to a tag that is already in the right place — unlike an entity, + * which has to move to another file from 1.17 onwards (see {@link CountEntities}). Two things about a + * block entity are explicitly out of scope for this step, and both are passed through + * untouched rather than fixed: the items inside a block entity — a chest's contents carry item + * ids that were renamed too, and this step does not look inside {@code Items} or any other nested + * list — and per-block-entity field changes that are not a rename of {@code id}, of which the + * sign text rework in Minecraft 1.20 (single {@code Text1}-{@code Text4} lines becoming + * {@code front_text}/{@code back_text} components) is the largest. A converted block entity is in the + * right place with the right {@code id}; what is inside it, and every field but {@code id}, has not + * been looked at. + *

+ *

+ * No verified block-entity {@code id} rename exists for the whole 1.13-26.1 span, so + * {@link #RENAMES} is empty. The 1.13 mapping file this module's block rules are checked against + * carries no {@code blockentities} list at all, so the difference that settles the block-rename + * question cannot be computed for block entities the same way. The route taken instead: 26.1's + * {@code blockentities} registry list has 49 entries, small enough to check exhaustively. Diffing that + * list across every version between 1.18 (the earliest version whose mapping file carries one at all) + * and 26.1 found not one name ever removed — every change across that whole span was an addition. + * Separately, PaperMC/DataConverter — the GPL rebuild of Mojang's own converter this module already + * treats as authoritative for structural questions no registry diff can answer — registers exactly one + * block-entity rename in its entire fix history from V99 to V4661: {@code minecraft:suspicious_sand} + * to {@code minecraft:brushable_block}, which cannot appear in a 1.13 world because + * {@code suspicious_sand} did not exist before 1.20 snapshots. Both independent sources agree: nothing + * a 1.13 world can contain ever had its block-entity {@code id} renamed on the way to 26.1. + *

+ *

+ * The block-entity list's own container key is renamed too, but not by this step. + * {@code Level.TileEntities} becomes the root-level {@code block_entities} in {@link UnfoldLevel} — + * the same snapshot (21w43a, DataVersion 2844) that removed {@code Level} and renamed + * {@code Sections} to {@code sections} also renamed this field, and {@code UnfoldLevel} handles both + * renames identically. By the time a pre-2844 chunk reaches this step in the chain, its block entities + * are therefore already under {@code block_entities}. This step still checks {@code TileEntities} as a + * fallback — reading whichever of the two keys is actually present — purely for its own robustness + * against being exercised outside the full chain (directly in a test, or ahead of a future reordering); + * in the chain this module builds, that fallback is never actually reached. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class TranslateBlockEntities implements MigrationStep { + + private static final String MODERN_KEY = "block_entities"; + private static final String LEGACY_KEY = "TileEntities"; + private static final String ID_KEY = "id"; + + /** + * The block-entity {@code id} renames this module has verified for the 1.13-26.1 span. Empty — + * see this class's Javadoc for why. + */ + private static final Map RENAMES = Map.of(); + + /** + * Creates a new instance of this stateless step. + */ + public TranslateBlockEntities() { + } + + @Override + public boolean appliesTo(int sourceVersion) { + return true; + } + + /** + * {@inheritDoc} + * + * @param chunk {@inheritDoc} + * @param context {@inheritDoc} + * @return {@code chunk} with every block entity's {@code id} translated through {@link #RENAMES}; + * unchanged if neither {@code block_entities} nor {@code TileEntities} is present + */ + @Override + public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context) { + String key = chunk.get(MODERN_KEY) instanceof ListBinaryTag ? MODERN_KEY + : chunk.get(LEGACY_KEY) instanceof ListBinaryTag ? LEGACY_KEY + : null; + if (key == null) { + return chunk; + } + + ListBinaryTag blockEntities = (ListBinaryTag) chunk.get(key); + if (blockEntities.isEmpty()) { + return chunk; + } + + ListBinaryTag translated = ListBinaryTag.empty(); + for (BinaryTag entry : blockEntities) { + translated = translated.add(translateEntry(entry)); + } + return chunk.put(key, translated); + } + + private static BinaryTag translateEntry(BinaryTag entry) { + if (!(entry instanceof CompoundBinaryTag compound) || !(compound.get(ID_KEY) instanceof StringBinaryTag idTag)) { + return entry; + } + String translated = translate(idTag.value(), RENAMES); + return translated.equals(idTag.value()) ? compound : compound.putString(ID_KEY, translated); + } + + /** + * The pure lookup {@link #apply} runs every block entity's {@code id} through. + *

+ * Package-private so a test can exercise the substitution mechanism itself against a rename table + * of its own, independent of {@link #RENAMES} — which was found to be empty for every {@code id} + * a 1.13 world can actually contain; see this class's Javadoc. + *

+ * + * @param id the block entity's current {@code id} + * @param renames the rename table to look {@code id} up in + * @return {@code renames.getOrDefault(id, id)} + */ + static String translate(String id, Map renames) { + return renames.getOrDefault(id, id); + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/TranslateBlockStates.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/TranslateBlockStates.java new file mode 100644 index 0000000..2adefae --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/TranslateBlockStates.java @@ -0,0 +1,227 @@ +package net.onelitefeather.falco.migration.steps; + +import net.kyori.adventure.nbt.BinaryTag; +import net.kyori.adventure.nbt.BinaryTagTypes; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.ListBinaryTag; +import net.kyori.adventure.nbt.LongArrayBinaryTag; +import net.kyori.adventure.nbt.StringBinaryTag; +import net.onelitefeather.falco.anvil.BitPacker; +import net.onelitefeather.falco.migration.BlockState; +import net.onelitefeather.falco.migration.BlockStateRules; +import net.onelitefeather.falco.migration.MigrationContext; +import net.onelitefeather.falco.migration.MigrationException; +import net.onelitefeather.falco.migration.MigrationStep; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Walks every section's block palette and puts each entry through + * {@link BlockStateRules#translate(BlockState, int)}, carrying the chunk's source version. + *

+ * Runs at every version — {@link #appliesTo(int)} always returns {@code true} — because a rename can + * apply anywhere in this module's whole 1.13-to-today range, and because this step is also the one + * that restructures a legacy section's block data into the modern container shape. + *

+ *

+ * A legacy section's block palette lives at the top level: {@code Palette} and + * {@code BlockStates}, siblings of {@code Y}. The nested {@code block_states: + * \{palette, data\}} container only exists from DataVersion 2844 onward — the same version that + * removed {@code Level} and introduced {@code yPos} — and no step earlier in the chain restructures + * it: {@link NormaliseBitPacking} only fixes the bit layout of the legacy {@code BlockStates} array, + * it does not rename or nest it. This step is therefore the one place that shape changes, for exactly + * the versions that still have it; a section that already carries {@code block_states} is read from + * there instead and always re-written as {@code block_states}, whether or not any rule fired. + *

+ *

+ * A palette entry is a compound with {@code Name} and, if the block has any, {@code Properties} — + * unchanged in shape across the whole range this module covers, only its container's position moved. + * Translating a palette entry can change its name, its properties, or both, but never the number of + * entries or which index of the packed data addresses which entry, so the packed indices themselves + * are carried through unchanged; only the palette they address is rewritten. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class TranslateBlockStates implements MigrationStep { + + private static final String SECTIONS_KEY = "sections"; + private static final String BLOCK_STATES_KEY = "block_states"; + private static final String LEGACY_PALETTE_KEY = "Palette"; + private static final String LEGACY_BLOCK_STATES_KEY = "BlockStates"; + private static final String PALETTE_KEY = "palette"; + private static final String DATA_KEY = "data"; + private static final String NAME_KEY = "Name"; + private static final String PROPERTIES_KEY = "Properties"; + + private static final int BLOCK_ENTRIES = 16 * 16 * 16; + + /** + * Minestom's {@code net.minestom.server.instance.palette.Palette.BLOCK_PALETTE_MIN_BITS} + * (checked in the sources jar of {@code net.minestom:minestom}). This module cannot depend on + * {@code net.minestom} — {@code falco-archunit}'s {@code migrationKnowsNoMinestom} forbids it — + * so the constant is pinned here instead, with its source named, rather than imported. Used only + * when this step chooses its own bits-per-entry while writing a container — never while + * reading one back; see {@link #readFrom} for why a palette-derived guess is not safe there. + */ + private static final int BLOCK_PALETTE_MIN_BITS = 4; + + /** + * Creates a new instance of this stateless step. + */ + public TranslateBlockStates() { + } + + @Override + public boolean appliesTo(int sourceVersion) { + return true; + } + + @Override + public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context) { + ListBinaryTag sections = chunk.getList(SECTIONS_KEY, BinaryTagTypes.COMPOUND); + if (sections.size() == 0) { + return chunk; + } + + ListBinaryTag translated = ListBinaryTag.empty(); + for (BinaryTag sectionTag : sections) { + translated = translated.add(sectionTag instanceof CompoundBinaryTag section + ? translate(section, context.sourceVersion()) + : sectionTag); + } + return chunk.put(SECTIONS_KEY, translated); + } + + private static CompoundBinaryTag translate(CompoundBinaryTag section, int sourceVersion) { + boolean alreadyModernShape = section.get(BLOCK_STATES_KEY) instanceof CompoundBinaryTag; + PaletteContainer container = readContainer(section); + if (container == null) { + return section; + } + + List translatedPalette = new ArrayList<>(container.palette().size()); + boolean anyStateChanged = false; + for (BlockState state : container.palette()) { + BlockState translated = BlockStateRules.translate(state, sourceVersion); + anyStateChanged |= !translated.equals(state); + translatedPalette.add(translated); + } + + if (alreadyModernShape && !anyStateChanged) { + // Nothing to restructure (the container is already in the modern shape) and no rule + // fired: unpacking and re-packing the section's data would only cost time and risk + // re-encoding it differently for no reason, so the whole section is passed through + // exactly as read, packed data included. + return section; + } + + CompoundBinaryTag rebuilt = writeContainer(translatedPalette, container.indices()); + return section.remove(LEGACY_PALETTE_KEY).remove(LEGACY_BLOCK_STATES_KEY).put(BLOCK_STATES_KEY, rebuilt); + } + + private static @Nullable PaletteContainer readContainer(CompoundBinaryTag section) { + if (section.get(BLOCK_STATES_KEY) instanceof CompoundBinaryTag modern) { + return readFrom(modern.getList(PALETTE_KEY, BinaryTagTypes.COMPOUND), modern.get(DATA_KEY)); + } + if (section.get(LEGACY_PALETTE_KEY) instanceof ListBinaryTag legacyPalette) { + return readFrom(legacyPalette, section.get(LEGACY_BLOCK_STATES_KEY)); + } + return null; + } + + /** + * Reads a palette and its packed indices, if any. + *

+ * The bits-per-entry a writer actually used is not assumed from the palette size. The + * format explicitly permits a writer to use more bits than a palette strictly needs, which is + * exactly why {@code falco-anvil}'s own {@code PaletteData.read} resolves the width against the + * packed array's own length instead of trusting the palette-derived minimum — and why this method + * does the same, through {@code BitPacker.resolveBitsPerEntry}, rather than repeating the mistake + * a palette-size guess would make: silently reading a 6-bit-packed palette of 17 entries as if it + * were the 5 bits the palette alone would suggest, corrupting every block in the section without + * throwing. + *

+ */ + private static PaletteContainer readFrom(ListBinaryTag paletteTag, @Nullable BinaryTag dataTag) { + List palette = new ArrayList<>(paletteTag.size()); + for (BinaryTag entryTag : paletteTag) { + if (entryTag instanceof CompoundBinaryTag entry) { + palette.add(readState(entry)); + } + } + + int[] indices; + if (dataTag instanceof LongArrayBinaryTag data) { + long[] packed = data.value(); + int expected = BitPacker.bitsPerEntry(palette.size(), BLOCK_PALETTE_MIN_BITS); + int bitsPerEntry = BitPacker.resolveBitsPerEntry(packed.length, BLOCK_ENTRIES, expected); + if (bitsPerEntry == 0) { + throw new MigrationException("A section's block data holds " + packed.length + + " longs, which matches no valid bits-per-entry for a palette of " + palette.size() + + " entries over " + BLOCK_ENTRIES + " block positions"); + } + indices = BitPacker.unpack(packed, BLOCK_ENTRIES, bitsPerEntry); + } else { + indices = new int[0]; + } + return new PaletteContainer(palette, indices); + } + + private static BlockState readState(CompoundBinaryTag entry) { + String name = entry.getString(NAME_KEY); + Map properties = new LinkedHashMap<>(); + if (entry.get(PROPERTIES_KEY) instanceof CompoundBinaryTag propertiesTag) { + for (Map.Entry property : propertiesTag) { + if (property.getValue() instanceof StringBinaryTag value) { + properties.put(property.getKey(), value.value()); + } + } + } + return new BlockState(name, properties); + } + + private static CompoundBinaryTag writeContainer(List palette, int[] indices) { + ListBinaryTag paletteTag = ListBinaryTag.empty(); + for (BlockState state : palette) { + paletteTag = paletteTag.add(writeState(state)); + } + + if (palette.size() > 1 && indices.length == 0) { + // A multi-entry palette with no indices to address it is not a uniform section the format + // can express by omitting 'data' — that shape means exactly one entry. Writing the palette + // anyway would produce a container no reader (including this loader's own, or vanilla's) + // can make sense of: several named options and nothing saying which block holds which. + throw new MigrationException("A section's palette holds " + palette.size() + + " entries but no packed indices to address them; a multi-entry palette without " + + "block data cannot be written as a valid container"); + } + + CompoundBinaryTag container = CompoundBinaryTag.builder().put(PALETTE_KEY, paletteTag).build(); + if (palette.size() > 1) { + int bitsPerEntry = BitPacker.bitsPerEntry(palette.size(), BLOCK_PALETTE_MIN_BITS); + long[] packed = BitPacker.pack(indices, bitsPerEntry); + container = container.put(DATA_KEY, LongArrayBinaryTag.longArrayBinaryTag(packed)); + } + return container; + } + + private static CompoundBinaryTag writeState(BlockState state) { + CompoundBinaryTag entry = CompoundBinaryTag.builder().putString(NAME_KEY, state.name()).build(); + if (!state.properties().isEmpty()) { + CompoundBinaryTag.Builder properties = CompoundBinaryTag.builder(); + state.properties().forEach(properties::putString); + entry = entry.put(PROPERTIES_KEY, properties.build()); + } + return entry; + } + + private record PaletteContainer(List palette, int[] indices) { + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/UnfoldLevel.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/UnfoldLevel.java new file mode 100644 index 0000000..f213602 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/UnfoldLevel.java @@ -0,0 +1,108 @@ +package net.onelitefeather.falco.migration.steps; + +import net.kyori.adventure.nbt.BinaryTag; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.onelitefeather.falco.migration.MigrationContext; +import net.onelitefeather.falco.migration.MigrationException; +import net.onelitefeather.falco.migration.MigrationStep; +import org.jetbrains.annotations.ApiStatus; + +import java.util.Map; + +/** + * Moves every child of the pre-1.18 {@code Level} compound onto the chunk's root, the shape every + * version from DataVersion 2844 onwards already stores a chunk in. + *

+ * Two field names change as they move, because the snapshot that removed {@code Level} (21w43a, + * DataVersion 2844) renamed them in the same breath: {@code Sections} becomes {@code sections}, and + * {@code TileEntities} becomes {@code block_entities}. Both renames are confirmed directly by that + * snapshot's own minecraft.wiki changelog, checked 2026-08-04. That same changelog entry also states + * {@code Level.Entities} moved to {@code entities} — a third rename this step deliberately does + * not perform: {@link CountEntities}, earlier in the chain, already establishes that nothing + * in this slice moves a chunk's entities anywhere, on purpose (see that class's own javadoc for "the + * entity debt"), so a chunk's {@code Entities} list is left exactly where {@code Level} leaves it — + * under whatever key it already has, moved to the root unrenamed like every other field this step + * does not know a rename for — rather than the destination the changelog names for a mover this + * module does not have. Every child keeps its own name as it moves except the two named above. A + * field that would silently overwrite a same-named field already present at the root is + * refused with a {@link MigrationException} instead: for every pre-1.18 format known to this module + * that case cannot happen, since a chunk's own top-level keys and {@code Level}'s children never + * collide, but a silent overwrite would contradict this project's fail-loud stance the moment some + * format this module has not seen turns out to disagree. + *

+ *

+ * {@code yPos} itself is not set here. DataVersion 2844 is also the version that introduced + * the field, but this step hands the decision of what it should hold to {@link SettleYRange}, which + * runs later in the chain, after biomes and block states have found their place in the unfolded + * {@code sections} list. See that class's javadoc for the sourced answer — the chunk's own lowest + * stored section, never an invented dimension floor — and why the two only diverge for a converted + * chunk in the first place. + *

+ *

+ * The list of fields this step moves is deliberately not written down here: it moves whatever + * {@code Level} actually holds, rather than an invented fixed set of names. A chunk with + * no {@code Level} compound at all — already unfolded, or never folded in the first place — is + * returned unchanged. + *

+ * + * @since 2.1.0 + */ +@ApiStatus.Experimental +public final class UnfoldLevel implements MigrationStep { + + /** + * DataVersion 2844 is the 21w43a snapshot that removed the {@code Level} compound and moved its + * contents to the chunk root. + */ + private static final int APPLIES_BELOW = 2844; + + private static final String LEVEL_KEY = "Level"; + + /** + * The two {@code Level} children whose own name changed in the same snapshot that removed + * {@code Level} itself — see this class's own javadoc for the source. Every other child keeps + * its name as it moves to the root. + */ + private static final Map RENAMED_ON_UNFOLD = Map.of( + "Sections", "sections", + "TileEntities", "block_entities"); + + /** + * Creates a new instance of this stateless step. + */ + public UnfoldLevel() { + } + + @Override + public boolean appliesTo(int sourceVersion) { + return sourceVersion < APPLIES_BELOW; + } + + /** + * {@inheritDoc} + * + * @param chunk {@inheritDoc} + * @param context {@inheritDoc} + * @return {@inheritDoc} + * @throws MigrationException if a child of {@code Level} — after {@link #RENAMED_ON_UNFOLD}, + * where applicable — has the same name as a field already present at + * the chunk's root, naming that field + */ + @Override + public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context) { + if (!(chunk.get(LEVEL_KEY) instanceof CompoundBinaryTag level)) { + return chunk; + } + + CompoundBinaryTag root = chunk.remove(LEVEL_KEY); + for (Map.Entry child : level) { + String key = RENAMED_ON_UNFOLD.getOrDefault(child.getKey(), child.getKey()); + if (root.get(key) != null) { + throw new MigrationException( + "Unfolding 'Level' would silently overwrite the existing root field '" + key + "'"); + } + root = root.put(key, child.getValue()); + } + return root; + } +} diff --git a/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/package-info.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/package-info.java new file mode 100644 index 0000000..d990163 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/package-info.java @@ -0,0 +1,15 @@ +/** + * The individual steps {@link net.onelitefeather.falco.migration.ChunkMigration} runs over a chunk's + * root compound, one {@link net.onelitefeather.falco.migration.MigrationStep} implementation per row + * of the step chain in + * {@code docs/superpowers/specs/2026-08-04-falco-migration-design.md}. + *

+ * Every public type here is experimental and may still change in a minor release. + *

+ * + * @since 2.1.0 + */ +@NotNullByDefault +package net.onelitefeather.falco.migration.steps; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/falco-migration/src/test/java/net/onelitefeather/falco/migration/BlockStateRulesTest.java b/falco-migration/src/test/java/net/onelitefeather/falco/migration/BlockStateRulesTest.java new file mode 100644 index 0000000..ff914e0 --- /dev/null +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/BlockStateRulesTest.java @@ -0,0 +1,230 @@ +package net.onelitefeather.falco.migration; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Pins down {@link BlockStateRules}: that rules are resolved by version, keyed on the whole state, + * and may change a block's name rather than only its properties. + */ +class BlockStateRulesTest { + + @Test + void testAPlainRenameIsApplied() { + assertEquals("minecraft:short_grass", + BlockStateRules.translate(BlockState.of("minecraft:grass"), 1519).name()); + } + + @Test + void testStoneSlabIsRenamedFromThirteenButNotFromSixteen() { + assertEquals("minecraft:smooth_stone_slab", + BlockStateRules.translate(BlockState.of("minecraft:stone_slab"), 1519).name()); + assertEquals("minecraft:stone_slab", + BlockStateRules.translate(BlockState.of("minecraft:stone_slab"), 2566).name()); + } + + @Test + void testACauldronsLevelDecidesItsName() { + BlockState empty = new BlockState("minecraft:cauldron", Map.of("level", "0")); + BlockState filled = new BlockState("minecraft:cauldron", Map.of("level", "2")); + + assertEquals("minecraft:cauldron", BlockStateRules.translate(empty, 1519).name()); + assertEquals(Map.of(), BlockStateRules.translate(empty, 1519).properties()); + + BlockState water = BlockStateRules.translate(filled, 1519); + assertEquals("minecraft:water_cauldron", water.name()); + assertEquals("2", water.properties().get("level")); + } + + @Test + void testAWallSideBecomesLowRatherThanTrue() { + BlockState wall = new BlockState("minecraft:cobblestone_wall", + Map.of("north", "true", "south", "false", "up", "true")); + + BlockState converted = BlockStateRules.translate(wall, 1519); + + assertEquals("low", converted.properties().get("north")); + assertEquals("none", converted.properties().get("south")); + assertEquals("true", converted.properties().get("up"), "up is not one of the four sides"); + } + + @Test + void testAMossyWallIsRewrittenByTheSameSharedTable() { + BlockState wall = new BlockState("minecraft:mossy_cobblestone_wall", Map.of("east", "true")); + + assertEquals("low", BlockStateRules.translate(wall, 1519).properties().get("east")); + } + + @Test + void testARuleAppliesExactlyBelowItsOwnVersionAndNotAtOrAboveIt() { + // stone_slab's rule fires below DataVersion 1901 (snapshot 18w43a, where the rename actually + // happened) and not at or above it. An earlier version of this test used 1801/1802, matching + // an earlier, wrong value for the rule itself; see BlockStateRules for the correction. + assertEquals("minecraft:smooth_stone_slab", + BlockStateRules.translate(BlockState.of("minecraft:stone_slab"), 1900).name()); + assertEquals("minecraft:stone_slab", + BlockStateRules.translate(BlockState.of("minecraft:stone_slab"), 1901).name()); + } + + @Test + void testASignIsRenamedToItsOnlyThirteenEraWoodType() { + assertEquals("minecraft:oak_sign", + BlockStateRules.translate(BlockState.of("minecraft:sign"), 1519).name()); + assertEquals("minecraft:oak_wall_sign", + BlockStateRules.translate(BlockState.of("minecraft:wall_sign"), 1519).name()); + } + + @Test + void testGrassPathIsRenamedToDirtPath() { + assertEquals("minecraft:dirt_path", + BlockStateRules.translate(BlockState.of("minecraft:grass_path"), 1519).name()); + } + + @Test + void testARenameCarriesItsPropertiesAlong() { + BlockState sign = new BlockState("minecraft:sign", Map.of("rotation", "4", "waterlogged", "false")); + + BlockState converted = BlockStateRules.translate(sign, 1519); + + assertEquals("minecraft:oak_sign", converted.name()); + assertEquals(Map.of("rotation", "4", "waterlogged", "false"), converted.properties()); + } + + @Test + void testAStateNoRuleKnowsAboutPassesThroughUnchanged() { + // Used to be redstone_wire, back when this module carried no rule for it at all. Now that + // BlockStateRules does resolve redstone_wire (see the dedicated tests below), a passthrough + // block that stays genuinely unmapped is furnace: its facing/lit properties exist unchanged + // from 1.13 to today, with nothing in RULES that names it. + BlockState furnace = new BlockState("minecraft:furnace", Map.of("facing", "north", "lit", "false")); + + assertEquals(furnace, BlockStateRules.translate(furnace, 1519), + "a state no rule recognizes must pass through translate() unchanged"); + } + + /** + * Builds a {@code redstone_wire} state with a fixed, deliberately odd {@code power} so tests can + * assert it is never read or written by the rule. + */ + private static BlockState redstoneWire(String north, String south, String east, String west) { + return new BlockState("minecraft:redstone_wire", + Map.of("north", north, "south", south, "east", east, "west", west, "power", "11")); + } + + private static void assertRedstoneSides(BlockState state, String north, String south, String east, + String west) { + assertEquals(north, state.properties().get("north"), "north"); + assertEquals(south, state.properties().get("south"), "south"); + assertEquals(east, state.properties().get("east"), "east"); + assertEquals(west, state.properties().get("west"), "west"); + } + + // The nine affected direction combinations (of 81), individually, so a crossed-axis mistake in + // the implementation cannot hide behind a shared helper's own bug. Each one is checked against + // BlockStateRules's derivation by hand in the redstone_wire rule's own comment. + + @Test + void testRedstoneWireWithNoConnectionsBecomesIsolatedOnAllFourSides() { + BlockState converted = BlockStateRules.translate(redstoneWire("none", "none", "none", "none"), 1519); + + assertRedstoneSides(converted, "side", "side", "side", "side"); + } + + @Test + void testRedstoneWireConnectedOnlyOnWestTurnsEastAndBothCrossSidesToSide() { + BlockState converted = BlockStateRules.translate(redstoneWire("none", "none", "none", "side"), 1519); + + assertRedstoneSides(converted, "none", "none", "side", "side"); + } + + @Test + void testRedstoneWireConnectedUpwardOnWestStillTurnsEastToSide() { + BlockState converted = BlockStateRules.translate(redstoneWire("none", "none", "none", "up"), 1519); + + assertRedstoneSides(converted, "none", "none", "side", "up"); + } + + @Test + void testRedstoneWireConnectedOnlyOnEastTurnsWestAndBothCrossSidesToSide() { + BlockState converted = BlockStateRules.translate(redstoneWire("none", "none", "side", "none"), 1519); + + assertRedstoneSides(converted, "none", "none", "side", "side"); + } + + @Test + void testRedstoneWireConnectedUpwardOnEastStillTurnsWestToSide() { + BlockState converted = BlockStateRules.translate(redstoneWire("none", "none", "up", "none"), 1519); + + assertRedstoneSides(converted, "none", "none", "up", "side"); + } + + @Test + void testRedstoneWireConnectedOnlyOnSouthTurnsNorthAndBothCrossSidesToSide() { + BlockState converted = BlockStateRules.translate(redstoneWire("none", "side", "none", "none"), 1519); + + assertRedstoneSides(converted, "side", "side", "none", "none"); + } + + @Test + void testRedstoneWireConnectedUpwardOnSouthStillTurnsNorthToSide() { + BlockState converted = BlockStateRules.translate(redstoneWire("none", "up", "none", "none"), 1519); + + assertRedstoneSides(converted, "side", "up", "none", "none"); + } + + @Test + void testRedstoneWireConnectedOnlyOnNorthTurnsSouthAndBothCrossSidesToSide() { + BlockState converted = BlockStateRules.translate(redstoneWire("side", "none", "none", "none"), 1519); + + assertRedstoneSides(converted, "side", "side", "none", "none"); + } + + @Test + void testRedstoneWireConnectedUpwardOnNorthStillTurnsSouthToSide() { + BlockState converted = BlockStateRules.translate(redstoneWire("up", "none", "none", "none"), 1519); + + assertRedstoneSides(converted, "up", "side", "none", "none"); + } + + // Fixed points: none of the 72 combinations outside the nine above may change. + + @Test + void testRedstoneWireWithTwoConnectionsOnTheSameAxisIsAFixedPoint() { + BlockState state = redstoneWire("none", "none", "side", "side"); + + assertEquals(state, BlockStateRules.translate(state, 1519)); + } + + @Test + void testRedstoneWireWithThreeConnectionsIsAFixedPoint() { + BlockState state = redstoneWire("side", "side", "side", "none"); + + assertEquals(state, BlockStateRules.translate(state, 1519)); + } + + @Test + void testRedstoneWireWithTwoConnectionsIncludingUpIsAFixedPoint() { + BlockState state = redstoneWire("up", "side", "none", "none"); + + assertEquals(state, BlockStateRules.translate(state, 1519)); + } + + @Test + void testRedstoneWiresPowerIsNeverReadOrWritten() { + BlockState converted = BlockStateRules.translate(redstoneWire("none", "none", "none", "none"), 1519); + + assertEquals("11", converted.properties().get("power"), + "power is a plain multiplier and must survive translate() untouched"); + } + + @Test + void testRedstoneWireIsUnchangedFromDataVersionTwentyFiveThirtyTwoOnwards() { + BlockState state = redstoneWire("none", "none", "none", "none"); + + assertEquals(state, BlockStateRules.translate(state, 2532), + "a source at or after DataVersion 2532 already carries the later connection meaning"); + } +} diff --git a/falco-migration/src/test/java/net/onelitefeather/falco/migration/ChunkMigrationTest.java b/falco-migration/src/test/java/net/onelitefeather/falco/migration/ChunkMigrationTest.java new file mode 100644 index 0000000..6c868d5 --- /dev/null +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/ChunkMigrationTest.java @@ -0,0 +1,124 @@ +package net.onelitefeather.falco.migration; + +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins down the step chain {@link ChunkMigration#migrate(CompoundBinaryTag, int)} runs, and the three + * steps that only move or delete data: {@code UnfoldLevel}, {@code NamespaceStatus} and + * {@code DiscardHeightmapsAndLight}. + */ +class ChunkMigrationTest { + + @Test + void testAPreEighteenChunkGetsItsFieldsOnTheRoot() { + // "postprocessed", not "full" — a 1.13-era chunk's own terminal status, per NamespaceStatus's + // own sourced javadoc. Every fixture in this module used to write "full" by hand, a value a + // pre-1.14 chunk never actually produces, which is exactly how the missing value translation + // this test now exercises went unnoticed by every other test in the suite. + CompoundBinaryTag legacy = CompoundBinaryTag.builder() + .putInt("DataVersion", 2566) + .put("Level", CompoundBinaryTag.builder() + .putInt("xPos", 3) + .putInt("zPos", 4) + .putString("Status", "postprocessed") + .put("Sections", ListBinaryTag.empty()) + .build()) + .build(); + + CompoundBinaryTag migrated = ChunkMigration.migrate(legacy, 4790); + + assertNull(migrated.get("Level")); + assertEquals(3, migrated.getInt("xPos")); + assertEquals("minecraft:full", migrated.getString("Status"), + "a 1.13 chunk's own terminal status, postprocessed, must become minecraft:full, not " + + "minecraft:postprocessed, or FalcoAnvilLoader silently skips every migrated chunk"); + assertNotNull(migrated.get("sections")); + // yPos = 0 because SettleYRange computes it from the chunk's own (here: empty) sections + // list rather than assuming a fixed floor — see that step's javadoc for the sourced reading + // of what yPos means ("the chunk's own lowest section", not the dimension floor) and why an + // empty sections list falls back to 0 rather than an arbitrary default. + assertEquals(0, migrated.getInt("yPos")); + } + + @Test + void testALevelFieldThatWouldOverwriteAnExistingRootFieldIsRejectedRatherThanSilentlyOverwritten() { + CompoundBinaryTag collision = CompoundBinaryTag.builder() + .putInt("DataVersion", 2566) + .putInt("xPos", 99) + .put("Level", CompoundBinaryTag.builder() + .putInt("xPos", 3) + .put("Sections", ListBinaryTag.empty()) + .build()) + .build(); + + MigrationException exception = assertThrows(MigrationException.class, + () -> ChunkMigration.migrate(collision, 4790)); + + assertTrue(exception.getMessage().contains("xPos"), + "the failure should name the field it refused to silently overwrite"); + } + + @Test + void testAModernChunkIsLeftAloneExceptForItsVersion() { + CompoundBinaryTag modern = CompoundBinaryTag.builder() + .putInt("DataVersion", 3700) + .putString("Status", "minecraft:full") + .put("sections", ListBinaryTag.empty()) + .build(); + + CompoundBinaryTag migrated = ChunkMigration.migrate(modern, 4790); + + assertEquals(4790, migrated.getInt("DataVersion")); + assertEquals("minecraft:full", migrated.getString("Status")); + } + + @Test + void testHeightmapsAndLightAreDroppedRatherThanConverted() { + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putInt("DataVersion", 2566) + .put("Level", CompoundBinaryTag.builder() + .put("Heightmaps", CompoundBinaryTag.builder() + .putLongArray("WORLD_SURFACE", new long[]{1L}) + .build()) + .put("Sections", ListBinaryTag.empty()) + .build()) + .build(); + + CompoundBinaryTag migrated = ChunkMigration.migrate(chunk, 4790); + + assertNull(migrated.get("Heightmaps"), "a wrongly ported heightmap never announces itself"); + } + + @Test + void testAChunkBelowTheFloorIsDeclinedRatherThanGuessedAt() { + CompoundBinaryTag ancient = CompoundBinaryTag.builder().putInt("DataVersion", 1000).build(); + + MigrationException exception = assertThrows(MigrationException.class, + () -> ChunkMigration.migrate(ancient, 4790)); + assertTrue(exception.getMessage().contains("1000"), + "a present, too-old DataVersion must be named in the failure"); + } + + @Test + void testAChunkWithNoDataVersionAtAllIsDeclinedWithADifferentMessageThanATooOldOne() { + // CompoundBinaryTag#getInt on a missing key defaults to 0, which is indistinguishable from a + // chunk that genuinely stamped "DataVersion: 0" unless the missing case is checked before + // that default is ever read — a real chunk of any age never carries a literal 0. + CompoundBinaryTag noVersion = CompoundBinaryTag.builder().build(); + + MigrationException exception = assertThrows(MigrationException.class, + () -> ChunkMigration.migrate(noVersion, 4790)); + assertFalse(exception.getMessage().contains("0"), + "a chunk with no DataVersion field at all must not be reported as DataVersion 0, which " + + "no real chunk of any age actually stores"); + } +} diff --git a/falco-migration/src/test/java/net/onelitefeather/falco/migration/LegacyBitReaderTest.java b/falco-migration/src/test/java/net/onelitefeather/falco/migration/LegacyBitReaderTest.java new file mode 100644 index 0000000..f892595 --- /dev/null +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/LegacyBitReaderTest.java @@ -0,0 +1,58 @@ +package net.onelitefeather.falco.migration; + +import net.onelitefeather.falco.anvil.BitPacker; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Pins down {@link LegacyBitReader}: that it reads an entry whole even when it spans two longs, and + * that {@code falco-anvil}'s {@link BitPacker} — which restarts at a long boundary for every entry — + * would read the same bytes differently. + */ +class LegacyBitReaderTest { + + @Test + void testAnEntryThatSpansTwoLongsIsReadWhole() { + // 5 bits per entry, entry index 12: its continuous bit offset is 12 * 5 = 60, so its 5 bits + // occupy continuous positions 60, 61, 62, 63, 64 — the last four bits of packed[0] and the + // first bit of packed[1] (bit positions are LSB-numbered within each long, matching + // BitPacker's own "<< bitOffset" convention). + // + // packed[0] = 0xF000_0000_0000_0000L: its top hex digit (F = 1111) occupies bits 60-63, so + // bit60=1, bit61=1, bit62=1, bit63=1; every other bit of packed[0] is 0. + // packed[1] = 0x0000_0000_0000_0001L: only its bit 0 is set, which is continuous bit 64. + // + // Reading the 5 bits of entry 12 from the low end to the high end: bit60=1 (entry bit 0), + // bit61=1 (bit 1), bit62=1 (bit 2), bit63=1 (bit 3), bit64=1 (bit 4). All five bits are 1, + // so the entry's value is 0b11111 = 31 — not 1 (which is what only reading packed[1]'s low + // bits, ignoring the four bits packed[0] contributes, would give). + long[] packed = {0xF000_0000_0000_0000L, 0x0000_0000_0000_0001L}; + + int[] values = LegacyBitReader.unpack(packed, 5, 13); + + assertEquals(0b11111, values[12], "the entry crosses the long boundary and must be read whole"); + } + + @Test + void testTheModernReaderWouldGetThatWrong() { + // BitPacker restarts at a long boundary for every entry instead of walking a continuous bit + // stream: with 5 bits per entry, 64 / 5 = 12 entries fit in one long (using only its low 60 + // bits; BitPacker leaves the top 4 bits of every long unused rather than letting an entry + // spill into the next one). Entry 12 is therefore the FIRST entry of the SECOND long: + // longIndex = 12 / 12 = 1, bitOffset = (12 % 12) * 5 = 0 — it reads bits 0-4 of packed[1], + // which is 0b00001 = 1, not the 31 LegacyBitReader reads for the same bytes. + // + // BitPacker.unpack's own parameter order is (packed, entryCount, bitsPerEntry) — the + // opposite of LegacyBitReader.unpack's (packed, bitsPerEntry, entryCount) — so the call + // below is BitPacker.unpack(packed, 13, 5), not BitPacker.unpack(packed, 5, 13). + long[] packed = {0xF000_0000_0000_0000L, 0x0000_0000_0000_0001L}; + + int[] modern = BitPacker.unpack(packed, 13, 5); + int[] legacy = LegacyBitReader.unpack(packed, 5, 13); + + assertNotEquals(modern[12], legacy[12], + "if these agree, the legacy reader is not doing anything and this module does not need it"); + } +} diff --git a/falco-migration/src/test/java/net/onelitefeather/falco/migration/WorldLayoutTest.java b/falco-migration/src/test/java/net/onelitefeather/falco/migration/WorldLayoutTest.java new file mode 100644 index 0000000..0624eda --- /dev/null +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/WorldLayoutTest.java @@ -0,0 +1,91 @@ +package net.onelitefeather.falco.migration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins down where {@link WorldLayout} looks for region files, across both the layout Anvil worlds + * used before Minecraft 1.16 and the one every version since keeps. + */ +class WorldLayoutTest { + + @Test + void testALegacyWorldYieldsAllThreeDimensions(@TempDir Path worldRoot) throws Exception { + Files.createDirectories(worldRoot.resolve("region")); + Files.createDirectories(worldRoot.resolve("DIM-1/region")); + Files.createDirectories(worldRoot.resolve("DIM1/region")); + + List found = WorldLayout.discover(worldRoot); + + assertEquals( + Set.of("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"), + found.stream().map(WorldLayout.Region::dimensionKey).collect(Collectors.toSet())); + assertTrue(found.stream().allMatch(WorldLayout.Region::legacy)); + } + + @Test + void testAModernWorldYieldsWhateverItContains(@TempDir Path worldRoot) throws Exception { + Files.createDirectories(worldRoot.resolve("dimensions/minecraft/overworld/region")); + Files.createDirectories(worldRoot.resolve("dimensions/mypack/mining/region")); + + List found = WorldLayout.discover(worldRoot); + + assertEquals( + Set.of("minecraft:overworld", "mypack:mining"), + found.stream().map(WorldLayout.Region::dimensionKey).collect(Collectors.toSet())); + assertFalse(found.stream().anyMatch(WorldLayout.Region::legacy)); + } + + @Test + void testADatapackDimensionIsNotHardCodedAway(@TempDir Path worldRoot) throws Exception { + Files.createDirectories(worldRoot.resolve("dimensions/mypack/mining/region")); + + assertEquals(1, WorldLayout.discover(worldRoot).size()); + } + + @Test + void testTheNetherLandsInItsModernPlace(@TempDir Path worldRoot) { + assertEquals( + worldRoot.resolve("dimensions/minecraft/the_nether/region"), + WorldLayout.targetDirectory(worldRoot, "minecraft:the_nether")); + } + + @Test + void testAWorldWithNoRegionsAtAllIsEmptyRatherThanAnError(@TempDir Path worldRoot) throws Exception { + assertTrue(WorldLayout.discover(worldRoot).isEmpty()); + } + + @Test + void testAPartiallyMigratedOverworldIsReturnedOnceLegacyAndOnceModern(@TempDir Path worldRoot) throws Exception { + Files.createDirectories(worldRoot.resolve("region")); + Files.createDirectories(worldRoot.resolve("dimensions/minecraft/overworld/region")); + + List found = WorldLayout.discover(worldRoot); + + assertEquals(2, found.size()); + assertTrue(found.stream().allMatch(region -> region.dimensionKey().equals("minecraft:overworld"))); + assertEquals( + Set.of(true, false), + found.stream().map(WorldLayout.Region::legacy).collect(Collectors.toSet())); + } + + @Test + void testTargetDirectoryRejectsAKeyWithoutANamespaceSeparator() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> WorldLayout.targetDirectory(Path.of("world"), "overworld")); + + assertTrue(exception.getMessage().contains("overworld")); + } +} diff --git a/falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/BlockEntityStepTest.java b/falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/BlockEntityStepTest.java new file mode 100644 index 0000000..a043572 --- /dev/null +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/BlockEntityStepTest.java @@ -0,0 +1,160 @@ +package net.onelitefeather.falco.migration.steps; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.ListBinaryTag; +import net.onelitefeather.falco.migration.ChunkMigration; +import net.onelitefeather.falco.migration.MigrationContext; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Pins down the two block-entity-facing steps this task adds: {@link TranslateBlockEntities}, which + * renames only a block entity's {@code id}, and {@link CountEntities}, which counts the entities a + * pre-1.17 chunk still carries in its own {@code Entities} list without moving them. + */ +class BlockEntityStepTest { + + @Test + void testABlockEntityWithNoVerifiedRenameKeepsItsIdAndPositionUnchanged() { + // This task's research (see the report) found no verified block-entity id rename anywhere in + // the 1.13-26.1 span, so every real id - a chest included - is expected to pass through + // unchanged. This is the honest replacement for a "gets renamed" fixture the brief's own + // example assumed but which the research did not confirm. + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putInt("DataVersion", 3700) + .put("block_entities", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder() + .putString("id", "minecraft:chest") + .putInt("x", 5) + .putInt("y", 64) + .putInt("z", -12) + .build()))) + .build(); + + CompoundBinaryTag migrated = ChunkMigration.migrate(chunk, 4790); + + ListBinaryTag blockEntities = (ListBinaryTag) migrated.get("block_entities"); + CompoundBinaryTag blockEntity = (CompoundBinaryTag) blockEntities.iterator().next(); + assertEquals("minecraft:chest", blockEntity.getString("id")); + assertEquals(5, blockEntity.getInt("x")); + assertEquals(64, blockEntity.getInt("y")); + assertEquals(-12, blockEntity.getInt("z")); + } + + @Test + void testALegacyTileEntitiesListEndsUpUnderBlockEntitiesBecauseUnfoldLevelRenamesTheContainerToo() { + // UnfoldLevel renames TileEntities to block_entities in the same move that renames + // Sections to sections, both landing in the snapshot that removed Level in the first + // place (21w43a, DataVersion 2844) - see UnfoldLevel's own Javadoc for the source. + // TranslateBlockEntities' own TileEntities fallback is therefore never actually reached + // for a chunk that goes through this chain; this pins the fixed, end-to-end behaviour. + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putInt("DataVersion", 2566) + .put("Level", CompoundBinaryTag.builder() + .putInt("xPos", 1) + .putInt("zPos", 1) + .put("Sections", ListBinaryTag.empty()) + .put("TileEntities", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putString("id", "minecraft:furnace").build()))) + .build()) + .build(); + + CompoundBinaryTag migrated = ChunkMigration.migrate(chunk, 4790); + + assertNull(migrated.get("TileEntities"), "the legacy container key must not survive"); + ListBinaryTag blockEntities = assertInstanceOf(ListBinaryTag.class, migrated.get("block_entities"), + "UnfoldLevel renames TileEntities to block_entities alongside Sections -> sections"); + CompoundBinaryTag blockEntity = (CompoundBinaryTag) blockEntities.iterator().next(); + assertEquals("minecraft:furnace", blockEntity.getString("id")); + } + + @Test + void testTheSubstitutionMechanismWouldRenameAnIdIfAVerifiedRuleExisted() { + // A synthetic table, not a claim about real Minecraft data: this only proves + // TranslateBlockEntities.translate's lookup wiring works, independent of the fact that + // today's verified table (RENAMES) is empty. + Map synthetic = Map.of("minecraft:old_name", "minecraft:new_name"); + + assertEquals("minecraft:new_name", TranslateBlockEntities.translate("minecraft:old_name", synthetic)); + assertEquals("minecraft:untouched", TranslateBlockEntities.translate("minecraft:untouched", synthetic)); + } + + @Test + void testTheEntitiesLeftInTheChunkAreCountedRatherThanMoved() { + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putInt("DataVersion", 1519) + .put("Level", CompoundBinaryTag.builder() + .putInt("xPos", 1519) + .putInt("zPos", 4790) + .put("Sections", ListBinaryTag.empty()) + .put("Entities", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putString("id", "minecraft:zombie").build(), + CompoundBinaryTag.builder().putString("id", "minecraft:item").build()))) + .build()) + .build(); + MigrationContext context = new MigrationContext(1519, 4790); + + ChunkMigration.migrate(chunk, context); + + assertEquals(2, context.entitiesLeftBehind()); + } + + @Test + void testAChunkAtOrAfterTheExtractionVersionIsNotCountedBecauseItsEntitiesAlreadyLeftTheChunk() { + // DataVersion 2681 is 20w45a, the snapshot that actually extracted entities into their own + // entities/ region files - not 2724, the 1.17 release number the design's step table names. + // See CountEntities' Javadoc and the task report for the correction and its two sources. + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putInt("DataVersion", 2681) + .put("Level", CompoundBinaryTag.builder() + .putInt("xPos", 0) + .putInt("zPos", 0) + .put("Sections", ListBinaryTag.empty()) + .put("Entities", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putString("id", "minecraft:zombie").build()))) + .build()) + .build(); + MigrationContext context = new MigrationContext(2681, 4790); + + ChunkMigration.migrate(chunk, context); + + assertEquals(0, context.entitiesLeftBehind()); + } + + @Test + void testTheEntityCountAccumulatesAcrossMultipleChunksSharingOneContext() { + MigrationContext context = new MigrationContext(1519, 4790); + CompoundBinaryTag chunkWithOneEntity = CompoundBinaryTag.builder() + .putInt("DataVersion", 1519) + .put("Level", CompoundBinaryTag.builder() + .putInt("xPos", 0) + .putInt("zPos", 0) + .put("Sections", ListBinaryTag.empty()) + .put("Entities", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putString("id", "minecraft:cow").build()))) + .build()) + .build(); + CompoundBinaryTag chunkWithTwoEntities = CompoundBinaryTag.builder() + .putInt("DataVersion", 1519) + .put("Level", CompoundBinaryTag.builder() + .putInt("xPos", 1) + .putInt("zPos", 0) + .put("Sections", ListBinaryTag.empty()) + .put("Entities", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putString("id", "minecraft:pig").build(), + CompoundBinaryTag.builder().putString("id", "minecraft:sheep").build()))) + .build()) + .build(); + + ChunkMigration.migrate(chunkWithOneEntity, context); + ChunkMigration.migrate(chunkWithTwoEntities, context); + + assertEquals(3, context.entitiesLeftBehind()); + } +} diff --git a/falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/NamespaceStatusTest.java b/falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/NamespaceStatusTest.java new file mode 100644 index 0000000..96ed351 --- /dev/null +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/NamespaceStatusTest.java @@ -0,0 +1,81 @@ +package net.onelitefeather.falco.migration.steps; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.onelitefeather.falco.migration.MigrationContext; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Pins {@link NamespaceStatus} down against the real, sourced pre-1.14 status values rather than the + * literal {@code "full"} every fixture elsewhere in this module used to write by hand — a value a + * 1.13 chunk never actually produces, which is exactly how the missing value translation this class + * fixes went unnoticed until the final review measured it directly against + * {@code FalcoAnvilLoader}. See {@link NamespaceStatus}'s own javadoc for the sourced chain + * ({@code postprocessed}/{@code fullchunk} to {@code full}, via + * {@code PaperMC/DataConverter}'s {@code V1905}/{@code V1911}, cross-checked against 1.13.2's own + * decompiled {@code ChunkStatus}). + */ +class NamespaceStatusTest { + + private static final MigrationContext ANY_CONTEXT = new MigrationContext(1519, 4790); + + @Test + void testAGenuineNineteenThirteenTerminalStatusBecomesMinecraftFull() { + // "postprocessed": a 1.13 chunk that has been loaded at least once and had its queued block + // updates run — the terminal status a chunk which has actually been played near carries. + CompoundBinaryTag chunk = CompoundBinaryTag.builder().putString("Status", "postprocessed").build(); + + CompoundBinaryTag namespaced = new NamespaceStatus().apply(chunk, ANY_CONTEXT); + + assertEquals("minecraft:full", namespaced.getString("Status")); + } + + @Test + void testAGenuineNineteenThirteenFullChunkStatusAlsoBecomesMinecraftFull() { + // "fullchunk": a 1.13 chunk whose terrain, decoration and lighting are all complete but which + // has never been loaded as a full, ticking chunk — equally "fully generated" as + // "postprocessed" for this module's purposes (FalcoAnvilLoader gates only on completeness), + // just never queued for the block updates postprocessing runs. + CompoundBinaryTag chunk = CompoundBinaryTag.builder().putString("Status", "fullchunk").build(); + + CompoundBinaryTag namespaced = new NamespaceStatus().apply(chunk, ANY_CONTEXT); + + assertEquals("minecraft:full", namespaced.getString("Status")); + } + + @Test + void testANonTerminalNineteenThirteenStatusIsNamespacedButNotRenamedToFull() { + // "finalized": a real 1.13 pipeline stage (mobs have spawned, lighting still pending - + // actually the stage right before fullchunk), but not the terminal one. This module cannot + // source a modern name for it, so it is namespaced and left alone rather than guessed at - + // which FalcoAnvilLoader still correctly treats as "not fully generated", the same outcome an + // exact modern name would have produced for this step's own purposes. + CompoundBinaryTag chunk = CompoundBinaryTag.builder().putString("Status", "finalized").build(); + + CompoundBinaryTag namespaced = new NamespaceStatus().apply(chunk, ANY_CONTEXT); + + assertEquals("minecraft:finalized", namespaced.getString("Status")); + } + + @Test + void testAnAlreadyNamespacedStatusIsLeftCompletelyAlone() { + CompoundBinaryTag chunk = CompoundBinaryTag.builder().putString("Status", "minecraft:carvers").build(); + + CompoundBinaryTag namespaced = new NamespaceStatus().apply(chunk, ANY_CONTEXT); + + assertEquals("minecraft:carvers", namespaced.getString("Status")); + } + + @Test + void testAChunkWithNoStatusFieldIsReturnedUnchanged() { + CompoundBinaryTag chunk = CompoundBinaryTag.builder().putInt("DataVersion", 1519).build(); + + CompoundBinaryTag result = new NamespaceStatus().apply(chunk, ANY_CONTEXT); + + assertSame(chunk, result); + assertNull(result.get("Status")); + } +} diff --git a/falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/SectionStepsTest.java b/falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/SectionStepsTest.java new file mode 100644 index 0000000..e13338a --- /dev/null +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/SectionStepsTest.java @@ -0,0 +1,664 @@ +package net.onelitefeather.falco.migration.steps; + +import net.kyori.adventure.nbt.BinaryTagTypes; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.ListBinaryTag; +import net.onelitefeather.falco.anvil.BitPacker; +import net.onelitefeather.falco.migration.ChunkMigration; +import net.onelitefeather.falco.migration.MigrationContext; +import net.onelitefeather.falco.migration.MigrationException; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins down the three section-facing steps this task adds: {@link NormaliseBitPacking}, which + * re-packs a pre-1.16 section's boundary-spanning block data into the long-aligned layout + * {@code BitPacker} can read; {@link RebuildBiomes}, which turns a whole-chunk biome array into a + * palettised container per section; and {@link TranslateBlockStates}, which walks every section's + * palette through {@link net.onelitefeather.falco.migration.BlockStateRules#translate}. + */ +class SectionStepsTest { + + private static final MigrationContext ANY_CONTEXT = new MigrationContext(1519, 4790); + private static final int BLOCK_ENTRIES = 16 * 16 * 16; + + // --- NormaliseBitPacking ------------------------------------------------------------------- + + @Test + void testASectionsSpanningBlockStatesAreReadableWithBitPackerAfterNormalising() { + // A palette of 17 distinct entries forces 5 bits per entry (BitPacker.bitsPerEntry(17, 4)): + // 5 does not divide 64, so the legacy (pre-1.16) packing this fixture builds by hand + // genuinely lets entries span a long boundary, exactly the case this step exists for. + int bitsPerEntry = 5; + int[] values = new int[16 * 16 * 16]; + for (int i = 0; i < values.length; i++) { + values[i] = i % 17; + } + long[] legacyPacked = legacyPack(values, bitsPerEntry); + + ListBinaryTag palette = ListBinaryTag.empty(); + for (int i = 0; i < 17; i++) { + palette = palette.add(CompoundBinaryTag.builder().putString("Name", "minecraft:test_" + i).build()); + } + + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder() + .put("Sections", ListBinaryTag.from(List.of(CompoundBinaryTag.builder() + .putByte("Y", (byte) 0) + .put("Palette", palette) + .putLongArray("BlockStates", legacyPacked) + .build()))) + .build()) + .build(); + + CompoundBinaryTag normalised = new NormaliseBitPacking().apply(chunk, ANY_CONTEXT); + + long[] repacked = normalised.getCompound("Level").getList("Sections").getCompound(0).getLongArray("BlockStates"); + int[] roundTripped = BitPacker.unpack(repacked, values.length, bitsPerEntry); + assertArrayEquals(values, roundTripped, + "BitPacker must read back exactly what the legacy, spanning layout held"); + } + + @Test + void testAnOverWidthPackedPaletteIsReadAtTheWidthItWasActuallyPackedWithButWrittenBackCanonically() { + // 17 entries need only 5 bits (BitPacker.bitsPerEntry(17, 4)), but the format explicitly + // allows a writer to use more. This fixture deliberately packs with 6 to prove that READING + // still derives the width from the array's own length rather than assumed from the palette + // size, which would silently misread 6-bit data as 5-bit and corrupt every block in the + // section without throwing. What comes back out, though, is re-packed at the 5-bit canonical + // width, not the 6 bits the input used — see this step's own javadoc for why preserving an + // over-provisioned width is not safe for TranslateBlockStates to read back later. + int bitsPerEntry = 6; + int[] values = new int[16 * 16 * 16]; + for (int i = 0; i < values.length; i++) { + values[i] = i % 17; + } + long[] overWidthPacked = legacyPack(values, bitsPerEntry); + + ListBinaryTag palette = ListBinaryTag.empty(); + for (int i = 0; i < 17; i++) { + palette = palette.add(CompoundBinaryTag.builder().putString("Name", "minecraft:test_" + i).build()); + } + + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder() + .put("Sections", ListBinaryTag.from(List.of(CompoundBinaryTag.builder() + .putByte("Y", (byte) 0) + .put("Palette", palette) + .putLongArray("BlockStates", overWidthPacked) + .build()))) + .build()) + .build(); + + CompoundBinaryTag normalised = new NormaliseBitPacking().apply(chunk, ANY_CONTEXT); + + long[] repacked = normalised.getCompound("Level").getList("Sections").getCompound(0).getLongArray("BlockStates"); + int canonicalBitsPerEntry = BitPacker.bitsPerEntry(17, 4); + assertEquals(5, canonicalBitsPerEntry, "sanity check on the fixture's own arithmetic"); + int[] roundTripped = BitPacker.unpack(repacked, values.length, canonicalBitsPerEntry); + assertArrayEquals(values, roundTripped, + "the values read at the 6-bit width the writer actually used must survive, but the " + + "output must be written at the 5-bit canonical width, not 6"); + } + + /** + * The measured failure from the final review: a palette of 2000 entries needs 11 bits + * ({@code BitPacker.bitsPerEntry(2000, 4)}), but a writer packing at 12 bits (still a legal, + * over-provisioned choice) produces an array of the same length — + * {@code BitPacker.expectedLongCount(4096, 11) == BitPacker.expectedLongCount(4096, 12) == 820} — + * because both widths fit exactly 5 entries per 64-bit long. A step that preserved the 12-bit + * read width verbatim left {@link TranslateBlockStates}'s own read-side heuristic no way to tell + * the two apart from the array's length and the (untranslated) palette size alone, and it always + * resolves the ambiguity to the smaller width — silently misreading 3274 of 4096 blocks in the + * section the reviewer actually measured this against. Canonicalising to the palette's own + * 11-bit minimum on the way out removes the ambiguity outright: there is no longer a 12-bit + * array for the heuristic to guess wrong about. + */ + @Test + void testAnOverWidthPackedSectionIsCanonicalisedRatherThanPreservingTheAmbiguousWidth() { + int paletteSize = 2000; + int readBitsPerEntry = 12; + int canonicalBitsPerEntry = BitPacker.bitsPerEntry(paletteSize, 4); + assertEquals(11, canonicalBitsPerEntry, "sanity check on the fixture's own arithmetic"); + assertEquals(BitPacker.expectedLongCount(BLOCK_ENTRIES, readBitsPerEntry), + BitPacker.expectedLongCount(BLOCK_ENTRIES, canonicalBitsPerEntry), + "sanity check: 11 and 12 bits must actually collide on long count for this to be the " + + "case the review measured"); + + int[] values = new int[BLOCK_ENTRIES]; + for (int i = 0; i < values.length; i++) { + values[i] = i % paletteSize; + } + long[] overWidthPacked = legacyPack(values, readBitsPerEntry); + + ListBinaryTag palette = ListBinaryTag.empty(); + for (int i = 0; i < paletteSize; i++) { + palette = palette.add(CompoundBinaryTag.builder().putString("Name", "minecraft:test_" + i).build()); + } + + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder() + .put("Sections", ListBinaryTag.from(List.of(CompoundBinaryTag.builder() + .putByte("Y", (byte) 0) + .put("Palette", palette) + .putLongArray("BlockStates", overWidthPacked) + .build()))) + .build()) + .build(); + + CompoundBinaryTag normalised = new NormaliseBitPacking().apply(chunk, ANY_CONTEXT); + + long[] repacked = normalised.getCompound("Level").getList("Sections").getCompound(0).getLongArray("BlockStates"); + assertEquals(BitPacker.expectedLongCount(BLOCK_ENTRIES, canonicalBitsPerEntry), repacked.length, + "the output must be written at the unambiguous 11-bit width, so its own length no " + + "longer collides with 12 bits' length for a downstream reader"); + int[] roundTripped = BitPacker.unpack(repacked, values.length, canonicalBitsPerEntry); + assertArrayEquals(values, roundTripped, + "every one of the 4096 values read at 12 bits must survive being written back at 11"); + } + + @Test + void testASectionWithBlockStatesButNoPaletteFailsRatherThanGuessingTheWidth() { + long[] packed = new long[820]; // a plausible length; the point is the missing Palette + CompoundBinaryTag section = CompoundBinaryTag.builder() + .putByte("Y", (byte) 0) + .putLongArray("BlockStates", packed) + .build(); + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder() + .put("Sections", ListBinaryTag.from(List.of(section))) + .build()) + .build(); + + MigrationException exception = assertThrows(MigrationException.class, + () -> new NormaliseBitPacking().apply(chunk, ANY_CONTEXT)); + assertTrue(exception.getMessage().contains("Palette")); + } + + @Test + void testABlockStatesArrayWhoseLengthIsNotAMultipleOfSixtyFourEntriesFailsRatherThanRoundingDown() { + // 63 longs cannot hold any whole number of bits-per-entry over 4096 positions (63 * 64 / 4096 + // rounds down to 0 under plain integer division): the old, unchecked division would have + // silently accepted this as "0 bits per entry" and only failed two calls later, inside + // LegacyBitReader, with an unrelated-looking IllegalArgumentException. A single palette entry + // is fine here — even a valid single-entry section carries no BlockStates array at all, so an + // array of any length alongside a palette is already a shape only a corrupt or hand-built + // chunk could produce, and the step must say so itself rather than let a lower-level class do + // it by accident. + long[] tooShort = new long[63]; + ListBinaryTag palette = ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putString("Name", "minecraft:stone").build(), + CompoundBinaryTag.builder().putString("Name", "minecraft:air").build())); + CompoundBinaryTag section = CompoundBinaryTag.builder() + .putByte("Y", (byte) 0) + .put("Palette", palette) + .putLongArray("BlockStates", tooShort) + .build(); + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder() + .put("Sections", ListBinaryTag.from(List.of(section))) + .build()) + .build(); + + MigrationException exception = assertThrows(MigrationException.class, + () -> new NormaliseBitPacking().apply(chunk, ANY_CONTEXT)); + assertTrue(exception.getMessage().contains("63")); + } + + @Test + void testABlockStatesArrayLongEnoughForOneBitButNotAnExactMultipleFailsRatherThanRoundingDown() { + // 100 longs: at least one bit's worth (64 longs) but 100 * 64 = 6400, which does not divide + // evenly by 4096 (6400 / 4096 = 1.5625). Plain integer division would silently round this + // down to "1 bit per entry" and misread the array rather than refusing it. + long[] notAMultiple = new long[100]; + ListBinaryTag palette = ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putString("Name", "minecraft:stone").build(), + CompoundBinaryTag.builder().putString("Name", "minecraft:air").build())); + CompoundBinaryTag section = CompoundBinaryTag.builder() + .putByte("Y", (byte) 0) + .put("Palette", palette) + .putLongArray("BlockStates", notAMultiple) + .build(); + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder() + .put("Sections", ListBinaryTag.from(List.of(section))) + .build()) + .build(); + + MigrationException exception = assertThrows(MigrationException.class, + () -> new NormaliseBitPacking().apply(chunk, ANY_CONTEXT)); + assertTrue(exception.getMessage().contains("100")); + } + + @Test + void testASingleValueSectionWithNoBlockStatesArrayIsLeftAlone() { + CompoundBinaryTag section = CompoundBinaryTag.builder() + .putByte("Y", (byte) 0) + .put("Palette", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putString("Name", "minecraft:air").build()))) + .build(); + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder() + .put("Sections", ListBinaryTag.from(List.of(section))) + .build()) + .build(); + + CompoundBinaryTag normalised = new NormaliseBitPacking().apply(chunk, ANY_CONTEXT); + + assertEquals(section, normalised.getCompound("Level").getList("Sections").getCompound(0)); + } + + // --- RebuildBiomes --------------------------------------------------------------------------- + + @Test + void testAOneThousandTwentyFourEntryBiomeArrayBecomesAUniformPaletteForItsSection() { + int[] biomes = new int[1024]; + java.util.Arrays.fill(biomes, 0, 64, 1); // section Y=0: every cell is id 1 (plains) + + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putIntArray("Biomes", biomes) + .put("sections", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putInt("Y", 0).put("Palette", airPalette()).build()))) + .build(); + + CompoundBinaryTag rebuilt = new RebuildBiomes().apply(chunk, ANY_CONTEXT); + + assertNull(rebuilt.get("Biomes"), "the whole-chunk array is consumed, not kept alongside the new containers"); + CompoundBinaryTag section = rebuilt.getList("sections").getCompound(0); + CompoundBinaryTag biomesContainer = section.getCompound("biomes"); + assertEquals(1, biomesContainer.getList("palette").size()); + assertEquals("minecraft:plains", biomesContainer.getList("palette").getString(0)); + assertNull(biomesContainer.get("data"), "a single-entry palette carries no packed data"); + } + + @Test + void testAPreFifteenTwoHundredFiftySixEntryArrayIsSampledAtEachQuadrantsCentreAndRepeatedByHeight() { + // Mirrors PaperMC/DataConverter's V2202 (DataVersion 2203) exactly: for XZ quadrant (i, j) + // the sampled column is old index ((i*4+2) << 4) | (j*4+2). Placing legacy biome id i*4+j at + // exactly those 16 centre columns, and an id that must never be sampled everywhere else, + // means the resulting section palette must be built purely from the centre samples in + // exactly that (i, j) order, proving the quadrant math rather than an average or a different + // sampling point. Ids 0-15 are themselves real, sourced entries of this step's own legacy + // biome table (ocean through mushroom_field_shore), so the expected names below are not + // invented for the test. + int[] legacy = new int[256]; + java.util.Arrays.fill(legacy, 999); // never assigned in this step's table; must not be sampled + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + int l = (i << 2) + 2; + int k = (j << 2) + 2; + legacy[(l << 4) | k] = i * 4 + j; // 16 distinct sampled ids, 0..15, in scan order + } + } + List expectedPalette = List.of( + "minecraft:ocean", "minecraft:plains", "minecraft:desert", "minecraft:mountains", + "minecraft:forest", "minecraft:taiga", "minecraft:swamp", "minecraft:river", + "minecraft:nether_wastes", "minecraft:the_end", "minecraft:frozen_ocean", "minecraft:frozen_river", + "minecraft:snowy_tundra", "minecraft:snowy_mountains", "minecraft:mushroom_fields", + "minecraft:mushroom_field_shore"); + + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putIntArray("Biomes", legacy) + .put("sections", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putInt("Y", 0).put("Palette", airPalette()).build(), + CompoundBinaryTag.builder().putInt("Y", 5).put("Palette", airPalette()).build()))) + .build(); + + CompoundBinaryTag rebuilt = new RebuildBiomes().apply(chunk, ANY_CONTEXT); + + for (int sectionIndex = 0; sectionIndex < 2; sectionIndex++) { + CompoundBinaryTag biomes = rebuilt.getList("sections").getCompound(sectionIndex).getCompound("biomes"); + ListBinaryTag palette = biomes.getList("palette"); + assertEquals(expectedPalette.size(), palette.size(), + "every one of the 16 sampled ids, and only the sampled ids, must appear in every Y-layer, " + + "because a pre-1.15 array has no variance by height"); + for (int p = 0; p < palette.size(); p++) { + assertEquals(expectedPalette.get(p), palette.getString(p), + "the centre-sample scan order (i, j) fixes the palette's own order"); + } + + // The 64 cells of each section cycle through local indices 0..15 four times over (64 / + // 16 = 4), because the widened 4x4 layer is repeated across every Y-group unchanged. + long[] packed = biomes.getLongArray("data"); + int bitsPerEntry = BitPacker.bitsPerEntry(palette.size(), 1); + int[] indices = BitPacker.unpack(packed, 64, bitsPerEntry); + for (int cell = 0; cell < 64; cell++) { + assertEquals(cell % 16, indices[cell], "cell " + cell + " of section " + sectionIndex); + } + } + } + + @Test + void testAnUnknownLegacyBiomeIdFailsRatherThanInventingAName() { + int[] biomes = new int[1024]; + java.util.Arrays.fill(biomes, 0, 64, 253); // never assigned by PaperMC/DataConverter's V2832 + + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putIntArray("Biomes", biomes) + .put("sections", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putInt("Y", 0).put("Palette", airPalette()).build()))) + .build(); + + MigrationException exception = assertThrows(MigrationException.class, + () -> new RebuildBiomes().apply(chunk, ANY_CONTEXT)); + assertTrue(exception.getMessage().contains("253")); + } + + // --- Sections without block data, discarded by content rather than by Y range ----------------- + + @Test + void testASectionWithoutBlockDataAtNegativeYIsDroppedRatherThanKept() { + // Vanilla writes one extra section below a chunk's real content (Y = -1) purely to carry + // lighting data for the section it borders; such a section never carries Palette, + // BlockStates or block_states. No Biomes array is present, so only the content-based + // discarding itself is exercised here, independent of the Y < 0 guard around the biome + // offset (that guard is proven separately below). + CompoundBinaryTag lightingOnly = CompoundBinaryTag.builder().putInt("Y", -1).build(); + CompoundBinaryTag realContent = CompoundBinaryTag.builder().putInt("Y", 0).put("Palette", airPalette()).build(); + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("sections", ListBinaryTag.from(List.of(lightingOnly, realContent))) + .build(); + + CompoundBinaryTag rebuilt = new RebuildBiomes().apply(chunk, ANY_CONTEXT); + + ListBinaryTag sections = rebuilt.getList("sections"); + assertEquals(1, sections.size(), "the section without block data must not survive into the output"); + assertEquals(0, sections.getCompound(0).getInt("Y")); + } + + @Test + void testASectionWithBlockDataAtNegativeYSurvivesWhenNoBiomesArrayIsPresent() { + // A configurable-height world (possible from DataVersion 2685 onward) can genuinely store + // real content below Y = 0; a section carrying block data there must survive discarding, + // regardless of its Y. No Biomes array is present, so the offset guard below never fires. + CompoundBinaryTag realContentBelowZero = CompoundBinaryTag.builder() + .putInt("Y", -1) + .put("Palette", airPalette()) + .build(); + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("sections", ListBinaryTag.from(List.of(realContentBelowZero))) + .build(); + + CompoundBinaryTag rebuilt = new RebuildBiomes().apply(chunk, ANY_CONTEXT); + + ListBinaryTag sections = rebuilt.getList("sections"); + assertEquals(1, sections.size(), "a section with real block data must survive regardless of its Y"); + assertEquals(-1, sections.getCompound(0).getInt("Y")); + } + + @Test + void testABiomesArrayWithASurvivingSectionBelowZeroThrowsRatherThanMisplacingTheOffset() { + // The section at Y = -1 carries block data, so it survives discarding and reaches the + // offset guard; this step's Y * SECTION_BIOME_ENTRIES offset assumes the lowest section is + // Y = 0, which does not hold here, so the chunk must be refused rather than writing every + // biome layer 64 blocks off from where it belongs. + int[] biomes = new int[1024]; + java.util.Arrays.fill(biomes, 1); + CompoundBinaryTag sectionBelowZero = CompoundBinaryTag.builder() + .putInt("Y", -1) + .put("Palette", airPalette()) + .build(); + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putIntArray("Biomes", biomes) + .put("sections", ListBinaryTag.from(List.of(sectionBelowZero))) + .build(); + + MigrationException exception = assertThrows(MigrationException.class, + () -> new RebuildBiomes().apply(chunk, ANY_CONTEXT)); + assertTrue(exception.getMessage().contains("-1")); + } + + @Test + void testSettleYRangeUsesTheActualLowestSectionEvenWhenItIsNegative() { + // The range filter this step used to apply is gone: every remaining section counts, because + // RebuildBiomes (earlier in the chain) is now trusted to have already removed anything + // without block data, whatever its Y. + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("sections", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putInt("Y", -1).build()))) + .build(); + + CompoundBinaryTag settled = new SettleYRange().apply(chunk, ANY_CONTEXT); + + assertEquals(-1, settled.getInt("yPos"), "the chunk's only section is at Y = -1, so yPos must be -1"); + } + + @Test + void testSettleYRangeLeavesAChunkWithNoSectionsUnchangedRatherThanInventingYPosZero() { + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("sections", ListBinaryTag.empty()) + .build(); + + CompoundBinaryTag settled = new SettleYRange().apply(chunk, ANY_CONTEXT); + + assertNull(settled.get("yPos"), "there is no section for yPos to describe, so none must be invented"); + } + + @Test + void testASectionWithoutBlockDataAtBothBordersIsDiscardedByTheWholeChainRatherThanCorruptingItOrYPos() { + // The end-to-end version of the direct tests above: a chunk with a lighting-only section on + // both sides of its real content must migrate cleanly, end up with only its one real + // section, and settle yPos on that real section's own Y rather than a discarded one. + int[] biomes = new int[1024]; + java.util.Arrays.fill(biomes, 1); + + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putInt("DataVersion", 1519) + .put("Level", CompoundBinaryTag.builder() + .putInt("xPos", 0) + .putInt("zPos", 0) + .putString("Status", "postprocessed") + .putIntArray("Biomes", biomes) + .put("Sections", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putByte("Y", (byte) -1).build(), + CompoundBinaryTag.builder().putByte("Y", (byte) 0) + .put("Palette", airPalette()).build(), + CompoundBinaryTag.builder().putByte("Y", (byte) 16).build()))) + .build()) + .build(); + + CompoundBinaryTag migrated = ChunkMigration.migrate(chunk, 4790); + + ListBinaryTag sections = migrated.getList("sections"); + assertEquals(1, sections.size(), "only Y=0 carries block data; Y=-1 and Y=16 are lighting-only"); + assertEquals(0, sections.getCompound(0).getInt("Y")); + assertEquals(0, migrated.getInt("yPos"), "the lowest REAL section is 0, not a discarded lighting-only one"); + } + + /** + * A section built by hand for these tests, carrying just enough block data ({@code Palette} with + * one air entry, no {@code BlockStates} — a single-entry palette needs no packed data, per + * {@link NormaliseBitPacking}'s own javadoc) to survive {@link RebuildBiomes}'s content-based + * discarding without affecting a test's own assertions. + */ + private static ListBinaryTag airPalette() { + return ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putString("Name", "minecraft:air").build())); + } + + // --- TranslateBlockStates, including the full-chain Gegenprobe target ----------------------- + + @Test + void testAMultiEntryPaletteWithNoIndicesToAddressItFailsRatherThanWritingAnIncompleteContainer() { + // A multi-entry palette with no BlockStates array at all is not a shape a genuine writer + // produces — the format's single-value shape (no data array) means exactly one entry — but + // nothing upstream of this step rules it out, so this step has to refuse it itself rather + // than silently write a palette with several named options and nothing saying which block + // holds which. + CompoundBinaryTag section = CompoundBinaryTag.builder() + .putInt("Y", 0) + .put("Palette", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putString("Name", "minecraft:stone_slab").build(), + CompoundBinaryTag.builder().putString("Name", "minecraft:air").build()))) + .build(); + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("sections", ListBinaryTag.from(List.of(section))) + .build(); + + MigrationException exception = assertThrows(MigrationException.class, + () -> new TranslateBlockStates().apply(chunk, new MigrationContext(1519, 4790))); + assertTrue(exception.getMessage().contains("2")); + } + + @Test + void testALegacyTopLevelPaletteBecomesAModernBlockStatesContainer() { + CompoundBinaryTag section = CompoundBinaryTag.builder() + .putInt("Y", 0) + .put("Palette", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putString("Name", "minecraft:stone_slab").build()))) + .build(); + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("sections", ListBinaryTag.from(List.of(section))) + .build(); + + CompoundBinaryTag translated = new TranslateBlockStates().apply(chunk, new MigrationContext(1519, 4790)); + + CompoundBinaryTag translatedSection = translated.getList("sections").getCompound(0); + assertNull(translatedSection.get("Palette"), "the legacy container key must not survive"); + assertNull(translatedSection.get("BlockStates")); + CompoundBinaryTag blockStates = translatedSection.getCompound("block_states"); + assertEquals("minecraft:smooth_stone_slab", blockStates.getList("palette").getCompound(0).getString("Name"), + "stone_slab renamed below DataVersion 1901, same as BlockStateRulesTest pins directly"); + } + + @Test + void testAnOverWidthPackedLegacyPaletteIsDecodedAtItsActualWidthNotThePaletteMinimum() { + // A palette of 17 entries needs only 5 bits (BitPacker.bitsPerEntry(17, 4)); this fixture + // packs it with 6 instead - a valid choice the format explicitly allows a writer to make, + // and exactly what NormaliseBitPacking itself would hand this step if the original writer + // had done the same (it preserves whatever width it finds rather than compacting to the + // minimum - see its own testAnOverWidthPackedPaletteIsReadAtTheWidthItWasActuallyPackedWith). + // Entry 0 is a stone_slab so a rule actually fires at DataVersion 1519, forcing the full + // decode-translate-reencode path to run rather than short-circuiting as unchanged. + int bitsPerEntry = 6; + int[] values = new int[16 * 16 * 16]; + for (int i = 0; i < values.length; i++) { + values[i] = i % 17; + } + long[] overWidthPacked = BitPacker.pack(values, bitsPerEntry); + + ListBinaryTag palette = ListBinaryTag.empty() + .add(CompoundBinaryTag.builder().putString("Name", "minecraft:stone_slab").build()); + for (int i = 1; i < 17; i++) { + palette = palette.add(CompoundBinaryTag.builder().putString("Name", "minecraft:test_" + i).build()); + } + + CompoundBinaryTag section = CompoundBinaryTag.builder() + .putInt("Y", 0) + .put("Palette", palette) + .putLongArray("BlockStates", overWidthPacked) + .build(); + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("sections", ListBinaryTag.from(List.of(section))) + .build(); + + CompoundBinaryTag translated = new TranslateBlockStates().apply(chunk, new MigrationContext(1519, 4790)); + + CompoundBinaryTag blockStates = translated.getList("sections").getCompound(0).getCompound("block_states"); + assertEquals("minecraft:smooth_stone_slab", blockStates.getList("palette").getCompound(0).getString("Name"), + "sanity check that the rename actually fired, which is what forces the full decode/reencode path"); + + // The rebuilt container re-encodes at its own minimal width (5 bits for 17 entries), + // regardless of the 6 bits the input used - what must survive is the actual VALUES, i.e. + // that decoding the 6-bit input read the right indices in the first place. + int outputBitsPerEntry = BitPacker.bitsPerEntry(17, 4); + long[] repacked = blockStates.getLongArray("data"); + int[] roundTripped = BitPacker.unpack(repacked, values.length, outputBitsPerEntry); + assertArrayEquals(values, roundTripped, + "the 6-bit width the section was actually packed at must be used to decode it, not the " + + "5-bit minimum a palette of 17 entries alone would suggest"); + } + + @Test + void testASectionAlreadyInTheModernShapeWithNoFiringRuleIsPassedThroughRatherThanReencoded() { + CompoundBinaryTag palette = CompoundBinaryTag.builder().putString("Name", "minecraft:stone").build(); + CompoundBinaryTag blockStates = CompoundBinaryTag.builder() + .put("palette", ListBinaryTag.from(List.of(palette))) + .build(); + CompoundBinaryTag section = CompoundBinaryTag.builder() + .putInt("Y", 0) + .put("block_states", blockStates) + .build(); + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .put("sections", ListBinaryTag.from(List.of(section))) + .build(); + + CompoundBinaryTag translated = new TranslateBlockStates().apply(chunk, new MigrationContext(4790, 4790)); + + assertEquals(section, translated.getList("sections").getCompound(0), + "no rule applies at DataVersion 4790, so the whole section must come back exactly as read"); + } + + /** + * This is the case the brief and the design both single out: a 1.13 {@code cobblestone_wall} + * with {@code north=true} must come out of the whole {@link ChunkMigration} chain as + * {@code north=low} — not just out of {@link TranslateBlockStates} in isolation. Loading a wall + * whose direction property is still the pre-1.16 boolean is exactly the case that aborts a chunk + * load; this test is the proof the rules and the chain are actually wired together. + */ + @Test + void testACobblestoneWallWithNorthTrueSurvivesTheWholeChainAsNorthLow() { + CompoundBinaryTag wallSection = CompoundBinaryTag.builder() + .putByte("Y", (byte) 0) + .put("Palette", ListBinaryTag.from(List.of(CompoundBinaryTag.builder() + .putString("Name", "minecraft:cobblestone_wall") + .put("Properties", CompoundBinaryTag.builder() + .putString("north", "true") + .putString("south", "false") + .putString("up", "true") + .build()) + .build()))) + .build(); + + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putInt("DataVersion", 1519) + .put("Level", CompoundBinaryTag.builder() + .putInt("xPos", 0) + .putInt("zPos", 0) + .putString("Status", "postprocessed") + .put("Sections", ListBinaryTag.from(List.of(wallSection))) + .build()) + .build(); + + CompoundBinaryTag migrated = ChunkMigration.migrate(chunk, 4790); + + CompoundBinaryTag properties = migrated.getList("sections").getCompound(0) + .getCompound("block_states").getList("palette").getCompound(0).getCompound("Properties"); + assertEquals("low", properties.getString("north")); + assertEquals("none", properties.getString("south")); + assertEquals("true", properties.getString("up"), "up is not one of the four rewritten sides"); + } + + /** + * Packs {@code values} using the pre-1.16 layout, in which an entry is allowed to span a long + * boundary — the exact inverse of {@link net.onelitefeather.falco.migration.LegacyBitReader}'s + * own unpacking, built independently here (rather than reused) so this fixture does not simply + * assert that the production code agrees with itself. + */ + private static long[] legacyPack(int[] values, int bitsPerEntry) { + long totalBits = (long) values.length * bitsPerEntry; + long[] packed = new long[(int) ((totalBits + 63) / 64)]; + long mask = (1L << bitsPerEntry) - 1L; + + for (int index = 0; index < values.length; index++) { + long bitOffset = (long) index * bitsPerEntry; + int longIndex = (int) (bitOffset / 64); + int bitInLong = (int) (bitOffset % 64); + long value = values[index] & mask; + + packed[longIndex] |= value << bitInLong; + int bitsWrittenInFirstLong = 64 - bitInLong; + if (bitsWrittenInFirstLong < bitsPerEntry) { + packed[longIndex + 1] |= value >>> bitsWrittenInFirstLong; + } + } + return packed; + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 38729a0..6996fab 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,6 +8,7 @@ include("falco-benchmarks") include("falco-demo") include("falco-bom") include("falco-archunit") +include("falco-migration") dependencyResolutionManagement { repositories {

+ * Widening a 256-entry array is not an average or a guess. It reproduces + * PaperMC/DataConverter's own + * {@code V2202} (commit {@code 0782df72}, GPL-3.0, DataVersion 2203) bit for bit: for each 4x4 + * quadrant of the 16x16 grid it samples the single column at the quadrant's centre — not an average + * of the four — and then repeats that resulting 4x4 layer across every one of the 64 four-block-tall + * layers a 1024-entry array holds, because a pre-1.15 chunk has no biome variance by height to + * preserve in the first place. + *