一个 GPL 的 ARM 计算器模拟器、一个从 2016 年捞出来的 Palm 游戏、一台 1995 年的 Windows 95 机器,最后都被塞进了同一个网页里。
现在打开 cloudlake.eu.org/nspire,你会看到四张卡片:经典灰度 Touchpad、彩色 CX II CAS、一个彩蛋 Windows 95,以及长期使用的登录入口。下面这篇文章把这个平台从架构到踩坑完整讲一遍。

一、最后做出来的东西
- 三种「机器」共用一个页面:经典 Nspire Touchpad(灰度 320×240)、CX II CAS(彩色 16 位色)、以及通过 WiNspire 跑的 Windows 95
- 完整键盘 + 触摸板 + 文件传输:70 个键的屏幕键盘、触摸板滑动/点击、拖拽/整文件夹上传、目录浏览与下载
- 游戏一键启动:点一下就从服务器把 nPal(仙剑奇侠传)传进计算器并自动运行
- 长期使用模式:用户名登录,状态存服务器,每 2 分钟自动保存,下次登录直接接着用

经典灰度机的页面是同一个文件,只是换了镜像:

二、地基:把 Firebird 编译成 WebAssembly
Firebird 是一个开源(GPL-3.0)的 TI-Nspire 模拟器,桌面版基于 Qt。它模拟的是 ARM926 级别的 SoC、LCD 控制器、键盘矩阵、NAND flash 和 USB 控制器。
网页版做的事情是:
- 用 emscripten 把 Firebird core(约 33 个源文件)编译成
firebird.js(约 88 KB)+firebird.wasm(约 230 KB) - 页面把 flash 镜像(整块 NAND 转储,经典机 33 MB,CX II 132 MB)写进 Emscripten 的内存文件系统
- 可选加载 snapshot(gzip 压缩的 RAM + CPU 状态),实现「秒开」
- 用
paintLCD()把 320×240 的帧缓冲画到<canvas>上
几个设计决定后来被证明是对的:
- 一个
emu.html支持所有机型:机型差异全部收敛到 URL 参数?flash=...&boot=...&snap=...,页面代码是同一份。经典机、CX II、Windows 95、长期使用模式都复用这套加载逻辑。 - 镜像放服务器、状态放浏览器/账号:静态镜像走 Caddy 的 precompressed(brotli/gzip),经典 flash 压缩后约 12 MB,CX II 约 17 MB,浏览器缓存 30 天。
- 快照优先:冷启动 60 秒 vs 快照恢复几秒,体验差距巨大,所以后来能存快照的场景都尽量存。
三、让模拟器「能用」:键盘、触摸板、文件传输
只有画面没有输入,模拟器就是个屏保。这部分是平台真正的骨架。
3.1 键盘:70 键矩阵
Firebird 的键盘是 8×11 矩阵,网页端只暴露一个 setKey(row, col, state)。页面上用 DOM 画了 70 个键,按下时转成矩阵坐标注入。
唯一的小坑:OS 的键盘扫描有间隔,太快松开会漏键,所以前端给每次按键加了 最少 80 ms 的保持时间。
3.2 触摸板:从「只认方向键」到真正能推鼠标
这是整个项目最值得写的一段。TI-Nspire 的触摸板在 OS 里既当 5 向键用(菜单导航),又当 鼠标用(图形、几何、Windows 95)。
原来的实现只会模拟「按触摸板边缘 → 方向键」,菜单能走,但 GUI 指针纹丝不动。原因是:OS 的指针用的是触摸板驱动算出来的相对位移(touchpad_rel_x/y),而页面从来没把连续滑动坐标送进去。
修复思路:
- 拖动时按 ~38 Hz 连续调用
setTouchpadNorm(x, y, contact=1, down=0),让 Firebird 的touchpad_set_state()计算相对位移 - 轻点 = 按下脉冲(中心 = 左键,边缘 = 方向键;
Ctrl+ 轻点 = 右键) - 混合回退:如果滑动了 220 ms、画面完全没变化(TI-OS 菜单只认方向事件),自动退化成方向键手势,这样拖动导航不丢
修复后的效果(拖动前 / 后,Win95 的鼠标从画面中心移动到右下):

3.3 文件传输:把虚拟 USB 线变成拖拽上传
Firebird 的 USB Link 层允许宿主机和模拟器里的 OS 互传文件。我们在 WASM 里加了这些导出:
| |
前端在此之上做了:
- 上传队列:串行传输、逐文件进度条、失败自动重试(OS 侧偶发返回负数错误码时,
emuUsblinkAbort()+ 重连再试) - 整文件夹上传:
<input webkitdirectory>+ 拖拽目录时用webkitGetAsEntry()递归,保留相对路径,按层级emuNewDir自动建目录 - 只收
.tns的 OS 限制:实测 OS 文档服务只接受.tns,其它扩展名(.txt/.dat/.bin/.zip/.py/...)一律被拒;文件夹里的非.tns会在界面上标「跳过」,并提示先改名

四、踩过的最大的坑:页面「弹输入窗口」然后冻结
有一段时间用户反馈:装 Ndless 的时候浏览器弹出一个输入窗口,然后整个页面卡死。
追下去发现链路非常长:
- Ndless 安装器在模拟器里触发了非法指令(
Prefetch abort/Cannot set T bit) - Firebird 的异常处理会进入内置调试器
- WASM 版的调试器输入函数还在读
stdin - Emscripten 的 stdin 兜底实现是
window.prompt("Input: ") window.prompt会阻塞页面主线程 → 整个模拟器看起来死了
修复是在 firebird/emscripten/main.cpp 里给 gui_debugger_request_input() 换成直接返回一条 c(continue),让异常路径走 EVENT_RESET 重启 OS,而不是等用户输入。
顺带修掉的还有一个自己引入的回归:点击画面曾经等于点触摸板中心,会误开文档/对话框。现在画面单击只负责取键盘焦点,点击请用触摸板区域。
五、nPal 移植:把 2016 年的 Palm 游戏跑进浏览器
nPal 是网友 ntzyz 把 PalmOS 时代的《仙剑奇侠传》 移植到 TI-Nspire 的项目。我们拿到的是 2016 年的 nPal 1.2 发布包,里面是一个 1.25 MB 的 sdlpal.tns。
5.1 旧包为什么会在 CX II 上弹「兼容性模式」
旧的 sdlpal.tns 是 PRG 容器(文件头 50 52 47 00),里面根本没有 Zehn 签名。Ndless r2022 会用旧加载路径跑它,于是:
- 读不到 Zehn 标志位表
supports_hww保持false- Ndless 弹出「Activating compatibility mode. This application hasn’t been updated to work with your hardware.」
- 走 320×320 的 LCD 兼容层,CX II 的原生 240×320 旋转屏适配失效
5.2 重新编译
用 Ndless r2022 SDK 重新编译 nPal 的 SDL 部分:
- SDK 自带的 nSDL 1.1.1 已经改用新的
lcd_blit屏幕 API genzehn打包时扫描 ELF 符号,发现_genzehn_new_lcd_api标记,自动写入USES_LCD_BLIT = true和RUNS_ON_HWW = true- 产物
sdlpal.tns419,248 B(纯 Zehn,无 PRG 外壳),genzehn --info能直接验证:
| |
另外两条工具链(官方 GCC 14.2 和 apt GCC 13)构建出的版本,在模拟器里逐帧逐像素完全一致,最后分发用官方工具链版。
5.3 资源文件与启动
nPal 的资源是 resources.bin.tns(23.9 MB)和 desc.dat.tns(16 KB)——它们本质上就是数据文件,只是 故意用 .tns 扩展名,这样才能通过 OS 的文档服务传进计算器。全部放进计算器的 /npal,运行 sdlpal.tns 即可。
在模拟器里看到的结果:

5.4 一键游戏启动
手动上传三个文件太麻烦,于是做了「快速启动游戏」:
- 服务器放
games.json清单 + 每个游戏的文件(同时生成.br/.gz供 Caddy 预压缩) - 页面选择 nPal → 依次下载 3 个文件 → 自动创建
/npal→ 走 USB 上传 → 主程序最后上传,保证文件浏览器选中它 → 自动发回车运行 - 旁边还有「安装 Ndless」按钮:自动上传安装器+资源,再按固定节奏发 Enter 走完安装流程
实测(线上):安装 Ndless 66 秒 + 启动游戏 59 秒,然后就是标题画面。


六、彩蛋:在计算器里跑 Windows 95
WiNspire 是 tiny386(x86 PC 模拟器)的 CX II 移植,以 Ndless 程序运行,Native 模式给 guest 16 MB RAM / 256 KB 显存。于是流程变成:
| |
- 磁盘镜像是从 PCjs 的 Windows 95 RTM 演示机用它的 “Save HD” 导出的 68 MB raw image
- 把镜像改名成
disk.img.tns,和winspire.tns、bios、vgabios、ini 一起上传到/winspire - 冷启动大约 4–5 分钟:SeaBIOS → Windows 95 开机画面 → 桌面


为了把「开机 4 分钟」变成「秒进桌面」,又做了预烘焙:
- 在模拟器里跑到 Win95 桌面
- 调用
saveSnapshot()生成快照 - 关键:Firebird 的 NAND 修改默认只在内存里,必须调用新加的
saveFlash()(内部就是flash_save_changes())把修改块写回flash.img,否则导出的镜像还是原样
最终页面加载 flash(brotli 后 17.2 MB)+ snapshot(33 MB,基本不可压缩),点开始几秒后直接是 Windows 95 桌面:

七、长期使用:用户名登录 + 状态持久化
短期玩一玩,刷新页面就重来;但总有人想「养」一台自己的计算器。于是加了长期使用模式:
- 登录页输入用户名(无密码,HMAC 签名 cookie 记身份)
- 一个用户名同一时间只能开一个窗口(心跳续锁,180 秒无心跳自动过期)
- 新用户从干净的 CX II 系统开始(默认镜像)
- 保存 = flash 镜像 + 内存快照:
saveFlash()把 NAND 修改写回 MEMFS 的flash.img(138 MB)saveSnapshot()生成snapshot.bin(gzip 流)- 浏览器里用
CompressionStream('gzip')把 flash 压到约 19 MB,和快照(约 10–33 MB)一起 POST 到服务器
- 恢复 = 直接加载 flash + snapshot,从快照恢复现场
- 每 2 分钟自动保存一次;「保存」按钮手动存;「退出」先保存再注销
后端是一个 Python 标准库写的单文件服务(nspire-lt,systemd 托管),Caddy 把 /nspire/api/ 反代到它,数据落在 /var/lib/nspire-lt/users/<user>/。
实测完整流程(登录 → 启动 → 打开图形应用 → 保存 → 退出 → 再登录):
| |

八、数据与性能
| 机型 | flash 原始 | 首屏(br 压缩) | 启动 | 备注 |
|---|---|---|---|---|
| 经典 Touchpad | 33 MB | ~12 MB | 快照恢复极快 | 灰度 320×240 |
| CX II CAS | 132 MB | 17.2 MB | 冷启动 ~60 s | 彩色 16 位色 |
| Windows 95 | 132 MB + 68 MB 磁盘 | ~50 MB(含快照) | 冷启动 4–5 分钟,快照秒开 | 双重模拟,慢 |
| 长期使用 | 每用户 flash + 快照 | 登录时下载 | 快照恢复 ~9 s | 服务器存档 |
一些数字:模拟 USB 传输约 550 KB/s(23 MB 的 nPal 资源约 40–60 秒);一次长期存档上行约 20–50 MB;长期模式每人磁盘占用约 30–50 MB(gzip 后)。
九、还欠着的
- Windows 95 冷启动版:预烘焙时遇到 NAND 写回/flash 保存的边界问题,目前只保留快照直启入口
- Ndless 冷启动激活状态:干净镜像冷启动后 Ndless 不是激活状态,需要在游戏面板点一次「安装 Ndless」(约 1 分钟);长期使用模式里装好保存后,快照会保留激活状态
- 移动端触控(目前触摸板/鼠标事件主要按桌面调优)
- 音频:Firebird 的 PC 喇叭/声音还没接
结语
这个项目最花时间的部分,其实不是「把模拟器编成 WASM」——那是几小时的活。真正难的是层与层之间的接缝:
浏览器输入 → ARM 模拟器 → TI-OS 驱动 → Ndless → 游戏/Windows,每一层都有自己的假设。触摸板的「方向 vs 指针」、文件系统的「只收 .tns」、调试器的「读 stdin」、NAND 的「写缓存」,每一个都足以让整个平台看起来「坏了」。
把这些接缝一个个对上之后,才有现在这个能在浏览器里养计算器、打仙剑、开 Windows 95、还能下次登录接着用的东西。
入口:https://cloudlake.eu.org/nspire/
长期使用:https://cloudlake.eu.org/nspire/lt/index.html
技术栈:Firebird(GPL-3.0)+ emscripten/WASM + Hugo + Caddy + 一个 Python 小服务
