同一时期(2016-03-28 → 2018-08-08)的又一批小文件,来自另一台机器,三个互不相干:一个按后缀列文件(5,241 字节),一个线程化播放 mp3(7,730 字节),一个是取 QQ 头像和昵称的易语言模块(3,274 字节)。共同点不在题材,在读出来的东西:这些 API 当年都没有文档给他看,用法全是试出来的。试对的方法和试错的方法都原样躺在源码里——这种东西比"做成了什么"值钱。

三份都用 e-packager 解成了文本源码,控制流(.如果 / .计次循环首 / .循环判断尾)是原样回来的,所以下面讲"怎么做的"。先立一个交代:解出来的文本带工具痕迹,比如字符串里的 #e2txt_text#\,在 IDE 里看就是 \;本文引用源码时按这个方式还原了 #e2txt_text#\,其余保持抽取原样,有出入以 IDE 打开 .e 为准。

一、寻找文件:同一个原语,一处用对,一处半懂

Win32 的老文件枚举协议是 FindFirstFile / FindNextFile:第一次带模式调用,之后只推进、不再改模式。易语言核心命令 寻找文件 包的正是这个形状:首次 寻找文件 (模式, ),后续用 寻找文件 (, )(空参数)继续推进,没有结果时返回空串。两个程序用了同一个原语。

先看用得对的。低配播放器.e 的 找音乐(2018-08-08):

文件夹 = 浏览文件夹 ("请选择您要打开的文件夹,播放器将会播放里面的所有音乐", )
.如果真 (文件夹 ≠ “”)
    加入成员 (音乐, 文件夹 + “\” + 寻找文件 (文件夹 + “\*.mp3”, ))
    .循环判断首 ()
        加入成员 (音乐, 文件夹 + “\” + 寻找文件 (, ))
    .循环判断尾 (音乐[取数组成员数 (音乐)] ≠ 文件夹 + “\”)
    删除成员 (音乐, 取数组成员数 (音乐), 1)

他不知道还剩几个文件,也没有显式判空串,结束检测靠的是:每轮把迭代结果拼上目录前缀 文件夹 + "\" 塞进 音乐 数组尾部,然后拿数组最后一项等不等于那个前缀本身当循环条件(易语言 .循环判断尾 (条件) 是"满足就继续"的后测循环)。原语迭代到没有结果时返回空串,拼完的串退化成 文件夹\——恰好等于前缀,条件翻假退出;删除成员 再把这条退化项删掉。整套"判断迭代到头"没有多一个状态变量:把迭代器耗尽时的退化形态直接写成循环条件。凡是不问数量、只看"输出塌回常量形状"的迭代都能这么收尾,这比易语言本身走得远。

这个哨兵还有个能从源码推出的小漏洞:.循环判断首 () 不带条件就是"先跑一遍再判",所以目录里一首 mp3 都没有时,第一次 加入成员 已经塞进一条退化项,循环体照跑又塞一条,删除成员 只删最后一条——数组里留下一条 文件夹\,放音乐 会把这条不是文件的路径当第一首歌往下放。

再看用得半懂的。1.e(2018-01-26)的 寻找文件1,参数注释写着「要加上*. 例如:jpg即填*.jpg」:

a = 寻找文件 (后缀, )
C = 分割文本 (a, "\", )
.计次循环首 (取数组成员数 (C) - 1, n)
    E = E + C[n]
.计次循环尾 ()
加入文本 (E)
列表框1.加入项目 (E, )
.判断循环首 (1 = 1)
    b = 寻找文件 (, )
    .如果真 (b = “”)
        跳出循环 ()
    .如果真结束
    D = 分割文本 (b, "\", )
    .计次循环首 (取数组成员数 (D) - 1, n)
        F = F + D[n]
    .计次循环尾 ()
    .如果真 (E ≠ F)
        列表框1.加入项目 (F, )
    .如果真结束
.判断循环尾 ()

迭代那一半是对的:首次带模式、空参在死循环里推进、拿到空串跳出——和 04 同一个协议,只是写得笨一点(1 = 1 配显式 跳出循环)。问题出在切分拼回:分割文本 按 \ 切,计次循环首 只跑 成员数 - 1 轮,数组最后一段必然被丢掉,拼回去时又不把 \ 补回来。丢掉的那段是文件名还是别的,这份源码自己回答不了,要看 寻找文件 的返回形态。04 的写法偏向"返回裸文件名"(04 是把目录前缀拼上去用的,返回值里再带路径的话 04 的播放列表就全废了);按这个形态,这里 分割文本 出来只有一段,成员数 - 1 等于 0,循环跑零轮,E、F 都是空串——列表框里加进去的是一个空串项,E ≠ F 比较的两个空串永远相等,之后一项都加不进去。反过来,若返回的是全路径,被丢的恰好是文件名,留下的是"没了分隔符的目录串",而且 F 在轮次之间不重置(F = F + D[n] 是往上累加),从第二个文件起拼进列表的是越滚越长的垃圾串。两种读法共有的结论只有一条:这段切分拼回哪边都没留住,列表框里出现的永远不是文件名。同一行的 加入文本 (a) 倒是在编辑框2里留下了原样的首个返回值——真正有用的输出反而是那行没经过任何处理的。

同一原语两处用法摆在一起,能拿走的是那句话:用一个 API 之前,先验证它返回值到底长什么样。哨兵那位验证过(退化形态就是他亲手测出来的形状),切分这位没有,于是所有下游处理建在一个他没确认过的假设上。

二、没有事件回调就拿时长当日程,没有任务结构就拿数组当表

低配播放器.e 声明了六个支持库:shell / shellEx / eMMedia / edroptarget / EThread / spec。播放线程的全部调度是:

媒体播放1.打开 (音乐[计次])
媒体播放1.播放 (-1, )
标签1.标题 = “您正在播放:” + 音乐[计次]
延迟 (媒体播放1.取长度 ())
计次 = 计次 + 1

没有"播放完成"回调,也不需要:媒体播放1.取长度 () 给出整首歌的时长,延迟 收毫秒参数,他直接把前者喂给后者——拿整首歌的毫秒数做阻塞睡眠,睡到"正常播就该播完了"再放下一首。任务有可知的固有时长时,用睡眠代替等待,是最便宜的调度器。代价是睡眠期间不可打断,他显然清楚这点,因为 F6 热键的回调就是那个外部打断器:暂停 / 停止 / 关闭 三连(每步都包在 调试输出 里打印返回值),计次 = 计次 + 1,重起播放线程。播放 (-1) 那个参数的语义没在这份源码里留下任何线索,本文不猜。

任务表更粗暴:程序集变量 句柄 是长度 2 的整数型数组,槽 [1] 永远给 找音乐、槽 [2] 永远给 放音乐,启动线程 (&放音乐, , 句柄[2]) 把句柄写回对应槽。凡是要重来(F5、F12、热键回调的 else 分支)都先把两个槽各调一遍 关闭线程句柄,再重新 启动线程。没有任务结构体、没有注册注销,就两条位置约定加一把"全关掉再起"。边界说死:这张表管的是句柄槽位,管不了线程死活——关闭线程句柄 之后线程里那个整曲睡眠照旧跑完,播放线程的旧歌不会被掐断(F5 分支连 媒体播放1.关闭 都调了,热键分支靠暂停停止关闭掐的是播放器,不是线程)。

还有一处这份代码自己证明不了的,我不替他圆:计次 唯一的初始化在 __启动窗口_创建完毕 里的 计次 = 1,之后源码里只剩两处 计次 = 计次 + 1,没有任何一处把它绕回 1。放完一轮的循环条件是 .循环判断尾 (循环 或 计次 ≤ 取数组成员数 (音乐))——顺序模式(循环=假)干净:过界即退、清空数组。循环模式条件恒真,计次 必然越过数组末尾,音乐[计次] 越界访问在运行时是什么行为,静态源码给不出答案,所以"循环模式能否回到第一首重放"这份代码证明不了。

三、没有 JSON 库:切两刀,第二刀的隔栏是手抄的

取QQ皮肤.e(2016-03-28)是个易模块——不是可执行程序:没有窗体,解包元数据里工程子系统类型是 unknown,源码是模块模板原样。支持库 internet / iconv,两个功能子程序。头像那个很直白:

取QQ头像, 字节集, 公开
返回 (HTTP读文件 ("http://q.qlogo.cn/headimg_dl?dst_uin=" + 到文本 (QQ账号) + "&spec=640&img_type=jpg"))

昵称那个才是本篇第三节的主角:

文本 = 到文本 (编码转换 (HTTP读文件 ("http://base.qzone.qq.com/fcg-bin/cgi_get_portrait.fcg?uins=" + 到文本 (QQ账号)), #编码_UTF_8, #编码_GBK, ))
分割 = 分割文本 (文本, #引号, )
分割 = 分割文本 (分割[取数组成员数 (分割) - 1], “?” + #引号 +“,0]})” + #右引号, )
.如果真 (取数组成员数 (分割) = 0)
    返回 (“未知???”)
.如果真结束
返回 (分割[1])

拆成三步:先 编码转换 UTF-8 → GBK(接口给 UTF-8,易语言文本在系统代码页里,不转中文昵称就是乱码);分割文本 按 #引号 把整个响应切成引号段,取倒数第二段;再用一条手抄的分隔片段切第二次,取 分割[1] 当昵称。“没有解析器就从响应里抠一个字段”,靠字段四周的锚点下刀——这个方法本身可迁移,任何非结构化文本抓字段都是这一手。脆的地方在锚点的选择:第二刀的分隔串是 "?" + #引号 + ",0]})" + #右引号,,0]}) 那截是响应里的结构字符,可开头的 ? 是全角问号——按这个串能切开,说明他抄的那一条响应里,昵称本身以 ? 结尾。他从哪一条真实响应手抄的这段,源码定不了;坐得住的结论是:这条分隔串贴合的是那一天一条具体响应的形状,昵称不含 ?、字段顺序一变、响应多一段,第二刀就切不开,掉进 未知??? 兜底。他自己知道这条路会断,兜底就是证据。锚点要挑接口保证的结构文本,不是某条响应的偶然内容——这是"抠字段"和"抄答案"的分界线。

顺带:这个模块名字叫"取QQ皮肤",代码里没有任何皮肤相关的调用,取的是头像和昵称。命名和实现不符,归档保留原名,照实记录。

四、接口存活实测(2026-10-17,合成 uin)

两个 2016 年的 URL,今天还剩几个?我用合成 uin 1 各发了一次实测(不是作者的真实号码,那个在仓库和本文里一律是 XXXXXXXXXX,QQ 号已打码):

请求状态码Content-Type响应字节
http://q.qlogo.cn/headimg_dl?dst_uin=1&spec=640&img_type=jpg200image/jpeg1,512
http://base.qzone.qq.com/fcg-bin/cgi_get_portrait.fcg?uins=1302text/html137(Location:同路径的 https 版)
跟随重定向 https://base.qzone.qq.com/fcg-bin/cgi_get_portrait.fcg?uins=1200text/html; charset=utf-8880

头像接口还活着:200,真 JPEG 响应,1,512 字节。十年前的 URL,今天直接能调。昵称接口已经不给数据了:http 回 302 跳 https,拿回来的 880 字节是一段"页面找不回来了"样式的 HTML(腾讯公益 404 页面),不再是那份带引号段和 ,0]}) 的文本。今天原样运行 取QQ账号,能走到的只剩 未知???。两个都是腾讯国内域名,本机直连测的,不存在"境外域名被本地 DNS 过滤所以测不准"的问题。这一轮定不了的只有一件事:真实注册 QQ 号今天从这个 fcg 能拿到什么——我只用了合成 uin,不该也没有拿真号去试;但对 uin=1 返回的是 404 页而不是数据,已经足够坐实这份代码的数据源断了。第三节的结论和这节互相印证:手抄的分隔串,就算接口还活着也会随一条响应的形状变化而断;而这里连接口本身都没活下来。

五、一个注定会消失的测试入口

程序集1.txt 的开头是易语言模块模板的原样:_启动子程序(模块初始化入口)调 _临时子程序,而 _临时子程序 上方保留着 IDE 生成的那句注释:

' 本名称子程序用作测试程序用,仅在开发及调试环境中有效,编译发布程序前将被系统自动清空,
' 请将所有用作测试的临时代码放在本子程序中。 ***注意不要修改本子程序的名称、参数及返回值类型。
取QQ账号 (XXXXXXXXXX)

这条机制的边界要说死:易语言把测试代码的位置定在一个由系统保证在发布前清空的专用子程序里,所以这类模块的测试入口在发布产物里必然不存在——不是作者忘了删,是模板机制替他删。反过来说,拿到编译产物的人永远复原不出作者当初测了什么,见过那句调用的只有 IDE 里的调试环境。这个文件还多一层:它是模块不是程序,_启动子程序 只在宿主程序加载模块时执行,所以"这两个公开子程序被谁、在什么账号上调用"没有任何信息存在这份文件里。

脱敏交代:那个 QQ 号在 .e 容器里不是文本存法——全文扫这 10 位数字的 ASCII/GBK 字节序列,0 命中;它以 8 字节小端 double 数值常量存在(偏移 0x5ab,1 处命中)。等长脱敏按容器真实形态做:8 字节换 8 字节,原值替换为合成数 1(和接口实测用的同一个),容器总长 3,274 字节不变。脱敏后的 .e 重新解包、与脱敏前的文本逐字节 diff:只有 程序集1.txt 第 15 行一处不同(取QQ账号 (XXXXXXXXXX) → 取QQ账号 (1),该行 1452 → 1,443 字节全文件长度差由此来),其余所有文件逐字节全等——容器活着,动的只有那一条记录。另外两个文件本来就不含该值,MD5 与原文件一致,零改动。

六、做了一半的东西,照原样留着

  • 拖放开了口没接:__启动窗口_创建完毕 里 拖放对象1.注册拖放控件 (取窗口句柄 ()) 注册是真的做了;_拖放对象1_得到文件 连参数(接收到的文件路径, 文本型)都声明了,函数体是空的。往窗口上拖一个 mp3,什么都不会发生——一个挖好了却没浇铸的接口。
  • 命名与实现不符:取QQ皮肤取的不是皮肤(第三节说过)。
  • 没有处理器的按钮:1.e 的 按钮2 标题"清空",查找期间跟着按钮1 一起被置 禁止,但源码和窗体 XML 里都没有它的被单击处理器——点它不会清空任何东西,它唯一一次被"使用"是被禁用。
  • 热键回调拿虚键码来比:_标签1_反馈事件 的分流是 参数一 = #P键 走"隐藏/显示窗体",否则一律按"下一首"处理(注册热键 当时恰好只注册了 Alt+P 和无修饰的 F6 两个,所以"否则"在正常使用里就是 F6)。热键消息落点是 标签1 的窗口句柄这个机制,MC 中继那篇已经讲透,不重复;这里只补一个源码事实:两份文件对 参数一 的比法不一样——那一份拿 注册热键 的返回值存变量再比,这一份直接比虚拟键码常量。参数一 里装的到底是哪一个、这份的 #P键 比法在运行时是否真的命中,静态源码定不了,我不替他圆场。

这个仓里有什么

https://cnb.cool/t1mmoc/tspace/eyuyan/epl-small-tools(public)。

根目录是三个 .e 本体,保持原始文件名(脱敏只落在 取QQ皮肤.e 上,见第五节);text/01_1/、text/04_低配播放器/、text/06_取QQ皮肤/ 是 e-packager 解出的文本源码:程序集 .txt、窗体 .xml,以及常量/全局变量/数据类型/DLL 声明四个占位文件(空也保留,只有 BOM)。三个 .e(含脱敏后副本)都用结构法复核过——MZ → e_lfanew 落在 0x40–0x400 → PE\0\0 → 节表——检出合法内嵌 PE 头 0 个,仓里没有任何可执行载荷。解包工具是 e-packager(MIT),#e2txt_text# 痕迹按开头的交代处理,以 IDE 打开 .e 为准。

最后

三份文件,2016-03-28、2018-01-26、2018-08-08——和前面几篇同一条时间线,另一台机器上的他。动机文件里读不出来,我就不编;方法能带走。从这一批里我能拿走的五件事:用 API 前先验证返回值长什么样;不问"还剩几个",把迭代耗尽的退化形态写成循环条件;没有回调时,任务有可知时长就用睡眠代替等待;没有解析器可以抠字段,但锚点要挑接口保证的结构而不是某条响应的巧合;以及,测试代码写在哪个位置,决定了它在发布产物里活不活得下来。