【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その2 CHR の 2BPP フォーマットと iNES ヘッダ、パレット
2026.09.24

どもです。
前回「R-CHR の概要とアーキテクチャ」とのことで記事を書かせていただきましたが、今回はその続きで、いよいよ中身のデータの話になります。
ファミコンの絵って、そもそもどういうバイト列で ROM に入っているのか。これが分かっていないとエディタは作れないわけでして。ということで、今回は R-CHR の io / model 層、つまり CHR の 2BPP フォーマット、iNES ヘッダのパース、そしてパレットの実装をまとめた形になります。
egui の話は一切出てきません。純粋にバイトと戦う回です。
連載の目次
- 概要とアーキテクチャ
- NES の CHR フォーマット(2BPP)と iNES ヘッダ、パレット(この記事)
- egui で組む 3 パネル UI とバンクビュー
- ドットエディタの基礎とアンドゥ設計
- 図形ツール(Bresenham・楕円・塗りつぶし・スタンプ)
- PNG インポートと 3 つのマッピング戦略
- macOS ネイティブメニュー・多言語対応・GitHub Actions でのリリース
ファミコンのタイルは「8×8 ドット、4 色、16 バイト」
ファミコンのグラフィックは 8×8 ドットのタイルが最小単位です。各ドットは 0〜3 の 4 色しか持てず、1 ドットあたり 2 ビット。なので 8×8 = 64 ドット × 2 ビット = 128 ビット = 16 バイトで 1 タイルになります。
この「1 ドット 2 ビット」を 2BPP(2 bits per pixel) と呼びます。
ただし、その 2 ビットが素直に並んでいるわけではないのがポイントで、ビットプレーンという形式になっています。
1 タイル = 16 バイト バイト 0〜7 : プレーン 0(各ドットの下位ビット) 1 バイト = 1 行 バイト 8〜15 : プレーン 1(各ドットの上位ビット) 1 バイト = 1 行 各行の左端ドットがビット 7、右端がビット 0
つまり、ある行のドットの色を知りたければ、プレーン 0 の該当バイトとプレーン 1 の該当バイトから同じビット位置を取り出して、(上位 << 1) | 下位 で合成する必要があります。
具体例を出したほうが早いですね。ある行のプレーン 0 が 0b01100110、プレーン 1 が 0b00111100 だったとします。
列: 0 1 2 3 4 5 6 7 プレーン0: 0 1 1 0 0 1 1 0 (0x66) プレーン1: 0 0 1 1 1 1 0 0 (0x3C) ------------------------------------ 色 idx: 0 1 3 2 2 3 1 0
左から「0, 1, 3, 2, 2, 3, 1, 0」という色インデックスの行になります。これが 8 行分あって、1 タイルです。
なお、この 0〜3 という値はあくまで「パレット内の何番目か」であって、RGB ではありません。実際に何色で表示されるかはパレットが決めます。ここは後半で。
デコード:16 バイト → 8×8 の色インデックス
上の話をそのままコードにしたのが src/io/chr.rs の decode_tile() です。
/// 2BPP NES 形式で 16バイトを 8×8 ピクセル(インデックス 0〜3)にデコード
pub fn decode_tile(data: &[u8]) -> [[u8; 8]; 8] {
let mut pixels = [[0u8; 8]; 8];
for row in 0..8 {
let plane0 = data[row];
let plane1 = data[row + 8];
for col in 0..8 {
let bit = 7 - col;
let lo = (plane0 >> bit) & 1;
let hi = (plane1 >> bit) & 1;
pixels[row][col] = (hi << 1) | lo;
}
}
pixels
}
data[row] がプレーン 0、data[row + 8] がプレーン 1。左端がビット 7 なので bit = 7 - col でシフト量を求めています。これだけです。
R-CHR のドットエディタは 1 タイルだけでなく、フォーカスサイズに応じて 2×2 や 4×4 タイルといった N×N のブロックをまとめて編集します。そのための decode_block() も用意してあります。
pub fn decode_block(
chr_data: &[u8],
top_left_tile: usize,
tiles_per_row: usize,
n: usize,
) -> Vec<vec> {
let block_px = n * 8;
let mut pixels = vec![vec![0u8; block_px]; block_px];
let top_row = top_left_tile / tiles_per_row;
let top_col = top_left_tile % tiles_per_row;
for by in 0..n {
for bx in 0..n {
let tile_idx = (top_row + by) * tiles_per_row + (top_col + bx);
let tile_offset = tile_idx * 16;
if tile_offset + 16 > chr_data.len() {
continue; // 範囲外はスキップ(黒のまま)
}
let tile = decode_tile(&chr_data[tile_offset..tile_offset + 16]);
for py in 0..8 {
for px in 0..8 {
pixels[by * 8 + py][bx * 8 + px] = tile[py][px];
}
}
}
}
pixels
}
R-CHR ではバンクビューを 1 行 16 タイルで並べているので、タイルのグローバルインデックスから行・列は / 16 と % 16 で求まります。1 行 = 16 タイル × 16 バイト = 0x100 バイト。この「1 行 = 0x100」はアドレス表示やスクロール位置の計算で何度も出てくる数字です。
エンコード:1 ドットだけ書き換える
ドットエディタで 1 ドット塗るときは、タイル 16 バイト全部を作り直す必要はなく、該当する 2 バイト(プレーン 0 とプレーン 1 の同じ行)の同じビットだけを書き換えれば OK です。
/// タイルデータの指定ドット (px, py) にカラーインデックスを書き込む(2BPP NES 形式)
pub fn encode_dot(data: &mut [u8], px: usize, py: usize, color_idx: u8) {
let bit = 7 - px;
data[py] = (data[py] & !(1 << bit)) | ((color_idx & 1) << bit);
data[py + 8] = (data[py + 8] & !(1 << bit)) | (((color_idx >> 1) & 1) << bit);
}
& !(1 << bit) で該当ビットをいったん 0 にクリアしてから、色インデックスの下位ビット / 上位ビットをそれぞれ << bit して OR する、という典型的な read-modify-write ですね。
この encode_dot() が、ペンも直線も塗りつぶしもスタンプも PNG インポートも、全ての書き込みの最終出口になっています。どのツールも最終的には「(タイルオフセット, px, py, 色)」のリストを作って、この関数を呼ぶだけ。ここを 1 箇所に絞っておいたおかげで、あとからツールを増やすのがかなり楽でした。
iNES ヘッダのパース
さて、CHR データ単体(.bin)ならファイルの先頭から 16 バイトずつがそのままタイルなのですが、.nes ファイルにはヘッダと PRG-ROM(プログラム)が前に付いています。
一般的な .nes ファイルは iNES 形式で、構造はこうなっています。
[0x00-0x0F] ヘッダ 16 バイト [0x00-0x03] マジック "NES\x1A" [0x04] PRG-ROM バンク数(16KB 単位) [0x05] CHR-ROM バンク数(8KB 単位)、0 = CHR-RAM [0x06] フラグ6(マッパー下位 4bit / ミラーリング / バッテリー / トレーナー) [0x07] フラグ7(マッパー上位 4bit) [トレーナー] 512 バイト(フラグ6 の bit2 が立っている場合のみ) [PRG-ROM] prg_banks × 16KB [CHR-ROM] chr_banks × 8KB
エディタとして欲しいのは CHR-ROM の位置とサイズだけなのですが、そこに辿り着くには PRG-ROM のサイズとトレーナーの有無を見る必要があります。
pub fn parse_nes(data: &[u8]) -> Result<nesrom, parseerror=""> {
if data.len() < 16 {
return Err(ParseError::TooShort);
}
// マジックナンバー: "NES" + 0x1A
if &data[0..4] != b"NES\x1a" {
return Err(ParseError::InvalidMagic);
}
let prg_rom_banks = data[4];
let chr_rom_banks = data[5];
let flags6 = data[6];
let flags7 = data[7];
let vertical_mirroring = flags6 & 0x01 != 0;
let has_battery = flags6 & 0x02 != 0;
let has_trainer = flags6 & 0x04 != 0;
let mapper_lo = flags6 >> 4;
let mapper_hi = flags7 & 0xF0;
let mapper = mapper_hi | mapper_lo;
let header = NesHeader {
prg_rom_banks,
chr_rom_banks,
mapper,
vertical_mirroring,
has_battery,
};
// トレーナー(512バイト)がある場合はスキップ
let mut offset = 16;
if has_trainer {
offset += 512;
}
let prg_size = header.prg_rom_size();
let chr_size = header.chr_rom_size();
if data.len() < offset + prg_size + chr_size {
return Err(ParseError::TooShort);
}
let chr_data_offset = offset + prg_size;
let prg_rom = data[offset..chr_data_offset].to_vec();
let chr_rom = data[chr_data_offset..chr_data_offset + chr_size].to_vec();
Ok(NesRom { header, prg_rom, chr_rom, chr_data_offset })
}</nesrom,>
マジックナンバーの "NES\x1A" をチェックして、バンク数からサイズを計算し、トレーナー分をスキップして、CHR-ROM を切り出す。素直な実装です。
CHR-RAM のカートリッジ
chr_rom_banks が 0 のカートリッジは、CHR-ROM ではなく CHR-RAM を積んでいるタイプで、グラフィックデータは PRG-ROM の中に圧縮されていたりプログラムで生成されたりします。この場合、chr_rom は空のベクタになるので、R-CHR では「このファイルは CHR-RAM を使用しています」という旨のメッセージを表示するだけで、編集対象にはしていません。
保存時のために元ファイルの位置を覚えておく
NesRom に chr_data_offset というフィールドを持たせているのがちょっとしたポイントで、これは保存のためです。
R-CHR は .nes を開くとき、パース結果とは別に元ファイルのバイト列をまるごと raw_file_data として保持しています。保存時は、編集後の chr_rom を元ファイルの chr_data_offset の位置に上書きコピーして、ファイル全体を書き出します。
RomData::Nes(nes_rom) => {
let raw = self.raw_file_data.as_mut().ok_or(t.err_no_raw)?;
let start = nes_rom.chr_data_offset;
let end = start + nes_rom.chr_rom.len();
if end > raw.len() {
return Err(t.err_filesize.to_string());
}
raw[start..end].copy_from_slice(&nes_rom.chr_rom);
std::fs::write(path, raw as &[u8]).map_err(|e| format!("Save failed: {e}"))?;
}
ヘッダや PRG-ROM を自前で再構築しないので、iNES 2.0 の拡張フィールドやトレーナーが付いていても、そこは元のまま壊さず保存できます。エディタが触るのは CHR-ROM の範囲だけ、という割り切りですね。
RomData:.nes と .bin を同じように扱う
エディタから見ると「.nes の CHR-ROM 部分」も「.bin ファイル全体」も、どちらも「16 バイト × N 個のタイル列」です。なので、この 2 つを enum でラップして、CHR データへの参照だけを共通で取れるようにしています。
pub enum RomData {
/// iNES (.nes) ファイル
Nes(NesRom),
/// 生 CHR バイナリ (.bin) ファイル
Bin(Vec),
}
impl RomData {
pub fn chr_data(&self) -> &[u8] {
match self {
RomData::Nes(rom) => &rom.chr_rom,
RomData::Bin(data) => data,
}
}
pub fn chr_data_mut(&mut self) -> &mut [u8] {
match self {
RomData::Nes(rom) => &mut rom.chr_rom,
RomData::Bin(data) => data,
}
}
}
UI 側のコードはほぼ全て rom.chr_data() / rom.chr_data_mut() 経由で触るので、ファイル形式の違いを意識するのは開くときと保存するときだけになります。
ちなみに .zip を開いた場合は、zip クレートでアーカイブ内を走査して最初に見つかった .nes を取り出し、あとは .nes と同じ流れに乗せています。「ZIP から開いた場合は上書き保存先が無い」ので、file_path を None にして別名保存に誘導する形です。
「新規作成」は 0x4000 バイト(16KB)のゼロ埋めベクタを RomData::Bin として作るだけ。真っ黒な CHR が 1024 タイル分できあがります。
パレット:インデックスを実際の色にする
ここまでで「0〜3 の色インデックス」までは取り出せました。次はそれを RGB にする話です。
ファミコンのパレットは 2 段構えになっています。
- マスターパレット:ハードウェア(PPU)が出せる 64 色の固定テーブル
- パレットセット:その 64 色の中から 4 色を選んだセット。ゲーム側はこれを 4 セット持てる(背景用に 4、スプライト用に 4 ですが、エディタでは 4 セット × 4 色として扱います)
タイルの色インデックス 0〜3 は「現在のパレットセットの何番目か」を指し、その値がマスターパレットのインデックス(0x00〜0x3F)になっていて、それが最終的な RGB を決めます。
MasterPalette と .pal ファイル
マスターパレットは [[u8; 3]; 64] の RGB 配列です。デフォルト値は nesdev.org 準拠の 64 色を定数で持っています。
/// NES ハードウェアの標準 64色マスターパレット(RGB各 8bit)
/// 参考: https://www.nesdev.org/wiki/PPU_palettes
pub const NES_PALETTE: [[u8; 3]; 64] = [
[84, 84, 84 ], // 0x00
[0, 30, 116], // 0x01
[8, 16, 144], // 0x02
// ...(中略)
[0, 0, 0 ], // 0x3F
];
#[derive(Clone)]
pub struct MasterPalette {
pub colors: [[u8; 3]; 64],
}
impl MasterPalette {
/// 192 バイトの .pal データ(64色 × RGB 3バイト)からパース
pub fn from_pal_bytes(data: &[u8]) -> Option {
if data.len() < 192 {
return None;
}
let mut colors = [[0u8; 3]; 64];
for i in 0..64 {
colors[i] = [data[i * 3], data[i * 3 + 1], data[i * 3 + 2]];
}
Some(Self { colors })
}
}
.pal ファイルは 64 色 × RGB 3 バイト = 192 バイトの生バイナリで、これは YY-CHR やエミュレータ界隈で広く使われている形式です。ファミコンの実機は NTSC の信号で色を出しているので「正しい RGB」というものが存在せず、エミュレータや好みによって微妙に違うパレットが出回っています。なので R-CHR でも .pal を差し替えられるようにしてあります。
DatPalette と .dat ファイル
パレットセットのほうは [[u8; 4]; 4]、つまり 4 セット × 4 色で、各値はマスターパレットのインデックスです。
/// DAT パレット: 4セット × 4色 の NES パレットインデックス
#[derive(Clone)]
pub struct DatPalette {
/// sets[set_index][color_index] = NES パレットインデックス
pub sets: [[u8; 4]; 4],
}
impl DatPalette {
/// 指定セット・カラーインデックスの RGB を返す
pub fn color_rgb(&self, set: usize, color_idx: usize, master: &MasterPalette) -> [u8; 3] {
let nes_idx = self.sets[set][color_idx] as usize & 0x3F;
master.colors[nes_idx]
}
/// 16バイト以上の .dat データからパース(4セット × 4色)
pub fn from_dat_bytes(data: &[u8]) -> Option {
if data.len() < 16 {
return None;
}
let mut sets = [[0u8; 4]; 4];
for s in 0..4 {
for c in 0..4 {
sets[s][c] = data[s * 4 + c] & 0x3F;
}
}
Some(Self { sets })
}
/// 現在のパレットを 16バイトの .dat 形式で返す
pub fn to_dat_bytes(&self) -> [u8; 16] {
let mut out = [0u8; 16];
for s in 0..4 {
for c in 0..4 {
out[s * 4 + c] = self.sets[s][c];
}
}
out
}
}
.dat は YY-CHR 互換のフォーマットで、各バイトが NES パレットインデックス、先頭 16 バイトを 4 セット × 4 色として読みます。& 0x3F で 0〜63 に丸めているのは、上位ビットにゴミが入っていても落ちないようにするためです。
色を引くときは color_rgb(set, color_idx, master) で、「DAT パレットのセット → NES インデックス → マスターパレットの RGB」と 2 段階で解決します。
デフォルトパレットはバイナリに埋め込む
起動時に読み込むパレットは include_bytes! でバイナリに埋め込んでいます。
const DEFAULT_PAL: &[u8] = include_bytes!("../../assets/rchr.pal");
const DEFAULT_DAT: &[u8] = include_bytes!("../../assets/rchr.dat");
/// NES 標準 64色パレット(リセット用)
pub(super) const NES_PAL: &[u8] = include_bytes!("../../assets/nes.pal");
配布バイナリの横に assets/ を置いてもらう必要が無いので、単一ファイルでポンと渡せます。「マスターパレットをリセット」メニューは nes.pal を読み直すだけです。
CHR 全体を 1 枚の画像にする
最後に、バンクビュー用のレンダリング関数です。CHR データ全体を 幅 128px(16 タイル × 8px)の縦長 RGBA 画像として一気に描きます。
pub fn render_full_image(
chr_data: &[u8],
palette: &DatPalette,
palette_set: usize,
master: &MasterPalette,
) -> ColorImage {
let total_tiles = chr_data.len() / 16;
let total_rows = (total_tiles + 15) / 16;
const W: usize = 128;
let h = (total_rows * 8).max(8);
let mut rgba = vec![0u8; W * h * 4];
for tile_idx in 0..total_tiles {
let tile_offset = tile_idx * 16;
let tile = decode_tile(&chr_data[tile_offset..tile_offset + 16]);
let tile_col = tile_idx % 16;
let tile_row = tile_idx / 16;
for py in 0..8 {
for px in 0..8 {
let img_x = tile_col * 8 + px;
let img_y = tile_row * 8 + py;
let color_idx = tile[py][px] as usize;
let [r, g, b] = palette.color_rgb(palette_set, color_idx, master);
let i = (img_y * W + img_x) * 4;
rgba[i] = r;
rgba[i + 1] = g;
rgba[i + 2] = b;
rgba[i + 3] = 255;
}
}
}
ColorImage::from_rgba_unmultiplied([W, h], &rgba)
}
8KB の CHR-ROM なら 512 タイル、32 行、高さ 256px の画像になります。これを egui のテクスチャとして GPU に載せて、スクロールエリアの中に表示するのが次回のバンクビューの話です。
1 画面ぶんずつ表示を切り替えて見る方式と違って、R-CHR はこの「全部を 1 枚にする」方式のおかげで、CHR-ROM の先頭から末尾まで連続スクロールで眺められます。これ、地味に気持ち良いんですよ。
まとめ
- NES のタイルは 8×8 ドット・4 色・16 バイトの 2BPP ビットプレーン形式
- プレーン 0(下位)とプレーン 1(上位)から同じビット位置を取り出して合成する
decode_tile()/encode_dot()の 2 つが CHR 操作の基本で、全ツールの書き込みはencode_dot()に集約- iNES ヘッダはマジック、PRG / CHR バンク数、トレーナーの有無を見て CHR-ROM の位置を割り出す
- 保存時は元ファイルのバイト列に CHR-ROM だけを書き戻す
- パレットは「マスター 64 色」と「4 セット × 4 色」の 2 段構え。
.pal(192 バイト)と.dat(16 バイト)は YY-CHR 互換
昔のゲームを解析している人には当たり前の内容だとは思うのですが、自分で書いてみると「あぁ、だからファミコンのキャラは 4 色(実質 3 色 + 透明)なのか」というのが体で分かってくるのが面白いところです。
次回は egui の話に入ります。この render_full_image() で作った画像をどうやって表示して、どうやってタイルをクリックで選択できるようにしているのか、という 3 パネル UI とバンクビューの実装になります。
ではではぁ。
またまたぁ。













