Featured image of post 在浏览器里养一台计算器:TI-Nspire 在线模拟器从 0 到「长期使用」

在浏览器里养一台计算器:TI-Nspire 在线模拟器从 0 到「长期使用」

Firebird 编译成 WASM、nPal 移植、Windows 95 彩蛋,以及用户名登录 + 服务器存档的长期使用模式。

一个 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 分钟自动保存,下次登录直接接着用

CX II 在线模拟器整页:左边屏幕、右边键盘、下面是文件传输和快速启动游戏

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

经典 Touchpad 页面


二、地基:把 Firebird 编译成 WebAssembly

Firebird 是一个开源(GPL-3.0)的 TI-Nspire 模拟器,桌面版基于 Qt。它模拟的是 ARM926 级别的 SoC、LCD 控制器、键盘矩阵、NAND flash 和 USB 控制器。

网页版做的事情是:

  1. 用 emscripten 把 Firebird core(约 33 个源文件)编译成 firebird.js(约 88 KB)+ firebird.wasm(约 230 KB)
  2. 页面把 flash 镜像(整块 NAND 转储,经典机 33 MB,CX II 132 MB)写进 Emscripten 的内存文件系统
  3. 可选加载 snapshot(gzip 压缩的 RAM + CPU 状态),实现「秒开」
  4. 用 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 里加了这些导出:

1
2
3
4
emuPutFile(local, remote)    MEMFS → 计算器
emuGetFile(remote, dest)     计算器 → MEMFS
emuNewDir / emuDirList / emuDelete / emuMove
emuUsblinkProgress / Busy / Done / Abort

前端在此之上做了:

  • 上传队列:串行传输、逐文件进度条、失败自动重试(OS 侧偶发返回负数错误码时,emuUsblinkAbort() + 重连再试)
  • 整文件夹上传:<input webkitdirectory> + 拖拽目录时用 webkitGetAsEntry() 递归,保留相对路径,按层级 emuNewDir 自动建目录
  • 只收 .tns 的 OS 限制:实测 OS 文档服务只接受 .tns,其它扩展名(.txt/.dat/.bin/.zip/.py/...)一律被拒;文件夹里的非 .tns 会在界面上标「跳过」,并提示先改名

文件传输面板:文件夹上传、队列、只收 .tns 的说明


四、踩过的最大的坑:页面「弹输入窗口」然后冻结

有一段时间用户反馈:装 Ndless 的时候浏览器弹出一个输入窗口,然后整个页面卡死。

追下去发现链路非常长:

  1. Ndless 安装器在模拟器里触发了非法指令(Prefetch abort / Cannot set T bit)
  2. Firebird 的异常处理会进入内置调试器
  3. WASM 版的调试器输入函数还在读 stdin
  4. Emscripten 的 stdin 兜底实现是 window.prompt("Input: ")
  5. 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.tns 419,248 B(纯 Zehn,无 PRG 外壳),genzehn --info 能直接验证:
1
2
3
Application: sdlpal
This executable uses the new lcd_blit API.
This executable supports the HW-W 240x320 LCD.

另外两条工具链(官方 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 即可。

在模拟器里看到的结果:

nPal 启动后的仙剑标题画面

5.4 一键游戏启动

手动上传三个文件太麻烦,于是做了「快速启动游戏」:

  • 服务器放 games.json 清单 + 每个游戏的文件(同时生成 .br/.gz 供 Caddy 预压缩)
  • 页面选择 nPal → 依次下载 3 个文件 → 自动创建 /npal → 走 USB 上传 → 主程序最后上传,保证文件浏览器选中它 → 自动发回车运行
  • 旁边还有「安装 Ndless」按钮:自动上传安装器+资源,再按固定节奏发 Enter 走完安装流程

实测(线上):安装 Ndless 66 秒 + 启动游戏 59 秒,然后就是标题画面。

文件面板里的快速启动游戏行

Ndless 安装器在模拟器里的界面


六、彩蛋:在计算器里跑 Windows 95

WiNspire 是 tiny386(x86 PC 模拟器)的 CX II 移植,以 Ndless 程序运行,Native 模式给 guest 16 MB RAM / 256 KB 显存。于是流程变成:

1
浏览器 → Firebird(WASM,模拟 CX II) → TI-OS + Ndless → winspire.tns(tiny386) → Windows 95
  • 磁盘镜像是从 PCjs 的 Windows 95 RTM 演示机用它的 “Save HD” 导出的 68 MB raw image
  • 把镜像改名成 disk.img.tns,和 winspire.tns、bios、vgabios、ini 一起上传到 /winspire
  • 冷启动大约 4–5 分钟:SeaBIOS → Windows 95 开机画面 → 桌面

WiNspire 里的 SeaBIOS 在浏览器中启动

Windows 95 启动画面

为了把「开机 4 分钟」变成「秒进桌面」,又做了预烘焙:

  • 在模拟器里跑到 Win95 桌面
  • 调用 saveSnapshot() 生成快照
  • 关键:Firebird 的 NAND 修改默认只在内存里,必须调用新加的 saveFlash()(内部就是 flash_save_changes())把修改块写回 flash.img,否则导出的镜像还是原样

最终页面加载 flash(brotli 后 17.2 MB)+ snapshot(33 MB,基本不可压缩),点开始几秒后直接是 Windows 95 桌面:

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>/。

实测完整流程(登录 → 启动 → 打开图形应用 → 保存 → 退出 → 再登录):

1
2
3
4
first load (default image) 10s
saved: ... · 已保存 11:58:19
second load (with state)  10s
restore elapsed 9236 ms; diff vs saved-graph screen: 0   ← 恢复后画面与保存时逐像素一致

长期使用登录页


八、数据与性能

机型flash 原始首屏(br 压缩)启动备注
经典 Touchpad33 MB~12 MB快照恢复极快灰度 320×240
CX II CAS132 MB17.2 MB冷启动 ~60 s彩色 16 位色
Windows 95132 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 小服务

使用 Hugo 构建
主题 Stack 由 Jimmy 设计