Skip to content

存档系统练习 (Save System Exercises)

练习 1:分析 Base64 编码的空间开销

题目: GBA Flash 存储为 128KB(131,072 字节)。游戏存档需要通过 Base64 编码后存入 localStorage

  1. 计算 128KB 数据经 Base64 编码后的字符串长度(字节数)
  2. localStorage 在大多数浏览器中有 5MB 的容量限制。一个存档占用了百分之几?
  3. 如果游戏支持多个存档槽位(例如 3 个),是否还有足够空间?如果加上 ROM 的存档校验数据呢?
参考答案
  1. Base64 编码将每 3 个字节编码为 4 个字符。因此:

    • 原始数据:131,072 字节
    • Base64 字符数:ceil(131072 / 3) * 4 = 43690 * 4 = 174,760 字符
    • 加上可能的换行符(每 76 字符一个 \r\n):约增加 4,600 字节
    • 总计约 175KB,膨胀率约 33%
  2. 175KB / 5120KB(5MB)= 约 3.4%。单个存档占用很少,空间充裕。

  3. 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. 步骤 1-6 执行了什么操作?地址 0x0E00F000 的特殊性是什么?
  2. 步骤 7-10 执行了什么操作?
  3. JavaScript 的 Flash 命令状态机在这 10 步中经历了哪些状态转换?
参考答案
  1. 步骤 1-6 是扇区擦除操作。

    • 步骤 1-3:进入擦除模式(0x80 命令)
    • 步骤 4-5:确认擦除(重复解锁序列)
    • 步骤 6:向目标扇区地址 0x0E00F000 写入 0x0030,表示擦除该扇区

    地址 0x0E00F000 的特殊性:它位于 Flash 存储的扇区边界上。Flash 以扇区为单位擦除,0x0E00F000 对应的是从 0x0E00F000 开始的一个扇区(通常 4KB,即到 0x0E00FFFF)。擦除后该区域所有字节变为 0xFF

  2. 步骤 7-10 是字写入操作。

    • 步骤 7-8:解锁序列(0xAA→0x555, 0x55→0x2AA
    • 步骤 9:写入命令(0xA0
    • 步骤 10:将数据 0x1234 写入地址 0x0E00F000
  3. 状态机转换:

    步骤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 文件。

  1. 设计导出功能:将 localStorage 中的 Base64 存档数据转换为可供下载的 .sav 文件
  2. 设计导入功能:用户选择 .sav 文件后,将内容写入 WASM 内存的 Flash 区域
  3. 考虑边界情况:导入的文件大小不是 128KB 会怎样?Flash 数据包含非法值会怎样?
参考答案
  1. 导出功能实现:

    javascript
    function 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);
    }
  2. 导入功能实现:

    javascript
    function 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]);
  3. 边界情况处理:

    • 文件大小不匹配:拒绝导入并提示用户。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 APIwindow.showSaveFilePicker())替代传统的 <a download> 方式实现存档导出,提供更原生的文件保存体验。
  • 挑战 2:实现存档差异备份——每次存档时保存差异(diff),而非整个 128KB。当 localStorage 空间不足时自动清理旧备份。
  • 挑战 3:研究 IndexedDB 替代 localStorage 作为存档存储方案。对比两者的容量限制、读写性能和 API 复杂度。
  • 挑战 4:实现云存档同步——使用 Web Crypto API 对存档数据进行加密,然后通过 fetch() 发送到远端服务器。需要考虑冲突解决策略(如最后写入胜出 vs 手动合并)。
  • 挑战 5:分析 Pokemon Emerald 的实际存档数据格式。游戏将 128KB 分为哪些区域?存档头部包含什么信息?如何解析出 Trainer 名字、队伍 Pokemon 等数据?