标题起得跟红警3无关,是因为这篇不想卖情怀。它想说的是三件你在任何语言、任何项目里都会碰到的事:怎么让一个程序在开发机和在别人机器上 behave 得不一样,怎么在没有 DOM 库的情况下手搓一份结构文本,以及怎么用"扫目录"这件事本身来充当清单。东西是二十行不到的易语言控制台代码,越短越能把方法看干净。
先给个定位。这不是一个 MOD,是一个生成 MOD 用的装配文件的小工具:它扫描一个目录里躺着的资源文件,把它们拼成一份 XML 清单,写回去。程序没有窗口,入口就是 _启动子程序。它的价值全在这几个动作的写法上。我把它拆开讲,不是因为它多精巧,恰恰因为它简陋——二十来行、没有抽象、没有库,简陋到每个决定都必须当场做给你看,反而比一个裹了三层封装的现代项目更能看清"当时为什么这么写、这么写能拿走什么"。
一、是否为调试版() 是一个两档开关,不是一个调试打印
代码第一段就把很多人写死的东西拆成了两条路:
.如果 (是否为调试版 ())
目录 = "E:\Red Alert 3\RA3 MODSDK-X\Mods\M02\Data"
.否则
目录 = 取运行目录 ()
.如果结束
是否为调试版 () 判的是"我现在是在 IDE 里被直接按运行,还是已经被编译成 exe 在跑"。IDE 里跑,它认那个写死的绝对路径;编译出去跑,它改用 取运行目录 (),也就是 exe 自己所在的那个目录。
这一点之所以值得单独拎出来讲:那个 E:\Red Alert 3\... 是一句货真价实的开发期便利——作者在写的时候,机器上就摆着这么个目录,IDE 里一键运行想都别想路径的事。但便利不能进成品。如果无条件写死,编译出去的 exe 换台机器、换个盘就废了。他做的处理是把这个写死值锁死在"调试版"这一档里,发行档走的是相对自己位置的 取运行目录 ()。于是成品里那条绝对路径根本不会被执行到。
这套写法可以搬到任何语言里去:把"我开发时偷懒用的绝对位置"包进一个只在开发态成立的分支,发行态一律走相对量或者配置。它跟那种"读环境变量、读配置文件、读命令行参数"是一个家族的,只是这里用编译器自身的调试态当开关,省了一个配置项,也就省了"忘了配就崩"的那类事故——它不需要任何外部输入就能选对路,代价是把选择权焊死在了运行形态上。
要注意这是个取巧:它依赖"调试/非调试"恰好等价于"我在自己机器上/我在别人机器上",这两件事多数时候重合,但不是永远。真要把成品拿到第三台机器上、又想让它读一个非默认目录,这个开关就给不出出路了——那种场景该走配置或命令行参数,不该指望调试态。所以这条方法的正确带走姿势是:把"只有我自己机器上成立的前提"关进一个进不了发行物的分支;至于用什么当那个开关的门闩,按项目挑,编译器调试态只是最省事的一种。
二、手搓 XML:#引号 和 #换行符 为什么会冒出来
它没有调用任何 XML 库,整份文档是字符串一条条拼出来的:
文本 = "<?xml version=" + #引号 + "1.0" + #引号 + " encoding=" + #引号 + "utf-8" + #引号 + "?>" + #换行符
文本 = 文本 + "<AssetDeclaration xmlns=" + #引号 + "uri:ea.com:eala:asset" + #引号 + " xmlns:xsi=" + #引号 + "http://www.w3.org/2001/XMLSchema-instance" + #引号 + ">" + #换行符
文本 = 文本 + " <Include type=" + #引号 + "reference" + #引号 + " source=" + #引号 + "DATA:static.xml" + #引号 + " />" + #换行符
看这两行就能明白那两个常量从哪来。易语言的文本常量用双引号包自身,所以你没法在一段字面文本里直接再塞一个裸的双引号——XML 属性值偏偏到处是双引号。解法就是把双引号当独立零件:#引号 是一个内置常量,代表一个双引号字符,用它把 "1.0" 这种带引号的片段夹在两段字面文本中间。同理 #换行符 代表换行,一行 XML 拼完就 + #换行符。这两个常量出现的唯一理由,就是"没有引号转义、没有模板引擎,只能靠常量拼接把结构化文本凑出来"。
这套做法可迁移的判断是:当输出目标只是一个"长得像 XML 的文本"、而且形状完全固定、里面没有需要转义的用户数据时,字符串拼接是够用的、也是最省事的。它省掉了引库、建节点、序列化这一整套。代价同样清楚——一旦要拼的内容里出现需要转义的字符(引号、尖括号、& 这类),或者结构要按条件嵌套,手写拼接立刻开始出错,那种场景就该换成真正的序列化器。这里能这么写,是因为它往里塞的都是自己造的定长标签和扫描出来的文件路径,路径本身不带这些特殊字符。
再补一层通用的读法:#引号 和 #换行符 这种"把特殊字符拆成命名常量再拼回去"的手法,本质是语言没有字符串转义或没有多行字面量时的替代方案。你换一门有原始字符串、有转义、有模板插值的语言,这两个常量就同时消失了——它们不是易语言的怪癖,而是"表达能力不够时的补偿动作"留下的化石。所以看老代码时,凡是遇到成串的特殊字符常量拼接,先别嫌啰嗦,它多半在告诉你这门语言在那个年代缺了什么。这跟读别人手写 HTML 字符串、手拼 SQL 是同一种嗅觉:能拼对的前提是内容可控,内容一旦不可控,拼字符串就从省事变成埋雷。
固定段拼完,剩下给每个扫到的文件补一行,用的是一个计次循环:
.计次循环首 (取数组成员数 (文件), 计次)
文本 = 文本 + " <Include type=" + #引号 + "all" + #引号 + " source=" + #引号 + 文件[计次] + #引号 + " />" + #换行符
.计次循环尾 ()
到这里,生成物字面上是什么已经很清楚了:一个 AssetDeclaration 根,namespace 写死成 uri:ea.com:eala:asset;开头三行固定的 <Include type="reference" ...>,分别指向 DATA:static.xml、DATA:global.xml、DATA:audio.xml;然后每个被扫出来的文件配一行 <Include type="all" source="那个文件">。我只描述这几行文本本身,至于游戏引擎怎么解释 reference 和 all、这三条固定引用是不是必须的——代码没告诉我,我也不替它编。
三、真正的点子:扫盘就是清单
前面两段是手法,这段才是这个程序存在的理由。
它怎么知道一个模块里有哪些文件要写进清单?它不靠任何手工维护的列表,靠的是现场扫盘。第一段先按 *.xml 摸一遍,再带上 #子目录 标志按 *.* 递归往下摸,用的是易语言里非常经典的一个枚举惯用法:
加入成员 (文件, 寻找文件 (目录 + "\*.xml", ))
.循环判断首 ()
加入成员 (文件, 寻找文件 (, ))
.循环判断尾 (文件[取数组成员数 (文件)] ≠ "")
寻找文件 第一次给路径和掩码,之后不带参数再调,它会把上一次没返回完的匹配项一个个接着吐出来,吐到头就回一个空串。于是"不断把返回值加入数组,直到数组最后一个元素不再是空串"这个 循环判断尾 条件,就是把一次枚举榨干的写法——这也是易语言老手一眼认得的套子。带 #子目录 那一趟同理把子目录里的东西也收进来,程序用一个 \zenter.xml 结尾的哨兵串来判定这轮枚举走到尽头,然后把哨兵本身剔掉。我不夸大这段的边角语义,它绕,但核心就是"把目录彻底枚举干净"。
把这两趟的结果当底料,接下来那圈 type="all" 就是照着扫出来的东西一一落账。这就是全部的机制:这个模块到底包含哪些文件,等于那一刻目录里实际有哪些文件。加一个资源、删一个资源,都不动代码,清单自动跟着磁盘走。它整个程序干的事,说穿了就是"拒绝手工维护一张列表"——列表会过期、会漏、会和磁盘对不上,而一次扫描不会,扫描的定义域永远是此刻的真相。
这跟代码生成里"以输入产物为唯一事实源、绝不手抄一份中间清单"是同一个原则,只不过这里输入产物就是文件系统本身。放到今天照样用:容器里的文件列表、构建产物清单、依赖树、备份范围,凡是你正在"把 A 里的东西如实记成 B",都该让 A 自己说话,而不是维护一份 B 的手工副本然后祈祷它跟 A 同步。手工清单的坏法就那么几种,而且都很安静:加了文件忘了登记,删了文件登记还在(生成的引用指向不存在的东西),改了名两边对不上——没有一种会在写下的当下报错,全是攒到用的时候才炸。让扫描当清单,等于把这三种坏法一次性消掉,因为根本不存在第二份可维护的东西。
代价也要说清楚,别把这条当银弹。扫描式清单放弃了"顺序可控"“内容白名单"“显式意图"这三件事:文件的进入次序、要不要把某个也扫出来的东西排除掉、扫到即入账——这些手工列表本来能精挑,改成扫描后就交给目录现状了。这个程序里没做过滤和排序(除了把条目按 zenter.xml 结尾这类粗筛),说明作者当时的需求就是"目录里有啥就全带上”,粗粒度够用。换成"清单要按依赖顺序、还要挡掉一些文件"的场景,光扫盘就不够,得在扫描之上再加选择逻辑——但那是把"手工列表"换成"带规则的计算列表”,仍然不是回退到手抄。
落盘那一步还有个小的分岔值得一提,因为它暴露了作者对这套目录约定的用法:
.如果 (取文本右边 (目录, 4) = "Data")
写到文件 (目录 + "\Mod.xml", 到字节集 (文本))
.否则
写到文件 (目录 + "\zenter.xml", 到字节集 (文本))
.如果结束
目录名以 Data 结尾就写 Mod.xml,否则写 zenter.xml。名字里带 Data 的那层,多半是他心目中的正式数据包目录;不带的,落到一个中间文件上。这是我从这两行字面读出来的意图猜测,作者没写注释,就当合理推断看,别当定论。
收尾
这工具小、老、只服务于一个具体游戏的装配环节,能不能真跑起来我没有依据说,也不猜。值得留下的就上面这三样:用调试态开关把绝对路径关在开发侧;在没有转义风险的固定形状文本上,用 #引号/#换行符 常量拼接手搓结构文件;以及最重要的一条——让一次磁盘扫描来充当清单,而不是维护一张会烂的手工表。三条的共同点是它们都不依赖红警3,也不依赖易语言,换栈照样成立。
仓库在这里:https://cnb.cool/t1mmoc/tspace/eyuyan/epl-ra3-mod-generator
仓库里放的是原始的 .e 容器,加上一份由工具从容器里抽取出来的可读源码文本。容器里那句作者邮箱做了同号数打码,.e 的字节长度没变。有一点要说清楚:抽取出来的文本带着 IDE 视图里看不到的工具痕迹——本工程里表现为 #e2txt_text# 这种字符串边界标记(本文引用的代码已把它去掉,只留语义);同类抽取还可能带出 krnln:: 库前缀,这个程序的代码里没有,我不假装它有。这些都不是源码本身,读的时候忽略即可。仓库里不含任何游戏文件、资源或 MOD 内容。