mapsyncer-paper: server side of MapSyncer for Xaero's World Map, discovery-limited

Paper 26.2 plugin speaking the stock MapSyncer client mod's protocol.
Renders Xaero region zips from the world's region files, but only for chunks
players have actually been sent (PlayerChunkLoadEvent), seeded once from
InhabitedTime. Shared or per-player visibility, background render cycle,
per-player streaming with hash/timestamp skipping, Gitea Actions release
workflow.

Vendors the MCA parser and Xaero writer from upstream MapSyncer (GPL-3.0),
see NOTICE.md.

Claude-Session: https://claude.ai/code/session_011FePLXwBsCGLTzaSkk1Z6V
This commit is contained in:
2026-09-07 00:04:43 +03:00
commit 7fb3a82482
67 changed files with 10934 additions and 0 deletions
@@ -0,0 +1,98 @@
package com.mapsyncer.mca.convert;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkMask;
import com.mapsyncer.mca.DimensionTypeInfo;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.RegionConverterStandalone;
import com.mapsyncer.mca.convert.io.McaRegionLoader;
import com.mapsyncer.mca.convert.io.McaRegionLoader.PassMapData;
import com.mapsyncer.mca.convert.io.XaeroBinaryWriter;
import com.mapsyncer.mca.convert.model.MapRegionData;
import com.mapsyncer.mca.convert.scan.RegionScanPass;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public final class RegionConversionPipeline {
private RegionConversionPipeline() {}
public static RegionConverterStandalone.ConvertedRegion convert(
Path mcaPath, int regionX, int regionZ,
int minBuildHeight, int worldTopY,
LightMode lightMode,
RegionConverterStandalone.CaveModeParams caveParams,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup) throws IOException {
MapRegionData regionData = McaRegionLoader.load(
mcaPath, minBuildHeight, worldTopY, lightMode, caveParams, worldHasSkylight, blockLookup);
if (!regionData.hasAnyMapData()) {
return new RegionConverterStandalone.ConvertedRegion(regionX, regionZ, new byte[0]);
}
byte[] xaeroData = XaeroBinaryWriter.serialize(regionData, minBuildHeight, blockLookup);
return new RegionConverterStandalone.ConvertedRegion(regionX, regionZ, xaeroData);
}
public static RegionConverterStandalone.ConvertedRegion convert(
Path mcaPath, int regionX, int regionZ,
DimensionTypeInfo dimTypeInfo,
LightMode lightMode,
RegionConverterStandalone.CaveModeParams caveParams,
BlockPropertyLookup blockLookup) throws IOException {
return convert(mcaPath, regionX, regionZ,
dimTypeInfo.minY(), dimTypeInfo.maxY(),
lightMode, caveParams, dimTypeInfo.hasSkylight(), blockLookup);
}
/**
* 单次 MCA 解析,输出多个层/地表 pass 的转换结果。
*/
public static List<RegionConverterStandalone.LayerConvertedRegion> convertMulti(
Path mcaPath, int regionX, int regionZ,
DimensionTypeInfo dimTypeInfo,
List<RegionScanPass> passes,
BlockPropertyLookup blockLookup) throws IOException {
return convertMulti(mcaPath, regionX, regionZ, dimTypeInfo, passes, blockLookup, ChunkMask.ALL);
}
/**
* 单次 MCA 解析,输出多个层/地表 pass 的转换结果;只渲染 {@code mask} 允许的区块。
*/
public static List<RegionConverterStandalone.LayerConvertedRegion> convertMulti(
Path mcaPath, int regionX, int regionZ,
DimensionTypeInfo dimTypeInfo,
List<RegionScanPass> passes,
BlockPropertyLookup blockLookup,
ChunkMask mask) throws IOException {
if (!Files.exists(mcaPath) || passes.isEmpty()) {
return List.of();
}
List<PassMapData> loaded = McaRegionLoader.loadMulti(
mcaPath, dimTypeInfo.minY(), dimTypeInfo.maxY(),
dimTypeInfo.hasSkylight(), blockLookup, passes, mask);
List<RegionConverterStandalone.LayerConvertedRegion> results = new ArrayList<>();
for (PassMapData passData : loaded) {
MapRegionData regionData = passData.data();
if (!regionData.hasAnyMapData()) {
results.add(new RegionConverterStandalone.LayerConvertedRegion(
regionX, regionZ, passData.pass().caveLayer(), new byte[0]));
continue;
}
byte[] xaeroData = XaeroBinaryWriter.serialize(regionData, dimTypeInfo.minY(), blockLookup);
results.add(new RegionConverterStandalone.LayerConvertedRegion(
regionX, regionZ, passData.pass().caveLayer(), xaeroData));
}
return results;
}
}
@@ -0,0 +1,62 @@
package com.mapsyncer.mca.convert.biome;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.convert.model.MapRegionData;
import static com.mapsyncer.mca.convert.model.ConvertConstants.CHUNKS_PER_REGION;
import static com.mapsyncer.mca.convert.model.ConvertConstants.REGION_SIZE_BLOCKS;
/**
* 扫描完成后填充 biome,对齐 Xaero fillBiomes(按 topHeight/height 采样)。
*
* <p>地表层:有扫描结果时用 heightMap;否则用高度图地表 Y。</p>
* <p>洞穴层:有扫描结果时用洞穴壁 Y;否则用 caveStart,且不回退到地表群系。</p>
*/
public final class BiomeFillPass {
private BiomeFillPass() {}
public static void fill(MapRegionData data) {
for (int rx = 0; rx < REGION_SIZE_BLOCKS; rx++) {
for (int rz = 0; rz < REGION_SIZE_BLOCKS; rz++) {
int chunkX = rx >> 4;
int chunkZ = rz >> 4;
if (chunkX >= CHUNKS_PER_REGION || chunkZ >= CHUNKS_PER_REGION) {
continue;
}
ChunkDataParser.ChunkInfo chunk = data.chunkGrid[chunkX][chunkZ];
if (chunk == null) {
continue;
}
int lx = rx & 0xF;
int lz = rz & 0xF;
int[][] heightmap = chunk.heightmap();
boolean caveMode = data.lightMode == LightMode.CAVE
&& data.caveParams.caveStart() != Integer.MAX_VALUE;
int sampleY;
if (data.hasData[rx][rz]) {
sampleY = data.heightMap[rx][rz];
} else if (caveMode) {
sampleY = data.caveParams.caveStart();
} else if (heightmap != null) {
sampleY = heightmap[lx][lz];
data.heightMap[rx][rz] = sampleY;
} else {
continue;
}
String biome = caveMode
? BiomeQuartResolver.resolveAtY(chunk, lx, sampleY, lz)
: BiomeQuartResolver.resolve(chunk, lx, sampleY, lz);
if (BiomeQuartResolver.isValidBiome(biome)) {
data.biomeNames[rx][rz] = biome;
}
}
}
}
}
@@ -0,0 +1,77 @@
package com.mapsyncer.mca.convert.biome;
import com.mapsyncer.mca.ChunkSectionParser;
import java.util.List;
/**
* 预计算的 chunk 内 quart4×4×4biome 体素表,将 fill 阶段查表降为 O(1)。
*/
public final class BiomeQuartGrid {
private static final int VOXELS_PER_SECTION = 64;
private final int minSectionY;
private final String[][] sectionVoxels;
private BiomeQuartGrid(int minSectionY, String[][] sectionVoxels) {
this.minSectionY = minSectionY;
this.sectionVoxels = sectionVoxels;
}
public static BiomeQuartGrid build(List<ChunkSectionParser.SectionData> sections,
int minSectionY,
ChunkSectionParser.SectionData[] sectionLookup) {
if (sectionLookup == null || sectionLookup.length == 0) {
return new BiomeQuartGrid(minSectionY, new String[0][]);
}
String[][] grids = new String[sectionLookup.length][];
for (ChunkSectionParser.SectionData section : sections) {
if (section == null || section.biomePalette().isEmpty()) {
continue;
}
int idx = section.sectionY() - minSectionY;
if (idx < 0 || idx >= grids.length) {
continue;
}
String[] voxels = new String[VOXELS_PER_SECTION];
if (section.biomePalette().size() == 1) {
String only = section.biomePalette().get(0);
java.util.Arrays.fill(voxels, only);
} else {
for (int voxelY = 0; voxelY < 4; voxelY++) {
for (int voxelZ = 0; voxelZ < 4; voxelZ++) {
for (int voxelX = 0; voxelX < 4; voxelX++) {
int blockX = voxelX << 2;
int blockY = voxelY << 2;
int blockZ = voxelZ << 2;
int voxelIndex = (voxelY << 4) | (voxelZ << 2) | voxelX;
voxels[voxelIndex] = ChunkSectionParser.getBiomeAt(
section, blockX, blockY, blockZ, false);
}
}
}
}
grids[idx] = voxels;
}
return new BiomeQuartGrid(minSectionY, grids);
}
/**
* O(1) quart 查表;无数据时返回 null(由 {@link BiomeQuartResolver} 走原有回退链)。
*/
public String lookup(int lx, int absoluteY, int lz) {
int sectionIdx = (absoluteY >> 4) - minSectionY;
if (sectionIdx < 0 || sectionIdx >= sectionVoxels.length) {
return null;
}
String[] voxels = sectionVoxels[sectionIdx];
if (voxels == null) {
return null;
}
int localY = absoluteY & 0xF;
int voxelIndex = ((localY >> 2) << 4) | ((lz >> 2) << 2) | (lx >> 2);
return voxels[voxelIndex];
}
}
@@ -0,0 +1,157 @@
package com.mapsyncer.mca.convert.biome;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.ChunkSectionParser;
import static com.mapsyncer.mca.convert.model.ConvertConstants.DEFAULT_BIOME;
/**
* 按 quart4×4×4)精度解析 chunk 内 biome,对齐 Xaero fillBiomes 的采样方式。
* 不包含邻域 region chunk 加载。
*/
public final class BiomeQuartResolver {
private BiomeQuartResolver() {}
public static String resolve(ChunkDataParser.ChunkInfo chunk, int lx, int absoluteY, int lz) {
return resolve(chunk, lx, absoluteY, lz, false);
}
/**
* 仅在指定 Y 及其 section 内解析 biome,不回退到高度图地表 Y。
* 洞穴层采样时使用,避免 Y=63 的像素被替换成 Y=127 的地表群系。
*/
public static String resolveAtY(ChunkDataParser.ChunkInfo chunk, int lx, int absoluteY, int lz) {
return resolveAtY(chunk, lx, absoluteY, lz, false);
}
public static String resolveAtY(ChunkDataParser.ChunkInfo chunk, int lx, int absoluteY, int lz,
boolean smoothBoundary) {
String biome = resolveBiomeAtAbsoluteY(chunk, lx, absoluteY, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
for (ChunkSectionParser.SectionData s : chunk.sections()) {
if (s.biomePalette().isEmpty()) {
continue;
}
int fallbackLy = absoluteY - s.sectionY() * 16;
if (fallbackLy < 0 || fallbackLy > 15) {
continue;
}
biome = ChunkSectionParser.getBiomeAt(s, lx, fallbackLy, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
}
for (ChunkSectionParser.SectionData s : chunk.sections()) {
if (s.biomePalette().isEmpty()) {
continue;
}
for (int tryLy = 0; tryLy <= 15; tryLy++) {
String candidate = ChunkSectionParser.getBiomeAt(s, lx, tryLy, lz, smoothBoundary);
if (isValidBiome(candidate)) {
return candidate;
}
}
}
return DEFAULT_BIOME;
}
public static String resolve(ChunkDataParser.ChunkInfo chunk, int lx, int absoluteY, int lz,
boolean smoothBoundary) {
String biome = resolveBiomeAtAbsoluteY(chunk, lx, absoluteY, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
int[][] heightmap = chunk.heightmap();
if (heightmap != null) {
int surfaceY = heightmap[lx][lz];
biome = resolveBiomeAtAbsoluteY(chunk, lx, surfaceY, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
}
for (ChunkSectionParser.SectionData s : chunk.sections()) {
if (s.biomePalette().isEmpty()) {
continue;
}
int fallbackLy = absoluteY - s.sectionY() * 16;
if (fallbackLy < 0 || fallbackLy > 15) {
continue;
}
biome = ChunkSectionParser.getBiomeAt(s, lx, fallbackLy, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
}
for (ChunkSectionParser.SectionData s : chunk.sections()) {
if (s.biomePalette().isEmpty()) {
continue;
}
for (int tryLy = 0; tryLy <= 15; tryLy++) {
String candidate = ChunkSectionParser.getBiomeAt(s, lx, tryLy, lz, smoothBoundary);
if (isValidBiome(candidate)) {
return candidate;
}
}
}
return DEFAULT_BIOME;
}
private static String resolveBiomeAtAbsoluteY(ChunkDataParser.ChunkInfo chunk,
int lx, int absoluteY, int lz,
boolean smoothBoundary) {
if (!smoothBoundary && chunk.biomeGrid() != null) {
String gridBiome = chunk.biomeGrid().lookup(lx, absoluteY, lz);
if (isValidBiome(gridBiome)) {
return gridBiome;
}
}
String biome = ChunkDataParser.getBiomeAt(chunk, lx, absoluteY, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
int targetSectionY = absoluteY >> 4;
int localY = absoluteY & 0xF;
for (ChunkSectionParser.SectionData s : chunk.sections()) {
if (s.sectionY() != targetSectionY || s.biomePalette().isEmpty()) {
continue;
}
biome = ChunkSectionParser.getBiomeAt(s, lx, localY, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
}
for (ChunkSectionParser.SectionData s : chunk.sections()) {
if (s.biomePalette().isEmpty()) {
continue;
}
int fallbackLy = absoluteY - s.sectionY() * 16;
if (fallbackLy < 0 || fallbackLy > 15) {
continue;
}
biome = ChunkSectionParser.getBiomeAt(s, lx, fallbackLy, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
}
return null;
}
static boolean isValidBiome(String biome) {
return biome != null && !biome.equals(DEFAULT_BIOME);
}
}
@@ -0,0 +1,144 @@
package com.mapsyncer.mca.convert.io;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.ChunkMask;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.McaReader;
import com.mapsyncer.mca.RegionConverterStandalone;
import com.mapsyncer.mca.convert.biome.BiomeFillPass;
import com.mapsyncer.mca.convert.model.ConvertConstants;
import com.mapsyncer.mca.convert.model.MapRegionData;
import com.mapsyncer.mca.convert.scan.ChunkColumnScanner;
import com.mapsyncer.mca.convert.scan.RegionScanPass;
import com.mapsyncer.nbt.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public final class McaRegionLoader {
private static final Logger LOGGER = LoggerFactory.getLogger(McaRegionLoader.class);
private McaRegionLoader() {}
public record PassMapData(RegionScanPass pass, MapRegionData data) {}
public static MapRegionData load(Path mcaPath, int minBuildHeight, int worldTopY,
LightMode lightMode,
RegionConverterStandalone.CaveModeParams caveParams,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup) throws IOException {
MapRegionData data = new MapRegionData(minBuildHeight, lightMode, caveParams);
try (McaReader reader = McaReader.open(mcaPath.toString())) {
int worldHeightRange = worldTopY - minBuildHeight;
ChunkDataParser.ChunkInfo[][] chunks = readAllChunks(reader, worldHeightRange);
for (int localX = 0; localX < ConvertConstants.CHUNKS_PER_REGION; localX++) {
for (int localZ = 0; localZ < ConvertConstants.CHUNKS_PER_REGION; localZ++) {
ChunkDataParser.ChunkInfo chunkInfo = chunks[localX][localZ];
if (chunkInfo == null) {
continue;
}
ChunkColumnScanner.scan(data, chunkInfo, minBuildHeight, worldTopY,
lightMode, caveParams, worldHasSkylight, blockLookup);
}
}
}
BiomeFillPass.fill(data);
return data;
}
/**
* 单次 MCA 解析,按多个扫描 pass 输出多份 MapRegionData。
*/
public static List<PassMapData> loadMulti(Path mcaPath, int minBuildHeight, int worldTopY,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup,
List<RegionScanPass> passes) throws IOException {
return loadMulti(mcaPath, minBuildHeight, worldTopY, worldHasSkylight, blockLookup, passes, ChunkMask.ALL);
}
/**
* 单次 MCA 解析,按多个扫描 pass 输出多份 MapRegionData;只处理 {@code mask} 允许的区块。
*/
public static List<PassMapData> loadMulti(Path mcaPath, int minBuildHeight, int worldTopY,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup,
List<RegionScanPass> passes,
ChunkMask mask) throws IOException {
if (passes.isEmpty()) {
return List.of();
}
List<PassMapData> results = new ArrayList<>(passes.size());
for (RegionScanPass pass : passes) {
results.add(new PassMapData(pass, new MapRegionData(minBuildHeight, pass.lightMode(), pass.caveParams())));
}
try (McaReader reader = McaReader.open(mcaPath.toString())) {
int worldHeightRange = worldTopY - minBuildHeight;
ChunkDataParser.ChunkInfo[][] chunks = readAllChunks(reader, worldHeightRange, mask);
for (int localX = 0; localX < ConvertConstants.CHUNKS_PER_REGION; localX++) {
for (int localZ = 0; localZ < ConvertConstants.CHUNKS_PER_REGION; localZ++) {
ChunkDataParser.ChunkInfo chunkInfo = chunks[localX][localZ];
if (chunkInfo == null) {
continue;
}
for (PassMapData passData : results) {
RegionScanPass pass = passData.pass();
ChunkColumnScanner.scan(
passData.data(), chunkInfo, minBuildHeight, worldTopY,
pass.lightMode(), pass.caveParams(), worldHasSkylight, blockLookup,
pass.verticalBounds());
}
}
}
}
for (PassMapData passData : results) {
BiomeFillPass.fill(passData.data());
}
return results;
}
private static ChunkDataParser.ChunkInfo[][] readAllChunks(McaReader reader, int worldHeightRange)
throws IOException {
return readAllChunks(reader, worldHeightRange, ChunkMask.ALL);
}
private static ChunkDataParser.ChunkInfo[][] readAllChunks(McaReader reader, int worldHeightRange,
ChunkMask mask)
throws IOException {
ChunkDataParser.ChunkInfo[][] grid =
new ChunkDataParser.ChunkInfo[ConvertConstants.CHUNKS_PER_REGION][ConvertConstants.CHUNKS_PER_REGION];
for (int localX = 0; localX < ConvertConstants.CHUNKS_PER_REGION; localX++) {
for (int localZ = 0; localZ < ConvertConstants.CHUNKS_PER_REGION; localZ++) {
if (!mask.includes(localX, localZ)) {
continue;
}
Tag.Compound nbt;
try {
nbt = reader.readChunkNbt(localX, localZ);
} catch (IOException e) {
LOGGER.warn("Failed to read chunk ({}, {}) from region file, skipping: {}",
localX, localZ, e.getMessage());
continue;
}
if (nbt == null) {
continue;
}
grid[localX][localZ] = ChunkDataParser.parseChunk(localX, localZ, nbt, worldHeightRange);
}
}
return grid;
}
}
@@ -0,0 +1,267 @@
package com.mapsyncer.mca.convert.io;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkSectionParser.BlockState;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.convert.io.XaeroBlockStateNbtWriter.PaletteKey;
import com.mapsyncer.mca.convert.model.MapRegionData;
import com.mapsyncer.mca.convert.model.OverlayEntry;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static com.mapsyncer.mca.convert.model.ConvertConstants.BLOCKS_PER_TILE;
import static com.mapsyncer.mca.convert.model.ConvertConstants.DEFAULT_BIOME;
import static com.mapsyncer.mca.convert.model.ConvertConstants.DEFAULT_BLOCK;
import static com.mapsyncer.mca.convert.model.ConvertConstants.MAJOR_VERSION;
import static com.mapsyncer.mca.convert.model.ConvertConstants.MINOR_VERSION;
import static com.mapsyncer.mca.convert.model.ConvertConstants.REGION_SIZE_BLOCKS;
import static com.mapsyncer.mca.convert.model.ConvertConstants.TILE_CHUNKS_PER_REGION;
import static com.mapsyncer.mca.convert.model.ConvertConstants.TILES_PER_TILE_CHUNK;
public final class XaeroBinaryWriter {
private XaeroBinaryWriter() {}
public static byte[] serialize(MapRegionData data, int minBuildHeight,
BlockPropertyLookup blockLookup) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (DataOutputStream dos = new DataOutputStream(baos)) {
dos.writeByte(0xFF);
dos.writeInt((MAJOR_VERSION << 16) | MINOR_VERSION);
Map<PaletteKey, Integer> blockPalette = new LinkedHashMap<>();
Map<String, Integer> biomePalette = new LinkedHashMap<>();
for (int tileChunkO = 0; tileChunkO < TILE_CHUNKS_PER_REGION; tileChunkO++) {
for (int tileChunkP = 0; tileChunkP < TILE_CHUNKS_PER_REGION; tileChunkP++) {
dos.writeByte((tileChunkO << 4) | tileChunkP);
for (int tileI = 0; tileI < TILES_PER_TILE_CHUNK; tileI++) {
for (int tileJ = 0; tileJ < TILES_PER_TILE_CHUNK; tileJ++) {
int chunkX = tileChunkO * 4 + tileI;
int chunkZ = tileChunkP * 4 + tileJ;
int baseX = chunkX * 16;
int baseZ = chunkZ * 16;
if (!data.chunkExists[chunkX][chunkZ]) {
dos.writeInt(-1);
continue;
}
for (int bx = 0; bx < BLOCKS_PER_TILE; bx++) {
for (int bz = 0; bz < BLOCKS_PER_TILE; bz++) {
int rx = baseX + bx;
int rz = baseZ + bz;
if (!data.hasData[rx][rz]) {
if (data.lightMode == LightMode.CAVE) {
writeCaveEmptyPixel(dos, data, rx, rz, minBuildHeight,
blockPalette, biomePalette);
} else {
writeEmptyPixel(dos, data, rx, rz, minBuildHeight,
blockPalette, biomePalette);
}
continue;
}
writePixel(dos, data, rx, rz, blockPalette, biomePalette, blockLookup);
}
}
dos.writeByte(1);
dos.writeInt(data.caveParams.caveStart());
dos.writeByte(data.caveParams.caveDepth() & 0xFF);
}
}
}
}
}
return baos.toByteArray();
}
private static void writeCaveEmptyPixel(DataOutputStream dos, MapRegionData data, int rx, int rz,
int minBuildHeight,
Map<PaletteKey, Integer> blockPalette,
Map<String, Integer> biomePalette) throws IOException {
BlockState air = XaeroBlockStateNbtWriter.AIR;
PaletteKey paletteKey = PaletteKey.from(air);
int emptyHeight = minBuildHeight;
String biomeName = data.biomeNames[rx][rz];
if (biomeName == null || biomeName.equals(DEFAULT_BIOME)) {
biomeName = null;
}
int emptyParams = 1;
emptyParams |= encodeHeightToParams(emptyHeight);
if (biomeName != null) {
emptyParams |= 0x100000;
}
if (!blockPalette.containsKey(paletteKey)) {
emptyParams |= 0x200000;
}
if (biomeName != null && !biomePalette.containsKey(biomeName)) {
emptyParams |= 0x400000;
}
dos.writeInt(emptyParams);
writeBlockStateRef(dos, air, blockPalette);
writeBiomeRef(dos, biomeName, biomePalette);
}
private static void writeEmptyPixel(DataOutputStream dos, MapRegionData data, int rx, int rz,
int minBuildHeight,
Map<PaletteKey, Integer> blockPalette,
Map<String, Integer> biomePalette) throws IOException {
BlockState air = XaeroBlockStateNbtWriter.AIR;
PaletteKey paletteKey = PaletteKey.from(air);
int emptyHeight = data.heightMap[rx][rz];
String biomeName = data.biomeNames[rx][rz];
if (biomeName == null || biomeName.equals(DEFAULT_BIOME)) {
biomeName = null;
}
int emptyParams = 0;
emptyParams |= 1;
emptyParams |= 15 << 8;
emptyParams |= encodeHeightToParams(emptyHeight);
if (biomeName != null) {
emptyParams |= 0x100000;
}
if (!blockPalette.containsKey(paletteKey)) {
emptyParams |= 0x200000;
}
if (biomeName != null && !biomePalette.containsKey(biomeName)) {
emptyParams |= 0x400000;
}
dos.writeInt(emptyParams);
writeBlockStateRef(dos, air, blockPalette);
writeBiomeRef(dos, biomeName, biomePalette);
}
private static void writeBiomeRef(DataOutputStream dos, String biomeName,
Map<String, Integer> biomePalette) throws IOException {
if (biomeName == null) {
return;
}
if (biomePalette.containsKey(biomeName)) {
dos.writeInt(biomePalette.get(biomeName));
} else {
dos.writeUTF(biomeName);
biomePalette.put(biomeName, biomePalette.size());
}
}
private static void writePixel(DataOutputStream dos, MapRegionData data, int rx, int rz,
Map<PaletteKey, Integer> blockPalette,
Map<String, Integer> biomePalette,
BlockPropertyLookup blockLookup) throws IOException {
BlockState blockState = data.blockStates[rx][rz];
if (blockState == null) {
blockState = new BlockState(DEFAULT_BLOCK, Map.of());
}
String blockName = blockState.name();
PaletteKey paletteKey = PaletteKey.from(blockState);
int height = data.heightMap[rx][rz];
int topY = data.topBlockY[rx][rz];
int topHeight = (topY >= 0) ? topY : height;
String biomeName = data.biomeNames[rx][rz];
if (biomeName == null || biomeName.equals(DEFAULT_BIOME)) {
biomeName = null;
}
int light = data.lightMap[rx][rz];
List<OverlayEntry> overlays = data.overlays.get(rx * REGION_SIZE_BLOCKS + rz);
boolean hasOverlays = overlays != null && !overlays.isEmpty();
boolean isGrass = blockLookup.isGrassBlock(blockName);
boolean topHeightDifferent = (height != topHeight);
int params = 0;
if (!isGrass) {
params |= 1;
}
if (hasOverlays) {
params |= 2;
}
params |= light << 8;
params |= encodeHeightToParams(height);
if (biomeName != null) {
params |= 0x100000;
}
if (topHeightDifferent) {
params |= 0x1000000;
}
if (!isGrass && !blockPalette.containsKey(paletteKey)) {
params |= 0x200000;
}
if (biomeName != null && !biomePalette.containsKey(biomeName)) {
params |= 0x400000;
}
dos.writeInt(params);
if (!isGrass) {
writeBlockStateRef(dos, blockState, blockPalette);
}
if (topHeightDifferent) {
dos.writeByte(topHeight & 0xFF);
}
if (hasOverlays) {
dos.writeByte(overlays.size());
for (OverlayEntry overlay : overlays) {
serializeOverlay(overlay, dos, blockPalette, blockLookup);
}
}
writeBiomeRef(dos, biomeName, biomePalette);
}
private static void writeBlockStateRef(DataOutputStream dos, BlockState blockState,
Map<PaletteKey, Integer> blockPalette) throws IOException {
PaletteKey paletteKey = PaletteKey.from(blockState);
if (blockPalette.containsKey(paletteKey)) {
dos.writeInt(blockPalette.get(paletteKey));
} else {
XaeroBlockStateNbtWriter.writeBlockState(blockState, dos);
blockPalette.put(paletteKey, blockPalette.size());
}
}
private static int encodeHeightToParams(int height) {
return (height & 0xFF) << 12 | ((height >> 8) & 0xF) << 25;
}
private static void serializeOverlay(OverlayEntry overlay, DataOutputStream dos,
Map<PaletteKey, Integer> blockPalette,
BlockPropertyLookup blockLookup) throws IOException {
BlockState blockState = overlay.blockState;
boolean isWater = blockLookup.isWater(blockState.name());
int opacity = overlay.opacity;
int light = overlay.light;
PaletteKey paletteKey = PaletteKey.from(blockState);
int overlayParams = 0;
if (!isWater) {
overlayParams |= 1;
}
overlayParams |= light << 4;
overlayParams |= opacity << 11;
if (!isWater && !blockPalette.containsKey(paletteKey)) {
overlayParams |= 0x400;
}
dos.writeInt(overlayParams);
if (!isWater) {
writeBlockStateRef(dos, blockState, blockPalette);
}
}
}
@@ -0,0 +1,77 @@
package com.mapsyncer.mca.convert.io;
import com.mapsyncer.mca.ChunkSectionParser.BlockState;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
/**
* 写入 Xaero MapSaveLoad / NbtUtils.writeBlockState 风格的方块状态 NBT。
*
* <p>格式:根 Compound → Name (string) + Properties (compound,属性按字母序)</p>
*/
public final class XaeroBlockStateNbtWriter {
public static final BlockState AIR = new BlockState("minecraft:air", Map.of());
public static final BlockState WATER = new BlockState("minecraft:water", Map.of());
private static final ConcurrentHashMap<BlockState, PaletteKey> PALETTE_KEY_CACHE = new ConcurrentHashMap<>();
private XaeroBlockStateNbtWriter() {}
/**
* Region 内 block palette 键:名称 + 按字母序排列的属性,对齐 Xaero HashMap&lt;BlockState&gt; 语义。
*/
public record PaletteKey(String name, List<Map.Entry<String, String>> properties) {
public static PaletteKey from(BlockState state) {
if (state == null) {
return from(AIR);
}
return PALETTE_KEY_CACHE.computeIfAbsent(state, s -> {
TreeMap<String, String> sorted = new TreeMap<>(s.properties());
return new PaletteKey(s.name(), List.copyOf(sorted.entrySet()));
});
}
public BlockState toBlockState() {
if (properties.isEmpty()) {
return new BlockState(name, Map.of());
}
var map = new java.util.LinkedHashMap<String, String>();
for (Map.Entry<String, String> e : properties) {
map.put(e.getKey(), e.getValue());
}
return new BlockState(name, Collections.unmodifiableMap(map));
}
}
public static void writeBlockState(BlockState state, DataOutputStream dos) throws IOException {
BlockState effective = state != null ? state : AIR;
dos.writeByte(10);
dos.writeShort(0);
dos.writeByte(8);
dos.writeUTF("Name");
dos.writeUTF(effective.name());
if (!effective.properties().isEmpty()) {
dos.writeByte(10);
dos.writeUTF("Properties");
TreeMap<String, String> sorted = new TreeMap<>(effective.properties());
for (Map.Entry<String, String> entry : sorted.entrySet()) {
dos.writeByte(8);
dos.writeUTF(entry.getKey());
dos.writeUTF(entry.getValue());
}
dos.writeByte(0);
}
dos.writeByte(0);
}
}
@@ -0,0 +1,18 @@
package com.mapsyncer.mca.convert.model;
public final class ConvertConstants {
public static final String DEFAULT_BLOCK = "minecraft:air";
public static final String DEFAULT_BIOME = "minecraft:the_void";
public static final int REGION_SIZE_BLOCKS = 512;
public static final int CHUNKS_PER_REGION = 32;
public static final int BLOCKS_PER_TILE_CHUNK = 64;
public static final int BLOCKS_PER_TILE = 16;
public static final int TILES_PER_TILE_CHUNK = 4;
public static final int TILE_CHUNKS_PER_REGION = 8;
public static final int MAJOR_VERSION = 6;
public static final int MINOR_VERSION = 8;
private ConvertConstants() {}
}
@@ -0,0 +1,66 @@
package com.mapsyncer.mca.convert.model;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.ChunkSectionParser.BlockState;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.RegionConverterStandalone.CaveModeParams;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static com.mapsyncer.mca.convert.model.ConvertConstants.CHUNKS_PER_REGION;
import static com.mapsyncer.mca.convert.model.ConvertConstants.REGION_SIZE_BLOCKS;
public class MapRegionData {
public final BlockState[][] blockStates;
public final int[][] topBlockY;
public final String[][] biomeNames;
public final int[][] heightMap;
public final byte[][] lightMap;
public final boolean[][] hasData;
public final boolean[][] chunkExists;
public final Map<Integer, List<OverlayEntry>> overlays;
public final int minBuildHeight;
public final LightMode lightMode;
public final CaveModeParams caveParams;
public final ChunkDataParser.ChunkInfo[][] chunkGrid;
public MapRegionData(int minBuildHeight, LightMode lightMode) {
this(minBuildHeight, lightMode, CaveModeParams.NONE);
}
public MapRegionData(int minBuildHeight, LightMode lightMode, CaveModeParams caveParams) {
this.minBuildHeight = minBuildHeight;
this.lightMode = lightMode;
this.caveParams = caveParams != null ? caveParams : CaveModeParams.NONE;
blockStates = new BlockState[REGION_SIZE_BLOCKS][REGION_SIZE_BLOCKS];
topBlockY = new int[REGION_SIZE_BLOCKS][REGION_SIZE_BLOCKS];
for (int x = 0; x < REGION_SIZE_BLOCKS; x++) {
Arrays.fill(topBlockY[x], -1);
}
biomeNames = new String[REGION_SIZE_BLOCKS][REGION_SIZE_BLOCKS];
heightMap = new int[REGION_SIZE_BLOCKS][REGION_SIZE_BLOCKS];
for (int x = 0; x < REGION_SIZE_BLOCKS; x++) {
Arrays.fill(heightMap[x], minBuildHeight);
}
lightMap = new byte[REGION_SIZE_BLOCKS][REGION_SIZE_BLOCKS];
hasData = new boolean[REGION_SIZE_BLOCKS][REGION_SIZE_BLOCKS];
chunkExists = new boolean[CHUNKS_PER_REGION][CHUNKS_PER_REGION];
overlays = new HashMap<>();
chunkGrid = new ChunkDataParser.ChunkInfo[CHUNKS_PER_REGION][CHUNKS_PER_REGION];
}
/** 是否至少有一个 tile 被扫描写入(非空 region)。 */
public boolean hasAnyMapData() {
for (int x = 0; x < REGION_SIZE_BLOCKS; x++) {
for (int z = 0; z < REGION_SIZE_BLOCKS; z++) {
if (hasData[x][z]) {
return true;
}
}
}
return false;
}
}
@@ -0,0 +1,21 @@
package com.mapsyncer.mca.convert.model;
import com.mapsyncer.mca.ChunkSectionParser.BlockState;
public class OverlayEntry {
public final BlockState blockState;
public final int y;
public int opacity;
public final int light;
public OverlayEntry(BlockState blockState, int y, int opacity, int light) {
this.blockState = blockState;
this.y = y;
this.opacity = opacity;
this.light = light;
}
public String blockName() {
return blockState.name();
}
}
@@ -0,0 +1,58 @@
package com.mapsyncer.mca.convert.overlay;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkSectionParser.BlockState;
import com.mapsyncer.mca.convert.io.XaeroBlockStateNbtWriter;
import com.mapsyncer.mca.convert.model.OverlayEntry;
import java.util.ArrayList;
import java.util.List;
public final class OverlayAccumulator {
public static final int MAX_LAYERS = 10;
private OverlayAccumulator() {}
public static void add(List<OverlayEntry> currentList, ArrayList<OverlayEntry> list,
BlockState blockState, int y, int opacityToAdd, int light,
BlockPropertyLookup blockLookup) {
if (currentList != list) {
addSingle(list, blockState, y, opacityToAdd, light, blockLookup);
return;
}
if (list.size() >= MAX_LAYERS) {
return;
}
opacityToAdd = normalizeOpacity(blockState.name(), opacityToAdd, blockLookup);
OverlayEntry last = list.isEmpty() ? null : list.get(list.size() - 1);
if (last != null && XaeroBlockStateNbtWriter.PaletteKey.from(last.blockState)
.equals(XaeroBlockStateNbtWriter.PaletteKey.from(blockState))) {
last.opacity = Math.min(15, last.opacity + opacityToAdd);
} else {
list.add(new OverlayEntry(blockState, y, opacityToAdd, light));
}
}
private static void addSingle(ArrayList<OverlayEntry> list, BlockState blockState, int y,
int opacityToAdd, int light, BlockPropertyLookup blockLookup) {
if (list.size() >= MAX_LAYERS) {
return;
}
opacityToAdd = normalizeOpacity(blockState.name(), opacityToAdd, blockLookup);
list.add(new OverlayEntry(blockState, y, opacityToAdd, light));
}
private static int normalizeOpacity(String blockName, int opacityToAdd, BlockPropertyLookup blockLookup) {
if (opacityToAdd > 15) {
opacityToAdd = 15;
}
if (opacityToAdd == 0 && !blockLookup.isWater(blockName)) {
String lower = blockName.toLowerCase();
if (lower.contains("seagrass") || lower.contains("kelp") || blockLookup.isTransparent(blockName)) {
opacityToAdd = 1;
}
}
return opacityToAdd;
}
}
@@ -0,0 +1,143 @@
package com.mapsyncer.mca.convert.scan;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.ChunkSectionParser;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.RegionConverterStandalone;
import com.mapsyncer.mca.convert.model.MapRegionData;
import static com.mapsyncer.mca.convert.model.ConvertConstants.REGION_SIZE_BLOCKS;
public final class ChunkColumnScanner {
private ChunkColumnScanner() {}
public static void scan(MapRegionData data,
ChunkDataParser.ChunkInfo chunk,
int minBuildHeight,
int worldTopY,
LightMode lightMode,
RegionConverterStandalone.CaveModeParams caveParams,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup) {
scan(data, chunk, minBuildHeight, worldTopY, lightMode, caveParams, worldHasSkylight,
blockLookup, ScanVerticalBounds.unbounded());
}
public static void scan(MapRegionData data,
ChunkDataParser.ChunkInfo chunk,
int minBuildHeight,
int worldTopY,
LightMode lightMode,
RegionConverterStandalone.CaveModeParams caveParams,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup,
ScanVerticalBounds bounds) {
int chunkX = chunk.chunkX();
int chunkZ = chunk.chunkZ();
data.chunkExists[chunkX][chunkZ] = true;
data.chunkGrid[chunkX][chunkZ] = chunk;
int caveStart = caveParams.caveStart();
int caveDepth = caveParams.caveDepth();
boolean isCaveMode = caveStart != Integer.MAX_VALUE;
boolean fullCave = caveStart == Integer.MIN_VALUE;
int[][] heightMap = chunk.heightmap();
int chunkBottomY = chunk.chunkBottomY();
ColumnScanContext ctx = new ColumnScanContext(fullCave);
int sectionIndex = 0;
for (ChunkSectionParser.SectionData section : chunk.sections()) {
if (section.blockPalette().isEmpty()) {
continue;
}
int sectionY = section.sectionY();
int sectionBaseY = sectionY * 16;
int sectionTopY = sectionBaseY + 15;
int sectionBottomY = sectionBaseY;
if (sectionTopY < chunkBottomY) {
continue;
}
boolean singlePalette = section.blockPalette().size() == 1 && section.blockData() == null;
ChunkSectionParser.BlockState singleState = singlePalette
? section.blockPalette().get(0) : null;
for (int lx = 0; lx < 16; lx++) {
for (int lz = 0; lz < 16; lz++) {
int relX = chunkX * 16 + lx;
int relZ = chunkZ * 16 + lz;
if (relX >= REGION_SIZE_BLOCKS || relZ >= REGION_SIZE_BLOCKS) {
continue;
}
int pos = ColumnScanContext.pos(lx, lz);
if (ctx.blockFound[pos]) {
continue;
}
int heightMapValue = heightMap[lx][lz];
int scanBottomY;
int startY;
if (isCaveMode) {
startY = bounds.clampStartY(caveStart);
scanBottomY = bounds.clampBottomY(minBuildHeight,
Math.max(caveStart - caveDepth, minBuildHeight));
} else {
startY = bounds.resolveSurfaceStartY(
ChunkDataParser.getHeightmapStartY(chunk, lx, lz, worldTopY));
scanBottomY = bounds.clampBottomY(minBuildHeight, minBuildHeight);
}
if (startY < scanBottomY) {
continue;
}
if (isCaveMode && sectionTopY > startY) {
continue;
}
// 整段在扫描底以下才跳过(用 sectionTopY,不能用 sectionBottomY
if (sectionTopY < scanBottomY) {
continue;
}
int effectiveStartY = computeEffectiveStartY(sectionIndex, startY, worldTopY,
isCaveMode, heightMapValue, chunkBottomY, sectionTopY, bounds);
if (!isCaveMode && effectiveStartY < sectionBottomY) {
continue;
}
PixelColumnProcessor.processColumn(chunk, section, sectionBaseY,
lx, lz, relX, relZ, effectiveStartY, scanBottomY, chunkBottomY,
heightMapValue, isCaveMode, worldHasSkylight, lightMode,
singlePalette, singleState, ctx, data, blockLookup);
}
}
sectionIndex++;
}
}
private static int computeEffectiveStartY(int sectionIndex, int startY, int worldTopY,
boolean isCaveMode, int heightMapValue, int chunkBottomY,
int sectionTopY, ScanVerticalBounds bounds) {
int effectiveStartY = startY;
if (sectionIndex > 0) {
effectiveStartY = Math.min(startY + 1, worldTopY - 1);
}
if (!isCaveMode && !bounds.ignoresHeightmap() && heightMapValue < chunkBottomY) {
effectiveStartY = sectionTopY;
}
if (isCaveMode) {
effectiveStartY = Math.min(effectiveStartY, sectionTopY);
}
return effectiveStartY;
}
}
@@ -0,0 +1,57 @@
package com.mapsyncer.mca.convert.scan;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkSectionParser;
import com.mapsyncer.mca.convert.model.OverlayEntry;
import java.util.ArrayList;
public final class ColumnScanContext {
public final boolean[] blockFound = new boolean[256];
public final boolean[] underair = new boolean[256];
/** 洞穴模式:扫描起点处尚未进入洞穴内部(参考 Xaero shouldEnterGround */
public final boolean[] shouldEnterGround = new boolean[256];
@SuppressWarnings("unchecked")
public final ArrayList<OverlayEntry>[] overlayLists = new ArrayList[256];
public final int[] topPixelH = new int[256];
public ColumnScanContext(boolean fullCave) {
for (int i = 0; i < 256; i++) {
underair[i] = fullCave;
shouldEnterGround[i] = fullCave;
topPixelH[i] = -1;
}
}
/** 进入空气区域(Xaero: 遇 air 设 underair=true */
void onAir(int pos) {
underair[pos] = true;
shouldEnterGround[pos] = false;
}
/**
* 流体触发 underairXaero MapWriter: 除非 cave && shouldEnterGround,否则设 underair
*/
void onFluid(int pos, boolean isCaveMode) {
if (!isCaveMode || !shouldEnterGround[pos]) {
underair[pos] = true;
}
}
/** 洞穴模式:只有 underair 后才可记录实体方块/overlay */
boolean canProcessCaveBlock(int pos, boolean isCaveMode) {
return !isCaveMode || underair[pos];
}
static boolean hasFluid(ChunkSectionParser.BlockState state, BlockPropertyLookup lookup) {
if (state.isFluid() || state.isWaterlogged()) {
return true;
}
return (lookup.getFlags(state.name()) & BlockPropertyLookup.FLAG_TRANSLUCENT_FLUID) != 0;
}
public static int pos(int lx, int lz) {
return (lz << 4) | lx;
}
}
@@ -0,0 +1,265 @@
package com.mapsyncer.mca.convert.scan;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.ChunkSectionParser;
import com.mapsyncer.mca.ChunkSectionParser.BlockState;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.convert.io.XaeroBlockStateNbtWriter;
import com.mapsyncer.mca.convert.model.MapRegionData;
import com.mapsyncer.mca.convert.model.OverlayEntry;
import com.mapsyncer.mca.convert.overlay.OverlayAccumulator;
import java.util.ArrayList;
import java.util.List;
import static com.mapsyncer.mca.convert.model.ConvertConstants.REGION_SIZE_BLOCKS;
/**
* 统一的列扫描逻辑,合并原 single/multi palette 路径。
*/
public final class PixelColumnProcessor {
private PixelColumnProcessor() {}
/**
* @return true 表示该像素已找到表面
*/
public static boolean processColumn(
ChunkDataParser.ChunkInfo chunk,
ChunkSectionParser.SectionData section,
int sectionBaseY,
int lx, int lz,
int relX, int relZ,
int effectiveStartY, int scanBottomY,
int chunkBottomY,
int heightMapValue,
boolean isCaveMode,
boolean worldHasSkylight,
LightMode lightMode,
boolean singlePalette,
ChunkSectionParser.BlockState singleState,
ColumnScanContext ctx,
MapRegionData data,
BlockPropertyLookup blockLookup) {
int pos = ColumnScanContext.pos(lx, lz);
if (ctx.blockFound[pos]) {
return false;
}
if (singlePalette) {
if (singleState.isAir()) {
if (isCaveMode) {
ctx.onAir(pos);
}
return false;
}
if (isCaveMode && ColumnScanContext.hasFluid(singleState, blockLookup)) {
ctx.onFluid(pos, true);
}
if (!ctx.canProcessCaveBlock(pos, isCaveMode)) {
return false;
}
}
int localStartY = 15;
if (effectiveStartY >= sectionBaseY && effectiveStartY <= sectionBaseY + 15) {
localStartY = effectiveStartY - sectionBaseY;
} else if (singlePalette) {
localStartY = Math.min(effectiveStartY - sectionBaseY, 15);
if (localStartY < 0) {
localStartY = 15;
}
}
int localScanBottomY = Math.max(0, scanBottomY - sectionBaseY);
for (int ly = localStartY; ly >= localScanBottomY; ly--) {
int worldY = sectionBaseY + ly;
if (worldY < scanBottomY) {
break;
}
if (worldY < chunkBottomY) {
break;
}
ChunkSectionParser.BlockState state = singlePalette
? singleState
: ChunkSectionParser.getBlockStateAt(section, lx, ly, lz);
if (state.isAir()) {
if (isCaveMode) {
ctx.onAir(pos);
}
continue;
}
if (isCaveMode && ColumnScanContext.hasFluid(state, blockLookup)) {
ctx.onFluid(pos, true);
}
if (!ctx.canProcessCaveBlock(pos, isCaveMode)) {
continue;
}
String blockName = state.name();
int flags = blockLookup.getFlags(blockName);
ArrayList<OverlayEntry> overlays = ctx.overlayLists[pos];
if ((flags & BlockPropertyLookup.FLAG_WATER_INHERITING) != 0) {
return finishSurface(chunk, section, lx, ly, lz, relX, relZ, worldY,
state, heightMapValue, overlays, ctx, data, blockLookup,
lightMode, worldHasSkylight, true);
}
if (blockLookup.isWaterloggedSurface(blockName, state.properties())
&& (flags & BlockPropertyLookup.FLAG_SHOULD_OVERLAY) == 0) {
return finishSurface(chunk, section, lx, ly, lz, relX, relZ, worldY,
state, heightMapValue, overlays, ctx, data, blockLookup,
lightMode, worldHasSkylight, false);
}
if ((flags & BlockPropertyLookup.FLAG_TRANSLUCENT_FLUID) != 0) {
addFluidOverlay(chunk, section, lx, ly, lz, worldY, state,
overlays, ctx, pos, blockLookup);
continue;
}
if (state.isWaterlogged() && (flags & BlockPropertyLookup.FLAG_SHOULD_OVERLAY) != 0) {
int aboveWorldY = worldY + 1;
int waterOpacity = blockLookup.getLightBlock("minecraft:water");
byte waterLight = SectionLightAccess.getBlockLightCrossSection(
chunk, section, lx, ly, lz, aboveWorldY);
overlays = ensureOverlayList(ctx, pos, overlays);
OverlayAccumulator.add(overlays, overlays, XaeroBlockStateNbtWriter.WATER, worldY,
waterOpacity, waterLight, blockLookup);
int opacity = blockLookup.getLightBlock(blockName);
byte light = SectionLightAccess.getBlockLightCrossSection(
chunk, section, lx, ly, lz, aboveWorldY);
OverlayAccumulator.add(overlays, overlays, state, worldY, opacity, light, blockLookup);
if (ctx.topPixelH[pos] < 0) {
ctx.topPixelH[pos] = worldY;
}
continue;
}
if ((flags & BlockPropertyLookup.FLAG_SHOULD_OVERLAY) != 0) {
int opacity = blockLookup.getLightBlock(blockName);
int aboveWorldY = worldY + 1;
byte light = SectionLightAccess.getBlockLightCrossSection(
chunk, section, lx, ly, lz, aboveWorldY);
overlays = ensureOverlayList(ctx, pos, overlays);
OverlayAccumulator.add(overlays, overlays, state, worldY, opacity, light, blockLookup);
if (ctx.topPixelH[pos] < 0) {
ctx.topPixelH[pos] = worldY;
}
continue;
}
if ((flags & BlockPropertyLookup.FLAG_INVISIBLE) != 0) {
continue;
}
if ((flags & BlockPropertyLookup.FLAG_TRANSPARENT) != 0) {
int opacity = blockLookup.getLightBlock(blockName);
int aboveWorldY = worldY + 1;
byte light = SectionLightAccess.getBlockLightCrossSection(
chunk, section, lx, ly, lz, aboveWorldY);
overlays = ensureOverlayList(ctx, pos, overlays);
OverlayAccumulator.add(overlays, overlays, state, worldY, opacity, light, blockLookup);
if (ctx.topPixelH[pos] < 0) {
ctx.topPixelH[pos] = worldY;
}
continue;
}
int aboveWorldY = worldY + 1;
byte light = SectionLightAccess.calculateSurfaceLight(chunk, section, lx, ly, lz, aboveWorldY,
heightMapValue, overlays, lightMode, worldHasSkylight, blockLookup);
int topBlockY = ctx.topPixelH[pos] < 0 ? worldY : ctx.topPixelH[pos];
recordPixelScan(data, state, worldY, topBlockY, light, ctx.overlayLists[pos], relX, relZ);
ctx.blockFound[pos] = true;
return true;
}
return false;
}
private static boolean finishSurface(
ChunkDataParser.ChunkInfo chunk,
ChunkSectionParser.SectionData section,
int lx, int ly, int lz,
int relX, int relZ,
int worldY,
ChunkSectionParser.BlockState state,
int heightMapValue,
ArrayList<OverlayEntry> overlays,
ColumnScanContext ctx,
MapRegionData data,
BlockPropertyLookup blockLookup,
LightMode lightMode,
boolean worldHasSkylight,
boolean useCalculateLight) {
int pos = ColumnScanContext.pos(lx, lz);
int opacity = blockLookup.getLightBlock("minecraft:water");
int aboveWorldY = worldY + 1;
byte light = useCalculateLight
? SectionLightAccess.calculateSurfaceLight(chunk, section, lx, ly, lz, aboveWorldY,
heightMapValue, overlays, lightMode, worldHasSkylight, blockLookup)
: SectionLightAccess.getBlockLightCrossSection(chunk, section, lx, ly, lz, aboveWorldY);
overlays = ensureOverlayList(ctx, pos, overlays);
OverlayAccumulator.add(overlays, overlays, XaeroBlockStateNbtWriter.WATER, worldY, opacity, light, blockLookup);
int topBlockY = ctx.topPixelH[pos] < 0 ? worldY : ctx.topPixelH[pos];
recordPixelScan(data, state, worldY, topBlockY, light, ctx.overlayLists[pos], relX, relZ);
ctx.blockFound[pos] = true;
return true;
}
private static void addFluidOverlay(
ChunkDataParser.ChunkInfo chunk,
ChunkSectionParser.SectionData section,
int lx, int ly, int lz,
int worldY,
ChunkSectionParser.BlockState state,
ArrayList<OverlayEntry> overlays,
ColumnScanContext ctx,
int pos,
BlockPropertyLookup blockLookup) {
int opacity = blockLookup.getLightBlock(state.name());
int aboveWorldY = worldY + 1;
byte light = SectionLightAccess.getBlockLightCrossSection(chunk, section, lx, ly, lz, aboveWorldY);
overlays = ensureOverlayList(ctx, pos, overlays);
OverlayAccumulator.add(overlays, overlays, state, worldY, opacity, light, blockLookup);
if (ctx.topPixelH[pos] < 0) {
ctx.topPixelH[pos] = worldY;
}
}
private static ArrayList<OverlayEntry> ensureOverlayList(
ColumnScanContext ctx, int pos, ArrayList<OverlayEntry> overlays) {
if (overlays == null) {
overlays = new ArrayList<>();
ctx.overlayLists[pos] = overlays;
}
return overlays;
}
static void recordPixelScan(MapRegionData data, ChunkSectionParser.BlockState surfaceState,
int topY, int highestBlockY, byte surfaceLight,
List<OverlayEntry> overlayList, int relX, int relZ) {
if (relX >= REGION_SIZE_BLOCKS || relZ >= REGION_SIZE_BLOCKS) {
return;
}
data.hasData[relX][relZ] = true;
BlockState stored = surfaceState != null ? surfaceState : XaeroBlockStateNbtWriter.AIR;
data.blockStates[relX][relZ] = stored;
data.topBlockY[relX][relZ] = highestBlockY;
data.heightMap[relX][relZ] = topY;
data.lightMap[relX][relZ] = surfaceLight;
if (overlayList != null && !overlayList.isEmpty()) {
data.overlays.put(relX * REGION_SIZE_BLOCKS + relZ, overlayList);
}
}
}
@@ -0,0 +1,18 @@
package com.mapsyncer.mca.convert.scan;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.RegionConverterStandalone.CaveModeParams;
/**
* 单个 region 的一种扫描/输出配置(对应一个 Xaero 层或地表层)。
*/
public record RegionScanPass(
int caveLayer,
LightMode lightMode,
CaveModeParams caveParams,
ScanVerticalBounds verticalBounds
) {
public boolean isSurfaceLayer() {
return caveLayer == Integer.MAX_VALUE;
}
}
@@ -0,0 +1,43 @@
package com.mapsyncer.mca.convert.scan;
/**
* 列扫描的垂直范围限制(地表模式用于逻辑顶以上区域等场景)。
*/
public record ScanVerticalBounds(int floorY, int ceilingY) {
public static ScanVerticalBounds unbounded() {
return new ScanVerticalBounds(Integer.MIN_VALUE, Integer.MAX_VALUE);
}
public static ScanVerticalBounds fullColumn(int minBuildHeight, int worldTopY) {
return new ScanVerticalBounds(minBuildHeight, worldTopY - 1);
}
/** 仅扫描 {@code floorY}(含)以上到世界顶 */
public static ScanVerticalBounds aboveY(int floorY, int worldTopY) {
return new ScanVerticalBounds(floorY, worldTopY - 1);
}
public int clampStartY(int startY) {
return Math.min(startY, ceilingY);
}
public int clampBottomY(int minBuildHeight, int scanBottomY) {
return Math.max(scanBottomY, Math.max(minBuildHeight, floorY));
}
/**
* 地表模式起点:有 {@code floorY} 限制时忽略高度图,从 {@code ceilingY} 向下扫
* (地狱逻辑顶以上地表;高度图指向下层可玩区,不能用于上层扫描起点)。
*/
public int resolveSurfaceStartY(int heightmapStartY) {
if (floorY > Integer.MIN_VALUE) {
return ceilingY;
}
return clampStartY(heightmapStartY);
}
public boolean ignoresHeightmap() {
return floorY > Integer.MIN_VALUE;
}
}
@@ -0,0 +1,98 @@
package com.mapsyncer.mca.convert.scan;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.ChunkSectionParser;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.convert.model.OverlayEntry;
import java.util.List;
public final class SectionLightAccess {
private SectionLightAccess() {}
public static ChunkSectionParser.SectionData findSectionAt(ChunkDataParser.ChunkInfo chunk, int worldY) {
ChunkSectionParser.SectionData[] lookup = chunk.sectionLookup();
if (lookup == null) {
return null;
}
int idx = (worldY >> 4) - chunk.minSectionY();
if (idx >= 0 && idx < lookup.length) {
return lookup[idx];
}
return null;
}
public static byte getBlockLightCrossSection(ChunkDataParser.ChunkInfo chunk,
ChunkSectionParser.SectionData currentSection,
int lx, int ly, int lz, int worldY) {
int sectionY = worldY >> 4;
if (sectionY == currentSection.sectionY()) {
int localY = worldY - (sectionY * 16);
if (localY >= 0 && localY <= 15) {
return ChunkSectionParser.getBlockLight(currentSection, lx, localY, lz);
}
}
ChunkSectionParser.SectionData targetSection = findSectionAt(chunk, worldY);
if (targetSection != null) {
int localY = worldY - (targetSection.sectionY() * 16);
return ChunkSectionParser.getBlockLight(targetSection, lx, localY, lz);
}
return 0;
}
public static byte calculateSurfaceLight(ChunkDataParser.ChunkInfo chunk,
ChunkSectionParser.SectionData currentSection,
int lx, int ly, int lz, int worldY,
int heightMapValue,
List<OverlayEntry> overlayList,
LightMode lightMode,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup) {
byte blockLight = getBlockLightCrossSection(chunk, currentSection, lx, ly, lz, worldY);
byte skyLight = 0;
ChunkSectionParser.SectionData stateSection = null;
int worldYSkySectionY = worldY >> 4;
if (worldYSkySectionY == currentSection.sectionY()) {
int localY = worldY - (worldYSkySectionY * 16);
if (localY >= 0 && localY <= 15) {
skyLight = ChunkSectionParser.getSkyLight(currentSection, lx, localY, lz);
}
} else {
stateSection = findSectionAt(chunk, worldY);
if (stateSection != null) {
int localY = worldY - (stateSection.sectionY() * 16);
skyLight = ChunkSectionParser.getSkyLight(stateSection, lx, localY, lz);
}
}
boolean hasFluidOverlay = false;
if (overlayList != null) {
for (OverlayEntry o : overlayList) {
if (blockLookup.isWater(o.blockName())) {
hasFluidOverlay = true;
break;
}
}
}
boolean hasSkyAccess = worldY >= heightMapValue;
if (stateSection == null) {
stateSection = findSectionAt(chunk, worldY);
}
if (stateSection == null) {
stateSection = currentSection;
}
int stateLocalY = worldY - (stateSection.sectionY() * 16);
if (stateLocalY < 0 || stateLocalY > 15) {
stateLocalY = ly;
}
boolean isGlowing = blockLookup.isGlowing(
ChunkSectionParser.getBlockStateAt(stateSection, lx, stateLocalY, lz).name());
return lightMode.calculateEffectiveLight(
blockLight, skyLight, hasSkyAccess, hasFluidOverlay, isGlowing, worldHasSkylight);
}
}