两个文件,各一千多字节,同一天改的:

服务端.源码.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 试验台。它的实用价值是零。

它的价值在另外两处:

  1. 它是这套读法的实验台。 长度前缀、支持库表项、窗体记录骨架,三条格式结论都是在这 3.8 KB 上定下来,然后才敢拿去套 878 KB 的聊天器。如果你要读一个陌生的专有容器,第一步是找一个它最小的样本 —— 越小越能排除巧合,伪影也越少到可以逐条查。
  2. 它是那个年代的真实水平记录。 2016 年 10 月的我,做了聊天器、做了远程控制之后,想知道"数据怎么从 A 穿到 B"。我打开易语言,把 服务器 和 客户 两个控件拖上去,给两边都拖了一份,然后……那天没写完。目录名叫"测试",名字倒是完全诚实。

仓库

https://cnb.cool/t1mmoc/tspace/eyuyan/lp-port-mapper