这台机器上有一个真实的 TI-Nspire 开发环境:OS 3.9.0.463 classic Touchpad、Ndless、从源码构建的 ARM 工具链。
目录里躺着 1055 个 .tns 文件。
.tns 不是一个格式。它是一层 PRG\0 外壳,里面是 Zehn 容器——Ndless 自己的可执行容器格式。而这个格式没有公开文档。
这个项目把它读懂了,并且用「能不能逐字节重建」来证明。
自己解出来的格式
| |
两个坑,我都先踩了
坑一:「Zehn」这个字符串会骗你
最直接的思路是——搜文件里 Zehn 这四个字节,从那里开始解。
但装载器桩里也出现了这个字符串。
只搜这 4 个字节会找到诱饵。真正的头必须同时满足三个条件:
version == 1- 各表长度放得下(不越界)
file_size确实落在文件范围内
光靠一个魔数是不够的。 得用那些字段之间的一致性来互相验证。
坑二:「能力」不等于「要求」
genzehn 的 --color-support 默认是 true。
它的含义是「支持 CX/CM 彩屏」,不是「需要彩屏」。
我一开始理解反了,于是每一个正常程序都被判成「需要彩屏,所以在这台黑白机上跑不了」——一个完全反向的结论,而且看起来非常合理。
正确判据必须看值为 0:0 才表示"不支持这类机器"。
这个坑值得单独说:当一个字段名叫
supports,人们会下意识把它读成requires。
验证:逐字节重建
rebuild() 从解析出来的字段把容器重新拼回去,然后和原文件比字节。
| |
这比"解析没崩溃"强得多。
因为偏移、计数、位域切分、字节序、字符串在额外数据里的定位——任何一处理解错,字节就对不上。
它一次同时检验所有假设。
语料是 1055 个真实文件,不是精选样本
其中 100 个是真正的 Ndless 程序,955 个只是沿用了 .tns 扩展名的文档/资源(boot1.img.tns、Hitori 关卡、Liero 素材……)。
一个只见过"干净样本"的解析器,证明力弱得多。跨十年、多个工具链、8 种不同桩的 100 个真实容器,才是像样的考验。
手写一个 DEFLATE 解码器
Zehn 的压缩用 zlib。
但语料里没有一个压缩容器——光靠真实文件测不到这条路。
所以自己按格式造一个再读回来。
解码器是手写的 RFC 1951 + 1950:stored / fixed / dynamic 三种块、LZ77 回引用、Adler-32 校验。
然后对照系统 zlib,在 40 组样本上比对输出(10 种载荷 × 4 个压缩级别)。
一个实现自己和自己一致,说明不了什么。两个独立实现一致,才算证据。
一个被窄样本骗过的断言
tests/test_zehn.py 打印出来的实测结果:
| 桩大小 | 容器数 |
|---|---|
| 0x1EC (492) | 53 |
| 0 | 17 |
| 632 | 11 |
| 480 | 8 |
| 616 | 6 |
| 604 | 5 |
17 个容器完全没有桩——容器就在文件偏移 0。
我最初写了一条「所有程序共用一个桩」的断言,在 14 个 samples-tns 上全部通过,在全语料上直接失败。
这正是把断言写在窄样本上会骗人的例子:样本越小,错误越自信。
兼容性判定:一个被推翻的直觉
compat.py 拿程序自己声明的能力和具体机器对判。
这台机器的约束很紧:classic 非 CX、320×240 黑白、Touchpad、Ndless r2016、64MB。
原本的直觉解释是:「SDK 是 r2022、设备是 r2016,所以自写的程序被拒载重启」。
但实测结论是:这 100 个程序里,没有一个声明 Ndless 版本窗口(NDLESS_VERSION_MIN / REVISION_MIN 全缺)。
也就是说 版本不匹配不会导致装载被拒。
那条"合理"的解释,直接被数据否掉了。真正的原因得往别处找——ut_read_os_version_index() 的地址哈希、sc_addrs_ptr 查表、或者 classic 上看门狗没关。
这是这个项目最值钱的一条:一个解释听起来越合理,越要拿数据去撞它。
还能往哪走
- 容器写入器:现在只会重建已有容器。接上 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 的表做差集——那才是"为什么重启"的正确问法
技术细节
| 文件 | 作用 |
|---|---|
zehnscope/core.py | 容器解析 + 逐字节重建 |
zehnscope/inflate.py | 手写 DEFLATE/zlib 解码器 |
zehnscope/__main__.py | CLI:inspect / --rebuild |
compat.py | 能力 vs 机型判定(含"能力≠要求"的说明) |
tests/test_zehn.py | 18 项断言 |
逆向工程真正的方法论,不是"猜对格式"。
是找到一种只有完全理解它才能通过的检验,然后不断撞上去。
逐字节重建就是这样一种检验。
