Appearance
存档系统代码导读 (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.js | Flash 命令状态机核心,处理每次写入 |
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.js | Flash 二进制数据 → Base64 字符串 |
decodeBase64ToFlash(base64) | app.js | Base64 字符串 → 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 并重启游戏以加载新存档 |