【Rockman2 hack】C++でゲームのセーブステートを作る――壊れたデータで現在のゲームを壊さない設計

AIで作成 C/C++
AIで作成

固定長データを使っている理由

ロード処理

最初に要素数を読み取ります。

std::uint32_t count{};

if (!reader.ReadU32(count) ||
    count > kMaximumEntityCount)
{
    return false;
}

ここで重要なのは、メモリを確保する前に上限を検査していることです。

改ざんファイルに次のような値が入っていた場合、

count = 4,294,967,295

そのまま処理すると、巨大なメモリ確保や数十億回のループが発生する可能性があります。

現在は最大値を設定しています。

constexpr std::uint32_t kMaximumEntityCount = 1'024;

上限を通過したら、一時的なvectorへ復元します。

EntityManagerState loaded{};

loaded.records.reserve(count);

reserve()とresize()の違い

ここは重要です。

loaded.records.reserve(count);

reserve()は、将来追加する要素のためにメモリを確保するだけです。

size     = 0
capacity = count

要素自体はまだ存在しません。そのため、レコードはループ内で追加します。

EntityStateRecord record{};

// recordの各フィールドを読み取る

loaded.records.emplace_back(std::move(record));

一方、バイト配列であるpayloadにはresize()を使用します。

record.payload.resize(payload_size);

reader.ReadBytes(
    std::span<std::uint8_t>(record.payload));

ReadBytes()は、書き込み先となる実在する要素が必要です。

record.payload.reserve(payload_size); // capacityだけ増える

これだけではsize()がゼロなので、読み込み先として使用できません。

record.payload.resize(payload_size);  // 要素が実際に作られる

こちらなら、payload_sizeバイト分の書き込み先が用意されます。

復元途中で失敗した場合

現在の実装では、直接this->recordsを書き換えません。

EntityManagerState loaded{};

まず、すべてを一時オブジェクトloadedへ復元します。

loaded.records.reserve(count);

for (...)
{
    EntityStateRecord record{};

    // 読み取りと検証

    loaded.records.emplace_back(std::move(record));
}

そして、読み取りと検証がすべて成功してから入れ替えます。

if (!loaded.IsValid())
{
    return false;
}

*this = std::move(loaded);

途中でファイルが途切れていた場合は、

現在の正常な状態
        ↓
一時オブジェクトへの復元に失敗
        ↓
現在の状態は変更されない

となります。

これはデータベースのトランザクションに近い考え方です。ロードに失敗しても、中途半端な状態がゲームへ適用されません。

std::moveは何をしているのか

次の部分では、

loaded.records.emplace_back(std::move(record));

record.payloadが所有するメモリを、新しい要素へ移しています。

大きなpayloadを丸ごとコピーするのではなく、所有権を引き渡せるため効率的です。

最終的な、

*this = std::move(loaded);

についても同様で、復元済みのrecordsを余計に複製せず、現在の状態へ移しています。

BGM音声チャンネルも同じ方式

BGMのエンコード用クラスでも同じ構造を使用しています。

writer.WriteU8(
    static_cast<std::uint8_t>(voices.size()));

for (const auto& voice_state : voices)
{
    writer.WriteU8(
        static_cast<std::uint8_t>(voice_state.voice));

    writer.WriteU64(voice_state.position);
    writer.WriteI32(voice_state.volume);
}

ロード時は次のようになります。

std::uint8_t voice_count{};

if (!reader.ReadU8(voice_count) ||
    voice_count > kApuVoiceCount)
{
    return false;
}

loaded.voices.reserve(voice_count);

for (std::uint8_t i = 0; i < voice_count; ++i)
{
    BgmVoiceTransportState voice_state{};

    // voice、再生位置、音量を読み取る

    loaded.voices.emplace_back(
        std::move(voice_state));
}

ファミコン相当の音声チャンネル数は最大5つなので、voice_countがそれを超えていたら不正データとして拒絶できます。

バイト配列と構造体配列の違い

std::vector<std::uint8_t>なら、要素は純粋な1バイトなので一括で保存できます。

writer.WriteU32(
    static_cast<std::uint32_t>(payload.size()));

writer.WriteBytes(payload);

一方、次のような構造体の配列は、原則として生メモリをそのまま保存しません。

std::vector<EntityStateRecord>

フィールドごとに固定の形式で保存します。

writer.WriteU16(type);
writer.WriteU32(instance_id);
writer.WriteU16(component_version);

これにより、コンパイラーによるパディング、メンバー配置、ポインター、プラットフォーム差などからセーブ形式を切り離せます。

まとめると、可変長配列の復元は、

長さを読む
  ↓
長さが安全な範囲か検証
  ↓
一時vectorの容量・要素を確保
  ↓
指定された個数だけ要素を復元
  ↓
全体を検証
  ↓
成功時だけ現在の状態と入れ替える

という流れです。

つまり.savへ保存しているのはstd::vectorではなく、vectorをもう一度組み立てるための設計図ということですね(・∀・)b


リトルエンディアンを固定する利点

複数バイトの整数を、どの順番で並べるかも決める必要があります。

例えば次の32-bit値を保存するとします。

0x12345678

リトルエンディアンでは、下位バイトから並べます。

78 56 34 12

ビッグエンディアンでは逆です。

12 34 56 78

CPUのメモリをそのままファイルへ書くと、実行環境のエンディアンに依存してしまいます。そこで、StateIO.h では順番を明示しています。

bool WriteU32(std::uint32_t value)
{
    const std::uint8_t bytes[] = {
        static_cast<std::uint8_t>(value),
        static_cast<std::uint8_t>(value >> 8),
        static_cast<std::uint8_t>(value >> 16),
        static_cast<std::uint8_t>(value >> 24)
    };
    return WriteBytes(bytes);
}

これにより、CPUがどの形式で整数を保持していても、.sav 内では必ず同じ順序になります。

読み込み側も同じ規則で復元します。

value =
    static_cast<std::uint32_t>(bytes[0]) |
    (static_cast<std::uint32_t>(bytes[1]) << 8) |
    (static_cast<std::uint32_t>(bytes[2]) << 16) |
    (static_cast<std::uint32_t>(bytes[3]) << 24);

なぜメモリをそのまま保存しないのか

次のような方法は一見簡単です。

stream.write(
    reinterpret_cast<const char*>(&state),
    sizeof(state));

しかし、これは次の情報まで暗黙的にファイル形式へ持ち込んでしまいます。

  • 構造体のパディング
  • メンバーの配置
  • コンパイラのABI
  • ポインターサイズ
  • CPUのエンディアン
  • boolや列挙型の表現
  • STLコンテナの内部構造

クラスへメンバーを追加しただけで、以前のセーブデータを正しく読めなくなる可能性もあります。

固定幅整数と明示的なエンコードを使えば、

Magic                    8 bytes
Format version           4 bytes
Compatibility ID         8 bytes
Sequence ID              4 bytes
Scene ID                 4 bytes
Payload length           4 bytes
CRC-32                    4 bytes
Scene payload             N bytes

という正規形式を定義できます。

CRCとの相性もよい

CRC-32を計算するときも、整数のバイト順が決まっている必要があります。

同じ数値でも環境ごとにバイト列が違えば、CRCの結果も変わってしまいます。今回の実装では、互換性IDや各種IDをリトルエンディアンとしてCRCへ投入しています。

したがって、

同じ論理データからは、どの対応環境でも同じバイト列とCRCが作られる

という性質を得られます。


巡回冗長検査(CRC)について

巡回冗長検査(CRC)って、結局「入力バイト列」に対して決定的に計算される関数なので、固定長データとはめちゃ相性が良い(というか実務的にはほぼ必須)っていう話ですね。整理してみます。

CRCの本質
CRCはデータをビット列(多項式)とみなして、生成多項式で割った余りを検査値にする仕組み。ポイントは「入力バイト列が1ビットでも違えば、出力(CRC値)も基本的に変わる」ということ。逆に言えば、同じ入力バイト列なら必ず同じCRC値になる、という決定性がある。

ここでintの可変長問題が刺さってくる
さっき話した int vs std::int32_t の話そのまま。同じ論理値「12345」でも、

  • 環境Aでは2バイト表現
  • 環境Bでは4バイト表現
  • 環境Cでは8バイト表現

こうなると、CRCに渡すバイト列自体が違うので、CRC値も当然変わる。「論理的に同じデータなのにチェックサムが合わない」という事故が起きる。これはCRCのアルゴリズムが壊れてるわけじゃなくて、そもそも入力が違ってた、という話。

だからプロトコルやファイル形式は固定長で定義される
ZIP、PNG、Ethernetフレームなど、CRCを使う仕様はだいたいフィールドを uint32_t 的な固定長で規定してる。これは「送信側と受信側でバイト表現が完全一致すること」を保証したいから。固定長 + エンディアン統一(ネットワークバイトオーダーとか)がセットで要求されるのも同じ理由で、幅だけ固定してもバイト順序がズレたら結局違う入力になっちゃう。

もう一つの落とし穴: 構造体パディング
メンバを全部 int32_t にしても、構造体をそのまま memcpy してCRCにかけると、アライメント都合で挿入されるパディングバイトが未定義値のまま混入することがある。これも「固定長にしたつもりが実は入力バイト列が環境依存」というパターン。フィールドごとに直列化するか #pragma pack 等で明示的に詰める必要がある。

図にすると、このズレがどこで起きるか一目で分かると思います。

まさに「同じ値」の入り口(左)は一緒でも、真ん中のバイト表現が固定か可変かで、出口のCRC結果が変わっちゃうっていう構図です。

なので実務的な結論としては、CRCなどのチェックサムをかける対象データは

  • フィールドを std::uint32_t 等の固定長型で定義する
  • エンディアンも明示的に揃える(または統一する)
  • 構造体をまるごとダンプするならパディングを排除する(field単位で直列化 or pack)

っていう3点セットが「再現可能なCRC」の前提条件になる、という話でした(・∀・)ノ


固定幅整数は「各項目が何バイトか」を保証し、リトルエンディアン指定は「そのバイトをどの順番で並べるか」を保証する。この2つによって、C++のメモリ表現ではなく、アプリケーション自身が管理できる永続的なファイル形式になる。


最後にmm2hackで実際に使っているステートセーブ/ロード処理の計算処理です。
参考になるかは分かりませんが(^^;

namespace
{
    constexpr std::array<std::uint8_t, 8> kSaveMagic{
        'M', 'M', '2', 'S', 'A', 'V', 'E', 0
    };
    constexpr std::uint32_t kCrc32Polynomial = 0xEDB88320U;

    void AccumulateCrc32(std::uint32_t& checksum, std::uint8_t value) noexcept
    {
        checksum ^= value;
        for (int bit = 0; bit < 8; ++bit)
        {
            const std::uint32_t mask =
                0U - static_cast<std::uint32_t>(checksum & 1U);
            checksum = (checksum >> 1) ^ (kCrc32Polynomial & mask);
        }
    }

    void AccumulateCrc32(std::uint32_t& checksum, std::uint32_t value) noexcept
    {
        for (int byte = 0; byte < 4; ++byte)
        {
            AccumulateCrc32(checksum, static_cast<std::uint8_t>(value));
            value >>= 8;
        }
    }

    void AccumulateCrc32(std::uint32_t& checksum, std::uint64_t value) noexcept
    {
        for (int byte = 0; byte < 8; ++byte)
        {
            AccumulateCrc32(checksum, static_cast<std::uint8_t>(value));
            value >>= 8;
        }
    }

    std::uint32_t ComputeSaveChecksum(
        std::uint64_t compatibility_id,
        std::int32_t sequence_id,
        std::int32_t scene_id,
        std::span<const std::uint8_t> payload) noexcept
    {
        std::uint32_t checksum = 0xFFFFFFFFU;
        AccumulateCrc32(checksum, compatibility_id);
        AccumulateCrc32(checksum, std::bit_cast<std::uint32_t>(sequence_id));
        AccumulateCrc32(checksum, std::bit_cast<std::uint32_t>(scene_id));
        AccumulateCrc32(checksum, static_cast<std::uint32_t>(payload.size()));
        for (const std::uint8_t byte : payload)
        {
            AccumulateCrc32(checksum, byte);
        }
        return ~checksum;
    }

    LoadResult ClassifyReadFailure(const std::istream& stream) noexcept
    {
        return stream.bad() ? LoadResult::IoError : LoadResult::Corrupt;
    }
}

bool SaveSystem::Save(const std::wstring& path, const SaveData& data)
{
    if (data.scenePayload.size() > kMaximumPayloadSize ||
        data.scenePayload.size() > std::numeric_limits<std::uint32_t>::max())
    {
        return false;
    }

    const fs::path savepath(path);
    fs::path temporary_path = savepath;
    temporary_path += L".tmp";

    try
    {
        if (const auto parent = savepath.parent_path(); !parent.empty())
        {
            fs::create_directories(parent);
        }
    }
    catch (const fs::filesystem_error&)
    {
        return false;
    }

    std::ofstream ofs(temporary_path, std::ios::binary | std::ios::trunc);
    if (!ofs)
    {
        return false;
    }

    StateWriter writer(ofs);
    const std::uint32_t checksum = ComputeSaveChecksum(
        data.gameContentCompatibilityId,
        data.sequenceID,
        data.sceneID,
        data.scenePayload);
    const bool wroteAll =
        writer.WriteBytes(kSaveMagic) &&
        writer.WriteU32(config::SystemConfig::kCurrentSaveVersion) &&
        writer.WriteU64(data.gameContentCompatibilityId) &&
        writer.WriteI32(data.sequenceID) &&
        writer.WriteI32(data.sceneID) &&
        writer.WriteU32(static_cast<std::uint32_t>(data.scenePayload.size())) &&
        writer.WriteU32(checksum) &&
        writer.WriteBytes(data.scenePayload);
    ofs.flush();
    const bool flushed = ofs.good();
    ofs.close();
    if (!wroteAll || !flushed || ofs.fail())
    {
        std::error_code ignored;
        fs::remove(temporary_path, ignored);
        return false;
    }

    if (!::MoveFileExW(
        temporary_path.c_str(),
        savepath.c_str(),
        MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
    {
        std::error_code ignored;
        fs::remove(temporary_path, ignored);
        return false;
    }
    return true;
}

LoadResult SaveSystem::Load(const std::wstring& path, SaveData& outData)
{
    std::error_code path_error;
    if (!fs::exists(path, path_error))
    {
        return path_error ? LoadResult::IoError : LoadResult::FileNotFound;
    }
    if (!fs::is_regular_file(path, path_error) || path_error)
    {
        return LoadResult::IoError;
    }

    std::ifstream ifs(path, std::ios::binary);
    if (!ifs)
    {
        return LoadResult::IoError;
    }

    StateReader reader(ifs);

    std::array<std::uint8_t, kSaveMagic.size()> magic{};
    std::uint32_t fileVersion{};
    std::uint64_t compatibilityId{};
    std::int32_t sequenceID{};
    std::int32_t sceneID{};
    std::uint32_t payloadSize{};
    std::uint32_t expectedChecksum{};

    if (!reader.ReadBytes(magic))
    {
        return ClassifyReadFailure(ifs);
    }
    if (magic != kSaveMagic)
    {
        return LoadResult::Corrupt;
    }
    if (!reader.ReadU32(fileVersion))
    {
        return ClassifyReadFailure(ifs);
    }
    if (fileVersion != config::SystemConfig::kCurrentSaveVersion)
    {
        return LoadResult::UnsupportedVersion;
    }
    if (!reader.ReadU64(compatibilityId) ||
        !reader.ReadI32(sequenceID) ||
        !reader.ReadI32(sceneID) ||
        !reader.ReadU32(payloadSize))
    {
        return ClassifyReadFailure(ifs);
    }
    if (payloadSize > kMaximumPayloadSize)
    {
        return LoadResult::Corrupt;
    }
    if (!reader.ReadU32(expectedChecksum))
    {
        return ClassifyReadFailure(ifs);
    }

    SaveData loaded{};
    loaded.gameContentCompatibilityId = compatibilityId;
    loaded.sequenceID = sequenceID;
    loaded.sceneID = sceneID;
    loaded.scenePayload.resize(payloadSize);
    if (!reader.ReadBytes(loaded.scenePayload))
    {
        return ClassifyReadFailure(ifs);
    }

    if (ComputeSaveChecksum(
        compatibilityId,
        sequenceID,
        sceneID,
        loaded.scenePayload) !=
        expectedChecksum)
    {
        return LoadResult::Corrupt;
    }

    const int trailing_byte = ifs.peek();
    if (ifs.bad())
    {
        return LoadResult::IoError;
    }
    if (trailing_byte != std::char_traits<char>::eof())
    {
        return LoadResult::Corrupt;
    }
    if (compatibilityId != config::SystemConfig::kGameContentCompatibilityId)
    {
        return LoadResult::IncompatibleContent;
    }

    outData = std::move(loaded);
    return LoadResult::Success;
}

コメント

タイトルとURLをコピーしました