[{"content":" 一个 GPL 的 ARM 计算器模拟器、一个从 2016 年捞出来的 Palm 游戏、一台 1995 年的 Windows 95 机器，最后都被塞进了同一个网页里。\n现在打开 cloudlake.eu.org/nspire，你会看到四张卡片：经典灰度 Touchpad、彩色 CX II CAS、一个彩蛋 Windows 95，以及长期使用的登录入口。下面这篇文章把这个平台从架构到踩坑完整讲一遍。\n一、最后做出来的东西 三种「机器」共用一个页面：经典 Nspire Touchpad（灰度 320×240）、CX II CAS（彩色 16 位色）、以及通过 WiNspire 跑的 Windows 95 完整键盘 + 触摸板 + 文件传输：70 个键的屏幕键盘、触摸板滑动/点击、拖拽/整文件夹上传、目录浏览与下载 游戏一键启动：点一下就从服务器把 nPal（仙剑奇侠传）传进计算器并自动运行 长期使用模式：用户名登录，状态存服务器，每 2 分钟自动保存，下次登录直接接着用 经典灰度机的页面是同一个文件，只是换了镜像：\n二、地基：把 Firebird 编译成 WebAssembly Firebird 是一个开源（GPL-3.0）的 TI-Nspire 模拟器，桌面版基于 Qt。它模拟的是 ARM926 级别的 SoC、LCD 控制器、键盘矩阵、NAND flash 和 USB 控制器。\n网页版做的事情是：\n用 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 的帧缓冲画到 \u0026lt;canvas\u0026gt; 上 几个设计决定后来被证明是对的：\n一个 emu.html 支持所有机型：机型差异全部收敛到 URL 参数 ?flash=...\u0026amp;boot=...\u0026amp;snap=...，页面代码是同一份。经典机、CX II、Windows 95、长期使用模式都复用这套加载逻辑。 镜像放服务器、状态放浏览器/账号：静态镜像走 Caddy 的 precompressed（brotli/gzip），经典 flash 压缩后约 12 MB，CX II 约 17 MB，浏览器缓存 30 天。 快照优先：冷启动 60 秒 vs 快照恢复几秒，体验差距巨大，所以后来能存快照的场景都尽量存。 三、让模拟器「能用」：键盘、触摸板、文件传输 只有画面没有输入，模拟器就是个屏保。这部分是平台真正的骨架。\n3.1 键盘：70 键矩阵 Firebird 的键盘是 8×11 矩阵，网页端只暴露一个 setKey(row, col, state)。页面上用 DOM 画了 70 个键，按下时转成矩阵坐标注入。\n唯一的小坑：OS 的键盘扫描有间隔，太快松开会漏键，所以前端给每次按键加了 最少 80 ms 的保持时间。\n3.2 触摸板：从「只认方向键」到真正能推鼠标 这是整个项目最值得写的一段。TI-Nspire 的触摸板在 OS 里既当 5 向键用（菜单导航），又当 鼠标用（图形、几何、Windows 95）。\n原来的实现只会模拟「按触摸板边缘 → 方向键」，菜单能走，但 GUI 指针纹丝不动。原因是：OS 的指针用的是触摸板驱动算出来的相对位移（touchpad_rel_x/y），而页面从来没把连续滑动坐标送进去。\n修复思路：\n拖动时按 ~38 Hz 连续调用 setTouchpadNorm(x, y, contact=1, down=0)，让 Firebird 的 touchpad_set_state() 计算相对位移 轻点 = 按下脉冲（中心 = 左键，边缘 = 方向键；Ctrl + 轻点 = 右键） 混合回退：如果滑动了 220 ms、画面完全没变化（TI-OS 菜单只认方向事件），自动退化成方向键手势，这样拖动导航不丢 修复后的效果（拖动前 / 后，Win95 的鼠标从画面中心移动到右下）：\n3.3 文件传输：把虚拟 USB 线变成拖拽上传 Firebird 的 USB Link 层允许宿主机和模拟器里的 OS 互传文件。我们在 WASM 里加了这些导出：\n1 2 3 4 emuPutFile(local, remote) MEMFS → 计算器 emuGetFile(remote, dest) 计算器 → MEMFS emuNewDir / emuDirList / emuDelete / emuMove emuUsblinkProgress / Busy / Done / Abort 前端在此之上做了：\n上传队列：串行传输、逐文件进度条、失败自动重试（OS 侧偶发返回负数错误码时，emuUsblinkAbort() + 重连再试） 整文件夹上传：\u0026lt;input webkitdirectory\u0026gt; + 拖拽目录时用 webkitGetAsEntry() 递归，保留相对路径，按层级 emuNewDir 自动建目录 只收 .tns 的 OS 限制：实测 OS 文档服务只接受 .tns，其它扩展名（.txt/.dat/.bin/.zip/.py/...）一律被拒；文件夹里的非 .tns 会在界面上标「跳过」，并提示先改名 四、踩过的最大的坑：页面「弹输入窗口」然后冻结 有一段时间用户反馈：装 Ndless 的时候浏览器弹出一个输入窗口，然后整个页面卡死。\n追下去发现链路非常长：\nNdless 安装器在模拟器里触发了非法指令（Prefetch abort / Cannot set T bit） Firebird 的异常处理会进入内置调试器 WASM 版的调试器输入函数还在读 stdin Emscripten 的 stdin 兜底实现是 window.prompt(\u0026quot;Input: \u0026quot;) window.prompt 会阻塞页面主线程 → 整个模拟器看起来死了 修复是在 firebird/emscripten/main.cpp 里给 gui_debugger_request_input() 换成直接返回一条 c（continue），让异常路径走 EVENT_RESET 重启 OS，而不是等用户输入。\n顺带修掉的还有一个自己引入的回归：点击画面曾经等于点触摸板中心，会误开文档/对话框。现在画面单击只负责取键盘焦点，点击请用触摸板区域。\n五、nPal 移植：把 2016 年的 Palm 游戏跑进浏览器 nPal 是网友 ntzyz 把 PalmOS 时代的《仙剑奇侠传》 移植到 TI-Nspire 的项目。我们拿到的是 2016 年的 nPal 1.2 发布包，里面是一个 1.25 MB 的 sdlpal.tns。\n5.1 旧包为什么会在 CX II 上弹「兼容性模式」 旧的 sdlpal.tns 是 PRG 容器（文件头 50 52 47 00），里面根本没有 Zehn 签名。Ndless r2022 会用旧加载路径跑它，于是：\n读不到 Zehn 标志位表 supports_hww 保持 false Ndless 弹出「Activating compatibility mode. This application hasn\u0026rsquo;t been updated to work with your hardware.」 走 320×320 的 LCD 兼容层，CX II 的原生 240×320 旋转屏适配失效 5.2 重新编译 用 Ndless r2022 SDK 重新编译 nPal 的 SDL 部分：\nSDK 自带的 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）构建出的版本，在模拟器里逐帧逐像素完全一致，最后分发用官方工具链版。\n5.3 资源文件与启动 nPal 的资源是 resources.bin.tns（23.9 MB）和 desc.dat.tns（16 KB）——它们本质上就是数据文件，只是 故意用 .tns 扩展名，这样才能通过 OS 的文档服务传进计算器。全部放进计算器的 /npal，运行 sdlpal.tns 即可。\n在模拟器里看到的结果：\n5.4 一键游戏启动 手动上传三个文件太麻烦，于是做了「快速启动游戏」：\n服务器放 games.json 清单 + 每个游戏的文件（同时生成 .br/.gz 供 Caddy 预压缩） 页面选择 nPal → 依次下载 3 个文件 → 自动创建 /npal → 走 USB 上传 → 主程序最后上传，保证文件浏览器选中它 → 自动发回车运行 旁边还有「安装 Ndless」按钮：自动上传安装器+资源，再按固定节奏发 Enter 走完安装流程 实测（线上）：安装 Ndless 66 秒 + 启动游戏 59 秒，然后就是标题画面。\n六、彩蛋：在计算器里跑 Windows 95 WiNspire 是 tiny386（x86 PC 模拟器）的 CX II 移植，以 Ndless 程序运行，Native 模式给 guest 16 MB RAM / 256 KB 显存。于是流程变成：\n1 浏览器 → 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 开机画面 → 桌面 为了把「开机 4 分钟」变成「秒进桌面」，又做了预烘焙：\n在模拟器里跑到 Win95 桌面 调用 saveSnapshot() 生成快照 关键：Firebird 的 NAND 修改默认只在内存里，必须调用新加的 saveFlash()（内部就是 flash_save_changes()）把修改块写回 flash.img，否则导出的镜像还是原样 最终页面加载 flash（brotli 后 17.2 MB）+ snapshot（33 MB，基本不可压缩），点开始几秒后直接是 Windows 95 桌面：\n七、长期使用：用户名登录 + 状态持久化 短期玩一玩，刷新页面就重来；但总有人想「养」一台自己的计算器。于是加了长期使用模式：\n登录页输入用户名（无密码，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/\u0026lt;user\u0026gt;/。\n实测完整流程（登录 → 启动 → 打开图形应用 → 保存 → 退出 → 再登录）：\n1 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 压缩） 启动 备注 经典 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 后）。\n九、还欠着的 Windows 95 冷启动版：预烘焙时遇到 NAND 写回/flash 保存的边界问题，目前只保留快照直启入口 Ndless 冷启动激活状态：干净镜像冷启动后 Ndless 不是激活状态，需要在游戏面板点一次「安装 Ndless」（约 1 分钟）；长期使用模式里装好保存后，快照会保留激活状态 移动端触控（目前触摸板/鼠标事件主要按桌面调优） 音频：Firebird 的 PC 喇叭/声音还没接 结语 这个项目最花时间的部分，其实不是「把模拟器编成 WASM」——那是几小时的活。真正难的是层与层之间的接缝：\n浏览器输入 → ARM 模拟器 → TI-OS 驱动 → Ndless → 游戏/Windows，每一层都有自己的假设。触摸板的「方向 vs 指针」、文件系统的「只收 .tns」、调试器的「读 stdin」、NAND 的「写缓存」，每一个都足以让整个平台看起来「坏了」。\n把这些接缝一个个对上之后，才有现在这个能在浏览器里养计算器、打仙剑、开 Windows 95、还能下次登录接着用的东西。\n入口：https://cloudlake.eu.org/nspire/\n长期使用：https://cloudlake.eu.org/nspire/lt/index.html\n技术栈：Firebird（GPL-3.0）+ emscripten/WASM + Hugo + Caddy + 一个 Python 小服务\n","date":"2026-10-24T00:00:00Z","image":"/p/nspire-web-emulator/cover.jpg","permalink":"/p/nspire-web-emulator/","title":"在浏览器里养一台计算器：TI-Nspire 在线模拟器从 0 到「长期使用」"},{"content":"你戴着它，问一句「前面有什么」。\n它需要回答的其实不是「前面有一个人」——而是「前面 0.8 米有一个人」。\n这两句话对一个只会做目标检测的系统来说，差着一整个维度。\n只有 2D 检测，是看不见「多远」的 YOLO 能给你一个框、一个类别、一个置信度。\n1 person 0.89 [x1,y1,x2,y2] 但它不知道这个人在一米外还是五米外。\n而这件事在真实场景里是致命的：\n「前面有人」→ 你继续走 「前面 0.4 米有人」→ 你必须停 同一个检测结果，两种完全相反的行动。\n所以我给这个系统加了两个东西：深度估计和激光雷达。\n三层感知 整个系统的骨架是这样的：\n层 干什么 ① 输入 文本指令，或 Whisper 实时语音转写 ② 调度 Orchestrator 把自然语言映射到一个视觉能力 ③ 视觉 YOLOv8n 检测 / MiDaS 深度 / EasyOCR 文字 ④ 标定 用 LiDAR 快照重标定深度尺度 ⑤ 输出 hazard_level + 人话 message + 结构化 meta 对外是 FastAPI 的 MCP 接口：\n1 2 POST /api/v1/mcp {\u0026#34;text\u0026#34;: \u0026#34;描述周围\u0026#34;} 返回里既有给人看的句子，也有给程序用的字段。\n五个能力，带优先级 调度器不猜——它把指令映射到一个明确的 function：\n1 2 3 4 5 \u0026#34;vision.ocr_read\u0026#34;: \u0026#34;high\u0026#34; # 读文字 \u0026#34;vision.detect_objects\u0026#34;: \u0026#34;medium\u0026#34; # 检测物体 \u0026#34;vision.distance_query\u0026#34;: \u0026#34;medium\u0026#34; # 问距离 \u0026#34;vision.describe_scene\u0026#34;: \u0026#34;medium\u0026#34; # 描述场景 \u0026#34;vision.check_hazards\u0026#34;: \u0026#34;low\u0026#34; # 判断危险 check_hazards 优先级最低，因为它要调用前面全部能力才能回答——它是管线末端，不是入口。\n意图分类有两路：\n规则匹配（关键词、正则）—— 快、可预测 sklearn 模型（intent_sklearn.joblib，965 KB）—— 处理规则覆盖不到的说法 规则优先，模型兜底。\n核心设计：LiDAR 只做标定，不做判定 这是整个项目里我最想讲的一个决定。\n先说结论（这也是代码注释里的原话）：\n基于视觉深度和 YOLO 距离统一评估危险，LiDAR 仅用于深度标定。\n为什么不直接用雷达测距？ 因为雷达虽然准，但它只有一条线。\nforward_min_distance 是 ±30° 扇区内的最小距离——它告诉你「正前方最近的东西有多远」，但不告诉你是谁、有几个、在哪。\n如果我拿这个数字直接判定危险，会有两个问题：\n误报：地上一个 5 厘米的纸盒，距离 0.3 米 → 判定「危险」。但你可以直接跨过去。 漏报：右边 0.5 米有个台阶，不在 ±30° 扇区内 → 雷达看不到，可你会撞上。 雷达给的是标量，安全需要的是语义。\n那怎么用雷达？ 把它当标尺，不当裁判。\nMiDaS 输出的是相对深度——它知道「这个人比那面墙近」，但不知道「近多少米」。所以：\n1 2 3 1. 先做线性投影，把相对深度映射到一个先验区间 near=0.4m, far=8.0m 2. 再用 LiDAR 的 forward_min_distance 对这个投影做一次重标定 3. 如果雷达认为近，就动态放宽 YOLO 的置信度阈值（0.5 → 0.3） 第 3 步是个很实用的联动：雷达说近，就宁可多报一点。因为近距离下漏检的代价，远大于误检。\n上图是 MiDaS 的输出叠加——颜色代表距离（暖色近、冷色远）。可以看出它能把空间关系画得很清楚，但具体数值需要标尺。\n危险分级 最终判定基于两条证据：\n1 2 3 if nearest_box_dist \u0026lt; 0.5: hazard_level = \u0026#34;danger\u0026#34; # 有物体且极近 elif nearest_box_dist \u0026lt; 1.0: hazard_level = \u0026#34;warning\u0026#34; # 有物体但还远 else: hazard_level = \u0026#34;safe\u0026#34; 没有检测到物体时，退回到视觉深度给出的前方最小距离。\n所以：\n有语义 → 用「谁 + 多远」 无语义 → 用「最近的表面多远」 两条路兜底，不会出现沉默失败。\n没有硬件也能跑：Stub 后端 这套系统依赖摄像头、激光雷达、YOLO 权重、MiDaS 权重。换一台机器就全没了。\n所以每个后端都有两套实现：\n真实后端 Stub 后端 YOLOv8n 固定框 + 固定距离 MiDaS 合成深度图 EasyOCR 固定文本 LiDAR 模拟快照 配置文件一行就能切：\n1 SIMULATION_MODE = 1 这不是为了偷懒，是为了让测试能跑。 有了 Stub，CI 里不需要 GPU、不需要串口、不需要摄像头，全套流程照样能验证。\n测试：让「软融合」这个说法可被验证 三个测试文件，分别守一件事：\n测试 在验证什么 test_p0_smoke API 契约、分阶段耗时、雷达快照新鲜度 test_p1_lidar_soft_fusion 紧急闸门、自适应阈值 test_p2_depth_metadata 深度可用/缺失时的元数据与降级 其中最有价值的是 P1：\n1 2 assert result.meta.get(\u0026#34;hazard_level\u0026#34;) == \u0026#34;high\u0026#34; assert result.meta.get(\u0026#34;forward_min_distance\u0026#34;) == pytest.approx(0.4) 1 assert thresholds.get(\u0026#34;yolo_conf_threshold\u0026#34;) == pytest.approx(0.3) 这两条断言把「雷达软融合」从一个形容词变成了一个可验证的行为：\n雷达报 0.4 米 → 危险等级必须是 high 雷达报近 → 置信度阈值必须降到 0.3 如果哪天我重构代码把这个逻辑弄丢了，测试会立刻失败。\nP2 守的是另一件事——深度失败时必须明确降级，而不是静默给出错误的距离：\n1 2 assert result.meta.get(\u0026#34;depth_error\u0026#34;) == \u0026#34;midas_missing\u0026#34; assert all(box.dist is None for box in result.objects) 宁可承认「我不知道多远」，也不编一个数字。\n为什么这个项目值得做 因为「AI 能看见」这件事，大多数人理解成「能识别」。\n但识别和感知是两件事：\n识别 感知 这是什么 这是什么 + 多远 + 算不算危险 一张图 连续的一段时间 给人看 给行动用 一个只会识别的系统，永远只能当玩具。\n要让它能驱动机器人、能辅助视障者、能放进 XR 头显——它必须先知道距离。\n技术栈 Python · FastAPI · YOLOv8n · MiDaS · EasyOCR · Whisper · RPLIDAR A1 · scikit-learn · pytest\n这个项目最后教会我的，不是怎么调深度模型。\n是「多一个传感器」不等于「多一份信任」——多一个传感器，意味着你要更清楚地决定该在什么时候听它的。\n","date":"2026-10-20T00:00:00Z","image":"/p/visionmate/cover.jpg","permalink":"/p/visionmate/","title":"给 AI 装上一双能测距的眼睛"},{"content":"表面上看，这是个网页小游戏。\n一个机器人在地图上走，收集正分地块，躲开负分地块，在限定步数内拿最高分。\n但真正被测试的不是机器人。\n是 AI 会不会给自己写一个能赢的函数。\n第一轮：AI 亲自玩 Round 1，机器人每一步都由 API 驱动：\n1 观察 → 调 DeepSeek → 返回「思考 + 行动」→ 移动 → 下一步 每步的响应是两行：\n1 2 思考: 右下角有正分格，但中间隔着墙，先向右绕 行动: RIGHT 「思考」显示在日志面板里，「行动」驱动移动。\n这一轮很慢，也很贵——每一步都是一次 API 调用。一局 60 步，就是 60 次请求。\n但它产出了一样极有价值的东西：一份完整的决策日志。\n反思：把日志变成代码 一局结束后，我把整局记录发回给 AI：\n每一步它看到了什么 每一步它决定了什么 最终的得分与路径 然后要求它：不要再一步一步决定。写一个函数，让函数来替你做决定。\n产出长这样：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function decide(observation, state, config, memory) { // observation: 周围 8 格（NW/N/NE/W/E/SW/S/SE） // state: 已走步数、剩余步数、已收集分数 // config: 网格配置 // memory: 跨轮记忆 const moves = [\u0026#39;UP\u0026#39;, \u0026#39;DOWN\u0026#39;, \u0026#39;LEFT\u0026#39;, \u0026#39;RIGHT\u0026#39;]; // 优先走向正分且未收集的格子 for (const m of moves) { const cell = observation[m]; if (cell \u0026amp;\u0026amp; cell.score \u0026gt; 0 \u0026amp;\u0026amp; !cell.collected) return m; } // 没有正分就随机 return moves[Math.floor(Math.random() * 4)]; } 这就是 v1。\n关键转折：成本从 O(步数) 变成 O(1) 对比一下两种驱动方式：\nAPI 模式 函数模式 驱动 每步一次 API 调用 本地执行一个函数 成本 O(步数) O(1) 速度 每秒 1~2 步（受网络限制） 每秒上万步 可复现 依赖模型版本 完全确定 同一个 AI，在第二轮里变成了一个 100 行的函数。\n然后这个函数可以跑几千局——不需要再花任何 API 费用。\n进化：让函数一代代变强 第二轮结束后，把「函数表现」和「上一轮表现」一起发回给 AI，让它改进。\n1 2 3 4 5 6 7 Round 1 API 亲自玩 → 收集日志 反思 日志 → v1 函数 Round 2 v1 函数跑 → 记录得分 反思 对比 → v2 函数 Round 3 v2 函数跑 → 记录得分 …… 最终 最优函数 + 完整进化轨迹 默认 5 轮。每一轮的函数版本都存进 localStorage，可以：\n查看任意版本的代码 手动编辑再测 导出/导入 对比各代得分 一个反直觉的地方 函数变得更长，不一定变得更强。\n我观察到的情况是：前两代函数通常一路膨胀（加启发式、加规则、加特判），但从第三代开始往往会缩短——AI 会主动删掉那些在实测里没帮助的分支。\n这其实很像人类写代码的过程：先什么都想加，再靠数据砍掉。\n沙箱：AI 写的代码不能直接信任 decide 是 AI 生成的任意 JavaScript。\n直接 eval 等于把浏览器交出去。\n所以：\n代码在 Web Worker 里执行，拿不到 DOM 和网络 执行前做语法校验（new Function 能构造出来才算合法） 必须有名为 decide 的函数，否则拒绝装载 抛异常就捕获、记录、不回退到默认策略（否则你永远不知道它坏了） 1 2 3 4 5 6 7 export function runDecide(code, observation, state, config, memory) { const fn = new Function(\u0026#39;observation\u0026#39;, \u0026#39;state\u0026#39;, \u0026#39;config\u0026#39;, \u0026#39;memory\u0026#39;, ` ${code} return decide(observation, state, config, memory); `); return fn(observation, state, config, memory); } 一个 AI 写的函数，要么在受限环境里跑，要么别跑。\n顺带测的第二件事：长上下文记忆 这个游戏还测了另一条线——AI 在不同信息密度下的决策质量。\n历史信息可以切换成两种模式：\n模式 每步传给 API 的内容 上下文大小 完整历史 每步观察 + 决策 + 完整路径 线性增长 压缩摘要 已收集分数列表 + 已访问坐标集合 基本恒定 这就是一个很干净的对照实验：\n同一个模型，给它线性增长的上下文，和给它固定大小的摘要，决策质量会不会下降？\n而且这个问题在当前特别实际——所有做长上下文应用的人都要面对。\n网格本身也是变量 为了不让实验退化成「一张随机图上的运气」，网格可以精细配置：\n分数分布\n均匀分布 / 正态分布（可调均值、标准差） 正分格占比（默认 60%） 数值取整，正态钳制到 [min, max] 墙壁结构\ncorridor — 曲折走廊，终点高奖励 room — 封闭房间，1~2 个入口，内部高奖励 maze — 递归分割小迷宫，死胡同里藏高奖励 随机递归分割（可设递归深度） 结构深处的格子奖励 2x~3x 同一份决策函数，在开放地图上可能很聪明，在迷宫里可能直接走进死胡同。\n所以只测一种地图是不够的。\n一个必须处理的边界情况 设计文档里我列了一张边缘情况表，其中一条特别重要：\n场景 行为 函数代码执行异常 捕获错误，日志提示，不回退 为什么不回退到「随机走」？\n因为静默降级会让统计数据失真。如果函数坏了却自动换成随机策略，你会看到「这一代得分下降了」，但不知道为什么——你会以为是策略变差了，实际上是崩溃了。\n让失败暴露出来，比让它看起来能跑更重要。\n这个项目真正在问什么 表面上它在问：AI 能不能在网格游戏里拿高分？\n实际上它在问三个问题：\nAI 能不能从「做决策」上升到「写决策程序」？ 写出来的程序，能不能在一代代反馈中变好？ 上下文从线性增长换成固定摘要，它的判断会退步多少？ 第一个是能力边界，第二个是进化机制，第三个是工程现实。\n而这三件事，恰好也是今天做 AI 应用的人每天在纠结的。\n技术栈 原生 JavaScript · Canvas · DeepSeek API · Web Worker 沙箱 · localStorage · 零构建步骤\n这个项目让我改了一个看法。\n以前我觉得「AI 会不会写代码」是个演示性问题。\n现在我觉得它是成本问题——当 AI 学会写一个函数来代替自己反复推理时，它就从「一次服务」变成了「一份资产」。\n","date":"2026-10-20T00:00:00Z","image":"/p/ai-evolution/cover.jpg","permalink":"/p/ai-evolution/","title":"让 AI 给自己写决策函数"},{"content":"举着 Quest，对着桌上的杯子按下扳机。\n几十秒后，一个可以拿在手里转的杯子出现在你面前。\n这就是这个项目要做的事：把生成式 3D 接进 XR 运行时。\n也正因为\u0026quot;接进\u0026quot;两个字，它比听起来难得多。\n为什么难 把 2D 生成接进网页，你只要拿回一张图，塞进一个 image 标签就行。\n但把 3D 生成接进 XR，你要面对三个运行时约束：\n① 材质不能在运行时可靠重建\n生成式管线经常把材质烘进 shader graph。而在 XR 渲染管线里，动态加载的 shader graph 无法可靠还原——它依赖编辑器期的编译结果。\n② 生成模型的坐标是不可信的\n自动生成的模型，pivot 位置随机、坐标原点不规则、内部结构千奇百怪。直接拿它的 pivot 去转，你会看到模型一边转一边漂。\n③ 尺度完全不可控\n有的模型生成出来像指甲盖，有的大得像房子。\n这三个问题都不是\u0026quot;模型生成得不好\u0026quot;——是生成结果和 XR 运行时之间的接口没有对齐。\n系统的三个部分 整个系统分三块：\n组件 跑在哪 职责 XR 运行时 Meta Quest 一体机（Unity） 透视取景、交互控制、运行时装配与渲染 服务端编排 轻量控制器 工作流协调、状态管理、资产路由 生成管线 ComfyUI + Hunyuan3D 所有计算密集的生成任务 通信走 REST：异步上传、状态轮询、资产拉取。\n整个系统完全在运行时工作——不需要任何离线预处理或编辑器期资产准备。这是刻意的：真实部署的 XR 应用就是这么跑的。\n决策一：在生成端就把几何和纹理拆开 这是整个系统最核心的一条。\n常规做法是生成一个带材质的整体 GLB，丢进引擎直接用。\n我们没这么做。\nComfyUI 工作流最后输出两个独立的东西：\n一个只含几何的 GLB 网格 一张纹理图片 然后在 XR 运行时里，显式创建一个原生渲染材质，把纹理绑到对应的输入通道上。\n为什么 因为这样绕过了隐式的 shader 编译机制。\n一旦材质是烘在资产里的，你就把控制权交给了资产生成方。而生成方给的材质，在 XR 运行时里未必能加载、未必能编译、也未必要得。\n拆开之后，材质绑定变成一个确定性的、你可以完全控制的步骤。\n一句话：牺牲了\u0026quot;资产自包含\u0026quot;的便利，换来了运行时行为的确定性。\n决策二：运行时显式装配 生成结果不会直接扔进场景。\n系统走一条显式装配流程：\n收到两个独立资产（GLB + 纹理） 实例化原生材质、绑定纹理 挂到转盘锚上（下一节） 自动归一化：用包围盒估算空间尺寸，算出一个统一缩放系数，让模型落进一个预设的\u0026quot;感知尺寸区间\u0026quot; 挂到更高层的空间根节点，成为可交互的 XR 对象 第 4 步解决的就是\u0026quot;有的像指甲盖有的像房子\u0026quot;。\n而且这个归一化是在运行时算的——不需要人工调、不需要离线预处理。\n决策三：转盘锚（Turntable Anchor） 这是解决\u0026quot;pivot 不可信\u0026quot;的答案。\n思路很干脆：不去修正模型的 pivot，而是把它挂在一个锚下面。\n模型自身的局部变换完全不动 所有关于\u0026quot;呈现\u0026quot;的变换——旋转、漂浮、缩放——全部作用在锚上 数学上就是：\n1 最终世界变换 T = T_a ∘ T_m 其中 T_m 是模型的局部变换（不动），T_a 是锚的变换（动态更新）。\n为什么这比\u0026quot;修 pivot\u0026quot;好 因为旋转轴只在世界空间里定义，与模型内部几何无关。\n于是：\n模型只会原地震转，不会一边转一边漂 不需要依赖任何几何启发式（比如\u0026quot;用包围盒中心当 pivot\u0026quot;——对不对称模型经常算错） 缩放、漂浮、透视/虚拟场景切换，全部在锚这一层统一处理 与其去猜一个结构不可信的对象\u0026quot;中心在哪\u0026quot;，不如给它一个外部坐标系。\n一个很具体的设计问题：什么时候该阻塞用户输入 这个问题看着小，但处理不好会直接打崩服务端。\n场景：用户按下扳机触发取景。\n问题：什么时候禁止再次触发？\n如果等到服务端响应才阻塞——那么在这段时间窗口里，用户可以连续触发多次取景。每一次都会向 ComfyUI 发一个新请求，任务队列直接被压垮。\n更麻烦的是输入抖动：一次物理按键，可能产生多个输入事件。这会让系统平白提交好几个重复的生成任务，烧掉大量算力。\n解法 在第一个取景命令触发的瞬间就阻塞输入。\n然后在上传完成并拿到服务端响应时释放——包括失败和超时的情况。\n正常流程下这个阻塞窗口很短，用户基本感知不到。而如果生成成功，阻塞会一直保持到整个工作流结束。\n另外还加了帧缓冲与丢弃机制，避免网络延迟导致的错误决策。\n这个例子的价值在于：它不是一个算法问题，是一个时序问题。 而时序问题只能靠想清楚状态机来解决。\n生成管线 工作流搭在 ComfyUI 里，核心生成模型是 Hunyuan3D-dit-v2.0。\n几个关键阶段：\n阶段 做什么 输入预处理 把透视 ROI 图缩放到固定方形分辨率 去背景 剥离环境，得到干净的目标 几何生成（GenerateMesh） 图像 → 中间隐表示 → 解码出三维表面 网格后处理 修整产物 去光照（Delighting） 把原图的光照信息剥掉，避免\u0026quot;烤\u0026quot;进纹理 多视角渲染 用于纹理采样 多视角纹理采样 + UV 烘焙 把各视角的颜色烘到统一 UV 上 去光照这一步很容易被忽略，但很关键。 如果不去，原图里的阴影和高光会被当成材质的一部分烘进去，模型换个光照就露馅。\n实测结果 系统在 Meta Quest 一体机上完整跑通。\n指标 实测 端到端延迟（取景 → 可视化） 数十秒，取决于管线复杂度和服务端负载 连续多次生成 稳定，无持久性失败或不可恢复状态 失败/超时 正确释放输入阻塞、回到 idle，无需重启应用 旋转与漂浮动画 在不同模型间保持稳定，无轨道运动或漂移 XR 交互 生成期间取景/上传被门控，但视角操作仍然响应 一个诚实的说明 这个延迟不满足严格的实时约束。\n\u0026ldquo;近实时\u0026quot;在这里指的是：通过异步执行和视觉反馈，让用户在等待期间保持参与感，而不是\u0026quot;几十秒内出结果\u0026rdquo;。\n这个边界我写进了论文，也写在这里。\n这个项目真正学到的东西 回头看，最值钱的不是\u0026quot;跑通了 Hunyuan3D\u0026quot;。\n而是这三个判断：\n接口比模型重要。 生成质量再好，如果和运行时的接口对不齐，也用不了。 不可信的东西，给它一个外部参照。 pivot 不可信就给锚，尺度不可控就归一化。 先想时序，再写代码。 输入阻塞那个问题，想清楚了只有十行代码；没想清楚会让服务端崩溃。 技术栈 Unity · Meta Quest · OpenXR · C# · ComfyUI · Hunyuan3D-dit-v2.0 · Python / Flask · REST 异步编排\n论文：A System for Near Real-Time Generation and Runtime Visualization of Generative 3D Models in XR Environments（IEEE 会议格式）\n最后一句是我写在论文里的判断：\n这套系统的价值不在于生成得多好，而在于它把一条不可控的生成管线，接上了一个必须可控的运行时。\n","date":"2026-10-19T00:00:00Z","image":"/p/xr-generative-3d/cover.jpg","permalink":"/p/xr-generative-3d/","title":"对着杯子按一下扳机：把生成式 3D 接进 XR 运行时"},{"content":"期末前一周，我打开自己的浏览器收藏夹。\n里面有 137 节 网课。一节都没点开过。\n那一刻我明白了一件事：问题不在我身上。\n知识从来不会主动来找你。 而市面上所有的学习软件，都在等一个「已经准备好学习的人」——可那个人从来就不存在。\n换个问法 如果问题不是「我为什么学不进去」，而是「知识为什么非要等人来找它」呢？\n那你该向谁学？\n向广告学。\n你从来不会在早上醒来想着「今天我要看些广告」。你从不给广告安排时间。它自己找上门来——在你刷的每一条信息流里，在你打开的每一个页面里，安静地、持续地、不需要你做任何决定地。\n如果知识也这样呢？\n没有「我该开始学习了」。没有意志力消耗。连一个决定都不需要。\n这就是「广而告知」——COMPUTHON 2026 的参赛作品，也是我们拿下的冠军方案。\n你的浏览器不支持视频播放，点此下载。 COMPUTHON 2026 冠军方案 · 3 分钟宣传片（服务器本地播放） 我们到底在解决什么 不是「资料不够」，是「启动不了」 用户嘴上说要的是「学习资料」。但资料从来不缺——B 站、MOOC、教材、论文到处都是。\n真正的需求是：一种外力机制，打破「我明天就开始」的死循环。\n拆开看，是三个具体的卡点：\n卡点 用户的原话 元认知误判 「我需要知道这门课到底要花多少时间，而不是考前才发现怎么这么多」 仪式感陷阱 「点开一节 50 分钟的网课，意味着接下来 50 分钟都得交出去。而 15 秒的短视频随时可以划走」 长内容回避 「我永远在等那个完美的整块时间，但它从来没到过」 还有一个更隐蔽的：「现在学会不会考试时忘了？」\n这个念头，让人心安理得地把所有学习推到 DDL 前三天——然后后悔莫及。\n所以产品不是「更多内容」，是「更少意志力」 这句话是整个项目的核心判断，也是最反直觉的一条：\n用户不需要更多功能。用户需要更少的意志力。\n怎么做的 三个端点，一条主线 优先级 形态 覆盖场景 第一梯队 浏览器扩展 新标签页卡片、视频站侧边栏注入、Quiz 弹窗 第二梯队 桌面应用 开机自启、托盘常驻、桌面悬浮卡 第三梯队 手机 APP 锁屏小组件、通知推送 为什么从浏览器扩展起步？\n安装成本为零——不用下载新软件，不用改变任何习惯。浏览网页本身就是最高频的碎片时间场景。\n如果连这个最低门槛的原型都不能让人感受到价值，那说明方向本身需要重来。\n核心是那个「投放引擎」 真正让它成为产品而不是插件的，是背后那个 Agentic AI 引擎：\n从课程大纲、课件、教材里自动提取全部知识点，生成知识地图 把每个知识点拆成 15 秒可读完的微卡片（核心概念 + 关键要点 + 一个自查问题） 自主决策：什么时机推、推什么、推多少 按遗忘曲线安排间隔重复——用机制解决「提前学了也会忘」 根据行为信号动态调整：已读/跳过、停留时长、Quiz 正确率、活跃时段 决赛阶段升级成了一个五 Agent 投票的决策引擎：每个 Agent 从不同维度打分，投票决定这一刻该不该推、推哪张。\n两种模式，把选择权交还给用户 纯被动模式：零交互，知识静默渗透 验证模式：Quiz 弹窗，答对才关 用户可以完全关掉验证模式。因为强迫学习，本身就是这个产品要解决的问题的反面。\n怎么验证它真的有用 一个\u0026quot;感觉有用\u0026quot;的产品和一个\u0026quot;被证明有用\u0026quot;的产品，差别在验证指标上：\n指标 判断标准 被动曝光率 卡片展示次数 / 用户活跃时间 微交互率 交互数 / 曝光数 知识点覆盖率 已推送知识点 / 课程总知识点 留存率 7 天 / 30 天仍在使用 还有三条定性的：\n侵入感评分（1–10）——如果平均 ≤ 3 但曝光达标，说明模式成立 学习感知调查——一周后问「你觉得这周学到东西了吗」。如果无感知但覆盖率达标，说明纯被动模式真的有效 行为改变——用户是否开始主动导入更多课件？这是最强的信号 最后这条最重要：用户主动投入更多，说明他真的觉得有用。\n一个具体的矛盾 设计过程中最折磨人的地方，是这两件事天然对立：\n想让人被动接受 → 就得高频出现 想让人不反感 → 就得低频出现 我们的解法是把它变成一个可调的问题，而不是一个要\u0026quot;选对\u0026quot;的问题：\n默认只在新标签页出现（用户主动打开，天然不侵入） 侧边栏 30 秒无交互自动收起 Quiz 默认关闭，用户自己开 把\u0026quot;该多频繁\u0026quot;的决定权交给用户的行为数据，而不是我们的猜测。\n这件事真正的意义 颁奖典礼上我说了一句话：\n这个奖不是终点，它是一次确认——确认「拖延」不是一个该被责备的道德问题，而是一个可以被设计解决的技术问题。\n如果把它当成道德问题，答案只能是\u0026quot;你要自律一点\u0026quot;。\n但如果它是技术问题，那就可以被拆解：\n启动阻力 → 降低到零（不用做决定） 内容长度 → 拆成 15 秒 遗忘 → 间隔重复 拖延 → 让知识主动出现 每一个都能被工程化。\n现在的状态 筛选阶段的 MVP 已经跑起来了：Chrome 扩展（新标签页卡片 + 侧边栏注入 + Quiz 弹窗 + 系统通知），配一个能用的 LLM 知识拆解管线。\n决赛阶段交付了：Agentic 决策引擎 + PC 桌面端 + 三端数据互通。\n下一步是手机端，以及申请 PolyU Micro Fund 做孵化。\n最后把我们的信念留在这里：\n让知识找到你，而不是你去找知识。\n","date":"2026-10-18T00:00:00Z","image":"/p/computhon-guangergaozhi/cover.jpg","permalink":"/p/computhon-guangergaozhi/","title":"广而告知：像投放广告一样投放知识"},{"content":"COMPUTHON 2026 是两个阶段的赛制：先交一页设计思维作业过筛，再进决赛做真东西。\n我没有队友。\n报名表上「Team」一栏，我填了「反DDL队」——一个只有一个人的队。\n为什么一个人 不是因为找不到人，是因为我想看看一个人能把一个想法推到多远。\n而且这个项目有个天然的讽刺：它讲的是「不要靠意志力」，而做它的过程，全程靠意志力。\n从一页作业开始 筛选阶段只要求交一份设计思维四步（Demand / Feasibility / Prototype / Test）的作业。\n我把它当成了产品说明书来写。\nDemand：把「我学不进去」拆成三个可操作的卡点（元认知误判、仪式感陷阱、长内容回避） Feasibility：每一项技术选型都直接对标一个用户需求，不用任何还没成熟的东西 Prototype：Chrome 扩展 MVP，三个核心功能 Test：四个定量指标 + 三条定性验证 + 一棵完整的迭代决策树 写完之后我意识到：如果这份东西只是作业，它会被扔进文件夹里；如果它是个真产品，我就得把它做出来。\n于是我开始写代码。\n一个人包办的所有事 回头看决赛阶段的文件目录，我自己都有点被吓到：\n类别 产出 代码 Chrome 扩展、Agentic 决策引擎、桌面端 文字 10 分钟双语答辩稿、5 分钟演示稿、2 分钟颁奖致辞 视频 3 分钟 Pitch、1 分钟演示、1 分钟获奖高光 动画 中英双版渲染动画 配音 自己写脚本调 TTS 生成旁白 PPT 15 页 Pitch Deck 材料 申请表单、CARES 模型、后续工作计划 最耗时的不是写代码。\n是那次为了赶 1 分钟演示视频，我连续调了六版动画时间轴——因为旁白的每一个断句都必须和画面转场对齐，差 0.3 秒就出戏。\n而它最后只播放 60 秒。\n最难的那一关 技术上的困难都能查、能试、能熬。\n真正难的是一个反直觉的判断：\n用户不需要更多功能。用户需要更少的意志力。\n这句话听着漂亮，但做的时候非常反本能。\n因为「加功能」是看得见的产出：\n加个番茄钟 加个打卡系统 加个排行榜 加个勋章 每个都能写进 PPT，每个都显得\u0026quot;功能丰富\u0026quot;。\n而「减意志力」意味着——你要把产品做到用户根本感觉不到自己在用它。\n这在一个比赛里是极其吃亏的：\n评委看不到「努力」 演示视频里没有炫目的交互 甚至用户自己都说不出\u0026quot;我用了它学到了什么\u0026quot; 我一度想加回那些功能。最后没加。\n因为一旦加了番茄钟，这个产品就变成了又一个「需要用户先下决心」的东西——从根上否定了自己。\n这个项目是被批评出来的 答辩稿里我写了这么一句，是真的。\n内测阶段收集到的反馈里，最难听也最有用的几条：\n「侧边栏太烦了，我刚要专注就被打断」 → 改成 30 秒无交互自动收起 「这些卡片我扫一眼就划走了，根本没读」 → 卡片从\u0026quot;一段话\u0026quot;压缩到\u0026quot;一句话 + 三个要点 + 一个问题\u0026quot; 「凭什么它觉得我该学这个？」 → 加了行为追踪和薄弱点优先 「我不想被它管着」 → Quiz 默认关闭，验证模式完全可选 每一条都在削功能，不在加功能。\n但产品就是在一次次被削之后，才变得像一个真的东西。\n那天下午 颁奖典礼在 PolyU 的一个报告厅。\n我在台上说了那段 2 分钟的致辞，中间有 4 个 [停顿]——写稿时就标好的位置，用来换气，也用来让话落下去。\n其中一句是这样的：\n这个奖不是终点，它是一次确认：确认「拖延」不是一个该被责备的道德问题，而是一个可以被设计解决的技术问题。\n下面这句是写在讲稿最后、也是整个项目的信念：\n让知识找到你，而不是你去找知识。\n拿到冠军之后 说实话，最高兴的不是奖本身。\n是有人认真听完了那个关于拖延的想法，并且认为它值得一个奖。\n因为在这个项目之前，我做过很多东西：一个 MR 解谜游戏、几个游戏复刻、一个汇编器和模拟器、一堆逆向工程。它们都有技术含量，但都只证明「我能做」。\n而这一次证明的是另一件事：\n我能看见一个真实的、别人也在痛的问题，并且做出一个有人愿意用的答案。\n如果重来一次 三件会改的事：\n早点找内测用户。 我做完 MVP 才开始找人试，如果一开始就找，\u0026ldquo;侧边栏太烦\u0026quot;这个坑能省两周。 视频要更早做。 演示视频是最能体现产品的东西，但它被我拖到了最后三天。 别一个人扛视频。 代码我自己写得最快，但视频制作应该找个人——它吃掉的精力，足够我多迭代两轮产品。 现在 「广而告知」还在继续：桌面端已经能跑，手机端在排期，也在准备申请 PolyU Micro Fund。\n如果你也在被 DDL 追着跑——这个产品想说的其实是：\n不是你的错。是知识出现的方式不对。\n","date":"2026-10-17T00:00:00Z","permalink":"/p/computhon-solo/","title":"一个人的 COMPUTHON 2026"},{"content":"我有一个文件夹，里面躺着 615 个 .txt 文件。每个文件里是一串以 0eJz... 开头的字符——这是《异星工厂》（Factorio）的蓝图字符串。\n蓝图是玩家花了几个月搭出来的工厂。它现在只是一串文本，你看不出里面有什么，算不出它能产多少东西，也没法验证它是不是真的能跑。\n我想让它变成能看、能算、能验证的东西。\n一、字符串里到底装了什么 官方没有公开蓝图格式。资料里的说法是：Base64 编码的 zlib 流，解出来是 JSON。\n照着做，第一步就失败了：\n1 zlib.error: Error -3 while decompressing data: incorrect header check 我换了好几种字母表、试了 URL-safe 变体、补了不同数量的填充，全都不对。最后不猜了，直接读原始字节：\n1 0e 4a 7a 5f ... 0e 是 Base64 解码的第一个字节？不是。真相是——第一个字符 0 根本不属于负载。\n0 是版本号，本身就是一个 Base64 字符（数值 52）。真正的负载从下标 1 开始。从第二个字符往后解，第一个字节正好是 78，那是 zlib 的合法头。\n坑一：开头那个字符不是负载。\n坑二：填充没被去掉。 好几处资料说 Factorio 会去掉 =，但三个文件全都以 = 或 == 结尾。去掉就刚好差两个字符，解出来是残的。\n结论写在项目里的一句话：以文件为准，不以文档为准。\n验证方式是往返：decode 之后再 encode，必须逐字符完全相同。三个原始文件、以及内部嵌的全部 615 个蓝图，逐字符都通过了。\n二、数值不能抄，得读 蓝图里只有\u0026quot;这台机器在这里、朝这个方向\u0026quot;，没有\u0026quot;它有多快\u0026quot;。速度表在游戏安装目录里。\n所以我写了一个受限的 Lua 求值器（fablue/lua.py）：它能处理 data:extend 语句、表达式、内置库和辅助函数，但绝不执行宿主代码——没有 require、没有文件系统、没有外部依赖。\n第一版解出 1,367 个原型，还有 149 个 data:extend 解不出来。加了对跨文件、局部变量、函数返回值的支持之后：\n修复前 现在 解出的原型 1,367 4,633 解不出的 data:extend 149 25 传送带速度 0 12 机器速度 0 17 剩下 25 个全是跨文件 data.raw 派生的瓦片/装饰类，不含速度表——这个上限是我报出来的，不是藏起来的。\n读出来的真值（全部来自 data.raw）：\n项目 值 传送带速度 0.03125 / 0.0625 / 0.09375 / 0.125 tile/tick 双 lane 吞吐 15 / 30 / 45 / 60 items/s 单 lane 吞吐 7.5 / 15 / 22.5 / 30 items/s 组装机 1/2/3 crafting_speed 0.5 / 0.75 / 1.25 电炉 / 石炉 / 钢炉 2 / 1 / 2 一个犯了两次的 2 倍错误 ×480 是双 lane 合计。推导是 0.03125 × 60 / 0.25 = 7.5（单 lane），乘 2 才得 15。\n早期版本把这个常数标成 \u0026ldquo;per lane\u0026rdquo;，同时 GameDB.belt_items_per_second 又乘了一次 lanes——同一个 2 倍错误，在两个地方各犯了一次。因为是\u0026quot;自洽的错\u0026quot;，测试抓不到。\n现在常量改名为\u0026quot;双 lane 因子\u0026quot;，新增 belt_items_per_second_lane 属性，并把推导写进注释。单位写清楚，就不会再被隐含。\n三、615 个蓝图，一个 canvas 查看器的约束很硬：单文件、离线、无外链。\n615 个蓝图、334,856 个实体，如果每个实体画一个 SVG 矩形，是数十 MB 的 DOM。所以：\n一个 canvas，不是每蓝图一个 每实体只存 4 个整数：x2, y2, 类别, 朝向。坐标翻倍是因为 Factorio 有大量实体放在半格上（整数存不下 0.5） 全部数据一次性嵌进页面 → 6.04 MB 性能靠视口裁剪：懒建空间哈希 + Int32Array。一个 34,650 实体的蓝图，在深度缩放下一帧只画 13 个实体；全可见时 p95 是 8.8 ms，中位 0.6 ms。\n实体还按真实尺寸绘制——尺寸取自每个原型的 collision_box（3×3 的机器是 2.4，补齐到 3 格；火箭发射井 8.4 → 9 格；传送带 0.8 → 1 格），690 个尺寸表项随页面发出。\n四、让它算 看图不够，我关心的是：这个工厂到底产多少、卡在哪。\n计算内核输出四类东西：净产出速率、外部输入、配比失衡、传送带饱和。\n一个真实例子（34,650 个实体）：\n1 2 3 4 传送带饱和 copper-cable 151.1/s，是 express-transport-belt 容量的 3.36 倍 净产出 copper-cable 151.13/s、sulfuric-acid 97.00/s、petroleum-gas 34.00/s 外部输入 water 1587.50/s、crude-oil 480.00/s、iron-plate 282.15/s 瓶颈 未知配方：assembling-machine-3 ×18 台无法确定配方，未计入产出速率 核心公式全部写在代码注释里，不从别处抄：\n1 2 3 每秒次数 = crafting_speed ÷ recipe.energy 每秒产出 = 次数 × 单次产出量 每 lane = belt_speed × 240，双 lane = belt_speed × 480 模块效应也计入了：同一个机器内的模块加成相加（三个 +50% 速度模块 = ×2.5，不是 1.5³）。有一条专门的断言守着：组装机 3 做铜缆，无模块 5.0/s，装三个速度模块 12.5/s。\n五、几处\u0026quot;刻意做对\u0026quot;的判断 这些地方很容易写成\u0026quot;看起来对、其实错\u0026quot;：\nstone-furnace 这类实体在蓝图里没有 recipe 字段，但 Factorio 也不把默认配方写进蓝图。所以必须报成\u0026quot;未知配方（可用 overrides 指定）\u0026quot;，而不是当成\u0026quot;这台机器闲置\u0026quot;。 信标（59 个）持有模块但不执行配方，如果当机器算，每个信标都会变成一条假\u0026quot;未知配方\u0026quot;。 实验室和采矿机的产出取决于科技/矿脉，单独报，不归入\u0026quot;未知配方\u0026quot;。 \u0026ldquo;蓝图没给配方\u0026quot;和\u0026quot;蓝图给了但游戏数据里查不到\u0026quot;分开报。后者我核实过 mods 目录确实没装 mod，所以那批 mod 蓝图算不出来是如实缺口，不是静默失败。 六、验证：334 项断言 + 三个浏览器终检 测试文件 断言 在验证什么 test_blueprint 14 编码往返、格式事实、拒绝畸形输入 test_all_blueprints 8 全部 615 个蓝图逐字符往返 test_gamedata 79 合成数据证明数值真来自文件 test_gamedata_independent 45 正则直接从 Lua 抠数值 + 物理独立推导 test_calc 90 精确数值、未知配方不静默丢弃、循环保护 test_integration 22 跨模块接线：模块效应→计算、尺寸→渲染 test_view 76 页面无外链、嵌入数据条数等于输入 test_gamedata_independent.py 是关键一条：它不经过提取器的 API，而是用正则直接从原始 Lua 文件里抠数值比对——提取器的 bug 无法靠\u0026quot;自洽\u0026quot;藏住。\n三个真浏览器终检不是检查 HTML 文本，而是真的操作页面：缩放/平移真的改变渲染、点到饱和蓝图真的显示警告、放大后每实体像素数真的从 3.1 涨到 50,181.6。\n这一层抓到了 5 个纯 Python 测试看不见的 bug：相机 NaN、缺边界、图例按数字索引查却按名字存、renderLegend() 重建时销毁了正在处理点击的节点、data-i=\u0026quot;0\u0026quot; 的 falsy 边界。\n这就是为什么要用真浏览器驱动页面。DOM 里的字符串说\u0026quot;我很正常\u0026rdquo;，不算数。\n最终：334/334 通过，7/7 浏览器终检全过，端到端 6.6 秒。\n七、还没做到的 信标效应没建模——需要空间分析（每台机器周围有几个信标），目前只在 notes 里计数。 传送带饱和是按物品总量、不是按段判定——蓝图 JSON 不记录机器和传送带的连接关系，要按段判就得从坐标和朝向重建拓扑，这是独立的一大块。 K2SE 的 mod 配方读不到，因为这台机器没装 mod。装上、把 data_root 指过去就能算。 品质加成、插入机吞吐、电力网络都还没建模。 这些限制我都写在页面的结论里，不含糊过去。\n打开蓝图平台（6 MB，含 615 张蓝图）→\r","date":"2026-10-16T00:00:00Z","image":"/p/factorio-lab/cover.jpg","permalink":"/p/factorio-lab/","title":"615 张蓝图，6.6 秒：我把《异星工厂》的蓝图拆开了"},{"content":"我想在这台机器上跑一段能在裸机上启动的代码。\n需要两样东西：汇编器（把汇编变成字节）和模拟器（把字节执行起来）。\nnasm 装不上，qemu 装不上——sudo 要密码、Docker 没启动、直接下载超时。于是这个项目把两样都自己实现了。\n模块 文件 说明 模拟器 kiln/cpu.py 8086 寄存器组、段寄存器、ModRM 全部寻址方式、8/16 位运算与标志位、中断、VGA 文本显存、键盘控制器 汇编器 kiln/asm.py 迭代到收敛的多遍汇编器，输出字节 + 清单 反汇编器 kiln/decode.py 独立实现，不参考汇编器 为什么反汇编器要独立写 汇编器决定\u0026quot;一条指令变成哪些字节\u0026quot;，反汇编器决定\u0026quot;这些字节是哪条指令\u0026quot;。\n如果反汇编器照着汇编器的逻辑写，那它只能证明\u0026quot;我按我的想法解释我自己的输出\u0026quot;——这是自证。\n所以两者互不参照。然后测试断言它们在 boot.asm 的每一条语句上都一致。\n这样，\u0026ldquo;两边以完全相同的方式出错\u0026quot;才会漏过去——比\u0026quot;汇编器没崩溃\u0026quot;强得多。\n但仍然不够。 因为两边可以同时错得一致。\n所以还有第二重证据：把程序真的执行一遍，用手算的期望值对最终寄存器和内存下断言。\ntests/test_core.py 里的每个期望值都是手写推导的，不是从模拟器输出里抄的。否则又成了自证。\n测试抓到的 12 个真实缺陷 这些不是假设，是测试真的报错、我逐条定位出来的：\n# 缺陷 症状 1 解码器对 0x83 /r 的立即数宽度判错 add ax,1 编码 3 字节、解码成 4 字节，之后全部错位 2 _cond 里局部变量名与标志位常量同名 CF 变成未绑定局部变量，所有条件跳转崩溃 3 _apply_alu 回调签名不一致 运算结果写进错误的操作数 4 ALU 的 form 分支反了 add ax,[mem] 变成写内存，把内存砸掉 5 ModRM 构造把 src/dst 写反 add ax,bx 编成 03 D8（实为 add bx,ax）——能跑、但做反了 6 解码器方向同样反了 汇编器改对后，清单仍显示相反结果，误导出 3 次错误\u0026quot;修复\u0026rdquo; 7 _shift 对 mod=3 的寄存器操作数走了内存写入 shl ax,8 把结果写进代码段，自我破坏引导扇区 8 0F 两字节转义前缀未拦截 被当成别的指令静默执行——最危险的一类 9 C1 /4 ib 立即数取在 ModRM 之前 把 ModRM 字节当移位位数 10 段寄存器 push/pop 未实现，位判断用错 1E（push ds）被当成 4 字节 NOP，指令流错位 11 两遍汇编 + 前向引用猜测 → 标签地址漂移 call blit 跳到标签后 3 字节 12 test reg,reg 用位测试而非 84/85 test al,al 把寄存器当立即数 第 5 和第 6 条最值得说：它们是对称的。汇编器把方向搞反，解码器也把方向搞反，于是\u0026quot;汇编→解码\u0026quot;的往返测试完全通过——两个错误互相抵消了。只有拿手算字节去对，才看得见。\n第 11 条促成了根本性重新设计：改成迭代到收敛，并且加宽单调（一旦加宽就不回缩）。否则跳转长度和标签地址会互相影响，永远不收敛。\n引导扇区：在裸机上画 Mandelbrot examples/boot.asm 是一个 512 字节的引导扇区，末尾带 55 AA 签名。BIOS 把它载入 0000:7C00。\n流程：\n建立 DS/SS 与栈 int 10h 切到 80×25 彩色文本 打印横幅 用 8.8 定点整数渲染 Mandelbrot 到 0800:0000 的缓冲区（40×24 字符） rep movsw 整块搬到 B800:0000 显存 hlt 缓冲区放在扇区之外有原因：800 个字符塞不进 512 字节。而且放在已知地址后，验证器可以直接从 RAM 里读渲染结果，和独立的浮点实现逐格比对。\n定点乘法，两个看着都对的错版本 imul 把有符号 32 位积放在 DX:AX。8.8 定点结果就是该积的 bit 8..23，由两半拼成：\n1 2 3 4 5 imul bx mov cx, dx shl cx, 8 ; bit 16..23 -\u0026gt; bit 8..15 shr ax, 8 ; bit 8..15 -\u0026gt; bit 0..7 or ax, cx 两个诱人的简写都是错的，而且都表现为\u0026quot;逻辑 bug\u0026quot;而不是\u0026quot;算术 bug\u0026quot;：\n只做 shl ax,8：保留 bit 16..31，丢掉低字的小数部分 对低字做 shr cx,8 / shl ax,8 / or：丢掉整数部分 这类错误的可怕之处在于：结果总是\u0026quot;差不多但不是\u0026quot;，而\u0026quot;差不多\u0026quot;在图形上表现为轻微的错位，很容易被误认为\u0026quot;渲染问题\u0026quot;。\n现在的状态（如实说） 已经跑通的：\npython tests/test_core.py → 62/62 汇编器对 boot.asm 收敛输出正好 512 字节，签名 55 AA 正确 反汇编器与汇编器在冒烟测试的每一条指令上长度一致，逐条核对助记符 模拟器通过全部条件码、栈/call/ret、段 wraparound、除零故障、BIOS 显存、键盘阻塞/轮询断言 尚未完成的：\n引导扇区的 Mandelbrot 渲染循环在末尾仍有一处控制流缺陷，会跑到扇区外执行。\n定点乘法本身已经被单独验证正确（imul 0xFE00×0xFE00 → 0x0400），渲染循环也能正确遍历 960 个像素、正确推进缓冲区指针。出问题的是渲染之后的返回路径，tests/test_boot.py 的 14 项断言目前 10 项通过。\n这一条我没有修完就停手了。\n它属于\u0026quot;能自己走完的收尾\u0026quot;，但它前面连续暴露了 12 个更深的机制性缺陷。我判断继续在同一处反复试错的收益，已经低于把已有成果讲清楚。\n已知的模拟器边界 这些会明确报错，而不是猜着执行：\n32 位操作数/地址、保护模式、FPU、多数字符串指令、0F 扩展表、本项目未用到的 BIOS 服务。\n如果继续 先用一个 trace 工具把渲染后的返回路径逐条打出来——这是定位最后那个缺陷最短的路。 给模拟器加\u0026ldquo;指令边界断言\u0026quot;开关：任何一次 step() 落在清单记录之外就立即报错。本次 12 个缺陷里有 5 个都是靠等价的观察才找到的，做成常驻检查会省很多时间。 加收敛性检查：断言任意源码都在 N 遍内收敛，并把实际遍数记进清单。 这个项目最后教会我的不是 x86。\n是\u0026ldquo;能跑\u0026quot;和\u0026quot;对\u0026quot;之间隔着多远——中间那 12 个缺陷，每一个都穿着\u0026quot;能跑\u0026quot;的外衣。\n","date":"2026-10-15T00:00:00Z","image":"/p/kiln/cover.jpg","permalink":"/p/kiln/","title":"没有 nasm 也没有 qemu，我从零写了汇编器和模拟器"},{"content":"F:\\CDDA\\graveyard\\ 里躺着 10 次角色死亡记录。\n这是《大灾变：黑暗之日》（Cataclysm: Dark Days Ahead）删档后自动留下的完整档案：每次一份 .sav（角色与世界在死亡那一刻的状态）和一份 .log（大事记）。\n存档格式是开着的——首行 # version N，之后是一整个 JSON 对象。所以那个角色的姓名、属性、技能、特性、变异、击杀数、走过的每一处地方，甚至一生中打破的每一条戒律，都能直接读出来。\n我把它读出来，然后做了两件事：把这些人还原成可看的记录，以及给他们写墓志铭。\n读出来的五个人 graveyard/ 里 5 份存档、3 个角色：\n角色 职业 / 出身 存活 击杀 变异 结局线索 特里安娜 实验室技术员 / 精灵模组 2 回合 0 29 刚开局就结束 克里斯托弗·哈里斯 无职业 / 避难者 23.6 小时 50 40 死在无线发射塔一带 阿里安娜·娜吉雅 维度法师 / 避难者 1.53 天 114 97 第一次尝试 阿里安娜·娜吉雅 同上 3.47 天 313 125 最远的一次 阿里安娜·娜吉雅 同上 0.2 小时 0 86 足迹停在「坚岩／实心土」——被埋在石头和泥土里 一个我最初写错的判断 阿里安娜出现了三次。怎么判断这三次是同一个人？\n我的第一版判据是：属性完全相同。\n测试直接把它打回来了。\n三次存档里 STR/DEX/INT 确实都是 24/24/23——但最后一次 PER 从 24 掉到了 23。而特性数从 69 → 57 → 73。\n真相是：属性会被变异和伤势改写，特性不会。\n所以正确的判据是特性集，不是当前属性。\n这个小错误是这个项目第一个教训：你以为稳定的标识，恰恰是最容易变的那个。\n墓志铭，以及它凭什么可信 生成式文字最容易犯的错是编造事实。\n一个 AI 写出来的墓志铭，读起来可以很动人，但里面的\u0026quot;击杀 313 个敌人\u0026quot;——是真的吗？还是它为了让句子好看随口说的？\n所以这里的做法是：\n每一句话涉及的每个数字和名字，都作为一条「可核查声明」（Claim）被登记下来，verify() 再从解析出的存档里重新推导一遍。\n比如这一段：\n阿里安娜·娜吉雅，女。存活 3.47 天后死去。生前击杀 313 个敌人。身上留下 125 处变异。带着 57 项特性离世。最擅长咒术（15 级）。足迹遍及 9 处，最常出现在紧急避难所 B-19（36 次）、森林（25 次）、公路（17 次）。一生打破 10 条戒律……她把这个世界推开了一段距离，然后被它合上。\n里面每一个数字——3.47 / 313 / 125 / 57 / 15 / 9 / 36 / 25 / 17 / 10——全部是声明，全部被重新推导核对过。\n校验器自己也要被校验 只有正向检查还不够。\n因为\u0026quot;校验器\u0026quot;本身可能是个摆设——它可能永远返回 true。\n所以测试里加了两条反向验证：故意塞一个伪造的击杀数（9999）和伪造的姓名进去，verify() 必须报错。\n这是这个项目最核心的一条设计原则：\n一个从不失败的校验器，等于没有校验器。\n测试抓到的两个真实缺陷 小时数校验逻辑写错了。 存活小时 这条声明拿 round(days24) 去和 days 比，23.6 小时被判成不匹配。修法是让声明带上单位（hours 与 days 分开）。\n地点到访次数没有登记为声明。 墓志铭里印着「幸存者营地（12 次）」，但 12 不在任何 Claim 里。是测试里那条「文本中出现的每个数字都必须有出处」把它揪出来的。\n这正是这套约束存在的理由：\n没被登记的印刷数字，就是没被核查的数字。\n足迹 每条 .log 事件都带 oter_id / oter_name（发生地点），所以\u0026quot;足迹\u0026quot;不是编的，是从数据长出来的：\n克里斯托弗：幸存者营地 ×12、无线发射塔 ×4、民宅 ×4、森林 ×4 阿里安娜（最远那次）：紧急避难所 B-19 ×36、森林 ×25、公路 ×17 阿里安娜（最后一次）：紧急避难所 C-41 ×2、坚岩 ×2、实心土 ×1 最后一行不需要任何解释。\n它只是数据——她死在岩石和泥土之间。\n一个说明 存档里含有成人模组的全局变量与事件名。\n我只做了结构化统计与叙事重建，没有展开那部分内容。纪念馆页面上也不会出现。\n为什么做这个 一个游戏存档里躺着的东西，本来会跟着硬盘一起被格式化掉。\n但它其实是一份完整的人生记录：这个人走过哪里、杀过什么、变成了什么、最后倒在什么地方。\n我只是把它读出来了。\n打开纪念馆 →\r技术细节 文件 作用 cddamem/save.py 存档解析（.sav + .log），产出 Life cddamem/epitaph.py 墓志铭生成 + Claim 校验 summarise.py 命令行汇总 make_memorial.py 纪念馆页面（自包含 HTML） tests/test_save.py 19 项断言，主要是与原始 JSON 交叉核对 tests/test_epitaph.py 5 项断言，含伪造数据必须被抓 两条验证主线：\n交叉核对：解析出的技能集必须与原始 JSON 的 level\u0026gt;0 集合完全相等；击杀数必须等于 kill_tracker.kills 之和；.log 里\u0026quot;开始大灾变\u0026quot;的回合必须等于 .sav 的 game_start。猜出来的数字过不了。 反向验证：故意伪造声明，校验器必须报错。 这个项目最后让我想的一句话是：\n写一个动人的故事不难。难的是故事里的每个数字都经得起回头查。\n","date":"2026-10-14T00:00:00Z","image":"/p/cdda-memorial/cover.jpg","permalink":"/p/cdda-memorial/","title":"大灾变纪念馆：从存档里读出五个人"},{"content":"这台机器上有一个真实的 TI-Nspire 开发环境：OS 3.9.0.463 classic Touchpad、Ndless、从源码构建的 ARM 工具链。\n目录里躺着 1055 个 .tns 文件。\n.tns 不是一个格式。它是一层 PRG\\0 外壳，里面是 Zehn 容器——Ndless 自己的可执行容器格式。而这个格式没有公开文档。\n这个项目把它读懂了，并且用「能不能逐字节重建」来证明。\n自己解出来的格式 1 2 3 4 5 6 7 8 9 10 11 .tns 文件 ├── 装载器桩 0 ~ 0x1EC 字节（常见；也有 0/480/604/616/632 字节的变体） └── Zehn 容器 ├── 头 32 字节 8 个小端 u32： │ \u0026#34;Zehn\u0026#34; / version=1 / file_size / reloc_count / │ flag_count / extra_size / alloc_size / entry_offset ├── 重定位表 reloc_count × 4 字节 │ 低 8 位 = 类型，高 24 位 = 偏移 ├── 标志表 flag_count × 4 字节（同样的 8/24 位切分） ├── 额外数据 extra_size 字节（名字/作者/声明等字符串住在这里） └── 代码 剩下的；若首个重定位是 FILE_COMPRESSED 则为 zlib 流 两个坑，我都先踩了 坑一：「Zehn」这个字符串会骗你 最直接的思路是——搜文件里 Zehn 这四个字节，从那里开始解。\n但装载器桩里也出现了这个字符串。\n只搜这 4 个字节会找到诱饵。真正的头必须同时满足三个条件：\nversion == 1 各表长度放得下（不越界） file_size 确实落在文件范围内 光靠一个魔数是不够的。 得用那些字段之间的一致性来互相验证。\n坑二：「能力」不等于「要求」 genzehn 的 --color-support 默认是 true。\n它的含义是「支持 CX/CM 彩屏」，不是「需要彩屏」。\n我一开始理解反了，于是每一个正常程序都被判成「需要彩屏，所以在这台黑白机上跑不了」——一个完全反向的结论，而且看起来非常合理。\n正确判据必须看值为 0：0 才表示\u0026quot;不支持这类机器\u0026quot;。\n这个坑值得单独说：当一个字段名叫 supports，人们会下意识把它读成 requires。\n验证：逐字节重建 rebuild() 从解析出来的字段把容器重新拼回去，然后和原文件比字节。\n1 2 3 4 5 $ python -m zehnscope --rebuild \u0026#34;E:\\QA\\nspire-dev\u0026#34; 11/11 containers rebuilt byte for byte $ python tests/test_zehn.py ok all 100 containers rebuild identically 这比\u0026quot;解析没崩溃\u0026quot;强得多。\n因为偏移、计数、位域切分、字节序、字符串在额外数据里的定位——任何一处理解错，字节就对不上。\n它一次同时检验所有假设。\n语料是 1055 个真实文件，不是精选样本 其中 100 个是真正的 Ndless 程序，955 个只是沿用了 .tns 扩展名的文档/资源（boot1.img.tns、Hitori 关卡、Liero 素材……）。\n一个只见过\u0026quot;干净样本\u0026quot;的解析器，证明力弱得多。跨十年、多个工具链、8 种不同桩的 100 个真实容器，才是像样的考验。\n手写一个 DEFLATE 解码器 Zehn 的压缩用 zlib。\n但语料里没有一个压缩容器——光靠真实文件测不到这条路。\n所以自己按格式造一个再读回来。\n解码器是手写的 RFC 1951 + 1950：stored / fixed / dynamic 三种块、LZ77 回引用、Adler-32 校验。\n然后对照系统 zlib，在 40 组样本上比对输出（10 种载荷 × 4 个压缩级别）。\n一个实现自己和自己一致，说明不了什么。两个独立实现一致，才算证据。\n一个被窄样本骗过的断言 tests/test_zehn.py 打印出来的实测结果：\n桩大小 容器数 0x1EC (492) 53 0 17 632 11 480 8 616 6 604 5 17 个容器完全没有桩——容器就在文件偏移 0。\n我最初写了一条「所有程序共用一个桩」的断言，在 14 个 samples-tns 上全部通过，在全语料上直接失败。\n这正是把断言写在窄样本上会骗人的例子：样本越小，错误越自信。\n兼容性判定：一个被推翻的直觉 compat.py 拿程序自己声明的能力和具体机器对判。\n这台机器的约束很紧：classic 非 CX、320×240 黑白、Touchpad、Ndless r2016、64MB。\n原本的直觉解释是：「SDK 是 r2022、设备是 r2016，所以自写的程序被拒载重启」。\n但实测结论是：这 100 个程序里，没有一个声明 Ndless 版本窗口（NDLESS_VERSION_MIN / REVISION_MIN 全缺）。\n也就是说 版本不匹配不会导致装载被拒。\n那条\u0026quot;合理\u0026quot;的解释，直接被数据否掉了。真正的原因得往别处找——ut_read_os_version_index() 的地址哈希、sc_addrs_ptr 查表、或者 classic 上看门狗没关。\n这是这个项目最值钱的一条：一个解释听起来越合理，越要拿数据去撞它。\n还能往哪走 容器写入器：现在只会重建已有容器。接上 ELF 解析就能把任意 ARM ELF 打包成合法 .tns——包括 genzehn 那部分（扫 .rel.text、发 ADD_BASE/ADD_BASE_GOT、处理 GOT 回滚） OS 镜像：boot1.img.tns / boot2.img.tns / diags.img.tns 不是 Zehn 容器，是 TI 的 OS/引导镜像，格式完全不同 syscall 面：把容器里的重定位和 syscall-list.h 的 ID 表关联，就能静态列出每个程序用到的 syscall，再和 r2016 的表做差集——那才是\u0026quot;为什么重启\u0026quot;的正确问法 看 100 个程序的容器报告 →\r技术细节 文件 作用 zehnscope/core.py 容器解析 + 逐字节重建 zehnscope/inflate.py 手写 DEFLATE/zlib 解码器 zehnscope/__main__.py CLI：inspect / --rebuild compat.py 能力 vs 机型判定（含\u0026quot;能力≠要求\u0026quot;的说明） tests/test_zehn.py 18 项断言 逆向工程真正的方法论，不是\u0026quot;猜对格式\u0026quot;。\n是找到一种只有完全理解它才能通过的检验，然后不断撞上去。\n逐字节重建就是这样一种检验。\n","date":"2026-10-13T00:00:00Z","image":"/p/zehnscope/cover.jpg","permalink":"/p/zehnscope/","title":"拆开 TI-Nspire 的 .tns 看里面"},{"content":"一个完整的中国象棋网页版。\n不需要构建步骤，克隆下来 npm start 就能跑。手机、平板、电脑都能连。\n但真正让我想做这个项目的，不是\u0026quot;做个象棋\u0026quot;，而是——\n一个规则引擎怎么写才算\u0026quot;写对了\u0026quot;？\n规则这东西，看起来简单 七种棋子，每种走法都耳熟能详。写起来好像没什么难的。\n真正难的是那些边界条款：\n规则 容易漏在哪 帅/将 九宫内直走一步；任何导致两将同线且中间无子的着法都非法 仕/士 九宫内斜走一步 相/象 走「田」字，象眼被堵不能走，不能过河 马 走「日」字，马腿被堵则该方向不能走 车 直线滑行，遇子即止 炮 不吃子时同车；吃子必须隔且只隔一个炮架 兵/卒 未过河只能向前；过河后可向前或左右，永不后退 还有胜负判定层：\n将死判负 —— 被将军且无合法着法 困毙判负 —— 没被将军但也没有任何合法着法（中国象棋特有，国际象棋是判和） 长将判负 —— 循环内某方每一步都在将军 长捉判负 —— 循环内某方每步都在追捉同一个非兵非将棋子 重复局面判和 —— 三次重复且不构成长将/长捉 自然限着判和 —— 120 个半回合无吃子 光靠\u0026quot;我觉得应该对了\u0026quot;是写不完的。\nperft：一个不给你留面子的检验 国际象棋引擎圈子里有个标准测试叫 perft：从开局开始，逐层统计所有合法着法能生成的节点数。\n它有一个残忍的性质：任何一处规则写错，数字立刻对不上，而且不告诉你错在哪。\n中国象棋的公认值是这样的。这是我的实测：\n深度 节点数（公认） 结果 1 44 ✅ 2 1,920 ✅ 3 79,666 ✅ 4 3,290,240 ✅ 到深度 4，要生成并验证 329 万个局面。\n这比\u0026quot;手动走几步看起来没问题\u0026quot;强得多——它逼你把每一个边界条款都写对，因为只要有一处错，329 万会变成 328 万或者别的数。\n一个能被数字否证的测试，比一百次\u0026quot;看起来没问题\u0026quot;更有价值。\n另外还有 69 项单元测试，逐条覆盖蹩马腿、塞象眼、炮翻山、将帅照面、被牵制棋子、困毙、长将判负、自然限着，以及中文记谱的前后缀消歧。\nAI：六档难度，但不许装傻 引擎是纯 JavaScript，跑在 Web Worker 里，所以深度搜索不会卡住界面。\n搜索 Negamax + Alpha-Beta 负极大值搜索 迭代加深 + 时间控制（随时可以返回当前最优着法） 置换表：Zobrist 校验、深度优先替换、100 万槽位 着法排序：置换表着法 \u0026gt; MVV-LVA 吃子 \u0026gt; 杀手着法 \u0026gt; 历史启发 静态搜索（Quiescence）：消除水平线效应 空着裁剪、将军延伸、后期着法削减（LMR） 搜索路径内重复局面按和棋处理，避免引擎自己走进循环 评估 子力价值 + 位置价值表（每种棋子一张 10×9 表）+ 结构项：\n机动性（马/车/炮/兵各自可走到的格数） 车的纵线开放性（半开放 / 全开放） 缺士怕双车、残局炮贬值、残局兵升值 开局评估为 0 分（完全对称），速度约 30 万节点/秒。\n六档难度：不是简单调深度 难度是四层可调的：搜索深度 + 思考时间 + 选点随机窗口 + 失误概率。\n档位 深度 时间 随机窗口 失误率 入门 2 0.15s 150 30% 初级 3 0.3s 55 12% 中级 5 0.7s 18 3% 高级 8 1.4s 5 0 大师 12 2.8s 0 0 宗师 20 6s 0 0 「随机窗口」＝在「最优分 − 窗口」范围内的着法里随机挑一个——这是低难度\u0026quot;像人但会犯错\u0026ldquo;的关键。\n但这里有一条铁律：\n无论哪一档，能一步杀就一定会杀。\n低难度可以犯错，但不能在该赢的时候装傻。\n因为一旦那种事发生，玩家就不认为自己在跟一个对手下棋，而是在跟一个随机数生成器。\n联机：服务端说了算 同一份服务端同时提供 HTTP 静态服务与 WebSocket 信令，端口共用一个。\n关键是规则引擎浏览器与 Node 共用同一份代码，所以：\n服务端为每个房间持有一份 Position 每一步都用规则引擎重新算一遍 非法着法直接回 error，局面不变 改前端无法作弊。\n其他细节：\n房间码 4 位，字符集去掉易混淆的 I/O/0/1 座位满了自动成为观战者，可观战、可聊天，不能走棋 悔棋需对方同意；对方在你请求后走了子，请求自动作废 断线重连：token 存在 localStorage，120 秒内自动回到座位，超时判负 心跳 25 秒一次，死连接自动清理 一个手机端的真实坑 移动浏览器的音频策略比桌面严格得多。\n页面加载时创建 AudioContext 会被自动播放策略永久挂起。\n所以这个项目特意不在加载时建上下文，而是在第一次用户触摸时才创建并解锁。\n配套做了个音效自检功能：\n状态显示 含义 运行中 · 音效已开启，却仍听不到 设备侧静音（iPhone 检查侧边静音拨片；Android 调媒体音量不是通话音量） 一直显示「已挂起」 浏览器还在拦截，再多点几下棋盘 显示「尚未开始」 还没产生过触摸 试听按钮始终会发声——这样就能区分\u0026quot;开关关了\u0026quot;和\u0026quot;设备真没声音\u0026rdquo;。\n自动化验证会在严格自动播放策略 + 手机触屏模拟下跑一条完整链路：加载时不建上下文 → 首次触摸即解锁 → 首声不被吞掉 → 落子/AI 应招都有声。\n素材：全部可自由使用 棋子汉字字形取自 Wikimedia Commons 的公有领域作品（作者 Inductiveload） 原素材是四格精灵图，只取汉字字形，去掉原图圆片，重新合成为木纹实体棋子（径向渐变 + 车削年轮 + 倒角 + 内圈阳文 + 投影） 棋盘木纹是 SVG feTurbulence 程序化生成的，无第三方版权 音效是 WebAudio 现场合成的，不打包任何音频文件 所以这个项目可以随便分发，没有版权风险。\n下一盘（单机版）→\r上面是在线单机版。局域网联机需要跑 Node 服务端，本地 npm start 即可。\n技术选型 选择 原因 原生 JS，无构建步骤 克隆下来 npm install \u0026amp;\u0026amp; npm start 就能跑 规则引擎浏览器与 Node 共用 联机时服务端复用同一份规则做权威校验，避免两套实现不一致 AI 用 Web Worker 深度搜索不阻塞界面；拿不到 Worker 时自动退回主线程 棋子用内联 SVG 精灵表 32 枚棋子共用一份渐变与滤镜定义，零额外请求，任意缩放清晰 音效用 WebAudio 合成 不需要打包音频，落子/吃子/将军音色各不相同 ws 是唯一运行时依赖 WebSocket 手写容易在分片、掩码、关闭握手上出错 这个项目让我对\u0026quot;做对\u0026quot;这件事的看法变了一点。\n规则能凭记忆写出来，但只有 perft 能证明你写对了。\n","date":"2026-10-12T00:00:00Z","image":"/p/chinese-cheese/cover.jpg","permalink":"/p/chinese-cheese/","title":"中国象棋：perft 证明我的规则写对了"},{"content":" 1 2 3 4 # 每轮固定转 1/47 圈，半径按 1+0.45·sin(3θ) 起伏 —— 走满一圈正好闭合 0.021277 turn 0.021277 i * 3 * sin 0.45 * 1 + 55 * fwd 0.0008 i * color 七行，画出一朵三瓣玫瑰。\n这是一门语言的完整程序。语料只有 17 个词，但它图灵完备——jz 加回跳足以表达任意控制流。\n打开一个 html 文件就能用：左边写程序，按 Ctrl+Enter 重跑，画布上出现图案。单文件、离线、零依赖。\n17 个词 词 作用 数字 / p0…p5 / i 压入常量、参数滑杆、当前轮数 + - / mod neg 二元算术；neg 取负 \u0026gt; \u0026lt; sign 比较（返回 0/1）；sign 取符号 sin cos 输入是圈不是弧度：0.25 → 90° rand 伪随机 0…1，种子固定 ⇒ 可复现 fwd back 沿朝向前进/后退（画线） turn right 转向：turn 0.25 = 左转 90° jump 画笔瞬移到 (x, y)，不画线 N st / N ld 存入 / 取出 N 号寄存器（N = 0…7） pen color width 抬落笔、色相 0…1、笔宽 jmp N / jz N 跳到第 N 行；jz 在栈顶为 0 时跳 pause / halt 交还控制权（动画靠它）/ 停机 栈是唯一的数据通道，所以约定是后缀式：写 0.25 turn，不写 turn 0.25。\n跳转用代码行号。跳到注释行或空行时会自动顺延到后面第一条真指令。\n程序会被反复执行——这就是动画的来源，i 是已经跑过的轮数。\n设计上真正需要决定的三件事 写这门语言的过程中，有三处不做决定就写不下去。\n1. 循环的进度谁提供？ 不是 while，而是「程序反复执行 + i 计数」。\n好处是写参数方程时不用自己维护计数器。代价很实在：每轮的步长必须一样。用 i 递增角度画不出闭合曲线——每轮走的角不同，永远回不到起点。\n这个坑是我自己写示例时踩到的，注释留在了「三瓣」示例里。\n2. 循环里的状态放哪？ 最初只有栈。而栈每轮必须清空，否则运行时会一圈圈涨。\n结果：累加器根本写不出来。\n所以加了寄存器：5 0 st 存、0 ld 取。\n栈保持\u0026quot;每轮归零\u0026quot;的干净语义，跨轮状态一律走寄存器。分析器因此能给出很强的保证：程序停止时栈必须是空的。\n3. 怎么保证这些约定不被写坏？ 靠 analyze()：对编译出的指令做前向数据流分析。\n一条指令被不同路径以不同栈深到达，就重新入队（上限 6 个栈深，再多就是无限增长）。\n判据是程序停止时栈深为 0。这一条覆盖三种坑：栈被弹穿、分支不均衡、循环里偷偷堆值。\n被拒绝时给的是可读的诊断：\n1 2 ✗ 栈不平衡：第 1 行（栈在循环里无限增长（同一条指令被越来越深的栈反复到达））。 每个词都要自己把栈收干净，跳转的两个分支不能一个多一个少。 编译期还有一道 peephole：N fwd 折成一条带参指令（fwd#N），既让指令数少一半，也消掉了\u0026quot;两个字面量在分支里叠在栈上\u0026quot;的歧义。\n验证 1 2 3 node verify.mjs # 63 项断言 node verify-browser.mjs # 27 项断言（真机 Chromium） node tools/make-samples.mjs --check # 逐个示例：编译 → 栈分析 → 真跑一遍 确定性：同一种子逐位一致，不同种子不同图 ⇒ 任何观察都可复现 敌意输入：空程序、只有注释、除零、取模零、寄存器槽越界、1e9 大数 —— 都不崩 性能：约 2400 万条指令/秒（单线程）；界面默认每秒执行 600 条 示例是机械生成的：生成器先编译、做栈分析、真跑 8000 步，全部通过才写回 loom.html 最后这条是被逼出来的做法——在此之前我手工改示例错了五次。\n验证抓到的 8 个真实缺陷 # 缺陷 症状 1 跳转目标被折成 [\u0026quot;lit\u0026quot;, idx] jz 拿到 0 而不是目标行，条件跳转全部失效 2 跳转占位指令多占一个槽位 目标整体错位，i 永不递增 3 跳转参数是字符串 \u0026quot;0\u0026quot; Number.isFinite(\u0026quot;0\u0026quot;) 为 false ⇒ 回边从未被分析 4 末端判定读的是执行前的栈深 1 fwd 这类正常程序被误判不平衡 5 分析器只记\u0026quot;首达\u0026quot;栈深 每轮偷偷堆值的程序被放行 6 缺\u0026quot;每轮入口栈深一致\u0026quot;不变量 同上——运行 1500 步后栈涨到 1500 7 示例里 i 0.1 color 的栈残留 每轮留一个数，栈无限增长 8 自动取景用\u0026quot;全部历史外接框\u0026quot; 随机游走把视野撑到无穷，画面全黑 第 8 条不是语言问题，是可视化策略问题。用历史包络去取景，对无界图形必然是错的；改成跟随画笔当前位置之后才正常。\n第 3 条尤其典型：\u0026quot;0\u0026quot; 和 0 在 JavaScript 里看起来都像 0，但 Number.isFinite(\u0026quot;0\u0026quot;) 是 false。于是回边从来没被分析过，静态检查等于不存在——而界面一切正常。\n它现在能画什么 它只画线。但 17 个词加上反复执行，能出来的东西比想象的多：蕨类、格栅、漂移的涡、以及那朵玫瑰。\n参数滑杆 p0…p5 让你不改代码就能调形状——这也是它作为一个\u0026quot;玩的东西\u0026quot;成立的原因。\n打开 Loom 玩一下 →\r还能往哪走 图形输出：现在只画线。加 fill / stroke 路径模式就能做面 多画笔：pen 已经是状态机，加若干\u0026quot;画笔槽\u0026quot;就能一次画多层 编辑器：行号已经在了，差行内报错高亮；analyze() 已经能给出准确行号，接口是现成的 17 个词听起来很少。\n但语言的表达能力从来不是词表决定的，是组合规则决定的。\n","date":"2026-10-11T00:00:00Z","image":"/p/loom/cover.jpg","permalink":"/p/loom/","title":"17 个词画出一朵玫瑰"},{"content":"一个极简的人工生命沙盒。规则只有三条，作用在每一对上：\n1 2 3 1) 径向力 f = w_ij · (1 − d/R) 正吸引 / 负排斥 2) 对齐力矩 速度朝邻居速度方向旋转 （群游、漩涡的来源） 3) 摩擦 每步速度 × fric 物种数量任意，相互作用矩阵 w_ij 可以编辑——所以它不是\u0026quot;一个模拟\u0026quot;，而是一个可以在上面找行为的实验台。\n拖动画布可以抓起粒子搅拌，滚轮缩放。\n我在这台实验台上发现的两件事 这些预设不是我手调出来的，是扫描脚本在规则空间里跑出来的。\n而扫描本身给了两个反直觉的结果。\n发现一：摩擦越大，体系反而越\u0026quot;活\u0026quot; 第一轮扫描 486 组对称规则（同类相吸 + 异类相斥），跑 900 步后统计\u0026quot;是否冻结\u0026quot;：\n摩擦 冻结比例 0.900 44% 0.950 31% 0.975 11% 直觉会说\u0026quot;摩擦越大越容易静止\u0026quot;。实测正相反。\n原因在于：成对吸引作用于彼此，会把动能互相抽干。\n粒子在吸引下加速、越过对方、被反向拉回——每一次都在消耗能量。摩擦弱时，这套\u0026quot;摆\u0026quot;很快把能量耗散干净，然后卡死在力平衡的位置，变成一动不动的静态点阵。\n摩擦大反而阻止了这个过程。能量来不及被抽干，体系保持缓慢滑动。\n顺带两条结论：\n高摩擦下所有活动规则都碎成 100+ 个孤立小团（最大团占比 5–11%），聚不成大结构 对齐这一项在对称规则里几乎不影响存活率，只改变形状 发现二：持续活动来自\u0026quot;力的不对称\u0026quot;，不是来自加能量 对称规则要么冻结、要么碎成小团。改一处结构就能解决：让力矩阵不对称。\nA 追 B、B 躲 A——即非互易作用。这样\u0026quot;作用—反作用\u0026quot;不再成对抵消，体系可以自己驱动自己。\n三物种循环（A 追 B，B 追 C，C 追 A）扫描 108 组：\n仍在移动的粒子 非互易矩阵 100% 同一矩阵对称化后 59–69% 对照实验很干净——同一组参数，只把矩阵对称化：\n1 2 3 4 chase flee fric align core | 非互易 moved/phi/团数 | 对称化 moved/phi/团数 0.80 -0.70 0.985 0.00 2.0 | 100% 0.694 186 | 59% 0.440 134 1.20 -1.20 0.985 0.00 2.0 | 100% 0.622 161 | 63% 0.182 126 0.40 -0.20 0.960 0.30 4.0 | 98% 0.724 75 | 69% 0.437 73 最好的规则（「翻涌」预设）：\n1 2 3 [[0, 1.2, -0.7], [-0.7, 0, 1.2], [1.2, -0.7, 0]] // 摩擦 0.96，对齐 0.3，斥核 4.0 100% 粒子持续移动，混合度 Φ=0.48，最大团占比 16%，而\u0026quot;结构还在重组\u0026quot;的指标（末段熵的波动）是全扫描最高——0.0245。\n把\u0026quot;看起来有意思\u0026quot;变成可测量的数 界面右侧的指标都是可证伪的，不是装饰：\n指标 定义 判读 聚类系数 C 你邻居的邻居也是你邻居的概率 随机撒点≈0，成团后上升 最大团占比 按并查集连通的最大团 / 总数 晶体≈100%，气体≈0 混合度 Φ 每个粒子\u0026quot;最近异类距离 ≤ 最近同类距离\u0026quot;的比例 相分离→0，追逐/共生→高 空间熵 H 网格计数的归一化熵 均匀→1，塌缩成团→下降 不可预测性 与\u0026quot;同初值 + 1.5px 微扰\u0026quot;副本的平均位置发散 从 0.02 起步，1200 步后可读 实测对照（1500 步，800–1100 粒子）：\n预设 C Φ H 最大团 动能 翻涌（非互易） 0.85 0.48 0.74 16% 5.61 生命边缘（对称） 1.00 0.13 0.79 98% ~0 结晶（对称、低摩擦） 0.77 0.95 0.79 33% 0.004 相分离 1.00 0.02 0.75 61% 1.64 原始汤（无作用） 0.57 0.49 0.77 98% ~0 「相分离」的 Φ=0.02 和「翻涌」的 Φ=0.48——同一个指标把两种完全不同的结构分开了。\n不可预测性上，「翻涌」的最大发散是冻结体系的数十倍。\n同一个 1.5px 的扰动，在自我驱动的体系里会长成完全不同的世界，在冻结体系里什么都不会发生。\n验证：不靠肉眼 1 2 node verify.mjs # 71 项断言 node verify-browser.mjs # 34 项断言（真机 Chromium） 确定性：同一种子两次运行逐位一致；不同种子不同 ⇒ 任何观察可复现 数值健全：7 个预设各跑 1500 步，无 NaN / Infinity，坐标不出界，速度有界 指标区分力：三个结构指标在预设之间跨度都在 0.05 以上 物理直觉断言：相分离的 Φ 必须低于追逐体系；晶体的最大团必须大于原始汤 性能：单线程 900 粒子 0.19 ms/步、3000 粒子 1.4 ms/步；浏览器里 3000 粒子仍 45–60 fps 关键的一条：verify.mjs 直接从 HTML 里抽出 UMD 核心在 Node 里跑。\n所以\u0026quot;这个模拟是什么行为\u0026quot;可以用脚本回答，不需要打开浏览器用眼睛猜。\n打开原始汤实验室 →\r还能往哪走 能量守恒版本：现在是耗散系统，加入速度相关的能量注入，看它是否进入稳态湍流 自适应规则：让 w_ij 本身随接触历史变化，看是否自发出现分工或领地 三维：核心已经是纯数组合集，换成三个坐标即可，代价只是渲染要改写 这个项目最让我意外的地方，不是某个预设好看。\n是\u0026ldquo;加能量\u0026quot;并不是让系统活起来的唯一办法——把对称性打破，它自己就会动。\n","date":"2026-10-10T00:00:00Z","image":"/p/primordium/cover.jpg","permalink":"/p/primordium/","title":"摩擦越大，原始汤越活"}]