Appearance
存档系统练习 (Save System Exercises)
练习 1:分析 Base64 编码的空间开销
题目: GBA Flash 存储为 128KB(131,072 字节)。游戏存档需要通过 Base64 编码后存入 localStorage。
- 计算 128KB 数据经 Base64 编码后的字符串长度(字节数)
localStorage在大多数浏览器中有 5MB 的容量限制。一个存档占用了百分之几?- 如果游戏支持多个存档槽位(例如 3 个),是否还有足够空间?如果加上 ROM 的存档校验数据呢?
参考答案
Base64 编码将每 3 个字节编码为 4 个字符。因此:
- 原始数据:131,072 字节
- Base64 字符数:
ceil(131072 / 3) * 4 = 43690 * 4 = 174,760字符 - 加上可能的换行符(每 76 字符一个
\r\n):约增加 4,600 字节 - 总计约 175KB,膨胀率约 33%
175KB / 5120KB(5MB)= 约 3.4%。单个存档占用很少,空间充裕。
3 个存档:175KB * 3 = 525KB,占 5MB 的 约 10.3%,仍有大量空间。ROM 校验数据通常只有几十字节(如 SHA-256 哈希为 32 字节),对总量影响微乎其微。
需要注意的是,
localStorage的 5MB 限制是按**源(origin)**计算的,包括所有键值对的总和。如果同一域名下还有其他数据(如设置、按键映射等),实际可用空间会略少。
练习 2:追踪一次完整的存档写入流程
题目: 当玩家在绿宝石中保存游戏时,C 代码执行以下 Flash 操作序列。请逐步追踪发生了什么:
步骤1: *(vu16*)0x0E000555 = 0x00AA
步骤2: *(vu16*)0x0E0002AA = 0x0055
步骤3: *(vu16*)0x0E000555 = 0x0080
步骤4: *(vu16*)0x0E000555 = 0x00AA
步骤5: *(vu16*)0x0E0002AA = 0x0055
步骤6: *(vu16*)0x0E00F000 = 0x0030
步骤7: *(vu16*)0x0E000555 = 0x00AA
步骤8: *(vu16*)0x0E0002AA = 0x0055
步骤9: *(vu16*)0x0E000555 = 0x00A0
步骤10: *(vu16*)0x0E00F000 = 0x1234- 步骤 1-6 执行了什么操作?地址
0x0E00F000的特殊性是什么? - 步骤 7-10 执行了什么操作?
- JavaScript 的 Flash 命令状态机在这 10 步中经历了哪些状态转换?
参考答案
步骤 1-6 是扇区擦除操作。
- 步骤 1-3:进入擦除模式(
0x80命令) - 步骤 4-5:确认擦除(重复解锁序列)
- 步骤 6:向目标扇区地址
0x0E00F000写入0x0030,表示擦除该扇区
地址
0x0E00F000的特殊性:它位于 Flash 存储的扇区边界上。Flash 以扇区为单位擦除,0x0E00F000对应的是从0x0E00F000开始的一个扇区(通常 4KB,即到0x0E00FFFF)。擦除后该区域所有字节变为0xFF。- 步骤 1-3:进入擦除模式(
步骤 7-10 是字写入操作。
- 步骤 7-8:解锁序列(
0xAA→0x555,0x55→0x2AA) - 步骤 9:写入命令(
0xA0) - 步骤 10:将数据
0x1234写入地址0x0E00F000
- 步骤 7-8:解锁序列(
状态机转换:
步骤1: Idle → Unlock1 (0xAA→0x555 匹配) 步骤2: Unlock1 → Unlock2 (0x55→0x2AA 匹配) 步骤3: Unlock2 → Command (0x80→0x555 = 擦除命令) Command → EraseSetup 步骤4: EraseSetup → EraseUnlock1 (0xAA→0x555 匹配) 步骤5: EraseUnlock1 → EraseUnlock2 (0x55→0x2AA 匹配) 步骤6: EraseUnlock2 → SectorErase (0x30→扇区地址) 执行扇区擦除 → 回到 Idle 步骤7: Idle → Unlock1 (0xAA→0x555 匹配) 步骤8: Unlock1 → Unlock2 (0x55→0x2AA 匹配) 步骤9: Unlock2 → Command (0xA0→0x555 = 写入命令) Command → WriteSetup 步骤10: WriteSetup → DataWritten (数据 0x1234 → 0x0E00F000) 执行数据写入 → 回到 Idle典型的 Flash 存档流程是先擦除目标扇区,再写入新数据。游戏通常不会只写入一个字,而是连续执行多次写入命令来保存完整的存档数据块。
练习 3:设计存档导出/导入功能
题目: 当前存档只保存在 localStorage 中。用户希望导出存档为 .sav 文件(与 VBA 等模拟器兼容),并支持导入 .sav 文件。
- 设计导出功能:将
localStorage中的 Base64 存档数据转换为可供下载的.sav文件 - 设计导入功能:用户选择
.sav文件后,将内容写入 WASM 内存的 Flash 区域 - 考虑边界情况:导入的文件大小不是 128KB 会怎样?Flash 数据包含非法值会怎样?
参考答案
导出功能实现:
javascriptfunction exportSave() { const base64Data = localStorage.getItem('pokemon_emerald_save'); if (!base64Data) { alert('没有找到存档数据'); return; } // Base64 解码为二进制 const binary = atob(base64Data); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } // 创建 Blob 并触发下载 const blob = new Blob([bytes], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'pokemon_emerald.sav'; a.click(); URL.revokeObjectURL(url); }导入功能实现:
javascriptfunction importSave(file) { const reader = new FileReader(); reader.onload = function(e) { const data = new Uint8Array(e.target.result); // 校验文件大小 if (data.length !== 131072) { alert('存档文件大小不正确(需要 128KB)'); return; } // 写入 WASM 内存的 Flash 区域 const flashView = new Uint8Array(sharedBuffer, FLASH_START, FLASH_SIZE); flashView.set(data); // 同步到 localStorage const base64 = encodeFlashToBase64(); localStorage.setItem('pokemon_emerald_save', base64); alert('存档导入成功'); }; reader.readAsArrayBuffer(file); } // 文件选择器 const input = document.createElement('input'); input.type = 'file'; input.accept = '.sav'; input.onchange = (e) => importSave(e.target.files[0]);边界情况处理:
- 文件大小不匹配:拒绝导入并提示用户。GBA Flash 大小固定为 128KB(某些游戏使用 64KB 或 512B EEPROM,但绿宝石使用 128KB Flash)。
- 文件过小:可以补零到 128KB 后导入(Flash 擦除后本身就是全
0xFF),但建议直接拒绝以避免混淆。 - 文件过大:截断前 128KB 或拒绝导入。截断可能丢失数据,建议拒绝。
- 非法 Flash 值:Flash 存储的值本质上没有"非法"一说——任何 16 位值都可以存储。但如果存档数据被篡改,游戏可能会在加载时校验失败。可以添加一个简单的校验和验证:javascript
function validateSaveData(data) { // 绿宝石的存档头部通常包含特定的魔数 // 这里仅做基本的空数据检查 const isEmpty = data.every(byte => byte === 0xFF); if (isEmpty) { console.warn('导入的存档似乎是空白的(全 0xFF)'); } return true; }
拓展挑战
- 挑战 1:使用
File System Access API(window.showSaveFilePicker())替代传统的<a download>方式实现存档导出,提供更原生的文件保存体验。 - 挑战 2:实现存档差异备份——每次存档时保存差异(diff),而非整个 128KB。当 localStorage 空间不足时自动清理旧备份。
- 挑战 3:研究 IndexedDB 替代 localStorage 作为存档存储方案。对比两者的容量限制、读写性能和 API 复杂度。
- 挑战 4:实现云存档同步——使用 Web Crypto API 对存档数据进行加密,然后通过
fetch()发送到远端服务器。需要考虑冲突解决策略(如最后写入胜出 vs 手动合并)。 - 挑战 5:分析 Pokemon Emerald 的实际存档数据格式。游戏将 128KB 分为哪些区域?存档头部包含什么信息?如何解析出 Trainer 名字、队伍 Pokemon 等数据?