Skip to content

存档系统代码导读 (Save System Code Walkthrough)

本节深入 app.js 中的存档系统代码,包括 Flash 命令模拟、Base64 序列化、localStorage 持久化的完整实现。

Flash 命令状态机

状态定义与常量

javascript
// Flash 存储参数
const FLASH_START = 0x0E000000;        // Flash 起始地址
const FLASH_SIZE  = 128 * 1024;        // 128KB
const SECTOR_SIZE = 4 * 1024;          // 4KB 扇区
const FLASH_SECTORS = FLASH_SIZE / SECTOR_SIZE;  // 32 个扇区

// Flash 命令地址(基于 Flash 基地址的偏移)
const CMD_ADDR_1 = 0x555;   // 命令序列地址 1
const CMD_ADDR_2 = 0x2AA;   // 命令序列地址 2

// Flash 命令值
const CMD_UNLOCK_1 = 0x00AA;
const CMD_UNLOCK_2 = 0x0055;
const CMD_ERASE    = 0x0080;
const CMD_WRITE    = 0x00A0;
const CMD_SECTOR_ERASE = 0x0030;
const CMD_CHIP_ERASE  = 0x0010;
const CMD_ID_ENTER    = 0x0090;

// 状态机状态
const FLASH_IDLE        = 0;
const FLASH_UNLOCK_1    = 1;  // 收到 0xAA → 0x555
const FLASH_UNLOCK_2    = 2;  // 收到 0x55 → 0x2AA
const FLASH_COMMAND     = 3;  // 等待命令码
const FLASH_ERASE_SETUP = 4;  // 擦除模式,等待第二次解锁
const FLASH_ERASE_U1    = 5;  // 擦除解锁 1
const FLASH_ERASE_U2    = 6;  // 擦除解锁 2
const FLASH_WRITE_READY = 7;  // 写入模式,下一个写入是数据
const FLASH_ID_MODE     = 8;  // 识别模式

状态机核心逻辑

javascript
let flashState = FLASH_IDLE;
let saveDirty = false;
let saveTimer = null;

function handleFlashWrite(address, value) {
  // 将绝对地址转换为 Flash 偏移
  const offset = address - FLASH_START;

  // 16位对齐
  const wordOffset = offset / 2;

  switch (flashState) {
    case FLASH_IDLE:
      if (wordOffset === CMD_ADDR_1 && value === CMD_UNLOCK_1) {
        flashState = FLASH_UNLOCK_1;
      }
      break;

    case FLASH_UNLOCK_1:
      if (wordOffset === CMD_ADDR_2 && value === CMD_UNLOCK_2) {
        flashState = FLASH_UNLOCK_2;
      } else {
        flashState = FLASH_IDLE;
      }
      break;

    case FLASH_UNLOCK_2:
      if (wordOffset === CMD_ADDR_1) {
        switch (value) {
          case CMD_ERASE:
            flashState = FLASH_ERASE_SETUP;
            break;
          case CMD_WRITE:
            flashState = FLASH_WRITE_READY;
            break;
          case CMD_ID_ENTER:
            flashState = FLASH_ID_MODE;
            break;
          default:
            flashState = FLASH_IDLE;
        }
      } else {
        flashState = FLASH_IDLE;
      }
      break;

    case FLASH_ERASE_SETUP:
      if (wordOffset === CMD_ADDR_1 && value === CMD_UNLOCK_1) {
        flashState = FLASH_ERASE_U1;
      } else {
        flashState = FLASH_IDLE;
      }
      break;

    case FLASH_ERASE_U1:
      if (wordOffset === CMD_ADDR_2 && value === CMD_UNLOCK_2) {
        flashState = FLASH_ERASE_U2;
      } else {
        flashState = FLASH_IDLE;
      }
      break;

    case FLASH_ERASE_U2:
      if (value === CMD_SECTOR_ERASE) {
        // 执行扇区擦除
        eraseSector(offset);
        flashState = FLASH_IDLE;
      } else if (wordOffset === CMD_ADDR_1 && value === CMD_CHIP_ERASE) {
        // 执行整片擦除
        eraseChip();
        flashState = FLASH_IDLE;
      } else {
        flashState = FLASH_IDLE;
      }
      break;

    case FLASH_WRITE_READY:
      // 实际数据写入
      writeFlashWord(offset, value);
      flashState = FLASH_IDLE;
      break;

    case FLASH_ID_MODE:
      // 识别模式下忽略写入,读取时返回厂商/设备 ID
      flashState = FLASH_IDLE;
      break;
  }
}

Flash 操作函数

扇区擦除

javascript
function eraseSector(offset) {
  // 计算扇区起始地址
  const sectorStart = Math.floor(offset / SECTOR_SIZE) * SECTOR_SIZE;

  // 将整个扇区填充为 0xFFFF(擦除状态)
  const flashView = new Uint16Array(sharedBuffer, FLASH_START, FLASH_SIZE / 2);
  const startWord = sectorStart / 2;
  const endWord = startWord + SECTOR_SIZE / 2;

  for (let i = startWord; i < endWord; i++) {
    flashView[i] = 0xFFFF;
  }

  markDirty();
  console.log(`Flash: 擦除扇区 @ 0x${(FLASH_START + sectorStart).toString(16)}`);
}

字写入

javascript
function writeFlashWord(offset, value) {
  // 写入 16 位数据到指定偏移
  const flashView = new Uint16Array(sharedBuffer, FLASH_START, FLASH_SIZE / 2);
  const wordIndex = offset / 2;

  // Flash 写入只能将 1 变为 0,不能将 0 变为 1
  // (实际实现中通常直接写入,因为游戏会先擦除再写入)
  flashView[wordIndex] = value;

  markDirty();
}

内存写入拦截

Flash 命令检测需要在游戏向 Flash 区域写入数据时触发:

javascript
// 设置 Flash 区域的写入监控
// 方式:通过导出给 WASM 的函数来通知 JS
const importObject = {
  env: {
    memory: memory,

    // WASM 在执行 Flash 区域写入时调用此函数
    jsWriteToFlash: function(address, value) {
      handleFlashWrite(address, value);
    },
  }
};

Base64 序列化与 localStorage

编码:保存到 localStorage

javascript
const SAVE_KEY = 'pokemon_emerald_save';
const AUTO_SAVE_DELAY = 500;  // 防抖延迟(毫秒)

function markDirty() {
  saveDirty = true;

  // 清除之前的计时器,重新开始防抖
  if (saveTimer) clearTimeout(saveTimer);

  saveTimer = setTimeout(() => {
    if (saveDirty) {
      saveToLocalStorage();
      saveDirty = false;
    }
  }, AUTO_SAVE_DELAY);
}

function saveToLocalStorage() {
  try {
    const base64 = encodeFlashToBase64();
    localStorage.setItem(SAVE_KEY, base64);
    console.log('存档已保存到 localStorage');
  } catch (e) {
    console.error('存档保存失败:', e);
    if (e.name === 'QuotaExceededError') {
      console.error('localStorage 空间不足!');
    }
  }
}

function encodeFlashToBase64() {
  const flashData = new Uint8Array(sharedBuffer, FLASH_START, FLASH_SIZE);
  let binary = '';
  // 分块处理,避免栈溢出
  const chunkSize = 8192;
  for (let i = 0; i < flashData.length; i += chunkSize) {
    const chunk = flashData.subarray(i, Math.min(i + chunkSize, flashData.length));
    binary += String.fromCharCode.apply(null, chunk);
  }
  return btoa(binary);
}

解码:从 localStorage 加载

javascript
function loadFromLocalStorage() {
  try {
    const base64 = localStorage.getItem(SAVE_KEY);
    if (!base64) {
      console.log('没有找到已保存的存档');
      return false;
    }

    decodeBase64ToFlash(base64);
    console.log('存档已从 localStorage 加载');
    return true;
  } catch (e) {
    console.error('存档加载失败:', e);
    return false;
  }
}

function decodeBase64ToFlash(base64String) {
  const binary = atob(base64String);
  const flashData = new Uint8Array(sharedBuffer, FLASH_START, FLASH_SIZE);

  if (binary.length !== FLASH_SIZE) {
    console.warn(`存档大小不匹配: 期望 ${FLASH_SIZE}, 实际 ${binary.length}`);
  }

  for (let i = 0; i < Math.min(binary.length, FLASH_SIZE); i++) {
    flashData[i] = binary.charCodeAt(i);
  }
}

页面加载与关闭处理

javascript
// 页面加载时恢复存档
window.addEventListener('DOMContentLoaded', () => {
  loadFromLocalStorage();
});

// 页面关闭前强制保存
window.addEventListener('beforeunload', () => {
  if (saveDirty) {
    // beforeunload 中不能使用异步操作
    // 使用同步方式保存
    try {
      const base64 = encodeFlashToBase64();
      localStorage.setItem(SAVE_KEY, base64);
    } catch (e) {
      console.error('关闭前保存失败:', e);
    }
  }
});

存档文件导入/导出与迁移

存档控制区(#download-save / #upload-save)在页面加载时绑定事件,实现 .sav 文件的下载与上传,对应 存档系统概念 中的"存档文件导入/导出"与"存档迁移"。

导出:下载 .sav

javascript
function downloadSave() {
  // 读取 128KB Flash 区域
  const flash = new Uint8Array(sharedBuffer, FLASH_START, FLASH_SIZE);
  // 封装为 Blob 并通过临时 <a> 触发下载
  const blob = new Blob([flash], { type: 'application/octet-stream' });
  const link = document.createElement('a');
  link.href = URL.createObjectURL(blob);
  link.download = 'pokeemerald.sav';
  link.click();
  URL.revokeObjectURL(link.href);
}

导入:上传并规范化 .sav

javascript
uploadSaveInput.addEventListener('change', async (e) => {
  const file = e.target.files[0];
  const bytes = new Uint8Array(await file.arrayBuffer());

  if (bytes.length !== FLASH_SIZE) {
    statusEl.textContent = `expected a ${FLASH_SIZE} byte Emerald .sav file, got ${bytes.length} bytes`;
    return;
  }

  // 规范化:迁移旧版布局 + 修复加密失配
  const normalized = normalizeSaveForCurrentBuild(bytes);
  if (!normalized) { /* 无法识别的存档格式 */ return; }

  localStorage.setItem(SAVE_STORAGE_KEY, bytesToBase64(normalized));
  await restartWithSave(normalized);   // 写入 Flash 并重启游戏
});

规范化入口

javascript
function normalizeSaveForCurrentBuild(bytes) {
  const migrated = migrateLegacyWasmSave(bytes);          // 旧版扇区布局 → 当前布局
  if (migrated) return repairStaleBagEncryptionSave(migrated) ?? migrated;
  return bytes;                                            // 已是当前布局,原样返回(幂等)
}

上传的存档会同时写入 localStorage,因此下次打开页面会自动加载规范化后的版本。页面加载时也会对 localStorage 中已有的存档调用 normalizeSaveForCurrentBuild(),实现自动升级。

关键函数索引

函数名所在文件说明
handleFlashWrite(address, value)app.jsFlash 命令状态机核心,处理每次写入
eraseSector(offset)app.js擦除指定扇区(填充 0xFFFF)
eraseChip()app.js擦除整个 Flash 芯片
writeFlashWord(offset, value)app.js向 Flash 写入 16 位数据
markDirty()app.js标记 Flash 数据已修改,启动防抖计时器
saveToLocalStorage()app.js将 Flash 数据 Base64 编码后存入 localStorage
loadFromLocalStorage()app.js从 localStorage 读取并解码存档到 Flash 区域
encodeFlashToBase64()app.jsFlash 二进制数据 → Base64 字符串
decodeBase64ToFlash(base64)app.jsBase64 字符串 → Flash 二进制数据
jsWriteToFlash(address, value)app.js导出给 WASM 的 Flash 写入通知函数
downloadSave()app.js将 128KB Flash 封装为 pokeemerald.sav 触发下载
upload-save change 处理app.js校验 .sav 大小 → 规范化 → 写入并重启
normalizeSaveForCurrentBuild(bytes)app.js规范化导入/已有存档(迁移 + 修复加密),幂等
migrateLegacyWasmSave(bytes)app.js将旧版 WASM 存档扇区布局迁移到当前版本
migrateLegacySaveBlock1/Block2()app.js迁移单个存档块(SaveBlock1/2)的内部布局
repairStaleBagEncryptionSave(blocks)app.js修复加密密钥失配导致的背包物品数量错乱
restartWithSave(bytes)app.js写入 Flash 并重启游戏以加载新存档

相关阅读