Skip to content

Commit 6cd3420

Browse files
committed
Add Anvil importer
1 parent 11e75db commit 6cd3420

File tree

19 files changed

+458
-15
lines changed

19 files changed

+458
-15
lines changed

build.gradle.kts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ subprojects {
1313
mavenCentral()
1414
}
1515

16+
dependencies {
17+
testCompileOnly("org.projectlombok:lombok:1.18.34")
18+
testAnnotationProcessor("org.projectlombok:lombok:1.18.34")
19+
testImplementation(platform("org.junit:junit-bom:5.10.0"))
20+
testImplementation("org.junit.jupiter:junit-jupiter")
21+
}
22+
1623
java {
1724
toolchain.languageVersion = JavaLanguageVersion.of(17)
1825

@@ -25,6 +32,10 @@ subprojects {
2532
options.release = 17
2633
dependsOn(clean)
2734
}
35+
36+
withType<Test> {
37+
useJUnitPlatform()
38+
}
2839
}
2940

3041
publishing {

importer-anvil/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dependencies {
2+
api(project(":importer")) {
3+
isTransitive = true
4+
}
5+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package net.roxymc.slime.importer.anvil;
2+
3+
record ChunkPos(int x, int z) {
4+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package net.roxymc.slime.importer.anvil;
2+
3+
import net.roxymc.slime.world.entity.Entity;
4+
5+
record EntityChunk(int x, int z, Entity[] entities) {
6+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package net.roxymc.slime.importer.anvil;
2+
3+
import net.kyori.adventure.nbt.CompoundBinaryTag;
4+
import net.roxymc.slime.importer.world.properties.WorldProperties;
5+
6+
record LevelData(int dataVersion, WorldProperties properties, CompoundBinaryTag tag) {
7+
}
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
package net.roxymc.slime.importer.anvil;
2+
3+
import com.google.common.io.ByteArrayDataInput;
4+
import com.google.common.io.ByteStreams;
5+
import net.kyori.adventure.nbt.BinaryTag;
6+
import net.kyori.adventure.nbt.BinaryTagIO;
7+
import net.kyori.adventure.nbt.BinaryTagTypes;
8+
import net.kyori.adventure.nbt.CompoundBinaryTag;
9+
import net.roxymc.slime.importer.ImportResult;
10+
import net.roxymc.slime.importer.SlimeImporter;
11+
import net.roxymc.slime.importer.world.properties.Difficulty;
12+
import net.roxymc.slime.importer.world.properties.GameType;
13+
import net.roxymc.slime.importer.world.properties.SpawnPosition;
14+
import net.roxymc.slime.importer.world.properties.WorldProperties;
15+
import net.roxymc.slime.loader.SlimeLoader;
16+
import net.roxymc.slime.world.Heightmaps;
17+
import net.roxymc.slime.world.block.entity.BlockEntity;
18+
import net.roxymc.slime.world.chunk.Chunk;
19+
import net.roxymc.slime.world.chunk.Section;
20+
import net.roxymc.slime.world.entity.Entity;
21+
import org.jspecify.annotations.Nullable;
22+
23+
import java.io.*;
24+
import java.util.*;
25+
import java.util.function.Function;
26+
import java.util.function.IntFunction;
27+
import java.util.stream.Collectors;
28+
import java.util.stream.Stream;
29+
import java.util.zip.GZIPInputStream;
30+
import java.util.zip.InflaterInputStream;
31+
32+
import static net.roxymc.slime.util.ObjectUtils.nonNull;
33+
34+
public class SlimeAnvilImporter implements SlimeImporter {
35+
private static final Entity[] EMPTY_ENTITIES = new Entity[0];
36+
private static final int SECTION_SIZE = 4096;
37+
private static final String LEVEL_DAT = "level.dat";
38+
private static final String REGION_DIR = "region";
39+
private static final String ENTITIES_DIR = "entities";
40+
private static final String MCA = ".mca";
41+
42+
// Level Data
43+
private static final String DATA_TAG = "Data";
44+
private static final String DATA_VERSION_TAG = "DataVersion";
45+
46+
// World Properties
47+
private static final String DIFFICULTY_TAG = "Difficulty";
48+
private static final String DIFFICULTY_LOCKED_TAG = "DifficultyLocked";
49+
private static final String GAME_TYPE_TAG = "GameType";
50+
private static final String HARDCORE_TAG = "hardcore";
51+
private static final String SPAWN_X_TAG = "SpawnX";
52+
private static final String SPAWN_Y_TAG = "SpawnY";
53+
private static final String SPAWN_Z_TAG = "SpawnZ";
54+
55+
// Entity Chunk Data
56+
private static final String POSITION_TAG = "Position";
57+
private static final String ENTITIES_TAG = "Entities";
58+
59+
// Chunk Data
60+
private static final String CHUNK_STATUS_TAG = "Status";
61+
private static final String FULL_CHUNK = "minecraft:full";
62+
private static final String CHUNK_X_TAG = "xPos";
63+
private static final String CHUNK_Y_TAG = "yPos";
64+
private static final String CHUNK_Z_TAG = "zPos";
65+
private static final String SECTIONS_TAG = "sections";
66+
private static final String BLOCK_LIGHT_TAG = "BlockLight";
67+
private static final String SKY_LIGHT_TAG = "SkyLight";
68+
private static final String BLOCK_STATES_TAG = "block_states";
69+
private static final String BIOMES_TAG = "biomes";
70+
private static final String HEIGHTMAPS_TAG = "Heightmaps";
71+
private static final String BLOCK_ENTITIES_TAG = "block_entities";
72+
73+
private final SlimeLoader slimeLoader;
74+
private final Set<String> preservedWorldTags;
75+
private final Set<String> preservedChunkTags;
76+
77+
private SlimeAnvilImporter(Builder builder) {
78+
this.slimeLoader = builder.slimeLoader;
79+
this.preservedWorldTags = builder.preservedWorldTags;
80+
this.preservedChunkTags = builder.preservedChunkTags;
81+
}
82+
83+
public static Builder builder(SlimeLoader slimeLoader) {
84+
return new Builder(slimeLoader);
85+
}
86+
87+
@Override
88+
public Set<String> preservedWorldTags() {
89+
return preservedWorldTags;
90+
}
91+
92+
@Override
93+
public Set<String> preservedChunkTags() {
94+
return preservedChunkTags;
95+
}
96+
97+
@Override
98+
public ImportResult importWorld(File source) throws IOException {
99+
LevelData levelData = readLevelData(source);
100+
Map<ChunkPos, EntityChunk> entityChunks = Arrays.stream(readEntityChunks(source)).collect(Collectors.toMap(
101+
chunk -> new ChunkPos(chunk.x(), chunk.z()),
102+
Function.identity()
103+
));
104+
Chunk[] chunks = readChunks(source, entityChunks);
105+
106+
return new ImportResult(
107+
slimeLoader.deserializers().world().deserialize(
108+
levelData.dataVersion(),
109+
chunks,
110+
levelData.tag()
111+
),
112+
levelData.properties()
113+
);
114+
}
115+
116+
private LevelData readLevelData(File root) throws IOException {
117+
File levelDat = new File(root, LEVEL_DAT);
118+
119+
CompoundBinaryTag tag;
120+
try (FileInputStream is = new FileInputStream(levelDat)) {
121+
tag = BinaryTagIO.reader().read(is, BinaryTagIO.Compression.GZIP);
122+
}
123+
124+
int dataVersion = tag.getCompound(DATA_TAG).getInt(DATA_VERSION_TAG);
125+
126+
CompoundBinaryTag customDataTag = readCustomData(tag, preservedWorldTags);
127+
128+
WorldProperties properties = new WorldProperties(
129+
Difficulty.byId(tag.getByte(DIFFICULTY_TAG, (byte) 1)),
130+
tag.getBoolean(DIFFICULTY_LOCKED_TAG),
131+
GameType.byId(tag.getInt(GAME_TYPE_TAG, 1)),
132+
tag.getBoolean(HARDCORE_TAG),
133+
new SpawnPosition(
134+
tag.getInt(SPAWN_X_TAG),
135+
tag.getInt(SPAWN_Y_TAG),
136+
tag.getInt(SPAWN_Z_TAG)
137+
)
138+
);
139+
140+
return new LevelData(dataVersion, properties, customDataTag);
141+
}
142+
143+
private EntityChunk[] readEntityChunks(File root) throws IOException {
144+
return readRegionFiles(new File(root, ENTITIES_DIR), EntityChunk[]::new, this::readEntityChunk);
145+
}
146+
147+
private EntityChunk readEntityChunk(CompoundBinaryTag tag) {
148+
int[] position = tag.getIntArray(POSITION_TAG);
149+
int x = position[0];
150+
int z = position[1];
151+
152+
Entity[] entities = tag.getList(ENTITIES_TAG, BinaryTagTypes.COMPOUND).stream()
153+
.map(CompoundBinaryTag.class::cast)
154+
.map(slimeLoader.deserializers().entity()::deserialize)
155+
.toArray(Entity[]::new);
156+
157+
return new EntityChunk(x, z, entities);
158+
}
159+
160+
private Chunk[] readChunks(File root, Map<ChunkPos, EntityChunk> entityChunks) throws IOException {
161+
return readRegionFiles(new File(root, REGION_DIR), Chunk[]::new, tag -> readChunk(tag, entityChunks));
162+
}
163+
164+
private @Nullable Chunk readChunk(CompoundBinaryTag tag, Map<ChunkPos, EntityChunk> entityChunks) {
165+
if (!tag.getString(CHUNK_STATUS_TAG).equals(FULL_CHUNK)) {
166+
return null;
167+
}
168+
169+
int x = tag.getInt(CHUNK_X_TAG);
170+
int z = tag.getInt(CHUNK_Z_TAG);
171+
172+
int minSectionY = tag.getInt(CHUNK_Y_TAG);
173+
174+
Section[] sections = tag.getList(SECTIONS_TAG, BinaryTagTypes.COMPOUND).stream()
175+
.map(CompoundBinaryTag.class::cast)
176+
.filter(binaryTag -> binaryTag.getInt("Y") >= minSectionY)
177+
.map(binaryTag -> {
178+
//noinspection DataFlowIssue
179+
byte[] blockLight = binaryTag.getByteArray(BLOCK_LIGHT_TAG, null);
180+
//noinspection DataFlowIssue
181+
byte[] skyLight = binaryTag.getByteArray(SKY_LIGHT_TAG, null);
182+
183+
CompoundBinaryTag blockStatesTag = binaryTag.getCompound(BLOCK_STATES_TAG);
184+
CompoundBinaryTag biomesTag = binaryTag.getCompound(BIOMES_TAG);
185+
186+
return slimeLoader.deserializers().section().deserialize(
187+
blockLight,
188+
skyLight,
189+
slimeLoader.deserializers().blockStates().deserialize(blockStatesTag),
190+
slimeLoader.deserializers().biomes().deserialize(biomesTag)
191+
);
192+
})
193+
.toArray(Section[]::new);
194+
195+
Heightmaps heightmaps = slimeLoader.deserializers().heightmaps().deserialize(
196+
tag.getCompound(HEIGHTMAPS_TAG)
197+
);
198+
199+
BlockEntity[] blockEntities = tag.getList(BLOCK_ENTITIES_TAG, BinaryTagTypes.COMPOUND).stream()
200+
.map(CompoundBinaryTag.class::cast)
201+
.map(slimeLoader.deserializers().blockEntity()::deserialize)
202+
.toArray(BlockEntity[]::new);
203+
204+
ChunkPos chunkPos = new ChunkPos(x, z);
205+
Entity[] entities = entityChunks.containsKey(chunkPos) ? entityChunks.get(chunkPos).entities() : EMPTY_ENTITIES;
206+
207+
CompoundBinaryTag customDataTag = readCustomData(tag, preservedChunkTags);
208+
209+
return slimeLoader.deserializers().chunk().deserialize(
210+
x,
211+
z,
212+
sections,
213+
heightmaps,
214+
blockEntities,
215+
entities,
216+
customDataTag
217+
);
218+
}
219+
220+
private CompoundBinaryTag readCustomData(CompoundBinaryTag tag, Set<String> tags) {
221+
if (tags.isEmpty()) {
222+
return CompoundBinaryTag.empty();
223+
} else {
224+
CompoundBinaryTag.Builder builder = CompoundBinaryTag.builder();
225+
226+
tags.forEach(tagName -> {
227+
BinaryTag binaryTag = tag.get(tagName);
228+
if (binaryTag == null) {
229+
return;
230+
}
231+
232+
builder.put(tagName, binaryTag);
233+
});
234+
235+
return builder.build();
236+
}
237+
}
238+
239+
private <T> T[] readRegionFiles(File regionDir, IntFunction<T[]> generator, Function<CompoundBinaryTag, T> function) throws IOException {
240+
File[] files = regionDir.listFiles((dir, name) -> name.endsWith(MCA));
241+
if (files == null || files.length == 0) {
242+
return generator.apply(0);
243+
}
244+
245+
List<T[]> list = new ArrayList<>();
246+
for (File file : files) {
247+
list.add(readRegionFile(file, generator, function));
248+
}
249+
250+
return list.stream().reduce(generator.apply(0), (arr1, arr2) ->
251+
Stream.concat(Arrays.stream(arr1), Arrays.stream(arr2)).toArray(generator)
252+
);
253+
}
254+
255+
private <T> T[] readRegionFile(File regionFile, IntFunction<T[]> generator, Function<CompoundBinaryTag, T> function) throws IOException {
256+
byte[] bytes;
257+
try (FileInputStream is = new FileInputStream(regionFile)) {
258+
bytes = is.readAllBytes();
259+
}
260+
261+
ByteArrayDataInput in = ByteStreams.newDataInput(bytes);
262+
263+
List<T> data = new ArrayList<>(1024);
264+
for (int i = 0; i < 1024; i++) {
265+
int entry;
266+
try {
267+
entry = in.readInt();
268+
} catch (IllegalStateException e) {
269+
break;
270+
}
271+
if (entry == 0) continue;
272+
273+
int offset = (entry >>> 8) * SECTION_SIZE;
274+
int size = (entry & 0xF) * SECTION_SIZE;
275+
276+
ByteArrayDataInput headerIn = ByteStreams.newDataInput(new ByteArrayInputStream(bytes, offset, size));
277+
int chunkSize = headerIn.readInt() - 1;
278+
int compressionScheme = headerIn.readByte();
279+
280+
InputStream chunkStream = new ByteArrayInputStream(bytes, offset + 5, chunkSize);
281+
InputStream decompressorStream = switch (compressionScheme) {
282+
case 1 -> new GZIPInputStream(chunkStream);
283+
case 2 -> new InflaterInputStream(chunkStream);
284+
case 3 -> chunkStream;
285+
default -> throw new IllegalStateException("Unexpected value: " + compressionScheme);
286+
};
287+
288+
CompoundBinaryTag tag = BinaryTagIO.reader().read(decompressorStream);
289+
T element = function.apply(tag);
290+
//noinspection ConstantValue
291+
if (element != null) {
292+
data.add(element);
293+
}
294+
}
295+
296+
return data.toArray(generator);
297+
}
298+
299+
public static class Builder {
300+
private final SlimeLoader slimeLoader;
301+
private Set<String> preservedWorldTags = Collections.emptySet();
302+
private Set<String> preservedChunkTags = Collections.emptySet();
303+
304+
private Builder(SlimeLoader slimeLoader) {
305+
this.slimeLoader = nonNull(slimeLoader, "slimeLoader");
306+
}
307+
308+
public Builder preserveWorldTags(String... tags) {
309+
preservedWorldTags = Set.of(tags);
310+
return this;
311+
}
312+
313+
public Builder preserveChunkTags(String... tags) {
314+
preservedChunkTags = Set.of(tags);
315+
return this;
316+
}
317+
318+
public SlimeAnvilImporter build() {
319+
return new SlimeAnvilImporter(this);
320+
}
321+
}
322+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@NullMarked
2+
package net.roxymc.slime.importer.anvil;
3+
4+
import org.jspecify.annotations.NullMarked;

importer/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dependencies {
2+
api(project(":slime-loader")) {
3+
isTransitive = true
4+
}
5+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package net.roxymc.slime.importer;
2+
3+
import net.roxymc.slime.importer.world.properties.WorldProperties;
4+
import net.roxymc.slime.world.World;
5+
import org.jspecify.annotations.Nullable;
6+
7+
public record ImportResult(World world, @Nullable WorldProperties properties) {
8+
}

0 commit comments

Comments
 (0)