
2026/07/21 5:54
ファイルからマインクラフトのワールドへの変換
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
この記事は、任意のファイルを入力バイトを一意のブロックタイプにマッピングすることで閲覧可能な Minecraft ワールドに変換するツール「Sulfur」を紹介し、地形を安全なデータコンテナとして効果的に利用する。暗号化プロセスでは、「SULF」というマジック文字列と 12 バイトのサイズフィールドを持つカスタムヘッダーが生成される。この構造は、初期に
.mca ヘッダーの最初の 8 バイトをペイロードサイズと誤って読み込み、約 2.3 エксаバイトの誤った計算を行ったというミスを補うために追加された。元の実装では BASE_Y = 0 が使用されており、「Flat Slab」エラーが発生してワールドの高さが 16 ブロック (~4 MiB/リージョン) に制限されていた。全容量 (~96 MiB/リージョン) を達成するために、現在はチャンク列あたり 16x16 の床を埋め尽くし、Y -64 から Y 320 の 24 セクションまで構築した後に 32x32 のチャンクグリッドを巡回する。バージョン 1.21.11 で mcdata_rs を用いて構築されたルックアップテーブル (PALETTE) は、破損や視覚的エラーに傾倒しやすいブロックを除外する。除外リスト (NAUGHTY_LIST) は、砂、石、金槌、ドラゴン卵、足場、雪、耕作地、チェスト、ベッド、ドア、氷などの問題のある単一ブロックの表現を取り除く。マルチブロックエンティティについては、単一ブロックとして配置された際に不正確または不可視になるため、その名前をこのリストに追加することで特に処理された。最終出力は標準的な Minecraft リージョンファイルであり、ワールドセーブを共有するだけでゲーム配信の中にアーカイブや秘密メッセージを隠すことができる。復号化はこのプロセスを逆に行い、カスタムヘッダーを読み込んで元のファイルサイズを回復し、「block_to_byte」を用いてペイロードブロックをバイトに変換し、再構築されたストリームをディスクに書き出す。本文
ファイルをマインクラフトの 3D ワールドに変換するプロジェクト
かつて「あらゆるファイルをマインクラフトの世界に保存したい」という着想を抱いたことはありませんか? そのアイデアを実現し、3D 幾何学・バイト配列・マインクラフトデータに関する深い探求を行ってきました。
プロジェクトの背景と目的
このプロジェクトの核心はシンプルです:
- 「あらゆるファイルを、マインクラフトのブロックとして表示したい」
なぜ取り組むのか?
- **「やってみる価値がないわけじゃない」**という単純な好奇心から始まりました。
- テキストファイルさえ**2.3 エクサバイト(EB)**もの容量になる可能性があるため、スケールへの驚きも大きかったです(笑)。
1. パレットの生成
任意の
0〜255 のバイトを、マインクラフトのブロックに一意に割り当てる必要があります。
処理フロー
- データの取得:バージョン
のブロックデータを取得(1.21.11
を使用)。mcdata_rs - フィルタリング:以下の条件で除外しました。
- 状態変化(
,age
,level
など)を持つブロック(例:作物、液面など)。 理由:状態が変わるとデコーダーがバイトを正しく読み取れず、ファイルが壊れるため。part - 後述する「汚らしいリスト」に指定された名前を含むブロック。
- 状態変化(
- 固定配列化:残ったブロックから先頭の
ブロックを取り出し、Rust のコードとして出力。256
コード例:パレット生成器
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("ダウンロード失敗"); // 条件を満たすブロックをフィルタリング let palette = data_1_21_11 .blocks_array .iter() // 境界ボックスが "block" のものだけ .filter(|b| b.bounding_box.eq("block")) // 状態を持たないブロックだけ(age, level, part を除外) .filter(|b| { !b.states.iter().any(|s| matches!(s.name.as_str(), "age" | "level" | "part")) }) // NAUGHTY_LIST に含まれる名前を除外 .filter(|b| !NAUGHTY_LIST.iter().any(|g| b.name.contains(g))) .collect::<Vec<_>>(); // 先頭 256 ブロックの名前抽出と配列生成 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"); for name in &names { out.push_str(&format!(" \"minecraft:{}\",\n", name)); } out.push_str("];\n"); // ファイル出力 let out_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/palette.rs"); fs::write(out_path, out).expect("書き込み失敗"); }
バイト ↔ ブロック変換関数
生成された
PALETTE を使用して、変換関数を定義します。
- バイト → ブロック: 単純な配列インデックスアクセス(高速)。
- ブロック → バイト: パレットでの検索位置を計算(逆参照)。
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(|p| *p == block) .map(|i| u8::try_from(i).expect("パレットは常に 256 エントリ")) .ok_or(SulfurError::BlockNotInPalette(block.to_string())) }
2. 座標計算の実装 (cube_coords
)
cube_coordsバイトのインデックスを、マインクラフトの 3D ワールド座標
(x, y, z) に変換します。
世界構造と配置戦略
- セクション:
ブロック(4,096 ブロック)のキューブ。16×16×16 - 垂直方向:
〜Y=-64
の間で、合計 24 セクション積み上げられる。320 - 水平方向:
チャンクグリッド。32×32 - 計算順序:
- まず
の床面を埋める。16×16 - その上に高さを上げる(1 セクション完遂)。
- 次のチャンクへ移動する前に、その列の下端から上端まで埋める。
- まず
コード例:座標計算ロジック
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; // 4096 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; // セクション内での座標計算 (x, y, z) 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("オーバーフロー"); let y = MIN_Y + i32::try_from(section_y * SECTION_SIZE + y_inside).expect("オーバーフロー"); let z = u32::try_from(section_z * SECTION_SIZE + z_inside).expect("オーバーフロー"); (x, y, z).into() }
座標遷移のテスト例
| バイト位置 | 結果的な動作 |
|---|---|
| :世界最底辺から開始 |
| :最初の床面行終了 |
| :奥へ移動(Z 方向ラップ) |
| :床面が満杯になる瞬間 |
| :1 ブロック高くなる |
| :最初のセクション(4,096 ブロック)満杯 |
| :次のセクションへ、同じ列で高くなる |
| :1 チャンク分横移動(列完了) |
| :Z 方向でラップ(次の行へ) |
容量計算:
32(チャンク) × 32(チャンク) × 24(セクション) × 4,096(ブロック) ≈ 1 リージョンあたり 96 MiB
3. エンコーディングとデコーディング
エンコーディング(ファイル → マインクラフト)
- ファイルを読み込み、リージョンを初期化。
- ヘッダー書き込み(詳細後述)。
- ファイルの各バイトをブロックに変換し、
で計算した座標に設置。cube_coords
形式として書き出す。.mca
pub fn file_to_region( source_file: impl AsRef<Path>, region_file: impl AsRef<Path>, ) -> Result<(), SulfurError> { let input_file = std::fs::read(source_file.as_ref())?; // ヘッダーサイズチェック(容量オーバー防止) 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))?; // ヘッダーのバイトをブロックとして配置 for (loc, byte) in Header::new(input_file.len()).to_bytes().iter().enumerate() { region.set_block(cube_coords(loc), byte_to_block(*byte))?; } // ファイル本体のバイトを配置 let header_len = HEADER_SIZE as usize; for (offset, byte) in input_file.iter().enumerate() { region.set_block( cube_coords(header_len + offset), byte_to_block(*byte), )?; } region.write(&mut std::fs::File::create(region_file)?)?; Ok(()) }
デコーディング(マインクラフト → ファイル)
- リージョンを読み込み、ヘッダーブロックからファイルサイズを取得。
- ヘッダーブロックをバイト列として復号。
- ペイロード領域(ファイル本体相当)のブロックをバイトに変換して出力。
pub fn region_to_file( region_file: impl AsRef<Path>, output_file: impl AsRef<Path>, ) -> Result<(), SulfurError> { let region = Region::from_region(&mut std::fs::File::open(region_file)?, (0, 0))?; // ヘッダー復号 let header_coords = (0..HEADER_SIZE).map(cube_coords).collect(); let raw_header_bytes = header_coords .iter() .map(|c| { let block_name = region.get_block(c)?.name.to_str(); block_to_byte(block_name) }) .collect::<Result<Vec<u8>, _>>()?; // ファイルサイズ解析とペイロード領域取得 let header_info = Header::from_bytes(&raw_header_bytes)?; let file_size = usize::try_from(header_info.file_size)?; // ペイロード用座標範囲計算 let start = HEADER_SIZE; let end = HEADER_SIZE + file_size; let payload_coords = (start..end).map(cube_coords).collect(); // バイト列へ書き出し let mut writer = std::io::BufWriter::new(std::fs::File::create(output_file)?); for coord in &payload_coords { let block_name = region.get_block(coord)?.name.to_str(); writer.write_all(&[block_to_byte(block_name)?])?; } writer.flush()?; Ok(()) }
4. 開発中のミステークスと解決策
❌ ミストーク #1:平坦なスラブ問題
初版実装では、Y 座標が適切に増えず、全ブロックが
Y=0 付近の「高さ 16 ブロックしかない幅広のスラブ」のように配置されていました。
- 影響: 本来の垂直容量(96 MiB)を使い切れず、世界が扁平化してしまった。
- 解決:
のロジック見直しにより、セクション間を正しく積み上げるよう修正。cube_coords
❌ ミストーク #2:キャパシティオーバーフロー
ファイルの最初の 8 バイトを読み取りそのままサイズと解釈すると、マインクラフトのフォーマットヘッダーとして誤解析されてしまいました。
- 現象:
という巨大な値が得られ、メモリエラーに陥る。2,338,328,528,344,327,162 - 解決: データファイルであることを示す専用ヘッダーを導入。
const MAGIC: [u8; 4] = *b"SULF"; pub const HEADER_SIZE: usize = 12; // magic(4) + size_le(8) pub struct Header { pub file_size: u64, }
❌ ミストーク #3:目に見えないブロック
_bed, double_chest などは「マルチブロック」として機能するため、単独で配置すると見た目がおかしくなります。
- 対策:
に追加し、パレット生成段階で除外しました。NAUGHTY_LIST
まとめと将来展望
現在の成果
- Jeb 氏(元 Mojang)の提案する「アノイルファイル形式」のようなアイデアを実現できました。
- 「マインクラフト映画」などの動画データをエンコード・復号可能です。(※ダウンサンプリングのため画質は粗くなります)
今後の拡張計画
- 暗号化: クリーパーや TNT のブロックパターンでデータを保護する仕組みの追加。
- 大容量対応: 複数のリージョンにまたがる巨大なワールド出力機能の実装。
コードの閲覧・貢献はこちらから
もし改善点やアイデアをご存知であれば、お気軽にお試しください!
🔗 sulfur リポジトリ (Codeberg)
(是非スターを押しておいてくださいね :))