プログラミング
ファイルをMinecraftの世界に変換する
Converting Files into Minecraft Worlds (wuemeli.com)
要約
この記事では、任意のファイルをMinecraftの世界に変換するプロジェクトについて解説しています。各バイトをMinecraftのブロックにマッピングするパレットを生成し、ファイルの内容を3D空間に配置して.mca形式のリージョンファイルとして保存する仕組みを説明しています。エンコードとデコードのプロセス、およびそのためのコード例も紹介されています。
全文翻訳
ファイルをMinecraftの世界に保存したことはありますか?いいえ?残念ですが、今ならできます。
最近、ファイルからMinecraftの世界を生成するという、ばかげたアイデアを思いつきました。これが、3Dジオメトリ、バイトレイアウト、そしてMinecraftについて多くのことを学ぶ、非常にクールなウサギの穴へと私を導きました。
中心的なアイデアは非常にシンプルです。任意のファイルを取り込み、それをMinecraftのブロックとして表示できるようにしたいのです。
その過程で、私は自分のコードに、小さなテキストファイルが2.3エクサバイトもあると信じ込ませることに成功しました。なぜ?なぜダメなのですか。
パレットジェネレーター
まず必要なのは、ルックアップテーブルです。1バイトは256個の値(0〜255)を保持でき、幸いなことにMinecraftには256個以上のブロックがあるため、各バイトに独自のブロックを割り当てることができます。バイト0は石、バイト1は花崗岩、255まで続きます。
多くのブロックは、自動的に変化したり、配置に依存したりする状態を持っています。小麦、ニンジン、その他の作物は成長段階があり、水や溶岩にはレベルがあります。もしバイト42が小麦にデコードされ、その小麦が成長段階を進めた場合、私のデコーダーは間違ったバイトを読み取り、ファイル全体が台無しになってしまいます。
私はまず、mcdata_rsを使って1.21.11のブロックデータを取得し、年齢やレベルの状態を持つもの(後述するベッドやチェストも含む)をすべて除外し、最初の256個のブロックを取り出して、Rustファイルに固定配列として書き込みます。(ビデオの最後には、ブロックがフィルタリングされて良いブロックだけになるプロセスを見ることができます)
コード:
use mcdata_rs::mc_data;
use std::{fs, path::Path};
const NAUGHTY_LIST: &[&str] = &[
"sand", "gravel", "anvil", "dragon_egg", "scaffolding", "dripstone", "snow", "farmland", "chest", "_bed", "door", "ice", "grass_block",
];
fn main() {
let data_1_21_11 = mc_data("1.21.11").expect("Failed to download Minecraft Block Data");
let palette = data_1_21_11
.blocks_array
.iter()
.filter(|b| b.bounding_box.eq("block"))
.filter(|b| {
!b.states.iter().any(|s| matches!(s.name.as_str(), "age" | "level" | "part"))
})
.filter(|b| !NAUGHTY_LIST.iter().any(|g| b.name.contains(g)))
.collect::<Vec<_>>();
let names: Vec<&str> = palette.iter().take(256).map(|b| b.name.as_str()).collect();
let mut out = String::new();
out.push_str("pub const PALETTE: [&str; 256] = [\n");
//TODO: make this cleaner
for name in &names {
out.push_str(" \"minecraft:");
out.push_str(name);
out.push_str("\",\n");
}
out.push_str("];\n");
let out_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/palette.rs");
fs::write(out_path, out).expect("Failed to write palette");
println!("Wrote {:?} blocks to palette.rs", names.len());
}
これが PALETTE: [&str; 256] を提供し、翻訳を行う2つの関数は非常に簡単です。
pub fn byte_to_block(byte: u8) -> &'static str {
PALETTE[byte as usize]
}
pub fn block_to_byte(block: &str) -> Result<u8, SulfurError> {
PALETTE
.iter()
.position(|palette_block| *palette_block == block)
.map(|i| {
u8::try_from(i).expect("palette has exactly 256 entries so this will never happen")
})
.ok_or(SulfurError::BlockNotInPalette(block.to_string()))
}
byte_to_block は単なる配列インデックスであり、block_to_byte は逆引きです。
実行する
バイトがブロックにマッピングされたので、各ブロックをどこに配置するかを決定する必要があります。まず16x16の床を埋め、次に1ブロック上に移動し、16x16x16の完全なキューブが埋まると、次のキューブにジャンプします。その16x16x16のキューブは1つのセクションであり、4096ブロックです。しかし、1つのセクションだけでは始まりにすぎません。Minecraftの世界はY -64からY 320まであり、これは384ブロック、つまり24個のセクションが積み重なったものです。そのため、1つのセクションで停止するのではなく、次のチャンクに移動する前に、チャンクの列全体を底から上まで、24個のセクションすべてを埋めます。リージョンファイル(.mca)は32x32のチャンクグリッドなので、すべてを掛け合わせると:32 x 32チャンク x 24セクション x 4096ブロック = リージョンあたり約96MiBです。
pub fn cube_coords(byte_location: usize) -> silverfish::Coords {
const SECTION_SIZE: usize = 16;
const BLOCKS_PER_SECTION: usize = SECTION_SIZE * SECTION_SIZE * SECTION_SIZE;
const MAX_CHUNKS: usize = 32;
const SECTIONS_PER_COLUMN: usize = 24;
const MIN_Y: i32 = -64;
let inside_section = byte_location % BLOCKS_PER_SECTION;
let section_number = byte_location / BLOCKS_PER_SECTION;
let x_inside = inside_section % SECTION_SIZE;
let y_inside = inside_section / (SECTION_SIZE * SECTION_SIZE);
let z_inside = (inside_section / SECTION_SIZE) % SECTION_SIZE;
let layer = section_number / SECTIONS_PER_COLUMN;
let section_x = layer % MAX_CHUNKS;
let section_y = section_number % SECTIONS_PER_COLUMN;
let section_z = (layer / MAX_CHUNKS) % MAX_CHUNKS;
let x = u32::try_from(section_x * SECTION_SIZE + x_inside).expect("coordinate overflow");
let y = MIN_Y + i32::try_from(section_y * SECTION_SIZE + y_inside).expect("coordinate overflow");
let z = u32::try_from(section_z * SECTION_SIZE + z_inside).expect("coordinate overflow");
(x, y, z).into()
}
テストが理解を助けるかもしれません。
バイト 0 → (0, -64, 0) - ワールドの最下部
バイト 1 → (1, -64, 0)
バイト 15 → (15, -64, 0) - 最初の行の終わり
バイト 16 → (0, -64, 1) - 次の行の後ろ
バイト 255 → (15, -64, 15) - 床がいっぱい
バイト 256 → (0, -63, 0) - 1つ上
バイト 4095 → (15, -49, 15) - セクションがいっぱい
バイト 4096 → (0, -48, 0) - 同じチャンク列の次のセクション上
バイト 4096 * 24 → (16, -64, 0) - 列全体がいっぱい(24セクション)、チャンクを1つ横に移動
バイト 4096 * 24 * 32 → (0, -64, 16) - チャンクのその行がいっぱい、Zでラップ
最終的な結果は次のようになります。(エンコードされたデータ;私のCargo.lockです)
エンコーディング
ファイルを読み込み、空のリージョンを作成し、小さなヘッダー(後述)を書き込み、次に各バイトを歩き、ブロックに変換し、cube_coordsに配置します。その後、.mcaリージョンファイルに書き込みます。
pub fn file_to_region(
source_file: impl AsRef<Path>,
region_file: impl AsRef<Path>,
) -> Result<(), SulfurError> {
let source_file = source_file.as_ref();
let region_file = region_file.as_ref();
if !source_file.exists() {
return Err(SulfurError::InputFileNotFound);
}
let input_file = std::fs::read(source_file)?;
if HEADER_SIZE + input_file.len() > REGION_CAPACITY {
return Err(SulfurError::EncodedPayloadTooLarge);
}
let mut region = Region::default();
region.set_config(Config::new(true, true, Config::DEFAULT_WORLD_HEIGHT))?;
let header = Header::new(input_file.len() as u64).to_bytes();
for (location, byte) in header.iter().enumerate() {
region.set_block(cube_coords(location), byte_to_block(*byte))?;
}
for (byte_location, byte) in input_file.iter().enumerate() {
region.set_block(
cube_coords(header.len() + byte_location),
byte_to_block(*byte),
)?;
}
region.write_blocks()?;
region.write(&mut std::fs::File::create(region_file)?)?;
Ok(())
}
デコーディング
リージョンをロードし、ヘッダーブロックをバイトに戻して元のファイルのサイズを特定し、正確にその数のペイロードブロックを読み込み、各ブロックをblock_to_byteで処理し、バイトをディスクにストリームバックします。
pub fn region_to_file(
region_file: impl AsRef<Path>,
output_file: impl AsRef<Path>,
) -> Result<(), SulfurError> {
let region_file = region_file.as_ref();
let output_file = output_file.as_ref();
if !region_file.exists() {
return Err(SulfurError::InputFileNotFound);
}
let region = Region::from_region(&mut std::fs::File::open(region_file)?, (0, 0))?;
let header_coords: Vec<Coords> = (0..HEADER_SIZE).map(cube_coords).collect();
let header_batch = region.get_blocks(&header_coords)?;
let raw_header_bytes = header_coords
.iter()
.map(|coord| {
let block = header_batch.get(*coord)?.ok_or(SulfurError::MissingBlockAt(*coord))?;
block_to_byte(&block.name.to_str())
})
.collect::<Result<Vec<u8>, _>>()?;
let (header, header_len) = Header::from_bytes(&raw_header_bytes)?;
let file_size = usize::try_from(header.file_size).map_err(|_| SulfurError::EncodedPayloadTooLarge)?;
let end = header_len.checked_add(file_size).ok_or(SulfurError::EncodedPayloadTooLarge)?;
let coords: Vec<Coords> = (header_len..end).map(cube_coords).collect();
let batch = region.get_blocks(&coords)?;
let mut file_buffer = std::io::BufWriter::new(std::fs::File::create(output_file)?);
for coord in &coords {
let block = b