[2025]中外AI大战!让AI们通过MCP玩帝国时代2 huoji 帝国时代2,AI大战 2025-10-04 855 次浏览 2 次点赞 ## 前言 说来话长, 熟悉我的人都知道,我每年国庆都会在家整点活,去年是 [2024]深度研究APT攻击组织Storm-0978的高级注入技术"Step Bear" https://key08.com/index.php/2024/10/27/2253.html 今年是AI通过MCP玩帝国时代2! 本系列非常非常非常复杂,原因是我一开始只是想做个能自动帮我玩帝国时代2的脚本,但是做着做着我突然觉得没意思,就改变想法搞个让大语言模型玩游戏的MCP。事实证明已经严重超出我得工作量了,不过经过快一个星期的逆向,分析,模型调用调优。我终于取得了进展。这一切都是值得的! 我们先说偏娱乐的部分,中外AI大战! ## 帝国时代2 帝国时代2是我最喜欢的游戏,玩了快15年了.如果你不知道他是什么, 他是一款类似于红警的RTS游戏,跟红色警戒不同,他是慢节奏游戏,一把大概一个小时。所有人要采集资源,然后从黑暗时代升级到封建时代->城堡时代->帝王时代 然后互相伤害:  我用的是steam的决定版本,因为他还在更新: https://store.steampowered.com/app/813780/Age_of_Empires_II_Definitive_Edition/ ## MCP MCP的具体设计思路下一篇再说了,总之我们有了一个能让AI操控升级时代/移动/攻击/研究科技的MCP了 MCP代码大概长这样,claude写的,1000多行:  claude是第一个(也许是世界上/人类上第一个),从黑暗时代升级到封建时代的AI!  并且还会训练马厩  话不多说! 让我们直接开始主题! 但是在直接AI大战之前,我进行了一轮初筛,毕竟一局帝国时代2大概要一个小时,我没那么多时间浪费,而且这些模型还是要付费token用的,我也没那么多钱,所以我决定先拿现在的模型进行一波简单人机对战,谁赢了才选出来! 先说一下各个AI的表现吧,我们的AI Agent有两个模型在混合工作.一个是军师,他是各家模型的最强think模型,根据游戏状态和历史会话总结部署战术的人物  一个是实际操作的,他是非think模型,因为think一次太慢和太耗费token了,所以要快:  接下来我会简单说一下各个模型的玩这游戏的玩法,以下数据我通过几个维度评估模型好坏: 1. 闲置村民数量 2. 战术得分 满分五分 ## 国外模型 ### claude 综合评分: 4.8分 但是有一处非常奇怪,他非常喜欢采集外围资源不采集自己的.并且会无上限爆农民导致没人口造兵。 还喜欢去别人家偷资源:  不过还是后期通过爆骑士打败了简单电脑 第2天测试,开局,基本完美贴合资源建造,这个是我第一次见空间感这么强的AI,其他家的包括GLM都做不到完美建筑位置开局  不过失误还是有的,两个村民杀猪没了:  从规划到进攻表现算亮眼 本来想因为后期的操作给5分的,但是他太太太贵了!!我一看钱包只能给他4.8分了。 ### GPT-5 综合评分: 3分 我也不知道为什么,gpt3我是API版本真的跟弱智一样.原地等了五分钟才发展,在此之前一直在废话:  但是没任何工具调用!发展了一会后,然后开始玩角色扮演了....  然后自己回答自己上一个问题 继续角色扮演  然后就这样无限循环了... ### grok 跟GPT的毛病一样,前几轮还好还在工作,后几轮就开始在玩起了角色扮演了,忽略  ### gemini-2.5-pro 综合评分: 2.0分 这玩意纯粹搞笑的,每一句话都带了纯粹的语文快乐 比如  又比如  再然后  还有,发现地方侦察骑兵在家门口,他就"组织农民敢死队"把所有空闲农民去砍侦察骑兵... 搞笑是搞笑,但是拉了,因为他前期一大半时间都在尝试建造市场,而系统提醒他那个地方不能建造,他每次失败就写个悲剧文,然后继续尝试在那个地方建造...最后在面临电脑"大军"的时候, 他最终选择建造弓箭手对抗投投矛手,然后输了... ## 国产模型 ### deepseek 综合评分: 2分 deepseek前期规划不错,农民全用了.但是一旦野果采集完毕后,他就不太行了,他也不会种田,也不会狩猎,后期会直接崩坏甚至是投降,而且我确定他没训练过帝国时代2,经常搞错单位升级什么的.但是至少会出兵打一下简单人机... 他像极了我的第一次玩帝国时代2,会建造防御箭塔在家周围,发现打不过选择来一点"高科技":  他的高科技:  战术也是有的: 比如埋伏一队兵在对方家里杀农民:  只不过越玩越崩坏,有些时候还会投降(他是AI里面唯一一个投降的,而且在经济巨好的情况下....) deepseek投降,原因是他的"大军"-6个骑士被敌方指挥中心射死了:   ### qwen 由于他的think token消耗太吓人,算了.我没那么多钱.具体吓人来说,为了做一个动作就要暴力think非常久.如果不think他就基本不智能.... ### 豆包 太慢了,调用一次黄花菜凉了,没时间等他思考..所以放弃 ### GLM4.6 评价: 5分满分,非常亮眼操作 真正的神!这不是广告,在耗费50元后,他压倒性的打败了简单电脑,并且会战术.  集结单位 包抄  袭击对方军事建筑 封建压制  我故意放水用作弊码让AI有城堡后,他选择造攻城车  最后胜利  ### kimi-k2-0905-preview 综合评分: 3.5分 他有点太敏感了:  一个侦察骑兵让他所有农民全部回防... 然后就开始崩坏了,这些回防的农民全部不动了,导致他升级不了时代,就这卡着耗费我token: 而且封建升级什么的错了,感觉跟ds一样没有研究过帝国时代2  不过令我惊讶的是他在尝试计算还有多少分钟升级,有一点那种电影里面ai计算还有多少分钟可能性的感觉了.  花了我快十块钱后还是毫无进展:  最后挂机了一会升级到封建时代了,我发现他开始跟gemini一模一样,开始逐渐崩坏..  但是不得不说,非常可爱,跟我以前第一次玩帝国时代2一样,建造几个垃圾兵种在"边疆"站岗有安全感  最后我的钱花了16还是没进展,我就终止了他  ## Claude-4-5-sonnet VS GLM4-6 综上所述,我准备举办一个1v1的比赛,本来想4v4的,但是要八个电脑而且要很多钱,我就放弃了.要让有人想搞的话可以关注我的github不定时开源.(虽然开源可能性非常小因为很容易被拿去做外挂) 一开始,GLM和claude战术一致,都选择了快速升级封建 GLM:  Claude:   在和平发展了一整子后,GLM派出了一个侦察兵发现了别人的位置,而这让CLaude非常不安:    从而让claude选择立刻出兵营,把有限的资源投入到军事里面  而正是这个举动,让CLAUDE经济落后了GLM  但正是这个举动,让GLM也陷入猜疑链,怀疑别人要进攻自己,立刻建造了一个兵营,并且训练了两个民兵  这样对峙了一段时间后:  claude率先犯蠢,他认为远方的树比较安全..就派了四个村民去,但是被野生动物袭击:   然后他发现了,想立刻召回,不过来不及了:  不过来不及了,这四个村民的损失导致claude经济大比分落后GLM 但是他却是第一个升级到封建时代的:  这是因为我们的GLM犯傻了,他认为封建时代是需要500肉,200黄金,然后派了一堆村民采集黄金....  导致慢了五分钟才进入封建时代.这非常致命! 然后GLM认为需要派部队保护金矿  双方升级到封建后又和平发育了一段时间,但是CLAUDE在连续尝试在磨坊建造农田失败后,派出了几个农民去地图边缘建造农田...但是不出意外,又被野生动物咬了  与此同时 GLM的农田铺的非常工整:  与此同时 claude又把农民撤回来了:  而这个时候GLM准备升级城堡时代了  市场/铁匠铺关键科技已经准备就绪  而claude还咋想办法研究为什么他的农田造不了:   GLM已经进入城堡时代  但是进入城堡时代后,GLM不想进攻却想着发展经济:  这导致claude也进入了城堡时代  目前两方对比图  而claude进入城堡时代后,第一件事就是策划进攻计划,准备使用联合部队进行进攻:  而这个消息也被GLM捕获到,GLM也加快了战备速度  claude在一般部队外,还准备了攻城武器  claude第一波攻势是采矿场  而与此同时,GLM却在关键时候不让手头的防御部队去防御,而是选择放弃抵抗:  然后转移石矿  而claude乘胜追击  claude在城市里面肆无忌惮的攻击  原来他是想城堡建造完才进行反击  而这个时候GLM的城堡建造起来了  GLM进行了第一波反击  双方进行了混战  最后GLM胜利了(所以为什么不早点来??)  这一波由于GLM的绥靖政策,自己城镇中心已经没了,并且经济损失过半  而GLM的军师认为,已经毫无胜算,准备最后一搏  于是他把所有的部队分成了两拨,一波直接冲城镇中心  而另外一波断农民金矿  而claude其实还有预备队,因为claude计划了三波攻击,刚刚攻击GLM的其实是第一波  毫无意外,这一波没了  而GLM选择重新建造城镇中心  而来不及了,claude准备了冲车+投矛手    GLM刚刚莽的那一波,自己的部队完全没了,现在只有两三个长矛兵 两辆冲车率先冲入  而GLM也没坐以待毙,等那些人接近城堡,在城堡的掩护下,用两个长矛兵反击  而投矛手全部暴露在城堡射程下,一大半都被射死  claude准备撤退,然后准备来一波更大的  而GLM认为自己没有任何抵抗能力了,投降  赛后总结: claude对战场处理能力非常好,长远规划非常精彩,包括攻击节奏 而GLM关键输的节点是,自己的矿场被攻击没有第一时间派兵迎战而是选择放弃战略要地,跑路继续发展经济。直到城堡建造好才反击,浪费了大量时间,导致自己的基地被干烂。虽然进行了漂亮的反击并且孤注一掷,但是大势已去。claude赢得比赛只是时间问题 ## 4v4 国产AI大战国外AI! 由于经费问题,不会有4v4中外AI大战了,虽然我也很想举办,但是目前看没几万是举办不了,原因是: 这一局claude花了700人民币  GLM花了200多:  实在是支撑不起4v4的巨型战斗了! ## 技术细节 这次整活真的是让我力竭了!原因是我基本国庆不休息的在逆向游戏并且写SDK,有非常多非常多的新发现和新技术,很可惜这里写不下,我们写一些基本的吧 这次逆向光笔记写了快几千个字了, 包含了大量算法,解密,反作弊  为了实现功能,我写了700多行的sdk,结合remote_call模块这样我就能跟回家一样调用游戏的功能了.  这样就能跟回家一样调用游戏引擎:  ### 基本游戏引擎 如果我们要做AI的MCP,我们需要完整了解这个引擎.这边由于是偏娱乐的第一篇,我们就不详细介绍了,只是简单介绍一下. 帝国时代2的总结构有几个 avgamescreen -> 负责UI层的东西,点击什么 PathingSystem-> 核心层,里面有世界的信息 通过ida+reclass我们很容易的就定位到他了(通过搜索"World: end_of_new_game"字符串)  在PathingSystem下面有我们关键信息:  ```cpp AVBaseWorld-> AvCommandBase PlayerList->AVWordBasePlayer ->我们玩家的信息 AVMapPtr ->staticObjects ``` 玩家playerlist:  avgamescreen有我们需要的摄像机,有了这个我们就能做world2screen操作 而里面的"玩家"实际上是多个class的合体  比如一个村民: ```cpp AVTRIBE_Combat_Object : AVRGE_Combat_Object : AVRGE_Action_Object : AVRGE_Moving_Object : AVGRGE_Animated_Object : AVRGE_Static_Object ``` 帝国时代2部分数据是加密的,微软做了混淆,不过他们犯了一个错误,混淆不能与0数据混淆,否则出来就是密钥了,出来密钥后,我们可以ida直接搜定位从哪来的,并且知道解密方式:  搜索后,让AI根据算法,写一个逆向的 ```cpp #include #include #include // 解密函数 std::vector decrypt_pointer(uint64_t encrypted_value, uint64_t multiplier) { std::vector possible_results; // 默认情况(原始值能被5整除) uint64_t decrypted_default = encrypted_value - 0x7987582189A6A79A; possible_results.push_back(decrypted_default); // 情况1(原始值 mod 5 == 1) // 根据乘数确定不同的常量 uint64_t case1_const; switch(multiplier) { case 1345: case1_const = 0x54DD3E6DE5EA4C5D; break; case 1368: case1_const = 0x54DD3E6DE5EA4C74; break; case 1386: case1_const = 0x54DD3E6DE5EA4C86; break; case 1387: case1_const = 0x54DD3E6DE5EA4C87; break; case 1388: case1_const = 0x54DD3E6DE5EA4C88; break; default: throw std::runtime_error("Unknown multiplier"); } // 检查是否能被0x5314BB974EE0274A整除 if ((encrypted_value - case1_const) % 0x5314BB974EE0274A == 0) { uint64_t decrypted_case1 = (encrypted_value - case1_const) / 0x5314BB974EE0274A; possible_results.push_back(decrypted_case1); } // 情况2(原始值 mod 5 == 2) uint64_t case2_term = encrypted_value + 0x6C5EAB60B426DD28; if (case2_term % multiplier == 0) { uint64_t divisor = case2_term / multiplier; if (divisor != 0) { uint64_t decrypted_case2 = 0xE7C353CF2DBBE2F2 / divisor; possible_results.push_back(decrypted_case2); } } // 情况3(原始值 mod 5 == 3) uint64_t case3_term = encrypted_value + 0x2B3719EF44DE99FF; if (case3_term % 0xC397D46924EFACCD == 0) { uint64_t quotient = case3_term / 0xC397D46924EFACCD; uint64_t decrypted_case3 = quotient * multiplier; possible_results.push_back(decrypted_case3); } // 情况4(原始值 mod 5 == 4) // 根据乘数确定不同的解密方法 if (multiplier == 1368) { // 对于1368,使用模0x558 uint64_t target = (encrypted_value + 0x726CEB9596408004) % 0x558; // 寻找满足 (0x5E9DF56934CB6EC7 * value) % 0x558 == target 的value for (uint64_t value = 0; value < 0x558; ++value) { if ((0x5E9DF56934CB6EC7 * value) % 0x558 == target) { possible_results.push_back(value); } } } else if (multiplier == 1386) { // 对于1386,使用模0x56A uint64_t target = (encrypted_value + 0x726CEB9596408004) % 0x56A; // 寻找满足 (0x5E9DF56934CB6EC7 * value) % 0x56A == target 的value for (uint64_t value = 0; value < 0x56A; ++value) { if ((0x5E9DF56934CB6EC7 * value) % 0x56A == target) { possible_results.push_back(value); } } } else { // 对于其他乘数(1345, 1387, 1388),使用更复杂的解密方法 // 这里简化处理,实际应用中可能需要更精确的逆向计算 uint64_t base = encrypted_value + 0x726CEB9596408004; // 尝试一些可能的值范围 for (uint64_t value = base - 1000; value <= base + 1000; ++value) { // 简化的验证 - 实际应用中应使用完整的逆向计算 if (value % 5 == 4) { // 情况4的条件 possible_results.push_back(value); } } } return possible_results; } // 辅助函数:从对象中解密指针 std::vector decrypt_object_pointers(uint8_t* object_base) { std::vector decrypted_pointers; // 第一组数据:偏移量16-176,使用乘数1345 for (int i = 0; i < 20; ++i) { uint64_t encrypted_value = *reinterpret_cast(object_base + 16 + i * 16); auto results = decrypt_pointer(encrypted_value, 1345); if (!results.empty()) { decrypted_pointers.push_back(results[0]); // 取第一个可能的结果 } } // 第二组数据:偏移量184-344,使用乘数1345 for (int i = 0; i < 20; ++i) { uint64_t encrypted_value = *reinterpret_cast(object_base + 184 + i * 16); auto results = decrypt_pointer(encrypted_value, 1345); if (!results.empty()) { decrypted_pointers.push_back(results[0]); } } // 第三组数据:偏移量560-736,使用乘数1368 for (int i = 0; i < 22; ++i) { uint64_t encrypted_value = *reinterpret_cast(object_base + 560 + i * 16); auto results = decrypt_pointer(encrypted_value, 1368); if (!results.empty()) { decrypted_pointers.push_back(results[0]); } } // 第四组数据:偏移量744-920,使用乘数1368 for (int i = 0; i < 22; ++i) { uint64_t encrypted_value = *reinterpret_cast(object_base + 744 + i * 16); auto results = decrypt_pointer(encrypted_value, 1368); if (!results.empty()) { decrypted_pointers.push_back(results[0]); } } // 第五组数据:偏移量968-1048,使用乘数1386 for (int i = 0; i < 10; ++i) { uint64_t encrypted_value = *reinterpret_cast(object_base + 968 + i * 16); auto results = decrypt_pointer(encrypted_value, 1386); if (!results.empty()) { decrypted_pointers.push_back(results[0]); } } // 第六组数据:偏移量1056-1136,使用乘数1386 for (int i = 0; i < 10; ++i) { uint64_t encrypted_value = *reinterpret_cast(object_base + 1056 + i * 16); auto results = decrypt_pointer(encrypted_value, 1386); if (!results.empty()) { decrypted_pointers.push_back(results[0]); } } // 第七组数据:偏移量1136-1264,使用乘数1387 for (int i = 0; i < 16; ++i) { uint64_t encrypted_value = *reinterpret_cast(object_base + 1136 + i * 16); auto results = decrypt_pointer(encrypted_value, 1387); if (!results.empty()) { decrypted_pointers.push_back(results[0]); } } // 第八组数据:偏移量1272-1400,使用乘数1387 for (int i = 0; i < 16; ++i) { uint64_t encrypted_value = *reinterpret_cast(object_base + 1272 + i * 16); auto results = decrypt_pointer(encrypted_value, 1387); if (!results.empty()) { decrypted_pointers.push_back(results[0]); } } // 第九组数据:偏移量1400-1520,使用乘数1388 for (int i = 0; i < 15; ++i) { uint64_t encrypted_value = *reinterpret_cast(object_base + 1400 + i * 16); auto results = decrypt_pointer(encrypted_value, 1388); if (!results.empty()) { decrypted_pointers.push_back(results[0]); } } // 第十组数据:偏移量1528-1648,使用乘数1388 for (int i = 0; i < 15; ++i) { uint64_t encrypted_value = *reinterpret_cast(object_base + 1528 + i * 16); auto results = decrypt_pointer(encrypted_value, 1388); if (!results.empty()) { decrypted_pointers.push_back(results[0]); } } return decrypted_pointers; } // 定义加密组结构体 struct EncryptedGroup { uint64_t start_offset; // 组起始偏移量 uint64_t end_offset; // 组结束偏移量 uint64_t multiplier; // 该组使用的乘数 const char* description; // 组描述(可选) }; // 获取指针位置对应的乘数 uint64_t get_multiplier_at_offset(uint64_t base, uint64_t offset) { // 定义所有加密组 const std::vector groups = { // 第一组:偏移量16-175,乘数1345 {16, 175, 1345, "Group 1 (offsets 16-175)"}, // 第二组:偏移量184-343,乘数1345 {184, 343, 1345, "Group 2 (offsets 184-343)"}, // 第三组:偏移量560-735,乘数1368 {560, 735, 1368, "Group 3 (offsets 560-735)"}, // 第四组:偏移量744-919,乘数1368 {744, 919, 1368, "Group 4 (offsets 744-919)"}, // 第五组:偏移量968-1047,乘数1386 {968, 1047, 1386, "Group 5 (offsets 968-1047)"}, // 第六组:偏移量1056-1135,乘数1386 {1056, 1135, 1386, "Group 6 (offsets 1056-1135)"}, // 第七组:偏移量1136-1263,乘数1387 {1136, 1263, 1387, "Group 7 (offsets 1136-1263)"}, // 第八组:偏移量1272-1399,乘数1387 {1272, 1399, 1387, "Group 8 (offsets 1272-1399)"}, // 第九组:偏移量1400-1519,乘数1388 {1400, 1519, 1388, "Group 9 (offsets 1400-1519)"}, // 第十组:偏移量1528-1647,乘数1388 {1528, 1647, 1388, "Group 10 (offsets 1528-1647)"} }; // 检查特殊位置(不使用乘数的位置) if (offset == 1520) { // 偏移量1520使用异或和加法加密 return 0xFFFFFFFFFFFFFFFE; // 特殊标记1 } if (offset == 1264) { // 偏移量1264使用加法加密 return 0xFFFFFFFFFFFFFFFD; // 特殊标记2 } // 检查偏移量是否在任何加密组内 for (const auto& group : groups) { if (offset >= group.start_offset && offset <= group.end_offset) { // 检查偏移量是否是8的倍数(指针应该对齐到8字节) if ((offset - group.start_offset) % 8 != 0) { throw std::runtime_error("Offset not aligned to 8-byte boundary within group"); } return group.multiplier; } } // 如果不在任何加密组内,返回0表示未加密 return 0; } // 获取对象中所有加密位置的乘数信息 std::vector> get_all_encrypted_offsets(uint64_t base) { std::vector> result; // 定义所有加密组 const std::vector groups = { {16, 175, 1345, "Group 1"}, {184, 343, 1345, "Group 2"}, {560, 735, 1368, "Group 3"}, {744, 919, 1368, "Group 4"}, {968, 1047, 1386, "Group 5"}, {1056, 1135, 1386, "Group 6"}, {1136, 1263, 1387, "Group 7"}, {1272, 1399, 1387, "Group 8"}, {1400, 1519, 1388, "Group 9"}, {1528, 1647, 1388, "Group 10"} }; // 添加特殊位置 result.emplace_back(1520, 0xFFFFFFFFFFFFFFFE); result.emplace_back(1264, 0xFFFFFFFFFFFFFFFD); // 添加所有加密组内的位置 for (const auto& group : groups) { for (uint64_t offset = group.start_offset; offset <= group.end_offset; offset += 8) { result.emplace_back(offset, group.multiplier); } } return result; } // 打印对象中所有加密位置的乘数信息 void print_encrypted_offsets_info(uint64_t base) { auto encrypted_offsets = get_all_encrypted_offsets(base); printf("Encrypted offsets in object at base 0x%llX:\n", base); printf("Offset\tMultiplier\tDescription\n"); printf("------\t----------\t-----------\n"); for (const auto& entry : encrypted_offsets) { uint64_t offset = entry.first; uint64_t multiplier = entry.second; if (multiplier == 0xFFFFFFFFFFFFFFFE) { printf("0x%llX\tSpecial\t\tXOR + ADD encryption\n", offset); } else if (multiplier == 0xFFFFFFFFFFFFFFFD) { printf("0x%llX\tSpecial\t\tADD encryption\n", offset); } else if (multiplier == 0) { printf("0x%llX\t0\t\tNot encrypted\n", offset); } else { printf("0x%llX\t%llu\t\tStandard encryption\n", offset, multiplier); } } } int main(){ uint64_t base = 0x7ff7c06b0000; // 对象基地址 print_encrypted_offsets_info(base); auto decrypt_test = decrypt_pointer(0x4EF2932AD180DD26, 1345); printf("decrypt_test: %llx \n",decrypt_test[0]); } ```  然后解密一下,当然其他不同的class加密方法不同,但是结果都一样:  帝国时代2反作弊: 如果你挂了CE或者其他debuger,他会提示  这是因为它使用isdebug和veh做检测 定位后patch掉就行 ```cpp __int64 __fastcall isWecheating(__int64 a1) { return *(unsigned __int8 *)(a1 + 0x698); } ``` ```cpp 搜索 IDS_TAMPERING_DETECTED_MESSAG ``` ```cpp 还有一个暗装: aoe2de_s.exe + 0xA39668 _QWORD *__fastcall sub_7FF63BB7BEC0(__int64 a1, _QWORD *a2) { struct _Mtx_internal_imp_t *v4; // rbx int v5; // eax __int64 v6; // r8 unsigned __int64 v7; // r9 *a2 = 0i64; v4 = (struct _Mtx_internal_imp_t *)(a1 + 9344); v5 = Mtx_lock((_Mtx_t)(a1 + 9344)); if ( v5 ) { std::_Throw_C_error(v5); JUMPOUT(0x7FF63BB7BF78i64); } v6 = *(_QWORD *)(a1 + 9336); if ( v6 ) { v7 = *(_QWORD *)(a1 + 9328); *a2 = *(_QWORD *)(*(_QWORD *)(*(_QWORD *)(a1 + 9312) + 8 * ((v7 >> 1) & (*(_QWORD *)(a1 + 9320) - 1i64))) + 8 * (v7 & 1)); *(_QWORD *)(a1 + 9336) = v6 - 1; if ( v6 == 1 ) *(_QWORD *)(a1 + 9328) = 0i64; else *(_QWORD *)(a1 + 9328) = v7 + 1; } Mtx_unlock_0(v4); return a2; } 弹信息框的地址是 7FF63B4B2AA0 - 0x7FF63AC70000 = 842AA0 aoe2de_s.exe + 0x842AA0 hook后看来源 0:306> kb # RetAddr : Args to Child : Call Site 00 00007ff7`c10ec93b : e1c096a0`0e29052d e1c096a0`0e29052d e1c096a0`0e29052d 00000230`c85d43a0 : AoE2DE_s+0x842aa0 01 00007ff7`c10daeff : 00000000`00000000 00000000`00000000 00000230`61a2e1f0 00000000`00000003 : AoE2DE_s+0xa3c93b 02 00007ffd`45459363 : 00000230`460ae850 00000000`00000000 00000000`00000000 00000000`00000000 : AoE2DE_s+0xa2aeff 03 00007ffd`464426ad : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ucrtbase!thread_start+0x93 04 00007ffd`4796a9f8 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : KERNEL32!BaseThreadInitThunk+0x1d 05 00000000`00000000 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!RtlUserThreadStart+0x28 0:323> kb # RetAddr : Args to Child : Call Site 00 00007ff7`c1c8352b : 00007ffd`4796a9f8 00007ff7`c3cf38c8 00000000`00000030 00007ff7`c3cf69b0 : USER32!MessageBoxW 01 00007ff7`c0ef2ac7 : ca057213`7bfa831d 00000000`ffffffff 00000230`60ad5d30 00000000`ffffffff : AoE2DE_s+0x15d352b 02 00007ff7`c10ec93b : ca057213`7bfa831d ca057213`7bfa831d ca057213`7bfa831d 00000230`c85d43a0 : AoE2DE_s+0x842ac7 03 00007ff7`c10daeff : 00000000`00000000 00000000`00000000 00000230`f8868840 00000000`00000003 : AoE2DE_s+0xa3c93b 04 00007ffd`45459363 : 00000230`46018fc0 00000000`00000000 00000000`00000000 00000000`00000000 : AoE2DE_s+0xa2aeff 05 00007ffd`464426ad : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ucrtbase!thread_start+0x93 06 00007ffd`4796a9f8 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : KERNEL32!BaseThreadInitThunk+0x1d 07 00000000`00000000 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!RtlUserThreadStart+0x28 __int64 __fastcall sub_7FF63BAE8380(__int64 a1) { __int64 result; // rax __int64 v3; // rcx __int64 (__fastcall ***v4)(_QWORD, __int64); // rcx if ( qword_7FF63F0780E8 ) sub_7FF63BB86ED0(qword_7FF63F0780E8, 4i64); if ( *(_BYTE *)(a1 + 0x699) ) sub_7FF63BB799C0(g_events); *(_BYTE *)(a1 + 0x699) = 1; result = g_events; v3 = *(_QWORD *)(*(_QWORD *)(g_events + 848) + 11088i64); if ( v3 ) result = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v3 + 552i64))(v3); v4 = *(__int64 (__fastcall ****)(_QWORD, __int64))(a1 + 1136); *(_QWORD *)(a1 + 1136) = 0i64; if ( v4 ) return (**v4)(v4, 1i64); return result; } 写入的几个地址: 0:293> g Breakpoint 4 hit AoE2DE_s+0xa3c11f: 00007ff7`c10ec11f c681f10e000001 mov byte ptr [rcx+0EF1h],1 ds:00000292`17f1dba1=01 0:293> kb # RetAddr : Args to Child : Call Site 00 00007ff7`c10ec92f : 6d4c1d03`8609bde5 6d4c1d03`8609bde5 6d4c1d03`8609bde5 00000292`0d042b50 : AoE2DE_s+0xa3c11f 01 00007ff7`c10daeff : 00000000`00000000 00000000`00000000 00000293`1d5f04f0 00000000`00000003 : AoE2DE_s+0xa3c92f 02 00007ffd`45459363 : 00000292`077fbfd0 00000000`00000000 00000000`00000000 00000000`00000000 : AoE2DE_s+0xa2aeff 03 00007ffd`464426ad : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ucrtbase!thread_start+0x93 04 00007ffd`4796a9f8 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : KERNEL32!BaseThreadInitThunk+0x1d 05 00000000`00000000 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!RtlUserThreadStart+0x28 call的函数是 aoe2de_s.exe + 0x501d43e 0x7FF63AC70000 + 0xa3c93b = AoE2DE_s.exe+2B86916 - 66 66 0F1F 84 00 00000000 - nop word ptr [rax+rax+00000000] AoE2DE_s.exe+2B86920 - 48 3B 0D 91D86A01 - cmp rcx,[AoE2DE_s.exe+42341B8] { (-562597392) } AoE2DE_s.exe+2B86927 - 75 10 - jne AoE2DE_s.exe+2B86939 AoE2DE_s.exe+2B86929 - 48 C1 C1 10 - rol rcx,10 { 16 } AoE2DE_s.exe+2B8692D - 66 F7 C1 FFFF - test cx,FFFF { 65535 } AoE2DE_s.exe+2B86932 - 75 01 - jne AoE2DE_s.exe+2B86935 AoE2DE_s.exe+2B86934 - C3 - ret AoE2DE_s.exe+2B86935 - 48 C1 C9 10 - ror rcx,10 { 16 } AoE2DE_s.exe+2B86939 - E9 C2030000 - jmp AoE2DE_s.exe+2B86D00 ``` 之后我们就可以顺利得到资源了.这些都不是本篇要说的内容. 为此 我做了一个简单的透视,打印出内容   值得注意的是,盖亚的树木和鱼和海要额外处理: 你可以看到他并不显示树木  这是因为,这些是 地图属性,需要从另外一个表读取,非常非常大,这个折腾花了我一天时间,最后借助GPT的帮助才恍然大悟!  ### 帝国时代2内存泄漏 我有理由相信帝国时代2存在某种资源泄漏.原因是死亡的对象会堆积在0-0-0的区域内,不会被删除  据我观察到,这块内存会越来越大在一局比赛中,这可能也是为什么帝国时代2这游戏越玩越卡的原因 ### 帝国时代2命令系统 微软九十年代的程序员,代码技术非常的硬.表现为他们要实现一个八人在线,数百个甚至是数千个单位,在10kb光猫下跑的联机对战系统!(以及反外挂) **这个系统细节我会放到下期,这对我们的影响是,我们没有办法直接移动单位,或者修改资源,或者做出任何事情!只能读取** 这是因为,帝国时代2的网络系统是lockstep,这是一种非常离谱的方法,在2000年的时候还专门写了一篇新闻说他们是如何的困难 https://web.archive.org/web/20180719170411/https://www.gamasutra.com/view/feature/131503/1500_archers_on_a_288_network_.php?print=1 我非常建议搞技术的详细阅读这篇文章.即便是不是游戏行业也受益匪浅  简单来说,你的所有状态都会同步给其他玩家, 其他玩家会根据你的少量信息在本地进行模拟计算 计算出来的结果再在本地呈现出来 这意味着: (1)所有对局看起来没多少延迟,因为大部分都是本地计算的.除非有大量丢包. (2)你可以改资源/甚至是修改视野范围,但是其他玩家发现你的状态并不是能在他本地"模拟计算"出来的结果,所以他就会发出不同步的指令,这代表本局有人开挂。 所以我们不能直接移动单位,而是寻找传说中的命令系统 命令系统寻找过程就不说了,因为是九十年代的代码,实在是过于复杂,在连续熬夜三天三夜只睡了4个小时后,说个血泪总结: 有一个函数,是命令分发的!原型是  接受的参数是  有了这个我们就能构造任意命令发送了。移动,升级时代,都是这样的 ps这个有个bug: 命令是P2P的,所以能瞎几把发,比如你选择B的玩家的单位发送命令移动或者摧毁自己都可以。不过我们的目的不是为了开外挂,是为了做MCP.所以默认我们是合法的 相关资料:  https://redrocket.club/posts/age_of_empires/ ## 远程call 为了发命令,我设计了一个基于ALPC的远程call,一个DLL注入到帝国时代2里面,暴露ALPC接口:  接受客户端的游戏指令并且在UI线程执行,之所以在UI线程执行,因为帝国时代2并没有多线程安全的机制,它默认所有的游戏都是UI更新来的(ps: 很多老游戏都这样,而且这玩意跟AI挂钩,自己的FPS越高,AI越聪明...)  这样客户端就能轻松CALL了  结果:  ## 地图系统 为了实现MCP,我做了一个地图系统。总所周知,帝国时代2的地图是一个XY坐标的地图,一般是320x320 650x650等等,这个地图系统,我们是不能直接for x-y去寻找对象的,因为效率太低。所以要用到另外一个算法,叫做空间哈希网格,这个算法我也不太熟悉,是AI看了帝国时代2的代码后给出的,他的主要作用是,能快速索引 某个对象周围的对象。时间复杂度只有o(1)  而寻路算法使用a star 不过帝国时代2自己有寻路系统,我们不需要操心(他也是A star) ```cpp 第一部分:什么是哈希算法? 想象一下你有一个巨大的图书馆,里面有成千上万本书。如果你每次找书都从第一排第一个书架开始一本一本地找,那会非常慢。 哈希算法就是一个超级高效的“图书管理员”。它的工作方式是: 输入:你给它一个“键”,比如一本书的书名《三体》。 计算:它通过一个固定的数学公式(哈希函数)对这个书名进行计算。 输出:计算出一个固定的、通常比较短的编号,比如 5381。这个编号就是“哈希值”。 核心特点: 确定性:同样的输入(《三体》),永远会得到同样的输出(5381)。 高效性:计算速度非常快。 散列性:一个好的哈希算法会让不同的输入尽量产生不同的输出,避免“冲突”(即两本不同的书算出了同一个编号)。 在我们的图书馆例子里,这个管理员(哈希算法)会告诉你:“《三体》这本书在 5381 号书架上”。你直接去那个书架找就行了,省去了遍历整个图书馆的时间。 第二部分:什么是空间哈希网格? 现在,我们把场景从图书馆换到一个游戏世界,比如你正在玩的《帝国时代2》。地图上有成百上千个单位:农民、士兵、建筑、资源…… 游戏需要 constantly (不断地)回答一个问题:“在某个单位周围,有哪些其他单位?” 例如: 一个士兵需要知道周围有没有敌人可以攻击。 一个农民需要知道附近有没有树可以砍。 一个建筑需要知道周围有没有友方单位在驻扎。 最笨的办法是让每个单位都和地图上所有其他单位都计算一次距离。如果有1000个单位,就需要计算1000 * 1000 = 1,000,000次!这会让游戏卡成幻灯片。 空间哈希网格就是一个聪明的“空间分区”方法,它把整个游戏世界划分成一个个小格子(单元格)。 它的工作原理是: 划分空间:将整个游戏地图虚拟地划分成许多大小相同的正方形格子。 分配对象:根据一个游戏单位(比如一个骑兵)的坐标 (x, y),计算出它位于哪个格子里。例如,坐标 (155, 267) 可能位于格子 (15, 26) 中。 只检查相邻格子:当需要查找某个单位附近的其他单位时,系统只检查这个单位所在的格子以及它周围的8个格子。 举个例子: 假设地图被分成100个格子。一个士兵在格子 (5,5) 里。 笨办法:它需要和地图上所有999个其他单位计算距离。 空间哈希网格:它只需要和也在格子 (4,4), (5,4), (6,4), (4,5), (5,5), (6,5), (4,6), (5,6), (6,6) 这9个格子里的单位计算距离。 如果单位均匀分布,每个格子里大约只有10个单位,那么士兵只需要进行 9 * 10 = 90 次计算,而不是999次!效率提升了成百上千倍。 第三部分:两者如何结合?“哈希”在哪里? 现在关键问题来了:我们如何快速知道一个坐标 (x, y) 属于哪个格子?又如何快速找到某个格子里的所有单位呢? 这里就用到了 哈希算法! 步骤如下: 生成“键”:我们取一个单位的坐标 (x, y),把它映射到对应的格子坐标 (grid_x, grid_y)。通常很简单: grid_x = floor(x / cell_size) grid_y = floor(y / cell_size) (floor 是向下取整,cell_size 是每个格子的大小) 计算哈希值:我们将这个格子坐标 (grid_x, grid_y) 作为“键”,输入给一个哈希函数。 hash_key = hash_function(grid_x, grid_y) 存储和查找:系统维护一个巨大的“字典”(哈希表)。这个 hash_key 就是字典的索引。所有在同一个格子里的单位,都会被放入这个 hash_key 对应的列表里。 当我们需要查找时: 取目标的坐标,算出它的格子坐标 (grid_x, grid_y)。 用哈希函数瞬间计算出 hash_key。 直接用这个 hash_key 去字典里,瞬间找到对应格子的单位列表。 检查这个格子及周围8个格子的列表,完成碰撞检测或范围查询。 总结 哈希算法:是一个高效的“计算器”,能把任意数据(如坐标)转换成一个快速的索引号。 空间哈希网格:是一种管理大量空间对象的数据结构,通过将空间分格来极大地减少需要计算的对象数量。 把它们结合在一起: 空间哈希网格使用哈希算法,来快速地将一个空间位置(坐标)映射到一个存储单位的容器(格子)上。 这就好比: 空间哈希网格是那个划分了区域的图书馆大厅。 哈希算法是那个看一眼书名就能立刻报出书架编号的超级管理员。 你(游戏引擎) 想问“《三体》在哪?”,管理员瞬间告诉你“5381号架”,你直接过去,而不用逛遍整个大厅。 在《帝国时代2》这样的游戏中,这套机制是保证成千上万个单位能够流畅运行而不会卡顿的关键技术之一。 ``` 反正我是不懂,不过就当是真的吧,确实有效果。 那么这样我们的MCP就有感知能力了,以下是AI感知周围的截图:  ## MCP服务端设计 这是最关键的一部分,也是最耗费时间的部分。总的来说,我们其实有很多种让AI玩的方案,不一定是大语言模型。其实我也考虑过其他方案,不过国庆就这么多天,我也没时间实现了。方案如下 0. OneBrain ALL IN 1. 决策层(有限状态机/行为树) + 命令执行层 2. 决策层 (效用AI) + 命令执行层 第一种是,我们直接让AI参与控制决策并且直接控制各个单位移动,部署。 -这样做的比较快,本次项目用的就是这个。 第二种第三种的“决策层”这个决策层不仅仅是大语言模型,还可以是传统游戏决策树/强化学习的AI甚至是一个xgboost就行,因为它只需要发几个特定的命令: 比如农田建造在x区域。然后命令执行层用代码根据村民位置,选择最好的方案执行。 如果你读过帝国时代2的AI代码,你就会发现,帝国时代2的AI就是这样做的。他的好处是决策和执行分开,不需要太大的沉默成本。但是也是最费时间的,原因很简单,有限状态机/决策树是可枚举的,但是帝国时代2的这个RTS被开发出了上百种玩法,即便是帝国时代2的极难AI也有缺陷-比如黑暗时代封墙,黑森林围墙之类的。所以这个方案耗时耗力,考虑到我不是专业游戏开发,还是算了- 要是哪天我去做游戏的我会试试的 第三种 不为AI定义明确的“如果-那么”规则,而是为每个可能的行动计算一个“得分”(效用值),然后选择得分最高的那个行动 比如,计算得分: 攻击的得分 = 攻击欲望 * 命中率 吃药的得分 = (1 - 生命值百分比) * 药瓶价值 找掩体的得分 = 危险程度 * 附近掩体质量 选择:比较这三个得分,执行最高的那个 这个也比较复杂,复杂不是决策层的设计而是那个命令执行层的设计。所以也pass了 因此我选择直接让AI控制单位这种低级粗暴的算法。因为我没多少时间,国庆后还要当牛马 因此我设计了17款工具,抛开别的,关键的几个是 ### incident 为了让AI有态势感知能力,我编写了一个incident系统,其实游戏也有incident系统,比如你被野生动物攻击,敌人正在进攻城镇什么的,但是我实在是不想hook了,因为hook意味着你还需要把incident传入客户端,alpc是单向的,我懒得再建立一个服务器了。因此我在incident加了一个events,循环遍历把事件加入到表里面:  比如遇见敌方军队,遇见自然资源,都加进去  事实证明这非常有用,基本所有的AI都靠这个做战场态势感知。 ### 移动  ### 智能交互 a单位和b单位进行交互,计算两个单位是什么东西,比如村民交互了绵羊那就是在杀羊.发attack命令。士兵选择了敌方城堡 那就是攻击命令  ### 地图发现 这个工具让AI输入坐标,输出里面一定范围内有什么  这样能方便建造,训练,位置摆放。 map_discover虽然我不想让AI作弊,但是我不想再搞个视野检查系统了,太累了,虽然能搞。但是时间有限,还是让AI作弊吧。不过AI也不知道敌人坐标,所以还好。 > 实战观测到,claude会用这个作弊,其他都是老老实实排除侦察骑兵进行侦察 ### 建造功能 值得注意的是,本来建造功能没那么复杂,直接调用游戏函数判断结果的  结果我发现: 大部分AI包括claude除了GLM,他们的"空间感"都不是很好,经常建造在重复建造的区域内...所以实在是受不了了,又自己硬编码了建筑物大小,前置信息等乱七八糟的东西,防止AI瞎搞并且给出信息  ### http服务器 这17个工具都用httplib暴露接口,做标准的MCP服务器 httplib 非常推荐这玩意,真的好用,祖传了几年,没出事过 https://github.com/yhirose/cpp-httplib ### agent设计 整个agent部分我用deepseek一边调试一边写的,deepseek可以说是第一个玩帝国时代2的agent,因为他的token真的便宜,可以给我大量的试错。经过deepseek的调整,花了几天功夫,我总结出以下几点 1. 所有AI没有长期规划能力,所以一定要让他们自己维护一个todolist.而且我规定必须带数字,比如搜集100食物,这规定是代码写死的,提交不明确的信息将被拒绝,事实证明这非常有效果。  2. 我把10-12次对话设置为一轮,一轮后一开始是由AI自己总结对话,继续新的对话实现token"压缩"但是token压缩效果非常差,表现为deepseek经常会把错误带入到下一个对话,导致拉闸。所以我决定彻底放弃对话,而是设计一个"军师"的角色,这个角色的目的是根据现在的游戏信息+反思历史AI和工具交互的错误,总结AI的错误,这不是简单的token压缩。因此deepseek才终于能反思自己的错误避免错误传递到下一轮,并且军师的思考能直接决定战局走向。非常不错  3. 写告警事件: 大部分AI其实完全不会玩帝国时代2,或者说他们压根没训练过帝国时代2, 很多时候不知道自己要干什么,比如一堆空闲农民他们觉得是正常的,因此我设计了一个硬编码的告警系统,提醒他们需要干什么,比如空闲农民过多,立刻不让他们空闲之类的:  这我也没办法,毕竟AI没玩过帝国时代2,就这样AI还会出错,比如GLM:  4. 内置工具call: 我也不知道为什么,deepseek的深度思考是不支持工具调用的,所以我没办法,只能做一个正则匹配的内置call,具体来说匹配tools开头的标签和结尾,然后把里面的json解析出来,事实证明,其他具备agent能力的模型,是内置call也好还是openai格式的call也罢都支持挺不错的,唯一就是deepseek的think模型完全不支持这样。反正非常蛋疼 ## 总结 感谢你的耐心收看,如果你对帝国时代2的游戏引擎/游戏机制/优秀的游戏网络系统感兴趣的话,**更多详细的技术分析报告详我会整理大概半个月后发出来**,微信公众号回复预约帝国时代2技术分析报告可以参与预约,如果预约的人太少,那就烂在肚子里面吧,因为他的游戏机制实在是太多,太复杂了,我还在考虑也不要在我忘记之前记录下来。 整个过程非常累人,这个工作量远远超出我的预期了,表现在,当我认为现在的功能完美能让AI用的时候,我发现AI又缺功能了,又缺功能,只能继续硬着头皮逆向,来来去去,,这种活本来预期三天的搞了快一个星期,我决定下次不整这种活了,国庆就应该好好休息。 本文由 huoji 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。 点赞 2
还不快抢沙发