两个文件,各一千多字节,同一天改的:
服务端.源码.e 1,729 字节 2016-10-02
客户端.源码.e 2,115 字节 2016-10-02
这一对我几乎可以肯定就是用来搞清楚格式的那两个文件 —— 因为在旗舰篇里,我推导长度前缀、窗体记录形状、支持库表项结构,用的全是它俩。
但也是这一对,让我推翻了自己几天前刚写下的话。
一、研究格式要找最小的标本
服务端.源码.e 1,729 字节,扫描产出 20 条串。全部列出来:
0 CNWTEPRG ← 容器魔数
20 嫉溪E突盼 ← 伪影
180 犆壕E位盼 ← 伪影
368 捞噪な协盼 ← 伪影
408 (无意义)
480 _启动窗口 ← 窗体名
493 在程序启动后自动调入本窗口 ← 窗体说明
678 服务器1 ← 控件:监听
796 捞与盼 ← 伪影
924 d09f2340818511d396f6aaf844c7e325 ← krnln 的 GUID
961 系统核心支持库 ← 唯一一个支持库
1015 窗口程序集1
1114 栗楼罚佛盼 ← 伪影
1220 躺吵恭墩r匙s ← 伪影
1324 测试端口映射.exe ← 容器里存的是带目录的全路径
1408 客户1 ← 控件:连接
1414 __启动窗口_创建完毕 ← 唯一的事件
1434 服务器1
1442 __启动窗口_创建完毕
1462 客户1
1500 锣荡E苹盼; ← 伪影
20 条里有 8 条是伪影 —— 这个比例(40%)正好也是旗舰篇里那套统计筛法要解决的问题。在小样本上,伪影不是噪音,它就是主要产出,所以你被迫把每一条都查到字节级才算读完。
这就是为什么它是最好的格式标本。三个我后来一直沿用的结论,都是在它身上定下来的:
长度前缀
0x01dc 09 00 00 00 │ 5f c6 f4 b6 af b4 b0 bf da = 9 "_启动窗口"
0x01e9 1a 00 00 00 │ d4 da cc d0 f2 c6 f4 b6 af ba f3 ... = 26 "在程序启动后自动调入本窗口"
0x03f3 0b 00 00 00 │ b4 b0 bf da b3 cc d0 f2 bc af 31 = 11 "窗口程序集1"
LE uint32,等于正文字节数。文件 1,729 字节,只有这几条记录,所以这个结论不可能是巧合 —— 大文件里你无法排除"猜对了三条但格式其实另有解释"。
支持库表项长什么样
0x0386 39 00 00 00 = 57
"krnln" 0d "d09f2340818511d396f6aaf844c7e325" 0d "5" 0d "0" 0d "系统核心支持库" 00
krnln 是核心库的内核名(另一个文件里能直接扫到字面量 krnln.fnr),接着是 GUID、版本 5.0、显示名,末尾多出来的 1 字节是 NUL。
同一个 GUID d09f2340… 在 26 个工程里全部相同 —— 所以它标识的是这个库,不是这个工程。这一点我在最初版本里读错了,把 d09f2340818511d396f6aaf844c7e325 当成"项目 ID"写进了草稿。是小文件让我看到它只有一个库、GUID 只有一个,才去别的文件里对了一遍。
窗体记录有固定骨架
小文件里窗体区只有一两个控件,于是骨架看得最清楚:窗体名 _启动窗口(480)→ 窗体说明(493)→ 控件名(678)→ 支持库表项(924 起)→ 程序集名(1015)→ 事件名(1414)。
二、然后我发现它什么都不做
两个文件的全部有效内容:
| 服务端.源码.e | 客户端.源码.e | |
|---|---|---|
| 窗体 | _启动窗口 | _启动窗口 |
| 控件 | 服务器1 + 客户1 | 客户1 + 服务器1 |
| 支持库 | 系统核心支持库 | 系统核心支持库 |
| 程序集 | 窗口程序集1 | 窗口程序集1 |
| 事件处理函数 | __启动窗口_创建完毕 | __启动窗口_创建完毕 |
| 字符串常量 | (无) | 成功、失败 |
| 编译产物 | 测试端口映射.exe | 同名 |
我给仓库 README 写过的是这句:
两边把收到的数据朝另一头转发——一个最小可用的 TCP 中继。
准备写这篇时我去数了几个词的出现次数:
>>> for k in ['数据到达', '创建完毕', '被单击', '连接', '发送数据', '监听', '端口']:
... print(k, d.count(k.encode('gbk')))
数据到达 0 ← 中继必须有这个事件
创建完毕 2
被单击 0 ← 界面上没有任何按钮
连接 0
发送数据 0
监听 0
端口 1 ← 来自 exe 文件名「测试端口映射」
_服务器1_数据到达 和 _客户1_数据到达 一次都没有出现。
对比同批归档里真正在做网络的事:
LP(服务端)/LP聊天器服务器.e有_服务器1_数据到达、_服务器1_客户进入、_服务器1_客户离开LP远程遥控/客户端.e有_客户1_数据到达- 2016 年那版聊天客户端甚至留着一行源码原文:
如果 (客户1.连接 (#IP, 20930) = 假)
这三个都明明白白写了事件名。端口映射测试里没有。
所以它是这样:界面上拖了一个 服务器1、一个 客户1,唯一挂的事件是窗口创建完毕,客户端里两个常量 成功 / 失败 —— 大概是"连接上没连接上"的提示词,写在一个编辑框或者标签上。
也就是说这两个工程停在**“我把两个网络控件放上去了,我知道它们在创建完毕里会被调用,然后就没有然后了”**。没有转发,没有中继,没有端口映射。
而且两边的编译目标同名 测试端口映射.exe —— 后编译的那个直接覆盖前一个。真要跑中继,两个程序不可能同名同路径。这是第二条独立的证据。
三、零命中怎么用来证伪自己的故事
我原本那个说法不是瞎编的 —— 它是从"两个工程各自同时持有 服务器1 和 客户1“这个真实观察里,顺着"端口映射"这个名字推出来的。观察对,推论过头。
破它的办法非常便宜:去找"我的推论必然导致容器里出现什么”,然后数它出现几次。
如果它真的转发数据 → 必然有 _*_数据到达 事件 → 实测 0 次
如果界面上有按钮操作 → 必然有 _按钮N_被单击 → 实测 0 次
如果两个程序要分别跑 → 编译产物路径应当不同 → 实测相同
三条全部不成立,所以原来的故事是错的。这类检查之所以值当,是因为它只要数字符串,不需要读懂任何东西 —— 在你完全不了解一个格式时,“证伪"比"证实"便宜得多,也可靠得多。
写文章的时候,零命中的价值经常被忽略:大家乐意贴"我扫到了 XX”,不乐意写"我扫不到 YY,所以它一定没做 YY"。但后者才是能钉死结论的那一半。
(这篇也是这个系列里第一次出现"我需要修改已提交仓库的 README" —— 那个改正跟着这篇一起进仓。)
四、补一段:两年后他把这件事做完了
上面那些零命中只证明这两个文件没实现转发,不证明我没做到。发出去之后我把另一批旧工程解成了文本,里面躺着一个真版本 —— 2018-07 的一个 MC 辅助工具,它的第一个按钮干的事就是端口映射:
.子程序 连接服务器与映射, 文本型
.参数 IP, 文本型
.参数 端口, 整数型
.局部变量 端口1, 整数型
.如果 (客户1.连接 (IP, 端口) = 真)
置随机数种子 ()
端口1 = 取随机数 (12914, 25565)
.循环判断首 ()
端口1 = 端口1 + 1
服务器1.端口 = 端口1
.循环判断尾 (客户2.连接 (“127.0.0.1”, 端口1) = 假)
客户2.断开连接 ()
返回 (“127.0.0.1:” + 到文本 (端口1))
.否则
返回 (“尝试连接服务器失败”)
.如果结束
找空闲本地端口的手法值得单独说一句:它不去问系统哪个端口能用,而是随机起一个端口、让 服务器1 去占、再用自己的 客户2 回环连它一下 —— 连上了就说明这个端口可用,于是 断开 掉 客户2,把 127.0.0.1:端口 返回给用户当映射地址。笨,但是自洽,而且不需要任何额外权限。
然后是这节存在的理由 —— 它终于双向转发了:
.子程序 _服务器1_数据到达
客户1.发送数据 (服务器1.取回数据 ())
编辑框4.加入文本 (到文本 (服务器1.取回数据 ()) + #换行符)
.子程序 _客户1_数据到达
服务器1.发送数据 (目标, 客户1.取回数据 (), )
编辑框5.加入文本 (到文本 (客户1.取回数据 ()) + #换行符)
正是我第一篇里说"必然有 _*_数据到达 事件"的那个东西 —— 在 2016 那两个文件里是 0 次,在这里两条都有。
所以那三行"零命中"的结论要这样读:它们证伪的是"这两个文件是端口映射器",不是"我没做出过端口映射器"。 我当时把前者写成后者,读起来像是我卡在原地了 —— 其实只是我先把结论收窄到了手上这一份证据,没往前翻两年。
顺带两个从同一段代码里长出来的细节:两个方向的 数据到达 都同时 加入成员 (录制, 取回数据 ()),另有个按钮把 录制[] 整条按原序双向重放;而 _服务器1_数据到达 里有一句 取回数据 () ≠ {2, 3, 1} 的过滤 —— 一个用字面字节序列认出来的握手包。这不是"端口映射"了,是一台带录放的中间人。
五、所以这个仓里到底有什么
两个总共 3.8 KB 的 .e,一个空的 TCP 试验台。它的实用价值是零。
它的价值在另外两处:
- 它是这套读法的实验台。 长度前缀、支持库表项、窗体记录骨架,三条格式结论都是在这 3.8 KB 上定下来,然后才敢拿去套 878 KB 的聊天器。如果你要读一个陌生的专有容器,第一步是找一个它最小的样本 —— 越小越能排除巧合,伪影也越少到可以逐条查。
- 它是那个年代的真实水平记录。 2016 年 10 月的我,做了聊天器、做了远程控制之后,想知道"数据怎么从 A 穿到 B"。我打开易语言,把
服务器和客户两个控件拖上去,给两边都拖了一份,然后……那天没写完。目录名叫"测试",名字倒是完全诚实。