Appearance
存档系统概念 (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 不是直接修改内存,而是先通过状态机判断这是命令还是实际数据写入:
- 命令阶段:写入值和地址匹配命令序列时,推进状态机
- 数据阶段:命令序列完成后,下一次写入被视为实际数据
- 完成:数据写入后回到空闲状态
内存监控机制
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+Blob或TextEncoder/TextDecoder优化。
自动存档触发
自动存档使用防抖 (debounce) 机制:
- 检测到 Flash 写入后,标记为"脏"(dirty)
- 启动一个 500ms 的计时器
- 如果在 500ms 内又有写入,重置计时器
- 计时器到期后执行实际的 Base64 编码和存储
- 页面关闭前(
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) 统一入口完成,包含两步:
- 遗留存档迁移:
migrateLegacyWasmSave()检测旧版扇区数据大小(LEGACY_WASM_SAVE_SECTOR_DATA_SIZES等),通过migrateLegacySaveBlock1/Block2()把数据搬运到当前布局 - 加密密钥修复:
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 Pokeblock、struct Mail、struct LinkBattleRecords、struct BattleFrontier 等。这些 padding 仅在 WASM=1 时生效:
- GBA 构建:结构体布局保持原样,生成标准 Emerald ROM
- WASM 构建:显式补齐填充,使
sizeof与 GBA 版严格相等
核心目标:存档是按固定字节偏移读写的二进制块。只要结构体布局一致,同一份
.sav就能在 GBA、模拟器、WASM 三者之间无缝互通。这也是上文"存档迁移"能可靠进行的基础。详见 源码兼容与适配 中的#if WASM守卫模式。
相关概念
- WASM 内存模型 -- Flash 存储在 WASM 线性内存中的位置
- WebAssembly -- WASM 内存共享与类型化数组
- 渲染管线 -- 其他内存区域的模拟与使用