From 67e364ab6d92ad85e17519df3772e4642a1bed33 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 19:12:49 +0200 Subject: [PATCH 01/22] docs: carry the migration spec, its measurement and plan 1 onto the build branch Branched from main after #47, so the extension points are present - plan 1 does not need them (the ChunkMigrator hook is plan 2), but plan 2 will. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-04-falco-migration-engine.md | 748 ++++++++++++++++++ ...2026-08-04-blockstate-property-research.md | 126 +++ .../2026-08-04-falco-migration-design.md | 492 ++++++++++++ 3 files changed, 1366 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-falco-migration-engine.md create mode 100644 docs/superpowers/specs/2026-08-04-blockstate-property-research.md create mode 100644 docs/superpowers/specs/2026-08-04-falco-migration-design.md 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..aedb62e --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-falco-migration-engine.md @@ -0,0 +1,748 @@ +# `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) throws MigrationException`. + +- [ ] **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` is a new checked type in this module — do **not** reuse `falco-anvil`'s sealed +`AnvilFormatException` hierarchy, which permits exactly two subtypes and is published API. + +- [ ] **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 and biomes + +**Files:** +- Create: `…/migration/steps/NormaliseBitPacking.java`, `…/migration/steps/RebuildBiomes.java`, `…/migration/steps/TranslateBlockStates.java` +- Create: `…/migration/LegacyBitReader.java` +- Test: `…/migration/LegacyBitReaderTest.java`, `…/migration/steps/SectionStepsTest.java` + +**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 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. 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..6d30c6a --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-blockstate-property-research.md @@ -0,0 +1,126 @@ +# How much of 1.13 to 26.x is block-state properties + +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..04370f9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-falco-migration-design.md @@ -0,0 +1,492 @@ +# `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 | 2566 (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 | +| 2 | **Count** entities still in the chunk | 2724 (1.17) | Counted and reported, **not moved**. See "The entity debt" | +| 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 | `full` → `minecraft:full` | +| 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. 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. From a45fb75e7e68dc741df587fc4d3c74949cdadae2 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 19:24:20 +0200 Subject: [PATCH 02/22] feat(migration): a module that cannot see a server, and the rule that says so --- falco-archunit/build.gradle.kts | 1 + .../architecture/MigrationBoundaryTest.java | 36 ++++++++++++++++ falco-migration/build.gradle.kts | 18 ++++++++ .../falco/migration/BlockState.java | 42 +++++++++++++++++++ .../falco/migration/package-info.java | 26 ++++++++++++ settings.gradle.kts | 1 + 6 files changed, 124 insertions(+) create mode 100644 falco-archunit/src/test/java/net/onelitefeather/falco/architecture/MigrationBoundaryTest.java create mode 100644 falco-migration/build.gradle.kts create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockState.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/package-info.java 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..99efbfb --- /dev/null +++ b/falco-migration/build.gradle.kts @@ -0,0 +1,18 @@ +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) +} 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/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/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 { From 39f9e2f432e369167e3be0e781c5dee22f8d5c75 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 19:34:53 +0200 Subject: [PATCH 03/22] docs(plan): make MigrationException unchecked, because two rules say so Task 1's review found the collision before task 4 could hit it. The plan called for a checked type, and that is impossible twice over: ErrorHandlingTest.checkedFaultsStayInsideTheHierarchy runs over the whole net.onelitefeather.falco.. tree rather than the published modules alone and demands every checked throwable be assignable to AnvilFormatException, and that hierarchy is sealed with two permits in another package, so it cannot be extended from here at all. Unchecked is the house style anyway, and it carries its own requirement: ownExceptionsAreUncheckedAndCarryACause wants a public (String, Throwable) constructor. The plan now says that where task 4 will read it. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-04-falco-migration-engine.md | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-08-04-falco-migration-engine.md b/docs/superpowers/plans/2026-08-04-falco-migration-engine.md index aedb62e..6c509dd 100644 --- a/docs/superpowers/plans/2026-08-04-falco-migration-engine.md +++ b/docs/superpowers/plans/2026-08-04-falco-migration-engine.md @@ -414,7 +414,7 @@ git commit -m "feat(migration): versioned rules over whole block states, with th - 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) throws MigrationException`. + `static CompoundBinaryTag ChunkMigration.migrate(CompoundBinaryTag chunk, int targetVersion)` — unchecked, see Step 3. - [ ] **Step 1: Write the failing tests** @@ -489,8 +489,22 @@ Expected: compilation failure. `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` is a new checked type in this module — do **not** reuse `falco-anvil`'s sealed -`AnvilFormatException` hierarchy, which permits exactly two subtypes and is published API. +**`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** @@ -739,7 +753,7 @@ Step 2's result, because writing a fixture before establishing the fact would be **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 type, never +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 From 963df97caa4d073adda9a97071047b4063094902 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 19:39:56 +0200 Subject: [PATCH 04/22] feat(migration): find every dimension a world has, not the one the loader looks at --- .../falco/migration/WorldLayout.java | 123 ++++++++++++++++++ .../falco/migration/WorldLayoutTest.java | 67 ++++++++++ 2 files changed, 190 insertions(+) create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/WorldLayout.java create mode 100644 falco-migration/src/test/java/net/onelitefeather/falco/migration/WorldLayoutTest.java 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..d3e26c6 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/WorldLayout.java @@ -0,0 +1,123 @@ +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. + * + * @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 + * @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 + */ + public static Path targetDirectory(Path worldRoot, String dimensionKey) { + int separator = dimensionKey.indexOf(':'); + 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/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..7e0c284 --- /dev/null +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/WorldLayoutTest.java @@ -0,0 +1,67 @@ +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.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()); + } +} From b284a20d002a03e9743ff3dc3e4e8eeed702f0a6 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 19:49:39 +0200 Subject: [PATCH 05/22] fix(migration): document duplicate dimension keys and validate targetDirectory's key - discover's Javadoc now states explicitly that a partially migrated world can return the same dimensionKey twice (legacy and modern), and that choosing between the two copies is the caller's responsibility, not this method's. - targetDirectory now throws IllegalArgumentException naming the offending value instead of letting a missing ':' surface as StringIndexOutOfBoundsException. Covered by two new tests: testAPartiallyMigratedOverworldIsReturnedOnceLegacyAndOnceModern and testTargetDirectoryRejectsAKeyWithoutANamespaceSeparator. --- .../falco/migration/WorldLayout.java | 21 +++++++++++++++- .../falco/migration/WorldLayoutTest.java | 24 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) 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 index d3e26c6..dd0909d 100644 --- a/falco-migration/src/main/java/net/onelitefeather/falco/migration/WorldLayout.java +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/WorldLayout.java @@ -50,10 +50,23 @@ 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 + * @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 { @@ -101,9 +114,15 @@ public static List discover(Path worldRoot) throws IOException { * @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); 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 index 7e0c284..0624eda 100644 --- a/falco-migration/src/test/java/net/onelitefeather/falco/migration/WorldLayoutTest.java +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/WorldLayoutTest.java @@ -11,6 +11,7 @@ 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; /** @@ -64,4 +65,27 @@ void testTheNetherLandsInItsModernPlace(@TempDir Path worldRoot) { 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")); + } } From c8ab506bb9e804c9585ebc57ae12f5dfb2462353 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 20:55:54 +0200 Subject: [PATCH 06/22] feat(migration): versioned rules over whole block states, with their sources --- .../falco/migration/BlockStateRule.java | 63 ++++++ .../falco/migration/BlockStateRules.java | 204 ++++++++++++++++++ .../falco/migration/BlockStateRulesTest.java | 103 +++++++++ 3 files changed, 370 insertions(+) create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRule.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java create mode 100644 falco-migration/src/test/java/net/onelitefeather/falco/migration/BlockStateRulesTest.java 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..80a3289 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRule.java @@ -0,0 +1,63 @@ +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() == 1802} therefore applies to a + * 1.13 world ({@code 1519 < 1802}) and leaves a 1.16 world ({@code 2566 > 1802}) 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..f4697aa --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java @@ -0,0 +1,204 @@ +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}, is deliberately absent; see + * the note above {@link #RULES} for why. + *

+ * + * @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} (V2531, 20w17a, Minecraft 1.16, 144 states) is not in this list. + * The research document establishes that a direction's new value depends on the other three + * directions of the same state (DataConverter's {@code connectedX}/{@code connectedZ} in V2531), + * that a per-property implementation is guaranteed wrong, and that 9 of the 81 direction + * combinations change (times 16 {@code power} values = 144 states). It does not say which 9 + * combinations change or what they become — only the count. That is not enough to reproduce + * V2531's logic exactly, and a guessed whole-state table for redstone wiring is exactly the + * silent corruption this task was told to refuse: the chunk still loads, the wiring looks + * subtly different, and nobody notices. A state this module does not recognize — including every + * {@code redstone_wire} state — 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: blockstate-property-research.md, + // "Widerspruch 2" (V1802, 18w43a, Minecraft 1.14). + renameRule("minecraft:sign", "minecraft:oak_sign", 1802), + + // wall_sign -> oak_wall_sign, same reasoning as sign above. + // Source: blockstate-property-research.md, "Widerspruch 2" (V1802, 18w43a, Minecraft 1.14). + renameRule("minecraft:wall_sign", "minecraft:oak_wall_sign", 1802), + + // 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: blockstate-property-research.md, section "A) Die Zahl", two-case table + // (V1802, 1.14; verified 1.13 stone_slab = 6 states, 26.3 stone_slab AND + // smooth_stone_slab each = 6 states). + renameRule("minecraft:stone_slab", "minecraft:smooth_stone_slab", 1802), + + // 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: blockstate-property-research.md, "Widerspruch 3" (V2503, Minecraft 1.16, + // DataConverter's BOOL_TO_WALL_HEIGHT-equivalent; confirmed no neighbor/chunk context is + // needed, contrary to what the wiki route alone suggested). + wallRule(2503), + + // 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: blockstate-property-research.md, section "A) Die Zahl", row + // "(c) Property weggefallen" (V2679, Minecraft 1.17). + cauldronRule(2679), + + // grass_path -> dirt_path, Minecraft 1.17. DataVersion 2724 is the same 1.17 threshold + // already used in the design doc's step-chain table for entity separation, another + // change that landed in the same release. + // Source: falco-migration-design.md, "What the registry lists do settle" table + // (names the Minecraft version, not the DataVersion integer; 2724 reused from the + // design doc's own step-chain table, row 2, also attributed to "1.17"). + renameRule("minecraft:grass_path", "minecraft:dirt_path", 2724), + + // grass -> short_grass, Minecraft 1.20.3. + // Source: falco-migration-design.md, "What the registry lists do settle" table (names + // the Minecraft version but not the DataVersion integer). DataVersion 3698 is not stated + // anywhere in this repository; it was looked up against minecraft.wiki's "Data version" + // table and the "Java Edition 1.20.3" infobox (both checked 2026-08-04) rather than + // taken from memory, because the same "no rule from memory" rule this module follows for + // block-state logic applies to the version number a rule is keyed on. + renameRule("minecraft:grass", "minecraft:short_grass", 3698) + ); + + 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() == 1802} therefore applies to a 1.13 world ({@code 1519 < 1802}) and not to a + * 1.16 world ({@code 2566 > 1802}). + *

+ *

+ * A state no rule recognizes — including every {@code redstone_wire} state, for which this + * module deliberately carries no rule — 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 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/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..6f87d8b --- /dev/null +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/BlockStateRulesTest.java @@ -0,0 +1,103 @@ +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() { + assertEquals("minecraft:smooth_stone_slab", + BlockStateRules.translate(BlockState.of("minecraft:stone_slab"), 1801).name()); + assertEquals("minecraft:stone_slab", + BlockStateRules.translate(BlockState.of("minecraft:stone_slab"), 1802).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() { + BlockState redstoneWire = new BlockState("minecraft:redstone_wire", + Map.of("north", "side", "south", "none", "east", "up", "west", "none", "power", "0")); + + assertEquals(redstoneWire, BlockStateRules.translate(redstoneWire, 1519), + "redstone_wire has no rule: the research this module is built from did not carry enough " + + "detail to reproduce V2531's cross-direction logic exactly, and a guessed table " + + "would be silent corruption rather than a fix"); + } +} From ece0a28e7591f8fa52a5955570fbd5dda9b8c752 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 21:09:16 +0200 Subject: [PATCH 07/22] fix(migration): use the snapshot DataVersion a rename happened in, not its release's --- .../falco/migration/BlockStateRules.java | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) 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 index f4697aa..3dca7e0 100644 --- a/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java @@ -91,22 +91,31 @@ public final class BlockStateRules { // "(c) Property weggefallen" (V2679, Minecraft 1.17). cauldronRule(2679), - // grass_path -> dirt_path, Minecraft 1.17. DataVersion 2724 is the same 1.17 threshold - // already used in the design doc's step-chain table for entity separation, another - // change that landed in the same release. - // Source: falco-migration-design.md, "What the registry lists do settle" table - // (names the Minecraft version, not the DataVersion integer; 2724 reused from the - // design doc's own step-chain table, row 2, also attributed to "1.17"). - renameRule("minecraft:grass_path", "minecraft:dirt_path", 2724), + // 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: falco-migration-design.md, "What the registry lists do settle" table (names - // the Minecraft version but not the DataVersion integer). DataVersion 3698 is not stated - // anywhere in this repository; it was looked up against minecraft.wiki's "Data version" - // table and the "Java Edition 1.20.3" infobox (both checked 2026-08-04) rather than - // taken from memory, because the same "no rule from memory" rule this module follows for - // block-state logic applies to the version number a rule is keyed on. - renameRule("minecraft:grass", "minecraft:short_grass", 3698) + // 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 = From 2cda5d1ea9786dc09ce1caf94b5ca8927e917dc7 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 21:21:49 +0200 Subject: [PATCH 08/22] fix(migration): correct three more rule versions to their actual snapshots sign/wall_sign/stone_slab (V1802), the wall table (V2503) and the cauldron split (V2679) all came from the research document's DataConverter citations, and all three numbers turned out to be wrong the same way 2724/3698 were: 1802 matches no named snapshot at all (it sits in an unnamed gap between 1.13.2 and 18w43a), 2503 and 2679 are each one snapshot's DataVersion off from the change they were meant to cite. Corrected against each snapshot's own changelog and infobox, checked independently: 18w43a (1901) for the three renames, 20w06a (2504) for the wall table, 20w45a (2681, same snapshot as grass_path) for the cauldron split. Unlike the grass/grass_path fix, this one matters beyond citation hygiene: 1802 made the rule fire too late rather than too early. A 1.14 chunk with a DataVersion between 1802 and 1900 still stored the pre-rename block name, and Minestom throws on an unknown name rather than tolerating it. --- .../falco/migration/BlockStateRules.java | 77 ++++++++++++++----- .../falco/migration/BlockStateRulesTest.java | 7 +- 2 files changed, 62 insertions(+), 22 deletions(-) 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 index 3dca7e0..ad472dc 100644 --- a/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java @@ -58,38 +58,75 @@ public final class BlockStateRules { */ 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: blockstate-property-research.md, - // "Widerspruch 2" (V1802, 18w43a, Minecraft 1.14). - renameRule("minecraft:sign", "minecraft:oak_sign", 1802), - - // wall_sign -> oak_wall_sign, same reasoning as sign above. - // Source: blockstate-property-research.md, "Widerspruch 2" (V1802, 18w43a, Minecraft 1.14). - renameRule("minecraft:wall_sign", "minecraft:oak_wall_sign", 1802), + // 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: blockstate-property-research.md, section "A) Die Zahl", two-case table - // (V1802, 1.14; verified 1.13 stone_slab = 6 states, 26.3 stone_slab AND - // smooth_stone_slab each = 6 states). - renameRule("minecraft:stone_slab", "minecraft:smooth_stone_slab", 1802), + // 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: blockstate-property-research.md, "Widerspruch 3" (V2503, Minecraft 1.16, - // DataConverter's BOOL_TO_WALL_HEIGHT-equivalent; confirmed no neighbor/chunk context is - // needed, contrary to what the wiki route alone suggested). - wallRule(2503), + // 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), // 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: blockstate-property-research.md, section "A) Die Zahl", row - // "(c) Property weggefallen" (V2679, Minecraft 1.17). - cauldronRule(2679), + // 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 @@ -132,8 +169,8 @@ private BlockStateRules() { * 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() == 1802} therefore applies to a 1.13 world ({@code 1519 < 1802}) and not to a - * 1.16 world ({@code 2566 > 1802}). + * {@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 — including every {@code redstone_wire} state, for which this 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 index 6f87d8b..aa90f96 100644 --- a/falco-migration/src/test/java/net/onelitefeather/falco/migration/BlockStateRulesTest.java +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/BlockStateRulesTest.java @@ -60,10 +60,13 @@ void testAMossyWallIsRewrittenByTheSameSharedTable() { @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"), 1801).name()); + BlockStateRules.translate(BlockState.of("minecraft:stone_slab"), 1900).name()); assertEquals("minecraft:stone_slab", - BlockStateRules.translate(BlockState.of("minecraft:stone_slab"), 1802).name()); + BlockStateRules.translate(BlockState.of("minecraft:stone_slab"), 1901).name()); } @Test From f7cdd06f1babdf0371d830679d8dc83dc09f7763 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 21:32:15 +0200 Subject: [PATCH 09/22] docs(research): warn that this document's DataVersions are wrong, and which Implementing the rules meant checking every V-number against the snapshot this document names, and three of six did not survive: V1802 is 1901, V2503 is 2504, V2679 is 2681. All 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 five for five. One of them mattered. 1802 made the rename rules fire too late, and a chunk from 1802 to 1900 still stores sign - which is a NullPointerException on Minestom, per the compatibility table this same document got right. A second, separate error is recorded because it is the one to expect elsewhere: grass_path and grass carried release DataVersions where a snapshot was meant, because the design document names 1.17 and 1.20.3 without a number and someone filled in the release. Everything structural held up - which cases exist, zero property renames, and that stone_slab and redstone_wire are invisible to a registry diff. Co-Authored-By: Claude Opus 5 (1M context) --- ...2026-08-04-blockstate-property-research.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/superpowers/specs/2026-08-04-blockstate-property-research.md b/docs/superpowers/specs/2026-08-04-blockstate-property-research.md index 6d30c6a..5518c1a 100644 --- a/docs/superpowers/specs/2026-08-04-blockstate-property-research.md +++ b/docs/superpowers/specs/2026-08-04-blockstate-property-research.md @@ -1,5 +1,34 @@ # 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. From d2ee0c09d63426e4a2252f75910bddd9088a95a9 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 22:04:14 +0200 Subject: [PATCH 10/22] feat(migration): the step chain, and the three that only move things Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR --- .../falco/migration/ChunkMigration.java | 75 ++++++++++++++++++ .../falco/migration/MigrationContext.java | 21 +++++ .../falco/migration/MigrationException.java | 49 ++++++++++++ .../falco/migration/MigrationStep.java | 40 ++++++++++ .../steps/DiscardHeightmapsAndLight.java | 62 +++++++++++++++ .../migration/steps/NamespaceStatus.java | 56 ++++++++++++++ .../falco/migration/steps/UnfoldLevel.java | 69 +++++++++++++++++ .../falco/migration/steps/package-info.java | 15 ++++ .../falco/migration/ChunkMigrationTest.java | 76 +++++++++++++++++++ 9 files changed, 463 insertions(+) create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/ChunkMigration.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationContext.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationException.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationStep.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/DiscardHeightmapsAndLight.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/NamespaceStatus.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/UnfoldLevel.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/package-info.java create mode 100644 falco-migration/src/test/java/net/onelitefeather/falco/migration/ChunkMigrationTest.java 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..06352fe --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/ChunkMigration.java @@ -0,0 +1,75 @@ +package net.onelitefeather.falco.migration; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.onelitefeather.falco.migration.steps.DiscardHeightmapsAndLight; +import net.onelitefeather.falco.migration.steps.NamespaceStatus; +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, 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 task wires only the three steps that move or delete data without needing any renaming + * knowledge: {@link UnfoldLevel}, {@link NamespaceStatus} and {@link DiscardHeightmapsAndLight}, in + * that order — the order the design's step table gives them (steps 3, 6 and 8). The remaining steps + * of that table (bit packing, entity counting, biome rebuilding, Y-range widening and block-state + * renaming) belong to later tasks and are not part of this chain yet. + *

+ * + * @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 UnfoldLevel(), new NamespaceStatus(), 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 the chunk's own {@code DataVersion} is older than + * {@link #MINIMUM_SOURCE_VERSION} + */ + public static CompoundBinaryTag migrate(CompoundBinaryTag chunk, int targetVersion) { + int sourceVersion = chunk.getInt(DATA_VERSION_KEY); + if (sourceVersion < MINIMUM_SOURCE_VERSION) { + throw new MigrationException("Chunk DataVersion " + sourceVersion + " is older than the " + + "supported floor " + MINIMUM_SOURCE_VERSION + " (Minecraft 1.13); nothing below " + + "the flattening can be migrated by this module"); + } + + MigrationContext context = new MigrationContext(sourceVersion, targetVersion); + CompoundBinaryTag result = chunk; + for (MigrationStep step : STEPS) { + if (step.appliesTo(sourceVersion)) { + result = step.apply(result, context); + } + } + return result.putInt(DATA_VERSION_KEY, targetVersion); + } +} 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..f2fa84a --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationContext.java @@ -0,0 +1,21 @@ +package net.onelitefeather.falco.migration; + +import org.jetbrains.annotations.ApiStatus; + +/** + * 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. + *

+ * 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 — none of the three structural steps built + * in this task need it, but a step that resolves a rename table for a specific target does. + *

+ * + * @param sourceVersion the chunk's own {@code DataVersion}, read before any step ran + * @param targetVersion the {@code DataVersion} the whole chain is converting towards + * @since 2.1.0 + */ +@ApiStatus.Experimental +public record MigrationContext(int sourceVersion, int targetVersion) { +} 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/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..5485572 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/NamespaceStatus.java @@ -0,0 +1,56 @@ +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; + +/** + * Rewrites a chunk status without a namespace, such as {@code full}, into its namespaced form, + * {@code minecraft: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 prefixed with + * {@code minecraft:}. + *

+ *

+ * 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:"; + + /** + * 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; + } + return chunk.putString(STATUS_KEY, MINECRAFT_NAMESPACE + value); + } +} 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..eb29b4d --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/UnfoldLevel.java @@ -0,0 +1,69 @@ +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.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. + *

+ * {@code Sections} is renamed to {@code sections} as it moves, because that is the one field name + * that changed rather than only its position — every other child keeps its own name. A {@code yPos} + * field is added at the root because 2844 is also the version that introduced it: it names the + * lowest section index a chunk stores, which for every chunk below 2844 is {@code 0}, since the + * pre-1.18 world height was fixed to sections {@code 0}–{@code 15} and never reached below the + * bottom of that range. + *

+ *

+ * The list of fields this step moves is deliberately not written down here: it moves whatever + * {@code Level} actually holds, rather than a fixed set of names invented for this task. 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"; + private static final String SECTIONS_KEY = "Sections"; + private static final String LOWERCASE_SECTIONS_KEY = "sections"; + private static final String Y_POS_KEY = "yPos"; + + /** + * Creates a new instance of this stateless step. + */ + public UnfoldLevel() { + } + + @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; + } + + CompoundBinaryTag root = chunk.remove(LEVEL_KEY); + for (Map.Entry child : level) { + String key = SECTIONS_KEY.equals(child.getKey()) ? LOWERCASE_SECTIONS_KEY : child.getKey(); + root = root.put(key, child.getValue()); + } + return root.putInt(Y_POS_KEY, 0); + } +} 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/ChunkMigrationTest.java b/falco-migration/src/test/java/net/onelitefeather/falco/migration/ChunkMigrationTest.java new file mode 100644 index 0000000..a8bf174 --- /dev/null +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/ChunkMigrationTest.java @@ -0,0 +1,76 @@ +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * 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() { + 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() { + 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(); + + assertThrows(MigrationException.class, () -> ChunkMigration.migrate(ancient, 4790)); + } +} From 72734ca442e1d366a3b6355cbedef03afc197068 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 22:12:55 +0200 Subject: [PATCH 11/22] docs(plan): give task 5 the Y range, which fell between two tasks The chain has a step 5, widen the Y range, and neither task 4 nor task 5 had been given it. Task 4 therefore wrote yPos = 0 inside UnfoldLevel as a stopgap - correct for a pre-1.18 source, unproven for the target it stamps. The value moves to task 5, and the task now has to settle what yPos means before writing anything. The review found the wiki ambiguous on exactly this: 'Lowest Y section position in the chunk (e.g. -4 in 1.18)' - the sentence says the chunk's own lowest section, the example says the dimension's floor. For vanilla both coincide because it writes every section; for a converted chunk with nothing below 0 they diverge. Either answer has a consequence, so the plan spells out both. If yPos is the chunk's own, 0 already holds and needs a test. If it anchors to the dimension, -4 is needed and the rule that empty sections are not invented has to be re-examined, because claiming a floor without sections for it is a second inconsistency rather than a fix. Minestom's loader decides what Falco's target actually does; if it disagrees with the wiki, that is a finding and a place to stop. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-04-falco-migration-engine.md | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-04-falco-migration-engine.md b/docs/superpowers/plans/2026-08-04-falco-migration-engine.md index 6c509dd..13ea825 100644 --- a/docs/superpowers/plans/2026-08-04-falco-migration-engine.md +++ b/docs/superpowers/plans/2026-08-04-falco-migration-engine.md @@ -537,13 +537,38 @@ git commit -m "feat(migration): the step chain, and the three that only move thi --- -### Task 5: Sections — bit packing and biomes +### Task 5: Sections — bit packing, biomes, and the Y range **Files:** -- Create: `…/migration/steps/NormaliseBitPacking.java`, `…/migration/steps/RebuildBiomes.java`, `…/migration/steps/TranslateBlockStates.java` +- 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)`. From 17c5bc607b06d9aff96ef9c91931621de2da8b18 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 22:17:06 +0200 Subject: [PATCH 12/22] fix(migration): flag yPos as provisional and refuse silent Level collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marks UnfoldLevel's yPos = 0 as provisional in its javadoc — correct for the source chunk, unresolved for the target until Task 5's SettleYRange step decides what yPos means there. Pins the current value in the existing test so a later change to it is visible rather than silent. UnfoldLevel now throws MigrationException, naming the field, instead of silently overwriting a root field that collides with one of Level's children. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR --- .../falco/migration/steps/UnfoldLevel.java | 41 ++++++++++++++++--- .../falco/migration/ChunkMigrationTest.java | 24 +++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) 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 index eb29b4d..ce26881 100644 --- 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 @@ -3,6 +3,7 @@ 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; @@ -13,11 +14,27 @@ * version from DataVersion 2844 onwards already stores a chunk in. *

* {@code Sections} is renamed to {@code sections} as it moves, because that is the one field name - * that changed rather than only its position — every other child keeps its own name. A {@code yPos} - * field is added at the root because 2844 is also the version that introduced it: it names the - * lowest section index a chunk stores, which for every chunk below 2844 is {@code 0}, since the - * pre-1.18 world height was fixed to sections {@code 0}–{@code 15} and never reached below the - * bottom of that range. + * that changed rather than only its position — every other child keeps its own name. 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 = 0} is provisional, not a settled answer. A {@code yPos} field is added at the + * root because 2844 is also the version that introduced it. {@code 0} is correct for what the + * source chunk means: every version below 2844 fixed the world height to sections + * {@code 0}–{@code 15}, so a pre-1.18 chunk's own lowest stored section is always {@code 0}. What + * {@code yPos} means for the target version is a separate, still-open question — the field's + * own wiki documentation ("Lowest Y section position in the chunk (e.g. -4 in 1.18)") is ambiguous + * between "the lowest section this chunk itself stores" and "the bottom of the dimension's height + * range", and vanilla data can never tell the two apart because vanilla always writes every section + * down to the dimension floor. A converted chunk, which does not invent sections below 0 it never + * had, is exactly the case where the two readings split. Resolving that split, and therefore whether + * {@code 0} is the value this step should keep producing, is Task 5's {@code SettleYRange} step, not + * this one — a reader of this class should not conclude the question is answered because a number is + * already here. *

*

* The list of fields this step moves is deliberately not written down here: it moves whatever @@ -53,6 +70,16 @@ 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 the {@code Sections}-to- + * {@code sections} rename, 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)) { @@ -62,6 +89,10 @@ public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context CompoundBinaryTag root = chunk.remove(LEVEL_KEY); for (Map.Entry child : level) { String key = SECTIONS_KEY.equals(child.getKey()) ? LOWERCASE_SECTIONS_KEY : 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.putInt(Y_POS_KEY, 0); 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 index a8bf174..463145c 100644 --- a/falco-migration/src/test/java/net/onelitefeather/falco/migration/ChunkMigrationTest.java +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/ChunkMigrationTest.java @@ -8,6 +8,7 @@ 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 @@ -34,6 +35,29 @@ void testAPreEighteenChunkGetsItsFieldsOnTheRoot() { assertEquals(3, migrated.getInt("xPos")); assertEquals("minecraft:full", migrated.getString("Status")); assertNotNull(migrated.get("sections")); + // Pins today's value, not a claim that it is the right one: yPos = 0 is UnfoldLevel's + // provisional stand-in until Task 5's SettleYRange decides what yPos means for the target + // version (see UnfoldLevel's javadoc). This assertion exists so that change is visible as a + // deliberate edit to this test rather than slipping past unnoticed. + 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 From 56e125548704c07fd23e9ab70d24282051561224 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 22:46:14 +0200 Subject: [PATCH 13/22] feat(migration): translate block entities, and count the entities this slice leaves behind CountEntities (below DataVersion 2681, snapshot 20w45a - not 2724, the 1.17 release number the design's step table names, which the same release-instead-of-snapshot mistake already caught once for grass_path in this identical snapshot) reads a pre-extraction chunk's own Level.Entities and adds its size to a new counting sink on MigrationContext, moving nothing. TranslateBlockEntities renames only a block entity's id, throughout the whole range; its rename table is empty because this task's research found zero verified block-entity id renames across the whole 1.13-26.1 span, cross-checked against two independent sources (ViaVersion's blockentities registry diff from 1.18 through 26.1, and PaperMC/DataConverter's complete rename-fix history from V99 to V4661). Both steps are wired into ChunkMigration's chain at their spec positions (2 and 7); a new migrate(chunk, MigrationContext) overload lets a caller read the counter back afterward without changing the existing migrate(chunk, int) signature or its behaviour. Known gap, reported rather than fixed here: UnfoldLevel does not rename Level.TileEntities to block_entities the way it already renames Sections to sections, even though both changes landed in the same snapshot (21w43a, DataVersion 2844). TranslateBlockEntities reads whichever of the two keys is present and writes back under that same key, so a legacy-origin chunk keeps its block entities under a name the target version's reader will never look under until that gap is closed elsewhere. Gegenprobe run: removed the counter increment from CountEntities, which turned exactly the two entity-count assertions red and left every other test in the module green; reverted. Co-Authored-By: Claude Opus 5 (1M context) --- .../falco/migration/ChunkMigration.java | 70 ++++++-- .../falco/migration/MigrationContext.java | 56 ++++++- .../falco/migration/steps/CountEntities.java | 90 ++++++++++ .../steps/TranslateBlockEntities.java | 139 +++++++++++++++ .../migration/steps/BlockEntityStepTest.java | 158 ++++++++++++++++++ 5 files changed, 491 insertions(+), 22 deletions(-) create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/CountEntities.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/TranslateBlockEntities.java create mode 100644 falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/BlockEntityStepTest.java 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 index 06352fe..10e13ce 100644 --- a/falco-migration/src/main/java/net/onelitefeather/falco/migration/ChunkMigration.java +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/ChunkMigration.java @@ -1,8 +1,14 @@ 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; @@ -12,17 +18,18 @@ * 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, 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. + * {@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 task wires only the three steps that move or delete data without needing any renaming - * knowledge: {@link UnfoldLevel}, {@link NamespaceStatus} and {@link DiscardHeightmapsAndLight}, in - * that order — the order the design's step table gives them (steps 3, 6 and 8). The remaining steps - * of that table (bit packing, entity counting, biome rebuilding, Y-range widening and block-state - * renaming) belong to later tasks and are not part of this chain yet. + * 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 @@ -39,8 +46,16 @@ public final class ChunkMigration { private static final String DATA_VERSION_KEY = "DataVersion"; - private static final List STEPS = - List.of(new UnfoldLevel(), new NamespaceStatus(), new DiscardHeightmapsAndLight()); + 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() { } @@ -57,19 +72,42 @@ private ChunkMigration() { */ public static CompoundBinaryTag migrate(CompoundBinaryTag chunk, int targetVersion) { int sourceVersion = chunk.getInt(DATA_VERSION_KEY); - if (sourceVersion < MINIMUM_SOURCE_VERSION) { - throw new MigrationException("Chunk DataVersion " + sourceVersion + " is older than the " + 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"); } - MigrationContext context = new MigrationContext(sourceVersion, targetVersion); CompoundBinaryTag result = chunk; for (MigrationStep step : STEPS) { - if (step.appliesTo(sourceVersion)) { + if (step.appliesTo(context.sourceVersion())) { result = step.apply(result, context); } } - return result.putInt(DATA_VERSION_KEY, targetVersion); + return result.putInt(DATA_VERSION_KEY, context.targetVersion()); } } 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 index f2fa84a..934fb61 100644 --- a/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationContext.java +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationContext.java @@ -2,20 +2,64 @@ 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. + * 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 — none of the three structural steps built - * in this task need it, but a step that resolves a rename table for a specific target does. + * in Task 4 need 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 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) { +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/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/TranslateBlockEntities.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/TranslateBlockEntities.java new file mode 100644 index 0000000..ed90ad7 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/TranslateBlockEntities.java @@ -0,0 +1,139 @@ +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 this task's research took + * 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. See the task report for the full per-entry accounting. + *

+ *

+ * Known gap, not fixed here: the block-entity list's own container key is not renamed by this + * chain. {@code Level.TileEntities} became the root-level {@code block_entities} in the same + * snapshot (21w43a, DataVersion 2844) that {@code UnfoldLevel} already treats as its own threshold for + * unfolding {@code Level} and renaming {@code Sections} to {@code sections} — but {@code UnfoldLevel}'s + * own Javadoc states that every child other than {@code Sections} keeps its name as it moves, which is + * inaccurate for this one field. This step reads whichever of {@code block_entities} or + * {@code TileEntities} is actually present at the chunk's root and writes the (possibly id-renamed) + * list back under that same key — it does not decide which name the output should carry, because + * doing so would mean quietly reaching into a decision {@code UnfoldLevel} (a different task's file) + * already made. A chunk converted from a pre-2844 source therefore still carries its block entities + * under {@code TileEntities} after this whole chain runs, a name the target version's reader will + * never look under. Reported here and in the task report rather than patched, matching how this + * module has handled every other gap found next to a task rather than inside it. + *

+ * + * @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, and the task report for the per-entry accounting that + * established it. + */ + 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 this task's research 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/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..2eb10fd --- /dev/null +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/BlockEntityStepTest.java @@ -0,0 +1,158 @@ +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; + +/** + * 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 testALegacyTileEntitiesListSurvivesUnderItsOwnKeyBecauseNoStepInThisChainRenamesTheContainer() { + // UnfoldLevel (Task 4) moves every child of Level onto the root without renaming it, except + // Sections -> sections; TileEntities is therefore still called TileEntities once it reaches + // this step. TranslateBlockEntities does not rename the container either - only the id inside + // each entry - so it stays TileEntities all the way through. Documented as a known gap in + // this class's Javadoc and the task report, not silently patched here. + 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); + + ListBinaryTag tileEntities = assertInstanceOf(ListBinaryTag.class, migrated.get("TileEntities"), + "no step in this chain renames the TileEntities container key - see the report"); + CompoundBinaryTag tileEntity = (CompoundBinaryTag) tileEntities.iterator().next(); + assertEquals("minecraft:furnace", tileEntity.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()); + } +} From d4ed7f7f5160828d87bfbe9fc7f576c38d5771ac Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 23:12:06 +0200 Subject: [PATCH 14/22] feat(migration): read the packing 1.13 wrote, and translate what it held NormaliseBitPacking re-packs a pre-1.16 section's boundary-spanning BlockStates into the long-aligned layout falco-anvil's BitPacker can read, using a new LegacyBitReader that walks a continuous bit offset instead of restarting per long. Its own threshold is DataVersion 2529 (20w17a), not the design document's 2566, which is 1.16's release number rather than the snapshot the change landed in - the same release-instead-of-snapshot mistake this project has now corrected four times over. RebuildBiomes turns the whole-chunk legacy Biomes array (256 entries pre-1.15, 1024 from DataVersion 2203) into a palettised per-section container, reproducing PaperMC/DataConverter's V2202 widening algorithm and V2832's numeric-id-to-name table (commit 0782df72) as sourced data, because this project's own vendored ViaVersion mapping files carry no biomes registry list at all. An id outside that table throws rather than falling back to plains the way DataConverter itself does, keeping faith with this module's stance on unmappable values elsewhere. TranslateBlockStates walks every section's palette through BlockStateRules.translate and is also the step that restructures a legacy section's top-level Palette/BlockStates into the modern block_states container, since no earlier step renames it. A cobblestone_wall with north=true is carried through the whole ChunkMigration chain and comes out as north=low - the case that otherwise aborts a chunk on load - proving the rules and the chain are actually wired together. SettleYRange, new in the plan though not the brief, computes yPos from the sections a chunk actually has rather than assuming a fixed floor. Sourced from minecraft.wiki's own Chunk format article (the sections list is always complete for vanilla, which is why the ambiguity in yPos's wording never surfaces there) and from Minestom's own AnvilLoader, which never reads yPos on load at all. UnfoldLevel hands the field over to this step instead of hardcoding it. Gegenprobe run twice: TranslateBlockStates given the target version instead of the source version turned exactly the two rule-dependent tests red; reverted. Co-Authored-By: Claude Opus 5 (1M context) --- .../falco/migration/LegacyBitReader.java | 80 +++++ .../migration/steps/NormaliseBitPacking.java | 116 +++++++ .../falco/migration/steps/RebuildBiomes.java | 295 ++++++++++++++++++ .../falco/migration/steps/SettleYRange.java | 94 ++++++ .../migration/steps/TranslateBlockStates.java | 180 +++++++++++ .../falco/migration/steps/UnfoldLevel.java | 22 +- .../falco/migration/ChunkMigrationTest.java | 8 +- .../falco/migration/LegacyBitReaderTest.java | 58 ++++ .../migration/steps/SectionStepsTest.java | 270 ++++++++++++++++ 9 files changed, 1104 insertions(+), 19 deletions(-) create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/LegacyBitReader.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/NormaliseBitPacking.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/RebuildBiomes.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/SettleYRange.java create mode 100644 falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/TranslateBlockStates.java create mode 100644 falco-migration/src/test/java/net/onelitefeather/falco/migration/LegacyBitReaderTest.java create mode 100644 falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/SectionStepsTest.java 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/steps/NormaliseBitPacking.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/NormaliseBitPacking.java new file mode 100644 index 0000000..1330509 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/NormaliseBitPacking.java @@ -0,0 +1,116 @@ +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.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 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, + * 2566, 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. + *

+ * + * @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 for the block-state rename table and, in Task 6, {@code CountEntities} — 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 PALETTE_KEY = "Palette"; + private static final String BLOCK_STATES_KEY = "BlockStates"; + + 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. + */ + 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; + } + + ListBinaryTag palette = section.getList(PALETTE_KEY, BinaryTagTypes.COMPOUND); + int bitsPerEntry = BitPacker.bitsPerEntry(palette.size(), BLOCK_PALETTE_MIN_BITS); + + int[] indices = LegacyBitReader.unpack(legacy.value(), bitsPerEntry, BLOCK_ENTRIES); + long[] repacked = BitPacker.pack(indices, bitsPerEntry); + + return section.putLongArray(BLOCK_STATES_KEY, repacked); + } +} 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..73801eb --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/RebuildBiomes.java @@ -0,0 +1,295 @@ +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 Task 3 built {@code BlockStateRules} 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. + *

+ * + * @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"; + + 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 NormaliseBitPacking#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, or holds a + * legacy numeric id this step's table does not know + */ + @Override + public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context) { + 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); + 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); + } + + 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..d60baf6 --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/SettleYRange.java @@ -0,0 +1,94 @@ +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, gets {@code yPos = 0} — the floor every version below + * 2844 in this module's range actually used (see {@code UnfoldLevel}'s own javadoc) — rather than an + * arbitrary default. + *

+ * + * @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 chunk.putInt(Y_POS_KEY, lowest); + } +} 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..268c5ba --- /dev/null +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/TranslateBlockStates.java @@ -0,0 +1,180 @@ +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.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}, + * pinned here for the same reason {@link NormaliseBitPacking#BLOCK_PALETTE_MIN_BITS} is. + */ + 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) { + PaletteContainer container = readContainer(section); + if (container == null) { + return section; + } + + List translatedPalette = new ArrayList<>(container.palette().size()); + for (BlockState state : container.palette()) { + translatedPalette.add(BlockStateRules.translate(state, sourceVersion)); + } + + 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; + } + + 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) { + int bitsPerEntry = BitPacker.bitsPerEntry(palette.size(), BLOCK_PALETTE_MIN_BITS); + indices = BitPacker.unpack(data.value(), 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)); + } + + CompoundBinaryTag container = CompoundBinaryTag.builder().put(PALETTE_KEY, paletteTag).build(); + if (palette.size() > 1 && indices.length > 0) { + 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 index ce26881..dbeab3a 100644 --- 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 @@ -22,19 +22,12 @@ * not seen turns out to disagree. *

*

- * {@code yPos = 0} is provisional, not a settled answer. A {@code yPos} field is added at the - * root because 2844 is also the version that introduced it. {@code 0} is correct for what the - * source chunk means: every version below 2844 fixed the world height to sections - * {@code 0}–{@code 15}, so a pre-1.18 chunk's own lowest stored section is always {@code 0}. What - * {@code yPos} means for the target version is a separate, still-open question — the field's - * own wiki documentation ("Lowest Y section position in the chunk (e.g. -4 in 1.18)") is ambiguous - * between "the lowest section this chunk itself stores" and "the bottom of the dimension's height - * range", and vanilla data can never tell the two apart because vanilla always writes every section - * down to the dimension floor. A converted chunk, which does not invent sections below 0 it never - * had, is exactly the case where the two readings split. Resolving that split, and therefore whether - * {@code 0} is the value this step should keep producing, is Task 5's {@code SettleYRange} step, not - * this one — a reader of this class should not conclude the question is answered because a number is - * already here. + * {@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 @@ -57,7 +50,6 @@ public final class UnfoldLevel implements MigrationStep { private static final String LEVEL_KEY = "Level"; private static final String SECTIONS_KEY = "Sections"; private static final String LOWERCASE_SECTIONS_KEY = "sections"; - private static final String Y_POS_KEY = "yPos"; /** * Creates a new instance of this stateless step. @@ -95,6 +87,6 @@ public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context } root = root.put(key, child.getValue()); } - return root.putInt(Y_POS_KEY, 0); + return root; } } 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 index 463145c..db75734 100644 --- a/falco-migration/src/test/java/net/onelitefeather/falco/migration/ChunkMigrationTest.java +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/ChunkMigrationTest.java @@ -35,10 +35,10 @@ void testAPreEighteenChunkGetsItsFieldsOnTheRoot() { assertEquals(3, migrated.getInt("xPos")); assertEquals("minecraft:full", migrated.getString("Status")); assertNotNull(migrated.get("sections")); - // Pins today's value, not a claim that it is the right one: yPos = 0 is UnfoldLevel's - // provisional stand-in until Task 5's SettleYRange decides what yPos means for the target - // version (see UnfoldLevel's javadoc). This assertion exists so that change is visible as a - // deliberate edit to this test rather than slipping past unnoticed. + // 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")); } 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/steps/SectionStepsTest.java b/falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/SectionStepsTest.java new file mode 100644 index 0000000..c7ac31e --- /dev/null +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/SectionStepsTest.java @@ -0,0 +1,270 @@ +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); + + // --- 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 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 testAWidenedTwentyFourEntryBiomeArrayBecomesAUniformPaletteForItsSection() { + 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).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).build(), + CompoundBinaryTag.builder().putInt("Y", 5).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).build()))) + .build(); + + MigrationException exception = assertThrows(MigrationException.class, + () -> new RebuildBiomes().apply(chunk, ANY_CONTEXT)); + assertTrue(exception.getMessage().contains("253")); + } + + // --- TranslateBlockStates, including the full-chain Gegenprobe target ----------------------- + + @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"); + } + + /** + * 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", "full") + .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; + } +} From 999b9fc69914fa705f84992f21b9c0c6df594ae6 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 4 Aug 2026 23:41:01 +0200 Subject: [PATCH 15/22] fix(migration): drop out-of-range sections, verify packed width, rename TileEntities Joint review of Tasks 5 and 6 found three defects, taken on together here so the fixes stay in one commit rather than splitting back across two agents sharing files again. RebuildBiomes crashed with ArrayIndexOutOfBoundsException on any real vanilla chunk below 1.18: vanilla writes a lighting-only section at Y=-1 and Y=16 outside the real 0..15 range, and nothing discarded them before RebuildBiomes indexed its widened biome array at a negative offset. RebuildBiomes now drops every out-of-range section as its first action; SettleYRange independently re-checks the same range rather than trusting the chain's ordering. NormaliseBitPacking and TranslateBlockStates both derived a section's bits-per-entry from its palette size, but the format explicitly allows a writer to use more bits than the palette needs - exactly why falco-anvil's own PaletteData.read resolves width against the packed array's length instead. A 17-entry palette packed at 6 bits was silently read at 5 and re-packed at 5, no exception anywhere. NormaliseBitPacking now derives the exact width from the array's own length (entryCount is a fixed multiple of 64, so the division is exact, not a guess); TranslateBlockStates now resolves it via BitPacker.resolveBitsPerEntry, throwing if nothing resolves. UnfoldLevel renamed Sections to sections but left TileEntities alone, even though both fields were renamed in the same snapshot (21w43a) that removed Level in the first place - every block entity from every pre-2844 world was landing under a key the target version never reads. UnfoldLevel's rename now covers both fields from one small table. Also: TranslateBlockStates passes an already-modern section through unchanged when no rule fires, instead of unpacking and re-packing it for nothing; ten Javadoc references to "this task" or a task report pointed at .superpowers/sdd/, which is gitignored, reworded to stand on their own. Gegenprobe run for the first two findings: reverted each fix in place, confirmed exactly the tests written for it went red (one reproducing the reviewer's own ArrayIndexOutOfBoundsException), everything else stayed green; reverted. The third finding's own pre-fix test already served as its red case. Co-Authored-By: Claude Opus 5 (1M context) --- .../falco/migration/BlockStateRules.java | 4 +- .../falco/migration/MigrationContext.java | 5 +- .../migration/steps/NormaliseBitPacking.java | 42 ++-- .../falco/migration/steps/RebuildBiomes.java | 56 +++++- .../falco/migration/steps/SettleYRange.java | 19 ++ .../steps/TranslateBlockEntities.java | 50 +++-- .../migration/steps/TranslateBlockStates.java | 47 ++++- .../falco/migration/steps/UnfoldLevel.java | 39 ++-- .../migration/steps/BlockEntityStepTest.java | 22 ++- .../migration/steps/SectionStepsTest.java | 179 +++++++++++++++++- 10 files changed, 385 insertions(+), 78 deletions(-) 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 index ad472dc..4281536 100644 --- a/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java @@ -49,8 +49,8 @@ public final class BlockStateRules { * that a per-property implementation is guaranteed wrong, and that 9 of the 81 direction * combinations change (times 16 {@code power} values = 144 states). It does not say which 9 * combinations change or what they become — only the count. That is not enough to reproduce - * V2531's logic exactly, and a guessed whole-state table for redstone wiring is exactly the - * silent corruption this task was told to refuse: the chunk still loads, the wiring looks + * V2531's logic exactly, and a guessed whole-state table for redstone wiring would be exactly the + * silent corruption this module exists to avoid: the chunk still loads, the wiring looks * subtly different, and nobody notices. A state this module does not recognize — including every * {@code redstone_wire} state — passes through {@link #translate(BlockState, int)} unchanged; * see {@code BlockStateRulesTest.testAStateNoRuleKnowsAboutPassesThroughUnchanged}. 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 index 934fb61..dcfceb6 100644 --- a/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationContext.java +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/MigrationContext.java @@ -11,8 +11,9 @@ *

* 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 — none of the three structural steps built - * in Task 4 need it, but a step that resolves a rename table for a specific target does. + * 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 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 index 1330509..429e6fa 100644 --- 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 @@ -44,8 +44,9 @@ public final class NormaliseBitPacking implements MigrationStep { * 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 for the block-state rename table and, in Task 6, {@code CountEntities} — so it was - * checked here too rather than trusted. A chunk in the gap this correction closes, DataVersion + * 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 @@ -55,18 +56,10 @@ public final class NormaliseBitPacking implements MigrationStep { private static final String LEVEL_KEY = "Level"; private static final String SECTIONS_KEY = "Sections"; - private static final String PALETTE_KEY = "Palette"; private static final String BLOCK_STATES_KEY = "BlockStates"; 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. - */ - private static final int BLOCK_PALETTE_MIN_BITS = 4; + private static final int BITS_PER_LONG = Long.SIZE; /** * Creates a new instance of this stateless step. @@ -105,12 +98,33 @@ private static CompoundBinaryTag normalise(CompoundBinaryTag section) { return section; } - ListBinaryTag palette = section.getList(PALETTE_KEY, BinaryTagTypes.COMPOUND); - int bitsPerEntry = BitPacker.bitsPerEntry(palette.size(), BLOCK_PALETTE_MIN_BITS); + long[] packed = legacy.value(); + int bitsPerEntry = exactBitsPerEntry(packed.length); - int[] indices = LegacyBitReader.unpack(legacy.value(), bitsPerEntry, BLOCK_ENTRIES); + int[] indices = LegacyBitReader.unpack(packed, bitsPerEntry, BLOCK_ENTRIES); long[] repacked = BitPacker.pack(indices, bitsPerEntry); return section.putLongArray(BLOCK_STATES_KEY, repacked); } + + /** + * Derives the bits-per-entry a legacy writer actually used from the packed array's own length, + * rather than from the palette size the way an earlier version of this method did. + *

+ * 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, and assuming the minimum here silently misreads an over-width-packed section and then + * corrupts it on repacking, without throwing. This derivation 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. + *

+ * + * @param longCount the length of the packed {@code BlockStates} array + * @return the exact bits-per-entry that array was packed with + */ + private static int exactBitsPerEntry(int longCount) { + return longCount * BITS_PER_LONG / BLOCK_ENTRIES; + } } 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 index 73801eb..6452191 100644 --- 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 @@ -46,8 +46,9 @@ * 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 Task 3 built {@code BlockStateRules} from, does not - * carry a {@code biomes} list at all (see the design document's "registry lists" section). The table + * {@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 @@ -70,6 +71,19 @@ * inventing a biome is the same silent corruption an unmappable block is refused for elsewhere in * this module. *

+ *

+ * A section outside the fixed pre-1.18 range, {@code Y} {@value #MIN_SECTION_Y} to + * {@value #MAX_SECTION_Y}, is discarded before anything else runs. Vanilla itself writes one + * extra section below and one above that range 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}). Nothing upstream of + * this step drops them, so without this check {@code Y = -1} would 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 {@code -4}..{@code 19} range as a + * spurious empty section nobody asked for. + *

* * @since 2.1.0 */ @@ -89,6 +103,14 @@ public final class RebuildBiomes implements MigrationStep { private static final int WIDENED_ENTRIES = 1024; private static final int SECTION_BIOME_ENTRIES = 4 * 4 * 4; + /** + * The fixed section range every pre-1.18 chunk in this module's range actually stores content + * for — see this class's own javadoc for why a section outside it is discarded rather than + * processed. + */ + private static final int MIN_SECTION_Y = 0; + private static final int MAX_SECTION_Y = 15; + /** * 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 @@ -121,6 +143,8 @@ public boolean appliesTo(int sourceVersion) { */ @Override public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context) { + chunk = discardSectionsOutsideTheFixedRange(chunk); + if (!(chunk.get(BIOMES_KEY) instanceof IntArrayBinaryTag biomesTag)) { return chunk; } @@ -147,6 +171,34 @@ public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context return chunk.remove(BIOMES_KEY).put(SECTIONS_KEY, rebuilt); } + /** + * Drops every section whose {@code Y} falls outside {@value #MIN_SECTION_Y}.. + * {@value #MAX_SECTION_Y} — see this class's own javadoc for why vanilla writes them and why they + * must not reach the biome-rebuilding logic below. 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 discardSectionsOutsideTheFixedRange(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) { + int sectionY = section.getInt(SECTION_Y_KEY); + if (sectionY < MIN_SECTION_Y || sectionY > MAX_SECTION_Y) { + droppedAny = true; + continue; + } + } + kept = kept.add(sectionTag); + } + return droppedAny ? chunk.put(SECTIONS_KEY, kept) : chunk; + } + private static int[] widen(int[] legacy) { int[] widened = new int[WIDENED_ENTRIES]; for (int i = 0; i < 4; i++) { 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 index d60baf6..7de66d8 100644 --- 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 @@ -46,6 +46,15 @@ * 2844 in this module's range actually used (see {@code UnfoldLevel}'s own javadoc) — rather than an * arbitrary default. *

+ *

+ * A section outside {@value #MIN_SECTION_Y}..{@value #MAX_SECTION_Y} does not count. Vanilla + * writes one extra section below and one above the real range purely to carry lighting data — see + * {@link RebuildBiomes}'s own javadoc, which discards these before this step ever runs, for the + * sourcing. This step re-checks the same range on its own rather than trusting that ordering: were it + * ever exercised on a chunk {@code RebuildBiomes} had not already cleaned — directly, in a test, or + * because the chain is reordered later — a lighting-only section at {@code Y = -1} would otherwise + * compute {@code yPos = -1}, which is not a section this chunk has any real content for. + *

* * @since 2.1.0 */ @@ -62,6 +71,13 @@ public final class SettleYRange implements MigrationStep { private static final String SECTION_Y_KEY = "Y"; private static final String Y_POS_KEY = "yPos"; + /** + * The fixed section range every pre-1.18 chunk in this module's range actually stores content + * for — see this class's own javadoc for why a section outside it must not count. + */ + private static final int MIN_SECTION_Y = 0; + private static final int MAX_SECTION_Y = 15; + /** * Creates a new instance of this stateless step. */ @@ -82,6 +98,9 @@ public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context for (BinaryTag sectionTag : sections) { if (sectionTag instanceof CompoundBinaryTag section) { int sectionY = section.getInt(SECTION_Y_KEY); + if (sectionY < MIN_SECTION_Y || sectionY > MAX_SECTION_Y) { + continue; + } if (!any || sectionY < lowest) { lowest = sectionY; any = true; 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 index ed90ad7..cd32b45 100644 --- 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 @@ -29,32 +29,27 @@ * 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 this task's research took - * 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. See the task report for the full per-entry accounting. + * 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. *

*

- * Known gap, not fixed here: the block-entity list's own container key is not renamed by this - * chain. {@code Level.TileEntities} became the root-level {@code block_entities} in the same - * snapshot (21w43a, DataVersion 2844) that {@code UnfoldLevel} already treats as its own threshold for - * unfolding {@code Level} and renaming {@code Sections} to {@code sections} — but {@code UnfoldLevel}'s - * own Javadoc states that every child other than {@code Sections} keeps its name as it moves, which is - * inaccurate for this one field. This step reads whichever of {@code block_entities} or - * {@code TileEntities} is actually present at the chunk's root and writes the (possibly id-renamed) - * list back under that same key — it does not decide which name the output should carry, because - * doing so would mean quietly reaching into a decision {@code UnfoldLevel} (a different task's file) - * already made. A chunk converted from a pre-2844 source therefore still carries its block entities - * under {@code TileEntities} after this whole chain runs, a name the target version's reader will - * never look under. Reported here and in the task report rather than patched, matching how this - * module has handled every other gap found next to a task rather than inside it. + * 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 @@ -68,8 +63,7 @@ public final class TranslateBlockEntities implements MigrationStep { /** * 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, and the task report for the per-entry accounting that - * established it. + * see this class's Javadoc for why. */ private static final Map RENAMES = Map.of(); @@ -125,8 +119,8 @@ private static BinaryTag translateEntry(BinaryTag entry) { * 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 this task's research found to be empty for - * every {@code id} a 1.13 world can actually contain; see this class's Javadoc. + * 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} 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 index 268c5ba..a451ec4 100644 --- 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 @@ -10,6 +10,7 @@ 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; @@ -62,8 +63,12 @@ public final class TranslateBlockStates implements MigrationStep { private static final int BLOCK_ENTRIES = 16 * 16 * 16; /** - * Minestom's {@code net.minestom.server.instance.palette.Palette.BLOCK_PALETTE_MIN_BITS}, - * pinned here for the same reason {@link NormaliseBitPacking#BLOCK_PALETTE_MIN_BITS} is. + * 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; @@ -95,14 +100,26 @@ public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context } 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()) { - translatedPalette.add(BlockStateRules.translate(state, sourceVersion)); + 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()); @@ -119,6 +136,19 @@ private static CompoundBinaryTag translate(CompoundBinaryTag section, int source 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) { @@ -129,8 +159,15 @@ private static PaletteContainer readFrom(ListBinaryTag paletteTag, @Nullable Bin int[] indices; if (dataTag instanceof LongArrayBinaryTag data) { - int bitsPerEntry = BitPacker.bitsPerEntry(palette.size(), BLOCK_PALETTE_MIN_BITS); - indices = BitPacker.unpack(data.value(), BLOCK_ENTRIES, bitsPerEntry); + 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]; } 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 index dbeab3a..bdaaffd 100644 --- 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 @@ -13,13 +13,17 @@ * 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. *

- * {@code Sections} is renamed to {@code sections} as it moves, because that is the one field name - * that changed rather than only its position — every other child keeps its own name. 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. + * 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: "Removed chunk's {@code Level} and moved everything it + * contained up. {@code Level.Entities} has moved to {@code entities}. {@code Level.TileEntities} has + * moved to {@code block_entities}..." — checked 2026-08-04. Every other child keeps its own name as + * it moves. 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 @@ -31,7 +35,7 @@ *

*

* The list of fields this step moves is deliberately not written down here: it moves whatever - * {@code Level} actually holds, rather than a fixed set of names invented for this task. A chunk with + * {@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. *

@@ -48,8 +52,15 @@ public final class UnfoldLevel implements MigrationStep { private static final int APPLIES_BELOW = 2844; private static final String LEVEL_KEY = "Level"; - private static final String SECTIONS_KEY = "Sections"; - private static final String LOWERCASE_SECTIONS_KEY = "sections"; + + /** + * 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. @@ -68,9 +79,9 @@ public boolean appliesTo(int sourceVersion) { * @param chunk {@inheritDoc} * @param context {@inheritDoc} * @return {@inheritDoc} - * @throws MigrationException if a child of {@code Level} — after the {@code Sections}-to- - * {@code sections} rename, where applicable — has the same name as a - * field already present at the chunk's root, naming that field + * @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) { @@ -80,7 +91,7 @@ public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context CompoundBinaryTag root = chunk.remove(LEVEL_KEY); for (Map.Entry child : level) { - String key = SECTIONS_KEY.equals(child.getKey()) ? LOWERCASE_SECTIONS_KEY : child.getKey(); + 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 + "'"); 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 index 2eb10fd..a043572 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -47,12 +48,12 @@ void testABlockEntityWithNoVerifiedRenameKeepsItsIdAndPositionUnchanged() { } @Test - void testALegacyTileEntitiesListSurvivesUnderItsOwnKeyBecauseNoStepInThisChainRenamesTheContainer() { - // UnfoldLevel (Task 4) moves every child of Level onto the root without renaming it, except - // Sections -> sections; TileEntities is therefore still called TileEntities once it reaches - // this step. TranslateBlockEntities does not rename the container either - only the id inside - // each entry - so it stays TileEntities all the way through. Documented as a known gap in - // this class's Javadoc and the task report, not silently patched here. + 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() @@ -66,10 +67,11 @@ void testALegacyTileEntitiesListSurvivesUnderItsOwnKeyBecauseNoStepInThisChainRe CompoundBinaryTag migrated = ChunkMigration.migrate(chunk, 4790); - ListBinaryTag tileEntities = assertInstanceOf(ListBinaryTag.class, migrated.get("TileEntities"), - "no step in this chain renames the TileEntities container key - see the report"); - CompoundBinaryTag tileEntity = (CompoundBinaryTag) tileEntities.iterator().next(); - assertEquals("minecraft:furnace", tileEntity.getString("id")); + 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 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 index c7ac31e..f9653ca 100644 --- 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 @@ -65,6 +65,44 @@ void testASectionsSpanningBlockStatesAreReadableWithBitPackerAfterNormalising() "BitPacker must read back exactly what the legacy, spanning layout held"); } + @Test + void testAnOverWidthPackedPaletteIsReadAtTheWidthItWasActuallyPackedWith() { + // 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 the width + // is derived 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. + 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[] roundTripped = BitPacker.unpack(repacked, values.length, bitsPerEntry); + assertArrayEquals(values, roundTripped, + "the 6-bit width the writer actually used must survive, not the 5 bits the palette size " + + "alone would suggest"); + } + @Test void testASingleValueSectionWithNoBlockStatesArrayIsLeftAlone() { CompoundBinaryTag section = CompoundBinaryTag.builder() @@ -86,7 +124,7 @@ void testASingleValueSectionWithNoBlockStatesArrayIsLeftAlone() { // --- RebuildBiomes --------------------------------------------------------------------------- @Test - void testAWidenedTwentyFourEntryBiomeArrayBecomesAUniformPaletteForItsSection() { + void testAOneThousandTwentyFourEntryBiomeArrayBecomesAUniformPaletteForItsSection() { int[] biomes = new int[1024]; java.util.Arrays.fill(biomes, 0, 64, 1); // section Y=0: every cell is id 1 (plains) @@ -179,6 +217,77 @@ void testAnUnknownLegacyBiomeIdFailsRatherThanInventingAName() { assertTrue(exception.getMessage().contains("253")); } + // --- Sections outside the fixed 0..15 range --------------------------------------------------- + + @Test + void testASectionBelowZeroDoesNotCrashRebuildBiomesAndIsDroppedRatherThanKept() { + // Vanilla writes one extra section below the real 0..15 range (Y = -1) purely to carry + // lighting data for the section it borders. Without discarding it first, its offset into + // the widened biome array (-1 * 64 = -64) is negative and indexing it throws + // ArrayIndexOutOfBoundsException rather than a MigrationException. + int[] biomes = new int[1024]; + java.util.Arrays.fill(biomes, 1); + + CompoundBinaryTag chunk = CompoundBinaryTag.builder() + .putIntArray("Biomes", biomes) + .put("sections", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putInt("Y", -1).build(), + CompoundBinaryTag.builder().putInt("Y", 0).build()))) + .build(); + + CompoundBinaryTag rebuilt = new RebuildBiomes().apply(chunk, ANY_CONTEXT); + + ListBinaryTag sections = rebuilt.getList("sections"); + assertEquals(1, sections.size(), "the Y=-1 lighting-only section must not survive into the output"); + assertEquals(0, sections.getCompound(0).getInt("Y")); + } + + @Test + void testAnOutOfRangeSectionDoesNotCountTowardsSettleYRangesMinimum() { + // Exercised directly against SettleYRange, independent of whether RebuildBiomes already + // ran and already cleaned the list - the step re-checks the range itself rather than + // trusting the chain's ordering (see its own Javadoc). + 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(0, settled.getInt("yPos"), + "no in-range section is present, so this falls back to the same default an empty list gets"); + } + + @Test + void testSectionsAtYMinusOneAndYSixteenSurviveTheWholeChainDiscardedRatherThanCorruptingItOrYPos() { + // The end-to-end version of the two direct tests above: a chunk with a lighting-only + // section on both sides of the real range must migrate cleanly, end up with only its one + // real section, and settle yPos on that real section's own Y rather than the 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", "full") + .putIntArray("Biomes", biomes) + .put("Sections", ListBinaryTag.from(List.of( + CompoundBinaryTag.builder().putByte("Y", (byte) -1).build(), + CompoundBinaryTag.builder().putByte("Y", (byte) 0).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 is real content; 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 the discarded Y=-1"); + } + // --- TranslateBlockStates, including the full-chain Gegenprobe target ----------------------- @Test @@ -202,6 +311,74 @@ void testALegacyTopLevelPaletteBecomesAModernBlockStatesContainer() { "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 From 7c7d9c0b1e287306da0bc071a36cce72d8ff0d62 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 5 Aug 2026 10:07:56 +0200 Subject: [PATCH 16/22] fix(migration): point the pinned-constant javadoc link at the field that still carries it RebuildBiomes.BIOME_PALETTE_MIN_BITS explained itself as pinned "for the same reason" NormaliseBitPacking#BLOCK_PALETTE_MIN_BITS was - a field the packing-width fix (999b9fc6) removed from that class when it moved the responsibility to TranslateBlockStates. The link now names TranslateBlockStates#BLOCK_PALETTE_MIN_BITS, the field the sentence actually describes and which still exists. No behavioural change; the target was always private, so javadoc never rendered the link either way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR --- .../net/onelitefeather/falco/migration/steps/RebuildBiomes.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 6452191..050008d 100644 --- 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 @@ -114,7 +114,7 @@ public final class RebuildBiomes implements MigrationStep { /** * 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 NormaliseBitPacking#BLOCK_PALETTE_MIN_BITS} is: this module cannot depend on + * {@link TranslateBlockStates#BLOCK_PALETTE_MIN_BITS} is: this module cannot depend on * {@code net.minestom}. */ private static final int BIOME_PALETTE_MIN_BITS = 1; From c72095db3a12233ea5e1e447ce1ee4269f557814 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 5 Aug 2026 10:08:05 +0200 Subject: [PATCH 17/22] test(anvil): pin Minestom's own default for a block property a 1.13 source omits falco-migration's Task 7 relies on this but cannot implement or test it itself: for the 258 block states where a 1.13 source lacks a property the target block gained later, the correct value is the target version's own default, resolved by Minestom - never a hardcoded false. Coral and the conduit default waterlogged to true, unlike the fences/stairs/walls that make false look like a safe universal substitute; a converter that filled every gap with false would quietly dry out every reef. Exercises the exact call BlockPaletteResolver.toId already uses in production (Block.fromKey(name).withProperties(partial)), and cross-checks the defaults against net.minestom:data:26.1.2-rv1's own block.json rather than assuming them. Lives here rather than in falco-migration because resolving a default needs Block, and that module's own ArchUnit rule (migrationKnowsNoMinestom) forbids it from depending on Minestom at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR --- .../MigrationEnginePropertyDefaultTest.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 falco-anvil/src/test/java/net/onelitefeather/falco/anvil/MigrationEnginePropertyDefaultTest.java 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"); + } +} From 627cdd0dfb966e7bd22ad13eb27c43f1ca2dba96 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 5 Aug 2026 10:08:15 +0200 Subject: [PATCH 18/22] docs(plan): record the migration engine's acceptance Task 7 measured the whole branch against origin/main: seven modules with --rerun-tasks (counts from the JUnit XML, none fell against either the anvil-extension-points document's own baseline or a direct re-measurement of the actual fork commit), a full build with javadoc and the API check (clean, and falco-migration's absence from publishedModules means neither javadoc nor checkApiCompatibility currently run for it - recorded rather than left unnoticed), both of Task 5's gate attacks re-run against the full suite and reverted, the block-entity-rename research restated with its sources, and an explicit accounting of what this plan does not deliver. The spec's third evidence tier - real pre-1.18 worlds - was searched for on this machine and not found; that gap is recorded with what it would cost and what it would prove, rather than papered over. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR --- .../2026-08-04-falco-migration-engine.md | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) diff --git a/docs/superpowers/plans/2026-08-04-falco-migration-engine.md b/docs/superpowers/plans/2026-08-04-falco-migration-engine.md index 13ea825..5f98493 100644 --- a/docs/superpowers/plans/2026-08-04-falco-migration-engine.md +++ b/docs/superpowers/plans/2026-08-04-falco-migration-engine.md @@ -785,3 +785,254 @@ what Task 5's Gegenprobe attacks. `MigrationException` is this module's own **un 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. From b43cb7ea164fc309b1de58b49fd10e4cf1230d98 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 5 Aug 2026 10:50:35 +0200 Subject: [PATCH 19/22] fix(migration): translate 1.13 status values and canonicalise packed width The final review found a converted 1.13 world loads as empty: NamespaceStatus namespaced Level.Status without translating its value, so a genuinely complete 1.13 chunk ("postprocessed" or "fullchunk" - confirmed against 1.13.2's own decompiled ChunkStatus and PaperMC/DataConverter's V1905/V1911 rename chain, never "full") came out as "minecraft:postprocessed", which FalcoAnvilLoader silently skips. Every Status fixture in the branch wrote "full" by hand, a value a 1.13 chunk never produces, which is exactly how this went unnoticed. Also fixes the second, measured finding: NormaliseBitPacking preserved the exact bit width it read from a legacy section, but that width is not always recoverable by TranslateBlockStates downstream - expectedLongCount(4096, 11) and (4096, 12) collide, so an over-provisioned width could be silently misread. Re-packing at the palette's own canonical minimum instead removes the ambiguity constructively. Smaller fixes bundled in the same pass: UnfoldLevel's javadoc no longer claims a third rename (Level.Entities) it deliberately does not perform; falco-migration's javadoc is now wired into `check`; ChunkMigration distinguishes a missing DataVersion from a present-but-too-old one; NormaliseBitPacking and TranslateBlockStates fail loud instead of silently mis-decoding on a few more malformed shapes; a stale BlockStateRule javadoc example and two wrong DataVersion numbers in the design doc are corrected. Adds the round-trip test the review asked for as the real acceptance check: MigrationRoundTripTest in falco-anvil runs a hand-built 1.13 chunk through ChunkMigration, writes it into a real region file, and loads it back through the production FalcoAnvilLoader, asserting a placed block actually arrives. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR --- .../2026-08-04-falco-migration-design.md | 6 +- falco-anvil/build.gradle.kts | 7 + .../falco/anvil/MigrationRoundTripTest.java | 166 +++++++++++++++++ falco-migration/build.gradle.kts | 8 + .../falco/migration/BlockStateRule.java | 5 +- .../falco/migration/ChunkMigration.java | 13 +- .../migration/steps/NamespaceStatus.java | 54 +++++- .../migration/steps/NormaliseBitPacking.java | 86 +++++++-- .../migration/steps/TranslateBlockStates.java | 12 +- .../falco/migration/steps/UnfoldLevel.java | 13 +- .../falco/migration/ChunkMigrationTest.java | 30 ++- .../migration/steps/NamespaceStatusTest.java | 81 ++++++++ .../migration/steps/SectionStepsTest.java | 176 +++++++++++++++++- 13 files changed, 614 insertions(+), 43 deletions(-) create mode 100644 falco-anvil/src/test/java/net/onelitefeather/falco/anvil/MigrationRoundTripTest.java create mode 100644 falco-migration/src/test/java/net/onelitefeather/falco/migration/steps/NamespaceStatusTest.java diff --git a/docs/superpowers/specs/2026-08-04-falco-migration-design.md b/docs/superpowers/specs/2026-08-04-falco-migration-design.md index 04370f9..a6fddf4 100644 --- a/docs/superpowers/specs/2026-08-04-falco-migration-design.md +++ b/docs/superpowers/specs/2026-08-04-falco-migration-design.md @@ -249,12 +249,12 @@ source version intersects, in order. | # | Step | Applies below | Notes | | --- | --- | --- | --- | -| 1 | Normalise the bit packing | 2566 (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 | -| 2 | **Count** entities still in the chunk | 2724 (1.17) | Counted and reported, **not moved**. See "The entity debt" | +| 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 | `full` → `minecraft:full` | +| 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 | 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/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-migration/build.gradle.kts b/falco-migration/build.gradle.kts index 99efbfb..541e63c 100644 --- a/falco-migration/build.gradle.kts +++ b/falco-migration/build.gradle.kts @@ -16,3 +16,11 @@ dependencies { 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/BlockStateRule.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRule.java index 80a3289..1db90d8 100644 --- a/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRule.java +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRule.java @@ -28,8 +28,9 @@ public interface BlockStateRule { *

* 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() == 1802} therefore applies to a - * 1.13 world ({@code 1519 < 1802}) and leaves a 1.16 world ({@code 2566 > 1802}) alone, because + * {@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. *

* 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 index 10e13ce..8dbfdc5 100644 --- a/falco-migration/src/main/java/net/onelitefeather/falco/migration/ChunkMigration.java +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/ChunkMigration.java @@ -67,10 +67,21 @@ private ChunkMigration() { * @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 the chunk's own {@code DataVersion} is older than + * @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)); } 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 index 5485572..902b3fc 100644 --- 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 @@ -6,17 +6,49 @@ import net.onelitefeather.falco.migration.MigrationStep; import org.jetbrains.annotations.ApiStatus; +import java.util.Map; + /** - * Rewrites a chunk status without a namespace, such as {@code full}, into its namespaced form, - * {@code minecraft:full}. + * 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 prefixed with - * {@code minecraft:}. + * 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. @@ -30,6 +62,17 @@ 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. */ @@ -51,6 +94,7 @@ public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context if (value.indexOf(':') >= 0) { return chunk; } - return chunk.putString(STATUS_KEY, MINECRAFT_NAMESPACE + value); + 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 index 429e6fa..4d61113 100644 --- 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 @@ -8,16 +8,18 @@ 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 2566) wrote into the long-aligned layout {@code falco-anvil}'s + * 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, - * 2566, is strictly below {@code UnfoldLevel}'s, 2844, so every chunk this step applies to still + * 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 @@ -31,6 +33,24 @@ * 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 */ @@ -57,10 +77,22 @@ public final class NormaliseBitPacking implements MigrationStep { 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. */ @@ -98,33 +130,59 @@ private static CompoundBinaryTag normalise(CompoundBinaryTag section) { 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 bitsPerEntry = exactBitsPerEntry(packed.length); + int readBitsPerEntry = exactBitsPerEntry(packed.length); + int[] indices = LegacyBitReader.unpack(packed, readBitsPerEntry, BLOCK_ENTRIES); - int[] indices = LegacyBitReader.unpack(packed, bitsPerEntry, BLOCK_ENTRIES); - long[] repacked = BitPacker.pack(indices, bitsPerEntry); + // 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, - * rather than from the palette size the way an earlier version of this method did. + * 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, and assuming the minimum here silently misreads an over-width-packed section and then - * corrupts it on repacking, without throwing. This derivation 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. + * 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) { - return longCount * BITS_PER_LONG / BLOCK_ENTRIES; + 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/TranslateBlockStates.java b/falco-migration/src/main/java/net/onelitefeather/falco/migration/steps/TranslateBlockStates.java index a451ec4..2adefae 100644 --- 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 @@ -193,8 +193,18 @@ private static CompoundBinaryTag writeContainer(List palette, int[] 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 && indices.length > 0) { + 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)); 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 index bdaaffd..f213602 100644 --- 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 @@ -16,10 +16,15 @@ * 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: "Removed chunk's {@code Level} and moved everything it - * contained up. {@code Level.Entities} has moved to {@code entities}. {@code Level.TileEntities} has - * moved to {@code block_entities}..." — checked 2026-08-04. Every other child keeps its own name as - * it moves. A field that would silently overwrite a same-named field already present at the root is + * 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 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 index db75734..6c868d5 100644 --- a/falco-migration/src/test/java/net/onelitefeather/falco/migration/ChunkMigrationTest.java +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/ChunkMigrationTest.java @@ -5,6 +5,7 @@ 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; @@ -19,12 +20,16 @@ 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", "full") + .putString("Status", "postprocessed") .put("Sections", ListBinaryTag.empty()) .build()) .build(); @@ -33,7 +38,9 @@ void testAPreEighteenChunkGetsItsFieldsOnTheRoot() { assertNull(migrated.get("Level")); assertEquals(3, migrated.getInt("xPos")); - assertEquals("minecraft:full", migrated.getString("Status")); + 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 @@ -95,6 +102,23 @@ void testHeightmapsAndLightAreDroppedRatherThanConverted() { void testAChunkBelowTheFloorIsDeclinedRatherThanGuessedAt() { CompoundBinaryTag ancient = CompoundBinaryTag.builder().putInt("DataVersion", 1000).build(); - assertThrows(MigrationException.class, () -> ChunkMigration.migrate(ancient, 4790)); + 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/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 index f9653ca..8189ad3 100644 --- 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 @@ -27,6 +27,7 @@ class SectionStepsTest { private static final MigrationContext ANY_CONTEXT = new MigrationContext(1519, 4790); + private static final int BLOCK_ENTRIES = 16 * 16 * 16; // --- NormaliseBitPacking ------------------------------------------------------------------- @@ -66,12 +67,14 @@ void testASectionsSpanningBlockStatesAreReadableWithBitPackerAfterNormalising() } @Test - void testAnOverWidthPackedPaletteIsReadAtTheWidthItWasActuallyPackedWith() { + 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 the width - // is derived 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. + // 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++) { @@ -97,10 +100,141 @@ void testAnOverWidthPackedPaletteIsReadAtTheWidthItWasActuallyPackedWith() { 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); + 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 6-bit width the writer actually used must survive, not the 5 bits the palette size " - + "alone would suggest"); + "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 @@ -271,7 +405,7 @@ void testSectionsAtYMinusOneAndYSixteenSurviveTheWholeChainDiscardedRatherThanCo .put("Level", CompoundBinaryTag.builder() .putInt("xPos", 0) .putInt("zPos", 0) - .putString("Status", "full") + .putString("Status", "postprocessed") .putIntArray("Biomes", biomes) .put("Sections", ListBinaryTag.from(List.of( CompoundBinaryTag.builder().putByte("Y", (byte) -1).build(), @@ -290,6 +424,28 @@ void testSectionsAtYMinusOneAndYSixteenSurviveTheWholeChainDiscardedRatherThanCo // --- 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() @@ -405,7 +561,7 @@ void testACobblestoneWallWithNorthTrueSurvivesTheWholeChainAsNorthLow() { .put("Level", CompoundBinaryTag.builder() .putInt("xPos", 0) .putInt("zPos", 0) - .putString("Status", "full") + .putString("Status", "postprocessed") .put("Sections", ListBinaryTag.from(List.of(wallSection))) .build()) .build(); From 728ea56ae98cecd0fae3e3889b7610f7c123740f Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 5 Aug 2026 12:33:13 +0200 Subject: [PATCH 20/22] build: publish falco-migration, and keep it out of the one check it cannot pass yet The module is meant to be consumed, so it joins publishedModules and the list that creates the maven publication with its javadoc and sources jars - maven-publish alone produces nothing, and that second list was easy to miss. It stays out of japicmp until it has been released once. The baseline check refuses an unresolvable baseline on purpose, because a comparison against nothing passes whatever changed, and weakening that guard to accommodate a new module would blunt it for the three that depend on it. The exclusion is a named list with a comment saying when to delete it, not a silent condition. The module-level "check dependsOn javadoc" from the acceptance stays, although withJavadocJar() now pulls javadoc into the graph anyway. It costs nothing and keeps the requirement stated where someone changing the publication would see it. Verified: build -x test green with all 31 tasks executed, falco-migration now produces jar, javadoc, javadocJar, sourcesJar and a publication, and checkApiCompatibility still resolves for exactly the three modules that have a baseline. Co-Authored-By: Claude Opus 5 (1M context) --- build.gradle.kts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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( From 7f53b6a18932a9b4342eba9f18c0223c48928fde Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 5 Aug 2026 13:02:02 +0200 Subject: [PATCH 21/22] fix(migration): discard chunk sections by block content, not a fixed Y range RebuildBiomes and SettleYRange discarded/counted sections using a hardcoded Y = 0..15 range, correct only for a world at the pre-1.18 fixed height (0..255). Configurable world height landed at DataVersion 2685 (20w49a, confirmed against that snapshot's own minecraft.wiki changelog) - well below every source version this module runs for - so the range check silently ate real content sections from any custom-height world, or a stock Overworld converted mid-cycle into the -4..19 layout while still carrying the old Level-based format. A section is now discarded when it carries none of Palette, BlockStates or block_states, independent of its Y. Because a surviving section can now legitimately sit at Y < 0, RebuildBiomes.biomeContainer's offset (sectionY * SECTION_BIOME_ENTRIES, which assumes the lowest section is Y = 0) is no longer safe to compute blindly: apply() now throws MigrationException when a whole-chunk Biomes array is present alongside any surviving section below zero, rather than silently writing every biome layer at the wrong height. SettleYRange's own range filter is removed outright; yPos is the actual minimum Y among surviving sections, with no fallback to 0 when none exist. SectionStepsTest rewritten: fixtures that used bare, block-data-less sections to represent real content now carry a minimal Palette so the new criterion doesn't eat them; new cases cover the block-data/no-block- data split at negative Y, the Biomes-plus-negative-Y guard, and SettleYRange with no range filter. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR --- .../falco/migration/steps/RebuildBiomes.java | 109 +++++++++++----- .../falco/migration/steps/SettleYRange.java | 36 +++-- .../migration/steps/SectionStepsTest.java | 123 +++++++++++++----- 3 files changed, 182 insertions(+), 86 deletions(-) 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 index 050008d..4bfdf12 100644 --- 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 @@ -72,17 +72,42 @@ * this module. *

*

- * A section outside the fixed pre-1.18 range, {@code Y} {@value #MIN_SECTION_Y} to - * {@value #MAX_SECTION_Y}, is discarded before anything else runs. Vanilla itself writes one - * extra section below and one above that range 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}). Nothing upstream of - * this step drops them, so without this check {@code Y = -1} would 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 {@code -4}..{@code 19} range as a - * spurious empty section nobody asked for. + * 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 @@ -99,18 +124,19 @@ public final class RebuildBiomes implements MigrationStep { 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; - /** - * The fixed section range every pre-1.18 chunk in this module's range actually stores content - * for — see this class's own javadoc for why a section outside it is discarded rather than - * processed. - */ - private static final int MIN_SECTION_Y = 0; - private static final int MAX_SECTION_Y = 15; - /** * 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 @@ -138,12 +164,13 @@ public boolean appliesTo(int sourceVersion) { * @param chunk {@inheritDoc} * @param context {@inheritDoc} * @return {@inheritDoc} - * @throws MigrationException if {@code Biomes} holds neither 256 nor 1024 entries, or holds a - * legacy numeric id this step's table does not know + * @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 = discardSectionsOutsideTheFixedRange(chunk); + chunk = discardSectionsWithoutBlockData(chunk); if (!(chunk.get(BIOMES_KEY) instanceof IntArrayBinaryTag biomesTag)) { return chunk; @@ -158,6 +185,17 @@ public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context } 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)) { @@ -172,13 +210,13 @@ public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context } /** - * Drops every section whose {@code Y} falls outside {@value #MIN_SECTION_Y}.. - * {@value #MAX_SECTION_Y} — see this class's own javadoc for why vanilla writes them and why they - * must not reach the biome-rebuilding logic below. 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). + * 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 discardSectionsOutsideTheFixedRange(CompoundBinaryTag chunk) { + private static CompoundBinaryTag discardSectionsWithoutBlockData(CompoundBinaryTag chunk) { ListBinaryTag sections = chunk.getList(SECTIONS_KEY, BinaryTagTypes.COMPOUND); if (sections.size() == 0) { return chunk; @@ -187,18 +225,21 @@ private static CompoundBinaryTag discardSectionsOutsideTheFixedRange(CompoundBin ListBinaryTag kept = ListBinaryTag.empty(); boolean droppedAny = false; for (BinaryTag sectionTag : sections) { - if (sectionTag instanceof CompoundBinaryTag section) { - int sectionY = section.getInt(SECTION_Y_KEY); - if (sectionY < MIN_SECTION_Y || sectionY > MAX_SECTION_Y) { - droppedAny = true; - continue; - } + 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++) { 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 index 7de66d8..ab03d64 100644 --- 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 @@ -42,18 +42,22 @@ *

*

* Runs after {@link UnfoldLevel}, so it reads the root {@code sections} list. A chunk whose - * {@code sections} list is empty, or absent, gets {@code yPos = 0} — the floor every version below - * 2844 in this module's range actually used (see {@code UnfoldLevel}'s own javadoc) — rather than an - * arbitrary default. + * {@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. *

*

- * A section outside {@value #MIN_SECTION_Y}..{@value #MAX_SECTION_Y} does not count. Vanilla - * writes one extra section below and one above the real range purely to carry lighting data — see - * {@link RebuildBiomes}'s own javadoc, which discards these before this step ever runs, for the - * sourcing. This step re-checks the same range on its own rather than trusting that ordering: were it - * ever exercised on a chunk {@code RebuildBiomes} had not already cleaned — directly, in a test, or - * because the chain is reordered later — a lighting-only section at {@code Y = -1} would otherwise - * compute {@code yPos = -1}, which is not a section this chunk has any real content for. + * 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 @@ -71,13 +75,6 @@ public final class SettleYRange implements MigrationStep { private static final String SECTION_Y_KEY = "Y"; private static final String Y_POS_KEY = "yPos"; - /** - * The fixed section range every pre-1.18 chunk in this module's range actually stores content - * for — see this class's own javadoc for why a section outside it must not count. - */ - private static final int MIN_SECTION_Y = 0; - private static final int MAX_SECTION_Y = 15; - /** * Creates a new instance of this stateless step. */ @@ -98,9 +95,6 @@ public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context for (BinaryTag sectionTag : sections) { if (sectionTag instanceof CompoundBinaryTag section) { int sectionY = section.getInt(SECTION_Y_KEY); - if (sectionY < MIN_SECTION_Y || sectionY > MAX_SECTION_Y) { - continue; - } if (!any || sectionY < lowest) { lowest = sectionY; any = true; @@ -108,6 +102,6 @@ public CompoundBinaryTag apply(CompoundBinaryTag chunk, MigrationContext context } } - return chunk.putInt(Y_POS_KEY, lowest); + return any ? chunk.putInt(Y_POS_KEY, lowest) : chunk; } } 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 index 8189ad3..e13338a 100644 --- 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 @@ -265,7 +265,7 @@ void testAOneThousandTwentyFourEntryBiomeArrayBecomesAUniformPaletteForItsSectio CompoundBinaryTag chunk = CompoundBinaryTag.builder() .putIntArray("Biomes", biomes) .put("sections", ListBinaryTag.from(List.of( - CompoundBinaryTag.builder().putInt("Y", 0).build()))) + CompoundBinaryTag.builder().putInt("Y", 0).put("Palette", airPalette()).build()))) .build(); CompoundBinaryTag rebuilt = new RebuildBiomes().apply(chunk, ANY_CONTEXT); @@ -307,8 +307,8 @@ void testAPreFifteenTwoHundredFiftySixEntryArrayIsSampledAtEachQuadrantsCentreAn CompoundBinaryTag chunk = CompoundBinaryTag.builder() .putIntArray("Biomes", legacy) .put("sections", ListBinaryTag.from(List.of( - CompoundBinaryTag.builder().putInt("Y", 0).build(), - CompoundBinaryTag.builder().putInt("Y", 5).build()))) + 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); @@ -343,7 +343,7 @@ void testAnUnknownLegacyBiomeIdFailsRatherThanInventingAName() { CompoundBinaryTag chunk = CompoundBinaryTag.builder() .putIntArray("Biomes", biomes) .put("sections", ListBinaryTag.from(List.of( - CompoundBinaryTag.builder().putInt("Y", 0).build()))) + CompoundBinaryTag.builder().putInt("Y", 0).put("Palette", airPalette()).build()))) .build(); MigrationException exception = assertThrows(MigrationException.class, @@ -351,36 +351,75 @@ void testAnUnknownLegacyBiomeIdFailsRatherThanInventingAName() { assertTrue(exception.getMessage().contains("253")); } - // --- Sections outside the fixed 0..15 range --------------------------------------------------- + // --- Sections without block data, discarded by content rather than by Y range ----------------- @Test - void testASectionBelowZeroDoesNotCrashRebuildBiomesAndIsDroppedRatherThanKept() { - // Vanilla writes one extra section below the real 0..15 range (Y = -1) purely to carry - // lighting data for the section it borders. Without discarding it first, its offset into - // the widened biome array (-1 * 64 = -64) is negative and indexing it throws - // ArrayIndexOutOfBoundsException rather than a MigrationException. - int[] biomes = new int[1024]; - java.util.Arrays.fill(biomes, 1); - + 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() - .putIntArray("Biomes", biomes) - .put("sections", ListBinaryTag.from(List.of( - CompoundBinaryTag.builder().putInt("Y", -1).build(), - CompoundBinaryTag.builder().putInt("Y", 0).build()))) + .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 Y=-1 lighting-only section must not survive into the output"); + assertEquals(1, sections.size(), "the section without block data must not survive into the output"); assertEquals(0, sections.getCompound(0).getInt("Y")); } @Test - void testAnOutOfRangeSectionDoesNotCountTowardsSettleYRangesMinimum() { - // Exercised directly against SettleYRange, independent of whether RebuildBiomes already - // ran and already cleaned the list - the step re-checks the range itself rather than - // trusting the chain's ordering (see its own Javadoc). + 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()))) @@ -388,15 +427,25 @@ void testAnOutOfRangeSectionDoesNotCountTowardsSettleYRangesMinimum() { CompoundBinaryTag settled = new SettleYRange().apply(chunk, ANY_CONTEXT); - assertEquals(0, settled.getInt("yPos"), - "no in-range section is present, so this falls back to the same default an empty list gets"); + 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 testSectionsAtYMinusOneAndYSixteenSurviveTheWholeChainDiscardedRatherThanCorruptingItOrYPos() { - // The end-to-end version of the two direct tests above: a chunk with a lighting-only - // section on both sides of the real range must migrate cleanly, end up with only its one - // real section, and settle yPos on that real section's own Y rather than the discarded one. + 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); @@ -409,7 +458,8 @@ void testSectionsAtYMinusOneAndYSixteenSurviveTheWholeChainDiscardedRatherThanCo .putIntArray("Biomes", biomes) .put("Sections", ListBinaryTag.from(List.of( CompoundBinaryTag.builder().putByte("Y", (byte) -1).build(), - CompoundBinaryTag.builder().putByte("Y", (byte) 0).build(), + CompoundBinaryTag.builder().putByte("Y", (byte) 0) + .put("Palette", airPalette()).build(), CompoundBinaryTag.builder().putByte("Y", (byte) 16).build()))) .build()) .build(); @@ -417,9 +467,20 @@ void testSectionsAtYMinusOneAndYSixteenSurviveTheWholeChainDiscardedRatherThanCo CompoundBinaryTag migrated = ChunkMigration.migrate(chunk, 4790); ListBinaryTag sections = migrated.getList("sections"); - assertEquals(1, sections.size(), "only Y=0 is real content; Y=-1 and Y=16 are lighting-only"); + 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 the discarded Y=-1"); + 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 ----------------------- From 7aba9202684c7297f02d4df55b8ba1dbf599c971 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 5 Aug 2026 13:14:04 +0200 Subject: [PATCH 22/22] feat(migration): resolve redstone_wire's crossed-axis connection states The prior measurement sized this change (144 of 1296 states, 9 of 81 direction combinations) but did not say which nine or what they become, so the rule was deliberately left unimplemented rather than guessed. Derived the connection semantics directly instead: a direction becomes "side" exactly when it is itself unconnected and the PERPENDICULAR axis has no connection either (north/south depend on east/west, east/west depend on north/south) - and verified the threshold independently, correcting the research document's "20w17a" guess to the snapshot whose own changelog actually describes it, 20w18a (DataVersion 2532). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jtJ4GUtmyCSHkiGY1CvgR --- .../2026-08-04-falco-migration-design.md | 7 +- .../falco/migration/BlockStateRules.java | 139 ++++++++++++++++-- .../falco/migration/BlockStateRulesTest.java | 136 ++++++++++++++++- 3 files changed, 260 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/specs/2026-08-04-falco-migration-design.md b/docs/superpowers/specs/2026-08-04-falco-migration-design.md index a6fddf4..d2e26e7 100644 --- a/docs/superpowers/specs/2026-08-04-falco-migration-design.md +++ b/docs/superpowers/specs/2026-08-04-falco-migration-design.md @@ -448,7 +448,12 @@ Vanilla silently tolerates a stale state, Falco's loader refuses the whole chunk | 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. So the order is **names → walls → +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 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 index 4281536..65e8c5c 100644 --- a/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java +++ b/falco-migration/src/main/java/net/onelitefeather/falco/migration/BlockStateRules.java @@ -18,8 +18,8 @@ * 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}, is deliberately absent; see - * the note above {@link #RULES} for why. + * 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 @@ -43,17 +43,29 @@ public final class BlockStateRules { * order for readability — {@link #translate(BlockState, int)} sorts its own copy regardless, so * this ordering is not load-bearing. *

- * {@code redstone_wire} (V2531, 20w17a, Minecraft 1.16, 144 states) is not in this list. - * The research document establishes that a direction's new value depends on the other three - * directions of the same state (DataConverter's {@code connectedX}/{@code connectedZ} in V2531), - * that a per-property implementation is guaranteed wrong, and that 9 of the 81 direction - * combinations change (times 16 {@code power} values = 144 states). It does not say which 9 - * combinations change or what they become — only the count. That is not enough to reproduce - * V2531's logic exactly, and a guessed whole-state table for redstone wiring would be exactly the - * silent corruption this module exists to avoid: the chunk still loads, the wiring looks - * subtly different, and nobody notices. A state this module does not recognize — including every - * {@code redstone_wire} state — passes through {@link #translate(BlockState, int)} unchanged; - * see {@code BlockStateRulesTest.testAStateNoRuleKnowsAboutPassesThroughUnchanged}. + * {@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( @@ -113,6 +125,36 @@ public final class BlockStateRules { // 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 — @@ -173,8 +215,7 @@ private BlockStateRules() { * 1.16 world ({@code 2566 > 1901}). *

*

- * A state no rule recognizes — including every {@code redstone_wire} state, for which this - * module deliberately carries no rule — passes through unchanged. + * A state no rule recognizes passes through unchanged. *

* * @param state the state as read from the source chunk, or as already transformed by an @@ -212,6 +253,74 @@ private static BlockState rewriteWallSides(BlockState state) { 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"), 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 index aa90f96..ff914e0 100644 --- a/falco-migration/src/test/java/net/onelitefeather/falco/migration/BlockStateRulesTest.java +++ b/falco-migration/src/test/java/net/onelitefeather/falco/migration/BlockStateRulesTest.java @@ -95,12 +95,136 @@ void testARenameCarriesItsPropertiesAlong() { @Test void testAStateNoRuleKnowsAboutPassesThroughUnchanged() { - BlockState redstoneWire = new BlockState("minecraft:redstone_wire", - Map.of("north", "side", "south", "none", "east", "up", "west", "none", "power", "0")); + // 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(redstoneWire, BlockStateRules.translate(redstoneWire, 1519), - "redstone_wire has no rule: the research this module is built from did not carry enough " - + "detail to reproduce V2531's cross-direction logic exactly, and a guessed table " - + "would be silent corruption rather than a fix"); + assertEquals(state, BlockStateRules.translate(state, 2532), + "a source at or after DataVersion 2532 already carries the later connection meaning"); } }

+ * 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. + *