Skip to content

存档系统概念 (Save System Concepts)

存档流程总览

pokeemerald-wasm 的存档系统通过 JavaScript 模拟 GBA Flash 存储芯片的行为,将游戏存档数据桥接到浏览器的 localStorage

GBA Flash 存储芯片

硬件特性

GBA 使用的 Flash 存储芯片(如 Macronix MX29L010 或 Panasonic MN63F805MNP)有以下特点:

特性
容量128KB (131,072 字节)
地址范围0x0E000000 - 0x0E01FFFF
数据总线宽度16 位
扇区大小因芯片型号而异,典型 4KB 或 64KB
擦除后值0xFFFF(全 1)
写入粒度单次可写入 16 位(一个字)

命令序列 (Command Sequences)

Flash 芯片不是直接可写的——需要通过特定的命令序列来解锁写入和擦除操作。这是为了防止意外写入。标准的命令序列如下:

关键命令:

命令序列说明
扇区擦除0xAA→0x555, 0x55→0x2AA, 0x80→0x555, 0xAA→0x555, 0x55→0x2AA, 0x30→扇区地址将一个扇区内容全部置为 0xFF
字写入0xAA→0x555, 0x55→0x2AA, 0xA0→0x555, 数据→目标地址向指定地址写入 16 位数据
芯片识别0xAA→0x555, 0x55→0x2AA, 0x90→0x555进入识别模式,读取厂商/设备 ID
芯片擦除0xAA→0x555, 0x55→0x2AA, 0x80→0x555, 0xAA→0x555, 0x55→0x2AA, 0x10→0x555擦除整个芯片

Flash 模拟策略

命令拦截

app.js 实现了一个状态机来追踪 Flash 命令序列。当游戏代码向 0x0E000000 区域写入数据时,JavaScript 不是直接修改内存,而是先通过状态机判断这是命令还是实际数据写入:

  1. 命令阶段:写入值和地址匹配命令序列时,推进状态机
  2. 数据阶段:命令序列完成后,下一次写入被视为实际数据
  3. 完成:数据写入后回到空闲状态

内存监控机制

Base64 序列化

为什么需要 Base64?

localStorage 只能存储字符串,而 Flash 数据是二进制的。Base64 编码将每 3 个字节转换为 4 个可打印 ASCII 字符:

原始数据:  [0x89, 0x50, 0x4E, 0x47, ...]  (128KB 二进制)
    ↓ Base64 编码
Base64串:  "iVBORw0KGgo..."  (约 170KB 字符串)
    ↓ localStorage.setItem()
存储:      localStorage["pokemon_emerald_save"] = "iVBORw0KGgo..."

128KB 的 Flash 数据经 Base64 编码后约为 170KB(膨胀约 33%)。

编码/解码流程

javascript
// 编码:Flash 内存 → Base64 字符串
function encodeFlashToBase64() {
  const flashData = new Uint8Array(sharedBuffer, FLASH_START, FLASH_SIZE);
  let binary = '';
  for (let i = 0; i < flashData.length; i++) {
    binary += String.fromCharCode(flashData[i]);
  }
  return btoa(binary);
}

// 解码:Base64 字符串 → Flash 内存
function decodeBase64ToFlash(base64String) {
  const binary = atob(base64String);
  const flashData = new Uint8Array(sharedBuffer, FLASH_START, FLASH_SIZE);
  for (let i = 0; i < binary.length; i++) {
    flashData[i] = binary.charCodeAt(i);
  }
}

性能提示:对于 128KB 的数据,btoa()/atob() 加上字符串拼接可能不够高效。生产环境中可考虑使用 FileReader + BlobTextEncoder/TextDecoder 优化。

自动存档触发

自动存档使用防抖 (debounce) 机制:

  1. 检测到 Flash 写入后,标记为"脏"(dirty)
  2. 启动一个 500ms 的计时器
  3. 如果在 500ms 内又有写入,重置计时器
  4. 计时器到期后执行实际的 Base64 编码和存储
  5. 页面关闭前(beforeunload 事件)强制保存

这样避免了游戏连续写入多个扇区时频繁触发序列化操作。

存档文件导入与导出

除了 localStorage 自动持久化,pokeemerald-wasm 还提供标准 .sav 文件的导入/导出,实现与真实 GBA 卡带、其他模拟器(如 mGBA)之间的存档互通。页面新增了专门的存档控制区:

html
<div class="save-controls">
  <button id="download-save" type="button">Download .sav</button>
  <label class="upload-save" for="upload-save">Upload .sav</label>
  <input id="upload-save" type="file" accept=".sav,.srm,application/octet-stream" />
</div>
  • 导出:将 128KB Flash 区域封装为 Blob,通过 <a download="pokeemerald.sav"> 触发浏览器下载
  • 导入<input type="file"> 读取文件,校验大小后规范化,写入 Flash 与 localStorage,最后 restartWithSave() 重新加载游戏

意义:用户可在不同设备/浏览器之间转移存档,也能直接导入从真实 GBA 导出或由模拟器生成的标准 Emerald .sav

存档迁移与规范化

WASM 版本在不同发布阶段,存档的内部布局(扇区数据大小、块大小)曾发生变化。直接加载旧版存档会导致数据错位,因此 app.js 在导入加载时统一执行规范化(normalize):

规范化由 normalizeSaveForCurrentBuild(bytes) 统一入口完成,包含两步:

  1. 遗留存档迁移migrateLegacyWasmSave() 检测旧版扇区数据大小(LEGACY_WASM_SAVE_SECTOR_DATA_SIZES 等),通过 migrateLegacySaveBlock1/Block2() 把数据搬运到当前布局
  2. 加密密钥修复repairStaleBagEncryptionSave() 处理因 SaveBlock2 加密密钥(SAVE_BLOCK2_ENCRYPTION_KEY_OFFSET)变化导致的背包物品数量错乱

设计要点:规范化是幂等的——已经匹配当前布局的存档会原样返回(normalized ?? bytes),不会重复改写。这是 .sav 能在版本之间安全升级的关键。

存档结构对齐(#if WASM padding)

要让 .sav 与真实 GBA 互通,WASM 编译出的存档结构体必须与 GBA ARM 编译时逐字节一致。但 Clang(wasm32)和 agbcc(ARM)对结构体末尾填充(trailing padding)的处理存在细微差异,会导致 sizeof 不一致。

为此,include/global.h 在多个存档相关结构体中加入了 #if WASM 显式填充:

c
struct Pokeblock
{
    u8 spicy;
    u8 dry;
    u8 sweet;
    u8 bitter;
    u8 sour;
    u8 feel;
#if WASM
    u8 wasmPadding;   // 补齐到与 GBA 版一致的 sizeof
#endif
};

struct Mail
{
    /* ... */
    u16 itemId;
#if WASM
    u8 wasmPadding[2];
#endif
};

涉及的结构体包括 struct Pokeblockstruct Mailstruct LinkBattleRecordsstruct BattleFrontier 等。这些 padding 仅在 WASM=1 时生效:

  • GBA 构建:结构体布局保持原样,生成标准 Emerald ROM
  • WASM 构建:显式补齐填充,使 sizeof 与 GBA 版严格相等

核心目标:存档是按固定字节偏移读写的二进制块。只要结构体布局一致,同一份 .sav 就能在 GBA、模拟器、WASM 三者之间无缝互通。这也是上文"存档迁移"能可靠进行的基础。详见 源码兼容与适配 中的 #if WASM 守卫模式。

相关概念