郁金香游戏辅助教程笔记-全-

郁金香游戏辅助教程笔记(全)

课程 P1:012 - 封装背包对象列表与读取背包物品信息 📦

在本节课中,我们将学习如何对游戏背包数据进行结构分析,并将其封装成易于管理的对象列表。我们将定义一个结构体来存储物品属性,并通过读取游戏内存数据来填充这个列表,最终实现背包物品信息的获取与显示。


结构体定义与封装 🏗️

上一节我们分析了背包数据的结构,本节中我们来看看如何将这些属性封装成一个结构体。

我们分析的对象包含以下属性:物品名称、物品功能描述和物品数量。因此,我们定义一个结构体来存储这些信息。

struct BagItem {
    std::string name;      // 物品名字
    std::string desc;      // 物品功能描述
    int count;             // 物品数量
};

背包共有36个格子,因此我们需要一个包含36个元素的数组来存储这些物品信息。为了提高代码可读性,我们使用常量代替数字。

const int BAG_SIZE = 36;
BagItem bagItems[BAG_SIZE];

初始化与数据读取 🔄

定义好结构后,我们需要从游戏内存中读取数据并填充到我们的结构体数组中。我们将这个过程封装在一个初始化函数中。

以下是初始化函数 GetBagData 的实现步骤:

  1. 获取背包基础地址。
  2. 循环遍历36个背包格子。
  3. 对于每个格子,计算其物品对象的地址。
  4. 通过地址偏移读取物品的名称、描述和数量。
  5. 将读取到的数据保存到 BagItem 结构体中。
BagItem* GetBagData() {
    // 异常处理,简化代码
    __try {
        // 1. 读取背包列表基础地址
        DWORD baseAddr = *(DWORD*)0x12345678; // 示例地址

        for (int i = 0; i < BAG_SIZE; i++) {
            // 2. 计算当前格子物品对象的地址
            DWORD itemAddr = *(DWORD*)(baseAddr + 0x4 * i);
            if (itemAddr == 0) {
                // 如果地址为空,表示该格子无物品
                bagItems[i].count = 0;
                continue;
            }

            // 3. 通过偏移读取各项数据
            // 物品名称偏移:0x1DC
            bagItems[i].name = (char*)(itemAddr + 0x1DC);
            // 物品描述偏移:0x1F1
            bagItems[i].desc = (char*)(itemAddr + 0x1F1);
            // 物品数量偏移:0x24
            bagItems[i].count = *(int*)(itemAddr + 0x24);
        }
    }
    __except(...) {
        // 处理所有异常
    }
    return bagItems;
}

关键点说明:

  • baseAddr + 0x4 * i:计算第 i 个格子物品指针的地址。乘以4是因为每个指针占4字节。
  • if (itemAddr == 0):检查格子是否为空,避免访问空指针导致错误。
  • (char*)(itemAddr + offset):获取字符串数据的指针。
  • *(int*)(itemAddr + offset):读取整数数据(如物品数量)。

使用封装的数据 📝

初始化完成后,我们就可以方便地使用背包数据了。以下是如何遍历并打印背包中非空物品的信息。

在使用数据前,必须先调用初始化函数。推荐使用函数返回的指针来访问数据,这样可以确保每次访问时数据都是最新的。

// 获取背包数据指针
BagItem* pBag = GetBagData();

// 遍历并打印物品信息
for (int i = 0; i < BAG_SIZE; i++) {
    if (pBag[i].count == 0) {
        continue; // 跳过空格子
    }
    // 打印格式:第X格: [名字] 数量: X 描述: XXX
    printf("第%d格: [%s] 数量: %d 描述: %s\n",
           i + 1,
           pBag[i].name.c_str(),
           pBag[i].count,
           pBag[i].desc.c_str());
}

代码改进建议:
GetBagData 设计为返回 BagItem* 指针,并在函数内部完成初始化。这样调用者无需担心忘记初始化,直接使用返回的指针即可访问最新数据。这是一种更安全、便捷的做法。


总结 🎯

本节课中我们一起学习了如何封装游戏背包数据。

  1. 我们首先定义了一个 BagItem 结构体,用于清晰存储物品的名称、描述和数量。
  2. 接着,我们实现了 GetBagData 函数,通过读取游戏内存并计算地址偏移,将原始数据填充到我们的结构体数组中。
  3. 最后,我们演示了如何遍历并使用封装好的数据,并给出了返回指针以简化调用的优化建议。

通过本次封装,我们将底层复杂的内存操作隐藏起来,后续所有关于背包物品的操作都可以基于这个清晰、安全的 BagItem 列表进行,大大提高了代码的可维护性和可读性。

课程 P10:021 - 分析动作数组与攻击捡物功能 🎮

在本节课中,我们将学习如何分析游戏中的动作数组,并找到调用攻击和捡物等动作功能的关键代码。这是为后续实现自动挂机打怪功能做准备。

概述

游戏的功能通常封装在动作数组中。本节课的目标是找到这个数组,并分析出调用其中特定动作(如攻击怪物)的方法。我们的分析思路是:通过观察游戏界面变化时内存数据的变动,定位到存储动作信息的变量,进而找到整个动作对象数组及其调用函数。

分析动作数组

上一节我们概述了目标,本节中我们来看看具体的分析步骤。首先,我们需要定位到游戏内部存储“当前选中动作”的变量。

  1. 打开调试器(CE),附加到游戏进程。
  2. 在未选择任何动作时,该变量值可能为0。我们可以首次扫描数值0。
  3. 在游戏界面选中“攻击”动作。此时,该变量会被写入一个非零值(可能是对象地址或ID),用于表示“攻击”。我们接着扫描“增加的数值”。
  4. 在游戏界面取消选择(放回),变量值应恢复为0。我们扫描“变动的数值”。
  5. 重复步骤3和4数次(选中攻击->扫描增加的数值;取消选中->扫描变动的数值),可以逐步缩小结果范围。
  6. 最终,我们可能会定位到一个或几个内存地址。记录下选中不同动作时,这些地址的值。

以下是记录到的三个可能对象的地址(示例):

  • 对象一地址:0x5050
  • 对象二地址:0x5298
  • 对象三地址:0x5410

定位动作对象数组

我们取得了几个疑似动作对象的地址。如果它们属于同一个数组,那么在内存中应该是连续存放的。我们可以通过查看内存区域来验证。

  1. 在调试器中查看 0x5050 附近的内存。发现它指向一片包含多个对象的数据区域。
  2. 查看 0x5298 附近的内存。发现它指向一个包含恰好12个对象的干净数组。
  3. 查看 0x5410 附近的内存。发现它指向的数据区域包含对象过多,不够“干净”。

通过对比,第二个地址(0x5298)指向的包含12个对象的数组最有可能是我们寻找的动作对象数组。因为游戏中的动作(攻击、打坐、捡物等)通常就是12个。

查找数组基地址与结构

我们找到了数组中的一个对象,但我们需要找到这个数组的基地址(起始地址),以便通过索引访问所有动作。

  1. 对找到的数组对象地址下内存访问断点。
  2. 在游戏中切换动作,触发断点。观察是哪条指令在读取这个地址。
  3. 分析该指令的寻址公式。通常形式为:[[基地址] + 偏移A] + 偏移B + 索引*步长]
  4. 通过回溯,找到稳定的绿色基地址。最终我们找到了动作数组的基地址 0x3129298

其访问公式可以总结为:

动作对象地址 = [[0x3129298] + 0x410] + 索引 * 4

其中,索引 对应动作的编号(0-11)。

分析动作调用功能(CALL)

找到了动作数组,接下来我们需要找到使用这些动作的功能函数(CALL)。我们的思路是:监视对攻击动作对象的访问,从而找到调用它的代码。

  1. 在内存中定位到攻击动作对象的地址(例如,索引为2)。
  2. 对该地址下内存访问断点。
  3. 在游戏中使用“攻击”功能(右键点击怪物)。这会触发断点。
  4. 断点触发后,观察附近的代码。通常,在取出动作对象后,附近会有判断和调用功能函数(CALL)的代码。
  5. 通过跟踪和测试不同的动作(如下标为1的打坐,下标为3的捡物),我们发现一个关键的函数调用(CALL)。

这个CALL的调用参数如下:

  • ECX 寄存器:来源于基地址 [[0x3129298] + 0x488] 的值。
  • EDX 寄存器:来源于动作对象地址 + 0x4C 的值。
  • 动作下标:通过 EBX 寄存器传递。

调用攻击功能(下标为2)的示例汇编代码大致如下:

mov edi, [0x3129298]    ; 读取基地址
mov edi, [edi+0x410]     ; 第一层偏移
mov eax, [edi+ebx*4]     ; 通过索引获取动作对象地址,ebx=2
mov edx, [eax+0x4C]      ; 获取参数EDX
mov ecx, [0x3129298]     ; 重新读取基地址
mov ecx, [ecx+0x488]     ; 获取参数ECX
call 0x6796B0            ; 调用动作功能函数

通过修改 EBX 的值(0-11),即可调用对应的12种游戏动作。

总结

本节课中我们一起学习了逆向分析游戏功能的基本流程:

  1. 定位数据:通过内存扫描,找到与游戏界面交互相关的关键变量和对象数组。
  2. 分析结构:确定数组的基地址、索引方式和对象结构。
  3. 查找功能:通过下断点跟踪,定位到操作这些数据的关键功能函数(CALL),并分析其调用约定和参数。

我们成功找到了游戏的动作数组 [[0x3129298]+0x410] 及其调用函数 call 0x6796B0。这为下一节课封装这些数据与功能,进而实现自动挂机逻辑打下了坚实的基础。

课程 P100:111-回城补给设计-封装仓库存取函数 📦

在本节课中,我们将学习如何将与仓库操作相关的函数进行封装,使其成为一个独立的模块,从而提高代码的可管理性和复用性。我们将从现有代码中提取功能,并重构为清晰的函数接口。


概述

我们将从游戏代码中提取与“存放物品到仓库”相关的逻辑,并将其封装成独立的函数。这个过程涉及代码迁移、函数重构以及接口定义,目标是让仓库操作像使用背包函数一样方便。

上一节我们介绍了仓库的基本结构,本节中我们来看看如何具体封装操作函数。


第一步:定位并迁移现有代码

首先,我们需要找到游戏中处理“存放物品”功能的原始代码。这段代码最初写在背包列表的成员函数中,现在需要将其移植到仓库结构中。

以下是具体操作步骤:

  1. 在代码中查找“存放物品”相关的函数或逻辑块。
  2. 确认该逻辑当前属于背包结构(例如 BagBackpack)。
  3. 将找到的代码段整体剪切或复制到目标仓库类(例如 DepotWarehouse)的定义中。
  4. 修改代码的所属关系,将函数绑定到仓库类,并调整其中引用的成员变量(例如将背包列表 bagList 改为仓库列表 depotList)。

迁移并修改所属关系后,需要编译测试以确保没有语法错误。如果测试通过,就可以将背包类中的原始函数注释掉或删除。


第二步:在主线程单元中封装接口

代码迁移完成后,我们需要在游戏的主线程单元中创建一个统一的调用接口。这样,其他模块就可以通过简单的函数调用来使用仓库功能,而无需关心内部实现。

我们首先封装“保存物品到仓库”的函数。

  1. 在主线程单元(例如 GameMainThread)中,找到或添加处理仓库操作的消息处理部分。
  2. 定义一个函数,例如 SaveToDepot,它接收必要的参数(如物品ID、物品数量)。
  3. 在这个函数内部,将参数打包成消息,并发送给仓库模块进行处理。

其核心流程可以用以下伪代码描述:

// 伪代码示例
void SaveToDepot(int itemId, int count) {
    // 1. 准备消息结构体
    DepotMessage msg;
    msg.type = MSG_SAVE_TO_DEPOT;
    msg.itemId = itemId;
    msg.count = count;

    // 2. 发送消息到仓库模块的消息队列
    SendMessageToDepotModule(msg);
}

需要注意的是,在封装时,要确保参数传递正确,特别是物品名称或ID的查找,应基于背包的全局数据,而不是仓库数据。


第三步:测试封装后的函数

函数封装完成后,必须进行测试以验证其功能是否正常。

以下是测试流程:

  1. 在测试代码中,调用新封装的 SaveToDepot 函数。
  2. 传入测试参数,例如物品“金创药”和数量3。
  3. 运行游戏,观察是否能成功将物品从背包转移到仓库。
  4. 同时检查游戏日志或调试信息,确认没有错误发生。

如果测试中发现“无法转移物品”或“找不到物品”等错误,需要检查:

  • 传递的物品名称或ID是否正确。
  • 函数内部查找物品时,是否错误地查询了仓库列表而不是背包列表。
  • 消息传递的流程是否完整,参数类型是否匹配。

第四步:封装其他仓库相关函数

成功封装保存函数后,我们可以用同样的模式封装其他仓库操作,形成一个完整的接口集。

我们需要封装的主要函数包括:

  • 从仓库取出物品GetGoodsFromDepot
  • 打开仓库NPC对话OpenDepotDialog
  • 关闭仓库CloseDepot

每个函数的封装步骤与之前类似:

  1. 在主线程单元定义函数原型。
  2. 在消息处理结构中添加对应的 case 分支。
  3. case 分支内,调用仓库模块的全局变量或函数来完成实际操作。

例如,取出物品的函数可能如下所示:

// 伪代码示例
void GetGoodsFromDepot(int itemId, int count) {
    // 通过全局的仓库管理器调用取出功能
    g_pDepotManager->RetrieveItem(itemId, count);
}

第五步:集成测试所有功能

所有函数封装完毕后,需要进行集成测试,确保整个流程顺畅。

我们可以编写一个简单的测试序列:

// 测试序列伪代码
void TestDepotFunctions() {
    OpenDepotDialog("仓库管理员"); // 1. 打开仓库NPC
    SaveToDepot("金创药", 3);      // 2. 存放3个金创药
    GetGoodsFromDepot("金创药", 1); // 3. 取出1个金创药
    CloseDepot();                   // 4. 关闭仓库
}

运行此测试,并观察每一步操作是否都能在游戏中正确执行。通过这个测试,我们可以验证所有封装函数的正确性和协同工作的能力。


总结

本节课中我们一起学习了如何封装仓库存取函数。我们首先将分散在背包模块中的仓库操作代码迁移并整合到仓库类中,然后在主线程单元为其创建了清晰的函数接口,包括存放、取出、打开和关闭仓库等功能。最后,我们通过测试验证了每个函数的正确性。通过这次封装,仓库相关的操作变得模块化且易于调用,为后续更复杂的自动化逻辑打下了基础。

下一节课,我们将在此基础上,进行更进一步的封装和流程整合测试。

课程 P101:回城补给函数 - 仓库存取部分 🏦

在本节课中,我们将学习如何封装一个名为 GotoDepotForSupply() 的函数,用于处理角色返回仓库进行物品存取的操作。我们将重点讲解如何遍历物品列表,判断物品去向,并实现与游戏仓库的交互。


概述

本节课的目标是创建一个函数,用于处理角色返回主城后,前往仓库进行物品存取的需求。该函数需要处理两个核心列表:一个用于存放需要存放到仓库的物品信息,另一个用于存放需要从仓库取出的物品信息。我们将通过代码逐步实现这些功能。


函数声明与结构

首先,我们需要在代码的适当位置声明两个函数:一个用于处理仓库补给(存放物品),另一个用于处理商店补给(将在后续课程实现)。以下是函数声明的示例:

// 函数声明
bool GotoDepotForSupply(); // 前往仓库进行补给
bool GotoShopForSupply();  // 前往商店进行补给(后续实现)

在回城补给的主函数中,我们需要调用这两个函数。为了确保代码结构清晰,我们先将这两个函数定义为空函数,后续再逐步填充其功能。


实现仓库补给函数

上一节我们声明了函数,本节中我们来看看如何实现 GotoDepotForSupply() 函数的核心逻辑。该函数主要处理两个 std::vector 容器(列表)中的数据。

1. 寻路至仓库

首先,角色需要移动到仓库NPC的位置。我们暂时使用固定的坐标和地图ID来实现寻路。请注意,不同地图的仓库坐标可能不同,后续我们需要将其与地图ID关联,以实现动态寻路。

以下是寻路至仓库的示例代码:

bool GotoDepotForSupply() {
    // 假设的仓库坐标和NPC名称(后续需动态化)
    int depotMapID = 1001;
    std::string depotNPCName = "仓库管理员";
    Position depotPos = {120, 80};

    // 调用寻路函数,移动到仓库NPC附近
    if (!MoveToPosition(depotMapID, depotPos)) {
        // 寻路失败处理
        return false;
    }
    // 增加延迟,确保寻路完成
    Sleep(1000);
    return true;
}

2. 与仓库NPC交互

到达仓库附近后,需要与仓库NPC对话并打开仓库界面。我们同样使用固定的NPC名称进行交互,后续需要将其与地图ID关联。

以下是打开仓库界面的示例代码:

// 与NPC对话
if (!TalkToNPC(depotNPCName)) {
    return false;
}
Sleep(1000); // 等待服务器响应

// 打开仓库界面
if (!OpenDepot(depotNPCName)) {
    return false;
}
Sleep(1000); // 等待界面加载

处理物品存取列表

成功打开仓库界面后,我们需要处理两个列表中的数据:一个是要存放到仓库的物品列表,另一个是要从仓库取出的物品列表。

处理存放物品列表

以下是遍历存放物品列表并执行存放操作的步骤:

  1. 遍历列表:使用迭代器遍历存放物品的 std::vector
  2. 判断物品去向:检查物品的标记(例如一个 DWORD 类型的值),确认其是否需要存放到仓库。
  3. 查询背包:在背包中查找该物品是否存在。
  4. 执行存放:如果物品存在,则获取其数量并执行存放操作。

以下是核心代码逻辑:

std::vector<ItemInfo>::iterator itDeposit = depositList.begin();
for (; itDeposit != depositList.end(); ++itDeposit) {
    // 判断物品去向标记是否为“仓库”
    if (itDeposit->destinationFlag == DESTINATION_DEPOT) {
        // 在背包中查询该物品
        int backpackIndex = FindItemInBackpack(itDeposit->itemName);
        if (backpackIndex < 0) {
            continue; // 背包中不存在,跳过
        }
        // 获取背包中该物品的数量
        int itemCount = GetItemCountInBackpack(backpackIndex);
        // 执行存放操作
        DepositItemToDepot(itDeposit->itemName, itemCount);
    }
}

处理取出物品列表

处理完存放列表后,接下来处理需要从仓库取出的物品列表。逻辑与存放类似,但查询和操作的对象是仓库。

以下是处理取出物品列表的步骤:

  1. 遍历列表:使用迭代器遍历取出物品的 std::vector
  2. 判断物品去向:确认物品需要从仓库取出。
  3. 查询仓库:在仓库中查找该物品是否存在。
  4. 计算数量:比较希望取出的数量和仓库实际数量,取较小值。
  5. 执行取出:执行从仓库取出物品的操作。

以下是核心代码逻辑:

std::vector<ItemInfo>::iterator itWithdraw = withdrawList.begin();
for (; itWithdraw != withdrawList.end(); ++itWithdraw) {
    if (itWithdraw->destinationFlag == DESTINATION_DEPOT) {
        // 在仓库中查询该物品
        int depotIndex = FindItemInDepot(itWithdraw->itemName);
        if (depotIndex < 0) {
            continue; // 仓库中不存在,跳过
        }
        // 获取仓库中该物品的数量
        int depotCount = GetItemCountInDepot(depotIndex);
        // 计算实际取出的数量(取期望值和实际值的最小值)
        int withdrawAmount = (itWithdraw->desiredCount < depotCount) ? itWithdraw->desiredCount : depotCount;
        // 执行取出操作
        WithdrawItemFromDepot(itWithdraw->itemName, withdrawAmount);
    }
}

错误处理与优化

在实现过程中,我们需要注意以下几点:

  1. 容器初始化:在每次更新列表数据前,清空 std::vector,防止数据累积。
  2. 延迟等待:在关键操作(如寻路、打开界面、存取物品)后添加适当的延迟(如 Sleep),等待服务器响应和数据同步。
  3. 线程安全:如果直接在其他线程中调用游戏函数,可能导致数据冲突或程序崩溃。理想情况下,应将此类函数封装并调度到游戏主线程中执行。
  4. 异常捕获:在遍历容器时,添加异常处理机制,以便在出现错误时打印调试信息,快速定位问题。


测试与调试

为了测试函数功能,我们应在独立的线程中调用 GotoDepotForSupply(),而不是在窗口主线程中直接测试,以免造成界面卡死。

测试步骤建议如下:

  1. 在补给设置界面,添加一些标记为“存仓库”的物品(如金创药小)。
  2. 添加一些标记为“从仓库取出”的物品。
  3. 启动挂机线程,观察角色是否能够正确寻路至仓库,并完成物品的存取。
  4. 通过调试信息或日志,确认操作是否成功,以及是否有错误产生。

总结

本节课中我们一起学习了如何封装 GotoDepotForSupply() 函数,实现了以下核心功能:

  • 角色自动寻路至仓库NPC。
  • 与仓库NPC交互并打开仓库界面。
  • 遍历物品列表,根据物品去向标记,将物品存放到仓库或从仓库取出。
  • 实现了基本的错误处理和延迟等待机制。

请注意,当前实现中使用了固定的坐标和NPC名称。在后续课程中,我们需要将其改进为通过地图ID动态关联,并完善线程安全等高级特性。


课后练习

请尝试完成以下练习,以巩固本节课的知识:

  1. 完善代码:根据课堂示例,补全 FindItemInBackpackDepositItemToDepot 等辅助函数的实现。
  2. 实现商店补给函数:仿照仓库补给函数,实现 GotoShopForSupply() 函数。该函数需要处理向商店出售物品以及从商店购买物品补给的逻辑。
  3. 优化列表管理:在物品处理界面,实现删除某一行物品数据的功能。

通过完成这些练习,你将更深入地理解游戏辅助功能中数据管理和流程控制的实现方法。

课程 P102:113-回城补给设计-封装商店买卖函数 GotoShopForSupply() 🛒

在本节课中,我们将学习如何封装一个完整的商店补给函数 GotoShopForSupply()。这个函数不仅会从商店购买所需物品,还会将背包中不需要的物品出售给商店。我们将基于之前编写的回城补给函数进行修改和扩展。


概述与准备工作

上一节我们实现了回城去药店补给的功能。本节中,我们将在此基础上,增加向商店出售物品的流程,从而创建一个更通用的商店补给函数。

首先,我们需要在现有代码上进行修改。找到并复制之前编写的回城补给函数(例如 GoToWarehouseForSupply)的代码作为基础模板。


第一步:修改寻路与NPC交互目标

原回城补给函数的目标是仓库管理员“韦大宝”。现在,我们的目标是商店NPC“平四指”。因此,需要修改寻路坐标和交互的NPC。

核心修改如下:

  1. 将寻路坐标改为“平四指”所在的位置。
  2. 将打开和关闭的NPC对象从“韦大宝”改为“平四指”。
// 示例:修改寻路目标
MoveToNPC("平四指"); // 替换原来的 MoveToNPC("韦大宝")

第二步:封装关闭NPC的通用函数

在原有代码中,关闭NPC(如仓库)的操作是硬编码的。为了同时支持关闭“平四指”(商店)和“韦大宝”(仓库),我们需要一个更通用的关闭函数。

分析发现,关闭不同NPC的主要区别在于数据包中一个特定字节的值。我们可以根据NPC名称来动态设置这个值。

以下是封装思路:

  1. 创建一个函数 CloseNPCByName,接收NPC名称作为参数。
  2. 在函数内部,根据名称判断并设置关键字节的值。
    • "平四指" 对应值 3
    • "韦大宝" 对应值 1
  3. 发送构造好的数据包以关闭NPC。
void CloseNPCByName(const char* npcName) {
    // 构造基础数据包
    BYTE packet[PACKET_SIZE];
    // ... 初始化数据包 ...

    // 根据NPC名称设置关键字节
    int keyByteValue = 0;
    if (strcmp(npcName, "平四指") == 0) {
        keyByteValue = 3;
    } else if (strcmp(npcName, "韦大宝") == 0) {
        keyByteValue = 1;
    }
    // 将 keyByteValue 设置到数据包特定偏移位置
    packet[SPECIFIC_OFFSET] = keyByteValue;

    // 发送数据包
    SendPacket(packet);
}

这样,在商店补给函数中,我们只需调用 CloseNPCByName("平四指") 即可。


第三步:处理物品出售逻辑

商店补给的核心新增功能是出售物品。这需要两个步骤:

  1. 判断物品去向:从配置列表中读取,判断物品是“存入仓库”还是“出售给商店”。
  2. 执行出售操作:若为出售,则调用出售函数。

以下是处理物品列表的核心逻辑:

// 遍历物品处理列表
for (Item item : itemList) {
    if (item.destination == DESTINATION_SHOP) {
        // 出售给商店
        SellItemToShop(item.name, item.quantity);
    } else if (item.destination == DESTINATION_WAREHOUSE) {
        // 存入仓库(原有逻辑)
        StoreItemToWarehouse(item.name, item.quantity);
    }
}


第四步:封装出售物品函数

我们发现,现有的背包操作函数中可能缺少直接出售物品给商店的功能。因此,需要封装一个新的函数 SellItemToShop

函数设计如下:

  1. 功能:根据物品名称和数量,将其出售给当前打开的商店。
  2. 参数:物品名称 (itemName)、出售数量 (quantity)。
  3. 实现:构造出售交易的数据包并发送。这通常涉及在背包中查找物品索引,然后向服务器发送出售指令。

同时,为了线程安全,最好将涉及游戏核心数据交互的函数(如出售)封装成消息,推送到主线程执行。

// 声明一个主线程消息处理函数
MSG_SellItemToShop(const char* itemName, int quantity) {
    // 在主线程中安全地执行出售操作
    int itemIndex = FindItemInBackpack(itemName);
    if (itemIndex != -1) {
        // 构造并发送出售数据包
        SendSellPacket(itemIndex, quantity);
    }
}

GotoShopForSupply 函数中,调用 MSG_SellItemToShop 来出售物品。


第五步:处理商店购买逻辑

购买逻辑与原有仓库补给类似,但查询和操作的目标是商店而非仓库。

购买流程如下:

  1. 查询商店中是否存在需要购买的物品。
  2. 如果存在,则调用购买函数 BuyItemFromShop
  3. 同样建议将购买函数封装为线程安全的消息函数 MSG_BuyItemFromShop
// 在补给循环中
if (item.destination == DESTINATION_BUY_FROM_SHOP) {
    if (FindItemInShop(item.name)) {
        MSG_BuyItemFromShop(item.name, item.quantity);
    }
}


第六步:整合与测试

将以上所有步骤整合到 GotoShopForSupply() 函数中。完整的函数流程如下:

  1. 寻路至商店NPC“平四指”。
  2. 打开与“平四指”的对话并进入商店界面。
  3. 出售阶段:遍历物品列表,将标记为“出售”的物品卖给商店。
  4. 购买阶段:遍历物品列表,从商店购买标记为“购买”的物品。
  5. 关闭商店界面。

测试时,注意在配置中正确设置物品的“去向”(出售/购买/存仓),并观察游戏角色是否能正确完成寻路、买卖和关闭界面等一系列操作。


注意事项与优化建议

在实现过程中,需要注意以下几点:

  • 线程安全:所有直接读写游戏内存或发送关键数据包的操作(如买卖、寻路),都应封装成消息函数(MSG_ 开头),由主线程统一处理,避免多线程冲突导致游戏崩溃或数据错误。
  • 函数分离:避免在 MSG_ 消息函数中使用 Sleep 或可能导致主线程阻塞的代码。耗时或等待操作应在辅助线程中处理。
  • 错误处理:在关键步骤(如查找物品、发送数据包)后添加日志输出,便于调试。
  • 扩展性CloseNPCByName 函数可以进一步扩展,以支持更多NPC类型。

总结

本节课中,我们一起学习了如何封装一个功能完整的商店补给函数 GotoShopForSupply()。关键点包括:

  1. 修改基础:基于仓库补给函数,更改目标NPC为商店。
  2. 通用关闭:封装了根据NPC名称关闭对话框的通用函数。
  3. 买卖逻辑:实现了向商店出售物品和从商店购买物品的双向逻辑。
  4. 线程安全:强调了将核心操作封装成主线程消息的重要性,以确保稳定性。

通过本课,你掌握了如何设计一个集购买与出售于一体的自动化商店交互模块,这是游戏辅助功能中非常实用的一环。请务必进行充分测试,并根据实际游戏情况调整数据包参数和延时设置。

课程 P103:114 - 重新设计挂机代码、防移动卡死与完善回城补给 🛠️

在本节课中,我们将学习如何重构挂机代码的逻辑,解决角色移动时可能出现的卡死问题,并完善自动回城补给的功能。我们将从分析现有代码的问题开始,逐步进行修改和优化。


概述

本节课的核心任务是优化挂机脚本。我们将修正原有代码的逻辑错误,更新游戏机制,并重新设计代码结构以提高可读性和健壮性。重点包括实现低血保护、完善回城补给流程以及为寻路系统增加防卡死机制。


第一步:更新游戏机制与初始化

首先,我们需要更新游戏的机制数据,因为游戏本身已经发生了变化。

  1. 打开机制单元,运行机制更新工具。
  2. 对比新旧机制,删除旧的机制数据,导入新的机制数据。
  3. 重新编译代码。

完成机制更新后,我们需要对数据进行初始化修改。

以下是初始化回城补给选项和仓库数据的步骤:

  • 移动到回城补给选项,复制相关数据。
  • 删除仓库的旧代码,并重新进行初始化。

第二步:重构挂机循环逻辑

上一节我们更新了基础数据,本节中我们来看看如何重构挂机的主循环逻辑。我们将使代码结构更清晰,功能模块化。

移动到挂机单元,对挂机率的代码进行完善。可以删除旧有的复杂循环,重新设计。

新的挂机循环将按以下顺序执行:

  1. 低血保护:当生命值过低时采取保护措施。
  2. 回城补给:检查药品和物资,必要时回城购买或存取。
  3. 自动打怪/任务:执行自动打怪或定点打怪逻辑。

我们将相应的功能代码(低血保护函数、回城补给函数、自动打怪函数)粘贴到新的结构框架中。自动任务逻辑应放在自动打怪之前。

低血保护的核心代码示例如下:

if (currentHP < lowHPThreshold) {
    useHealthPotion(); // 使用血瓶
    retreatToSafeZone(); // 撤退到安全区
}

第三步:完善回城补给与自动打怪

在重构了主框架后,我们需要完善各个子模块。首先处理回城补给和自动打怪模块。

在挂机循环中,我们将旧的、分散的代码删除。定点打怪的逻辑需要整合到自动打怪的函数中去。

我们需要设计一个独立的自动打怪函数,将挂机循环和定点打怪的代码都添加进去。这样的设计提高了代码的可读性和可维护性。自动拾取物品的功能我们将在后续另外设计。

修改后,循环内的代码主要分为三部分:低血保护、回城补给以及预留的自动拾物。


第四步:解决寻路卡死问题

一个常见的问题是角色在寻路过程中会在某个点卡住不动。我们需要修改寻路逻辑来防止这种情况。

原有的寻路逻辑有一个错误:当距离目标小于某个值时,它错误地继续执行寻路。正确的逻辑应该是:当距离大于该值时,才继续寻路;当距离小于或等于该值时,表示寻路成功并返回。

为了防卡死,我们可以引入一个记录上次距离的变量 lastDistance

防卡死算法的核心思路是:

  1. 初始化 lastDistance 为0或任意值。
  2. 每次寻路后,保存当前距离到 lastDistance
  3. 在下一次寻路前进行判断:如果 当前距离 == lastDistance,说明角色卡住了。
  4. 触发防卡移动:将当前坐标的X或Y值增加一个随机数(例如1-10),然后重新寻路。
  5. 移动后,需要给予角色足够的时间到达新设定的目的地。

修改后的寻路判断逻辑如下:

if (currentDistance > targetDistance) {
    // 继续寻路
    findPath(targetX, targetY);
} else if (currentDistance <= targetDistance) {
    // 寻路成功,返回
    return true;
}

// 防卡死判断
if (currentDistance == lastDistance) {
    // 卡住了,进行随机位移
    currentX += rand() % 10 + 1;
    findPath(currentX, currentY);
    Sleep(2000); // 等待2秒让角色移动
}
lastDistance = currentDistance; // 更新上次距离

第五步:修正数据获取与界面同步

在回城补给模块中,从编辑框获取数据的方式需要修改,以支持用户直接输入和从列表选择。

我们不能直接获取列表选中的项,而应该先取得编辑框内的文本。这确保了无论是选择的还是手动输入的数据都能被正确添加到列表。

以下是修改编辑框文本获取的示例:

TCHAR buffer[256];
GetWindowText(hEditBox, buffer, sizeof(buffer)/sizeof(TCHAR));
// 此时 buffer 中存储了编辑框内的文本

同样,仓库相关的代码也需要进行类似的修改。

此外,我们需要确保在界面上应用的设置能同步更新到挂机脚本的核心变量中。在“应用设置”的功能里,应将窗口控件的数据更新到对应的成员变量,最终更新到挂机对象中,这样脚本运行时才能使用正确的数值。


第六步:添加延迟与测试优化

在寻路到NPC或特定地点后,NPC可能不会立即刷新出来。如果立刻执行交互操作(如购买、对话),可能会失败。

因此,在关键操作点需要添加适当的延迟:

  • 到达NPC处后,等待1-2秒让其刷新。
  • 前往仓库等特定地点时,等待时间可以稍长,例如2.6秒。

完成以上修改后,进行全面的功能测试。测试流程应包括:

  1. 启动挂机脚本。
  2. 设置药品补给数量(设置一个较大的值以触发回城)。
  3. 测试购买药品、存取仓库、出售物品等功能。
  4. 观察调试信息,确保逻辑按预期执行。
  5. 测试在城外挂机点,是否不会误操作NPC。

在测试过程中,如果发现购买数量出现负数、寻路不动等问题,需要返回检查对应的计算逻辑和防卡死代码是否生效。


第七步:完善用户交互与配置保存

最后,我们还需要优化用户交互体验并实现配置的持久化。

  1. 列表交互优化:当用户再次点击已选中的列表项时,应取消选中状态。这需要在列表的单击事件处理中,先判断当前单元格是否已有选中标志,如果有则清空。
  2. 配置持久化:将药品列表、补给设置等数据保存到配置文件(如INI或XML文件)。这样每次启动脚本时无需重新设置,直接加载即可。
  3. NPC选中状态清理:在打开商店或仓库NPC并关闭后,应取消对NPC的选中状态,避免干扰后续的自动打怪逻辑。这个功能可以在关闭NPC窗口的函数中实现。

总结

本节课中我们一起学习了如何系统性地优化一个挂机脚本。
我们首先更新了游戏机制并初始化了数据,然后重构了挂机主循环,使其结构更清晰。
接着,我们重点完善了回城补给逻辑为寻路系统增加了防卡死机制
此外,我们还修正了数据获取方式优化了用户界面交互,并提出了配置持久化的方案。
通过这些步骤,我们使得脚本的稳定性、可读性和用户体验都得到了显著提升。

课程P104:115-保存挂机设置到配置文件 🗂️

在本节课中,我们将学习如何将窗口界面中的数据保存到自定义的配置文件中。我们将以挂机页面的变量数据为例,演示如何将这些数据写入文本文件,以便下次使用外挂时无需重复设置。


概述与目标

上一节我们介绍了窗口数据的处理。本节中,我们来看看如何将这些数据持久化保存到本地文件中。我们将创建一个函数,用于将挂机页面的所有设置项保存为文本格式的配置文件。

以下是实现此功能的核心步骤:

  1. 将窗口中的各类数据(如布尔值、坐标、字符串)转换为文本格式。
  2. 将这些文本数据按特定格式组织。
  3. 将组织好的数据写入到指定的文件路径中。


准备工作与思路分析

首先,我们需要打开第114课的代码作为基础。保存设置的方法有多种,例如,如果数据是连续的内存结构,可以直接保存为二进制文件。但为了方便手动查看和修改,本节课我们选择使用文本文件来保存配置。

我们将以挂机页面为例,该页面包含复选框(布尔类型)、坐标值(整数类型)和文本框(字符串类型)等共11个数据项。在保存之前,需要先清理代码中一些已无用的数据项,例如已保存到其他列表中的武器名称等。

接下来,我们将在窗口单元的应用设置部分,添加一个用于保存所有配置数据的函数。


实现保存配置函数

我们的核心思路是:将所有数据转换为字符串,格式化后拼接成一个完整的配置字符串,最后写入文件。

首先,我们创建一个字符串变量来保存最终的配置内容,并创建一个临时字符串用于格式化每一行数据。

代码示例:格式化并拼接数据

CString strConfigData; // 用于保存最终的配置字符串
CString strTemp;       // 临时字符串,用于格式化每一行

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/2fcce7f3a580a54babd91b03189c13e7_13.png)

// 示例:格式化一个布尔值选项
strTemp.Format(_T("AutoFight=%d\n"), m_bAutoFight);
strConfigData += strTemp;

// 示例:格式化一个坐标值
strTemp.Format(_T("PosX=%d\n"), m_nPosX);
strConfigData += strTemp;

// 示例:直接添加一个字符串(如角色名)
strTemp.Format(_T("RoleName=%s\n"), m_strRoleName);
strConfigData += strTemp;

// ... 重复此过程,处理所有11个数据项

对于布尔值和整数,我们使用 %d 进行格式化;对于字符串,我们使用 %s 进行格式化。每行格式为 “变量名=值”,并以换行符 \n 结尾,使配置文件清晰易读。


写入配置文件

数据拼接完成后,我们需要将其写入文件。可以选择相对路径(如游戏主程序目录)或绝对路径。这里我们选择相对路径。

代码示例:打开文件并写入数据

FILE* pFile = NULL;
// 使用 _tfopen 打开文件,指定写入模式("w")
errno_t err = _tfopen_s(&pFile, _T("MyConfig.ini"), _T("w"));

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/2fcce7f3a580a54babd91b03189c13e7_15.png)

if (pFile != NULL && err == 0) {
    // 将整个配置字符串写入文件
    _fputts(strConfigData, pFile);
    // 关闭文件
    fclose(pFile);
}

函数 _tfopen_s 用于安全地打开文件。第二个参数 "w" 表示以写入模式打开,如果文件已存在则会覆盖。写入完成后,务必使用 fclose 关闭文件以释放资源。

在调用此保存函数之前,必须确保窗口上的最新数据已经更新到了对应的成员变量中。


功能测试

我们可以在代码中添加一个测试调用,例如在某个按钮点击事件中调用保存函数。保存成功后,在程序的当前目录下会生成配置文件(如 MyConfig.ini)。

打开该文件,可以检查内容是否与窗口设置一致。可以尝试修改窗口设置后再次保存,观察配置文件内容是否相应更新。


总结

本节课中我们一起学习了如何将窗口数据保存到自定义的文本配置文件中。我们掌握了将不同类型数据格式化为字符串、组织文件内容以及使用文件API进行写入的完整流程。

通过本课的学习,我们实现了外挂设置的持久化功能,避免了每次启动都需要重新配置的麻烦。下一节课,我们将讨论如何从配置文件中读取这些数据,并在程序启动时自动更新到窗口界面上。

课程 P105:从配置文件读取数据到窗口 🗂️➡️🪟

在本节课中,我们将学习如何从上一节保存的配置文件中读取数据,并将这些数据更新到应用程序的窗口中。我们将使用文件流操作来读取文件,解析其中的数据,并最终更新界面控件。


打开项目与添加头文件

上一节我们介绍了如何将窗口数据保存到配置文件。本节中,我们来看看如何反向操作,将数据读取回来。

首先,打开第115课的代码项目。为了使用文件流进行读取操作,我们需要在文件头部包含相应的头文件。

#include <fstream>

实现读取配置文件的函数

接下来,我们在类中添加一个成员函数,用于从指定文件路径读取配置。

以下是实现该函数的核心步骤:

  1. 使用 std::ifstream 以读取模式打开文件。
  2. 循环读取文件的每一行。
  3. 对每一行数据进行拆分和解析。
  4. 根据解析出的变量名,为对应的成员变量赋值。

void YourClass::ReadConfigFromFile(const char* filename) {
    std::ifstream file(filename, std::ios::in);
    if (!file.is_open()) {
        // 处理文件打开失败的情况
        return;
    }

    char buffer[256];
    while (!file.eof()) {
        file.getline(buffer, sizeof(buffer));
        // 处理读取到的每一行数据
        ProcessConfigLine(buffer);
    }
    file.close();
}

解析配置行数据

ReadConfigFromFile 函数中,我们逐行读取了文件内容。现在,我们需要编写 ProcessConfigLine 函数来解析每一行。

配置文件的每一行格式通常为 变量名=值。我们需要将其拆分为两部分。

以下是拆分字符串的核心逻辑:

  1. 使用 strchr 函数查找等号 = 的位置。
  2. 将等号替换为字符串结束符 \0,从而将原字符串分割为变量名和变量值两部分。
  3. 移动指针,使变量值指针指向等号之后的位置。
void YourClass::ProcessConfigLine(char* line) {
    char* pVarName = line;
    char* pValue = line;

    // 查找等号位置
    pValue = strchr(line, '=');
    if (pValue == nullptr) {
        return; // 格式错误,跳过此行
    }

    // 将等号替换为结束符,分割字符串
    *pValue = '\0';
    pValue++; // 指针后移,指向变量值部分

    // 根据变量名进行赋值
    AssignValueToVariable(pVarName, pValue);
}

根据变量名进行赋值

拆分出变量名和变量值后,我们需要根据变量名将值赋给对应的成员变量。

以下是赋值过程的关键点:

  1. 使用 strcmp 函数比较变量名。
  2. 根据变量类型(如 int, float, bool, char*),使用 atoi, atof 等函数将字符串值转换为相应类型。
  3. 为对应的类成员变量赋值。
void YourClass::AssignValueToVariable(const char* varName, const char* varValue) {
    if (strcmp(varName, "m_AutoUse") == 0) {
        this->m_AutoUse = (atoi(varValue) != 0);
    }
    else if (strcmp(varName, "m_PlayerName") == 0) {
        strcpy(this->m_PlayerName, varValue);
    }
    else if (strcmp(varName, "m_CoordinateX") == 0) {
        this->m_CoordinateX = atoi(varValue);
    }
    // ... 处理其他变量
}

更新窗口界面数据

所有变量从配置文件读取并赋值完成后,最后一步是将这些数据更新到窗口的各个控件上。

我们可以在 ReadConfigFromFile 函数的末尾,调用更新窗口数据的函数。

void YourClass::ReadConfigFromFile(const char* filename) {
    // ... 读取和解析文件的代码 ...

    // 文件读取并赋值完成后,更新窗口
    UpdateData(FALSE); // 假设使用MFC,将变量值更新到控件
}


测试与调试

完成代码编写后,进行编译和测试。可以修改配置文件中的值,观察程序启动后窗口控件是否正确地显示了这些值。

例如,将配置文件中玩家名字改为“张三”,坐标改为 (100, 200),然后运行程序,检查界面是否相应更新。


总结

本节课中我们一起学习了从配置文件读取数据到窗口的完整流程。我们掌握了使用文件流读取文件、按行解析字符串、根据变量名进行类型转换和赋值,以及最终更新界面控件的方法。通过本课,你实现了程序数据的持久化加载功能,这对于保存用户设置和程序状态非常有用。

对于更复杂的数据(如列表控件),其解析逻辑会更为复杂,可以作为课后练习进行探索。

课程P106:保存补给选项卡数据至文件 📂

在本节课中,我们将学习如何将第116课中“补给”选项卡界面的数据保存到配置文件中。我们将重点关注如何保存包含多项字符串内容的列表控件,并确保配置文件具有良好的可读性,以便后续修改和读取。


界面回顾与问题分析

上一节我们介绍了武器界面的数据保存。本节中,我们来看看“补给”选项卡的界面。

打开第116课的代码。补给选项卡中的其他控件,其保存方式与上一节课相同。例如,选中的药品项及其后的数值(整数类型),都可以方便地保存到配置文件,并在读取时根据变量名定位数据。

然而,补给列表控件有所不同。这个变量包含了多项字符串内容,在保存方式上与上一节课存在差异。因此,本节课的重点就是讲解如何保存补给列表,以及后续如何从配置文件中读取这些数据。

需要保存的变量共有九个。我们将依次对这些变量进行操作。


编写保存配置的代码

我们转到代码的 save_configure_to_file 函数中。前面的代码段用于保存“挂机”页面的数据,我们将对其稍作改动,以提高配置文件的可读性和后续读取的便利性。

如果不考虑用户手动修改配置文件,我们可以将控件数据以二进制形式保存。但为了方便用户修改,我们选择以文本格式保存。

首先,我们为“武器”页面添加一个标识。这样在读取时可以方便地定位。

// 设计武器页面标识
config_file << "[Weapon Page]" << std::endl;

接下来,我们将依次存储这九个变量的值。前面变量的保存方式与之前相同。

对于“补给”页面的数据,我们复制一段类似的代码结构,并替换为对应的变量属性和类名。

最后,我们重点讲解如何保存“补给列表”。因为它包含多项数据,我们需要使用循环来解决。


处理补给列表的保存

以下是保存补给列表的核心代码逻辑:

首先,我们需要定位列表的开始和结束位置。通过 GetCount 方法获取列表项的数量,并循环相应次数。

在循环中,我们获取从零开始的每一项的字符串,并将其添加到总的配置字符串中,每项之后进行换行。

处理完成后,我们添加一个特定的结束标记(例如 {End of Supply List})来表示列表数据结束。这样在读取时,当我们读到这个标记,就知道列表数据读取完毕。

// 开始保存补给列表
config_file << "[Supply List Start]" << std::endl;
int itemCount = m_listSupply.GetCount();
for (int i = 0; i < itemCount; ++i) {
    CString strItem;
    m_listSupply.GetText(i, strItem);
    config_file << strItem << std::endl;
}
config_file << "[Supply List End]" << std::endl;

在后续读取配置文件时,我们可以设定一个条件判断。当读取到 [Supply List Start] 标记时,开始循环读取后续的每一行,并将其添加到列表框中,直到遇到 [Supply List End] 标记为止。


测试与验证

现在,我们进行编译和测试,以验证配置文件是否正确写入。

首先,清空旧的配置文件。然后运行程序并注入游戏。

检查生成的配置文件,可以看到所有数据,包括补给列表,都已正确保存。列表数据以清晰的开始和结束标记包裹,便于识别和解析。

在定位和快速读取时,可以编写一个快速定位函数。例如,先搜索“补给页面”的标识,如果匹配成功,再进行后续的赋值读取操作。这只是一个设计思路的示例,具体实现可根据需求调整。


总结

本节课中,我们一起学习了如何将补给选项卡的界面数据保存到配置文件。重点掌握了如何处理包含多项数据的列表控件,通过循环和添加特定标记的方式,实现了数据的结构化保存,为后续的读取操作奠定了基础。

下一节课,我们将讨论如何从配置文件中读取这些数据,并完整地还原整个界面。

课程 P107:118 - 读取配置文件数据到补给选项卡 📂➡️📝

在本节课中,我们将学习如何将上一节课(P117)保存在配置文件中的数据,读取并更新到外挂程序的“补给”选项卡界面中,用于初始化相关的硬件数据。


配置文件格式回顾 📄

上一节我们介绍了如何将数据保存到配置文件。本节中我们来看看配置文件的格式,以便正确读取。

配置文件主要包含一个武器物品列表,其大小可能变化。列表下方可能包含其他物品,如“野山参”、“药仙医生”等。用户可以自行编辑配置文件,但必须确保格式正确。

以下是配置文件的核心格式示例:

[挂机页面]
坐标X=100
坐标Y=200
[补给页面]
物品1=精装药特
物品2=野山参
物品3=药仙医生

本节课的任务就是读取这种格式的配置文件。


修改读取函数以适应新格式 🔧

为了读取新的配置文件格式,我们需要对现有的 ReadConfigFromFile 函数进行修改。主要改动在于对每一行数据的处理逻辑。

之前的代码在 CConfigDlg::OnInitDialog 函数中处理数据行,其逻辑是查找“=”号并进行分割。然而,“挂机页面”这样的节标题行并不包含“=”号。如果找不到“=”号,相关指针会变为空指针(NULL),后续的写入操作会导致程序崩溃。

因此,我们必须添加一个判断:只有在找到的指针不是空指针时,才执行后续的分割和赋值操作。这样可以安全地跳过节标题行。


处理“补给页面”的数据 📦

上一节我们介绍了通用行的处理逻辑。本节中我们来看看如何专门处理“补给页面”的数据。

“补给页面”的数据结构较为特殊,尤其是最后的物品列表,其长度不固定。因此,我们不能像处理固定项那样进行硬编码。

以下是处理“补给页面”的步骤:

  1. 定位到“补给页面”节:首先,我们需要在读取文件时,定位到 [补给页面] 这一行。
  2. 处理固定项:节标题之后,有几行固定的配置项(如某些特定物品的默认设置)。这些可以像之前一样,通过查找“=”号、分割字符串并赋值给对应控件来处理。
  3. 处理动态列表:固定项之后,就是动态的物品列表。这是我们需要重点处理的部分。

为了处理这个动态列表,我们需要进入一个循环。循环的退出条件是读取到文件结尾,或者读取到下一个节标题(如 [其他页面])。在循环体内,我们逐行读取数据,并将每一行作为一个新项添加到列表控件(如 CListCtrl)中。

这里的关键是,我们需要将文件指针(FILE*CStdioFile)传递给处理列表的函数,以便它能连续读取行,而不是每次都重新打开文件。


代码实现与调试 🐛

根据上述思路,我们对代码进行了修改。主要改动包括:

  • 在分割字符串前,增加了对空指针的判断。
  • 修改了 ReadConfigFromFile 函数的签名,使其能接收文件指针参数。
  • 在读取“补给页面”时,使用 fgets 或类似函数逐行读取。
  • 使用循环来读取动态列表,直到满足退出条件。

在初步测试时,我们遇到了问题:列表数据没有成功加载。经过调试,发现原因如下:

  1. 变量更新遗漏:读取数据后,需要调用 UpdateData(FALSE) 将成员变量中的数据更新到窗口控件上。
  2. 循环条件错误:在处理动态列表的循环中,结束条件判断有误。我们错误地使用了不变的缓冲区地址进行比较,导致循环无法退出,形成死循环。修正方法是使用每次读取后更新的行内容字符串进行比较。
  3. 多余数据添加:循环结束的判断应放在添加列表项的操作之前,否则会把结束标记行本身也当作数据添加进去。
  4. 列表未清空:每次读取新配置前,没有清空列表控件,导致多次读取后数据累积。需要在读取函数开始时,调用列表控件的 ResetContent()DeleteAllItems() 方法进行清空。

修正这些问题后,配置文件的数据就能正确读取并显示在“补给”选项卡界面中了。


集成到程序流程中 🔄

函数修改调试完成后,我们需要将其集成到程序的初始化流程中。

  • 保存设置:在用户点击“应用设置”按钮时,调用 SaveConfigToFile 函数,将当前界面设置保存到配置文件。
  • 读取设置:在外挂窗口初始化(例如 OnInitDialog 函数)时,调用 ReadConfigFromFile 函数,自动载入上次保存的配置。
  • 可选功能:为了更方便,可以在界面上添加“载入默认配置”和“保存当前配置”两个按钮,分别调用读取和保存函数。

对于程序中其他类似的复杂页面(如单独的“物品页面”),也可以采用相同的思路:为它们建立独立的配置文件节,使用循环读取动态列表。


总结 📚

本节课中我们一起学习了如何从配置文件中读取数据,并更新到程序的“补给”选项卡界面。我们重点掌握了:

  1. 分析配置文件格式,并据此设计读取逻辑。
  2. 修改读取函数,增加安全性判断(空指针检查),避免程序崩溃。
  3. 处理包含动态列表的复杂数据结构,使用循环逐行读取。
  4. 将读取和保存功能集成到程序的初始化与设置流程中。
  5. 通过调试解决了数据未更新、死循环、列表累积等常见问题。

通过本课的学习,你的程序现在具备了完整的配置持久化能力,可以记住用户的设置了。

课程 P108:物品处理列表保存 💾

在本节课中,我们将学习如何将物品处理列表的信息保存到配置文件中。我们将设计一种数据格式,用于存储物品名称及其对应的处理选项(如存入仓库、出售等),并编写代码实现数据的写入功能。


概述

上一节我们介绍了物品处理列表的界面设计。本节中,我们来看看如何将用户在列表中设置的数据持久化保存到本地配置文件中。核心思路是设计一种特定的字符串格式,将表格的每一行数据序列化后写入文件,以便后续程序启动时能够重新加载。

数据格式设计

为了清晰地区分和读取数据,我们设计以下保存格式:

  • 起始标记:在物品列表数据块的开头,写入一个特定的起始字符串,例如 [ItemListStart]
  • 数据行格式:每一行数据代表一个物品及其处理设置。格式为:物品名称|选项1|选项2|选项3。其中,| 是分隔符,选项通常用 0(未勾选/否)或 1(已勾选/是)表示。
  • 结束标记:在所有物品数据写入完毕后,添加一个结束标记,例如 [ItemListEnd]

这样,在读取时,程序可以先定位起始标记,然后逐行读取并解析,直到遇到结束标记为止。

代码实现:写入配置文件

以下是实现保存功能的核心代码步骤。我们将在保存配置的函数(例如 SaveConfigToFile)中,找到写入其他列表(如技能列表)的代码位置,并在其后添加物品列表的保存逻辑。

首先,我们需要获取指向物品列表控件(假设为 CListCtrl 类型,变量名为 m_listItem)的指针,以便操作其内容。

// 假设在保存配置的函数中
CString strConfig; // 用于累积所有配置信息的字符串

// ... 其他配置的保存代码 ...

// ========== 开始保存物品列表 ==========
strConfig += _T("[ItemListStart]\r\n"); // 添加起始标记并换行

// 获取列表控件指针(假设通过类成员变量访问)
CListCtrl* pList = &m_listItem;
int nItemCount = pList->GetItemCount(); // 获取列表中的总行数

// 循环遍历列表的每一行
for (int y = 0; y < nItemCount; y++)
{
    CString strLine; // 用于保存当前行的数据
    // 1. 获取物品名称(第0列)
    CString strItemName = pList->GetItemText(y, 0);
    strLine = strItemName + _T("|"); // 物品名后添加分隔符

    // 2. 循环处理后面的选项列(假设第1, 2, 3列是勾选框)
    for (int x = 1; x <= 3; x++)
    {
        CString strValue = pList->GetItemText(y, x);
        // 判断该单元格是否被勾选。通常,勾选框被勾选时,GetItemText返回非空字符串(如"1"或"√")。
        if (strValue.IsEmpty()) // 为空表示未勾选
        {
            strLine += _T("0");
        }
        else // 非空表示已勾选
        {
            strLine += _T("1");
        }
        // 如果不是最后一列,添加列分隔符
        if (x < 3)
        {
            strLine += _T("|");
        }
    }
    // 当前行数据处理完毕,添加到总配置字符串并换行
    strConfig += strLine + _T("\r\n");
}
// 所有行处理完毕,添加结束标记
strConfig += _T("[ItemListEnd]\r\n");
// ========== 物品列表保存结束 ==========

// ... 后续将 strConfig 写入配置文件的代码 ...

代码解释

  1. GetItemCount() 获取列表的总行数。
  2. 外层 for 循环遍历每一行。
  3. GetItemText(y, x) 获取第 y 行、第 x 列的文本内容。对于勾选框列,勾选状态会以特定文本(如”1″)表示。
  4. 内层 for 循环处理第1到第3列(选项列),根据单元格是否为空来判断勾选状态,并拼接成 01
  5. 每一行数据拼接完成后,加上换行符 \r\n 追加到总配置字符串中。
  6. 最后,在物品列表数据块末尾加上结束标记。

功能测试

编写完代码后,需要进行测试以确保数据能正确保存。

  1. 在程序的物品列表中添加几行数据,例如:
    • 物品:铁剑,选项:出售。
    • 物品:强化石,选项:存入仓库。
    • 物品:金创药,选项:不处理。
  2. 点击“保存”或“应用设置”按钮,触发保存代码。
  3. 打开生成的配置文件,检查内容。正确的格式应类似于:
    [ItemListStart]
    铁剑|1|0|0
    强化石|0|1|0
    金创药|0|0|0
    [ItemListEnd]
    
    这表示“铁剑”的第1个选项(如“出售”)被勾选,其他未勾选;“强化石”的第2个选项(如“存仓库”)被勾选。

如果文件内容符合预期,则证明保存功能工作正常。如果写入失败或格式错误,需要检查代码中的字符串拼接逻辑和文件写入操作。


总结

本节课中我们一起学习了如何将物品处理列表的数据保存到配置文件。我们首先设计了一个包含起始标记、数据行和结束标记的清晰格式。然后,通过编写代码遍历列表控件,将每一行的物品名称和勾选状态序列化为字符串,并最终写入文件。下一节课,我们将实现相反的过程:从配置文件中读取这些数据,并重新填充到物品列表控件中,完成数据的持久化闭环。

课程P109:120-物品过滤列表信息读取 📖

在本节课中,我们将学习如何从配置文件中读取物品过滤列表的信息,并将其正确地加载到程序界面中。我们将基于上一节课保存的配置文件结构,编写代码来解析数据并填充表格控件。


概述

上一节我们介绍了如何将物品处理列表的信息保存到配置文件中。本节中,我们来看看如何从该配置文件中读取这些信息,并还原到程序的列表控件中。

首先,我们打开上一节课(119课)的代码。

我们转到 main 函数内部。

转到 LoadConfig 函数。这里是我们对每一行读取出来的数据进行处理的地方。我们移到最后。

该操作与我们之前处理列表数据的代码有类似之处。因此,我们可以将之前处理列表的那段代码复制过来。

需要取消掉 return 语句。我们把前面的数据复制过来,并按照配置文件的格式进行一些改动。

定位数据起始与结束

首先,我们需要判断读取的起始位置。如果读取到的行名等于特定字符串,说明我们定位到了“物品处理列表”的起始位置。

结束条件与之前代码中的结束条件相同。

数据读取与处理

数据读取的逻辑是相同的。不同的地方在于对每一行数据的处理,以及对控件清空的操作。

以下是处理流程的核心步骤:

  1. 定位控件:首先用一个指针指向我们的列表控件。
  2. 清空控件:在载入新数据前,清空表格中的所有项。
  3. 读取并解析行数据:从文件读取一行数据。判断是否到达结束标记,如果是则退出循环,否则继续处理。
  4. 拆分数据:定义指针变量,指向读取的字符串缓冲区。使用分隔符(如逗号)将字符串拆分为物品名和后续的数据项。
    • 代码示例:pItemName = strtok(lineBuffer, ",");
  5. 处理物品名:分隔出的第一个字符串是物品名,将其插入表格的第一列。
  6. 循环处理后续数据项:后续有三项数据(例如,是否存仓库、是否使用等)。我们用一个 for 循环来处理。
    • 指针 pData 指向第一个数据项(01)。
    • 在循环中判断 pData 指向的字符是否为 '1'
    • 如果是 '1',则对表格中对应的项进行设置(例如,勾选复选框)。
    • 处理完一项后,将指针 pData 向后移动两个字符(跳过当前数据和分隔符),指向下一个数据项。
  7. 更新行索引:每处理完一行数据,将行索引 y 加一,以便下一行数据插入正确的位置。

调试与修正

在初步编写代码后,运行程序发现数据未能正确读取。通过添加调试信息和分析,发现了一个逻辑错误。

问题:在判断数据项是否为 '1'if 语句内部,才移动指针到下一项。这意味着如果数据项是 '0',指针就不会移动,导致程序一直判断第一项,后续项无法被处理。

解决方案:将移动指针到下一项的操作 pData += 2; 移到 if 判断语句的外面。这样无论当前项是 '0' 还是 '1',处理完后都会指向下一项。

修正代码后,重新编译运行。

再次检查配置文件,确认物品名读取正确,但勾选状态仍不对。检查代码执行流程后,确认了上述指针移动的逻辑错误,并进行了修正。

修正后,程序成功从配置文件读取了所有物品及其设置(如“铁剑”、“强化石”等),并正确地在表格中显示了勾选状态。

我们可以修改表格中的选项,点击“应用设置”保存,然后点击“载入设置”,界面会恢复为配置文件中的状态。如果配置文件损坏或格式错误,载入时程序会进行相应的初始化。

总结

本节课中,我们一起学习了如何从配置文件读取物品过滤列表信息。我们实现了以下关键步骤:

  1. 定位配置文件中特定数据段的起始和结束。
  2. 清空目标控件并逐行读取数据。
  3. 使用字符串分割和指针操作解析每行数据。
  4. 将解析出的物品名和状态数据填充到表格控件中。
  5. 通过调试解决了指针移动的逻辑错误,确保了所有数据项都被正确处理。

至此,关于配置文件读写物品过滤列表的相关内容就讲解完毕了。

课程 P11:022-封装动作数组功能 📚

在本节课中,我们将学习如何分析游戏中的动作对象属性,并封装一个用于调用多个动作对象的功能函数。我们将从分析内存结构开始,逐步构建数据结构,并最终实现一个可通过下标调用动作的函数。


分析动作对象属性 🔍

上一节我们介绍了如何定位游戏中的对象。本节中,我们来看看动作对象的特定属性。

通过查看内存地址,我们发现动作对象有两个关键属性:

  • 在偏移 +0xC 处存储了动作的名称。
  • 在偏移 +0x4C 处存储了动作的分类ID。

这两个偏移与我们之前分析的背包对象有相似之处。此外,我们还注意到在偏移 +0x0C+0x08 处存在与一个更大的总对象数组相关的下标和分类编号,但当前阶段这些属性对我们暂时没有用处。

因此,在封装对象结构时,我们将只包含名称(+0xC)和分类ID(+0x4C)这两个有用的属性。


定义基址与数据结构 🏗️

为了访问动作数组,我们首先需要定义相关的内存基址。

以下是动作数组功能所需的核心基址:

#define BASE_ADDR_1 0x00A98XXXX // 动作数组基址1
#define BASE_ADDR_2 0x00XXXXXX  // 动作数组基址2(扩展)
// 计算最终ECX值的公式,对应两条汇编指令
// ECX = *(DWORD*)(*(DWORD*)(BASE_ADDR_1) + 0x2TC)

基址定义好后,我们接下来定义动作对象的结构体和数组。

动作对象结构体定义如下:

typedef struct _ActionObject {
    wchar_t name[20]; // 动作名称,偏移 +0xC
    DWORD typeId;     // 动作分类ID,偏移 +0x4C
} ActionObject;

// 动作对象数组,共有12个元素
ActionObject g_ActionList[12];

初始化动作数组 🔄

定义好结构后,我们需要初始化这个数组,从游戏内存中读取数据填充它。

以下是初始化动作数组的步骤:

  1. 计算指向第一个动作对象地址的指针。
  2. 使用循环遍历所有12个动作对象。
  3. 对于每个对象,根据公式计算出其内存地址。
  4. 从该地址的 +0xC 偏移处读取动作名称。
  5. 从该地址的 +0x4C 偏移处读取动作分类ID。

核心读取代码如下:

// 计算并读取第一个对象的地址指针
DWORD basePtr = *(DWORD*)(BASE_ADDR_1);
DWORD firstActionAddrPtr = basePtr + 0x410;

for (int i = 0; i < 12; i++) {
    // 计算当前下标对象的实际地址
    DWORD objAddr = *(DWORD*)(firstActionAddrPtr + 4 * i);
    // 读取名称 (+0xC)
    wcscpy(g_ActionList[i].name, (wchar_t*)(objAddr + 0xC));
    // 读取分类ID (+0x4C)
    g_ActionList[i].typeId = *(DWORD*)(objAddr + 0x4C);
}

为了验证初始化是否成功,我们可以编写一个测试函数来打印数组内容:

void PrintActionList() {
    for (int i = 0; i < 12; i++) {
        printf("Name: %ls, Index: %d, TypeID: %d\n", 
               g_ActionList[i].name, 
               i, 
               g_ActionList[i].typeId);
    }
}

运行测试后,控制台会输出类似“攻击”、“拾取”、“逃脱”等动作名称及其对应的索引和ID。


封装动作调用函数 ⚙️

初始化并验证数据后,我们就可以封装一个直接通过数组下标调用动作的函数了。

这个函数的核心任务是模拟游戏调用动作的流程,需要处理两个关键参数:

  1. ECX 寄存器值,我们从固定的基址公式计算得出。
  2. 需要压栈的参数,即动作的分类ID(typeId)。

封装后的函数代码如下:

void UseActionByIndex(int index) {
    __try {
        // 1. 获取所需的 ECX 值
        DWORD ecxValue = *(DWORD*)(*(DWORD*)(BASE_ADDR_1) + 0x2TC);
        
        // 2. 获取要使用的动作的分类ID
        DWORD actionTypeId = g_ActionList[index].typeId;
        
        // 3. 内联汇编:模拟游戏原有的调用过程
        __asm {
            mov ecx, ecxValue      // 设置 ECX
            push actionTypeId      // 将动作ID压栈
            call CALL_ADDR         // 调用游戏的功能函数
        }
    } __except(EXCEPTION_EXECUTE_HANDLER) {
        printf("调用动作时发生异常。\n");
    }
}

功能测试与作业 🧪

我们可以编写一个简单的测试来验证函数是否工作。

例如,要执行“攻击”动作(假设其下标为1):

// 选中一个怪物后调用
UseActionByIndex(1);

注入代码并运行测试,角色应该会对选中的目标发起攻击。

本节课中我们一起学习了如何分析动作对象、定义数据结构、初始化数组以及封装功能函数。

最后留给大家一个作业:重构动作调用函数

  • 要求:新函数应通过动作名称(而不是数组下标)来调用动作。
  • 提示:可以参考之前封装背包物品使用函数的思路,实现一个 UseActionByName(const wchar_t* actionName) 函数。

通过完成这个作业,你将能更灵活地操作游戏功能。

课程 P110:121 - 无限视野与怪物隐身分析 🎯

在本节课中,我们将学习如何通过逆向分析,找到并修改游戏中控制怪物显示距离的关键数值,从而实现“无限视野”和“怪物隐身”的功能。

概述与思路

上一节我们介绍了逆向分析的基本概念。本节中,我们来看看如何具体定位并修改游戏中的关键数据。

实现无限视野或隐藏怪物的核心思路在于:游戏通常会计算怪物与玩家角色之间的距离,并以此判断是否在屏幕上渲染该怪物。如果距离超过某个设定值,怪物就不会显示。因此,我们的目标是找到这个用于判断的距离值或计算它的代码,并进行修改。

  • 扩大视野:将判断距离的数值改大,使更远处的怪物也能被看到。
  • 隐藏怪物:将判断距离的数值改小,使怪物即使在很近处也不显示。

定位怪物对象与距离属性

以下是定位关键数据的具体步骤:

首先,我们需要在游戏中选中一个怪物,以获取其对象ID。通常,选中怪物的ID会存储在某个特定地址(例如示例中的 0x14B8)。当没有选中任何对象时,该地址的值可能是 0xFF

当玩家靠近怪物时,怪物才会显示并被选中。此时,我们记下怪物的ID(例如 0x12BF)。这个ID是怪物在游戏全局对象数组中的索引。

通过对象数组的基址,加上索引乘以每个对象的大小(结构体宽度),我们就可以计算出该怪物对象在内存中的起始地址。通常,一个怪物对象的大小在3000字节左右。

搜索距离数值

取得怪物对象的起始地址后,我们以其为起点,在其后约3000字节的范围内搜索可能的距离数值。

因为距离通常是浮点数,所以我们在内存扫描工具中选择 浮点 (float) 类型进行搜索。

搜索时,利用距离会动态变化的特性:

  1. 先远离怪物,距离值会增大,搜索“增加的数值”。
  2. 再靠近怪物,距离值会减少,搜索“减少的数值”。
    通过反复筛选,最终可以锁定一个最可疑的地址,其数值随玩家与怪物的距离变化而同步变化。

分析关键代码

找到可疑的距离值地址后,我们使用调试器(如OD)查看哪些代码访问了它。

我们特别关注包含 比较 (CMP) 指令的代码段,这很可能就是进行距离判断的地方。例如,可能会发现类似以下的汇编代码:

FLD DWORD PTR [ESI+31C]    ; 将怪物距离(ESI+31C)加载到浮点寄存器
FCOMP QWORD PTR [EBP-288]  ; 与某个值(可能是预设视野范围)进行比较

在分析过程中,我们可能会找到两处关键代码:

  1. 写入代码:负责将计算出的距离值写入怪物对象属性(如 ESI+31C)。
  2. 判断代码:负责读取距离值,并与一个固定的“视野范围”值进行比较,以决定是否显示怪物。

通过跟踪,我们最终定位到核心的判断逻辑。这里有一个固定的浮点数值(例如 250.0)与怪物距离进行比较。这个数值就是控制怪物显示范围的关键阈值

功能验证与修改

找到关键地址后,我们进行验证和修改:

  1. 修改视野范围值:在内存中修改这个关键数值(例如从250改为10000)。
    • 效果:远处的怪物开始显示,实现了“无限视野”。
  2. 修改为极小值:将该数值改得非常小(例如改为1)。
    • 效果:几乎所有怪物都消失,实现了“怪物隐身”。

在分析中,可能会发现另一个较大的数值(例如100000),它可能用于其他系统(如AI活动范围)的判断,但对“显示/隐藏”功能影响不大,核心修改点通常是第一个较小的比较值。

最后,记录下包含此关键比较指令的代码段特征码,便于日后制作辅助工具时进行定位。

总结

本节课中我们一起学习了如何通过逆向工程分析游戏中的怪物显示机制。我们掌握了从定位怪物对象、搜索动态距离数值,到分析关键比较代码并最终修改实现“无限视野”和“怪物隐身”功能的完整流程。核心在于找到并修改那个与怪物距离进行比较的固定范围值

课程 P111:穿墙功能相关数据分析 - 寻路路径与坐标数组 📊

在本节课中,我们将学习如何分析游戏中的寻路路径与坐标数组。这是实现“穿墙”功能的关键准备步骤,因为穿墙功能的核心在于绕过或修改游戏对障碍物的判断逻辑。我们将通过分析坐标数据的来源、结构和变化规律,为后续定位障碍判断代码打下基础。

概述:寻路与坐标数据

游戏角色在移动到指定地点时,会生成一系列路径点坐标,这就是寻路。如果路径上存在障碍物,游戏会生成绕行的坐标点。我们的目标是找到存储和管理这些坐标数据的数组及其相关代码。

上一节我们介绍了游戏坐标的基本概念,本节中我们来看看如何定位和分析寻路产生的坐标数组。

定位目的地坐标的写入点

首先,我们已经知道游戏中存在“当前坐标”和“目的地坐标”的地址。目的地坐标会随着寻路过程不断更新,其数据来源于一个坐标数组。

为了找到这个数组,我们可以对“目的地坐标地址”下写入断点,追踪是哪些代码在修改它。

以下是定位写入代码的关键步骤:

  1. 在调试器中找到写入目的地坐标的汇编指令。
  2. 观察指令的数据来源,例如 [EBP+0x154] 或通过某个 CALL 函数获取。
  3. 逆向追踪,找到数据的最终来源。

通过分析,我们发现写入目的地坐标的代码类似以下形式:

MOV DWORD PTR [EDI], ECX  ; 将ECX中的坐标值写入EDI指向的目的地地址

ECX 中的坐标值,最终来源于角色对象地址的一个特定偏移。

分析坐标数组的结构

通过追踪数据来源,我们定位到坐标数组存储在角色对象基址的一个偏移处。

以下是关于坐标数组结构的重要发现:

  • 数组指针偏移:坐标数组的起始指针位于角色对象基址 + 0x164 的位置。这是一个指针,需要再次寻址才能访问到实际的坐标数据。
  • 数组结束指针偏移:数组的结束指针位于角色对象基址 + 0x168 的位置。游戏通过比较起始和结束指针来判断是否遍历完所有路径点。
  • 坐标结构体:每个路径点坐标是一个结构体,大小为 0xEC 字节。
  • 结构体内容:每个结构体包含 X, Z, Y 三个坐标值(可能是浮点数),后面跟随4个字节的0。用伪代码表示如下:
    struct PathPoint {
        float X;
        float Z;
        float Y;
        int zero[1]; // 占4字节,值为0
    }; // 总大小 = 0xEC
    

在寻路过程中,游戏会从 [对象基址+0x164] 指向的地址开始,依次读取这些 PathPoint 结构体,并将其坐标写入目的地地址,引导角色移动。

理解坐标数组的遍历机制

我们发现一个有趣的机制:游戏在读取一个坐标点后,会将整个数组的数据向前移动一个结构体的大小(0xEC 字节)。

以下是该机制的工作原理:

  1. 起始指针 [对象基址+0x164] 指向数组第一个坐标点。
  2. 结束指针 [对象基址+0x168] 指向数组末尾。
  3. 当读取一个坐标点后,执行类似内存移动的操作,将后续所有坐标点数据前移,覆盖已读取的点。
  4. 同时,结束指针的值会减去 0xEC,指向新的“逻辑末尾”。
  5. 这样,起始指针位置不变,但内容已经是下一个待读取的坐标点,实现了队列(FIFO)的遍历效果。

这种机制意味着路径坐标数组在内存中是一个动态变化的队列,而不是静态列表。

坐标数据的产生与穿墙关联

直线行走时,目的地坐标不变,不会触发寻路和坐标数组的更新。只有当遇到障碍、需要拐弯时,游戏才会生成新的路径点序列,并更新坐标数组。

因此,坐标数组的生成逻辑(即寻路算法)必然包含了障碍物判断。这正是我们实现穿墙功能需要切入的关键点。在下节课中,我们将重点分析这个坐标序列是如何产生的,并尝试在其附近找到障碍物判断的相关代码。

关键地址与偏移总结

本节课我们共同学习了寻路坐标数组的分析方法,并找到了以下关键信息:

  • 角色对象基址:分析的核心起点。
  • 坐标数组起始指针偏移对象基址 + 0x164
  • 坐标数组结束指针偏移对象基址 + 0x168
  • 单个坐标点结构体大小0xEC 字节
  • 目的地坐标写入地址:例如 [EDI](具体地址需动态分析)

通过理解坐标数据的存储和遍历方式,我们为下一步直接分析寻路算法和障碍判断逻辑做好了充分的数据准备。下节课,我们将向“穿墙”的核心逻辑迈进。

P112:穿墙功能相关数据分析 - 坐标数组来源与去向 🧭

在本节课中,我们将学习如何分析游戏穿墙功能相关的数据,重点在于追踪坐标数组的来源与去向。我们将通过逆向工程工具,定位并理解访问坐标数组的关键代码,从而找到障碍物判断的核心逻辑。


上一节我们介绍了坐标数组的基本概念,本节中我们来看看如何追踪访问这个数组的代码。

首先,打开逆向分析工具CE,访问坐标数组的地址 1964。为了找到障碍判断的代码,我们对数组的第一个坐标进行访问操作。因为障碍判断代码很可能就在其附近,会用到这个坐标数组。

访问坐标数组的代码有两种可能情况:

  1. 程序先生成一条直线的坐标序列,写入数组。
  2. 程序再判断这个序列中的坐标点是否存在障碍物。

无论哪种情况,代码都必须访问我们的坐标序列。我们已知第一个数组在 1964,第二个数组的结构在 1334

我们从 1964 地址开始,找出所有访问该地址的代码。然后,我们分别在有障碍物和无障碍物的路径上执行代码,观察差异。

以下是观察到的关键现象:

  • 无障碍时,程序访问固定的几行代码。
  • 有障碍时,程序会额外访问后面的几行代码。

这表明,后面新增的几行代码很可能与障碍判断关系更大。可能性最大的判断代码就在这些新增访问的附近。这些代码可能负责计算如何绕过障碍。


上一节我们定位了可能的障碍判断代码区域,本节中我们深入分析这些具体的汇编指令。

我们使用分析工具,重点观察访问坐标数组后代码的走向。我们特别关注那些使用括号 [] 进行内存访问的指令,例如 mov eax, [ecx+xx]。如果这个 [ ] 访问的是我们的坐标数据,那么后续很可能有判断逻辑。

以下是常见的判断代码模式:

  • cmp eax, xxtest eax, eax
  • 后面跟随条件跳转,如 jz (为零跳转) 或 jnz (非零跳转)
  • 或者将判断结果保存到某个变量中

我们主要寻找这类模式。如果发现 [ ] 访问了坐标数据,并且后面有比较和跳转,我们就跟进查看。

我们跟踪到一个关键的函数调用(call)。通过分析其参数,发现它传入了坐标序列中的第一个和第二个坐标点。这强烈暗示该函数是在计算这两个点之间是否存在障碍物。

我们在此函数调用处下断点,并观察其返回值(通常存储在 EAX 寄存器中):

  • 无障碍时:返回值 EAX = 0
  • 有障碍时:返回值 EAX > 0 (非零)。

这个函数 call xxxxxxxx 很可能就是障碍物判断的核心函数


上一节我们确认了障碍判断函数,本节中我们看看如何利用这个发现。

我们跟踪该判断函数的返回点。发现程序根据返回值 EAX 是否为0,进行了不同的跳转:

  • 如果 EAX == 0 (无障碍),程序沿一条路径返回,继续正常移动。
  • 如果 EAX != 0 (有障碍),程序可能跳转到另一处代码。

在分析过程中,我们还偶然发现了一个现象:在特定时机暂停游戏线程,角色有时能“卡”过墙壁。这证明游戏客户端和服务器之间存在时间差,也说明我们离实现穿墙功能很近了。

基于以上分析,最直接的修改思路是:强制让障碍判断函数的返回值始终为0(即始终判断为无障碍)。我们可以修改调用该函数后的跳转逻辑,或者直接修改函数本身的返回值。

修改此处特征码,可能就能实现基础的穿墙功能。当然,更稳定的方法需要更全面地分析所有相关的判断线程。


本节课中我们一起学习了如何通过逆向工程定位游戏中的坐标数组,并找到了关键的障碍物判断函数。我们分析了该函数的调用模式、返回值含义,并提出了实现穿墙功能的潜在修改点。下一节课,我们将继续分析其他可能相关的判断逻辑。

课程 P113:穿墙功能相关数据分析 - 路径坐标数组去向跟踪 🧭

在本节课中,我们将继续深入分析游戏中的穿墙功能,重点跟踪路径坐标数组的访问与修改过程。上一节我们分析了关键判断函数,但修改后仍未实现穿墙,这表明可能存在其他检测点。本节我们将跟踪更多数据访问地址,寻找所有与障碍判断相关的代码。

跟踪第二个地址的访问

上一节我们分析了第一个关键地址。本节中,我们来看看第二个访问目的地坐标路径的地址。

以下是分析步骤:

  1. 附加调试器到游戏进程。
  2. 转到第二个目标地址,该地址同样访问目的地坐标数组的第一个元素。
  3. 在此地址下断点,然后执行移动操作(走路或寻路),观察断点触发情况。
  4. 分析断点触发时的寄存器数据,特别是 ECX 的值,它可能包含坐标相关数据。
  5. 观察代码逻辑,发现该处读取坐标后并未用于关键判断,因此可能不是障碍检测的核心位置。

分析关键判断函数

我们越过上一个非关键点,继续跟踪下一个地址 56A 处的代码。

以下是分析过程:

  1. 在地址 56A 处下断点,并尝试移动角色。
  2. 发现该断点并非每次移动都触发,而是在目的地为障碍物(如墙)时才触发。
  3. 这表明,之前的某段代码进行了障碍判断,并根据结果跳转至此。
  4. 分析跳转来源,发现是从下方代码跳转而来,其中比较了 ECXEDX 的值。
  5. 这两个值可能分别代表路径坐标数组的起始地址和结束地址,用于循环判断。
  6. 进一步分析,发现该函数内部可能负责计算绕过障碍物的新路径。

确认并标记障碍判断函数

通过多次跟踪和测试,我们确认了一个反复出现的核心函数。

以下是确认步骤:

  1. 返回到调用层,定位到函数 56B650
  2. 在该函数入口下断点,测试在有障碍和无障碍情况下是否触发。
  3. 确认该函数在两种情况下均会执行,但其内部返回值 (EAX) 可能不同。
  4. 结合上一节课的分析,确认此函数正是进行“两点间是否有障碍物”判断的关键函数。其逻辑可简化为:
    返回值 = CheckObstacle(坐标A, 坐标B)
  5. 为代码添加清晰标签和注释,例如“障碍判断函数”,便于后续分析。将返回值意义注明:0 为无障碍,非 0 为有障碍。

跟踪坐标数组的复制过程

除了直接判断,游戏还可能通过复制和修改坐标数组来处理路径。

以下是跟踪发现:

  1. 跟踪另一个数据访问地址,发现一段将原坐标数组数据复制到新内存区域(EDI 指向)的代码。
  2. 分析 ESIEDI,发现 EDI 指向的可能是“人物角色属性+1964”处的目的地坐标数组副本。
  3. 这意味着,游戏可能先在一个临时区域计算路径(包括绕行),然后再写回或应用。
  4. 要找到最终的障碍生效点,可能需要跟踪这个副本数组的后续修改和访问情况。

新的分析思路:从坐标写入点入手

如果直接跟踪数组流向复杂,我们可以转换思路。

以下是新思路的切入点:

  1. 无论路径如何计算,角色最终要移动,就必须修改其“当前坐标”。
  2. 在无障碍正常行走和有障碍被阻挡时,修改“当前坐标”的代码路径很可能不同。
  3. 对“当前坐标”的内存地址下“写入”断点。
  4. 分别观察正常行走和遇到障碍时,断点触发的代码位置有何差异。
  5. 在差异点附近,很可能就存在着决定是否更新坐标(即是否允许移动)的最终障碍判断。

本节课中我们一起学习了如何多角度跟踪和分析路径坐标数组。我们确认了一个核心的障碍判断函数,并发现了坐标数据被复制处理的流程。最后,我们提出了通过对比“当前坐标”写入点来定位最终判断逻辑的新思路。穿墙功能分析需要耐心,下节课我们将基于今天的发现继续深入。

课程 P114:125-穿墙功能分析-关键代码 🧱➡️🚶

在本节课中,我们将学习如何分析并实现游戏中的“穿墙”功能。我们将从上一节课的代码入手,定位到控制角色移动的关键逻辑,并学习如何修改代码以绕过障碍物检测,最终实现穿墙效果。


概述

上一节我们介绍了如何定位到与角色坐标相关的内存地址。本节中,我们来看看如何分析并修改控制角色移动路径的障碍物检测逻辑,从而实现穿墙功能。

核心原理是:游戏在移动角色前,会调用一个函数来判断起点与目标点之间是否存在障碍物。如果存在障碍物,则不会更新角色坐标。我们的目标就是修改这个判断逻辑,使其始终返回“无障碍”的结果。


关键代码定位与分析

我们首先需要找到负责判断两点之间是否有障碍物的关键函数(CALL)。

通过分析对坐标地址 1680C 的访问,我们发现角色移动前会进行一个判断。这个判断决定了是否更新当前坐标。

以下是分析过程中定位到的关键汇编代码片段:

; 这是一个判断跳转,esi的值是关键
cmp esi, 2
je SOME_LABEL ; 如果esi等于2则跳转,不执行移动代码
cmp esi, 3
je SOME_LABEL ; 如果esi等于3则跳转,不执行移动代码
; ... 其他判断

核心概念:寄存器 esi 的值来源于一个关键函数(CALL)的返回值。这个返回值代表了路径的状态:

  • esi = 0, 2, 3 通常表示存在障碍,角色不会移动。
  • esi = 1(或其他非0、2、3的值)通常表示路径畅通,角色可以移动。

因此,实现穿墙的思路就是:确保这个关键CALL的返回值不为0、2或3,最好恒定为1


修改方案与实施

我们找到了影响移动判断的多个位置。以下是几种可行的修改方法:

方案一:修改判断跳转

直接修改对 esi 值的比较和跳转指令,让程序即使检测到障碍也继续执行移动代码。

例如,将 je(等于则跳转)指令修改为 jmp(无条件跳转),或者直接 nop(空指令)掉整个判断块。

; 修改前:有障碍则跳走
cmp esi, 2
je BLOCK_MOVEMENT

; 修改后:无视障碍,继续执行
cmp esi, 2
jmp CONTINUE_MOVEMENT ; 或直接 nop 掉 je 指令

方案二:修改关键CALL的返回值

这是更根本的修改方法。在关键CALL的函数尾部,强制设置其返回值(通常存放在 eaxesi 寄存器中)为1。

; 在CALL返回前插入代码
mov eax, 1 ; 或 mov esi, 1
retn

方案三:修改函数内部的逻辑

进入关键CALL内部,找到其生成返回值0、2、3的逻辑分支,并修改它们,使其走向返回1的逻辑分支。

这需要更深入的分析,但效果最稳定。


测试与验证

修改完成后,需要进行测试。

  1. 尝试点击墙后的位置,角色应能直接穿过障碍物移动过去。
  2. 尝试正常的寻路功能,确保修改没有破坏游戏的其他逻辑(例如自动寻路)。

如果寻路出现问题,可能需要调整修改点,确保只在“点击移动”时绕过检测,而在“自动寻路”时保持原有逻辑。


关键代码地址总结

以下是分析过程中找到的关键代码地址(示例,实际地址需动态分析):

  • 坐标访问点xxxxxxxx (例如1680C相关代码)
  • 障碍判断CALLyyyyyyyy
  • 移动执行代码块zzzzzzzz
  • 关键跳转点1aaaaaaaa (判断 esi == 2)
  • 关键跳转点2bbbbbbbb (判断 esi == 3)

将修改应用到这些关键位置,即可实现穿墙功能。


总结

本节课中我们一起学习了游戏穿墙功能的实现原理与步骤。

我们首先回顾了角色坐标的存储位置,然后分析了控制移动的障碍物检测逻辑。核心在于理解一个关键函数(CALL)的返回值决定了角色能否移动。通过修改该函数的返回值,或修改基于该返回值的条件跳转,我们成功地让角色无视障碍物移动,实现了穿墙效果。

记住,修改游戏代码时需谨慎测试,以确保功能的实现不会引起游戏崩溃或其他意外行为。

课程 P115:CPU优化降低游戏CPU占用率 🎮⚙️

在本节课中,我们将学习如何通过编程手段优化游戏,降低其对CPU的占用率。我们将探讨一种通用方法,并介绍其他高级优化思路的原理。

概述

游戏对CPU占用率较高,通常与画面渲染和画质更新有关。这些操作可能由定时器或独立线程频繁执行。通过干预这些过程,可以有效降低CPU负载。一种通用做法是强制游戏主线程定时休眠。

通用优化方法:线程定时休眠

上一节我们介绍了优化的基本思路,本节中我们来看看如何通过代码实现线程定时休眠来降低CPU占用。

这种方法的核心是:在游戏主线程上设置一个定时器,使其周期性地进入短暂休眠状态,从而减少CPU时间片的占用。

以下是实现步骤:

  1. 获取游戏窗口句柄:这是操作目标窗口的必要标识。
  2. 设置定时器:使用 SetTimer API函数在主线程上创建一个定时器。
    • 函数原型参考SetTimer(HWND hWnd, UINT_PTR nIDEvent, UINT uElapse, TIMERPROC lpTimerFunc)
    • 参数说明
      • hWnd: 窗口句柄。
      • nIDEvent: 定时器ID,建议定义为宏常量以提高代码可读性,例如 #define TIMER_ID_OPTIMIZE 1
      • uElapse: 定时器间隔,以毫秒为单位。此值不宜过大或过小,经测试10毫秒左右效果较好。
      • lpTimerFunc: 定时器回调函数指针。
  3. 实现回调函数:在回调函数中调用 Sleep() 函数使线程休眠。
    • 休眠时间:回调函数中的 Sleep() 参数应尽量小,例如10毫秒。这个值可以作为可调参数提供给用户。
  4. 提供关闭功能:使用 KillTimer API函数来停止定时器。
    • 函数原型KillTimer(HWND hWnd, UINT_PTR uIDEvent)

通过测试,启用此优化后,游戏CPU占用率可从30%-40%显著降低至10%以下,且不影响正常游戏操作。取消优化后,占用率会恢复原状。

高级优化思路探究

除了通用的线程休眠法,更有效的优化需要深入游戏内部机制。游戏通常在一个消息循环中处理各种事件,包括渲染更新。

我们可以使用工具(如Spy++)监视游戏窗口的消息流。观察发现,游戏会频繁处理某些特定消息(例如消息代码492、268等),这些很可能对应着画面更新的定时器事件。

理论上,如果能定位到负责渲染的关键定时器ID及其回调函数,并重新设置其时间间隔(例如延长触发间隔),就能从根源上降低CPU占用。这需要逆向分析技术来辅助完成。

总结

本节课中我们一起学习了降低游戏CPU占用率的两种思路:

  1. 通用方法:通过 SetTimerSleep 强制游戏主线程周期性休眠,快速见效。
  2. 进阶方向:分析游戏消息循环,定位并修改高频率的渲染定时器参数,实现更精准的优化。

通用方法简单有效,适合初学者实践。而深入消息机制的方法则作为进阶练习,鼓励大家课后使用工具进行探索。下一节课我们将深入研究具体的定时器消息。

课程 P116:CPU优化降低游戏CPU占用率方法二 🔍

在本节课中,我们将继续学习如何通过分析游戏的消息循环来降低其CPU占用率。我们将使用调试工具定位关键函数,并通过注入代码(Hook)的方式,在非关键消息处理流程中让线程休眠,从而有效降低CPU负载。


概述

上一节我们分析了定时器与CPU占用的关系。本节中,我们将深入游戏的消息处理循环,定位图形更新和逻辑处理的关键代码,并探索通过干预消息处理流程来降低CPU使用率的方法。

分析消息循环

游戏通常在一个循环中处理系统消息,例如鼠标、键盘和定时器事件。这个循环的核心是 GetMessagePeekMessage 这两个Windows API函数。

  • GetMessage: 从消息队列中获取消息。如果队列为空,线程会在此处挂起等待,不消耗CPU。
  • PeekMessage: 同样从消息队列获取消息,但无论队列是否为空都会立即返回。这通常用于实现“不阻塞”的消息循环。

以下是这两个函数的基本形式:

// GetMessage 示例
while (GetMessage(&msg, NULL, 0, 0)) {
    TranslateMessage(&msg);
    DispatchMessage(&msg);
}

// PeekMessage 示例
while (true) {
    if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    // 其他游戏逻辑(如渲染)...
}

通过调试器附加到游戏进程,我们在 PeekMessage 函数处下断点,可以追踪到游戏的主消息循环位置。

定位图形更新代码

在消息循环之后,游戏会执行其核心逻辑,包括从服务器接收数据、更新角色状态和渲染图形。

以下是定位关键代码的步骤:

  1. PeekMessage 调用后单步跟踪。
  2. 观察堆栈和寄存器变化,寻找与图形API(如 d3d9.dll 中的函数)或游戏逻辑相关的调用。
  3. 尝试临时“NOP”(空操作)掉疑似负责非必要高频更新的函数调用,观察游戏画面和CPU占用率的变化。

通过此方法,我们定位到几个关键函数:

  • 一个与角色动作更新相关的函数(Call_UpdateActor)。
  • 几个与Direct3D图形渲染和贴图相关的函数(Call_D3D9_Render)。

初步测试发现,直接禁用图形渲染函数会严重破坏画面,但CPU占用下降不明显。这说明单纯阻止渲染不是最优解。

优化思路:Hook消息循环

更优雅的方法是在消息循环内部进行优化。核心思路是:让处理消息的主线程在非关键时期主动“休息”

我们分析发现,游戏循环结构大致如下:

循环开始:
  调用 PeekMessage 取消息
  如果 取到消息:
      处理消息 (TranslateMessage/DispatchMessage)
      跳转到 循环开始
  如果 没取到消息:
      执行游戏逻辑(如渲染、网络处理)
  跳转到 循环开始

PeekMessage 没有取到消息时(即 WM_NULL 或返回值为0),游戏会执行那些消耗CPU的逻辑(如空闲状态下的渲染)。这正是我们可以插入 Sleep 调用的理想位置。

以下是修改后的逻辑示意:

循环开始:
  调用 PeekMessage 取消息
  如果 取到消息:
      处理消息 (TranslateMessage/DispatchMessage)
      跳转到 循环开始
  如果 没取到消息:
      调用 Sleep(10) // 让线程休眠10毫秒,释放CPU时间片
      执行游戏逻辑(如渲染、网络处理)
  跳转到 循环开始

实施优化与测试

我们在调试器中直接修改汇编代码,在消息循环中“未取到消息”的分支路径上,插入 push 0Ahcall Sleep 的指令。

优化后效果显著:

  • CPU占用率从约 40% 下降到 3%-4%
  • 游戏基本功能(移动、打怪)未受影响。
  • 但窗口管理消息(如最小化)可能因我们跳过了部分消息派发而失效,这需要在完整Hook代码中精细过滤消息类型来解决。

总结与展望

本节课中我们一起学习了第二种降低游戏CPU占用率的核心方法:通过Hook游戏主消息循环,在空闲处理阶段插入线程休眠

关键要点如下:

  1. 定位循环:使用调试器找到 PeekMessage/GetMessage 所在的消息循环。
  2. 分析分支:识别出“无消息处理”时执行游戏逻辑的代码路径。
  3. 注入休眠:在该路径上注入 Sleep 函数调用,强制线程让出CPU。
  4. 效果权衡:此方法能大幅降低CPU占用,但可能影响对实时性要求极高的操作,且需妥善处理系统消息以避免功能异常。

目前我们在调试器中的修改是临时的。下一节课,我们将探讨如何编写一个稳定、完善的Hook代码(DLL注入),并实现消息过滤机制,使优化方案更健壮和实用。

课程 P117:CPU 代码优化实战教程 🚀

在本节课中,我们将学习如何通过 Hook 游戏主线程来优化其 CPU 占用率。我们将从原理分析开始,逐步编写代码,并最终实现一个可显著降低 CPU 使用率的优化模块。

概述与原理

上一节我们介绍了 CPU 优化的基本概念,本节中我们来看看具体的实现原理。核心思想是 Hook 游戏的主线程循环,在特定位置插入我们自己的代码,让线程在空闲时“休息”,从而降低 CPU 的持续占用率。

理论上,Hook 点可以选择在主循环的任意位置。但为了稳定性和效果,最好选择一个能确保每次循环都被执行到的位置。本教程选择在循环头部附近的第二个空指令位置进行 Hook。

代码实现步骤

以下是实现 CPU 优化的主要步骤。

1. 准备工作与变量定义

首先,我们需要定义几个关键的变量,用于控制时间间隔和保存状态。

// 定义关键变量
DWORD g_dwLastTime = 0;     // 上一次记录的系统时间
DWORD g_dwInterval = 10;    // 检测时间间隔(毫秒)
DWORD g_dwSleepTime = 1;    // 线程休息时间(毫秒)
BYTE g_OldCode[5] = {0};    // 用于保存被 Hook 地址的原始数据

2. 编写自定义 Hook 函数

我们的自定义函数需要用汇编编写,以确保对执行环境的精确控制。该函数的核心逻辑是:检查消息队列,若非必要(如无消息或非定时器消息),则让线程休眠。

MyHookProc:
    // 保存现场
    PUSHAD
    // 检查消息队列
    PUSH 0
    PUSH 0
    PUSH 0
    PUSH 0
    CALL DWORD PTR [PeekMessageA]
    TEST EAX, EAX
    JZ EXIT_FUNC
    // 检查是否为定时器消息(示例,可选)
    CMP DWORD PTR [EBP-0x2C], 0x113
    JNE EXIT_FUNC
    // 获取当前时间
    CALL DWORD PTR [GetTickCount]
    MOV EBX, EAX
    // 计算时间差
    SUB EBX, g_dwLastTime
    CMP EBX, g_dwInterval
    JB EXIT_FUNC
    // 达到间隔,让线程休息
    MOV EAX, g_dwSleepTime
    PUSH EAX
    CALL DWORD PTR [Sleep]
    // 更新上一次时间
    CALL DWORD PTR [GetTickCount]
    MOV g_dwLastTime, EAX
EXIT_FUNC:
    // 恢复现场并跳回原函数
    POPAD
    JMP g_dwHookRetAddr

3. 计算并写入 Hook 地址

Hook 的关键是计算正确的跳转地址并写入目标位置。我们不能直接写入绝对地址,需要根据相对偏移来计算。

公式如下:
假设当前指令地址为 CurrentAddr,原跳转偏移为 OriginalOffset,我们的函数地址为 MyFuncAddr
我们需要计算的新偏移 NewOffset 为:
NewOffset = MyFuncAddr - (CurrentAddr + 5)

以下是实现代码:

void WriteHook(DWORD dwHookAddr, DWORD dwMyFuncAddr) {
    // 1. 保存原始字节
    ReadProcessMemory(... , dwHookAddr, g_OldCode, 5, ...);
    
    // 2. 计算新的跳转偏移
    DWORD dwNewOffset = dwMyFuncAddr - (dwHookAddr + 5);
    
    // 3. 修改内存页面属性为可写
    DWORD dwOldProtect;
    VirtualProtect((LPVOID)dwHookAddr, 5, PAGE_EXECUTE_READWRITE, &dwOldProtect);
    
    // 4. 写入跳转指令 (E9) 和计算出的偏移
    BYTE jmpCode[5] = { 0xE9 };
    memcpy(&jmpCode[1], &dwNewOffset, 4);
    WriteProcessMemory(... , dwHookAddr, jmpCode, 5, ...);
    
    // 5. 恢复页面属性
    VirtualProtect((LPVOID)dwHookAddr, 5, dwOldProtect, &dwOldProtect);
}

4. 恢复 Hook

当需要卸载优化功能时,必须将原始指令写回,恢复游戏代码的原本状态。

void Unhook(DWORD dwHookAddr) {
    // 恢复内存页面属性
    DWORD dwOldProtect;
    VirtualProtect((LPVOID)dwHookAddr, 5, PAGE_EXECUTE_READWRITE, &dwOldProtect);
    
    // 写回原始字节
    WriteProcessMemory(... , dwHookAddr, g_OldCode, 5, ...);
    
    // 恢复页面属性
    VirtualProtect((LPVOID)dwHookAddr, 5, dwOldProtect, &dwOldProtect);
}

参数优化与效果

通过调整 g_dwInterval(检测间隔)和 g_dwSleepTime(休息时间)两个参数,可以微调优化效果。

  • 间隔时间 (g_dwInterval):值越小,检测越频繁,优化可能越细腻,但开销会略微增加。
  • 休息时间 (g_dwSleepTime):值越大,CPU 休息得越充分,占用率下降越明显,但需注意不要影响游戏响应。

通常,CPU 性能越强,间隔可以设得越小,休息时间可以设得越大,从而达到最佳的优化效果。最佳参数需要根据具体游戏和硬件进行测试。

总结

本节课中我们一起学习了 CPU 代码优化的实战方法。我们从原理出发,分析了 Hook 主线程的思路,然后逐步实现了自定义 Hook 函数、地址计算与写入、以及环境恢复的完整代码。通过调整两个关键参数,我们可以有效将游戏的 CPU 占用率从较高的水平(如 30-40%)显著降低(如 3-6%),同时保持游戏的流畅操作。

下一节课,我们将为这个优化器创建一个配置界面,添加进度条来方便地调节这两个参数,使其成为一个更完善、更易用的工具。

课程 P118:129-游戏黑屏优化分析 🎮

在本节课中,我们将学习如何分析并定位导致游戏黑屏的关键函数。我们将从游戏主线程的循环入手,通过逆向分析,逐步识别出与画面绘制、角色动作、场景贴图以及最终黑屏效果相关的核心函数。


上一节我们介绍了CPU优化的相关分析,本节中我们来看看如何针对游戏黑屏现象进行优化分析。我们的入手点主要是从游戏的主线程循环开始。

首先,我们需要附加到游戏进程。

接着,我们查看主线程的循环代码。这是分析画面更新的起点。

在主线程循环中,通常会有与时间计算相关的函数,用于控制画面更新频率。例如:

  • GetTickCount
  • timeGetTime

这些函数计算时间差,决定何时更新画面。我们的目标是找到负责绘制游戏界面的具体函数。

我们在疑似绘制相关的代码处下断点,并逐步跟踪执行流程。画面更新并非每次循环都发生,可能需要多次跟踪才能找到正确的绘制调用。

在跟踪过程中,我们遇到了多个函数。以下是初步分析的一些函数及其可能的作用:

  • 函数A:可能与角色移动和界面更新有关。Hook此函数后,游戏角色无法移动。
  • 函数B、C、D:这三个函数可能与角色和怪物的动作更新相关。Hook后,角色和怪物的动作会停止。
  • 函数E:可能与怪物死亡后的数据更新有关。Hook后,怪物被攻击后不会死亡。
  • 函数F:是一个D3D9相关的函数,与画面处理有关,可能是场景贴图的一部分。
  • 函数G:另一个D3D9相关函数,参数较少,具体作用暂时不明。

我们继续向后分析,发现了一个关键函数。

这个函数只有一个参数。当我们尝试Hook或直接返回(return)此函数时,整个游戏屏幕变黑。

因此,我们可以初步判定这个函数是实现黑屏处理的关键。它很可能负责最终的场景绘制或提交。

之后我们还看到一个与高精度计时相关的函数 QueryPerformanceCounter,根据MSDN说明,它用于获取高分辨率性能计数器的值,通常用于精确计时,与黑屏处理直接关系不大。

在黑屏状态下测试,游戏寻路等功能似乎未受影响。


本节课中我们一起学习了游戏黑屏优化的分析思路。我们从主线程循环入手,通过下断点和跟踪执行流,逐步筛选并识别出与角色动作、怪物状态、场景贴图相关的多个函数,并最终定位到一个关键函数。通过Hook或绕过这个函数,可以实现游戏画面的黑屏效果。若要编写黑屏功能,可以从此处入手。

课程 P119:真正的黑屏优化 🖥️

在本节课中,我们将学习如何通过逆向分析,定位并实现一个能显著降低游戏CPU占用率的“真正黑屏”优化方法。我们将分析关键函数,区分“伪黑屏”与“真黑屏”,并学习如何通过Hook技术实现优化。


回顾与起点

上一节我们分析了与游戏建筑物隐藏相关的数据。本节课我们将从上一节找到的相关地址继续深入分析,目标是找到负责画面绘制的核心函数。

我们首先打开上一课(129课)分析的数据。从相关地址继续跟进,目标锁定在可能包含游戏对象或建筑物画面更新的函数库中。这个函数可能在库内部,也可能是单独完成的。

分析关键函数

我们首先转到上一课分析的黑屏相关函数库中查看。

以下是上一课分析的黑屏函数,我们将从这里继续向下跟踪。

按下F8单步执行,我们观察这个函数。上一课已跟进过,它内部没有循环,主要包含一些定时相关操作。

我们继续向下执行,发现另一个函数调用。我们跟进到这个函数内部查看。

首先,我们观察ECX寄存器的值。从上下文看,ECX指向的是我们的人物角色对象。这个函数可能执行与人物角色相关的操作。函数内部有GetTickCount等计时函数。这个函数体量较大,实现的功能可能较多。

另一种分析方法是直接观察堆栈ESP值在执行前后的变化,而不必深入函数内部。这可以帮助我们判断函数的参数数量。

执行前,ESP值为0x148。按下F8执行该函数后,ESP值变为0x3A。计算可知,堆栈变化了0x10E(即0x148 - 0x3A),这通常意味着函数清理了其参数。通过计算,可以推断它大约有3个参数。

由于该函数后面还有可能引发错误的语句,我们可以选择跳过它。具体方法是修改代码,直接跳转到地址0x27BBE1处执行。这样我们就跳过了这个函数调用。

我们将这一修改记录下来,然后重新启动游戏进行验证。

验证与定位

我们再次转到分析的目标地址。

首先,在此处设置一个跳转指令。

由于此处没有参数压栈操作,我们跳转后无需恢复堆栈。

再次运行游戏观察。此时,这个函数暂时看不出与绘图界面有直接关系。

我们继续向后执行。接下来会遇到一段与D3D9相关的操作,这里也可能是界面绘制的关键部分。

我们主要观察执行该函数后堆栈ESP的变化。执行前ESP0x54,执行后变为0x8。因此,我们可以让ESP的值增加0x4C来跳过此调用。由于指令空间可能不足,我们可以考虑直接NOP掉这几条指令。

我们在此处下断点,观察ESI寄存器的值。ESI的值可能不属于常见的游戏对象,因为其偏移+8处的数值过大。它可能是与游戏界面相关的某个数据结构。

此时,我们观察游戏画面。画面变成了真正的黑屏,与上一课的效果不同。同时,CPU占用率变得非常低,大约只有3%。这表明我们找到了实现真正黑屏优化的关键点。

撤销修改后,CPU使用率立刻回升。因此,实现真正黑屏优化的关键函数调用就在这个地址:call d3d9.xxxxxxx

我们可以直接Hook这个D3D9函数调用的地址,来实现黑屏优化。

区分“伪黑屏”与“真黑屏”

上一节课我们分析了一个“黑屏”函数,但那是“伪黑屏”。本节课我们找到了实现“真黑屏”优化的核心函数。

“伪黑屏”函数虽然能使屏幕变黑,但CPU占用率并未显著下降。其函数特征如下(作为特征码参考):

// 伪黑屏函数特征
call some_library_function
// ... 其他操作

而“真黑屏”函数位于D3D9调用处,Hook此处能同时实现黑屏和降低CPU占用。其附近可能有明显的字符串可用于特征码定位。

此外,在图像更新后,还有一个函数调用可能用于发送消息或挂起线程。如果只NOP掉这个函数,虽然能达到黑屏效果,但CPU占用率不会下降,因此它属于“伪黑屏”函数。

核心结论:需要同时NOP掉D3D9绘图调用和其后的特定挂起/消息函数,才能实现CPU占用率显著降低的“真正黑屏”。

总结

本节课我们一起学习了:

  1. 回顾分析:从上一课的数据地址继续深入。
  2. 函数分析技巧:通过观察堆栈ESP变化判断函数参数,以及使用跳转或NOP跳过特定调用。
  3. 定位关键函数:找到了位于D3D9调用处的、能实现真正黑屏优化(显著降低CPU占用)的核心代码位置。
  4. 概念区分:明确了“伪黑屏”(仅画面变黑)与“真黑屏”(画面变黑且CPU占用降低)的区别及实现原理。

关键实现在于Hook以下公式描述的核心调用:

Hook(Address_of_D3D9_Draw_Call) -> NOP 或 Return

下节课,我们将在此基础上,分析如何实现游戏内人物或建筑物的隐藏功能。


:教程中涉及的地址和特征码因游戏版本和环境而异,需在实际分析中动态确定。

课程 P12:封装完善动作使用CALL与避免重复包含头文件 🛠️

在本节课中,我们将学习如何封装和完善游戏动作的调用函数,使其更易读、更易用。同时,我们还将探讨一个重要的编程技巧:如何避免因重复包含头文件而导致的编译错误。通过本课的学习,你将能够编写出更健壮、更易于维护的代码。


完善动作调用函数

上一节我们介绍了如何调用游戏中的动作。本节中,我们来看看如何封装一个更便捷的函数,通过动作名称来执行对应的操作。

我们打开第26课的代码,目标是创建一个函数,它接收一个动作名称作为参数,然后执行该动作。


首先,我们需要遍历动作数组,找到与传入名称匹配的动作下标。找到下标后,就可以调用之前编写的执行函数了。

以下是实现该功能的核心代码逻辑:

int i;
for (i = 0; i < 12; i++) {
    if (strcmp(g_ActionList[i].name, actionName) == 0) {
        // 找到对应动作,使用其下标
        UseAction(i);
        break;
    }
}

这段代码中,g_ActionList 是存储所有动作信息的结构体数组,actionName 是传入的参数。我们使用 strcmp 函数比较字符串,如果相等,则调用 UseAction 函数并传入找到的下标 i

为了使代码更清晰,我们可以在调用前对字符串比较函数 strcmp 进行初始化操作。当然,也可以将初始化步骤直接写在循环内部。


优化:使用全局变量

在测试时,我们发现每次调用函数都需要创建一个结构体对象,这很麻烦。为了解决这个问题,我们可以将常用的结构体对象声明为全局变量。

我们创建一个专门的头文件(例如 GlobalVars.h)来存放这些全局变量的声明。

在这个全局变量单元中,我们定义动作数组、背包数据等所有需要全局访问的变量。定义之前,需要包含相应的结构体头文件。

// GlobalVars.h
#include “ActionStruct.h” // 假设动作结构体定义在此头文件

// 声明全局动作数组
extern Action g_ActionList[12];
// 声明其他全局变量...

这样,在其他源代码单元中,只需包含 GlobalVars.h 即可使用这些全局变量,而无需再单独包含 ActionStruct.h


避免重复包含头文件 🔄

当我们尝试在多个地方包含同一个头文件时,编译器可能会报“重定义”错误。这是一个常见问题。

为了解决这个问题,我们可以在头文件中使用“包含守卫”或“宏定义保护”。

其原理是:在头文件的开头检查一个特定的宏是否已被定义。如果是第一次包含,则定义该宏并编译头文件内容;如果是第二次或后续包含,则因为宏已定义,头文件内容会被跳过,从而避免重复编译。

以下是具体的实现方法:

// ActionStruct.h
#ifndef ACTION_STRUCT_H // 如果 ACTION_STRUCT_H 这个宏没有被定义
#define ACTION_STRUCT_H // 那么定义它

// 这里是头文件的实际内容,比如结构体定义
struct Action {
    char name[20];
    int id;
    // ... 其他成员
};

#endif // ACTION_STRUCT_H

#ifndef (if not defined) 指令会检查 ACTION_STRUCT_H 是否已定义。如果没有,则执行 #define 定义它,并编译后续代码直到 #endif。如果这个头文件被再次包含,由于宏已定义,#ifndef 条件为假,中间的所有代码都不会被再次编译,从而避免了重定义错误。

Windows 系统头文件也大量使用了这种技巧。我们也可以为自己编写的头文件(如 GlobalVars.h)加上类似的保护。


测试与总结

现在,我们可以使用封装好的函数进行测试了。例如,要执行“调息”动作,只需调用:

ExecuteActionByName(“调息”);

要攻击怪物,可以先选中怪物,然后调用:

ExecuteActionByName(“攻击”);

这样,代码的可读性大大提高。我们不再需要记忆晦涩的数字下标,直接使用动作名称即可。这为后续实现更复杂的自动化功能(如挂机)打下了基础。

本节课中我们一起学习了两个核心内容:

  1. 封装动作调用函数:通过动作名称来查找并执行对应功能,提升了代码的可用性和可读性。
  2. 使用包含守卫:通过 #ifndef / #define / #endif 宏来防止头文件被重复包含,这是编写健壮C/C++程序的重要技巧。

通过将动作调用封装成易于使用的函数,并合理组织全局变量和头文件,我们的代码结构变得更加清晰和易于维护。下一节课,我们将在此基础上继续探索。

逆向工程课程 P120:131-隐藏建筑物分析 🏗️🔍

在本节课中,我们将学习如何通过逆向工程分析游戏中的隐藏建筑物或场景对象。我们将从黑屏优化相关的函数入手,逐步追踪并分析负责游戏画面数据准备和对象绘制的关键代码逻辑。


概述

本节课的目标是定位并分析游戏中负责处理场景对象(如建筑物、角色、UI界面)绘制数据的核心函数。我们将使用调试工具附加进程,通过分析函数调用栈、参数变化及执行效果,来理解游戏渲染对象的管理机制。


从黑屏优化函数入手

上一节我们介绍了逆向分析的基本思路。本节中,我们来看看如何从游戏的黑屏优化相关代码开始分析。

首先,我们找到了两个与黑屏优化相关的函数。其中一个函数是真正的黑屏优化处理函数,而另一个函数(我们暂时称其为“管理函数”)可能负责游戏画面相关数据的准备工作。

以下是初步分析步骤:

  1. 使用调试器(如OD)附加到游戏进程。
  2. 转到疑似黑屏优化的函数地址。
  3. 观察函数执行时CPU占用率的变化,以区分“真黑屏”(CPU占用大幅下降)和“伪黑屏”(CPU占用变化不大)。

通过观察,我们确定目标函数是一个“伪黑屏”函数,它可能负责管理对话框、人物角色等场景对象的画面更新数据。


深入分析数据管理函数

既然我们初步定位了可能的数据管理函数,本节中我们来看看其内部的具体逻辑。

我们进入该函数内部,发现它体积较大且没有明显的参数。为了便于分析,我们首先记录下该函数的地址,并尝试在其内部下断点,观察堆栈变化。

以下是关键操作记录:

  • 在函数入口下断,执行一步(F8),观察ESP寄存器值从 0x1C 变为 0x28,表明该函数可能有3个参数(因为 0x28 - 0x1C = 0x0C,即12字节,通常对应3个4字节参数)。
  • 我们尝试使用 ADD ESP, 0x0C 来平衡堆栈并跳过该函数调用,发现游戏画面未受影响,说明此函数可能不是核心的数据处理函数。

我们继续在函数内部跟踪,发现了一些循环结构和条件判断,这暗示它可能在对某种对象列表进行分类处理。


定位对象绘制相关函数

上一节我们发现了一个可能进行对象分类的函数。本节中,我们来看看如何找到真正负责对象绘制的函数。

我们沿着调用链继续跟踪,发现了一个被循环调用的函数。当我们跳过这个函数执行时,游戏出现了黑屏现象,这强烈暗示它与画面数据处理直接相关。

以下是定位过程:

  1. 在疑似函数上下断点并跟踪。
  2. 发现该函数在一个大循环中被反复调用,每次传入一个对象指针(存储在ECX或ESI寄存器中)。
  3. 函数内部会读取对象指针偏移 +8 处的数据,并与一些固定值(如 0x33310x2E31)进行比较。
  4. 根据比较结果进行跳转,可能是在区分对象类型(例如,人形NPC、怪物等)。

我们尝试修改跳转条件,发现当跳过对人形NPC的处理时,选中NPC后其血条不再显示。这表明我们可能找到了与对象状态(如选中高亮、血条绘制)相关的判断逻辑。


分析核心绘制逻辑

我们找到了影响血条显示的函数,但核心的物体(如建筑物)绘制逻辑可能还在别处。本节中,我们继续深入分析。

我们跟踪了另一个被频繁调用的函数,它同样处在一个循环中。跳过此函数后,游戏发生了显著变化:

  • 地面贴图消失。
  • 游戏菜单、背包界面(按Tab键)无法打开。
  • F1-F10的快捷栏图标消失。
  • 但CPU占用率没有下降,游戏角色和NPC依然可见且可动。

这表明我们找到了一个核心的绘制分发函数。它可能根据不同的参数,负责调度不同类别对象的绘制工作,包括UI界面、地面贴图等。而建筑物、角色模型的绘制,可能是由它调用其他更具体的函数来完成的。

由于此函数调用点非常多,且内部逻辑复杂,直接全面分析耗时较长。我们在尝试跳过其内部一个子函数时导致了游戏崩溃,说明函数内部的数据处理存在依赖关系。


总结

本节课中我们一起学习了逆向分析游戏渲染流程的初步方法:

  1. 从现象切入:从“黑屏”这类显著现象入手,定位相关的优化或管理函数。
  2. 动态调试:利用调试器观察函数执行、参数传递和堆栈变化,区分关键函数与非关键函数。
  3. 逻辑分析:通过分析循环、比较和跳转指令,理解游戏如何分类和处理不同的场景对象(如NPC、UI)。
  4. 功能验证:通过修改代码(如NOP指令跳过调用)并观察游戏画面变化,来验证函数的具体功能。

我们最终定位到了一个核心的函数,它很可能负责游戏内多种元素(UI、地面、可能包括建筑物)的绘制调度。由于内部逻辑复杂且存在数据依赖,完整的分析需要更细致的跟踪和测试。

在下一节课中,我们将基于本节课找到的线索,继续深入分析这个核心函数,并尝试定位到专门处理建筑物隐藏或显示的具体代码逻辑。

逆向工程课程 P121:隐藏人物分析 🕵️♂️

在本节课中,我们将继续分析如何隐藏游戏中的建筑物和人物角色。我们将通过调试和修改汇编代码,探索实现特定视觉效果的方法。


概述

上一节课我们开始分析隐藏建筑物的方法。本节课我们将从之前找到的代码位置继续深入,通过下断点、修改跳转指令等方式,测试哪些函数调用与人物和建筑的绘制相关,并尝试实现隐藏效果。


准备工作

首先,打开调试器并附加到游戏进程。

这是上一节课设置的断点。我们需要先将所有现有断点取消。




分析绘制循环

我们接着上一节课的数据继续分析。

从之前的位置重新下断点开始分析。首先,我们尝试跳过第一个 CALL 指令的执行。通过修改其附近的跳转指令,使其无条件跳转,从而绕过该 CALL

JMP [目标地址]

修改后运行游戏,观察发现没有任何视觉效果变化。我们继续向后分析代码。


测试多个函数调用

代码中后续有几个 CALL 指令,可能是绘制相关函数。

以下是测试步骤:

  1. 在下一个 CALL 处下断点,观察栈指针 ESP 的变化。
  2. 如果执行 CALL 前后 ESP 无变化,说明该函数不涉及参数操作,可以尝试将其 NOP 掉或跳过。
  3. 取消断点,返回游戏查看场景变化。

按此方法测试了循环内的几个 CALL,均未发现建筑隐藏效果。但修改到某个特定跳转后,人物和怪物的选中状态血条消失了。

; 修改前
JE [目标地址]
; 修改后
JMP [目标地址]

这提示该处代码可能与界面层显示有关,而非建筑绘制。


发现人物隐藏点

继续向前分析,在另一个 CALL 处修改跳转指令后,游戏内的人物角色被隐藏了,只能看到一个影子。

这是一个重要发现。我们重新进入游戏,并取消其他所有断点,单独测试此处的修改。


单独修改此处跳转后,确认可以隐藏当前控制的人物角色。


功能扩展测试

CALL 函数可能被多处调用。我们尝试直接在该函数开头返回 (RET),以测试其影响范围。

RET

修改后,切换到有其他玩家的区域进行测试。

测试发现,该修改隐藏了场景中所有的玩家角色,但他们的武器仍然可见。这说明武器绘制可能由其他函数负责。


错误排查

在测试过程中,游戏偶尔会出现错误。这可能是由于以下原因:

  1. 插件冲突。
  2. 修改的代码被后续指令依赖。
  3. 游戏本身对关键代码有检测和保护机制。

我们尝试移除不必要的调试插件,仅保留核心功能,然后重新附加游戏进行测试。




深入分析与备选方案

通过分析,我们发现调用目标函数的位置不止一处。直接修改函数或某些跳转可能导致错误。


一种更稳定的修改思路是:不直接 NOPCALL,而是修改其逻辑,使其跳过关键操作但保持函数框架。例如,让函数执行到末尾的 RET 指令。

; 跳过函数内部操作,直接到返回指令
JMP [函数内RET指令地址]

此外,如果修改代码的方式不稳定,我们可以转而分析其决策数据。函数内部可能会根据某个内存地址或寄存器的值来决定是否绘制。找到这个判断数据并修改它,是另一种实现隐藏效果的途径。


总结

本节课我们一起探索了通过逆向工程隐藏游戏内人物角色的方法。

  • 我们通过调试定位了可能与角色绘制相关的函数调用。
  • 通过修改汇编跳转指令,实现了隐藏所有玩家角色的效果。
  • 分析了修改后游戏出错的可能原因,包括代码依赖和游戏保护。
  • 提出了两种思路:一是寻找更稳定的代码修改点,二是分析并修改其内部用于控制显示的逻辑数据。

隐藏建筑物的功能尚未在本节课找到,这将是后续课程继续分析的目标。同时,如何稳定地实现人物隐藏而不引发游戏错误,也需要进一步研究。

逆向分析课程 P122:隐藏人物与建筑物分析 🕵️♂️

在本节课中,我们将继续上一节课的分析,学习如何通过逆向工程找到并修改游戏代码,以实现隐藏游戏内人物和建筑物的功能。我们将分析关键的内存地址、函数调用和判断逻辑,并最终通过修改数据或代码实现目标效果。


回顾与问题定位 🔍

上一节我们介绍了分析接口的初步修改,但修改代码后程序会崩溃。本节中我们来看看如何定位并解决这些问题。

首先,我们转到之前分析过的地址。当时我们修改了 4354B0 处的跳转,但运行一段时间后程序会出错。

根据分析,这个地址的处理逻辑与人物角色和画面更新的数据相关,它有三个参数。接下来,我们尝试分析更前面的代码,因为修改跳转后数据会出错。

分析前置判断逻辑 🧩

我们再往前查找,到了两个关键位置。这里有一个判断,从代码结构看,它涉及一个数组。

以下是分析该数组内容的步骤:

  1. 查看数组的第一个元素,类型是 0x31,代表玩家对象。
  2. 查看数组的第二个元素,类型是怪物对象。
  3. 初步判断,这个数组可能是我们周围单位的列表(包括玩家和怪物)。

我们让程序运行起来,然后在内存单元中查看。


我们查找更新周围怪物对象的函数,找到了地址 450F20。这里显示的是附近的怪物/人物对象数组。

既然相关,那么后续的数据处理肯定与怪物和人物数据有关。此处的比较逻辑是检查数组中是否存在某个单位。如果我们直接让其跳转(即绕过判断),就等于告诉程序“周围不存在怪物”。

我们进入该逻辑查看。跳转后,周围的人物和怪物可能全部被隐藏了。但隐藏后还有一个问题:角色移动也会受到影响。虽然这个功能能隐藏周围单位,但实际作用有限。

我们注释掉这里的修改。目前,我们能隐藏周围的对象(玩家、怪物、其他人物),但建筑物的隐藏可能另有其法。

探索建筑物隐藏逻辑 🏗️

我们再往前查找,发现另一个判断,其来源是地址 3117808。这个地址我们之前没见过。


这里也有一个比较,并且会被执行。我们观察其执行地址和 EDX 寄存器的值,运行几次后发现 EDX 没有变化,说明这个调用可能固定指向某个函数。

我们尝试在此处添加一个 JMP 指令跳过它,然后观察效果。跳过这个调用后,角色无法移动,但大部分建筑物被隐藏了(尽管还能看到虚影)。这很可能就是处理建筑物隐藏的关键函数。

我们根据这个调用地址进去查看。在函数内部下断点,发现 EDX 值一直不变,初步判断这个地址固定,专门处理建筑物的显示数据。

我们备注一下:此处是建筑物相关显示数据的处理函数。如果不处理这个函数,建筑物就不会被绘制出来,因此它能绘制部分建筑物虚影。

深入分析并完美隐藏建筑物 🎯

我们转到这个函数内部,观察其参数。通过观察 ESP 寄存器的变化,可以推断它有三个参数。

我们在函数头部尝试修改代码,然后返回游戏查看效果。修改后,建筑物的虚影完全消失,所有建筑物都被完美隐藏,且不影响自动挂机功能。

优化人物与怪物的隐藏方案 👥

关于人物和怪物的隐藏,我们之前分析过一个相关的“距离”参数。只要将该参数改为零,实际上也能达到完美隐藏的效果,无需修改此处代码。

以下是实现完美隐藏的步骤:

  1. 找到之前课程中分析出的特征码和基址。
  2. 使用工具(如CE)修改内存数据。



我们查看之前人物隐藏的地址(P121 课程内容),找到相应的特征码。由于游戏更新,特征码可能已变化,需要重新搜索。


打开CE工具,搜索新的基址。

我们已经将新的特征码添加到基址列表。直接在此处查看并修改,将类型为 float 的两个值从 300 改为 0

修改后,除了自身以外的所有玩家和怪物都被隐藏了。这种通过数据隐藏的方式比直接修改代码更方便。自动挂机功能仍然可用,因为打怪逻辑读取的是内存数据,而非显示数据。

关键特征码总结 📝

本节课涉及几个关键修改点,以下是相关的特征码:

  1. 隐藏人物/怪物(距离修改)

    • 特征码位置:之前课程中分析的地址。
    • 修改方法:找到两个 float 类型数据,将其值改为 0
    • 效果:隐藏自身以外的所有玩家和怪物。


  1. 隐藏建筑物(代码修改)

    • 关键调用地址:本节课分析的函数。
    • 修改方法:修改该函数的机器码为 C2 01 C0,或直接NOP掉关键调用。
    • 效果:完美隐藏所有建筑物。


恢复代码后,建筑物将重新显示。若需隐藏,则应用上述修改。

课程总结 🎓

本节课中,我们一起学习了如何通过逆向分析定位游戏内隐藏人物和建筑物的逻辑。

  • 我们首先回顾了之前修改导致崩溃的问题,并向前追溯分析逻辑。
  • 然后,我们分析了处理周围单位列表的判断,并找到了隐藏人物的替代方案(通过修改距离数据)。
  • 接着,我们发现了专门处理建筑物显示的函数,并通过修改其代码实现了建筑物的完美隐藏。
  • 最后,我们总结了实现这两个功能的关键特征码和修改方法。

由于有前面的数据分析基础,本节课的探讨效率较高。通过修改数据而非代码,可以实现更稳定、方便的功能。

我们下节课再见!


课程 P123:神奇的游戏内存优化 🧠

在本节课中,我们将学习一个用于优化游戏进程内存占用的 Windows API 函数。我们将了解其工作原理、使用方法以及注意事项,并通过一个简单的代码示例来演示其效果。

概述

内存优化函数是一个 Windows API,其本质是将物理内存中的数据交换到虚拟内存(通常位于硬盘上)中。这并非真正减少内存使用量,而是通过置换不常使用的数据来更高效地利用物理内存。

上一节我们介绍了内存优化的基本概念,本节中我们来看看具体的实现方法。

函数原理

根据官方文档说明,当该函数的第二个和第三个参数同时设置为 0xFFFFFFFF(即十进制的 -1)时,函数会将进程的物理内存数据置换到虚拟内存中。

其核心作用是创建一个页面文件并保存到硬盘上。如果某些数据不经常被使用,这种方法可以有效地释放物理内存空间。

代码实现

以下是调用该函数进行内存优化的一个简单示例。我们将在游戏加载完成后使用它。

// 获取当前进程句柄并调用优化函数
HANDLE hProcess = GetCurrentProcess();
// 调用内存优化函数,传入三个 -1 参数
OptimizeMemoryFunction(hProcess, -1, -1);

注意:该函数建议在游戏加载好后仅使用一次,反复调用可能对硬盘造成额外负担。

操作演示

我们通过一个实际案例来观察效果。优化前,游戏进程的内存占用约为 618 MB。

执行上述优化函数后,内存占用显著下降至几十 MB。这是因为大量数据被置换到了硬盘上的虚拟内存中。

然而,随着游戏运行,系统需要访问相关数据时,会逐渐将数据从硬盘换回内存,因此内存占用会缓慢回升。这个过程如果频繁发生,硬盘指示灯会频繁闪烁。

注意事项

在使用此优化技术时,需要注意以下几点:

  • 一次性使用:该效果在首次调用时最明显,后续调用效果有限。
  • 硬盘影响:频繁的数据置换会增加硬盘读写,可能影响硬盘寿命。
  • 虚拟内存依赖:此功能依赖于系统设置的页面文件(如 pagefile.sys)。如果虚拟内存被禁用或设置得过小,此函数可能失效。
  • 参数简写:函数调用时可以传入三个 -1 作为参数,其中第一个 -1 代表当前进程句柄。

总结

本节课我们一起学习了如何通过一个特定的 API 函数来优化游戏进程的内存占用。我们了解到,该技术通过将物理内存数据置换到硬盘虚拟内存中来暂时减少内存占用,但并非永久性减少内存使用。使用时需注意其一次性效果以及对硬盘的潜在影响。理解其原理有助于我们更合理地运用这一优化手段。

课程 P124:D3D9_HOOK黑屏优化分析及地址定位 🎮

在本节课中,我们将学习两种实现D3D9黑屏优化的方法,并详细讲解如何定位实现这些优化所需的关键内存地址。黑屏优化的核心原理是阻止游戏渲染图形,从而显著降低CPU占用率。

两种黑屏优化实现方式 💡

上一节我们介绍了黑屏优化的概念,本节中我们来看看两种具体的实现路径。

实现黑屏优化主要有两个切入点:

  1. 在主线程中通过HOOK技术,跳过关键的图形绘制调用。
  2. 直接修改d3d9.dll动态链接库中的函数代码,使其提前返回。

这两种方式的实现原理相同,都是通过阻止图形更新和绘制来达到节省CPU资源的目的。

方法一:主线程HOOK实现 🧵

首先,我们分析第一种在主线程中实现的方法。

以下是实现步骤:

  1. 使用调试工具定位到负责图形更新的关键代码段。
  2. 将该代码段进行NOP(空操作)填充或修改为无害指令(如 mov eax, eax)。
  3. 修改后,游戏将不再渲染画面,CPU占用率会随之下降。

这种优化方式比另一种通过强制CPU休眠的优化方法效果更好,因为它不会导致数据处理延迟。

特征码定位与偏移计算 🔍

为了实现自动化,我们需要通过特征码来定位这个关键地址。

以下是定位流程:

  1. 从关键代码段前的稳定区域提取特征字节序列。
  2. 使用特征码在内存中搜索,找到匹配的地址。
  3. 计算从特征码地址到目标修改地址的偏移量。公式为:
    目标地址 = 特征码定位地址 + 偏移量
  4. 例如,若偏移量为 0x15(十进制21),则最终地址为定位地址加上该值。

定位成功后,我们需要修改的目标是连续的三个字节,将其全部替换为 0x90(NOP指令)即可。

方法二:修改D3D9动态链接库 🔧

接下来,我们探讨第二种直接修改d3d9.dll的方法。

由于d3d9.dll是动态加载的,其基地址每次启动游戏都会变化。因此,我们不能使用硬编码的地址。解决方案是动态获取模块基址,然后加上固定的函数偏移。

动态获取模块地址与计算

以下是获取和计算目标地址的步骤:

  1. 使用Windows API函数获取d3d9.dll的模块句柄(即基地址)。
    • 常用函数有 GetModuleHandle(L"d3d9.dll")
    • 模块句柄的返回值就是该DLL加载到内存中的基地址。
  2. 通过逆向分析,找到目标函数相对于DLL基地址的固定偏移量。
  3. 使用公式计算出目标函数的绝对地址:
    目标函数地址 = GetModuleHandle("d3d9.dll") + 函数偏移量

函数修改

定位到目标函数后,我们修改其入口处的代码。
该函数通常以 push ebp; mov ebp, esp 开头。我们只需将开头的几个字节替换为 c2 04 00,即汇编指令 retn 4,使函数立即返回并清理一个参数,从而跳过后续的所有绘制操作。

在修改前,务必备份被覆盖的原始字节,以便后续恢复。

代码实现与作业 📝

最后,我们将上述逻辑整合到代码中。

我们需要定义两个宏或函数来生成最终的可写地址:

  • BLACK_SCREEN_MAIN_HOOK_ADDR: 通过搜索主线程特征码并加上偏移量生成。
  • BLACK_SCREEN_D3D9_HOOK_ADDR: 通过 GetModuleHandle 获取基址再加上偏移量生成。

对于主线程HOOK,写入的数据是三个 0x90
对于D3D9函数HOOK,写入的数据是 {0xC2, 0x04, 0x00}

本节课的作业:请参考第56课的代码框架,编写程序自动完成上述地址的计算与代码改写操作。

总结 📚

本节课中我们一起学习了D3D9黑屏优化的两种实现方式:主线程HOOK和直接修改D3D9动态链接库。我们深入探讨了如何通过特征码定位和动态基址计算来找到关键的内存地址,并明确了具体的修改字节。掌握这些方法,你就能有效地实现游戏的黑屏优化,降低资源消耗。下一节课,我们将共同完成相关的代码实践作业。

课程 P125:136-自动生成黑屏优化基址 BaseBlackScreen 📝

在本节课中,我们将学习如何自动生成实现黑屏优化所需的两个关键基址。上一节我们分析了实现黑屏优化的两种方式:主线程Hook和动态链接库Hook。本节我们将重点讲解如何通过代码自动定位并生成这两个基址,为后续的Hook操作做好准备。

回顾与目标 🎯

上一节我们介绍了两种黑屏优化的实现方式。第一种是通过搜索特征码 8B3090 来定位主线程中的指令。第二种是通过三个 NOP 指令(90)来实现Hook。无论采用哪种方式,都需要先准确定位到目标基址。本节的目标就是编写代码,自动生成这两个基址。

自动生成主线程黑屏优化基址 🔍

首先,我们来生成主线程黑屏优化所需的基址。这个基址的定位依赖于搜索特定的特征字符串。

以下是实现步骤:

  1. 复制并修改现有代码:我们参考第56课的基址定位代码。该代码之前已能定位21个基址,现在我们需要添加第22个基址用于黑屏优化。
  2. 设置特征码与偏移:将黑屏优化的特征字符串和对应的偏移量复制到代码中。特征码为 8B3090,我们需要搜索它。
  3. 优化搜索范围:为了提高搜索效率,我们不是从进程起始地址(如0x400)开始搜索,而是在一个已知地址(例如 528)附近的小范围(如增加1万字节)内搜索。这样可以大大加快速度。
  4. 计算最终地址:搜索到特征码地址后,还需要加上一个15字节的偏移,才能得到最终的目标基址。
  5. 代码调整:与之前一些需要读取指针的基址不同,这个地址是直接使用的。因此,我们不需要调用读取内存的函数,而是直接将搜索到的地址加上偏移即可。
  6. 验证与输出:将计算出的基址输出到文件或屏幕,供后续Hook使用。

核心的地址计算逻辑可以用以下伪代码表示:

DWORD SearchedAddr = FindPattern(“8B3090”, StartAddr, SearchRange);
DWORD FinalBase = SearchedAddr + 0x0F; // 加上15字节偏移

通过以上步骤,我们就能成功生成主线程黑屏优化的固定基址。

动态生成动态链接库中的基址 ⚙️

接下来,我们处理第二种方式所需的基址。这个基址位于动态链接库中,其地址不是固定的,每次游戏启动都可能变化,因此不能像上一个那样生成静态的“红”(即常量)。我们需要在动态链接库加载时动态计算它。

以下是实现方法:

  1. 定义全局变量:在动态链接库的代码中,定义一个全局变量来存储这个基址。
  2. 动态初始化:在动态链接库被加载时(例如在 DllMain 函数或初始化函数中),计算这个基址的值。
  3. 计算方法:基址的计算公式为:目标模块的基址 + 固定偏移。我们需要先获取目标模块(如 game.dll)的句柄和基址,然后加上一个已知的偏移量。
  4. 类型转换:注意相关API返回值的类型转换,例如将 HMODULE 转换为 DWORD
  5. 调试输出:可以打印出计算出的基址进行验证。

核心的计算公式如下:

// 假设目标模块句柄为 hModule,固定偏移为 offset
DWORD DynamicBase = (DWORD)hModule + offset;

这样,无论游戏如何重启,动态链接库每次都能正确计算出当前的基址。

总结与下节预告 📚

本节课我们一起学习了如何自动生成黑屏优化所需的两个基址。

  • 对于主线程的固定基址,我们通过搜索特征码并计算偏移的方式来自动定位。
  • 对于动态链接库中的可变基址,我们通过在库加载时动态计算“模块基址+固定偏移” 的方式来获取。

基址定位是Hook操作的前提。下一节,我们将基于本节获取的基址,具体实现黑屏优化的Hook与反Hook(还原)功能。其核心操作就是向目标地址写入数据(如 90 90 90)或恢复原数据。请大家尝试提前完成这两个功能的函数编写。

课程 P126:两种游戏黑屏优化与取消优化代码设计 🎮

在本节课中,我们将学习如何通过两种不同的方式实现游戏的黑屏优化(即关闭画面渲染以降低CPU占用),并设计相应的代码来取消此优化。我们将从已有代码基础开始,逐步完成函数的编写、测试与封装。


概述与准备工作

上一节我们分析了游戏渲染的相关机制。本节中,我们将动手编写代码来实现优化。

首先,打开上一节课(第136课)的代码工程。我们需要将最新的基地址数据复制到当前项目中。

接着,转到存放基地址的单元,找到我们所需的地址。准备工作完成后,便可以开始编写第一个优化函数。


第一种优化方式:Hook特定函数

为了代码结构清晰,我们将函数封装在专门的功能模块中。此函数最终会被主线程单元调用。

我们首先在头文件中声明一个函数,例如命名为 HookBlackScreen。为了区分开启与关闭优化,我们使用一个布尔标志 flag

// 函数声明示例
void HookBlackScreen(bool flag);

函数的设计思路是:根据 flag 的值决定执行操作。若标志为真,则进行Hook操作以实现黑屏;若标志为假,则进行Unhook操作以恢复显示。

在实现之前,我们需要参考第135课或第136课的代码,获取需要修改的指令数据。


以下是实现所需的核心数据:

  • Hook数据:用于替换原指令,实现关闭渲染。可能是全0x90(NOP指令),或特定的三条指令(例如 mov eax, eaxret)。
  • 恢复数据:用于还原被修改的指令,以恢复正常显示。我们需要从原程序代码中提取这三个字节的原始数据。


例如,原始数据可能是 0x50, 0xFF, 0xD2。我们将它保存为恢复数据。

在CPP单元中实现函数。核心操作是向目标地址写入指定数据。由于是修改当前进程内存,句柄可使用 -1

void HookBlackScreen(bool flag) {
    DWORD address = 0xXXXXXXXX; // 替换为目标地址
    BYTE hookData[3] = {0x90, 0x90, 0x90}; // Hook数据示例
    BYTE originalData[3] = {0x50, 0xFF, 0xD2}; // 恢复数据示例

    if (flag) {
        // 执行Hook,写入hookData
        WriteProcessMemory((HANDLE)-1, (LPVOID)address, hookData, sizeof(hookData), NULL);
    } else {
        // 取消Hook,恢复原始数据
        WriteProcessMemory((HANDLE)-1, (LPVOID)address, originalData, sizeof(originalData), NULL);
    }
}

编译成功后,我们创建一个测试界面来调用它。例如,添加一个复选框控件,并将其点击事件关联到该函数。

测试时,勾选复选框,游戏画面将停止更新(表现为“黑屏”或画面冻结),CPU占用率显著下降。但游戏逻辑(如角色移动、寻路)仍在后台运行。


取消勾选后,画面显示恢复正常。第一种方式测试成功。


第二种优化方式:Hook Direct3D 9 API

上一节我们实现了针对特定游戏函数的Hook。本节中,我们来看一种更通用的方法——Hook Direct3D 9的API。

我们创建第二个函数,例如命名为 HookBlackScreen_D3D9。其结构与第一个函数类似,但Hook的目标地址和数据不同。

我们需要从D3D9动态链接库中动态定位目标函数地址。这通常通过获取模块基地址加上一个固定偏移量来计算。

// 动态获取D3D9函数地址示例
HMODULE hD3D9 = GetModuleHandle(L"d3d9.dll");
DWORD targetAddress = (DWORD)hD3D9 + 0xXXXXXX; // XXXX为特定偏移

Hook数据和恢复数据也需要相应更改。例如,Hook数据可能为 0xC2, 0x04, 0x00(即 retn 4),而原始恢复数据可能是 0x8B, 0xFF, 0x55

在测试界面中,我们可以用另一个复选框或复用之前的控件来调用这个新函数。

测试效果与第一种方式相同:开启优化后,画面停止更新,CPU占用率大幅降低(例如从40%降至4%左右);关闭优化后,渲染恢复。




两种方式对比与注意事项

我们已经完成了两种方式的代码实现与测试。现在对它们进行简单对比:

  • 第一种方式(Hook游戏函数)

    • 优点:针对性强,直接有效。
    • 缺点:通用性差,需要针对不同游戏进行具体分析,找到正确的函数和地址。
  • 第二种方式(Hook D3D9 API)

    • 优点:通用性较强,对于使用Direct3D 9的游戏都可能适用。
    • 缺点:需要处理动态链接库加载问题,且不同版本的D3D9库其函数偏移可能不同,需要做版本检测或兼容性处理。


在编写生产代码时,对于第二种方式,务必增加健壮性判断:

  1. 检查 d3d9.dll 是否已成功加载。
  2. 考虑不同系统或游戏版本下D3D9模块的差异。
  3. 添加适当的错误提示信息。


总结 🎯

本节课中,我们一起学习了两种实现游戏黑屏优化(关闭渲染)的代码设计方法:

  1. Hook特定游戏函数:通过修改游戏内部渲染相关函数的代码,直接使其失效。
  2. Hook Direct3D 9 API:通过修改图形接口层的函数,实现更通用的渲染关闭。

两种方法的核心都是通过 WriteProcessMemory 在内存中替换指令,并通过一个标志位来控制开启与关闭。我们完成了从分析、编码到测试的完整流程。

需要注意的是,这里教授的是方法原理。在实际应用中,需要根据具体情况调整地址、数据,并增加必要的错误处理和兼容性判断,做到活学活用。

本节课到此结束。

课程 P127:138 - 分析游戏气功对象数组及属性 🎮

在本节课中,我们将学习如何分析游戏中的气功对象数组及其属性。我们将从修复上一节课的代码Bug开始,然后使用调试工具定位并分析气功对象在内存中的结构,最终理解其数组的组织方式和关键属性。


修复代码Bug 🐛

上一节我们介绍了读取角色配置文件的代码。本节中我们来看看如何修复一个可能导致界面无法显示的Bug。

我们需要在读取文件前添加一个判断,确保文件成功打开后再执行后续循环。

以下是修复后的关键代码片段:

// 假设 filePath 是配置文件路径
FILE* pFile = fopen(filePath, "r");
if (pFile == NULL) {
    // 文件打开失败,直接返回,避免后续无限循环
    return;
}
// 文件打开成功,继续执行后续读取和循环逻辑

分析气功点数与对象 🔍

上一节我们修复了代码Bug,本节中我们来看看如何在游戏内存中定位气功数据。

首先,使用CE(Cheat Engine)附加到游戏进程。为了便于分析,建议选择气功点数较多的角色。

  1. 以“移花接木”气功为例,假设当前点数为14。
  2. 在CE中搜索14(字节类型)。
  3. 在游戏中给该气功加点,点数变为15,在CE中搜索变动的数值(15)。
  4. 反复此过程,可以筛选出存储气功点数的地址。

通过查看这些地址的内存区域,可以发现一个规律:它们以数组形式排列,每个字节代表一个气功的已加点数。例如,地址偏移可能对应如下结构:

地址A: 0x15 (移花接木 21点)
地址A+1: 0x14 (狂风万破 20点)
地址A+2: 0x0C (破天一剑 12点)
...

然而,这只是点数的数组。我们真正的目标是找到气功对象本身的数组,它包含名称、ID等更多属性。

直接搜索对象地址比较困难,因为对象可能在操作时动态生成。因此,我们转换思路,从游戏发送给服务器的数据包入手分析。


通过发包函数定位关键代码 📤

上一节我们尝试直接搜索对象未果,本节中我们来看看如何通过拦截游戏网络封包来定位关键代码。

使用调试器(如x64dbg)附加游戏,在发包函数上下断点。

  1. 在游戏中尝试给气功加点,断点触发。
  2. 通过堆栈回溯(Return to Caller),找到调用发包函数的代码位置。
  3. 在该位置上方,通常就是组织发送数据(包含气功ID等信息)的代码。

分析该段汇编代码,发现它从一个数组中取出数据。公式类似于:

对象地址 = [[基础地址 + 偏移A] + 偏移B] + 索引 * 4

其中,偏移B 经常是 0x410,这是一个在游戏中常见的数组基址偏移。


定位并分析气功对象数组 🧱

上一节我们找到了读取气功数据的代码,本节中我们来看看如何定位并分析完整的气功对象数组。

在调试器中单步执行,观察从数组中取出的数据。

  1. 确认取出的数据是一个对象指针。
  2. 查看该指针指向的内存,在偏移 +0x5C 处发现了气功的名称字符串(如“力劈华山”)。
  3. 在偏移 +0x4C 处发现了一个标识ID,这个ID很可能用于在数据包中唯一标识该气功。

通过遍历这个数组(例如,索引从0到7),我们找到了所有气功的对象。每个对象结构大致如下:

对象基址:
+0x00: 未知数据...
+0x4C: 气功标识ID (例如 0x13)
+0x5C: 气功名称指针
...

这个数组的基址(公式中的基础地址)是动态的。要可靠地获取它,需要分析其来源。回溯代码发现,它最终来源于一个更大的“所有对象数组”列表。


总结与下节预告 📚

本节课中我们一起学习了:

  1. 修复了文件读取的Bug,防止了界面卡死。
  2. 分析了气功点数数组,了解了其简单的字节存储结构。
  3. 通过拦截发包,定位了处理气功加点的关键代码。
  4. 定位并分析了气功对象数组,发现了其通过多层指针(基址+0x368+0x410+索引*4)访问的结构,并识别了对象中存储名称(+0x5C)标识ID(+0x4C) 的关键属性。

直接搜索气功对象数组的静态基址比较困难,因为它被嵌套在更复杂的对象管理结构中。

下一节课,我们将编写一个函数,通过特征码搜索或遍历“所有对象数组”的方式,动态地获取这个气功对象数组的基址,从而实现稳定的读取和修改。

课程 P128:139-动态定位搞定找不到的气功数组基址 🎯

在本节课中,我们将学习如何通过编写函数来动态定位游戏中无法直接找到的气功数组基址。我们将从分析内存结构入手,逐步构建搜索条件,并最终实现一个稳定、高效的定位函数。


概述

上一节课程中,我们在分析气功宿主时,未能直接找到其基址。本节我们将再次使用调试工具进行分析,如果直接搜索失败,我们将编写函数来动态定位目标数据。

首先,我们转到之前分析的地址。

如果选择动态定位,我们可以定位 EDXEBX 对象,或者定位之前 +4 的对象。我们将根据实际情况选择最方便的对象进行定位。现在,我们再次尝试寻找其基址。

既然之前的 EBX 基址找不到,我们可以尝试寻找 EDX 这个对象,看看其他地方是否保存了相应的基址。

我们清空当前数据(这里应该是 8,前面有两个参数)。现在,我们查看 EDX 的值并复制下来,然后用工具搜索这个对象。

这个对象地址是 31DEEF8。它可能也是所有对象数组里的一个数字。我们按访问记录查看,发现它也是在 311DDBZ8 处,由 EX018Z 指令断下的。因此,这里也不是我们要找的相应数据。

剩下的办法就是从这里开始查看其属性。我们从最近的开始找,从 EF 开始找可以节省时间,因为它离我们的对象应当最近。如果这里找到了,我们只需要加上一个 0x410 偏移即可(这是“运气疗伤”的偏移)。加上 0xC 我们查看一下,确认是“运气疗伤”。

我们有两种方法可以用来搜索到这个对象:

  1. 直接读出这个对象,加上 0x410,再加上 4 的偏移(“神隐屏”)来判断它的第一个对象。
  2. 判断第一个对象,因为它也有一个对应的类别。+8 这个位置等于 0x1D。如果这里等于 0x1D,我们再查看它之前对象的 +8 位置是多少。

我们记一下:气功对象的 +8 位置是 0x1D。这是我们的搜索条件之一。

我们在备注里搜索 0x410 这个偏移。这个对象等于 0x1D(条件一)。我们再来看 Base 这个地方,它等于 0x1F(条件二)。

我们再看一下 +8 这个位置的数字是否会变化,或者是否有特殊的数字。例如,这里有一个 1,但这看起来像是一个地址,具有特殊性,可能也是动态的。我们能够利用起来的可能也是这个地方。这个值通常是 1,但游戏更新后它也可能变化,因为它是对象的成员函数。唯一可能不变的是 +4 这个地方的地址。

所以,这个地方应当与我们之前 ESI 的类型类似。我们还是从后面的对象里来找答案,即 +0x410 这个地方。

我们看一下它里面有哪些比较特殊的属性。加上 0x410 后,它是否有下标属性?我们看一下,第一个是 1,第二个的 1 也是 1,第三个是 -1。这几个值应当是固定的。我们复制一下。

如果加上它的成员函数列表,我们应当就能搜索到特征了。我们再来看第三个地方,也是 0x1D。当然,这是它的一个下标 0EFF1。这一片都是相同的判断。

如果加上这个列表,肯定能够搜索到特征。它相当于是一个特征码,或者可以通过其对象的成员函数地址来进行判断。例如这个地址就比较有利。但在这里定义和判断起来比较复杂,我们也需要动态生成特征码,然后每次对对象 +4 的地方再进行判断。

我们先看一下简单的设计能否参考。我们再来看第三个对象,找一下它是否有下标属性。这里有一个 A83。我们加上 4 再加上 A8 看一下,那么 4 的下标也是 3

理论上它还有一个树状的下标属性。我们从这个地址用工具搜索一下数字 4,也可以做成一个判断条件。我们新扫描,搜索数字 4,起始地址是 2310D6202311 离得太远。我们再搜一下数字 5,没有。说明下标属性不存在,但不存在下标,它直接就是我们树状下标计算出来的结果。

我们先编写函数进行一下判断,定位 BASE

打开我们第138课时的代码,移到测试页面。

然后开始设计我们的代码,放在“测试1”这里。在设计代码时,我们需要对所有对象的列表进行遍历。我们先把它的基址取出来,然后这里需要一个循环。我们看一下,它的对象数组会有多大。

因为遍历的地方比较多,马上就会触发断点,先把断点删掉。整个树状数组,我们看一共应该有多大。这里应该是 0x2710 个元素。我们在这里也设置 0x2710。当然也可以把它设置成一个常量,即最大的数字。这里我们暂时可以固化使用,或者说也设置一个常量:

#define SIZE_FOR_OBJECT_LIST 0x2710

当然,在这里我们也加上异常处理。如果访问出错,我们就进行处理。异常处理应该放在循环里面。一旦有异常,我们就继续执行下一次循环。

在这里我们遍历我们的对象。首先,我们需要建立一个变量 nTempObject,先把它赋值为 0。然后在这里先取出对象:

nTempObject = *(DWORD*)(BaseAddr + i * 4);

这是我们对象的一个地址。取出来之后,接下来就是判断是否是我们要找的对象。

宿主对象基址有一个明显的属性:+8 在这个地方等于 0xEF。所以说我们要先取出对象的类型:

DWORD dwType = *(DWORD*)(nTempObject + 0x8);

后边的话,这里我们最好把它设置成一个指针的形式。注意这里一定要加上括号,不然就成了一个指针的运算,得出来的数据就不对。

在这里我们再取出它 +8 的这个位置(恰好等于 2+1 是等于 4048,这个位置我们取出类型)。取出来之后,我们就进行一个判断:

if(dwType == 0xEF) // 条件1达成

如果 0xEF 这个条件达成了,再判断条件2。条件2是去 +8 这个地方,取对象的 +8 这个地方,它等于 0x1D。但这个条件2需要一个公式:前面的基址 +0x410+4,实际上就是 +0x410 这个地方,再读取这个对象。

我们先把另外一个对象取出来,即对象1的第一个元素。我们把公式贴出来,也就是计算出这个元素的地址并取出来:

DWORD dwPossibleObjAddr = *(DWORD*)(nTempObject + 0x410);

计算出来之后,等会我们再读取它里面的 +8 的位置。要取这个数据,我们需要先转换地址。0x410 这里,我们需要先转换成 DWORD 类型,再加上我们的偏移 0x410,最后再转换成指针类型:

DWORD dwTargetObj = *(DWORD*)( (BYTE*)nTempObject + 0x410 );

这个时候我们就得到了第一个对象,也就是 +0x410 偏移处的对象地址(正常情况下,会是“金钟罡气”对象的地址)。

获得这个对象地址之后,我们再次取出它的 +8 位置的类型:

DWORD dwTargetType = *(DWORD*)(dwTargetObj + 0x8);

我们在这里再次做一个判断,需要它等于 0x1D

if(dwTargetType == 0x1D) // 条件2达成

这个时候判断,可能也有相同的对象,这个地方也等于 0x1D。所以我们还需要更进一步的一个判断,来看一下需要什么。当然,我们最好是先看一下这个地方,如果这个 0x1D 就是标明我们气功的唯一对象ID类型,那么我们直接这样就可以找出来。现在我们担心的是还有其他对象也用到了 0x1D 这个对象ID来标示。

我们先运行一下,如果找到了这个地方,我们先把对象的地址打印出来。打印出来我们应该是哪一个基址?我们来看一下,应该是最之前的这个 Base

我们在这里另外定义一个变量来存放,这个需要是一个静态变量:

static DWORD s_dwQiGongBase = 0;

最先我们把它赋值为空或者是零。当然,在这里的时候,如果是找到了之后,我们就给它重新赋值。第二次的时候,我们给它进行一个判断。如果这个 s_dwQiGongBase 不等于空,那么我们直接返回就可以了。这个地方我们直接返回这个值。

现在,我们最好把它放在一个函数里面去,设计成一个函数 GetQiGongBase()。如果执行到最后都没找到,那么这里直接返回空;或者这里找到了,也是返回相应的数值。

我们再看一下我们的设计。这里还需要定义一个变量,用来临时存放这个可能的基址。我们取得的,如果运气好,就会取得相应的基址。当然,我们需要读出来。实际上我们这里需要把这个数值读取出来,赋值给这个指针。

或者这里直接这样写,之后我们要做一个判断:如果这个值等于 0,那么我们 continue,继续下一次循环。也就是说,它没有这个对象,这个地址是空的。如果不为空,我们进行赋值。

首先,我们是获得它的基址,+4+4 这样加过去之后,我们需要用这个语句。这个语句,我们这里只是指向了某一个基址,指向了之后,我们通过指定的操作,需要读出这里面对象的数据。

例如这个 ERCF8,读出来之后,这是另外一个对象的地址。

就存放在这里面了。存放在这里面之后,这里我们再对它进行一个读取的操作,+8 这个位置读取出来,进行一个判断。如果等于 0xEF,那么我们接着后面的操作。

这里就应该是 nTempBase 加上我们的 0x4100x410 之后,这个时候它转换了,实际上也是一个指针。nTempBase 这个时候已经变成了相应的地址,加上 0x410,然后再取对象的 +8 的位置。

如果这两个条件我们都达成,那么我们就给静态变量进行一个赋值,赋值为我们的 nTempBase。这个 nTempBase 就是我们之前所取出来的对象,也就是列表里面的某一个数字。如果这个是的话,那么它的 +8 这个位置就应当是 0xEF,然后它里面 +0x410 这个位置是 0x1D。取出来之后,这个时候我们就返回它。

好的,我们看一下是否能够取得我们正常的基址。如果不行的,我们还需要加上其他的条件。

我们编辑生成一下。这里我们需要强制把类型转换一下。我们测试一下,这里找到的数字是 0。我们看一下为什么会是 0,正常情况下都应该不是 0 的,可能是我们的代码有误。

首先,我们是取得对象的地址取出来,对象地址我们 +8 的这个位置取出来。我们首先看一下,在这里我们取出的数字对不对,dwType 遍历的类型。还有条件2,这里的 dwTargetType

我们重新生成一下,先清零,然后这里就出错了。可能是我们只遍历到了这一部分数据,条件2这里取出来的这个类型一直为 0。看来是条件2一直没有达成。这里的这个类型...

我们再看一下我们的公式。条件1取出来的这里是正确的,我们应当把它放在这个地方。0xEF 这个判断成立了之后,我们再把它的这个类型打印出来,这是我们的条件1。如果条件2达成的话,应该是在这个地方。

看这个条件2的话,它一直取出来的是 0,没有达成。我们看一下语句,可能是出现在这个地方。首先我们 nTempBase 是取出了对象的地址,对象的地址加上了 0x410。那么这里的话,我们还应当要取一道:要取这个对象的话,需要有一个“读”的动作,需要先把它读取出来,然后再进行条件1的判断。

这个时候找到了一个 ES3710,我们附加一下游戏。

加上我们的 0x410 来看一下。但是我们发现的话,这个也不对,这里它为 0x20。我们再来看一下我们的信息,这里应该是 ES,我们这里写错了,ES3710,这里是 0xEF。那么 0xEF 再加上一个 0x410,那么这里可能就是我们的这个宿主。

再加上一个,我们用连线来看一下,“金钟罡气”。

后来这里再加上一个 0x4100x410 “运气疗伤”、0x410*2 “连坏分身”、0x410*3 “狂风万破”。那么看来是不需要我们其他的一个条件的判断了,就是这两个条件我们就能够找到。

我们回到之前的这个地址,从这里做一下核对,看我们找到这个对象是否正确。这里我们之前有下一个断点,看来这个断点被取消掉了。幸好我们没有修炼这个气功,如果是修炼气功的话,这里就会异常退出。

我们在这里把它 Hook 住这个地址。好的,我们再想办法让它断下来,看一下我们的 EBX 找得对不对。然后我们在这里随便狂升几下,下标应该是 67... 1,2,3,4,6,6,77 的话它的下标是 6。然后我们看一下 EBX 的一个数值。

这个时候的话,EBX 是前一个的数值。我们还需要这里有个 EX,看没有被执行到,气功ID。那么这里的话应该还有另外的一段数据。但是我们这里看一下我们原来的代码是什么,我们刚才复制出来的 70870B,然后这是我们的 715715,这里是这段指令。我们把它恢复一下,那么这里应该是这段指令才对,然后 EX

所以说我们应该是找 EDX 取出来。我们看一下 EDX 的,恰好就是我们找的 ES...,再来对比一下。那么这里是 ES37EB0,恰好我们宿主的基址是找对了。找对了,那么就 OK 了。

那么这节我们就讲到这里,气功的基址我们就已经找对了。把它注释一下,这个地方通过什么样的方式来获取气功的对象。实际上我们不是找到的这个 BASE,而是找到的这个地址。我们已经把它的地址基址取出来了,那么我们就通过这个公式来获取它下面的这些数据。

在这个地方,我们注释一下,或者我们可以这样来表达:

// 气功对象地址 = 基址 + 下标 * 0x410
// 气功ID = 气功对象地址 + 0x4C

当然还没有发现其他还有用的数据,我们暂时还没有发现。那么这几个我们暂时就只有分析到这里。

当然我们还可以来分析一下它的其他的属性,暂时还没有发现有用的属性。那这里有个 1,那么 1 会不会是表示它的可用这一类的?我们来看一下。那么这里的话,它一共 8 个,倒数第三个的话,它的下标是 5。我们看一下第五个是否可用,这里也是 1

是否可用的话,它应当有一个标记。如果这里没有标记的话,我们可以把它与之前加点的这个数据关联起来,就能够获得它相应的下标所在的位置,已经加了多少个点。我们在后面的分析当中再来完成。实际上我们之前很早以前就已经有这个数据。当然我们上一期的分析的时候:

实际上已经找到了这几个数据,但是当时没有记录下来。好,那么这几个我们的这个数据的基址的话,已经通过我们的编程过去了。后边我们再来分析这个气功加点的相关的函数。

复制一下。

这样写的好处是什么?我们还有一个就是修正 Bug,不需要了。那么这里我们用了一个静态变量,它的好处是,不会反复地去遍历。一旦为空,只要注入游戏之后,我们只需要遍历一次,那么第二次的时候它不为空,我们直接就返回这个基址了,后面再多就省略了。这样的算法就非常优化。如果不加上在前面这一段的话,那么每次都会去找这个循环,这样的算法就非常低下。相当于我们在这里是给它缓存了一个基址。

好的,那么我们下一节课再见,这节课我们就到这里。

【过去的事物】。


总结

本节课中,我们一起学习了如何通过动态定位的方法解决找不到气功数组基址的问题。我们分析了内存结构,确定了两个关键搜索条件(对象类型 0xEF 和其内部 +0x410 偏移处对象的类型 0x1D),并编写了一个遍历对象数组、使用静态变量缓存结果的函数来高效稳定地获取目标基址。这个方法避免了每次重新扫描,提升了代码性能。

课程P129:140-分析气功加点CALL及参数 🎮

在本节课中,我们将学习如何分析游戏中的“气功加点”功能所调用的CALL及其参数。我们将使用调试器,通过逆向工程的方法,定位关键代码、理解参数结构,并最终整理出可用的调用方法。


准备工作 🛠️

开始分析前,需要准备一个游戏账号。该账号的气功剩余点数至少需要两点以上,点数越多越便于反复测试。

首先,使用调试器附加到游戏进程。

我们将延续上一节课的分析进度,继续深入。


定位气功数组与关键CALL 🔍

上一节课我们分析到了气功数组的相关地址。

我们转到该地址进行进一步分析。

通过回溯调用栈约3-4层,我们找到了一个关键位置。当时我们分析出 EAX 寄存器的值来源于气功的身份标志。我们按下 Ctrl+A 进行分析,并在该地址下断点。

然后,在游戏中点击第二个气功进行“加点”操作,程序会断在断点处。此时观察 ECX 寄存器的值,它似乎是 EAX 的来源。EAX 应该是上层某个函数的返回值。

我们需要进入这个CALL内部查看其实现,以便修改其逻辑,使得加点操作不会真正消耗点数,从而可以反复测试。


分析参数来源 📊

我们继续在前一层调用处下断点,再次执行加点操作并中断。观察 ECX 的数值,发现它最终来源于上层 ECX 寄存器的值加上 0x4C 的偏移。

进入该CALL内部查看,确认数据的确来源于 ESI,而 ESI 又来自上层的 ECX 加上 0x4C。因此,我们可以确定一个参数来源于 对象基址 + 0x4C,我们暂时将其命名为“服务器的身份标识”或“服务器ID”。


分析气功下标参数 🔢

我们需要分析的参数较多,其中 ECX(或 EDI)是关键。再次执行加点并查看 ECX 的值,发现它包含对象的ID信息。

我们返回到上一层CALL,取消当前断点,在更早的调用处(地址 0x420 附近)下断点。这个CALL负责获取对象ID。

中断后,观察 EDI 寄存器的值。我们发现 EDI 的值是一段连续的数字(如 0x12c, 0x12d, 0x12e, 0x12f...),其数量恰好与气功数组的数量(32个)对应。

通过在不同气功上加点测试,发现 EDI 的值遵循一个规律:基础值 + 下标。基础值是 0x12C,下标从0开始。因此,EDI 的计算公式为:

EDI = 0x12C + 气功下标

其中,气功下标范围是 0 到 31,对应32个气功槽位。


分析ECX(ESI)参数来源 🧩

另一个关键参数是 ECX,它来源于 ESI。我们观察 ESI 的值在执行过程中是否变化。通过多次测试,发现 ESI 的值保持不变,而 EDI 的值随气功下标变化。

为了测试CALL是否有效,我们首先需要撤销之前对CALL内部的修改(否则加点不会成功)。然后,我们可以构造参数进行调用测试。

使用代码注入器,我们构造如下调用(以给下标为0的气功加点为例):

MOV EDI, 0x12C        ; EDI = 基础值 + 下标0
ADD EDI, 0            ; 明确加上下标0,等同于 MOV EDI, 0x12C
MOV ECX, [ESI]        ; ECX 来源于 ESI 指向的值
CALL 0x0070F670       ; 气功加点CALL的地址

测试发现,调用成功,气功点数被扣除。我们可以通过改变 EDI 中的下标值(0x12C + N)来给不同的气功加点。


追溯ESI的最终来源 ⛓️

接下来,我们需要找到 ESI 的最终来源,以便完整地构造调用。

我们对保存 ESI 值的内存地址下“访问”断点,回溯其写入过程。发现它来源于上一层函数的 [EBP+8] 参数。

继续向上层回溯,最终发现 ESI 的值来源于一个全局的游戏对象数组机制。通过多次下访问断点和分析,我们定位到一个关键的基址,我们称之为“气功对象基址”。

最终确定,ESI 的值来源于以下公式:

ESI = [气功对象基址] + 0x284

其中,“气功对象基址”是一个需要通过指针遍历找到的全局地址。


完整调用示例与总结 📝

综合以上分析,一个完整的气功加点CALL调用需要以下参数和步骤:

  1. 获取气功对象基址:通过游戏全局结构找到。
  2. 计算 ESIESI = [气功对象基址] + 0x284
  3. 计算 EDIEDI = 0x12C + 气功下标 (下标 0~31)
  4. 设置 ECXECX = [ESI]
  5. 调用CALLCALL 0x0070F670

以下是给下标为6的气功加点的代码示例:

// 假设气功对象基址已存储在变量 g_pSkillObj 中
MOV ESI, [g_pSkillObj]   // 获取基址
ADD ESI, 0x284           // ESI = 基址 + 0x284
MOV ECX, [ESI]           // ECX 参数
MOV EDI, 0x12C           // 基础值
ADD EDI, 6               // EDI = 0x12C + 6 (下标6)
CALL 0x0070F670          // 执行加点CALL


课后作业与练习 📚

本节课我们一起学习了气功加点CALL的分析方法、参数构造及调用过程。

作为练习,请你独立完成以下任务:

  1. 在游戏中定位并验证“气功对象基址”。
  2. 编写一个完整的函数,能够根据给定的气功下标,成功调用加点CALL。
  3. (进阶)思考如何将此调用整合到自动化的脚本或程序中,并注意安全调用,避免下标越界。


通过完成这个练习,你将更深入地理解游戏函数的逆向分析与调用。

逆向教程 P13:024-选怪功能分析 🎯

在本节课中,我们将学习如何通过逆向工程分析游戏中的“选中怪物”功能。我们将探讨该功能可能的实现方式,并使用调试工具定位关键的内存地址和代码。


概述

选中怪物功能通常有两种实现方式。第一种是向某个全局变量写入目标怪物对象的地址。第二种是向对象内部的某个属性(例如怪物ID或标识符)写入特定值。这两种情况都很常见。

我们将使用调试器来搜索并确定游戏具体采用了哪种方式。无论哪种情况,只要在选中或切换怪物时,相关变量或属性的值就会发生变化。我们可以利用这一点进行分析。


分析步骤

第一步:定位变动的数据

首先,我们尝试搜索未知的初始值,因为此时我们不知道具体的数值是多少。首次扫描后,只要我们不改变选中的目标,该数值就不会变动。

这种未变动的数值可以多次扫描,但速度较慢。

更快的办法是主动更改选中的目标。选中一个怪物后,搜索变动的数值,这样可以快速过滤掉大量无关数据。

以下是具体的操作流程:

  1. 首次扫描未知的初始值。
  2. 更改一次选中的怪物,然后搜索变动的数值。
  3. 再次更改选中的怪物,继续搜索变动的数值。
  4. 重复此过程,不断缩小范围。

我们也可以选中自己的角色(数值可能变为0)或取消选中(数值可能变为-1)来辅助搜索。最终,我们会得到几个可能性较高的内存地址。

第二步:分析写入指令

将可疑地址添加到监视列表,并对第一个地址查找“访问了该地址的代码”。然后尝试选中一个怪物,观察哪些汇编指令被执行。

我们会发现,大部分代码是对堆栈进行操作,这种可能性较低,因为堆栈地址(如从0x18开始的地址)通常是临时的。同时,我们也会注意到有些地址的数值在不停变化,即使我们没有进行任何操作,这些地址也应被排除。

经过筛选,最终可能只剩下两个关键地址。其中一个地址在选中怪物时,有明确的写入指令(如MOV [ECX+14], EAX),这很可能就是向“选中怪物变量”写入数据的代码,需要重点分析。

第三步:验证功能

关闭调试器,重新附加到游戏进程,然后转到上一步找到的写入指令地址。

在此处设置断点或修改代码进行测试。例如,尝试修改写入的值,观察游戏内选中怪物的行为是否发生变化。

测试发现,当向该地址写入特定怪物的ID时,角色会攻击该怪物,而不再攻击显示血条的怪物。这证实了此处就是实现选中怪物功能的关键代码。

写入的值来源于寄存器ESI,而ESI指向怪物对象。通过分析怪物对象结构,我们发现偏移+0x8处是一个标识对象类型的值(例如,0x21代表怪物,0x31代表玩家)。这正是用于区分目标类型的标识符。

第四步:确定上下文对象

我们还需要弄清楚,是向哪个对象的地址写入了怪物ID。回溯代码发现,写入的基础地址(ECX)来源于一个上层对象。

分析这个上层对象的内存结构:其偏移+0x8处的类型标识是0x31(玩家),偏移+0x18处存储着玩家自己的名字。因此,这个基础地址就是我们自己角色(玩家)的对象地址。

核心概念总结如下:

  • 玩家对象地址: 存储着我们自己角色的数据。
  • 选中怪物偏移: 玩家对象地址 + 0x14 处存储着当前选中怪物的ID。
  • 怪物类型标识: 怪物对象地址 + 0x8 处的值(如 0x21)标识了它是一个怪物。
// 伪代码示例
DWORD playerObjAddr = GetPlayerObjectAddress();
DWORD selectedMonsterId = ReadMemory(playerObjAddr + 0x14);

功能扩展:显示血条

在之前的课程中,我们分析过怪物显示血条的功能。它与怪物对象偏移+0x314处的值相关。当该值为1时,怪物会显示血条。

因此,在实现选中怪物功能时,可以同时向怪物对象的这个地址写入1,以触发血条显示。


总结

本节课中,我们一起学习了如何逆向分析游戏的选怪功能。我们通过搜索内存变动定位了关键地址,分析了写入怪物ID的汇编指令,并最终确定了该功能依赖于玩家对象中的一个特定偏移(+0x14)来存储选中目标的ID。

我们还回顾了怪物对象的类型标识(+0x8)和血条显示开关(+0x314)等关键属性。下一节课,我们将基于这些分析结果,对选怪功能进行代码封装和实现。


逆向工程课程 P130:游戏多开分析 🎮

在本节课中,我们将学习如何分析一款游戏程序阻止多开的机制。我们将通过逆向工程的方法,定位到程序检测已运行实例的关键代码,并探讨绕过此限制的思路。

概述

游戏多开限制通常通过多种技术实现,例如枚举窗口、查找窗口标题或类名,以及创建共享内存等。本节课,我们将针对一个具体的游戏登录器进行分析,它会在检测到游戏已运行时弹窗提示,阻止第二个实例启动。

多开检测的常见方法

游戏程序阻止多开通常会使用以下几种方法进行检测:

  • 枚举窗口:使用 EnumWindows API 遍历所有顶层窗口。
  • 查找窗口:使用 FindWindowFindWindowEx API,通过窗口标题或类名查找特定窗口。
  • 进程枚举:使用 CreateToolhelp32Snapshot 等函数枚举系统进程。
  • 共享内存/互斥体:使用 CreateFileMappingCreateMutex 创建命名的共享对象。如果创建失败(错误码指示已存在),则说明已有实例在运行。

定位弹窗代码

当尝试启动第二个游戏登录器时,程序会弹出一个提示窗口。在Windows编程中,创建此类消息框常用的API是 MessageBox 及其变体。

以下是创建消息对话框的相关API函数:

  • MessageBoxA / MessageBoxW
  • MessageBoxExA / MessageBoxExW
  • MessageBoxIndirectA / MessageBoxIndirectW
  • DialogBoxParam / CreateDialogParam
  • CreateWindowExA / CreateWindowExW

理论上,我们可以对这些函数下断点。当程序弹窗时,断点将被触发,通过调用栈回溯,我们就能找到触发弹窗的判断逻辑所在的位置。

动态调试与分析

上一节我们介绍了定位弹窗的思路,本节中我们来看看具体的调试过程。

我们使用调试器加载游戏登录器,并对 MessageBoxA 等函数下断点。运行程序后,断点成功命中,我们看到了弹窗。

通过执行“执行到返回”操作并分析调用栈,我们逐步回溯代码,最终定位到一片可能负责检测的关键代码区域。该区域包含一个对 CreateFileMappingA 函数的调用。

CreateFileMappingA 函数用于创建或打开一个命名的文件映射对象。其关键特性在于:如果尝试创建一个已存在的命名对象,函数会失败,并通过 GetLastError 返回特定的错误代码。

以下是相关的代码逻辑示意:

// 尝试创建或打开一个命名的文件映射
HANDLE hMap = CreateFileMappingA(INVALID_HANDLE_VALUE, NULL, PAGE_READONLY, 0, 1, “Global\\MyGameInstance”);
if (hMap == NULL) {
    DWORD dwError = GetLastError();
    if (dwError == ERROR_ALREADY_EXISTS) { // 错误码 0xB7 (183)
        // 对象已存在,说明游戏已在运行
        MessageBoxA(...); // 弹出阻止多开的提示
    }
}

分析汇编代码后发现,程序正是利用了这一机制。它尝试创建一个具有特定名称的共享内存。如果创建失败且 GetLastError() 返回 0xB7(即 ERROR_ALREADY_EXISTS),程序就判定已有实例运行,继而执行弹窗代码。

尝试绕过检测

找到了检测逻辑的核心,即对 GetLastError() 返回值是否等于 0xB7 的判断,我们就可以尝试修改程序行为。

以下是两种简单的修改思路:

  1. 修改判断条件:将汇编指令中比较错误码是否为 0xB7 的指令,改为一个永远不会成立的值(例如 0xB1)。
  2. 修改跳转指令:直接修改关键的条件跳转指令(如 JNZJZ),使其强制跳转或不跳转,从而绕过弹窗流程。

我们在调试器中尝试了第二种方法,修改了关键跳转。修改后,第二个登录器窗口成功启动,绕过了第一道多开检测。

总结与后续

本节课中我们一起学习了游戏多开检测的一种常见实现方式——通过命名共享对象进行实例检测。我们掌握了从弹窗入手,利用调试器回溯关键代码,定位到基于 CreateFileMappingGetLastError 的判断逻辑,并尝试通过修改汇编指令来绕过此限制。

需要注意的是,本次分析仅绕过了登录器层面的多开检测。成功打开第二个登录窗口后,在尝试登录游戏客户端时,程序依然报错。这说明游戏客户端内部很可能还存在另一套或多套检测机制。

在下一节课中,我们将继续深入分析,探索客户端内部可能存在的其他多开防护手段。

课程 P131:游戏启动过程分析 🎮

在本节课中,我们将学习如何分析一个游戏的启动过程。掌握这一过程,能为后续制作自动登录工具、游戏登录器,以及分析游戏多开限制打下基础。

准备工作与核心概念

上一节我们明确了课程目标,本节中我们来看看需要准备哪些知识。分析游戏启动,关键在于理解Windows系统创建外部进程的几个核心API函数。

以下是三个常用的进程创建函数及其Unicode宽字符版本:

  • WinExec
  • ShellExecute / ShellExecuteEx
  • CreateProcess / CreateProcessW

最终,这些函数通常都会调用 CreateProcessCreateProcessW 来完成实际的进程创建工作。因此,CreateProcessW 是分析启动流程的必经之路。

实战分析:下断点与追踪

了解了核心函数后,我们进入实战环节,使用调试器对游戏启动过程进行动态分析。

首先,打开调试器并附加到目标程序。待相关系统模块加载后,对 CreateProcessW 函数下断点。这是最关键的断点,能捕获到最终的进程创建调用。

为了更全面地观察启动链,我们也可以对 WinExecShellExecute 等相关函数下断点。

设置好断点后,通过游戏登录器选择服务器(例如“网通四区”)并点击“开始”。此时,调试器会在 CreateProcessW 处中断。

从调用堆栈和函数参数中,我们可以观察到关键的启动信息。最重要的参数是 命令行(Command Line),它包含了程序路径和启动参数。例如,我们可能看到类似这样的结构:

"D:\Game\Client.exe" 120.0.0.1 10031 0 0 123456789

其中,120.0.0.110031 很可能是服务器地址和端口,后面的数字可能是动态生成的令牌或标识。

让程序继续运行,可能会观察到它先调用了 ShellExecuteW,最终再调用 CreateProcessW 完成启动。记录下 CreateProcessW 中的完整命令行参数。

应用:提取参数与独立启动

上一节我们捕获了启动参数,本节中我们来看看如何利用这些参数。

获取到完整的命令行后,我们就可以脱离原版登录器,直接启动游戏客户端。具体操作是:在命令行或批处理文件中,使用获取到的完整命令。

例如,直接运行:

"D:\Game\Client.exe" 120.0.0.1 10031 0 0 123456789

这种方法的意义在于:

  1. 制作自定义登录器:我们可以编写程序,模拟登录器行为,直接调用 CreateProcessW 并传入对应的服务器参数来启动游戏。
  2. 绕过登录器多开限制:如果游戏的多开检测机制集成在官方登录器中,那么通过自定义登录器直接启动客户端,有可能绕过该限制。

我们可以通过选择不同的游戏区服,重复上述分析过程,来获取不同区服的启动参数。通过对比可以发现,命令行中通常只有服务器地址/域名和少数几个参数会随区服变化,其余参数可能固定或随时间动态生成。

总结与展望

本节课中我们一起学习了游戏启动过程的分析方法。

我们首先认识了创建进程的核心API函数,特别是 CreateProcessW。接着,通过调试器下断点实战,追踪并提取了游戏启动时的关键命令行参数。最后,我们探讨了如何利用这些参数实现独立启动游戏客户端,并阐述了其在制作登录器和分析多开机制方面的应用价值。

通过本课的学习,你已经掌握了分析程序启动流程的基本技能。后续课程中,我们将在此基础上,进一步深入分析游戏的具体保护机制和实现更复杂的工具。

课程 P132:编写游戏登录器 🎮

在本节课中,我们将学习如何分析并编写一个简单的游戏登录器。我们将通过理解游戏客户端启动的原理,并调用关键的 Windows API 函数来实现这一功能。


回顾与分析 📝

上一节我们分析了游戏登录的流程,了解了游戏客户端启动的基本原理。我们知道,通过调用特定的 API 函数并传递正确的参数,可以启动游戏。

因此,理论上我们可以自己编写一个登录器。首先,我们需要获取并理解游戏启动时传递的参数。


获取启动参数 🔍

为了获取启动参数,我们使用调试器加载游戏的登录器,并分析其调用 ShellExecuteEx 函数时传递的数据。

以下是分析步骤:

  1. ShellExecuteEx 函数处设置断点。
  2. 运行程序,在选择游戏大区时,程序会对参数进行初始化。
  3. 点击“游戏开始”后,程序会调用 API 启动客户端,此时我们可以查看具体的参数。

通过分析,我们得到了一个 SHELLEXECUTEINFO 结构体,其中包含了启动游戏所需的关键信息,如文件路径、参数和工作目录。


编写登录器程序 💻

有了参数数据后,我们就可以开始编写自己的登录器了。我们使用 Visual Studio 创建一个基于对话框的 MFC 应用程序。

以下是创建界面的步骤:

  1. 创建一个新的 MFC 应用程序项目,选择基于对话框。
  2. 调整对话框大小,并添加按钮控件,例如“网通一区”、“网通二区”、“网通三区”和“登录”。
  3. 为这些按钮添加相应的事件处理函数。

核心代码实现 ⚙️

登录的核心是调用 ShellExecuteEx 函数。我们需要先定义一个 SHELLEXECUTEINFO 结构体变量,并用获取到的参数填充它。

以下是关键代码结构:

void CMyLoginDlg::OnLogin(int nZoneIndex)
{
    SHELLEXECUTEINFO sei = {0};
    sei.cbSize = sizeof(sei);
    sei.fMask = SEE_MASK_NOCLOSEPROCESS; // 例如 0x40
    sei.hwnd = NULL;
    sei.lpVerb = _T("open");
    sei.lpFile = _T("GameClient.exe"); // 游戏客户端路径
    sei.lpParameters = _T("-zone 4"); // 启动参数,随大区变化
    sei.lpDirectory = _T("C:\\Game\\"); // 游戏工作目录
    sei.nShow = SW_SHOW;

    ShellExecuteEx(&sei);
    
    // 等待游戏进程结束
    if (sei.hProcess)
    {
        WaitForSingleObject(sei.hProcess, INFINITE);
        CloseHandle(sei.hProcess);
    }
    
    // 登录器隐藏或退出
    ShowWindow(SW_HIDE);
    // ... 其他清理代码
}

代码说明

  • sei.cbSize: 必须设置为结构体大小。
  • sei.lpParameters: 此参数需要根据所选游戏大区进行动态更改。例如,网通一区、二区、三区对应的服务器地址或编号不同。
  • sei.lpDirectory: 需要设置为游戏客户端的实际工作目录,确保使用双反斜杠 \\ 表示路径。
  • sei.nShow: 设置为 SW_SHOW 以正常显示游戏窗口。
  • 调用 ShellExecuteEx 后,我们使用 WaitForSingleObject 等待游戏进程结束,这样登录器会在游戏退出后才关闭。

测试与完善 🧪

编写完成后,我们编译并运行程序进行测试。

测试流程如下:

  1. 点击对应大区的按钮(如“网通四区”),程序会初始化对应的启动参数。
  2. 点击“登录”按钮,调用 ShellExecuteEx 启动游戏。
  3. 登录器界面会隐藏,并在后台等待游戏进程。
  4. 当游戏客户端退出时,登录器进程才会随之结束。

如果测试成功,意味着我们的基础登录器已经可以工作。要完善它,我们还需要:

  • 为每个大区获取并设置正确的 lpParameters
  • 动态获取游戏客户端的安装路径,而不是硬编码。
  • 优化界面和用户体验。

总结 📚

本节课中,我们一起学习了如何分析游戏启动参数,并动手编写了一个简单的游戏登录器。我们掌握了使用 ShellExecuteEx API 启动外部程序的方法,并实现了登录器等待游戏退出的逻辑。

核心要点包括:

  • 理解 SHELLEXECUTEINFO 结构体的填充。
  • 掌握 ShellExecuteEx 函数的调用。
  • 使用 WaitForSingleObject 实现进程等待。

通过本课的学习,你已经掌握了编写自定义登录器的基本技能。你可以尝试进一步完善它,例如从配置文件读取服务器列表,或增加自动更新功能。

课程 P133:游戏多开实现 - 绕过登录器检测 🎮

在本节课中,我们将学习如何通过修改游戏客户端和登录器的代码,绕过其多开检测机制,从而实现游戏的多开功能。我们将从效果演示开始,逐步分析其实现原理,并介绍关键的技术步骤。

概述

我们首先展示了一段测试代码的效果。这段代码在启动游戏客户端的同时,实时修改了其部分代码,从而绕过了登录器的多开检测。通过这种方式启动的客户端,可以正常登录游戏,而不会触发多开的限制提示。

接下来,我们来看第二个游戏实例。此时,游戏不会出现任何多开提示,能够正常登录。如果结合游戏优化功能以降低CPU占用率,在内存充足的情况下,理论上可以实现十开、二十开,甚至更多。

实现原理分析

上一节我们展示了多开功能的效果,本节中我们来看看其背后的实现原理。经过分析,游戏的检测机制主要涉及两个方面。

登录器检测绕过

如果我们正常启动游戏,不绕过其多开保护,当尝试打开第二个登录器时,程序会提示“请关闭运行中的程序”。这个检测逻辑位于一段特定的代码段中。

以下是绕过此检测的核心思路:

  • 我们需要跳过一条 jnz(条件跳转)指令。
  • 该指令原本用于比较一个值,如果比较结果不等于特定值(例如 B7),则跳转,表示客户端已打开。
  • 我们的目标是将 jnz 指令修改为 jmp(无条件跳转)指令。
  • 这样修改后,无论比较结果如何,程序都会执行跳转,从而绕过“已打开”的判断。

具体操作是将机器码 75(对应 jnz)修改为 EB(对应 jmp)。

客户端检测绕过

仅仅修改登录器检测还不够。游戏客户端本身也存在检测机制。经过测试,该检测可能是基于游戏客户端的运行路径。

如果检测到相同路径下的客户端进程已经启动,新的客户端将无法登录。因此,我们需要为每个新启动的客户端指定一个不同的运行路径。

技术实现步骤

以下是实现多开的关键步骤概述。

1. 修改登录器检测代码

我们需要在登录器的主线程代码加载后、但尚未执行检测代码之前,就完成对 jnzjmp 的修改。这要求我们以“挂起”模式创建登录器进程。

核心代码逻辑如下:

// 以 CREATE_SUSPENDED 标志创建进程,使其主线程暂停
CreateProcess(..., CREATE_SUSPENDED, ...);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/70eab26c4353e882321a971488d34e6d_16.png)

// 在内存中找到检测代码地址(例如 0x407BFA),将 0x75 修改为 0xEB
WriteProcessMemory(..., 0x407BFA, &patch_byte_EB, 1, ...);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/70eab26c4353e882321a971488d34e6d_18.png)

// 恢复进程线程,让其继续运行
ResumeThread(...);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/70eab26c4353e882321a971488d34e6d_20.png)

// (可选)运行一段时间后,将代码恢复原状,以应对更强的检测
Sleep(...);
WriteProcessMemory(..., 0x407BFA, &original_byte_75, 1, ...);

2. 为客户端创建独立路径

为了绕过客户端的路径检测,我们需要为每个新启动的游戏实例准备一个独立的目录。

一个简单的方法是使用批处理文件(.bat)来实现。以下是批处理文件的核心思路:

@echo off
REM 创建多个独立的客户端目录,例如 client00, client01, client02...
for /l %%i in (0,1,3) do (
    mkdir "client0%%i"
    xcopy /E /I "原始客户端路径\*.*" "client0%%i\"
)

解释:

  • mkdir 命令用于创建新目录。
  • xcopy 命令用于将原始客户端的所有文件复制到新目录中。
  • 理论上,只要目录名不同(例如使用递增数字或随机字符),就可以创建无数个副本。

3. 修改客户端启动参数

当登录器调用游戏客户端时,它会传递一个包含运行目录的参数。我们需要在启动每个新客户端时,修改这个目录参数,使其指向我们新建的独立路径(例如 ...\client01\ 而不是默认的 ...\client\)。

在调试器中,这个参数通常可以在调用 CreateProcessShellExecute 的函数附近找到。我们需要在代码中动态修改这个路径字符串。

核心代码逻辑如下:

// 格式化生成新的客户端路径,例如 "C:\Game\client%02d\game.exe"
sprintf(new_client_path, "C:\\Game\\client%02d\\game.exe", instance_id);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/70eab26c4353e882321a971488d34e6d_26.png)

// 在启动客户端进程时,使用新的路径替换原始路径

这样,每个游戏客户端进程都运行在独一无二的路径下,从而绕过了基于路径的检测。

总结

本节课中我们一起学习了实现游戏多开的关键技术。我们了解到,多开检测通常分为登录器检测和客户端检测两层。通过将登录器检测代码中的 jnz 修改为 jmp,我们可以绕过第一层防护。通过为每个客户端实例创建独立的运行目录并修改启动参数,我们可以绕过第二层基于路径的检测。结合“挂起进程-修改内存-恢复运行”的技术,我们可以自动化完成整个多开流程。理解这些原理是进行相关安全分析和开发的基础。

课程 P134:游戏多开准备 - 获取游戏路径 🎮

在本节课中,我们将学习如何通过编程方式获取游戏的安装目录路径。这是实现游戏多开功能的第一步,它比使用批处理文件更灵活、更精确。

概述

上一节我们介绍了使用批处理文件进行游戏多开的局限性,例如无法检测目录是否存在、无法进行文件版本对比等。本节中,我们来看看如何通过编写代码来获取游戏的准确路径,为后续的智能文件复制和目录检测打下基础。

核心概念与函数

为了实现路径选择功能,我们需要使用两个关键的 Windows API 函数:

  1. SHBrowseForFolder:显示一个浏览文件夹的对话框。
  2. SHGetPathFromIDList:将对话框返回的标识符列表转换为标准的文件系统路径。

实现步骤详解

以下是获取游戏路径的具体实现步骤。

1. 定义全局变量

首先,我们需要定义存储路径的全局变量,以替代之前代码中硬编码的路径,提高程序的通用性。

// 定义全局变量
TCHAR g_szGamePath[MAX_PATH];      // 游戏主目录路径
TCHAR g_szClientPath[MAX_PATH];    // 客户端(如Client00)路径

2. 初始化浏览对话框结构

在按钮点击事件中,我们需要初始化 BROWSEINFO 结构体来配置文件夹浏览对话框。

BROWSEINFO bi = {0};
bi.hwndOwner = m_hWnd; // 设置父窗口句柄,可为NULL
bi.lpszTitle = _T("请选择游戏启动器 (launch.exe) 所在的目录"); // 设置对话框标题
bi.ulFlags = BIF_RETURNONLYFSDIRS | BIF_EDITBOX | BIF_NEWDIALOGSTYLE; // 设置对话框标志

标志说明

  • BIF_RETURNONLYFSDIRS:仅返回文件系统目录。
  • BIF_EDITBOX:对话框中包含一个编辑框,允许用户手动输入路径。
  • BIF_NEWDIALOGSTYLE:使用新样式的对话框,并显示文件和目录。

3. 显示对话框并获取路径

调用 SHBrowseForFolder 显示对话框,并使用 SHGetPathFromIDList 将用户的选择转换为路径。

LPITEMIDLIST pidl = SHBrowseForFolder(&bi); // 显示对话框并获取项目标识列表
if (pidl != NULL) {
    // 将标识列表转换为路径字符串
    if (SHGetPathFromIDList(pidl, g_szGamePath)) {
        // 路径获取成功,g_szGamePath 中 now 存储了用户选择的路径
        OutputDebugString(g_szGamePath); // 输出调试信息
    }
    // 释放内存
    CoTaskMemFree(pidl);
}

4. 验证用户选择

为了确保用户选择的是正确的游戏目录(包含 launch.exe),我们可以利用 BROWSEINFOszDisplayName 字段进行验证。该字段会存储用户在对话框中最后选中的项目名称。

TCHAR szSelectedName[MAX_PATH];
bi.pszDisplayName = szSelectedName; // 指定缓冲区来接收选中的名称

// ... 显示对话框并获取路径后 ...

// 验证用户最后选中的是否是 launch.exe
if (_tcsicmp(szSelectedName, _T("launch.exe")) != 0) {
    MessageBox(_T("错误:请选择包含 launch.exe 的游戏目录!"));
    return; // 选择错误,退出
}

5. 构造客户端路径

获取到正确的游戏根目录(例如 D:\Game\)后,我们可以据此构造出客户端子目录的完整路径(例如 D:\Game\Client00\)。

// 假设游戏根目录已存储在 g_szGamePath 中
_tcscpy_s(g_szClientPath, MAX_PATH, g_szGamePath); // 复制根路径
_tcscat_s(g_szClientPath, MAX_PATH, _T("Client00\\")); // 拼接客户端目录名
// 现在 g_szClientPath 为 D:\Game\Client00\

6. 路径的进一步处理

有时 SHGetPathFromIDList 返回的路径可能直接指向了 launch.exe 文件本身(如 D:\Game\launch.exe)。为了得到纯粹的目录路径,我们需要去除末尾的文件名。

// 在路径字符串中查找 "launch.exe"
TCHAR* pFound = _tcsstr(g_szGamePath, _T("launch.exe"));
if (pFound != NULL) {
    *pFound = _T('\0'); // 在 "launch.exe" 起始处截断字符串
    // 现在 g_szGamePath 变为 D:\Game\
}

总结

本节课中我们一起学习了如何通过编程动态获取游戏路径。我们介绍了 SHBrowseForFolderSHGetPathFromIDList 这两个核心 API 函数的使用方法,并实现了路径选择、验证和构造的完整流程。通过本课的内容,我们为游戏多开功能准备了准确的基础路径信息。下一节,我们将利用获取到的路径,实现智能的目录检测与文件复制逻辑。

课程 P135:游戏多开准备 - 复制目录文件 📂➡️📂

在本节课中,我们将学习如何编写一个函数,用于复制游戏客户端的整个目录。这是实现游戏多开功能的重要准备工作,通过复制游戏文件到新的目录,可以为每个游戏实例提供独立的环境。


上一节我们介绍了多开的基本概念,本节中我们来看看如何具体实现目录的复制功能。

首先,我们需要在现有代码中添加一个新按钮,用于测试目录检测功能。在此之前,我们将编写一个核心的复制函数。

这个函数的主要作用是:将源目录中的所有文件及子文件夹,完整地复制到目标目录中。我们可以使用Windows API函数 SHFileOperation 来实现此功能。

以下是该函数的核心代码框架:

// 定义 SHFILEOPSTRUCT 结构体
SHFILEOPSTRUCT fileOp = {0};
fileOp.hwnd = NULL; // 不显示进度窗口的父窗口句柄
fileOp.wFunc = FO_COPY; // 操作类型为复制
fileOp.pFrom = sourcePath; // 源路径
fileOp.pTo = destPath; // 目标路径
fileOp.fFlags = FOF_SILENT | FOF_NOCONFIRMATION | FOF_NOERRORUI; // 设置标志:静默、无需确认、不显示错误界面
// 执行复制操作
SHFileOperation(&fileOp);

在调用复制函数前,我们需要生成一个唯一的目标目录路径。通常,我们在原始游戏目录名后追加一个数字序列来实现。

以下是生成目标路径的示例代码:

char destPath[MAX_PATH];
static int counter = 0; // 使用静态变量确保序号递增
sprintf(destPath, "%s%02d", originalGamePath, counter++);

为了确保不重复复制已存在的目录,在复制前我们需要检测目标目录是否已存在。

以下是检测文件是否存在的代码:

#include <io.h> // 用于 _access 函数
// 构建需要检测的完整文件路径(例如游戏主程序)
char checkPath[MAX_PATH];
sprintf(checkPath, "%s\\GameClient.exe", destPath);
// 检测文件是否存在,返回-1表示不存在
if (_access(checkPath, 0) == -1) {
    // 文件不存在,执行复制操作
    CopyDirectory(sourcePath, destPath);
}

通过以上步骤,我们就能够检测并复制游戏目录,为后续的多开操作做好准备。


本节课中我们一起学习了如何编写目录复制函数、生成唯一目标路径以及检测目录是否存在。这些是构建游戏多开功能的基础步骤。下一节课,我们将在此基础上,继续编写多开功能的核心代码。

课程 P136:编写游戏多开代码 🎮

在本节课中,我们将学习如何编写代码来实现游戏多开。我们将从修改现有代码开始,逐步讲解如何使用特定函数创建并控制游戏进程,最终绕过游戏的多开检测机制。


修改启动代码

首先,打开第146课的代码。我们需要修改启动游戏的部分。因为目标是实现多开而非简单的登录器,所以需要注释掉直接启动客户端的两行代码。

// 注释掉原有的直接启动客户端代码
// StartGameClient();
// Login();

启动游戏客户端的工作应由登录器完成,而非我们的代码直接调用。因此,我们需要先打开游戏的登录器。


使用 CreateProcess 创建登录器进程

上一节我们介绍了需要修改启动方式,本节中我们来看看如何使用 CreateProcess 函数来创建并控制登录器进程。

CreateProcess 函数有几个必要的参数:

  • 命令行:指定要运行的程序。
  • 当前目录:进程的当前目录。
  • 启动信息:用于控制窗口显示等。
  • 进程信息:用于接收新进程的信息。
  • 创建标志:这是关键参数,我们需要将其设置为 CREATE_SUSPENDED(挂起状态)。

创建标志是关键。如果不设置挂起标志,进程一旦创建就会立即执行游戏的多开检测代码,导致多开失败。我们必须先以挂起状态创建进程,修改其内存中的数据后,再恢复进程执行。

以下是调用 CreateProcess 的基本步骤:

  1. 准备必要的参数和结构体。
  2. 构建登录器的完整路径。
  3. 设置创建标志为 CREATE_SUSPENDED
  4. 调用 CreateProcess 函数。
  5. 使用 ResumeThread 恢复挂起的进程。
STARTUPINFO si = {sizeof(si)};
PROCESS_INFORMATION pi = {0};
DWORD dwCreationFlags = CREATE_SUSPENDED;

// 构建登录器路径,例如:game_path + "\\Launcher.exe"
TCHAR szLauncherPath[MAX_PATH];
_stprintf(szLauncherPath, _T("%s\\Launcher.exe"), szGamePath);

BOOL bSuccess = CreateProcess(
    NULL,                   // 应用程序名(使用命令行参数)
    szLauncherPath,         // 命令行
    NULL,                   // 进程安全属性
    NULL,                   // 线程安全属性
    FALSE,                  // 句柄继承选项
    dwCreationFlags,        // 创建标志(关键:挂起)
    NULL,                   // 环境变量
    szGamePath,             // 当前目录
    &si,                    // 启动信息
    &pi                     // 进程信息
);

if (bSuccess) {
    // 进程创建成功,处于挂起状态
    // 此处可以修改进程内存...
    ResumeThread(pi.hThread); // 恢复线程执行
    CloseHandle(pi.hThread);
    CloseHandle(pi.hProcess);
}

绕过登录器多开检测

成功创建挂起的登录器进程后,下一步是修改其内存数据,以绕过第一层多开检测。

登录器内部通常有一个标志位或特定数据用于检测是否已有一个实例在运行。我们需要找到这个数据的地址,并在进程运行前将其修改。

  1. 使用工具(如CE)找到检测数据的地址。
  2. 在代码中,使用 WriteProcessMemory 函数向该地址写入新数据。
// 假设检测标志的地址是 0x449714
DWORD dwDetectFlagAddr = 0x449714;
BYTE bNewData = 0x00; // 修改为允许多开的值
SIZE_T bytesWritten = 0;

// 修改内存属性为可写
DWORD dwOldProtect = 0;
VirtualProtectEx(pi.hProcess, (LPVOID)dwDetectFlagAddr, 1, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// 写入数据
BOOL bWriteOK = WriteProcessMemory(
    pi.hProcess,            // 目标进程句柄
    (LPVOID)dwDetectFlagAddr, // 要写入的地址
    &bNewData,              // 指向数据的缓冲区
    sizeof(bNewData),       // 要写入的字节数
    &bytesWritten           // 实际写入的字节数
);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/d93eb983c618728b667ede1148e95996_3.png)

// 恢复内存属性
VirtualProtectEx(pi.hProcess, (LPVOID)dwDetectFlagAddr, 1, dwOldProtect, &dwOldProtect);

if (bWriteOK && bytesWritten > 0) {
    // 写入成功,可以恢复进程
    ResumeThread(pi.hThread);
}


绕过客户端多开检测

上一节我们绕过了登录器的检测,本节中我们来看看如何绕过游戏客户端本身的检测。

客户端启动时,可能会检查特定的路径或文件。我们需要修改登录器内存中存储的客户端路径,使每个实例使用不同的路径启动。

  1. 找到登录器中存储客户端路径的字符串地址。
  2. 生成一个格式化的新路径字符串(例如,在路径末尾追加数字编号 00, 01 等)。
  3. 将新路径字符串写入目标进程的内存。

以下是生成和写入新路径的示例:

// 假设路径字符串的起始写入地址是 0x449714 + 0x09
DWORD dwPathAddr = 0x44971D;
TCHAR szNewPath[MAX_PATH];
int iIndex = 0; // 可以从列表框等控件获取

// 生成新路径,例如 "X:\\Game\\Client\\game_00.exe"
_stprintf(szNewPath, _T("X:\\Game\\Client\\game_%02d.exe"), iIndex);

// 计算字符串长度(包含结束符)
SIZE_T pathLen = (_tcslen(szNewPath) + 1) * sizeof(TCHAR);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/d93eb983c618728b667ede1148e95996_7.png)

// 修改内存属性并写入
DWORD dwOldProtect = 0;
VirtualProtectEx(pi.hProcess, (LPVOID)dwPathAddr, pathLen, PAGE_EXECUTE_READWRITE, &dwOldProtect);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/d93eb983c618728b667ede1148e95996_9.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/d93eb983c618728b667ede1148e95996_11.png)

BOOL bWriteOK = WriteProcessMemory(
    pi.hProcess,
    (LPVOID)dwPathAddr,
    szNewPath,
    pathLen,
    &bytesWritten
);

VirtualProtectEx(pi.hProcess, (LPVOID)dwPathAddr, pathLen, dwOldProtect, &dwOldProtect);


完善与自动化

为了使多开工具更易用,我们可以添加一些自动化功能:

以下是可选的完善步骤:

  • 自动递增索引:每次成功启动一个客户端后,自动将路径索引加1,以便下次启动时使用新路径。
  • 默认选择:在列表框中默认选中第一个可用的索引。
  • 错误处理:检查 WriteProcessMemory 等函数的返回值,并使用 GetLastError 获取详细错误信息以便调试。

// 示例:启动成功后递增索引
if (bSuccess && bWriteOK) {
    // 更新界面或变量,为下次启动做准备
    m_iCurrentIndex++; // 假设 m_iCurrentIndex 是当前路径索引
    UpdateData(FALSE); // 更新控件显示
}

总结 🎯

本节课中我们一起学习了编写游戏多开代码的核心步骤。

我们首先修改了代码,将直接启动改为通过 CreateProcess 创建进程。关键在于使用 CREATE_SUSPENDED 标志挂起新进程。接着,我们分两步绕过检测:先修改登录器内存中的检测标志,再修改其内部存储的客户端路径字符串。最后,我们探讨了如何通过自动递增索引等方法来完善工具。

通过结合挂起进程、修改内存和恢复执行这一流程,我们能够有效地绕过游戏的多开限制,实现真正的多开功能。

课程 P137:游戏自动登录原理 🎮

在本节课中,我们将学习游戏自动登录的基本原理与设计思路。我们将以一款游戏为例,讲解如何通过模拟鼠标和键盘操作,结合进程控制,实现从启动登录器到进入游戏世界的全自动化流程。

概述

游戏自动登录的核心是通过程序模拟用户操作。这通常涉及启动游戏客户端、选择服务器、输入账号密码以及处理游戏内的角色选择等步骤。我们将使用代码来模拟这些点击和输入行为,并讨论如何优化等待时间以提高效率。

自动登录的设计流程

上一节我们概述了自动登录的概念,本节中我们来看看具体的实现步骤。整个过程可以分解为几个关键阶段。

以下是实现自动登录的主要步骤:

  1. 启动并处理游戏登录器:首先需要启动游戏登录器进程,并进行必要的处理以支持多开。
  2. 模拟选区操作:通过鼠标模拟点击,选择目标游戏服务器。
  3. 启动游戏客户端:点击“开始游戏”按钮,等待游戏主窗口启动。
  4. 自动输入账号密码:在游戏登录界面,通过键盘模拟输入账号和密码。
  5. 处理角色选择:自动选择游戏角色并进入游戏世界。
  6. 优化与判断:引入循环判断逻辑,以缩短不必要的固定等待时间,提高登录效率。

关键技术点详解

1. 多开与进程控制

为了实现多开,我们需要对游戏登录器进程进行特殊处理。这包括创建进程、挂起线程、修改内存数据以绕过检测,以及恢复线程运行。

相关代码可能涉及 Windows API 调用,例如:

// 伪代码示例:创建并挂起进程
CreateProcess(..., CREATE_SUSPENDED, ...);
// 修改特定内存地址的数据
WriteProcessMemory(..., 0x1B, ...);
// 恢复线程运行
ResumeThread(...);

2. 鼠标与键盘模拟

自动登录的绝大部分操作依赖于对鼠标和键盘的精确模拟。我们需要获取目标按钮(如服务器选项、开始按钮、输入框)的屏幕坐标,然后发送鼠标点击或移动事件。

同样,输入账号密码需要模拟键盘按键序列:

// 伪代码示例:模拟键盘输入
keybd_event(VK_TAB, 0, 0, 0); // 按下Tab键切换焦点
keybd_event(VK_TAB, 0, KEYEVENTF_KEYUP, 0); // 松开Tab键
// 模拟输入字符串
SendInput(...);

3. 等待与判断逻辑

简单的固定时长等待(如 Sleep(2000))效率低下。更优的做法是主动判断游戏状态。例如,在点击“开始游戏”后,循环检测游戏主窗口是否出现;在选择角色后,循环尝试读取游戏内存中的人物信息,一旦读取成功即代表登录完成。

// 伪代码示例:循环判断登录是否完成
for(int i = 0; i < 600; i++) { // 尝试约1分钟(600*100ms)
    if (ReadPlayerInfo() == SUCCESS) {
        break; // 登录成功,退出循环
    }
    Sleep(100); // 等待100毫秒再次尝试
}

4. 数据管理与批量处理

为了支持批量登录多个账号,我们需要将账号信息(如分区、账号、密码、角色)结构化存储。可以将这些信息保存在配置文件或数据库中,程序运行时依次读取并执行登录流程。

// 伪代码示例:定义账号信息结构体
struct AccountInfo {
    char server[50];
    char username[50];
    char password[50];
    char character[50];
};

总结

本节课我们一起学习了游戏自动登录的基本原理和实现框架。我们了解到,自动登录的核心在于模拟用户操作智能状态判断。通过结合进程控制、鼠标键盘模拟以及高效的循环检测逻辑,我们可以构建一个稳定、高效的自动登录系统,为后续的自动化游戏行为(如自动挂机、自动任务)打下基础。

在下一节课中,我们将深入细节,逐步分析如何提取游戏界面数据、精确设计模拟操作的函数,并进一步完善整个自动化流程。

课程 P138:游戏自动登录设计 - 自动选游戏分区 🎮

在本节课中,我们将学习如何编写自动登录功能中的游戏分区选择模块。我们将通过模拟鼠标操作,实现自动点击指定游戏分区的功能,为后续的账号密码自动输入打下基础。


工程搭建与代码移植

首先,我们需要在 Visual Studio 2010 中新建一个名为“自动登录”的工程。为了方便代码的移植和复用,我们将把所有与自动登录相关的功能封装成独立的函数。

上一节我们介绍了工程搭建,本节中我们来看看如何移植已有的多开功能代码。

我们将从第147课的代码中,移植与游戏多开相关的部分。以下是移植的核心步骤:

  1. 复制游戏多开相关的代码段到新工程。
  2. 处理编译时出现的变量未定义错误,将相关参数修改为静态变量。
  3. 在按钮事件中关联并调用相应的函数,确保客户端能够正常启动。

完成以上步骤并编译通过后,我们可以测试客户端是否能被正常打开。如果路径设置有问题,需要检查并修正。


获取分区坐标位置

客户端成功打开后,下一步是编写代码来自动选择游戏分区。当前登录界面显示有十个分区,我们需要获取每个分区可供鼠标点击的屏幕坐标。

上一节我们确保了客户端能启动,本节中我们来看看如何定位分区按钮的坐标。

获取坐标有多种方法。一种相对简单的方法是使用系统自带的画图工具。将登录界面截图保存后,用画图工具打开,将鼠标悬停在目标分区(如“网通一区”)上,工具左下角会显示当前光标位置的坐标。

以下是获取坐标的步骤:

  1. 截图并保存登录界面。
  2. 用画图工具打开图片,将鼠标移动到“网通一区”按钮上,记录坐标(例如:(226, 112))。
  3. 观察第一个分区和最后一个分区的Y坐标,计算差值。例如,第一个分区Y坐标约为100,最后一个约为400,差值约为300像素。
  4. 由于有十个分区,可估算出每个分区之间的垂直间距约为 300 / 10 = 30 像素。

因此,第 i 个分区(i 从0开始)的Y坐标计算公式可以近似为:
y = 112 + i * 30
其中,(226, 112) 是第一个分区(i=0)的基准坐标。


编写分区选择函数

获取坐标计算方法后,我们可以开始编写自动选择分区的函数。这个函数的核心是模拟鼠标移动和点击。

上一节我们分析了坐标的计算方法,本节中我们来看看如何用代码实现鼠标模拟操作。

我们将创建一个函数 SelectGameZone,它接收一个分区索引参数(例如1到10)。函数内部主要用到两个Windows API:

  • SetCursorPos:设置鼠标位置。
  • mouse_event:模拟鼠标按下 (MOUSEEVENTF_LEFTDOWN) 和弹起 (MOUSEEVENTF_LEFTUP) 事件。

需要注意的是,我们之前获取的坐标 (226, 112) 是相对于登录器窗口左上角的。而 SetCursorPos 设置的坐标是相对于整个屏幕的。因此,我们需要进行坐标转换。

以下是函数实现的关键逻辑:

  1. 获取窗口位置:首先使用 FindWindow 找到登录器窗口句柄,再用 GetWindowRect 获取该窗口在屏幕上的矩形区域(包含 left, top, right, bottom 信息)。
  2. 计算绝对坐标:将窗口相对坐标转换为屏幕绝对坐标。公式为:
    screen_x = window_rect.left + 226
    screen_y = window_rect.top + 112 + (zone_index - 1) * 30
    (假设 zone_index 从1开始,代表第几个分区)
  3. 移动并点击鼠标:调用 SetCursorPos 将鼠标移动到计算出的绝对坐标,然后调用两次 mouse_event 分别模拟鼠标左键按下和弹起动作。
  4. 优化用户体验(可选):为了不让鼠标指针在屏幕上 visibly 跳动,可以在移动前用 GetCursorPos 保存原始位置,完成点击操作后再用 SetCursorPos 移回原位。

编写完成后,可以创建一个测试按钮,调用 SelectGameZone(1) 来测试是否能正确选中“网通一区”。


整合与流程控制

分区选择函数测试成功后,需要将其整合到自动登录的主流程中,并确保执行时机正确。

上一节我们完成了核心的分区选择函数,本节中我们来看看如何将它融入完整的登录流程。

自动登录的完整流程需要更严谨的等待和判断机制,不能简单地使用固定的 Sleep 延时。以下是整合时需要注意的要点:

  1. 等待登录器就绪:在启动客户端后,不能立即选择分区。应使用一个循环,不断检测登录器窗口是否创建并完全加载。可以使用 FindWindow 结合短暂的 Sleep 进行轮询,直到窗口句柄有效为止。
  2. 前置登录窗口:在模拟点击前,最好使用 SetForegroundWindow 将登录器窗口设置为前台,确保点击操作能正确送达。
  3. 等待“开始游戏”按钮:选择分区后,登录界面会发生变化,出现“开始游戏”按钮。在点击该按钮前,同样需要等待其就绪。我们可以记录“开始游戏”按钮的坐标(例如 (664, 428)),在分区选择后,等待一段时间,再将鼠标移动到此坐标并点击。
  4. 坐标校准:在实际测试中,如果发现点击位置有偏差,需要重新校准基准坐标 (226, 112) 和分区间距 30 这两个参数。

将以上逻辑串联起来,就形成了一个从启动客户端 -> 等待窗口 -> 选择分区 -> 点击开始游戏的初步自动化流程。


总结与下节预告

本节课中我们一起学习了游戏自动登录设计中“自动选择游戏分区”模块的实现。

我们首先移植了基础工程,然后学习了如何获取和计算游戏分区的屏幕坐标。接着,我们利用 SetCursorPosmouse_event 这两个Windows API编写了分区选择函数,实现了鼠标的模拟移动与点击。最后,我们讨论了如何将这一功能整合到完整的流程中,并加入了必要的等待和窗口控制逻辑,使自动化脚本更加健壮。

目前,我们已经能够自动启动游戏并选择分区。下一节课,我们将完成自动登录的最后一步:模拟键盘输入,实现账号和密码的自动填写。这将主要用到 keybd_event 这个用于模拟键盘操作的API函数。

课程 P139:游戏自动登录设计 - 自动输入账号与密码 🔑

在本节课中,我们将学习如何为游戏设计自动登录功能,核心是实现账号与密码的自动输入。我们将使用键盘模拟技术,而非直接读写游戏内存,因为后者通常涉及加密数据且通用性不强。本节课的重点是掌握如何使用 keybd_event 函数来模拟键盘输入。

概述与原理

上一节我们介绍了自动登录的整体框架。本节中,我们来看看如何具体实现账号密码的输入。

自动输入账号密码通常不通过直接读写游戏内存(例如使用CE工具)实现,因为密码等数据通常是加密的,直接查找困难且方法通用性差。因此,更通用的方法是模拟键盘操作。

我们将使用Windows API中的 keybd_event 函数来模拟按键。这个函数可以合成一次键盘事件(包括按下和释放)。

VOID keybd_event(
  BYTE      bVk,        // 虚拟键码
  BYTE      bScan,      // 硬件扫描码(通常为0)
  DWORD     dwFlags,    // 操作标志,如 KEYEVENTF_KEYUP 表示释放
  ULONG_PTR dwExtraInfo // 附加信息(通常为0)
);

它的第一个参数是虚拟键码(范围1~254)。我们的账号密码通常由字母和数字组成,因此本节课主要模拟输入这些字符。

定义登录信息结构

为了方便管理,我们首先定义一个结构体来存放登录所需的信息。

struct LoginInfo {
    char szZone[32];     // 游戏分区
    char szUserName[64]; // 游戏账号
    char szPassword[64]; // 游戏密码
    int nRoleIndex;      // 角色索引(例如0代表第一个角色)
};

我们以账号 sn_yjxp03 和密码 yujinxiang 为例,说明如何输入。

编写字符串输入函数

我们需要设计一个核心函数,其功能是将一个字符串(账号或密码)通过模拟按键的方式输入到游戏中。

以下是该函数需要处理的关键逻辑:

  1. 遍历字符串:逐个字符处理。
  2. 字符分类判断:判断当前字符是小写字母、大写字母还是数字。
  3. 模拟按键:根据字符类型,调用 keybd_event 模拟按下和释放对应的键。
  4. 大小写处理:输入大写字母时,需要配合 Caps LockShift 键。

以下是输入函数的实现框架:

void InputString(const char* str) {
    int len = strlen(str);
    for (int i = 0; i < len; i++) {
        char ch = str[i];
        // 判断字符类型并模拟按键
        if (ch >= 'a' && ch <= 'z') {
            // 处理小写字母:确保CapsLock关闭,然后模拟按键
            // 虚拟键码需要转换为大写字母的键码
            BYTE vk = ch - 'a' + 'A'; // 转换为对应大写字母的虚拟键码
            keybd_event(vk, 0, 0, 0);          // 按下
            Sleep(50);
            keybd_event(vk, 0, KEYEVENTF_KEYUP, 0); // 释放
        } else if (ch >= 'A' && ch <= 'Z') {
            // 处理大写字母:确保CapsLock打开,或配合Shift键
            // 此处简化处理,直接模拟按键(假设CapsLock已打开)
            keybd_event(ch, 0, 0, 0);
            Sleep(50);
            keybd_event(ch, 0, KEYEVENTF_KEYUP, 0);
        } else if (ch >= '0' && ch <= '9') {
            // 处理数字:直接模拟数字键
            keybd_event(ch, 0, 0, 0);
            Sleep(50);
            keybd_event(ch, 0, KEYEVENTF_KEYUP, 0);
        }
        // 其他字符(如符号)暂不考虑,因为账号密码通常只包含字母和数字
        Sleep(30); // 字符间短暂间隔,模拟真人输入速度
    }
}

注意:关于大小写锁定键(Caps Lock)的精确控制(打开或关闭)我们将在下一节课完善。本节课的示例代码做了简化。

整合到自动登录流程

现在,我们将字符串输入函数整合到完整的自动登录流程中。

以下是自动登录函数的关键步骤:

void AutoLogin(const LoginInfo& info) {
    // 1. 等待游戏登录界面加载完成(此处用Sleep简单模拟)
    Sleep(8000);

    // 2. 输入账号
    InputString(info.szUserName);

    // 3. 按下Tab键,切换到密码输入框
    keybd_event(VK_TAB, 0, 0, 0);
    keybd_event(VK_TAB, 0, KEYEVENTF_KEYUP, 0);
    Sleep(500);

    // 4. 输入密码
    InputString(info.szPassword);

    // 5. 按下回车键,确认登录
    keybd_event(VK_RETURN, 0, 0, 0);
    keybd_event(VK_RETURN, 0, KEYEVENTF_KEYUP, 0);
}

在主函数中,我们初始化登录信息并调用自动登录函数:

int main() {
    LoginInfo info;
    strcpy(info.szZone, "电信一区");
    strcpy(info.szUserName, "sn_yjxp03");
    strcpy(info.szPassword, "yujinxiang");
    info.nRoleIndex = 0; // 选择第一个角色

    AutoLogin(info);
    return 0;
}

测试与问题排查

编译并运行程序后,可能会遇到一些问题,例如:

  • 字符重复输入:可能是 keybd_eventdwFlags 参数使用错误,导致按下和释放事件被识别为两次独立按键。确保释放时使用了 KEYEVENTF_KEYUP 标志。
  • Tab键无效:检查虚拟键码 VK_TAB 是否正确,并确保按下和释放事件成对出现。
  • 大小写错误:输入大写字母时,如果CapsLock状态不正确,会导致输入小写。这是我们需要在下节课完善的地方。

修正代码并重新编译后,程序应能成功完成账号和密码的自动输入。

总结与下节预告

本节课中我们一起学习了游戏自动登录设计中账号密码输入的核心实现。我们掌握了以下内容:

  1. 使用 keybd_event Windows API函数模拟键盘按键。
  2. 编写 InputString 函数,将字符串分解为字符并模拟输入。
  3. 区分处理小写字母、大写字母和数字的输入逻辑。
  4. 将输入功能整合到自动登录流程中,包括使用Tab键切换输入框和回车键确认。

目前我们的实现还有可完善之处,主要是大小写锁定键(Caps Lock)的精确控制。下一节课,我们将解决这个问题,并进一步实现自动选择游戏分区、服务器、线路以及游戏角色的功能。这些功能将通过模拟鼠标点击和键盘操作来完成。


下节课预告:我们将实现自动选区与角色选择功能。🚀

课程 P14:025 - 完善选怪功能 🎯

在本节课中,我们将学习如何完善选中怪物、NPC和玩家的功能。我们将分析游戏内部的数据结构,找到选中状态的关键偏移地址,并通过调用游戏内部的函数来实现完整的选中与取消选中逻辑。


概述

选中游戏中的对象(如怪物、NPC、玩家)通常涉及多个步骤。简单地向目标地址写入对象ID可能无法正确显示选中标志(如血条)。本节课将深入分析,找到完整的调用流程,实现一个功能完善的选中功能。

上一节我们测试了直接写入对象ID的方法,本节中我们来看看如何通过调用游戏内部函数来实现更完美的选中效果。


分析选中状态偏移

首先,我们需要找到控制对象选中状态的内部数据。通过调试器分析发现,对于玩家对象,其选中状态的偏移地址是 +0x2D0C

核心偏移公式:

玩家对象基地址 + 0x2D0C = 选中状态地址

当向此地址写入 1 时,会显示选中标志;写入 0 时,则取消选中。


寻找内部调用函数

直接修改内存地址虽然有效,但为了确保所有选中效果(如血条显示)都能正确触发,最好调用游戏自身的函数。

通过下内存写入断点,我们追踪到游戏在选中或取消选中对象时,会调用两个关键的函数。它们的参数相似,但功能不同:

  • 一个函数用于取消选中(写入 0)。
  • 另一个函数用于选中对象(写入 1)。

这两个函数都需要一个关键参数:对象在游戏内部对象数组中的下标(Index),而非直接的对象地址。


理解对象下标(Index)

在游戏的对象列表中,每个对象都有一个唯一的下标(Index)。这个下标存储在对象基地址的 +0x0C 偏移处。

下标获取公式:

对象下标 = 读取内存(对象基地址 + 0x0C)

选中功能函数正是通过这个下标,从全局对象列表中取出对应的对象地址,然后进行后续操作。


构建完整的选中逻辑

一个完整的选中新对象的流程应包含三个步骤:

以下是实现选中新对象的三个步骤:

  1. 取消当前选中对象:调用“取消选中”函数,清除之前对象的选中状态。
  2. 写入新对象ID:将新目标对象的下标(Index) 写入到游戏指定的选中目标地址(例如 +0x1498)。
  3. 选中新对象:调用“选中对象”函数,为新对象设置选中状态并显示血条等标志。

伪代码逻辑描述:

// 1. 取消之前选中的对象
call CancelSelectFunction(ObjectIndex_Previous);

// 2. 将新对象的Index写入目标地址
WriteMemory(TargetSelectAddress, ObjectIndex_New);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/c43afaa72939a6b0ee8d3a9b568cf9e9_17.png)

// 3. 选中新的对象
call SelectFunction(ObjectIndex_New);

代码实现与测试

根据逆向分析得到的汇编指令,我们可以用代码模拟这一过程。以下是一个示例框架:

关键代码示例(概念):

// 假设已知的函数地址和偏移量
DWORD dwCancelFunc = 0xXXXXXX; // 取消选中函数地址
DWORD dwSelectFunc = 0xYYYYYY; // 选中函数地址
DWORD dwPlayerBase = 0xZZZZZZ; // 玩家对象基地址
DWORD dwTargetAddr = dwPlayerBase + 0x1498; // 写入新对象ID的目标地址

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/c43afaa72939a6b0ee8d3a9b568cf9e9_23.png)

// 步骤1:取消当前选中(参数为之前对象的Index)
__asm {
    mov ecx, [dwPlayerBase + 0x0C] // 获取当前选中对象的Index
    call [dwCancelFunc]
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/c43afaa72939a6b0ee8d3a9b568cf9e9_25.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/c43afaa72939a6b0ee8d3a9b568cf9e9_27.png)

// 步骤2:写入新对象的Index(例如 0x12B3)
DWORD dwNewObjectIndex = 0x12B3;
WriteProcessMemory(..., dwTargetAddr, &dwNewObjectIndex, ...);

// 步骤3:选中新对象
__asm {
    mov ecx, dwNewObjectIndex // 传入新对象的Index
    call [dwSelectFunc]
}

将上述逻辑注入游戏进行测试,可以成功实现:取消原有选中目标,并正确选中新怪物,同时其血条和选中标志均正常显示。


总结

本节课中我们一起学习了如何完善游戏中的对象选中功能。关键在于理解并遵循游戏自身的逻辑流程:

  1. 找到控制选中状态的内部偏移(+0x2D0C)。
  2. 定位并调用游戏内部的“取消选中”与“选中对象”两个函数。
  3. 理解并使用“对象下标(Index)”而非直接地址作为函数的参数。
  4. 按照“取消旧对象 -> 写入新ID -> 选中新对象”的三步顺序执行。

通过这种方式实现的选中功能,比直接写内存更加稳定和完整,能够确保所有视觉反馈(如血条)正确触发。下一节课,我们将对这些代码进行封装,使其更易于管理和使用。

课程 P140:游戏自动登录设计 - 自动输入账号与密码修改完善 🔧

在本节课中,我们将学习如何完善游戏自动登录脚本中的账号密码自动输入功能。我们将重点解决因键盘大小写锁定状态导致的输入错误问题,并探讨如何更智能地判断游戏登录界面是否已就绪。


上一节我们介绍了自动输入账号密码的基本框架。本节中,我们来看看如何通过检测键盘状态来确保输入的准确性。

检测键盘大小写锁定状态

在自动输入字符串时,如果键盘的大小写锁定灯是亮着的,那么输入小写字母时可能会意外地变成大写,导致账号密码错误。因此,我们需要在输入前判断大小写锁定键的状态。

我们可以调用 Windows API 函数 GetKeyState 来获取特定虚拟键的状态。

核心代码:判断大小写锁定键

// 方法一:使用 GetKeyState 函数
SHORT keyState = GetKeyState(VK_CAPITAL);
// 判断低字节是否为1,1表示大小写锁定开启
bool isCapsLockOn = (keyState & 0x0001) != 0;

GetKeyState 函数返回一个 SHORT 类型的整数。其低位字节如果为1,则表示该键的切换状态是开启的(例如,大小写锁定灯亮起)。

以下是测试该函数返回值的步骤:

  1. 当大小写锁定灯熄灭时,GetKeyState(VK_CAPITAL) 返回值(与1进行位与运算后)为 0
  2. 当按下大小写锁定键,灯亮起时,返回值变为 1

另一种检测方法

除了 GetKeyState,还可以使用 GetKeyboardState 函数来获取整个键盘256个虚拟键的状态。

核心代码:使用 GetKeyboardState

// 方法二:使用 GetKeyboardState 函数
BYTE keyStates[256];
GetKeyboardState(keyStates);
// 判断大小写锁定键的状态(同样看低字节)
bool isCapsLockOn = (keyStates[VK_CAPITAL] & 0x0001) != 0;

这种方法会将所有键的状态存入一个数组,我们可以通过虚拟键值作为下标来查询特定键的状态。经测试,两种方法在判断大小写锁定状态上效果一致。

完善自动输入函数

基于上述检测,我们需要在输入逻辑中加入判断,以确保无论当前键盘状态如何,都能正确输入目标字符。

逻辑如下:

  • 如果需要输入的是小写字母,但检测到大小写锁定已开启,则需要在输入前模拟按下一次 CapsLock 键来关闭大写状态。
  • 如果需要输入的是大写字母,但检测到大小写锁定未开启,则需要在输入前模拟按下一次 CapsLock 键来开启大写状态。

这样,我们的自动输入函数就能适应任何初始的键盘状态,保证输入的准确性。


上一节我们解决了输入准确性的问题。本节中,我们来看看如何确保输入时机的正确性,避免因游戏界面未加载完成而输入失败。

智能等待登录界面

之前测试失败的一个原因是,在游戏登录窗口完全出现之前,脚本就开始输入了。简单的固定时间等待并不可靠。

更稳健的方法是:检测游戏登录界面是否已完全加载。一个简单有效的方法是判断登录窗口上特定像素点的颜色。

实现思路:

  1. 在游戏登录界面完全出现后,记录一个特征点(例如,登录按钮上的某个点)的坐标和颜色值。
  2. 在脚本中,循环检测该坐标点的颜色是否变为我们记录的目标颜色。
  3. 只有当颜色匹配时,才执行后续的账号密码输入操作。

这样可以确保脚本只在正确的时机进行操作,大大提高自动化流程的稳定性。


本节课中我们一起学习了完善游戏自动登录脚本的两个关键点:

  1. 键盘状态检测:通过 GetKeyStateGetKeyboardState API 函数判断大小写锁定状态,并在输入前后进行必要的调整,确保了账号密码输入的准确性
  2. 界面就绪判断:提出了通过检测特定像素点颜色来判断游戏登录界面是否加载完成的方法,以此替代固定的时间等待,确保了输入操作的时机正确性

通过这两项改进,我们的自动登录脚本将变得更加健壮和可靠。下一节课,我们将继续深入,实现并测试基于颜色检测的智能等待逻辑。

课程 P141:游戏自动登录设计 - 等待登录窗口显示代码 🎮

在本节课中,我们将学习如何编写一段代码,用于等待游戏登录界面窗口显示出来,然后再进行账号和密码的输入。这样做可以解决因不同电脑配置差异导致的加载时间不一致问题,使自动登录脚本更加稳定可靠。


上一节我们介绍了自动登录的基本流程。本节中,我们来看看如何实现“等待登录窗口显示”这一关键步骤。

准备工作与思路分析

首先,我们需要打开第151课的代码,并在此基础上进行修改。我们的目标是在“等待进入游戏”这个环节,用代码检测游戏是否已正常加载到可以输入密码的状态。

核心思路是:在登录界面的一个特定坐标点(例如 (288, 150))取色。当该点的颜色变为目标颜色时,即代表登录窗口已完全显示,可以执行后续的输入操作。

获取目标窗口与颜色

以下是实现等待功能的具体步骤。

1. 获取窗口句柄

要获取窗口内像素的颜色,首先需要获得目标窗口的句柄。我们使用窗口标题来查找它。

HWND hWnd = FindWindow(NULL, L"游戏客户端标题");
if (hWnd == NULL) {
    // 调试信息:窗口未找到
    return;
}

2. 获取设备上下文并取色

获得窗口句柄后,需要获取其设备上下文(DC),然后使用 GetPixel 函数获取指定坐标的颜色值。

HDC hDC = GetDC(hWnd);
COLORREF color = GetPixel(hDC, 288, 150);
ReleaseDC(hWnd, hDC);

为了测试,我们可以先将获取到的颜色值打印出来,以确认坐标点和颜色是否正确。

实现等待循环检测

获取到目标颜色值后,我们就可以完善之前的等待代码了。我们将用一个循环来持续检测,直到目标点的颜色匹配为止。

以下是实现等待检测的核心代码结构:

COLORREF targetColor = 0x00FF00; // 替换为实际获取到的目标颜色值
bool isWindowReady = false;

for (int i = 0; i < 200; ++i) { // 设置一个最大循环次数,防止无限等待
    HWND hWnd = FindWindow(NULL, L"游戏客户端标题");
    if (hWnd != NULL) {
        HDC hDC = GetDC(hWnd);
        COLORREF currentColor = GetPixel(hDC, 288, 150);
        ReleaseDC(hWnd, hDC);

        if (currentColor == targetColor) {
            isWindowReady = true;
            break; // 颜色匹配,退出循环
        }
    }
    Sleep(50); // 每次检测间隔50毫秒
}

if (isWindowReady) {
    // 执行输入账号密码等后续操作
}

在这段代码中:

  • 我们通过一个 for 循环进行多次检测。
  • 每次循环中,都重新获取窗口句柄和指定点的颜色。
  • 将当前颜色与之前获取的 targetColor 进行比较。
  • 如果颜色匹配,则设置标志并跳出循环,执行后续的输入操作。
  • 每次检测后使用 Sleep(50) 暂停一小段时间,避免过度占用CPU资源。
  • 注意,在颜色不匹配时,应使用 continue 继续下一次循环,而不是 return,否则会提前结束整个函数。

扩展应用与练习

等待界面显示的逻辑可以应用于自动登录流程的多个环节。例如,在分区选择之后,界面可能会显示一个“开始”按钮,同样可以通过检测按钮特定位置的颜色变化来判断是否可以进行点击。

大家可以尝试将此方法应用到脚本的其他等待环节,逐步完善整个自动登录流程。


本节课中,我们一起学习了如何通过检测屏幕特定坐标颜色来实现等待游戏登录窗口显示的功能。我们掌握了获取窗口句柄、设备上下文以及像素颜色的方法,并利用循环检测构建了一个可靠的等待机制。下一节课,我们将继续学习角色选择等相关功能的代码设计。

课程 P142:游戏自动登录设计 - 选区与选角色 🎮

在本节课中,我们将学习如何为游戏自动登录功能编写代码,核心目标是实现自动选择服务器分区、线路以及游戏角色,并最终进入游戏。我们将通过模拟鼠标点击来完成这些操作。

概述

上一节我们介绍了自动登录的基础框架。本节中,我们来看看如何具体实现选区、选线和选角色的功能。整个过程依赖于计算好的屏幕坐标,并通过代码模拟鼠标的单击和双击操作。

第一步:分析界面与计算坐标

首先,我们需要获取游戏登录界面中关键元素的屏幕坐标。通过截图分析,我们确定了以下数据:

以下是服务器分区选择的坐标计算:

  • 分区选择区域的起始Y坐标约为305,结束Y坐标约为516。
  • 总高度约为210像素,共10个分区,因此每个分区的高度差为21像素。
  • 分区选择的X坐标固定,Y坐标遵循公式:Y = 基础Y坐标 + 分区索引 * 21

以下是服务器线路选择的坐标计算:

  • 线路选择与分区选择类似,其X坐标不同,但Y坐标的间隔同样为21像素。

以下是游戏角色选择的坐标计算:

  • 角色选择区域的起始坐标约为(136, 168)。
  • 四个角色水平排列,总宽度约为160像素,因此每个角色的宽度间隔约为40像素。
  • 角色选择的X坐标遵循公式:X = 基础X坐标 + 角色索引 * 40,Y坐标固定。

进入游戏的按钮是一个固定坐标的单击操作。

第二步:编写鼠标模拟函数

为了简化操作,我们首先编写两个通用的鼠标模拟函数:一个用于单击,一个用于双击。

以下是鼠标单击函数的代码框架:

void ClickAtPoint(int x, int y) {
    // 1. 查找游戏窗口
    // 2. 获取窗口左上角坐标(winX, winY)
    // 3. 计算实际屏幕坐标:screenX = winX + x; screenY = winY + y;
    // 4. 移动鼠标到(screenX, screenY)
    // 5. 执行鼠标按下和释放操作
}

以下是鼠标双击函数的代码框架,它在单击函数的基础上执行两次快速点击:

void DoubleClickAtPoint(int x, int y) {
    ClickAtPoint(x, y);
    // 短暂延迟
    ClickAtPoint(x, y);
}

第三步:定义登录配置数据结构

我们需要一个结构体来存储登录时的选择配置,例如选择第几个分区、第几条线路以及第几个角色。

以下是登录配置结构体的示例:

struct LoginConfig {
    int serverRegion; // 游戏分区 (0-9)
    int serverLine;   // 服务器线路 (0-9)
    int gameRegion;   // 游戏内分区 (0-9)
    int roleIndex;    // 角色索引 (0-3)
};

请注意,在代码中传递参数时,如果用户输入是从1开始的,需要在函数内部进行减1操作,因为我们的索引是从0开始计算的。

第四步:集成到自动登录流程

将上述函数和配置集成到主登录逻辑中。流程如下:

  1. 等待登录完成:登录账号密码后,等待界面加载(例如3秒)。
  2. 选择游戏分区:使用DoubleClickAtPoint函数,结合公式 Y = 316 + serverRegion * 21 进行点击。
  3. 选择服务器线路:使用DoubleClickAtPoint函数,结合公式 Y = 316 + serverLine * 21 进行点击。
  4. 等待角色界面加载:等待一段时间(例如3秒)。
  5. 选择游戏角色:使用ClickAtPoint函数,结合公式 X = 136 + roleIndex * 40 进行点击。
  6. 进入游戏:短暂延迟后,使用ClickAtPoint函数点击“进入游戏”的固定坐标。

在每一步操作之间,需要根据游戏客户端的响应速度添加适当的等待时间。

第五步:测试与调试

初始测试时,可能会因为等待时间不足或坐标计算有误导致失败。我们需要:

  • 添加调试信息:在每一步操作前后打印日志,方便定位问题。
  • 调整等待时间:将固定等待时间适当加长,确保界面元素加载完成。
  • 验证参数传递:确保从配置结构体到点击函数的参数传递正确,特别是索引的减1操作。

第六步:优化与完善(下节课预告)

当前的实现使用固定等待时间,不够健壮。下一节课我们将进行优化:

  • 智能等待:通过循环检测特定像素颜色或判断游戏窗口状态,来代替固定的Sleep等待。
  • 登录状态判断:在尝试进入游戏后,通过读取游戏内存(如人物血量指针或角色基址)是否被正确赋值,来判断是否真正登录成功,从而决定是否启动后续的挂机脚本。

总结

本节课中我们一起学习了游戏自动登录中选区、选线和选角色功能的设计与实现。我们分析了界面坐标并推导出点击公式,编写了通用的鼠标模拟函数,定义了登录配置,并将所有步骤集成到自动登录流程中。通过本次实践,我们掌握了通过模拟用户操作实现界面自动化的基本方法。在下一节课,我们将着重优化等待机制和登录状态判断,使我们的自动登录脚本更加稳定和智能。

课程 P143:游戏自动登录设计 - 等待与出错处理 🛠️

在本节课中,我们将学习如何为游戏自动登录脚本添加等待机制和错误处理逻辑,以提高其稳定性和可靠性。我们将基于上一课的代码进行完善,确保脚本能够应对网络延迟、输入错误等异常情况。


概述

本节课的目标是完善自动登录函数,增加对登录过程中各种状态的等待判断和错误处理。核心内容包括:检测客户端是否成功启动、验证账号密码输入是否正确、判断游戏角色是否正常进入游戏,并在失败时进行相应处理。


一、 检测客户端启动状态

上一节我们介绍了自动登录的基本流程,本节中我们来看看如何确保游戏客户端成功启动。

首先,在登录函数中,我们需要在输入账号密码前,判断游戏客户端窗口是否成功打开。这里使用一个循环来持续寻找窗口句柄,并设置超时机制。

// 伪代码示例:检测客户端窗口
HWND hWnd = NULL;
int i = 0;
for (i = 0; i < 1000; i++) {
    hWnd = FindWindow(NULL, "客户端窗口标题");
    if (hWnd != NULL) {
        break; // 找到窗口,跳出循环
    }
    Sleep(50); // 等待50毫秒
}
if (i >= 1000) {
    // 超过50秒未找到窗口,判定为启动失败
    return false;
}

以下是关键逻辑说明:

  • 循环寻找目标窗口,每次循环间隔50毫秒。
  • 设置最大循环次数为1000次,即最长等待50秒。
  • 如果超时仍未找到窗口,则函数返回 false,表示自动登录失败。

二、 验证账号密码输入

在成功找到客户端窗口并输入账号密码后,我们需要验证输入是否被游戏接受。

一种有效的方法是检测登录界面某个特定像素点的颜色变化。输入正确的账号密码后,界面通常会切换,导致特定位置颜色改变。

// 伪代码示例:检测密码输入后的界面变化
COLORREF targetColor = 0x00FF00; // 登录成功后目标点的颜色
POINT checkPoint = {296, 418}; // 需要检测的坐标点
int retryCount = 0;
bool loginSuccess = false;

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/4f4ddb8237fb581d36cce2281faa41bf_7.png)

while (retryCount < 50) { // 最大检测约2.5秒
    COLORREF currentColor = GetPixel(hDC, checkPoint.x, checkPoint.y);
    if (currentColor == targetColor) {
        loginSuccess = true;
        break; // 颜色匹配,登录成功
    }
    Sleep(50);
    retryCount++;
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/4f4ddb8237fb581d36cce2281faa41bf_9.png)

if (!loginSuccess) {
    // 账号密码可能错误,或登录失败
    return false;
}

以下是检测逻辑的要点:

  • 在输入密码后,开始循环检测预设坐标点的颜色。
  • 如果颜色变为预期的“成功状态”颜色,则跳出循环,继续后续步骤。
  • 如果循环超时(例如2.5秒)后颜色仍未变化,则判定为账号密码错误或登录失败,函数返回 false
  • 更精确的方法可以检测密码错误时弹出的提示框所在位置的颜色。

三、 判断游戏角色正常进入

账号密码验证通过后,脚本会点击进入游戏。接下来需要判断游戏角色是否成功加载并进入游戏世界。

这里我们使用读取游戏内存中“人物角色指针”的方法来判断。在角色未进入游戏前,该指针值为空或零;成功进入后,该指针会被初始化为有效的对象地址。

// 伪代码示例:通过角色指针判断游戏进入状态
DWORD roleBaseAddr = 0x12345678; // 人物角色基址
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, gamePID);
int waitTime = 0;
bool gameEntered = false;

while (waitTime < 1000) { // 最大等待约60秒
    DWORD rolePointer = 0;
    ReadProcessMemory(hProcess, (LPCVOID)roleBaseAddr, &rolePointer, sizeof(rolePointer), NULL);
    
    if (rolePointer > 0) {
        // 成功读取到有效的角色指针,说明已进入游戏
        gameEntered = true;
        break;
    }
    Sleep(60); // 每60毫秒检测一次
    waitTime++;
}

CloseHandle(hProcess);

if (!gameEntered) {
    // 超时未进入游戏,判定为登录失败
    TerminateProcess(...); // 结束游戏进程
    return false;
}

以下是此环节的核心步骤:

  • 以读写权限打开游戏进程。
  • 循环读取“人物角色指针”的内存地址。
  • 如果读出的值大于零,说明角色已加载,游戏进入成功。
  • 如果等待超时(例如60秒)后仍未读到有效值,则判定为登录失败,并强制结束游戏进程。


四、 错误处理与进程管理

在检测到任何一步失败时(如客户端未启动、密码错误、角色未进入),都应进行清理工作并返回错误状态。

关键操作是结束残留的游戏进程,防止留下未响应的客户端。

// 在判定登录失败的各个分支中
if (登录失败) {
    if (游戏进程句柄有效) {
        TerminateProcess(hProcess, 0); // 强制结束进程
        CloseHandle(hProcess);
    }
    return false; // 向主调函数报告失败
}

总结

本节课中我们一起学习了如何为自动登录脚本添加健壮的等待与错误处理机制。

  1. 客户端启动检测:通过循环和超时机制,确保脚本只在客户端成功启动后继续执行。
  2. 输入结果验证:利用界面像素颜色变化,判断账号密码是否输入正确。
  3. 游戏进入判断:通过读取游戏内存中关键指针的状态,确认角色是否成功进入游戏。
  4. 异常流程处理:在任何一步失败时,妥善结束游戏进程并返回错误信息,保证脚本的稳定性和可控性。

通过实现这些逻辑,你的自动登录脚本将能够更可靠地处理各种异常情况,为后续的自动挂机或任务脚本打下坚实基础。

课程 P144:游戏自动登录设计 - 界面设计 🎮

在本节课中,我们将学习如何为游戏自动登录程序设计一个用户界面。我们将基于上一节课的代码,添加列表控件、文本框、下拉框等元素,并完成界面布局与数据初始化。


界面布局设计

上一节我们介绍了项目的基础框架,本节中我们来看看如何设计主窗口的界面布局。

我们打开第154课的代码,在其基础上进行修改。首先调整窗口大小,并将测试按钮移至左侧。

以下是界面需要添加的核心控件:

  • 列表控件 (List Control):用于保存自动登录的账号、密码等相关信息。
  • 文本框 (Edit Control):两个,分别用于输入账号和密码。
  • 下拉框 (Combo Box):四个,分别用于选择游戏大区、服务器、服务器线路和游戏角色。

调整这些控件的位置,使其顶端对齐。接着,我们为下拉框添加说明标签。

以下是添加静态文本标签的步骤:

  1. 复制已有的静态文本控件。
  2. 按住 Ctrl 键并拖动鼠标进行复制。
  3. 将标签文本分别修改为:“游戏大区选择”、“游戏服务器选择”、“服务器线路选择”和“人物角色选择”。

调整完毕后,再次确保所有控件顶端对齐。


控件属性与数据初始化

界面布局完成后,我们需要对各个控件的属性进行设置,并初始化其数据。

首先,在列表控件的属性中,关闭“自动排序”功能。然后,我们初始化各个下拉框的选项。

以下是各下拉框的初始化数据:

  • 游戏大区:网通一区到四区,电信一区到六区。在控件的 Data 属性中进行初始化。
  • 服务器:其选项将根据所选大区动态变化。
  • 服务器线路:通常为1到10条线路,可先默认设置。
  • 人物角色:可初始化为“角色1”、“角色2”等。

接着,我们添加两个按钮控件,分别用于“添加”账号信息到列表和“删除”列表中的信息。将“自动登录”功能调整为复选框形式,并调整测试按钮的大小。

保存当前设计后,我们需要编写代码来实现服务器列表的动态联动。


实现控件联动逻辑

当用户选择不同的游戏大区时,服务器下拉框的选项应相应改变。我们通过为“游戏大区选择”下拉框添加事件处理函数来实现。

双击该下拉框控件,为其“选择改变”事件生成处理函数。在该函数中,我们需要编写分支逻辑。

核心逻辑公式如下:

  1. 获取游戏大区下拉框的指针。
  2. 获取当前选择的序号 index
  3. 使用 switch 语句,根据 index 值清空并填充服务器下拉框的选项。

例如,当 index 为 0(网通一区)时,向服务器下拉框添加“雪原”、“雄霸”等选项。

运行程序测试,选择“网通一区”时,服务器下拉框应显示对应的服务器列表。调整下拉框的大小以确保所有选项可见。

对于“服务器线路”和“人物角色”下拉框,我们可以直接进行静态初始化,例如默认设置10条线路和4个角色。


列表控件样式与初始化

接下来,我们需要详细设置列表控件的样式,并在窗口初始化函数中为其添加表头。

在对话框的 OnInitDialog 初始化函数末尾,添加自定义代码。

以下是初始化列表的核心代码步骤:

// 1. 获取列表控件指针
CListCtrl* pList = (CListCtrl*)GetDlgItem(IDC_LIST1);
// 2. 设置控件为报表样式
pList->ModifyStyle(0, LVS_REPORT);
// 3. 设置扩展样式(如网格线、整行选择)
pList->SetExtendedStyle(LVS_EX_GRIDLINES | LVS_EX_FULLROWSELECT);
// 4. 插入列并设置列宽
pList->InsertColumn(0, _T("账号"), LVCFMT_LEFT, 80);
pList->InsertColumn(1, _T("密码"), LVCFMT_LEFT, 80);
pList->InsertColumn(2, _T("大区"), LVCFMT_LEFT, 60);
pList->InsertColumn(3, _T("服务器"), LVCFMT_LEFT, 80);
pList->InsertColumn(4, _T("线路"), LVCFMT_LEFT, 50);
pList->InsertColumn(5, _T("角色"), LVCFMT_LEFT, 60);

编译运行后,列表控件将显示带有“账号”、“密码”、“大区”、“服务器”、“线路”、“角色”表头的表格。


完善默认设置

为了使界面启动时更友好,我们可以为各个下拉框设置默认选中项。

例如,在初始化函数中,将游戏大区默认设置为“网通四区”(索引为3)。设置后,需要手动调用服务器选择改变的事件处理函数,以联动初始化服务器列表。

同样,我们也可以为“服务器线路”和“人物角色”下拉框设置一个默认选项。

最后,确保所有控件变量都已正确关联。至此,一个功能清晰、具备联动效果的自动登录程序界面就设计完成了。


本节课中我们一起学习了游戏自动登录程序的界面设计。我们完成了主窗口的布局,添加了列表、文本框、下拉框等控件,实现了下拉框之间的数据联动,并完成了列表控件的样式初始化与数据展示。下一节课,我们将在此基础上,实现“添加”和“删除”账号信息的核心功能代码。

课程 P145:156-游戏自动登录设计-批量账号信息处理 📝

在本节课中,我们将学习如何为游戏自动登录程序编写批量账号信息处理功能。我们将基于上一节课的代码,实现账号信息的添加、显示和删除,并设计数据结构来管理多个账号。


概述

上一节我们介绍了游戏登录界面的基本设计。本节中,我们将重点实现批量账号信息的处理逻辑。这包括定义一个结构体来存储账号信息,使用动态数组管理多个账号,并将数据显示在表格控件中。


定义账号信息结构体

首先,我们需要定义一个结构体来存储单个账号的所有登录信息。这个结构体包含账号、密码、大区、服务器、线路和角色等信息。

struct AccountInfo {
    CString strUsername; // 用户名
    CString strPassword; // 密码
    int nZone;           // 大区索引
    int nServer;         // 服务器索引
    int nLine;           // 线路索引
    int nRole;           // 角色索引
};

为了管理多个账号,我们使用一个 std::vector 动态数组。

std::vector<AccountInfo> m_vAccountList; // 用于存放多个账号信息

定义好结构后,需要在文件头部包含必要的头文件,例如 <vector>


实现信息添加功能

接下来,我们实现“添加登录信息”按钮的功能。该功能需要从界面的各个输入控件中获取数据,填充到 AccountInfo 结构体中,然后将其添加到 m_vAccountList 数组。

以下是实现该功能的核心步骤:

  1. 获取各个控件(如编辑框、组合框)的指针。
  2. 从控件中读取文本或选项索引。
  3. 将读取到的值赋值给一个新的 AccountInfo 对象。
  4. 将新对象 push_backm_vAccountList 数组中。
  5. 调用更新表格的函数,将数组数据显示出来。

在编写代码时,需要注意控件ID冲突的问题。如果发现点击按钮没有反应,可能是消息映射关联到了错误的函数。需要检查资源ID,并确保消息映射指向正确的处理函数。


更新表格显示数据

数据添加到数组后,我们需要将其同步显示到列表控件中。为此,我们编写一个 UpdateListCtrl 函数。

这个函数的主要逻辑是清空列表现有项,然后遍历 m_vAccountList 数组,将每个账号的六项信息依次插入到列表的新行中。

对于非文本类型的数据(如大区、服务器索引),我们需要将其转换为用户可读的文字。这需要两个辅助函数:

  • GetZoneName(int nIndex): 根据大区索引返回大区名称(如“网通一区”)。
  • GetServerName(int nZone, int nServer): 根据大区索引和服务器索引返回服务器名称。这里使用嵌套的 switch 结构来实现。

CString GetServerName(int nZone, int nServer) {
    switch(nZone) {
        case 0: // 网通一区
            switch(nServer) {
                case 0: return _T("龙争虎斗");
                case 1: return _T("雄霸天下");
                // ... 其他服务器
            }
            break;
        case 1: // 电信四区
            switch(nServer) {
                case 0: return _T("啸天");
                // ... 其他服务器
            }
            break;
        // ... 其他大区
    }
    return _T("未知");
}

在插入数据时,注意索引通常从0开始,而显示给用户时通常从1开始(例如“线路1”、“角色1”),因此需要进行 +1 处理。


实现信息删除功能

我们还需要提供删除已添加账号信息的功能。以下是删除选中行信息的步骤:

  1. 获取列表控件中当前选中的行号。
  2. 如果行号有效(大于等于0),则根据该行号找到 m_vAccountList 数组中对应的元素。
  3. 使用 vector::erase 方法删除该元素。
  4. 删除元素后,必须立即调用 break 退出查找循环,因为数组大小和索引已发生变化。
  5. 最后,调用 UpdateListCtrl 函数刷新表格显示。
// 删除选中行
int nSel = m_listCtrl.GetNextItem(-1, LVNI_SELECTED);
if (nSel >= 0) {
    for (size_t i = 0; i < m_vAccountList.size(); ++i) {
        if (i == nSel) {
            m_vAccountList.erase(m_vAccountList.begin() + i);
            UpdateListCtrl(); // 更新表格
            break; // 重要:删除后立即退出循环
        }
    }
}


总结与下节预告

本节课中,我们一起学习了批量账号信息处理的核心功能:

  1. 定义了 AccountInfo 结构体来存储账号信息。
  2. 使用 std::vector<AccountInfo> 管理账号列表。
  3. 实现了向列表添加账号信息的功能。
  4. 编写了将数据更新到表格控件显示的 UpdateListCtrl 函数。
  5. 实现了删除表格中选中行账号信息的功能。

目前,我们的程序还缺少两个重要功能:

  1. 重复账号检测:在添加信息时,应检查 m_vAccountList 中是否已存在相同账号名的记录,避免重复添加。
  2. 数据持久化:将 m_vAccountList 中的数据保存到文件,并在程序启动时自动加载,这样就不需要每次重启都重新输入账号。

下一节课,我们将首先实现重复账号检测功能,然后重点讲解如何将账号信息保存到配置文件中。


课程 P146:游戏自动登录设计 - 保存及载入账号信息 📂

在本节课中,我们将学习如何将游戏账号信息保存到本地配置文件,并在程序启动时自动载入这些信息。这将极大提升工具的便利性,避免用户每次都需要手动输入账号。

概述与目标

上一节我们完成了账号列表的界面交互功能。本节中,我们将实现数据的持久化存储。核心目标是:设计两个函数,一个用于将内存中的账号列表以二进制形式保存到文件,另一个用于从文件读取数据并恢复到内存和界面中。

添加测试按钮

为了便于开发和测试,我们首先在界面上添加两个临时按钮。

以下是按钮的功能说明:

  • 保存账号信息:点击后,将当前列表中的所有账号信息保存到指定的配置文件中。
  • 读取账号信息:点击后,从配置文件中读取账号信息,并更新到列表控件中。

测试成功后,我们会将相关代码集成到窗口初始化函数和账号添加/删除逻辑中,实现自动保存与载入。

设计保存函数

本节中我们来看看如何将数据保存到文件。我们采用二进制文件流的方式,将内存中的数据原封不动地写入文件。

核心操作是使用 std::ofstream(输出文件流),并指定 std::ios::out | std::ios::binary 模式打开文件。

void CGameLoginDlg::SaveAccountToFile()
{
    std::ofstream ofs("account.cfg", std::ios::out | std::ios::binary);
    if (!ofs.is_open())
    {
        // 文件打开失败,进行清理并返回
        ofs.clear();
        ofs.close();
        return;
    }

    // 遍历存储账号信息的动态数组(vector)
    for (auto& acc : m_vAccountList)
    {
        // 将每个账号结构体的内存数据写入文件
        ofs.write((char*)&acc, sizeof(AccountInfo));
    }

    // 写入完成后清理
    ofs.clear();
    ofs.close();
}

代码解释

  1. "account.cfg" 是配置文件名,后缀可以任意指定(如 .txt, .ini, .dat 等)。
  2. std::ios::out 表示写入模式,std::ios::binary 表示以二进制模式操作,防止数据被转换。
  3. 使用 is_open() 判断文件是否成功打开。
  4. 通过 write 函数,将 AccountInfo 结构体变量的内存映像直接写入文件。写入顺序必须与结构体定义一致。
  5. 操作完成后,调用 clear()close() 是良好的编程习惯。

设计读取函数

上一节我们介绍了如何保存数据,本节中我们来看看如何从文件读取数据并恢复。我们使用 std::ifstream(输入文件流),并指定 std::ios::in | std::ios::binary 模式。

void CGameLoginDlg::LoadAccountFromFile()
{
    std::ifstream ifs("account.cfg", std::ios::in | std::ios::binary);
    if (!ifs.is_open())
    {
        // 文件打开失败(可能首次运行),清理并返回
        ifs.clear();
        ifs.close();
        return;
    }

    // 可选:清空当前内存中的列表,防止重复添加
    m_vAccountList.clear();

    AccountInfo tempAcc;
    // 循环读取,直到文件末尾
    while (!ifs.eof())
    {
        // 读取一个结构体大小的数据到临时变量
        ifs.read((char*)&tempAcc, sizeof(AccountInfo));
        // 判断本次读取是否成功(可能遇到文件尾)
        if (ifs.fail())
        {
            break;
        }
        // 将读取到的账号信息添加到内存列表
        m_vAccountList.push_back(tempAcc);
    }

    ifs.clear();
    ifs.close();

    // 关键步骤:调用之前编写的函数,将内存列表数据更新到界面控件
    RefreshAccountToListCtrl();
}

代码解释

  1. std::ios::in 表示读取模式。
  2. ifs.eof() 判断是否到达文件末尾。
  3. ifs.read(...) 从文件中读取一个 AccountInfo 结构体大小的数据。
  4. ifs.fail() 判断单次读取操作是否失败(例如在文件末尾尝试读取),失败则退出循环。
  5. 读取成功后,通过 push_back 将数据添加到 m_vAccountList 动态数组中。
  6. 最后必须调用 RefreshAccountToListCtrl() 函数,将内存数据同步更新到列表控件显示。

集成与自动化

测试确认保存和读取功能正常工作后,我们将移除测试按钮,并将功能集成到主流程中。

以下是需要集成的关键点:

  • 程序启动时自动载入:在对话框的初始化函数(如 OnInitDialog)中调用 LoadAccountFromFile()
  • 数据变更时自动保存:在添加账号、删除账号、修改账号信息等任何导致 m_vAccountList 发生变化的操作之后,调用 SaveAccountToFile()

这样,用户每次添加账号信息都会自动保存到文件。下次启动程序时,所有账号信息会自动加载并显示在列表中,无需手动操作。

注意事项与调试

在集成过程中,需要注意文件路径问题。如果程序调试运行时的“工作目录”与可执行文件(.exe)所在目录不同,可能导致找不到配置文件。

解决方法

  1. 在代码中使用绝对路径。
  2. 或者,在调试器的设置中配置正确的工作目录。
  3. 在文件打开失败的处理分支中,可以输出调试信息(注意格式化字符串的正确性),帮助定位问题。

总结

本节课中我们一起学习了游戏自动登录工具数据持久化的实现方法。我们掌握了两个核心函数:SaveAccountToFile 用于将账号列表以二进制格式保存到本地文件,LoadAccountFromFile 用于从文件读取并恢复数据。通过将这两个函数集成到程序初始化和数据变更事件中,我们实现了账号信息的自动保存与载入,大大提升了工具的实用性和用户体验。

课程 P147:游戏自动登录设计 - 批量登录代码设计 🎮

在本节课中,我们将学习如何设计批量登录游戏账号的代码。我们将基于已有的单个账号登录函数,通过循环调用它来实现多个账号的自动登录。


回顾与准备

上一节我们介绍了单个账号的登录函数。本节中我们来看看如何批量调用这个函数。

首先,我们打开第157课的代码。我们已经设计好了账号登录函数 water_login。批量登录的核心思路就是循环调用这个函数。

账号和密码的来源是我们的动态数组 vector<user_data_table>。但这里存在一个细节问题:登录函数 water_login 所使用的数据结构 login_data 与我们存储账号的动态数组结构 user_data_table 略有不同。

这两个结构的差异意味着我们在调用 water_login 函数前,需要将数据从一个结构复制到另一个结构。当然,你也可以通过修改源代码来统一这两个结构,使调用更简单。本节课,我们先讨论在不修改原有结构的情况下如何实现。


设计批量登录函数

我们可以编写一个新的函数来处理批量登录。

这个函数的作用是遍历 vector 动态数组中的所有账号密码信息,并依次进行登录。遍历动态数组有多种方式,之前我们使用过迭代器指针。本节课我们介绍另一种形式:通过 size() 函数获取数组大小进行循环。

以下是实现步骤:

  1. 使用 size() 函数获取动态数组中账号信息的个数。
  2. 根据这个数量进行 for 循环遍历。
  3. 在循环中,将每个 user_data_table 元素中的账号、分区、密码等信息,复制到一个临时的 login_data 结构变量中。
  4. 将这个临时变量的地址传给 water_login 函数,实现自动登录。

核心代码逻辑如下:

void batch_login() {
    int count = user_data_vector.size(); // 获取账号数量
    for (int i = 0; i < count; i++) {
        login_data temp_data;
        // 将 user_data_vector[i] 的信息复制到 temp_data
        temp_data.account = user_data_vector[i].account;
        temp_data.password = user_data_vector[i].password;
        temp_data.server = user_data_vector[i].server + 1; // 注意分区编号的调整
        // 调用登录函数
        water_login(&temp_data);
        // 可在此处添加等待时间,确保上一个账号登录完成
        Sleep(3000);
    }
}

关于分区编号有一个细节需要注意:我们保存的分区号是从0开始的,但游戏登录时可能需要从1开始。因此,在复制数据时需要做 +1 处理。这个调整可以在保存信息时进行,也可以在复制到临时结构时进行,这需要你根据整体代码结构来决定。


测试与优化

代码编写完成后,我们需要进行测试。

首先重新生成项目。在测试时,我们可以将之前单个登录的代码注释掉,直接调用批量登录函数 batch_login

这里有一个重要的优化建议:最好将批量登录的代码放在一个独立的线程中运行。如果不这样做,由于登录过程等待时间较长,可能会导致主程序窗口出现“假死”状态,无法响应用户操作。

在测试过程中,可能会遇到登录不成功的情况。这可能是由于安全软件干扰,或者等待时间不足。我们可以尝试以下调整:

  1. 暂时退出安全软件。
  2. 适当增加登录过程中的等待时间。例如,在 water_login 函数内部,等待登录器打开、选择分区等环节的延时可以加长。

调整后再次生成并测试。使用两个测试账号即可验证功能是否正常。程序会等待第一个账号完全登录(读取完人物信息)后,再开始下一个账号的登录流程。


总结与作业

本节课我们一起学习了游戏账号批量登录的代码设计。我们利用已有的登录函数,通过遍历存储账号信息的动态数组,并处理数据结构差异,实现了自动化批量登录。

课后作业
尝试修改源代码,统一 user_data_tablelogin_data 这两个结构。目标是只使用一个统一的结构来完成账号信息存储和登录传递,使代码更加简洁。修改成功后,可以删除冗余的结构定义。

在下一节课中,我们将开始编写游戏管理脚本的相关功能,例如掉线检测与自动重连。


课程 P148:游戏自动登录设计 - 断线分析 🕵️♂️

在本节课中,我们将学习如何分析游戏在断线时的行为,并定位用于判断网络连接状态的代码和数据。我们将通过分析网络事件和函数调用,来理解游戏是如何检测并响应断线情况的。

断线现象与初步分析

上一节我们介绍了自动登录的基本概念,本节中我们来看看如何处理网络断线的情况。当游戏与服务器断开连接时,通常会弹出一个提示窗口。

我们可以通过禁用本地网络连接来模拟断线,并观察游戏的反应。游戏在断线时会弹出一个“游戏断开链接”的窗口。一种直接的方法是查找这个窗口本身,另一种方法是分析断线时游戏内部数据的变化。直接搜索窗口可能更简单快捷。

从字符串入手的方法

以下是尝试通过搜索游戏内字符串来判断断线的方法:

  1. 在游戏内存中搜索“与服务器断开”或类似的字符串。
  2. 附加调试器,观察该字符串是否为全局变量。
  3. 发现该字符串并非全局变量,游戏重启后地址会变化,因此此方法不可行。

分析网络事件判断函数

既然直接搜索字符串行不通,我们需要从判断网络连接状态的函数入手。游戏通常会使用 WSAEnumNetworkEvents 这个API函数来枚举网络事件。

winsock.h 头文件中定义了一个事件:FD_CLOSE,它表示套接字关闭事件。其值定义为 0x20。当网络断开、超时或连接被重置时,通常会触发此事件。

WSAEnumNetworkEvents 函数的第三个参数是一个指向 LPWSANETWORKEVENTS 结构的指针。该结构中的 lNetworkEvents 字段是一个长整型变量,用于存放已发生的网络事件。如果 FD_CLOSE 事件发生,则 lNetworkEvents 的值会包含 0x20

此外,该结构还有一个 iErrorCode 数组。如果 FD_CLOSE 事件发生且没有错误,iErrorCode[FD_CLOSE_BIT](即数组下标为5的元素)的值应为0。如果该值不为0,则表明关闭时出现了错误。

定位关键判断代码

我们在游戏中找到调用 WSAEnumNetworkEvents 函数的位置,并下断点进行分析。

断点触发后,我们查看上层调用代码。发现有一段关键判断:代码比较一个值(来自 lNetworkEvents 字段)是否等于 0x20FD_CLOSE)。

公式:判断是否为关闭事件
if (lNetworkEvents & 0x20) { ... }

如果相等,则表明发生了关闭事件。接着,代码会检查 iErrorCode[FD_CLOSE_BIT] 数组中的错误代码。

代码:检查关闭事件的错误码

// 假设 lNetworkEvents 地址为 0x3C
if (*(DWORD*)(0x3C) == 0x20) {
    // 发生了关闭事件
    DWORD errorCode = *(DWORD*)(0x3C + 0x24); // 获取错误码数组的第五个元素(偏移0x24)
    if (errorCode != 0) {
        // 关闭时出错,意味着断线
        // 此处可能调用显示断线窗口的函数
    }
}

分析发现,当错误码不为0时,程序会跳转到处理断线的流程,并调用一个函数(我们暂称为 CallShowDisconnectWindow)。我们推测这个函数负责弹出断线提示窗口。

验证与总结

为了验证,我们尝试修改代码,跳过错误码检查或直接调用疑似显示窗口的函数。实验发现,调用特定函数后,游戏成功弹出了断线服务器选择窗口,证实了我们的分析。

本节课中我们一起学习了如何通过分析 WSAEnumNetworkEvents 函数及其相关数据结构来定位游戏的断线检测机制。我们找到了判断 FD_CLOSE 事件和检查错误码的关键代码位置,并验证了其与断线提示窗口的关联。这为后续实现自动重连逻辑打下了基础。下一节课,我们将继续深入分析用于标记断线状态的全局变量。

课程 P149:160-游戏自动登录设计-分析断线判断标志 🔍

在本节课中,我们将学习如何分析一款网络游戏客户端的断线判断机制。我们将通过逆向工程的方法,定位并理解游戏在检测到网络断开时使用的关键标志。


概述

本节课的核心目标是找到游戏客户端用于判断网络连接是否断开的标志位。我们将从上一节课的分析结果出发,深入游戏代码,追踪其网络事件处理逻辑,最终定位到关键的套接字状态值。


分析过程

上一节我们介绍了如何定位到显示“断线”提示窗口的函数。本节中,我们来看看这个函数周围的代码逻辑,以找到触发断线判断的具体条件。

首先,我们附加到游戏进程。在上一节课的分析中,我们已经找到了显示断线窗口的函数调用位置。这个函数需要特定的条件才会被触发。

寻找断线判断逻辑

断线可能由多种原因引起,例如服务器关闭、网络适配器被禁用或物理网线被拔掉。因此,游戏代码中可能存在多处判断逻辑。在我们找到的函数附近,可以看到至少有三处不同的判断分支。

以下是其中一处关键判断的代码逻辑:

cmp dword ptr [ebx+10h], -1
jnz short loc_continue
; 如果 [ebx+10h] 不等于 -1,则跳转到正常流程
; 否则,继续执行断线处理代码

这段代码读取 ebx+10h 地址处的值,并与 -1 进行比较。如果不等于 -1,则跳过断线处理。

定位关键数据

为了验证这个判断,我们需要找到写入 ebx+10h 地址的代码。通过搜索内存写入访问,我们找到了几处可能的位置。

其中一处关键的写入操作如下:

mov dword ptr [ebx+10h], 0FFFFFFFFh ; 写入 -1

这段代码将值 -1 写入目标地址。结合前面的判断,可以推测:当该地址的值为 -1 时,游戏判定为网络已断开

验证数据含义

我们进一步分析发现,ebx+10h 地址实际上存储的是一个套接字(Socket)句柄。在正常的网络通信中,这是一个有效的句柄值。当网络断开时,游戏内核或网络层可能会将这个句柄值设置为 INVALID_SOCKET,在许多系统中,这个值就是 -1

因此,游戏的断线判断逻辑可以总结为以下公式:

断线标志 = (套接字句柄 == -1)

当此条件为真时,游戏就会执行显示断线窗口等一系列断线处理操作。

观察发包行为

为了进一步确认,我们观察了游戏发送网络数据包(发包)的函数。在发包函数入口处,也有类似的检查:

mov edi, [ecx+10h]
cmp edi, -1
jz short loc_skip_send ; 如果套接字无效,则跳过发送

当网络断开、套接字被置为 -1 后,所有发包请求都会被此检查跳过,从而避免了无效的网络操作。


核心发现总结

通过以上分析,我们确定了游戏客户端的断线判断核心机制:

  1. 关键地址:游戏使用一个固定的内存地址(例如 [基址+偏移])来存储当前网络连接的套接字句柄。
  2. 判断逻辑:通过持续检查该地址的值是否等于 -1(即 0xFFFFFFFF)来判断网络是否断开。
  3. 行为影响:一旦检测到套接字为 -1,游戏将:
    • 阻止后续所有网络数据包的发送。
    • 调用函数,显示“网络断开”或“重新连接”的用户界面。

这个标志位非常稳定,无论是服务器主动断开、本地禁用网卡还是其他网络故障,最终都会导致该标志被设置为 -1


下节课预告

本节课中我们一起学习了如何定位并分析游戏断线的内存判断标志。我们找到了关键的套接字句柄地址,并理解了其值为 -1 时代表断线。

在下一节课中,我们将利用这个发现来编写代码。我们将学习如何读取游戏内存中的这个标志位,并实现一个自动检测断线、然后自动重启游戏或执行登录脚本的自动化工具。

课程 P15:026-封装选怪功能-写代码 🧩

在本节课中,我们将学习如何将游戏中的“选择怪物”功能进行代码封装。我们将从现有代码出发,通过添加新的类和结构体,使代码结构更清晰、更易于管理。


概述

上一节我们介绍了选怪功能的基本思路。本节中,我们将动手编写代码,对玩家对象和怪物对象的数据访问逻辑进行封装。核心目标是创建清晰的类结构来管理游戏对象及其内存偏移量。

代码结构设计

首先,我们打开第23课的代码。为了提升代码可读性,我们计划添加两个新的分类。

以下是主要的封装思路:

  1. 一个类用于管理玩家对象的机制。
  2. 一个结构体用于管理所有对象(包括怪物)的数据及其相关内存偏移量。

我们将机制相关的代码放在前面的类中,而偏移量等数据定义放在后面的游戏结构体中。

封装玩家对象

我们首先封装玩家对象。复制相关代码到新创建的类中。

// 示例:玩家对象类封装
class CPlayer {
public:
    // 构造函数、析构函数等
    // 成员函数,如选择目标
    void SelectTarget(int targetIndex);
private:
    // 玩家相关数据成员
};

在封装过程中,需要注意代码格式。例如,我们发现一处缺少分号,需要补充完整以确保编译通过。

封装怪物列表数据

接下来,我们封装所有对象的数据,特别是怪物列表。这涉及到一个结构体,用于存储对象ID和索引等信息。

// 示例:怪物对象结构体
struct MonsterData {
    int objectID; // 对象ID,偏移量例如 0x04
    int index;    // 在列表中的索引
    // ... 其他属性
};

之前我们的怪物列表属性中可能缺少索引成员。因此,需要回过头在怪物列表类中添加相应的成员变量,并在初始化函数(如GetData)中从正确偏移量(例如0x04)读取并初始化这个索引值。

功能测试与调试

封装完成后,我们需要进行测试。测试思路是:从怪物列表中获取第一个怪物对象的索引,然后调用封装好的选怪函数。

以下是测试步骤:

  1. 在测试单元(如Hook单元)中,声明一个全局的玩家对象变量。
  2. 从怪物列表中取出第一个怪物的索引。
  3. 调用玩家对象的SelectTarget函数,传入该索引。

我们首次运行测试时,发现没有成功选中怪物。通过调试信息发现,传入的索引值可能为0或无效。

我们通过以下方法排查问题:

  • 在代码中打印调试信息,确认SelectTarget函数是否被执行。
  • 检查怪物列表的初始化代码,确认索引值是否正确读取和写入。
  • 使用游戏内存查看工具(如CE)直接查看游戏数据,验证怪物对象和索引值在内存中的实际状态。

经过排查,我们发现怪物列表的第一个对象可能不是怪物(例如可能是玩家自身),导致索引为0的对象无效。作为临时测试方案,我们将测试索引手动改为1。后续正式功能中,需要增加条件判断来筛选出真正的怪物对象。

修改后再次测试,成功选中了怪物。按下攻击键,角色会向选中的怪物移动并攻击。我们也测试了切换选中目标,功能正常。

总结

本节课中我们一起学习了如何封装选怪功能。我们通过创建CPlayer类和MonsterData结构体,将分散的逻辑和数据组织起来。过程中,我们经历了编写、测试、调试和解决问题的完整流程。

关键点包括:设计清晰的类结构、管理内存偏移量、在封装时查漏补缺(如添加缺失的索引成员)、以及通过打印日志和使用外部工具进行有效的调试。

目前我们实现了基础的选怪功能,后续可以在此基础上增加更智能的怪物筛选条件。下一节课我们将继续完善相关功能。

课程P150:161-游戏自动登录设计-遍历所有游戏进程与窗口 🎮

在本节课中,我们将学习如何设计游戏断线检测功能,这是实现游戏自动登录的关键部分。我们将重点讲解如何遍历所有游戏窗口,并获取其对应的进程信息,为后续读取在线状态打下基础。

概述 📋

断线检测是自动登录功能的一部分。其核心原理是:首先遍历所有游戏窗口,根据窗口句柄获取对应的进程ID,然后通过读取特定内存数据来判断游戏是否在线。如果读取到的数值等于-1,则判定为掉线状态。

上一节我们介绍了自动登录的基本框架,本节中我们来看看如何枚举系统中的游戏窗口和进程。

遍历游戏窗口与进程

为了实现断线检测,我们需要先获取所有游戏窗口及其对应进程的信息。为了方便后续操作,我们将创建一个类成员函数来执行此任务。

首先,我们定义一个结构体来存储所需信息,并创建一个全局动态数组来存放这些结构体数据。

#include <vector>
#include <windows.h>

struct GameProcessInfo {
    HWND hWnd;        // 游戏窗口句柄
    DWORD dwProcessId; // 进程ID
    HANDLE hProcess;   // 游戏进程句柄
    TCHAR szTitle[256]; // 游戏窗口标题(通常是账号名)
};

// 全局变量,用于存储所有游戏进程信息
std::vector<GameProcessInfo> g_vGameProcesses;

接下来,我们设计一个枚举窗口的回调函数。Windows API EnumWindows 可以帮助我们遍历所有顶层窗口。

以下是枚举窗口回调函数的关键格式和实现:

BOOL CALLBACK EnumWindowsProc(HWND hWnd, LPARAM lParam) {
    // 将传入的参数转换为我们的动态数组指针
    std::vector<GameProcessInfo>* pVec = (std::vector<GameProcessInfo>*)lParam;

    // 获取窗口类名,用于判断是否为游戏窗口
    TCHAR szClassName[256];
    GetClassName(hWnd, szClassName, 256);

    // 假设游戏窗口类名为 "GameWindowClass"
    if (_tcscmp(szClassName, _T("GameWindowClass")) == 0) {
        GameProcessInfo info = {0};
        info.hWnd = hWnd;

        // 通过窗口句柄获取进程ID
        GetWindowThreadProcessId(hWnd, &info.dwProcessId);

        // 通过进程ID打开进程,获取进程句柄
        info.hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, info.dwProcessId);

        // 获取窗口标题(通常是游戏账号名)
        GetWindowText(hWnd, info.szTitle, 256);

        // 将信息添加到动态数组中
        pVec->push_back(info);
    }

    // 返回TRUE以继续枚举下一个窗口
    return TRUE;
}

然后,我们创建类成员函数来调用此枚举过程,并清空之前的数组数据。

void CYourClass::EnumerateGameWindows() {
    // 清空之前的进程信息
    g_vGameProcesses.clear();

    // 开始枚举所有顶层窗口
    // 将全局动态数组的地址作为参数传递给回调函数
    EnumWindows(EnumWindowsProc, (LPARAM)&g_vGameProcesses);
}

测试与验证

为了验证我们的代码是否正确获取了游戏进程信息,我们可以添加一个测试按钮,并在点击时调用枚举函数并打印结果。

以下是测试按钮点击事件的处理代码示例:

void CYourClass::OnBtnTest() {
    // 调用枚举函数
    EnumerateGameWindows();

    // 遍历并打印所有找到的游戏进程信息
    for (const auto& info : g_vGameProcesses) {
        // 输出窗口句柄、进程ID、进程句柄和窗口标题
        // 在实际代码中,这里可以使用OutputDebugString或写入日志文件
        // 例如:printf("HWND: %p, PID: %lu, HPROCESS: %p, Title: %s\n", 
        //        info.hWnd, info.dwProcessId, info.hProcess, info.szTitle);
    }
}

编译并运行程序后,点击测试按钮。如果游戏窗口已打开,你应该能在输出中看到类似以下的信息,这证明我们成功获取了游戏窗口和进程的数据。

HWND: 0x000A1B2C, PID: 52988, HPROCESS: 0x00000048, Title: 玩家账号1
HWND: 0x000C3D4E, PID: 3404, HPROCESS: 0x00000052, Title: 玩家账号2

注意:进程句柄是系统内核对象的一个引用,每次打开都可能不同。窗口标题通常是游戏内显示的账号名,这在多开管理时非常有用。

总结 🎯

本节课中我们一起学习了如何遍历系统中的游戏窗口和进程。我们掌握了以下核心步骤:

  1. 定义数据结构:创建结构体来存储窗口句柄、进程ID、进程句柄和窗口标题。
  2. 使用Windows API:利用 EnumWindows 函数和其回调函数遍历所有顶层窗口。
  3. 筛选目标窗口:在回调函数中,通过比较窗口类名来识别出特定的游戏窗口。
  4. 获取进程信息:使用 GetWindowThreadProcessIdOpenProcess 获取与窗口关联的进程详细信息。
  5. 存储与测试:将信息存入全局动态数组,并通过测试函数验证结果。

通过本课的学习,我们已经成功获取了游戏进程的关键句柄信息。下一节课,我们将在此基础上,学习如何读取每个进程的特定内存数据,从而判断其在线状态,完成断线检测的核心功能。

课程P151:162-游戏自动登录设计-监视游戏进程状态 🎮

在本节课中,我们将学习如何扩展游戏进程信息结构,并编写代码读取游戏的在线状态和角色名称等关键信息。我们将基于上一节课的代码进行修改,实现更全面的游戏状态监控。


概述

上一节我们介绍了如何获取游戏进程的基本信息。本节中,我们来看看如何读取游戏内部的特定数据,例如在线状态和角色名称。我们将通过修改数据结构和回调函数来实现这些功能。

修改数据结构

首先,我们需要修改存储游戏进程信息的结构体,以容纳新的数据字段。

以下是需要添加的字段:

  • 在线状态:一个布尔类型或DWORD类型的变量,用于表示游戏是否在线。
  • 角色名称:一个字符数组,用于存储游戏角色的名字,32字节的长度通常足够。

修改后的结构体定义示例如下(在头文件中):

typedef struct _GAME_PROCESS_INFO {
    // ... 其他原有字段 ...
    BOOL bIsOnline;     // 在线状态
    CHAR szRoleName[32]; // 角色名称
    // 可根据需要添加更多字段,如血量、死亡状态等
} GAME_PROCESS_INFO;

在回调函数中读取数据

定义了新的结构体成员后,我们需要在枚举窗口的回调函数中进行数据读取。

以下是实现步骤:

  1. 获取基址指针:使用ReadProcessMemory函数读取存储游戏数据的基础内存地址。

    DWORD dwBaseAddr = 0;
    ReadProcessMemory(hProcess, (LPCVOID)基址偏移, &dwBaseAddr, sizeof(dwBaseAddr), NULL);
    
  2. 读取在线状态:在线状态的实际地址通常位于基址指针加上某个偏移量(例如0x10)的位置。读取该地址的4字节数据。

    DWORD dwSocketStatus = 0;
    ReadProcessMemory(hProcess, (LPCVOID)(dwBaseAddr + 0x10), &dwSocketStatus, sizeof(dwSocketStatus), NULL);
    

    根据游戏逻辑,dwSocketStatus的值可能为-1表示掉线,其他值表示在线。我们需要将其转换并赋值给结构体的bIsOnline成员。

  3. 读取角色名称:角色名称的地址可能位于另一个偏移量(例如0x18)。我们直接读取32字节到字符缓冲区中。

    CHAR szNameBuffer[32] = {0};
    ReadProcessMemory(hProcess, (LPCVOID)(dwBaseAddr + 0x18), szNameBuffer, sizeof(szNameBuffer), NULL);
    strncpy(pInfo->szRoleName, szNameBuffer, sizeof(pInfo->szRoleName) - 1);
    pInfo->szRoleName[sizeof(pInfo->szRoleName) - 1] = '\0'; // 确保字符串终止
    

重要提示:所有读取操作必须在调用PostMessage函数将信息发送到主窗口之前完成。

代码测试与验证

完成代码编写后,进行编译并测试。

  1. 启动游戏并登录:确保目标游戏进程正常运行。
  2. 运行监控程序:程序应能正确枚举到游戏窗口。
  3. 验证数据:在调试信息或程序界面中,检查读取到的在线状态(例如,非零值或特定句柄值表示在线)和角色名称是否正确显示。
  4. 测试掉线检测:可以尝试断开网络连接,观察程序是否能检测到状态变为“离线”(例如,状态值变为0或-1)。

测试时,可以将在线状态以十六进制显示,以便更直观地查看套接字句柄等底层信息。

优化建议:使用独立线程

在自动登录或持续监控过程中,如果相关函数直接在主线程中执行,可能会导致程序界面卡顿或无响应。

为了解决这个问题,建议将耗时的操作(如自动登录逻辑)放在独立的线程中执行。可以使用_beginthreadCreateThread函数来创建线程。

// 示例:创建线程
HANDLE hThread = CreateThread(NULL, 0, AutoLoginThreadFunc, NULL, 0, NULL);
if (hThread) {
    CloseHandle(hThread); // 如果不需等待线程结束,可立即关闭句柄
}

这样,后台任务就不会阻塞用户界面,从而提升程序的响应性和用户体验。


总结

本节课中我们一起学习了如何扩展游戏进程监控功能。我们修改了数据结构以保存在线状态和角色名称,并在回调函数中利用ReadProcessMemory读取了这些游戏内存数据。最后,我们讨论了将耗时操作移至独立线程以优化程序性能的方法。通过本课实践,你可以掌握读取游戏特定状态信息的基本流程。

课程 P152:163-游戏自动登录设计-断线重连设计 OffLineReLogin 🎮

在本节课中,我们将学习如何为游戏自动登录功能设计和编写断线重连的代码。我们将基于第162课的代码进行扩展,添加检测游戏是否断线以及自动重新登录的功能。


概述与准备工作

上一节我们介绍了游戏自动登录的基础设计。本节中,我们来看看如何实现断线重连功能。

首先,我们需要打开第162课的代码项目。接着,在项目内部添加实现断线重连所需的函数。

我们可以手动在项目文件中添加这些函数。添加完成后,确保所有函数都已正确包含在项目中。


检测游戏断线

要实现重连,首先要检测游戏是否已经断线。在开始检测之前,我们需要对相关的动态数据和进程信息进行初始化。

以下是初始化相关数据的步骤:

  • 初始化动态数据。
  • 初始化断线检测所需的进程信息数据。

初始化数据之后,就可以开始遍历相关的进程数据。另一种写法是直接在回调函数中修改数据,但这里我们采用分离处理的设计,逻辑会更清晰。

此时开始遍历进程信息,目标是找出已经掉线的游戏进程。我们需要引入标准库的相关程序来辅助遍历。

遍历时,我们进行判断:

  • 如果某个进程不在线,则判定为掉线。

如果检测到掉线情况,就进入掉线处理流程,即重新连接并登录相应的游戏账号。


重新登录流程

在掉线处理中,我们需要重新登录账号。实际上,我们已经在程序的结构体中保存了账号和密码信息,可以直接使用。

游戏窗口的标题通常包含账号信息,我们可以通过获取窗口标题来得到账号。此外,也可以通过读取游戏内存来获取账号。

获取账号后,我们需要查询存储密码的表格,以完成登录。目前,我们暂时使用窗口标题作为账号。

接下来,我们需要遍历之前定义的 VTUidetab 表格,以找到对应用户的登录信息。这次遍历的目的是获取完整的用户登录凭证。

我们先编译生成一下代码,检查是否有错误。如果没有错误,就继续执行。

我们将取出的窗口标题与用户名进行比较。用户名来自用户信息结构体中的 User Name 字段。如果两者相同,就找到了对应的登录信息。

重新登录前,需要先结束掉已经断线的游戏进程。我们使用进程结束函数,并传入一个整数作为退出代码。

结束进程后,可以等待一小段时间,例如一秒钟,以确保进程完全关闭。

进程结束后,就可以开始重新登录。我们可以参考自动登录或批量登录功能中的代码。

在头文件中找到批量登录的函数,将相关代码复制过来,并在此基础上进行修改。

我们不需要使用原有的循环结构,只需对登录数据 LogData 进行填充。同时,将代码中对应的下标替换为我们找到的用户信息指针。

替换指针后,就可以在此处尝试重新登录。


整合与循环检测

我们再来审视一下整体逻辑。可以将断线检测和重连逻辑单独放在一个定时器里,或者开启一个独立的线程来循环执行检测。

在设计时,需要设置一个检测时间间隔,例如每10分钟或20分钟检测一次,这个频率比较合适。

我们再次编译生成代码,然后开始测试断线重连函数。

为了方便测试,我们先自动登录两个游戏客户端。游戏登录成功后,手动让其中一个客户端断线,然后再尝试让它重新连接上。完成这些操作后,我们正式开始测试。

测试发现,代码还需要一些改动。在重新登录完成之后,应该再等待一段时间,才进行下一次检测。因为代码中多处调用了 Sleep 函数,如果直接在主线程中运行,可能会导致窗口卡死。

我们先进行测试,因为 Word Login 函数内部本身包含一些延迟判断。

测试时,我们发现有一个掉线的客户端被成功检测到,并且程序尝试去重新登录它。但是,它没有正常地输入账号和密码。

我们再次测试,程序似乎在某个位置卡住了,没有检测到游戏客户端。

刚才的界面显示,它没有检测到游戏服务器。如果是在循环检测中,它还会尝试重新登录。

此时应该是登录失败了。我们先将基本框架设计到这里,关于代码的进一步完善,我们将在下一节课中具体进行。


总结

本节课中,我们一起学习了如何为游戏自动登录程序添加断线重连功能。我们实现了断线检测、进程结束、以及自动重新登录的核心流程,并搭建了基本的测试框架。虽然目前还存在一些需要完善的问题,但核心机制已经建立。下一节课我们将继续完善相关代码。

本节课暂时到这里,下一节课再见。

课程 P153:游戏自动登录设计 - 解决路径冲突问题 🎮

在本节课中,我们将学习如何解决游戏多开时,因客户端路径冲突导致登录失败的问题。我们将通过检测已运行进程的路径,动态分配一个未使用的客户端索引,从而实现稳定、无冲突的自动多开登录。


问题背景与解决思路

上一节我们介绍了自动登录的基本框架。本节中我们来看看一个具体问题:当多个游戏账号尝试使用同一个客户端路径登录时,会发生冲突,导致登录失败。

为了解决这个问题,我们需要在启动新客户端前,检查目标路径是否已被占用。核心思路是遍历所有已运行的特定游戏进程,获取它们的完整路径,并从中解析出使用的索引号(例如路径中的0102)。然后,我们选择一个未被占用的最小索引号,用于启动新的客户端。

以下是两种可行的检测思路:

  • 检查指定的客户端编号是否已被使用。
  • 检查目标路径是否已存在于当前进程列表中。

本节课将采用第二种思路,并介绍实现所需的关键API函数。


核心API:GetModuleFileNameEx

我们解决冲突的办法,核心是使用一个Windows API函数:GetModuleFileNameEx

这个函数的功能是获取指定进程中某个模块的完整路径文件名。它的参数如下:

  • hProcess: 进程句柄。
  • hModule: 模块句柄。当此参数为NULL时,函数返回进程的可执行文件(.exe)路径。
  • lpFilename: 指向缓冲区的指针,用于接收路径字符串。
  • nSize: 缓冲区的大小。

因为我们需要获取的是主程序的路径,所以第二个参数hModule设置为NULL即可。

其调用形式类似于:

DWORD GetModuleFileNameEx(
  HANDLE  hProcess,
  HMODULE hModule,
  LPTSTR  lpFilename,
  DWORD   nSize
);

在调用此函数前,我们需要为其分配一块字符串缓冲区来存放获取到的进程路径。


实现流程详解

认识了核心函数后,我们来编写相应的函数,用于获取一个可用的进程索引ID。

假设当前已有索引为010306的客户端在运行。我们的流程是:

  1. 枚举所有游戏窗口,获取其对应进程的完整路径。
  2. 从路径中解析出已使用的索引号(如01, 03, 06),并存入一个列表。
  3. 在一个预设的范围内(例如从120,代表支持最多20开)进行遍历。
  4. 检查当前遍历的数字是否存在于已使用的索引列表中。
  5. 返回第一个未在列表中找到的数字,即为可用的最小索引号。

例如,已使用列表为[1, 3, 6]。我们从1开始检查,1存在则跳过;检查2,发现不在列表中,则返回2作为可用索引。


代码实现与解析

我们将在原有代码基础上增加冲突检测功能。主要新增两个函数:一个回调函数用于枚举窗口并收集已运行的索引;另一个函数用于计算并返回一个可用的索引号。

首先,需要在代码头部包含必要的头文件和库:

#include <TlHelp32.h>
#include <Psapi.h>
#pragma comment(lib, "Psapi.lib")

以下是核心步骤的代码逻辑:

1. 枚举窗口与收集索引
我们通过EnumWindows函数遍历所有顶层窗口。在回调函数中:

  • 获取窗口类名,与游戏窗口类名进行比较,确认是目标游戏窗口。
  • 通过窗口句柄获得进程ID(PID),再打开进程获得进程句柄。
  • 调用GetModuleFileNameEx函数,获取该进程的完整可执行文件路径。
  • 将路径统一转换为大写,便于后续字符串处理。
  • 在路径中搜索特定子串(如"CLIENT"),定位到其后的索引数字部分。
  • 使用atoi函数将数字字符串转换为整数,这就是已运行的索引号,将其添加到一个动态数组(如std::vector)中。

2. 计算可用索引
在收集完所有已运行索引后,我们编写函数GetAvailableIndex来计算可用索引:

  • 预设一个多开上限,例如60
  • 1开始循环到这个上限值。
  • 对于每个循环值i,在已运行索引数组中查找是否存在。
  • 如果找不到,则i就是可用的索引,立即返回。
  • 如果循环结束都未返回,说明所有索引都已占用(理论上不应发生在上限设置合理的情况下)。

3. 集成到登录流程
在批量登录的函数中,不再使用简单的累加来分配索引,而是改为调用GetAvailableIndex函数,获取一个确保未冲突的索引号,用于构建客户端路径并启动进程。


优化与后续计划

本节课我们实现了基本的路径冲突检测。然而,一个健壮的自动登录系统还需要处理更多异常情况。

例如,游戏客户端进程可能异常退出。我们需要能够检测到这种情况,并自动重新登录该账号。这要求我们维护一个账号-进程的映射表,并定期检查进程是否存在。

此外,在登录等待环节(如读取人物信息时),如果进程已经异常退出,代码不应长时间等待。我们可以通过检查ReadProcessMemory等函数的返回值来判断目标进程是否存活。如果读取失败,应立即退出等待循环,进行错误处理或重试。

在下一节课中,我们将探讨如何实现进程存活性监控和异常重启机制。


总结

本节课中,我们一起学习了如何解决游戏多开自动登录时的路径冲突问题。

我们首先分析了问题根源:多个实例使用相同路径会导致冲突。接着,引入了GetModuleFileNameEx这个关键API来获取运行中进程的路径。然后,详细阐述了“枚举进程 -> 解析已用索引 -> 计算可用索引”的实现流程,并集成了到自动登录逻辑中。最后,我们讨论了当前方案的优化点,如增加进程异常退出的检测,为构建更稳定的自动化脚本奠定了基础。

通过本课的学习,你应该能够为自己的多开脚本增加智能的路径选择功能,避免手动管理客户端目录的麻烦。

课程 P154:游戏自动登录设计 - 完善掉线重连功能 - 进程退出检测 🎮

在本节课中,我们将学习如何为游戏自动登录系统增加进程退出检测功能。我们将完善掉线重连逻辑,使其不仅能检测账号掉线,还能检测游戏进程是否异常退出,从而确保所有指定账号都能保持在线状态。


概述

上一节我们实现了基础的掉线检测与重连。本节中,我们来看看另一种需要处理的情况:游戏进程本身已异常退出。这意味着账号并未正常登录,我们需要检测到这种情况并重新启动登录流程。

检测进程异常退出

我们需要在现有的离线登录函数中增加对进程状态的检测。之前的代码主要检测账号是否已登录但掉线。现在,我们需要额外判断指定账号对应的游戏进程是否仍在运行。

以下是实现此功能的核心步骤。

1. 定义状态标记数组

首先,我们需要一个数组来标记每个指定账号的运行状态。

bool bAccountRunning[MAX_ACCOUNT]; // 假设 MAX_ACCOUNT 是最大账号数

遍历所有正在运行的进程,并与我们的账号列表进行比较。如果某个账号的进程存在,则在数组中将其标记为 true(在线);如果不存在,则标记为 false(异常退出或未登录)。

2. 遍历与比较账号

接下来,我们遍历需要登录的账号数组。

for (int i = 0; i < accountList.size(); ++i) {
    User* pUser = accountList[i];
    bool bFound = false;

    // 与当前运行中的进程列表进行比较
    for (int j = 0; j < runningProcessList.size(); ++j) {
        if (IsSameAccount(pUser, runningProcessList[j])) {
            bFound = true;
            bAccountRunning[i] = true; // 标记为正常登录(进程在运行)
            break;
        }
    }

    if (!bFound) {
        bAccountRunning[i] = false; // 标记为异常退出或未登录
    }
}

这段代码完成后,bAccountRunning 数组就准确反映了每个账号的进程状态。

3. 处理异常退出的账号

得到状态数组后,我们再次循环检查。对于标记为 false(即进程已退出)的账号,我们需要重新获取其账号密码信息,并调用登录函数。

for (int i = 0; i < accountList.size(); ++i) {
    if (bAccountRunning[i] == false) {
        // 该账号进程已异常退出,需要重新登录
        LoginData data;
        data.username = accountList[i]->GetUsername();
        data.password = accountList[i]->GetPassword();

        // 调用登录函数(需使用上节课改进的、能获取可用客户端路径的版本)
        LoginAccount(data);
    }
}

通过以上步骤,系统便能自动发现并重新登录那些进程已退出的账号。


优化:进程运行状态检测函数

为了更优雅地判断进程是否存在,我们可以封装一个专用函数。其原理是尝试读取游戏窗口的句柄。

bool IsProcessRunning(HWND hWnd) {
    if (hWnd == NULL || hWnd == INVALID_HANDLE_VALUE) {
        // 句柄无效,进程已关闭
        return false;
    }
    // 可选的:获取更详细的错误信息
    // DWORD dwError = GetLastError();
    // if (dwError == ERROR_INVALID_WINDOW_HANDLE) {...}
    return true;
}

我们可以在读取人物信息等操作前调用此函数。如果检测到进程已退出,则立即返回,避免无谓的长时间等待。


功能测试与整合

将上述代码整合到离线登录函数后,我们进行测试。

  1. 测试进程退出检测:手动关闭一个已登录账号的游戏进程,然后触发离线检测。系统应能识别到该账号进程已退出,并自动重新登录该账号。
  2. 测试掉线检测:让一个账号网络断开(掉线)。系统应能检测到掉线状态并尝试重连。注意,在掉线状态下操作游戏界面可能导致客户端关闭,因此重连过程可能需要多次尝试。
  3. 正常状态测试:当所有账号均正常在线时,触发离线检测函数,系统应不做任何操作。

测试时需注意,离线检测函数最好运行在单独的线程中,否则在执行检测和重连时可能会阻塞主线程,导致程序界面“假死”。


总结

本节课中我们一起学习了如何完善游戏自动登录系统的掉线重连功能。我们新增了对游戏进程异常退出的检测机制,确保即使进程意外关闭,系统也能自动重新登录账号。

我们主要完成了以下工作:

  1. 设计了状态标记数组来追踪每个账号的进程运行状态。
  2. 通过遍历比较,区分出正常在线、掉线以及进程已退出的账号。
  3. 对进程已退出的账号,自动提取其信息并重新调用登录流程。
  4. 封装了进程状态检测函数以优化代码结构。
  5. 强调了将耗时检测逻辑放入独立线程的重要性,以保持程序响应流畅。

至此,我们的自动登录系统具备了更健壮的账号状态维护能力。

课程P155:166-死亡回城CALL分析 🎮

在本节课中,我们将学习如何分析游戏中的“死亡回城”功能调用(CALL)。我们将通过逆向工程工具(如OD)来定位和测试相关的函数调用,并理解其参数传递机制,特别是如何动态获取关键对象地址。


概述

“死亡回城”是游戏中的一项功能,当角色死亡后,玩家可以选择消耗元宝立即回城。此功能需要向游戏服务器发送特定指令。本节课的目标是分析触发此功能的代码调用点,并理解其参数结构。


分析准备

首先,我们需要使用逆向工具(如OD)附加到游戏进程。为了准确分析,应暂时取消其他无关的断点,避免干扰。


定位发包函数

游戏在执行回城操作时,会向服务器发送数据包。无论回城成功、取消还是因元宝不足触发保护,都会发送相应指令。

我们首先关注通过游戏菜单触发回城的情况。菜单中应包含回城选项,选择后游戏会调用一个函数向服务器发送指令。

通过逐步执行(如使用 Ctrl+F9 执行到返回),我们可以定位到负责发送菜单指令的函数。这个函数是分析的关键点之一。


分析菜单指令参数

在分析调用点时,我们发现函数通过一个窗口ID来区分不同的菜单操作。例如:

  • 选择菜单1时,传递的ID是 0x385
  • 选择菜单2时,ID是 0x386
  • 选择菜单3时,ID是 0x387
  • 取消操作时,ID是 0x38B

这表明,通过调用同一个函数并传入不同的ID参数,可以模拟各种菜单操作。

以下是调用示例代码:

// 假设 ecx 是对象地址,ID 是菜单操作码
call DeathRecallFunction // 内部会根据 ID 处理不同逻辑

测试与验证

我们将找到的函数地址和参数代入代码注入器进行测试。

  1. 初始化正确的 ECX 参数(一个对象地址)。
  2. 分别传入ID 0x3850x3860x387 来模拟选择回城。
  3. 传入ID 0x38B 来模拟取消操作。

测试发现,当元宝充足时,传入回城ID可使角色成功回城;当元宝不足时,游戏会提示“元宝不足”。这验证了该调用点的有效性。


寻找关键对象地址(ECX)

上一节我们定位了调用函数和参数ID,本节中我们来看看最关键的部分:如何动态获取 ECX 寄存器的值,即函数所需的对象地址

分析表明,这个对象地址来源于游戏的“所有对象数组”。在本次分析中找到的静态地址 0x31DB1B0 就是这个数组的基址。然而,这个基址在游戏重启后会发生变化。

在对象数组中,每个对象都有固定结构:

  • +0x08 偏移处存放对象类型。
  • +0x0C 偏移处存放对象ID。
  • 其他偏移存放对象相关数据。

我们需要编写代码,从这个动态的“所有对象数组”中遍历并找到我们角色对应的对象,从而获得正确的 ECX 地址。


课程总结

本节课中我们一起学习了:

  1. 如何定位游戏内“死亡回城”功能的发包调用函数。
  2. 如何分析并测试该函数的参数(菜单操作ID)。
  3. 理解了关键参数 ECX 来源于游戏的对象数组。
  4. 明确了下一步目标是编写自动化脚本来动态获取正确的对象地址。

课后作业 📝

请尝试编写一个函数或使用其他方法,动态获取“死亡回城”CALL所需的 ECX 对象地址。

  • 提示:可以从“所有对象数组”基址出发,遍历数组,根据对象类型或ID筛选出玩家角色对象。
  • 目标:确保每次调用都能传入正确的 ECX 值,使回城功能稳定工作。

课程 P156:死亡回城CALL参数分析(定位动态基址)及函数封装 🎮

在本节课中,我们将学习如何分析游戏中的“死亡回城”功能,并定位其动态变化的参数基址。我们将通过对比不同游戏状态下的数据,找出稳定的特征值,并最终编写一个函数来动态获取正确的参数。


概述

上一节我们分析了死亡回城CALL的参数结构,发现其中一个关键参数 ECX 的地址会随着游戏重启而改变。本节我们将深入分析这个动态地址,找出其内存中的特征,并编写代码来动态定位它。


分析动态变化的ECX地址

重新登录游戏后,我们使用调试器附加到游戏进程,并转到死亡回城CALL的地址。

观察此时 ECX 寄存器指向的数据,会发现其数值与上一节课记录的值不同。我们需要找出这些数据中不变的部分,作为定位特征。

以下是对象数据的对比分析:

  • 未变化的区域:数据块起始部分的一行代码、对象类型标识、以及“摇摇”这个偏移位置的数据保持不变。
  • 可能变化的区域+4 偏移处的数据发生了变化。
  • 两个基址:数据中存在两个基址(例如 31118),它们本身也是动态的,但可以作为中间跳板来定位我们的目标。

我们判断,开头的几个未变化区域是比较稳定的特征。虽然游戏更新后这些特征也可能改变,但目前我们可以利用它们进行定位。


寻找更稳定的定位特征

为了找到更可靠的定位方法,我们尝试将当前对象数据与已知的游戏基址进行关联。

通过回溯调用代码发现,ECX 地址来源于一段指令,该指令从某个地址取出值并加上4,最终得到的就是我们需要的 Core 地址。这个 Core 地址与游戏角色对象的 Core 地址是相等的。

我们进入 Core 地址指向的内存,发现其头部有一个常数值 280。这个值很可能代表某个数据结构的大小,可以作为稳定的特征码。

如何获取这个 280
通过分析汇编指令,我们发现它可以通过 [[ECX+4]+4] 这个偏移读取到。这为我们提供了一个关键的判断条件:[[ECX+4]+4] == 280

此外,ECX+8 偏移处的值可能对应角色的ID,但ID可能会变,不如 280 这个常量稳定。

因此,最可靠的定位特征是:

  1. [[ECX+4]+4] 的值等于 280
  2. 结合其他一两个稳定偏移值(如 +0+8 处的特征)进行综合判断。

我们将尝试编写代码,用这些特征来唯一确定正确的 ECX 地址。


编写动态定位函数

现在,我们打开项目代码,开始编写定位函数。

首先,我们需要遍历游戏中的所有对象列表。已知对象列表的大小为 0x2700

以下是函数的核心逻辑步骤:

  1. 遍历对象列表:循环遍历所有游戏对象。
  2. 读取候选ECX:从对象列表中取出一个地址作为候选的 ECX
  3. 进行特征校验
    • 计算 [[ECX+4]+4] 的值。
    • 判断该值是否等于特征值 280
    • 可以附加判断 ECX+0ECX+8 等偏移处的值是否符合预期特征。
  4. 返回结果:如果找到满足所有特征的对象,则返回该 ECX 地址。如果遍历完都未找到,则定位失败。

我们编写一个函数 FindDeathRecallECX() 来实现上述逻辑。在函数内部,通过循环遍历对象数组,对每个候选地址应用我们的特征判断公式。

// 伪代码示例
DWORD FindDeathRecallECX() {
    DWORD objectListBase = 0xXXXXXXX; // 对象列表基址
    int objectCount = 0x2700; // 对象数量

    for (int i = 0; i < objectCount; ++i) {
        DWORD candidateECX = ReadMemory<DWORD>(objectListBase + i * 0x4); // 假设每个指针4字节

        // 特征判断:[[ECX+4]+4] == 280
        DWORD tempAddr = ReadMemory<DWORD>(candidateECX + 0x4);
        if (tempAddr == 0) continue;
        DWORD featureValue = ReadMemory<DWORD>(tempAddr + 0x4);

        if (featureValue == 280) {
            // 可附加其他特征检查,如 ReadMemory<DWORD>(candidateECX + 0x8) == someID
            return candidateECX; // 找到目标地址
        }
    }
    return 0; // 未找到
}

测试与优化定位条件

编写完初步函数后,我们进行测试。

测试发现,仅凭 [[ECX+4]+4] == 280 这一个条件,可能会匹配到多个对象,说明该特征不具备唯一性。

我们需要增加额外的判断条件来缩小范围。例如,可以检查 ECX+8ECX+0x10 等偏移处的值。通过对比找到的多个对象,分析它们在这些偏移处的数据差异,选取一个稳定且能区分目标的特征值加入判断逻辑。

优化后的判断条件可能类似于:
[[ECX+4]+4] == 280 && ReadMemory<WORD/DWORD>(ECX + offset_X) == expectedValue

通过迭代测试和增加判断条件,直到函数能够稳定、唯一地定位到死亡回城CALL所需的正确 ECX 地址。


总结

本节课中,我们一起学习了如何分析动态变化的内存地址。

  1. 核心思路:通过对比数据快照,找出内存中不变的特征值或特征结构。
  2. 关键步骤:我们发现了 Core 结构头部的常量 280,并通过公式 [[ECX+4]+4] 来访问它,以此作为定位的锚点。
  3. 实现方法:编写了遍历函数,结合多个特征条件来动态定位正确的参数地址。
  4. 优化过程:通过测试发现单一条件可能不足,进而引入了额外的偏移检查来确保定位的唯一性和准确性。

通过本课的学习,你掌握了分析动态基址和封装功能函数的基本方法,这对于游戏逆向工程中的许多场景都非常有用。

课程 P157:认识LUA与编写Hello World 👨‍🏫

在本节课中,我们将学习如何搭建Lua脚本语言的开发环境,并将其与C++程序进行整合,最终编写并运行一个简单的“Hello World”程序。


概述 📋

Lua是一种简洁、轻量且可扩展的脚本语言。它既可以独立运行,也可以嵌入到C/C++等宿主程序中。本节课我们将重点学习如何获取Lua源码、编译生成静态链接库,并在一个C++控制台项目中调用Lua脚本,实现一个基础的“Hello World”示例。


获取Lua源码 🔽

首先,我们需要获取Lua的源代码。你可以访问Lua的官方网站下载最新版本(当前为5.3),也可以从相关技术论坛获取。

下载完成后,将压缩包解压到本地目录,例如 C:\。我们主要需要其 src 目录下的所有源文件。


编译Lua静态库 🛠️

Lua官方并未直接提供编译好的库文件,因此我们需要自行编译。以下是在Visual Studio环境中编译静态库的步骤。

上一节我们获取了Lua源码,本节中我们来看看如何将其编译为可供C++程序使用的静态链接库。

  1. 创建VS项目:在Visual Studio中创建一个新的空项目,例如命名为 lua53。项目位置可以放在Lua源码目录下以便管理。
  2. 添加源文件:将 src 目录下所有的 .c 源文件(除 lua.cluac.c 外)添加到项目中。这两个文件包含 main 函数,会与我们的项目冲突,需要从项目中排除(不是删除)。
  3. 配置项目属性
    • 将项目配置类型改为 “静态库(.lib)”
    • C/C++ -> 常规 -> 附加包含目录 中,添加Lua头文件所在目录(即 src 目录的上一级)。
  4. 生成库文件:编译项目。成功后在输出目录(如 Debug)中即可得到 lua53.lib(或根据你的配置名)静态库文件。

为了方便,你也可以直接使用老师提供的已配置好的VS工程文件进行编译。


创建C++测试项目并整合Lua 🧪

库文件编译完成后,我们就可以创建一个C++项目来调用Lua了。

上一节我们成功生成了Lua的静态库,本节我们将创建一个新的C++项目,并配置环境以使用这个库。

  1. 新建控制台项目:创建一个新的C++控制台应用程序项目,命名为 TestLua
  2. 配置项目属性:这是整合的关键步骤,以下是需要配置的项:
    • 附加包含目录:在 C/C++ -> 常规 -> 附加包含目录 中,添加Lua头文件所在目录。
    • 附加库目录:在 链接器 -> 常规 -> 附加库目录 中,添加上一步生成的 lua53.lib 文件所在目录。
    • 附加依赖项:在 链接器 -> 输入 -> 附加依赖项 中,添加 lua53.lib
    • 预处理器定义:由于Lua是用纯C编写的,为了在C++中正确链接,需要在 C/C++ -> 预处理器 -> 预处理器定义 中添加 _CRT_SECURE_NO_WARNINGS
  3. 编写C++主程序:在 main.cpp 中编写以下代码,用于初始化Lua状态并运行脚本。

extern "C" {
    #include "lua.h"
    #include "lauxlib.h"
    #include "lualib.h"
}

int main() {
    // 1. 创建Lua状态机
    lua_State* L = luaL_newstate();
    // 2. 打开Lua标准库
    luaL_openlibs(L);
    // 3. 加载并执行指定的Lua脚本文件
    luaL_dofile(L, "test.lua");
    // 4. 关闭状态机
    lua_close(L);
    return 0;
}


编写并运行Lua脚本 📜

C++程序配置好后,我们需要编写一个Lua脚本来被调用。

上一节我们完成了C++端的配置和编码,本节我们来创建Lua脚本文件并完成最终测试。

  1. 确定脚本路径:C++程序中的 luaL_dofile(L, "test.lua") 会在程序的工作目录下寻找 test.lua 文件。你可以在VS项目属性中 调试 -> 工作目录 设置此路径,通常设为 $(ProjectDir) 以便在项目目录下查找。
  2. 创建Lua脚本:在工作目录下,用记事本创建一个新文件,命名为 test.lua,并输入以下内容:
print("Hello World from Lua!")
  1. 运行程序:编译并运行C++项目。如果一切配置正确,控制台将输出Lua脚本中的打印信息。
Hello World from Lua!

总结 🎯

本节课我们一起学习了Lua开发环境的完整搭建流程。

我们首先从官网获取了Lua源代码,接着在Visual Studio中将其编译成静态链接库(.lib文件)。然后,我们创建了一个新的C++控制台项目,通过配置包含目录、库目录和附加依赖项,成功将Lua库整合到项目中。最后,我们编写了C++主程序来初始化Lua并执行外部的 test.lua 脚本文件,实现了经典的“Hello World”输出。

通过这个流程,你已经掌握了在Windows平台下使用C++嵌入Lua脚本的基本方法,为后续更复杂的脚本交互打下了基础。

课程 P158:Lua 与 C/C++ 接口基础教程 🧩

在本节课中,我们将学习如何在 Lua 脚本中调用 C/C++ 编写的函数。我们将从创建项目开始,逐步讲解如何配置环境、注册函数,并通过 Lua 脚本执行这些函数。


环境配置与项目创建

首先,我们需要在 Visual Studio 2010 中创建一个新的控制台项目。

项目创建完成后,需要包含必要的头文件和链接库。以下是三个必需的头文件:

  • lua.h:包含 Lua 脚本的基础函数。
  • lualib.h:提供 Lua 标准库函数的支持。
  • lauxlib.h:提供 Lua 辅助库函数的支持。

配置好这些之后,我们就可以开始编写代码了。


创建 Lua 工作环境

上一节我们配置好了开发环境,本节中我们来看看如何初始化 Lua 的运行环境。

在 C/C++ 程序中,我们首先需要创建一个 Lua 工作环境。通常使用 lua_State* 类型的指针来表示这个环境。

lua_State* L = luaL_newstate(); // 创建新的 Lua 状态机

创建环境后,一般会打开所有标准库的支持,以便在脚本中使用。

luaL_openlibs(L); // 打开所有标准库

程序结束时,需要关闭并销毁这个 Lua 工作环境以释放资源。

lua_close(L); // 关闭 Lua 状态机

注册 C/C++ 函数供 Lua 调用

环境创建好后,我们需要让 Lua 脚本能够调用我们编写的 C/C++ 函数。这需要通过“注册”来实现。

假设我们有一个 C++ 函数 FindWay,它模拟游戏中的寻路指令。

void FindWay() {
    printf("寻路指令被执行\n");
}

Lua 不能直接调用这个函数。我们需要定义一个符合 Lua C 函数格式的包装函数来注册。该函数必须接收一个 lua_State* 参数并返回一个整数。

// 符合 Lua 要求的 C 函数格式
int lua_FindWay(lua_State* L) {
    FindWay(); // 在这里调用我们实际的 C++ 函数
    return 0; // 返回值个数为 0
}

有了这个包装函数,我们就可以将其注册到 Lua 环境中。注册时使用的函数名必须是英文字符串。

// 将函数 lua_FindWay 注册到 Lua 中,命名为 "FindWay"
lua_register(L, "FindWay", lua_FindWay);


在 Lua 脚本中调用注册的函数

函数注册成功后,我们就可以在 Lua 脚本中调用它了。有两种主要的方式来执行包含调用的 Lua 代码。

第一种方式是使用 luaL_dostring 直接执行一段 Lua 代码字符串。

// 执行一段 Lua 代码字符串,调用注册的 FindWay 函数
luaL_dostring(L, "FindWay()");

第二种方式是使用 luaL_dofile 执行一个外部的 Lua 脚本文件。

我们可以创建一个名为 test.lua 的脚本文件,内容如下:

-- test.lua
FindWay()
FindWay()

然后在 C++ 程序中加载并执行这个文件:

// 执行外部的 Lua 脚本文件
luaL_dofile(L, "test.lua");

此时,FindWay() 函数会被调用两次。


注意事项与路径问题

在使用 luaL_dofile 时,需要注意文件路径问题。程序会在其“当前工作目录”下寻找脚本文件。

  • 在调试模式下,工作目录通常是项目解决方案目录下的 Debug 文件夹。
  • 直接运行生成的 .exe 文件时,工作目录就是 .exe 文件所在的目录。

为了保证脚本文件能被正确找到,你可以:

  1. 将 Lua 脚本文件复制到可执行文件(.exe)所在的目录。
  2. 或者在 IDE 的调试设置中,将“工作目录”修改为脚本文件所在的路径。

课程总结

本节课中我们一起学习了 Lua 与 C/C++ 交互的基础知识:

  1. 我们学习了如何配置开发环境,包含必要的头文件。
  2. 我们掌握了如何创建和销毁 Lua 工作环境(lua_State)。
  3. 我们理解了如何将 C/C++ 函数包装并注册到 Lua 中,使其能被脚本调用。
  4. 我们实践了两种执行 Lua 代码的方式:直接执行字符串(luaL_dostring)和执行外部脚本文件(luaL_dofile)。
  5. 我们注意到了执行外部文件时的路径问题及其解决方法。

通过本节课的学习,你已经能够让 Lua 脚本成功调用简单的 C/C++ 函数了。在后续课程中,我们将探讨如何向这些函数传递参数以及从函数中返回值。

课程 P159:Lua与C交互 - 调用带参数的C函数 🧩

在本节课中,我们将学习如何在Lua脚本中调用带参数的C语言函数。我们将重点探讨如何从Lua向C函数传递参数,以及如何在C函数内部接收和处理这些参数,特别是数字类型的参数。


项目初始化与代码准备

首先,我们需要新建一个项目。

上一节课我们学习了如何调用一个不带参数的C函数。本节课,我们将在此基础上进行修改。很多时候,我们需要从Lua脚本向C函数传递参数,因此我们需要研究如何实现参数的传递与接收。

参数传递的基本原理

在Lua调用C函数时,可以传递任意数量的参数。即使C函数不处理这些参数,程序也不会出错,只是参数数据不会被使用。

本节课我们主要研究如何接收数字类型的数据。这需要使用Lua提供的API函数 lua_tonumber 来从Lua的“栈”中获取数据。

Lua与C之间的数据交换都是通过一个特殊的“栈”来完成的。这个栈类似于一个堆栈数据结构,数据被压入栈中,然后通过特定的函数来获取。

栈中元素的下标从1开始编号。要获取第几个参数,就使用对应的下标。例如,获取第一个参数,下标就是1。

接收单个数字参数

在获取参数之前,最好先判断参数的类型是否符合预期。我们可以使用 lua_isnumber 函数来判断指定位置的参数是否为数字类型。

以下是接收并处理第一个数字参数的C代码示例:

// 假设这是注册到Lua的C函数
static int my_add(lua_State *L) {
    // 判断第一个参数是否为数字
    if (lua_isnumber(L, 1)) {
        // 获取第一个参数的值。Lua中所有数字都以双精度浮点数表示。
        double arg1 = lua_tonumber(L, 1);
        // 打印获取到的值
        printf("第一个参数是:%f\n", arg1);
    }
    return 0;
}

注意:Lua内部不区分整数和浮点数,所有数字都以双精度浮点数(lua_Number,通常是 double)表示。即使你传递了一个整数,获取到的也是浮点数。如果需要整数,可以强制转换。

接收多个数字参数

如果我们传递了多个参数,并且希望全部获取,可以使用循环。前提是我们能确定栈中所有参数都是数字类型。

以下是一个使用 for 循环获取所有数字参数的示例:

static int my_add_all(lua_State *L) {
    int i = 1; // 栈下标从1开始
    double sum = 0.0;

    // 循环检查栈中每个位置是否为数字
    while (lua_isnumber(L, i)) {
        double arg = lua_tonumber(L, i);
        sum += arg;
        printf("第%d个参数是:%f\n", i, arg);
        i++; // 检查下一个位置
    }

    printf("所有参数之和为:%f\n", sum);
    // 注意:目前还没有将结果返回给Lua
    return 0;
}

在Lua脚本中,我们可以这样调用:

my_add_all(1, 2, 3, 4, 5, 6, 7, 8)

C函数会依次获取并打印每个参数,然后计算总和。

实际应用与扩展

掌握了参数传递的方法后,我们就可以在C函数中调用其他更复杂的逻辑。例如,我们可以单独写一个执行加法运算的函数,然后在注册函数中获取Lua参数,调用这个加法函数,最后处理结果。

// 一个独立的加法函数
double add_numbers(double a, double b) {
    return a + b;
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/270b4bd62e5be83d02284ba74909391b_19.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/270b4bd62e5be83d02284ba74909391b_21.png)

// 注册到Lua的函数
static int lua_add(lua_State *L) {
    // 获取前两个参数
    double a = lua_tonumber(L, 1);
    double b = lua_tonumber(L, 2);

    // 调用独立的C函数进行计算
    double result = add_numbers(a, b);

    printf("计算结果:%f + %f = %f\n", a, b, result);
    // 如何将result返回给Lua?我们下节课讨论。
    return 0;
}

这为将来在游戏等实际项目中,通过Lua调用底层C/C++函数(如寻路算法、渲染指令等)奠定了基础。


总结

本节课我们一起学习了Lua调用带参数C函数的核心方法:

  1. Lua通过“栈”向C函数传递参数。
  2. 使用 lua_isnumber 判断参数类型。
  3. 使用 lua_tonumber(L, index) 获取栈中指定位置(下标从1开始)的数字参数。
  4. 可以通过循环获取多个参数。

关键点:Lua中所有数字都以双精度浮点数传递,栈索引从1开始。


下节课预告与作业

本节课我们成功从Lua获取了参数,但函数还没有将计算结果返回给Lua脚本。例如,my_add_all 函数计算了总和,但Lua脚本还无法使用这个结果。

课后作业:请查阅资料,了解如何将C函数中的结果(例如一个数字)压入Lua栈,并返回给Lua脚本。我们将在下节课详细探讨函数返回值的处理。

我们下节课再见!

课程 P16:027 - 完善选怪功能 - 计算怪物与玩家的距离 📏

在本节课中,我们将学习如何计算游戏内怪物与玩家角色之间的距离。这是实现高效“选怪”功能的关键一步,因为游戏本身并未直接提供这个属性。我们将通过分析并读取怪物与玩家的坐标数据,然后运用数学公式计算出它们之间的距离。


分析坐标数据

上一节我们介绍了选怪的需求,本节中我们来看看如何获取计算距离所需的基础数据——坐标。

首先,我们需要分析并找到游戏中玩家角色的当前坐标。由于坐标值是浮点数,在内存中搜索时需要使用范围搜索来确保准确性。

以下是分析玩家坐标的步骤:

  1. 在游戏中查看当前坐标(例如:2197)。
  2. 使用内存扫描工具,以浮点数类型并设定一个范围(例如:2196 到 2198)进行首次搜索。
  3. 移动角色改变坐标,根据新坐标值(例如:2211)再次扫描变动的数值。
  4. 重复此过程,逐步筛选出与玩家对象地址相关联的、持续变动的坐标值地址。
  5. 通过对比移动、静止以及使用小地图传送等操作下数值的变化,可以区分出“当前坐标”和“目的地坐标”。
  6. 最终,我们找到了玩家对象基址下的偏移量,用于读取当前坐标:
    • 当前X坐标偏移对象基址 + 0x16834
    • 当前Y坐标偏移对象基址 + 0x16838 (X坐标偏移 + 4)
    • 当前Z坐标偏移对象基址 + 0x1683C (Y坐标偏移 + 4)

注:本游戏中坐标顺序为 X, Z, Y。怪物对象的坐标偏移已在之前课程中分析获得。


距离计算原理 🧮

获取了怪物和玩家的坐标后,我们就可以计算它们之间的距离。这需要用到平面直角坐标系中的距离公式。

假设有两个点:

  • 点 A,坐标为 (x1, y1)
  • 点 B,坐标为 (x2, y2)

我们可以通过勾股定理来计算它们之间的直线距离。如下图所示,两点在X轴和Y轴方向上的差值构成了一个直角三角形的两条直角边。

点A (x1, y1)
    |\
    | \  距离 c
  a |  \
    |___\
        点B (x2, y2)
         b

其中:

  • 直角边 a = |y1 - y2| (Y方向差值绝对值)
  • 直角边 b = |x1 - x2| (X方向差值绝对值)
  • 斜边 c 即为我们要求的距离。

根据勾股定理:c² = a² + b²。因此,距离 c 的计算公式为:

距离 = sqrt( (x1-x2)² + (y1-y2)² )

在代码中,我们可以这样实现:

float CalculateDistance(float x1, float y1, float x2, float y2) {
    float a = fabs(y1 - y2); // Y轴方向差值
    float b = fabs(x1 - x2); // X轴方向差值
    float distance = sqrt(a * a + b * b); // 勾股定理求斜边
    return distance;
}

对于有Z轴(高度)的3D游戏,距离公式需扩展为:距离 = sqrt( (x1-x2)² + (z1-z2)² + (y1-y2)² )。本教程暂不考虑Z轴影响。


代码实现与集成

理解了计算原理后,我们将把距离计算功能集成到我们的辅助工具代码中。

首先,在玩家角色对象的结构体中,添加读取当前坐标的成员变量,并在初始化时从游戏内存中读取相应的值。

// 在玩家对象结构体中添加
float currentPosX; // 当前X坐标
float currentPosY; // 当前Y坐标
// 初始化时读取内存
player.currentPosX = ReadMemory<float>(player.baseAddress + 0x16834);
player.currentPosY = ReadMemory<float>(player.baseAddress + 0x1683C); // 注意Y坐标偏移

接着,封装一个通用的距离计算函数。

// 计算两点间距离的函数
float GetDistanceBetweenPoints(float x1, float y1, float x2, float y2) {
    float deltaX = fabs(x1 - x2);
    float deltaY = fabs(y1 - y2);
    return sqrt(deltaX * deltaX + deltaY * deltaY);
}

然后,在遍历怪物列表、初始化每个怪物信息时,调用该函数计算其与玩家的距离,并将结果保存为怪物的一个属性。

// 在初始化怪物数据的循环中
for (auto& monster : monsterList) {
    // ... 读取怪物坐标 (monster.x, monster.y) ...
    // 计算距离
    monster.distanceToPlayer = GetDistanceBetweenPoints(
        player.currentPosX, player.currentPosY,
        monster.x, monster.y
    );
    // ... 打印或其他操作 ...
}

最后,我们可以在信息显示部分,将每个怪物的ID、坐标和与玩家的距离一并打印出来,以便验证。


功能测试与验证

代码集成完毕后,需要进行测试以确保功能正确。

我们将工具注入游戏,并观察输出的调试信息。当角色在游戏世界中移动或怪物移动时,每个怪物对应的“距离”属性应该会动态变化。我们可以尝试攻击距离显示最近的怪物,以验证筛选逻辑的有效性。

通过测试,我们能够确认:

  1. 坐标读取正确。
  2. 距离计算准确,数值随位置改变而实时更新。
  3. 基于此距离信息,可以成功标识出离玩家最近的怪物。

这为下一节课实现“自动选择最近怪物”的功能打下了坚实的基础。


总结

本节课中我们一起学习了如何完善选怪功能的核心步骤——计算距离。

我们首先分析了游戏内存,找到了玩家角色的坐标数据。然后,我们回顾了平面直角坐标系中两点间距离的计算公式(勾股定理),并将其转化为代码实现。最后,我们将距离计算功能集成到怪物信息管理模块中,并进行了成功测试。

现在,我们的程序已经能够知道每个怪物离我们有多远了。在下一节课中,我们将利用这个“距离”属性,来优化我们的选怪逻辑,实现自动优先攻击最近目标的功能。

P160:171-了解Lua栈及相关操作函数 📚

在本节课中,我们将要学习Lua虚拟栈的基本概念以及如何通过C API对栈进行基础操作,例如压栈、出栈和取值。栈是Lua与C语言之间进行数据交换的核心机制。

概述

Lua栈是一个虚拟的栈结构,主要用于函数之间传递参数,或是在Lua脚本与C代码之间传递数值数据。栈上的数据可以是多种类型,例如空类型、数字或字符串等。我们将在后续课程中逐步深入了解。

Lua栈的基本特性

无论何时调用一个函数,都会产生一个新的栈。这与C语言或汇编中的栈概念类似,每个调用都有自己独立的栈帧。Lua栈独立于C函数调用栈。

所有对栈的操作都通过一个索引来指向栈中的元素。正的索引值表示从栈底开始的绝对位置(从1开始),而负的索引值则表示从栈顶开始的偏移量。

栈操作的核心函数

以下是本节课将涉及的几个核心栈操作函数。

  • lua_pushnumber:将一个双精度浮点数压入栈顶。
  • lua_pushinteger:将一个整型数压入栈顶。
  • lua_pop:从栈顶弹出指定数量的元素。
  • lua_tonumber:根据索引从栈中获取一个双精度浮点数值。

实践:数字的入栈与取值

上一节我们介绍了栈的基本概念,本节中我们来看看如何将数字压入栈中并读取它们。

首先,我们创建一个Lua状态并注册一个C函数供测试。在测试函数中,我们将一系列数字压入栈。

// 将数字1到6压入栈
lua_pushnumber(L, 1);
lua_pushnumber(L, 2);
lua_pushnumber(L, 3);
lua_pushnumber(L, 4);
lua_pushnumber(L, 5);
lua_pushnumber(L, 6);

压栈后,栈底(索引1)是数字1,栈顶(索引-1)是数字6。我们可以使用lua_tonumber函数并指定索引来获取值。

// 获取栈底元素(索引1)
double bottom = lua_tonumber(L, 1); // 值为 1.0
// 获取栈顶元素(索引-1)
double top = lua_tonumber(L, -1); // 值为 6.0

索引规则如下:

  • 正索引从栈底(1)开始向上计数。
  • 负索引从栈顶(-1)开始向下计数。

例如,要获取数字4,可以使用索引4(从栈底数第4个)或索引-3(从栈顶数第3个)。

double val1 = lua_tonumber(L, 4);  // 值为 4.0
double val2 = lua_tonumber(L, -3); // 值为 4.0

实践:出栈操作

了解了如何向栈中添加和读取数据后,我们来看看如何移除栈顶的元素。这通过lua_pop函数实现。

lua_pop(L, n)函数从栈顶弹出n个元素。例如,初始栈为 [1, 2, 3, 4, 5, 6](栈底到栈顶)。

// 弹出2个元素
lua_pop(L, 2);
// 此时栈变为 [1, 2, 3, 4],栈顶元素是4
double new_top = lua_tonumber(L, -1); // 值为 4.0

如果弹出元素的数量等于或超过栈的大小,栈会被清空,此时再尝试取值可能得到未定义的结果(如0)。

lua_pop(L, 6); // 弹出所有元素
// 栈已空,以下操作可能得到0或未定义值
double undefined = lua_tonumber(L, 1);

整数与浮点数的压栈

Lua提供了不同的函数来压入整数和浮点数,但需要注意它们在栈内的存储最终都会统一为double类型。

  • lua_pushinteger(L, ivalue): 压入一个lua_Integer类型(通常是long long)的整数。
  • lua_pushnumber(L, nvalue): 压入一个lua_Number类型(通常是double)的浮点数。

即使使用lua_pushinteger压入整数,当使用lua_tonumber读取时,返回的仍是double类型。如果需要整数,可以进行强制类型转换。

lua_pushinteger(L, 100);
// 读取为浮点数
double dval = lua_tonumber(L, -1);
// 转换为整数
int ival = (int)lua_tonumber(L, -1); // 或者使用 lua_tointeger 函数

总结

本节课中我们一起学习了Lua栈的基础知识。我们了解到栈是Lua与C交互的桥梁,通过索引(正索引从栈底开始,负索引从栈顶开始)来访问元素。我们实践了四个核心操作:

  • lua_pushnumber / lua_pushinteger:用于将数值压入栈顶。
  • lua_pop:用于从栈顶弹出指定数量的元素,改变栈顶指针。
  • lua_tonumber:用于根据索引从栈中获取一个双精度浮点数值。

这些是操作Lua栈最基本和常用的函数。在后续课程中,我们将学习如何利用栈来传递函数参数和返回值。

课程 P161:在 C/C++ 中获取 Lua 函数返回值 📥

在本节课中,我们将学习如何在 C/C++ 程序中调用 Lua 函数并获取其返回值。我们将重点理解 lua_pcall 函数的调用规范、参数与返回值的压栈顺序,并通过一个完整的代码示例来实践。


调用 Lua 函数的基本要求

上一节我们介绍了 Lua 虚拟栈的基本操作。本节中我们来看看如何通过 C 代码调用 Lua 函数。

要正确调用 Lua 函数并获取其返回值,必须遵循特定的栈操作协议。核心是通过 lua_pcall 函数进行调用。

以下是调用前必须完成的栈准备工作:

  1. 将要调用的函数压入栈顶。
  2. 将函数的参数按正序(第一个参数先入栈)压入栈中。

调用完成后,函数的返回值会按正序被推入栈中。


Lua 函数的 C 语言接口规范

为了确保 C 函数能与 Lua 正确通信,所有注册给 Lua 的 C 函数都必须遵循统一的接口定义和参数传递协议。

以下是 C 函数的标准格式和参数获取方式:

  • 函数定义int function_name(lua_State *L)
  • 参数获取:函数开始时,参数已按正序压入栈中。第一个参数(如果存在)在索引 1 的位置,最后一个参数在索引 lua_gettop(L) 的位置。lua_gettop(L) 返回的值就是传入参数的个数。
  • 返回值:C 函数通过将返回值压入栈中,并返回一个整数来告知 Lua 返回值个数。例如,return 1; 表示有 1 个返回值在栈顶。

实践:编写一个累加函数

现在,我们通过一个具体的例子来实践上述理论。我们将创建一个 C 函数,在 Lua 中注册它,然后从 C 端调用它并获取计算结果。

首先,我们创建一个新的项目,并复制上一节课的基础代码框架,用于初始化 Lua 状态。

1. 定义并注册 C 函数

我们定义一个名为 add_number 的 C 函数,它接收多个整数参数,计算它们的累加和,并将结果返回。

// 定义给Lua调用的C函数
static int add_number(lua_State *L) {
    int n = lua_gettop(L); // 获取参数个数
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        sum += lua_tointeger(L, i); // 依次获取每个参数并累加
    }
    lua_pushinteger(L, sum); // 将结果压入栈顶
    return 1; // 返回值的个数为1
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/4bccc80ed190dda61de46ed7f90f8716_15.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/4bccc80ed190dda61de46ed7f90f8716_17.png)

int main() {
    lua_State *L = luaL_newstate(); // 创建Lua状态机
    luaL_openlibs(L); // 打开标准库

    // 将C函数注册为Lua的全局函数
    lua_register(L, "add_number", add_number);
    // ... 后续调用代码
}

2. 从 C 端调用 Lua 函数

注册函数后,我们需要在 C 代码中主动调用它。这需要使用 lua_pcall 函数。

以下是调用 add_number 并传入 8 个参数的完整步骤:

// 1. 将需要调用的函数(add_number)压入栈顶
lua_getglobal(L, "add_number");

// 2. 将8个参数按正序压入栈中
lua_pushinteger(L, 1);
lua_pushinteger(L, 2);
lua_pushinteger(L, 3);
lua_pushinteger(L, 4);
lua_pushinteger(L, 5);
lua_pushinteger(L, 6);
lua_pushinteger(L, 7);
lua_pushinteger(L, 8);

// 3. 调用函数,告知有8个参数,期望1个返回值
if (lua_pcall(L, 8, 1, 0) != LUA_OK) {
    printf("调用错误: %s\n", lua_tostring(L, -1));
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/4bccc80ed190dda61de46ed7f90f8716_23.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/4bccc80ed190dda61de46ed7f90f8716_25.png)

// 4. 调用成功,返回值位于栈顶
int result = lua_tointeger(L, -1);
printf("累加结果为: %d\n", result);

// 5. 将返回值弹出栈,保持栈平衡
lua_pop(L, 1);

关键点说明

  • lua_pcall(L, 8, 1, 0) 的三个数字参数分别代表:参数个数、期望的返回值个数、错误处理函数索引(0表示无)。
  • 参数个数必须与实际压栈的参数个数严格一致,否则会导致调用错误或结果异常。
  • 期望的返回值个数必须与C函数实际返回的个数一致,否则无法正确获取到所有返回值。

3. 处理多个返回值

Lua 函数可以返回多个值。如果我们的 C 函数返回多个值,并在 lua_pcall 中声明了相应的数量,就可以依次从栈中取出。

假设 add_number 函数修改为返回三个值(和、平均值、参数个数):

static int add_number(lua_State *L) {
    int n = lua_gettop(L);
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        sum += lua_tointeger(L, i);
    }
    lua_pushinteger(L, sum);      // 第一个返回值:和
    lua_pushnumber(L, sum / (double)n); // 第二个返回值:平均值
    lua_pushinteger(L, n);        // 第三个返回值:参数个数
    return 3; // 声明返回3个值
}

调用时,我们需要相应地修改 lua_pcall 并获取所有返回值:

// 调用函数,期望3个返回值
lua_pcall(L, 8, 3, 0);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/4bccc80ed190dda61de46ed7f90f8716_35.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/4bccc80ed190dda61de46ed7f90f8716_37.png)

// 返回值按正序入栈。栈底是第一个返回值,栈顶是最后一个。
// 索引 -3, -2, -1 分别对应三个返回值。
int sum = lua_tointeger(L, -3);
double avg = lua_tonumber(L, -2);
int count = lua_tointeger(L, -1);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/4bccc80ed190dda61de46ed7f90f8716_39.png)

printf("和:%d, 平均值:%.2f, 参数个数:%d\n", sum, avg, count);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/4bccc80ed190dda61de46ed7f90f8716_41.png)

// 弹出三个返回值
lua_pop(L, 3);


总结

本节课中我们一起学习了在 C/C++ 中与 Lua 交互的核心操作——调用 Lua 函数并获取返回值。

我们掌握了以下关键知识:

  1. 调用规范:使用 lua_pcall 前,必须先将函数和其参数按规则压入虚拟栈。
  2. C函数接口:遵循 int func(lua_State *L) 格式,用 lua_gettop 获取参数,用 return n 声明返回值数量。
  3. 参数与返回值的对应关系lua_pcall 中声明的参数个数、返回值个数必须与实际压栈的数量、C函数返回的数量严格一致,这是正确交互的基石。
  4. 多返回值处理:通过调整 lua_pcall 的期望返回值个数和正确使用栈索引,可以轻松处理多个返回值。

通过本课的实践,你已经能够打通 C/C++ 与 Lua 之间的函数调用通道。下一节课,我们将继续探索 Lua 与其他更复杂的数据结构进行交互。

课程 P162:纯LUA函数中的参数与返回值与print函数 📚

在本节课中,我们将学习Lua语言中函数的基本概念,包括如何定义函数、传递参数、处理返回值以及使用print函数进行输出。课程内容将涵盖无参函数、有参函数、多返回值函数以及变参函数,并通过简单示例帮助初学者理解。


概述 📖

Lua函数以function关键字开始,以end结束。函数定义的基本语法是function 函数名(参数列表)。与C/C++等语言不同,Lua函数的参数无需声明类型,这增加了灵活性,但也可能影响程序的健壮性。


函数定义与调用 🔧

上一节我们介绍了Lua函数的基本结构,本节中我们来看看如何具体定义一个函数并调用它。

Lua脚本可以在记事本或专业编辑器中编写。函数体外的代码会被顺序执行,这类似于C语言中的main函数。

以下是一个简单的无参函数定义与调用示例:

function myFun()
    print("Hello from function!")
end

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/bcd5c29ebe234562737105b5ab19644e_4.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/bcd5c29ebe234562737105b5ab19644e_5.png)

myFun() -- 调用函数

使用Lua控制台执行该脚本,例如使用dofile(“filename.lua”)命令,即可看到输出结果。


print函数的使用 🖨️

print函数用于输出信息到控制台。它与C语言的printf不同,不支持格式化字符串,但可以打印任何类型的值,并会自动换行。

以下是print函数的使用示例:

a = 10
print(a)          -- 打印变量
print("Hello")    -- 打印字符串
print(123, 456)   -- 打印多个值,用逗号分隔

需要注意的是,不同版本的Lua解释器在print输出的换行处理上可能略有差异。


函数的参数 📦

函数可以接收参数。参数在定义时无需指定类型,其实际类型由调用时传入的值决定。

以下是一个接收多个参数并打印的函数示例:

function printParams(a, b, c, d)
    print(a, b, c, d)
end

printParams(1, “test”, 3.14, true) -- 传入不同类型的参数

函数的返回值 🔄

函数可以有返回值,使用return语句。Lua函数可以返回任意类型和任意数量的值。

以下是返回单个值和多个值的函数示例:

-- 返回单个值
function getValue()
    return 111
end
print(getValue())

-- 返回多个值
function getMultiValues()
    return 100, “apple”, 3.14
end
local num, str, pi = getMultiValues()
print(num, str, pi)


变参函数 🔄

Lua支持变参函数,即参数数量不确定的函数。在旧版本(如5.1)中,变参通过一个名为arg的局部表来访问,其中arg.n表示参数个数。

以下是变参函数的示例(适用于Lua 5.1):

function varArgsFunc(...)
    print(“Number of arguments:”, arg.n)
    for i=1, arg.n do
        print(arg[i])
    end
end

varArgsFunc(1, “a”, true, 4, 5)

注意:在新版本Lua(如5.3)中,arg表的使用方式可能已改变,建议查阅对应版本的官方文档。


总结 🎯

本节课我们一起学习了Lua函数的核心知识:

  1. 使用 functionend 定义函数。
  2. 使用 print 函数输出信息,它会自动处理换行。
  3. 定义和调用带参数的函数,参数无需类型声明。
  4. 使用 return 语句让函数返回一个或多个值。
  5. 了解了变参函数的基本概念,并注意到不同Lua版本在实现上的差异。

掌握这些基础是编写更复杂Lua程序的关键。下一节课,我们将探讨如何编译Lua源代码并将其集成到C语言项目中。

课程 P163:LUA中的变量 📚

在本节课中,我们将学习Lua脚本语言中的变量。我们将了解Lua变量的特点、类型、作用域以及它与C/C++等语言中变量的主要区别。课程将通过代码示例来演示这些概念。


概述 📖

Lua脚本可以独立运行,也可以嵌入到C/C++程序中运行。与C/C++不同,Lua中的变量无需显式声明类型,其类型由赋予它的值动态决定。本节我们将通过实践来理解Lua变量的基本用法。


变量的定义与作用域 🎯

在Lua中,变量分为全局变量和局部变量。默认情况下,所有变量都是全局的。使用 local 关键字可以定义局部变量,其作用域仅限于定义它的代码块(如函数)内。

以下是定义变量的基本语法:

local a = 10  -- 这是一个局部变量
b = 20        -- 这是一个全局变量

上一节我们介绍了变量的基本概念,本节中我们来看看变量的作用域和赋值规则。


变量的赋值与类型推断 🔄

Lua变量的类型由赋值给它的值决定。变量可以随时被重新赋值为不同类型的值。

以下是变量赋值与类型推断的示例:

local x = 100      -- x 现在是数字类型
x = "Hello"        -- x 现在是字符串类型
x = true           -- x 现在是布尔类型

如果变量未赋值就使用,其值为 nil,表示“无”或“未定义”。


变量作用域示例 📝

为了更清晰地理解局部变量与全局变量,以及“就近原则”,我们来看一段代码。

以下是作用域测试代码:

local d = 5
f = 1
print(d, f)        -- 输出: 5   1

do
    local d = 11
    local f = 23
    print(d, f)    -- 输出: 11  23 (此处的f是新的局部变量)
end

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/c1fa583ab66cee005d18e95ec156357a_7.png)

print(d, f)        -- 输出: 5   1 (回到外层作用域)

在这段代码中,do...end 块内定义的局部变量 df 不会影响外部的同名变量。


Lua的数据类型 🧱

Lua是一种动态类型语言,支持多种基本数据类型。变量的类型会随着赋值而改变。

以下是Lua支持的主要数据类型:

  1. nil:表示一个无效值或变量未赋值。
  2. boolean:布尔值,包含 truefalse
  3. number:数字,包括整数和双精度浮点数。
  4. string:字符串。
  5. table:表,是Lua中唯一的数据结构,功能类似数组、字典等。


数字与字符串类型示例 🔢

数字和字符串是常用的数据类型。Lua中数字不区分整型和浮点型。

以下是数字与字符串的示例:

local myVar = 111          -- 被当作数字类型
print(myVar)               -- 输出: 111
myVar = 222.333           -- 被当作双精度浮点数
print(myVar)               -- 输出: 222.333
myVar = "I am a string"   -- 类型变为字符串
print(myVar)               -- 输出: I am a string

Table类型(类似数组) 📊

Lua没有内置的数组类型,但使用 table 可以实现类似数组的功能。Table的下标默认从 1 开始。

以下是Table的基本用法:

local myTable = {11, 22, 33, 44, 55, 665} -- 定义一个表
print(myTable[1])  -- 输出第一个元素: 11
print(myTable[2])  -- 输出第二个元素: 22
print(myTable[6])  -- 输出第六个元素: 665
print(myTable[7])  -- 下标越界,输出: nil

Table的功能非常强大,现阶段我们可以先将其理解为可动态增长的数组。


开发环境建议 💻

使用纯文本编辑器编写Lua脚本效率较低。建议使用集成开发环境(IDE),它们能提供语法高亮、代码提示和调试功能,提升开发效率。

一个可选的工具是 LuaStudio,它提供了类似VC++的集成开发体验,方便代码编写和测试。


总结 ✨

本节课中我们一起学习了Lua中变量的核心知识:

  1. Lua变量无需显式声明类型,类型由赋值决定。
  2. 使用 local 定义局部变量,否则为全局变量。
  3. 变量作用域遵循“就近原则”。
  4. 未赋值的变量值为 nil
  5. Lua的主要数据类型包括 nil, boolean, number, string, table
  6. table 是Lua中核心的数据结构,可当作数组使用,下标从1开始。

理解这些基础概念是掌握Lua编程的第一步。建议多编写代码并在控制台测试,以加深理解。

课程 P164:Lua 中的运算符与 if 语句 🧮

在本节课中,我们将学习 Lua 语言中的运算符和 if 条件语句。我们将了解算术、关系和逻辑运算符的用法,并掌握如何使用 if 语句来控制程序的执行流程。


概述 📋

Lua 中的运算符用于执行计算和比较,而 if 语句则用于根据条件决定执行哪部分代码。理解这些基础概念是编写逻辑程序的关键。


算术运算符 ➕➖✖️➗

算术运算符用于执行基本的数学运算。Lua 中的算术运算符与 C 语言类似。

以下是主要的算术运算符:

  • +:加法
  • -:减法
  • *:乘法
  • /:除法

赋值方式也与 C 语言类似,使用单个等号 =。变量可以参与运算。

local a = 5 + 2  -- a 的值为 7
local b = a * 2  -- b 的值为 14

关系运算符 ⚖️

关系运算符用于比较两个值,其结果为布尔值(truefalse)。

以下是主要的关系运算符:

  • >:大于
  • <:小于
  • >=:大于等于
  • <=:小于等于
  • ==:等于
  • ~=:不等于

注意:Lua 中的“不等于”运算符是 ~=,这与 C 语言中的 != 不同。

local x = 22
local y = 212
print(x > y)   -- 输出 false
print(x < y)   -- 输出 true
print(x == y)  -- 输出 false
print(x ~= y)  -- 输出 true

逻辑运算符 🔗

逻辑运算符用于操作布尔值,进行逻辑判断。Lua 的逻辑运算符在书写上与 C 语言不同,但逻辑意义相同。

以下是主要的逻辑运算符:

  • and:逻辑与(C 语言中为 &&
  • or:逻辑或(C 语言中为 ||
  • not:逻辑非(C 语言中为 !

逻辑运算符主要用于布尔值的运算:

  • and:两边的操作数都为 true 时,结果才为 true
  • or:两边的操作数有一个为 true 时,结果就为 true
  • not:对布尔值取反。
local isTrue = true
local isFalse = false

print(isTrue and isFalse) -- 输出 false
print(isTrue or isFalse)  -- 输出 true
print(not isTrue)         -- 输出 false

If 条件语句 🚦

if 语句是主要的流程控制结构,用于根据条件执行不同的代码块。Lua 中的 if 语句有三种基本形式。

形式一:简单的 if 语句

当条件成立时,执行一段代码。

local a = 33
local b = 1

if a > b then
    print("a 大于 b")
end

形式二:if...else 语句

当条件成立时执行一个代码块,不成立时执行另一个代码块。

local a = 33
local b = 33

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/8ce0c6072335d486c12c32ad0c9231b7_8.png)

if a == b then
    print("a 等于 b")
else
    print("a 不等于 b")
end

形式三:if...elseif...else 语句

用于处理多个分支条件。程序会从上到下判断条件,一旦某个条件满足,就执行对应的代码块,然后跳出整个 if 结构。

local score = 85

if score >= 90 then
    print("优秀")
elseif score >= 60 then
    print("及格") -- 当 score 为 85 时,会执行这里
else
    print("不及格")
end

总结 🎯

本节课我们一起学习了 Lua 语言的基础运算符和 if 条件语句。

  • 我们了解了用于计算的算术运算符+, -, *, /)。
  • 我们掌握了用于比较的关系运算符>, <, ==, ~= 等),特别注意了 Lua 中“不等于”是 ~=
  • 我们认识了用于逻辑判断的逻辑运算符and, or, not)。
  • 最后,我们学习了三种形式的 if 语句,它们是我们控制程序流程的重要工具。

掌握这些基础知识是编写更复杂 Lua 程序的第一步。建议你通过实际编写代码来加深理解。

课程 P165:Lua中的三种循环(while, for, repeat)🔄

在本节课中,我们将学习Lua编程语言中的三种循环结构:while循环、for循环和repeat循环。我们将通过简单的示例来理解它们的语法、执行规则以及它们之间的区别。


概述

Lua中的循环结构与C语言类似,但在语法规则上略有不同。本节课将借助Lua的集成环境,逐一测试并讲解这三种循环的使用方法。


1. while循环 🔄

上一节我们介绍了课程概述,本节中我们来看看第一种循环结构:while循环。

while循环首先判断一个条件。当条件为真(true)时,执行循环体内的代码块。当条件变为假(false)时,则退出循环。

以下是while循环的基本语法结构:

while 条件 do
    -- 代码块
end

例如,我们定义一个变量i,初始值为0。当i小于等于10时,执行循环体,每次循环打印i的值并将i加1。

local i = 0
while i <= 10 do
    print(i)
    i = i + 1
end

这段代码会输出从0到10的整数。

如果初始条件为假,例如i初始值为0,但条件是i >= 1,那么循环体一次都不会执行。


2. repeat循环 🔁

上一节我们介绍了while循环,本节中我们来看看第二种循环:repeat循环。

repeat循环的特点是循环体至少会被执行一次。它的结束条件在循环体之后判断。当条件为真(true)时,循环结束。

以下是repeat循环的基本语法结构:

repeat
    -- 代码块
until 条件

例如,要输出1到10的整数,可以这样写:

local i = 1
repeat
    print(i)
    i = i + 1
until i > 10

这段代码会输出从1到10的整数。

需要注意的是,until后面的条件是结束条件。当条件成立时,退出循环。这与while循环的继续执行条件正好相反。在repeat循环中,如果没有在循环体内改变变量的值,可能会导致死循环。


3. for循环 🔂

上一节我们介绍了repeat循环,本节中我们来看看第三种,也是最常见的循环:for循环。

Lua的for循环有两种形式:数值for循环和泛型for循环。本节课我们主要讲解数值for循环。它包含一个起始值、一个结束值和一个可选的步长。

以下是数值for循环的基本语法结构:

for 变量 = 起始值, 结束值, 步长 do
    -- 代码块
end

循环会从起始值开始,每次循环后变量增加一个步长,直到变量的值大于结束值时停止。当步长为1时,可以省略。

例如,输出1到10的整数:

for i = 1, 10 do
    print(i)
end

如果指定步长为2,则输出1, 3, 5, 7, 9:

for i = 1, 10, 2 do
    print(i)
end

4. 循环控制:break关键字 🛑

在循环体中,我们可以使用break关键字来立即终止整个循环。

例如,在for循环中,当i大于5时跳出循环:

for i = 1, 10 do
    if i > 5 then
        break
    end
    print(i)
end

这段代码只会输出1到5。

Lua中没有continue关键字。如果需要跳过某次循环,可以使用if语句进行条件控制。


总结

本节课中我们一起学习了Lua中的三种循环结构:

  • while循环:先判断条件,条件为真时执行循环体。
  • repeat循环:先执行一次循环体,再判断结束条件。
  • for循环:常用于按数值范围进行迭代,可以指定起始值、结束值和步长。

我们还学习了使用break关键字来提前退出循环。理解这些循环的区别和适用场景,是编写Lua程序的基础。建议课后多加练习和测试以巩固理解。

课程 P166:将Lua环境更换为5.1.5版本 📦

在本节课中,我们将学习如何将Lua脚本的运行环境从5.3.2版本更换为5.1.5版本。我们将了解更换的原因、具体操作步骤,并完成一个简单的代码测试。


概述:为何需要更换版本?🔍

上一节我们介绍了Lua的基本使用。本节中我们来看看版本兼容性问题。

Lua 5.3.2版本对某些脚本的支持不够完善,尤其是在处理变参(可变参数)时。这可能是由于版本升级后进行了较大修改。为了确保脚本的兼容性和稳定性,我们将环境更换为资料更丰富、兼容性更好的Lua 5.1.5版本。

为了说明问题,我们首先编写一段使用变参的Lua脚本。

以下是示例脚本代码:

-- 这是一个支持变参的累加函数
function sum(...)
    local total = 0
    -- 使用for循环遍历所有变参
    for i = 1, arg.n do
        total = total + arg[i]
    end
    return total
end

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/6b2b3deb284b48b7c558632f417d4bef_10.png)

-- 在主程序中调用并打印结果
print(sum(1, 3, 5, 9))

这段脚本定义了一个sum函数,它接受可变数量的参数并将它们累加。arg是一个隐藏的数组,用于存储所有传入的变参。arg.n表示变参的数量。

在Lua 5.1.5环境中,这段脚本可以正常运行并输出结果18(即1+3+5+9)。然而,在5.3.2版本中运行相同的脚本,则会提示语法错误,无法得到正确结果。同样,在C++中调用时,如果链接的是5.2版本之前的库,也可能无法正常工作。

因此,为了获得最佳的兼容性和最多的学习资料支持,我们决定将开发环境更换为Lua 5.1.5。


第一步:下载并准备Lua 5.1.5库文件 📥

首先,我们需要获取Lua 5.1.5的预编译库文件。这些文件通常可以在相关的开发者论坛或资源网站找到。

下载完成后,你会得到一个压缩包。将其解压到一个固定的目录中,例如C:\lua515。解压后的目录结构应包含以下关键部分:

  • include/: 存放Lua头文件(如lua.h)。
  • lib/: 存放静态链接库文件(.lib)。通常包含两个版本:
    • lua515.lib: 发布(Release)版本。
    • lua515d.lib: 调试(Debug)版本。

请确保你清楚这些文件的位置,因为在下一步的Visual Studio配置中需要用到这些路径。


第二步:在Visual Studio 2010中配置项目 ⚙️

上一节我们准备好了库文件,本节中我们来看看如何在开发环境中进行配置。

  1. 打开或创建一个新的Visual Studio 2010 C++项目(例如,项目名为P166)。
  2. 在解决方案资源管理器中,切换到“属性管理器”视图。
  3. 展开你的项目,找到Debug | Win32Release | Win32配置下的Microsoft.Cpp.Win32.user条目。

以下是针对Debug配置的详细设置步骤:

  • 配置C++包含目录:

    • 右键点击Debug | Win32下的Microsoft.Cpp.Win32.user,选择“属性”。
    • 在“通用属性” -> “VC++ 目录” -> “包含目录”中,点击编辑。
    • 删除旧的路径,添加你解压的Lua头文件目录,例如:C:\lua515\include。点击确定。
  • 配置库目录:

    • 在同一个属性页,找到“库目录”。
    • 点击编辑,添加Lua库文件所在的目录,例如:C:\lua515\lib。点击确定。
  • 配置链接器输入:

    • 在左侧选择“链接器” -> “输入”。
    • 在“附加依赖项”中,点击编辑。
    • 确保添加了调试版的库文件名:lua515d.lib。点击确定并应用。
  1. Release | Win32配置重复上述步骤。注意:在“链接器” -> “输入” -> “附加依赖项”中,应添加发布版的库文件名:lua515.lib

完成这些配置后,你的项目就具备了链接Lua 5.1.5库的能力。


第三步:编写并测试C++调用代码 💻

环境配置完成后,我们可以编写一个简单的C++程序来加载并执行Lua脚本。

以下是调用Lua脚本的C++示例代码:

// 包含必要的头文件
extern "C" {
    #include "lua.h"
    #include "lualib.h"
    #include "lauxlib.h"
}

int main() {
    // 1. 创建Lua状态机(环境指针)
    lua_State* L = luaL_newstate();
    // 2. 打开Lua标准库
    luaL_openlibs(L);
    // 3. 加载并执行指定的Lua脚本文件
    luaL_dofile(L, "177.lua");
    // 4. 关闭Lua状态机
    lua_close(L);

    // 防止控制台窗口一闪而过
    getchar();
    return 0;
}

代码说明:

  • luaL_newstate(): 创建一个新的Lua环境。
  • luaL_openlibs(L): 打开所有Lua标准库,以便在脚本中使用print等函数。
  • luaL_dofile(L, “177.lua”): 加载并执行名为177.lua的脚本文件。
  • lua_close(L): 最后关闭环境,释放资源。
  • getchar(): 让程序暂停,等待用户按回车键,以便观察控制台输出。

重要提示:
确保你编写的177.lua脚本文件与最终生成的可执行文件(.exe)位于同一目录下。你可以在项目属性中配置输出目录,使其与调试工作目录一致,这样可以避免“找不到文件”的错误。

编译并运行此C++程序。如果一切配置正确,控制台将成功输出Lua脚本177.lua的执行结果(例如,数字18),这证明Lua 5.1.5环境已成功集成并正常工作。


总结 📝

本节课中我们一起学习了如何将Lua开发环境从5.3.2版本更换为5.1.5版本。

我们首先通过一个变参脚本示例,理解了版本不兼容可能导致的问题。接着,我们分步操作:下载Lua 5.1.5的库文件、在Visual Studio 2010中正确配置项目的包含目录、库目录和链接器设置。最后,我们编写了一个简单的C++程序来创建Lua状态机、执行外部脚本,并验证了环境配置的成功。

通过本次课程,你掌握了在特定开发环境中管理和切换第三方库版本的基本流程,这是解决依赖兼容性问题的重要技能。

课程 P167:Lua 中的 Table 结构及相关操作 - 数组篇 📚

在本节课中,我们将要学习 Lua 语言中一个核心且强大的数据结构:Table。我们将重点探讨它作为数组使用时的定义、初始化、遍历以及内置函数操作。通过本教程,你将掌握如何创建和使用 Lua 数组。


概述 📖

Table 是 Lua 中唯一的数据结构,它非常灵活,可以模拟数组、字典等多种结构。本节课,我们将聚焦于 Table 作为数组的用法,了解其与 C++ 等语言中数组的异同,并学习相关的操作函数。


Table 的定义与初始化

Table 的定义非常简单。我们可以创建一个空表,也可以直接为其赋予初始值。

-- 定义一个空表
local myTable = {}

-- 定义一个包含初始值的表(类似数组)
local myArray = {1, 2, 0x15, 4, 5, 6, 7}

在 Lua 中,数组的下标默认从 1 开始,而不是 0。因此,访问元素的方式如下:

print(myArray[1]) -- 输出:1
print(myArray[2]) -- 输出:2
print(myArray[3]) -- 输出:21 (0x15的十进制值)

遍历 Table(数组)

为了遍历整个数组,我们可以使用 Lua 内置的 table.getn 函数(或 # 操作符)来获取数组的长度。

以下是遍历数组的一个示例函数:

function printTable(t)
    local n = #t -- 或 table.getn(t),获取表的大小
    print("开始遍历表:")
    for i = 1, n do
        print(t[i])
    end
    print("遍历表结束。")
end

-- 调用函数遍历之前定义的数组
printTable(myArray)

执行上述代码,将依次输出数组中的元素:1, 2, 21, 4, 5, 6, 7。

我们也可以先获取表的大小,再进行操作:

local size = #myArray
print("表的大小是:" .. size) -- 输出:表的大小是:7

动态操作数组元素

Table 作为数组,其大小是动态的。我们可以方便地增加、修改或删除元素。

上一节我们介绍了如何定义和遍历数组,本节中我们来看看如何动态地操作它。

使用循环初始化数组

我们可以通过循环来动态地为数组赋值。

local dynamicArray = {}
for i = 1, 10 do
    dynamicArray[i] = i
end
printTable(dynamicArray) -- 将输出 1 到 10

在数组末尾追加元素

我们可以在现有数组的基础上继续添加元素。

local n = #dynamicArray -- 获取当前数组长度
for i = n + 1, n + 10 do
    dynamicArray[i] = i
end
printTable(dynamicArray) -- 将输出 1 到 20

Table 的内置函数操作

Lua 为 Table 提供了一些非常实用的内置函数,用于排序、插入和删除元素。

排序数组

我们可以使用 table.sort 函数对数组进行升序排序。请注意,此函数要求数组中的所有元素都是数字类型。

local unsortedArray = {33, 22, 11, 3, 2}
table.sort(unsortedArray)
printTable(unsortedArray) -- 输出:2, 3, 11, 22, 33

在指定位置插入元素

使用 table.insert 函数可以在数组的任意位置插入一个新元素,其后的元素会自动后移。

以下是 table.insert 函数的用法:

-- 语法:table.insert(table, [pos,] value)
-- 在位置`pos`插入`value`,如果省略`pos`,则默认插入到末尾。

local testArray = {1, 3, 4, 5}
table.insert(testArray, 2, 2) -- 在第二个位置插入数字2
printTable(testArray) -- 输出:1, 2, 3, 4, 5

移除指定位置的元素

使用 table.remove 函数可以移除数组中指定位置的元素,其后的元素会自动前移。

以下是 table.remove 函数的用法:

-- 语法:table.remove(table, [pos])
-- 移除位置`pos`的元素并返回它,如果省略`pos`,则默认移除最后一个元素。

local testArray = {1, 2, 9, 3, 4}
table.remove(testArray, 3) -- 移除第三个元素(数字9)
printTable(testArray) -- 输出:1, 2, 3, 4

注意事项与总结

本节课我们一起学习了 Lua 中 Table 结构作为数组的基本用法。我们来回顾一下重点:

  1. 定义与访问:Table 使用花括号 {} 定义,下标从 1 开始。
  2. 遍历:使用 # 操作符获取长度,配合 for 循环进行遍历。
  3. 动态性:数组大小可变,可以随时增删元素。
  4. 内置函数
    • table.sort(t):对数组进行升序排序(要求元素为数字)。
    • table.insert(t, pos, value):在指定位置插入元素。
    • table.remove(t, pos):移除指定位置的元素。

重要提示table.sort 函数要求数组内的所有元素都是可比较的数字。如果数组中包含字符串或其他类型,排序可能会产生错误或非预期结果。

Table 的功能远不止于此,它还可以作为字典(键值对)使用,并支持多维结构。我们将在后续课程中继续探讨 Table 更强大的功能。

📚 课程 P168:Table结构高级运用 - 多维数组及pairs函数

在本节课中,我们将学习Lua中Table结构的两个高级主题:用于遍历表的pairsipairs函数,以及如何构建和访问多维数组。掌握这些知识将帮助你更灵活地处理复杂的数据结构。


🔍 遍历函数:pairs 与 ipairs

上一节我们介绍了Table的基本操作,本节中我们来看看如何遍历Table。Lua提供了pairsipairs两个函数,它们都用于配合for循环来遍历一张表。

以下是两种遍历有序数组式Table的方法对比:

-- 创建一个表
local myTable = {10, 20, 30}

-- 方法1:使用 # 获取长度和数字索引遍历
for i = 1, #myTable do
    print(i, myTable[i])
end

-- 方法2:使用 ipairs 函数遍历
for i, v in ipairs(myTable) do
    print(i, v)
end

这两种方式遍历有序表(数组部分)的结果是相同的。ipairs函数会依次返回索引i和对应的值v

然而,当Table是一个键值对无序表(或称字典)时,ipairs将无法遍历其所有元素。

-- 创建一个无序表
local myUnorderedTable = {name = "Alice", age = 25, [10] = "ten"}

-- 使用 ipairs 遍历(无效)
for i, v in ipairs(myUnorderedTable) do
    print(i, v) -- 不会输出任何内容
end

-- 使用 pairs 遍历(有效)
for k, v in pairs(myUnorderedTable) do
    print(k, v) -- 会输出所有键值对,如 "name Alice", "age 25", "10 ten"
end

pairs函数可以遍历表中的所有元素,无论其键是数字还是字符串,无论是否有序。而ipairs仅遍历从1开始的连续整数索引部分。

核心概念ipairs(t) 遍历 t[1], t[2], ... 直到遇到第一个nilpairs(t) 遍历表 t 中的所有键值对。


🧱 构建多维数组

了解了遍历方法后,我们来看如何用Table构建更复杂的数据结构。在Lua中,我们可以通过让Table的元素也是Table来创建多维数组。

假设我们有两个一维数组(Table):

local tableA = {"A1", "A2", "A3"}
local tableB = {7, 8, 9}

现在,我们可以创建另一个Table来包含它们,从而形成一个二维数组:

local multiTable = {tableA, tableB}

访问这个二维数组中的元素,需要使用多个索引:

print(multiTable[1][1]) -- 输出: A1 (访问tableA的第一个元素)
print(multiTable[2][1]) -- 输出: 7  (访问tableB的第一个元素)
print(multiTable[2][4]) -- 输出: nil (索引超出范围)

multiTable[1]获取到的是tableA这个子表,再通过[1]索引就访问到了tableA中的第一个元素“A1”。

多维数组同样可以包含无序表,结构会更复杂,但访问原理相通,都是通过层层索引。


📝 本节总结

本节课中我们一起学习了:

  1. pairsipairs 函数ipairs用于遍历有序的数组部分;pairs用于遍历整个表的所有键值对,适用于无序字典。
  2. 多维数组的构建与访问:通过将Table嵌套在另一个Table中,可以创建多维数据结构,并使用连续索引(如t[1][2])进行访问。

理解这些概念的关键在于多练习和测试。你可以尝试创建不同的表结构,分别用pairsipairs遍历,并构建自己的二维、三维数组来加深理解。

课程 P169:Lua 文件读写操作 📂

在本节课中,我们将学习 Lua 语言中如何进行文件的基本读写操作。文件操作是编程中处理数据持久化的重要环节,掌握它可以帮助你将程序运行的结果保存下来,或者从外部文件中读取配置和数据。


概述

本节课将介绍 Lua 内置的 io 库,它提供了打开、读取、写入和关闭文件的功能。我们将通过具体的代码示例,学习如何创建新文件、向文件中写入数据,以及如何从现有文件中读取内容。


创建与写入文件 ✍️

在 Lua 中,创建和写入文件主要使用 io.open 函数。该函数接受两个参数:文件名和打开模式。当模式为 "w"(写入)时,如果文件不存在则会创建新文件;如果文件已存在,则会清空原有内容。

以下是创建并写入文件的核心代码:

local function createFile()
    local myFile = io.open("test.lua", "w")
    if myFile ~= nil then
        myFile:write("第一行文本" .. string.char(10)) -- 使用 string.char(10) 换行
        myFile:write("第二行文本\n") -- 使用转义字符 \n 换行
        myFile:close()
        print("文件创建并写入成功。")
    else
        print("文件创建失败。")
    end
end

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/7cec48798f8140f1c1fa8bfb6c4b5515_13.png)

createFile()

代码解析:

  1. io.open("test.lua", "w") 尝试以写入模式打开文件。成功则返回一个文件句柄,失败则返回 nil
  2. myFile:write(...) 方法用于向文件写入字符串。可以使用 .. string.char(10)\n 实现换行。
  3. 操作完成后,必须调用 myFile:close() 来关闭文件,确保数据被正确保存。

上一节我们介绍了如何创建和写入文件,本节中我们来看看如何读取文件中的内容。


读取文件内容 📖

读取文件同样使用 io.open 函数,但模式需改为 "r"(读取)。Lua 提供了多种读取文件内容的方法。

方法一:使用 while 循环逐行读取

这是一种常见的读取方式,使用 file:read("*l") 每次读取一行。

local function readFileWithWhile()
    local fileName = "test.lua"
    local file = io.open(fileName, "r")
    if file then
        local contentLine = file:read("*l")
        while contentLine do
            print(contentLine)
            contentLine = file:read("*l")
        end
        file:close()
    else
        print("无法打开文件: " .. fileName)
    end
end

readFileWithWhile()

方法二:使用 for 循环与迭代器

Lua 的 io.lines 函数或 file:lines() 方法可以返回一个迭代器,使代码更简洁。

local function readFileWithFor()
    local fileName = "test.lua"
    local file = io.open(fileName, "r")
    if file then
        for line in file:lines() do
            print(line)
        end
        file:close()
    end
end

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/7cec48798f8140f1c1fa8bfb6c4b5515_25.png)

readFileWithFor()

两种方法的对比:

  • while 循环方式更基础,可以更灵活地控制读取过程(例如在读取过程中进行条件判断)。
  • for 循环方式代码更简洁,是遍历文件每一行的推荐做法。

以下是 file:read 函数支持的部分格式化读取模式:

  • "*l": 读取下一行(默认模式)。
  • "*a": 读取整个文件。
  • "*n": 读取一个数字。
  • number: 读取指定字节数的字符(例如 file:read(5) 读取5个字节)。

其他文件操作模式

除了 "w"(写入)和 "r"(读取)模式,io.open 还支持其他有用的模式:

  • "a": 追加模式。如果文件存在,则在文件末尾追加内容;如果不存在,则创建新文件。
  • "r+": 读写模式。文件必须存在。
  • "w+": 读写模式。会清空原文件或创建新文件。
  • "a+": 追加读写模式。

例如,以追加模式写入文件:

local appendFile = io.open("log.txt", "a")
appendFile:write("这是一条新的日志记录\n")
appendFile:close()


总结

本节课中我们一起学习了 Lua 文件操作的核心知识:

  1. 写入文件:使用 io.open(filename, "w") 打开文件,通过 file:write() 写入数据,最后用 file:close() 关闭。
  2. 读取文件:使用 io.open(filename, "r") 打开文件,可以通过 while 循环配合 file:read("*l") 或更简洁的 for line in file:lines() 循环来逐行读取内容。
  3. 操作模式:了解了 "r""w""a" 等不同文件打开模式的区别和用途。

文件读写是程序与外部世界交互的基础。掌握这些操作后,你就可以让 Lua 程序保存状态、读取配置或处理文本数据了。在后续课程中,我们会在需要时进一步探讨 io 库的其他功能。

课程 P17:028-封装选怪打怪功能 🎮

在本节课中,我们将学习如何封装两个核心函数:一个用于自动选择附近的怪物,另一个用于实现自动攻击。通过将之前学到的知识模块化,我们将构建出自动挂机打怪功能的雏形。


封装自动选怪函数 🎯

上一节我们介绍了根据下标选中对象的功能。本节中,我们来看看如何封装一个更智能的函数,使其能自动选择距离角色最近的怪物。

首先,我们需要在角色的结构体中添加新的函数。这个函数的核心逻辑是:遍历附近的对象列表,筛选出怪物,并计算距离,最终选中最近的那一个。

以下是实现此功能的关键步骤:

  1. 遍历附近对象列表:该列表不仅包含怪物,也包含玩家等其他对象。
  2. 判断对象类型:筛选出类型为“怪物”的对象。
  3. 计算并比较距离:计算每个怪物与角色的距离,并记录最小值。
  4. 选中目标:调用之前封装的函数,选中距离最近的怪物。

为了实现上述逻辑,我们需要定义两个全局变量:

  • nIndex:用于存储找到的怪物下标。
  • nDistance:用于存储当前找到的最小距离,初始值可设为一个很大的数(如 0xFFFFFF)。

在遍历列表前,我们需要对数据进行预处理。原“怪物列表”名称并不准确,因为它包含了多种对象。我们应将其重命名为“附近对象列表”,并在初始化时过滤数据,例如通过对象类型(如类型ID为2)来标识怪物。

以下是核心代码逻辑的伪代码描述:

int nIndex = 0xFF; // 初始化为无效值
int nDistance = 0xFFFFFF; // 初始化为一个很大的距离

for (int i = 0; i < nearbyObjectCount; i++) {
    // 1. 检查对象数据是否有效(非零)
    if (object[i].isValid == false) {
        continue; // 跳过无效对象,继续下一次循环
    }
    
    // 2. 判断对象类型是否为怪物(例如 type == 2)
    if (object[i].type != MONSTER_TYPE) {
        continue; // 跳过非怪物对象
    }
    
    // 3. 计算距离
    int currentDistance = CalculateDistance(player, object[i]);
    
    // 4. 比较并更新最小距离及对应下标
    if (currentDistance < nDistance) {
        nDistance = currentDistance;
        nIndex = i; // 记录最近怪物的下标
    }
}

// 5. 如果找到了有效的怪物下标,则选中它
if (nIndex != 0xFF) {
    SetTarget(nIndex); // 调用选中函数
    return true; // 选怪成功
} else {
    return false; // 选怪失败,附近无怪物
}

完成循环后,nDistance 中存储的就是最小距离,nIndex 中存储的就是对应怪物的下标。最后,调用 SetTarget 函数即可选中目标。如果遍历完列表后 nIndex 值未改变(仍为初始值 0xFF),则说明选怪失败,附近没有符合条件的怪物。


封装自动打怪函数 ⚔️

选怪功能封装完成后,我们就可以在此基础上构建自动打怪函数了。自动打怪主要包含两个动作:选怪和攻击。

然而,直接连续执行这两个动作可能效率不高。例如,如果当前已经选中了一个怪物且它尚未被击败,此时立即重新选怪是不合理的。因此,我们需要增加一个判断条件:仅当角色当前没有选中任何有效的怪物目标时,才执行选怪逻辑。

以下是实现自动打怪的关键步骤:

  1. 检查当前目标状态:获取角色当前选中的目标ID。
  2. 判断是否需要选怪:如果当前目标ID无效(例如为 0xFF-1,代表空目标或选中自己),则执行选怪函数。
  3. 执行攻击动作:选怪成功后(或已有有效目标),调用攻击命令。

以下是简化的代码逻辑:

void AutoFight() {
    // 1. 获取当前选中的目标ID
    int currentTargetId = GetCurrentTarget();
    
    // 2. 判断是否需要重新选怪(例如目标无效或为空)
    if (currentTargetId == INVALID_TARGET_ID) {
        // 调用我们封装的自动选怪函数
        if (SelectNearestMonster() == true) {
            // 选怪成功,执行攻击
            ExecuteAttack();
        } else {
            // 选怪失败,可能附近无怪
            // 可以等待或执行其他逻辑
        }
    } else {
        // 3. 当前已有有效目标,直接攻击
        ExecuteAttack();
    }
}

AutoFight 函数绑定到测试按钮上,即可进行点击测试。点击按钮后,函数会检查当前状态:若无目标则选怪并攻击;若有目标则直接攻击。

需要注意的是,目前的判断条件(currentTargetId == INVALID_TARGET_ID)还比较粗略。在实际游戏中,角色可能选中自己或其他玩家,这些情况都应被视为“无效战斗目标”。更完善的判断应检查当前选中对象的类型是否为怪物。我们将在后续课程中对此进行优化。

若要实现真正意义上的“自动”挂机,需要配合定时器或线程来循环调用 AutoFight 函数,而不是依靠手动点击。


总结 📝

本节课中我们一起学习了如何封装两个关键的游戏自动化功能。

首先,我们封装了 SelectNearestMonster(自动选怪)函数。它通过遍历附近对象列表、筛选怪物类型、计算并比较距离,最终自动选中最近的怪物。这个函数是智能战斗的基础。

接着,我们基于选怪函数封装了 AutoFight(自动打怪)函数。它加入了简单的状态判断,只在没有有效攻击目标时才执行选怪,然后发起攻击,从而构成了自动战斗循环的雏形。

目前实现的版本已能完成基本流程,但判断逻辑还可以更加精确(例如排除选中玩家自身的情况)。我们将在下一节课中继续完善这些细节。通过本节课的实践,你已经掌握了将复杂操作模块化封装的核心思路,这是编写高效、可维护游戏辅助程序的重要一步。

课程 P170:181 - 修改Lua文件让它支持中文函数 🛠️

在本节课中,我们将学习如何通过修改Lua解释器的源代码,使其能够识别和注册中文函数名。这对于希望在脚本中使用中文标识符的开发者来说非常有用。


概述

默认情况下,Lua解释器在编译脚本时,只支持由字母、数字和下划线组成的标识符。这意味着我们无法直接注册或调用名为“加法”或“累加”这样的中文函数。本节课的目标就是修改Lua的词法分析器,使其能够正确处理中文字符,从而支持中文函数名。


问题演示

首先,我们通过一个简单的测试工程来演示问题。

我们创建一个测试工程,并编写以下测试代码。这段代码尝试用英文和中文两种方式注册同一个函数。

// 示例:注册英文函数名(正常)
lua_register(L, "add123", id_number);
lua_register(L, "add345", id_number);

// 示例:尝试注册中文函数名(修改前会失败)
lua_register(L, "加法", id_number);
lua_register(L, "累加", id_number);

在未修改的Lua库中,执行包含加法()调用的脚本不会有任何输出,因为解释器无法识别中文函数名,导致编译失败。


解决方案:修改源代码

上一节我们演示了问题所在,本节中我们来看看如何通过修改Lua的源代码来解决它。

核心思路是修改Lua的词法分析器(lexer),具体是llex.c文件中的llex函数。该函数负责读取并识别源代码中的标识符(如变量名、函数名)。我们需要让它能够识别中文字符。

关键修改点

中文字符通常占两个字节(UTF-8编码),且其首字节的值大于0x80。我们需要在判断标识符字符的地方加入这个条件。

以下是需要修改的代码段,位于llex.c文件的llex函数中:

  1. 首先,我们添加一个辅助宏或函数来判断是否为中文字符首字节。 为了简单起见,我们可以在文件顶部附近添加一个宏定义:

    #define isChinese(c)  ((c) >= 0x80)
    

    (注意:这是一个简化的判断,适用于GBK等编码。对于完整的UTF-8支持需要更复杂的逻辑,但本例旨在说明原理。)

  2. 接着,找到标识符识别的循环部分。llex函数中,寻找处理标识符(名称)的代码块,通常包含一个while循环,条件类似:

    while (isalpha(c) || isdigit(c) || c == '_') {
        // ... 保存字符并读取下一个
    }
    
  3. 修改循环条件,将中文字符判断加入。 将其修改为:

    while (isalpha(c) || isdigit(c) || c == '_' || isChinese(c)) {
        // ... 保存字符并读取下一个
    }
    
  4. 确保字符读取正确。 由于中文字符占多个字节,在Save和读取下一个字符(next)时,需要确保逻辑能处理多字节字符。原始的llex函数可能使用next(ls)读取单个字节。如果我们的源文件编码是GBK,一个中文字符需要连续读取两个字节。这可能需要调整savenext的调用次数,但基本的Lua源码结构通常能通过循环自动处理。

核心修改公式:标识符字符的判断条件从 isalpha(c) || isdigit(c) || c == '_' 扩展为 isalpha(c) || isdigit(c) || c == '_' || (c >= 0x80)


实施步骤

以下是具体的操作步骤:

  1. 备份原文件:在修改前,备份llex.c文件。
  2. 打开源代码:用文本编辑器或IDE打开Lua源码中的llex.c文件。
  3. 定位函数:搜索llex函数。
  4. 添加判断宏:在文件开头合适位置添加#define isChinese(c) ((c) >= 0x80)
  5. 修改循环条件:找到标识符读取的while循环,将其条件修改为包含isChinese(c)
  6. 编译测试:重新编译Lua库(生成lua5.1.liblua5.1.dll等)。
  7. 更新项目:将新编译的库文件链接到你的测试工程中。
  8. 运行测试:再次运行之前的测试代码,现在调用加法()累加()应该能成功执行并输出结果。

测试结果

修改并重新编译Lua库后,运行相同的测试工程。

此时,执行包含中文函数调用的脚本,例如:

加法()

程序能够正确识别“加法”这个函数名,并跳转到对应的C函数id_number执行,输出预期结果。这表明我们的修改成功了。


总结

本节课中我们一起学习了如何让Lua脚本支持中文函数名。

  1. 我们分析了问题:原版Lua的词法分析器不支持中文字符作为标识符。
  2. 我们找到了解决方案:通过修改llex.c源文件,在标识符识别逻辑中加入对中文字符(首字节>0x80)的判断。
  3. 我们实施了修改:添加判断宏并修改了关键的while循环条件。
  4. 我们验证了结果:重新编译库后,中文函数名可以被正常注册和调用。

通过这个修改,你可以更自由地在Lua脚本中使用中文命名,提高代码对中文使用者的可读性。请注意,本例使用的是简单的GBK编码判断原则,在实际项目中如需支持UTF-8,可能需要更细致的字符处理逻辑。

课程 P171:为LUA封装调试函数dbgprint_mine 🐛

在本节课中,我们将学习如何为Lua脚本环境封装一个自定义的调试打印函数 dbgprint_mine。这个函数可以将脚本中的调试信息输出到调试查看器中,方便我们观察脚本运行状态。


1. 准备工作与项目结构

上一节我们介绍了Lua脚本的基本调用。本节中,我们来看看如何为脚本环境添加一个实用的调试工具。

首先,我们需要打开第139课的代码项目。

在解决方案资源管理器中,添加一个新的筛选器,可以命名为“LuaScript”或类似名称,用于存放与脚本相关的文件。

在该筛选器下,添加两个新项:

  1. 一个头文件(.h
  2. 一个源文件(.cpp

在头文件中,我们需要包含Lua相关的头文件,并将我们即将编写的函数进行前置声明。

// LuaScript.h
#pragma once
extern "C" {
    #include "lua.h"
    #include "lualib.h"
    #include "lauxlib.h"
}

// 前置声明调试打印函数
int dbgprint_mine(lua_State* L);

2. 实现调试打印函数

准备工作完成后,现在我们来具体实现 dbgprint_mine 函数。这个函数的核心功能是接收Lua脚本传入的多个参数,并将它们格式化成字符串后输出。

在源文件中,我们首先需要包含必要的头文件。

// LuaScript.cpp
#include “LuaScript.h”
#include <windows.h> // 用于OutputDebugString函数

接下来,我们实现 dbgprint_mine 函数。

int dbgprint_mine(lua_State* L) {
    // 1. 获取传入参数的个数
    int n = lua_gettop(L);
    if (n == 0) {
        // 没有参数,直接返回
        return 0;
    }

    // 2. 分配一个缓冲区来存放格式化后的字符串
    char szBuffer[1024] = {0};
    char* cptr = szBuffer;

    // 3. 添加自定义前缀,便于在调试信息中识别
    strcpy(cptr, “[MyDebug] “);
    cptr += strlen(“[MyDebug] “);

    // 4. 转换并拼接第一个参数
    const char* str = lua_tostring(L, 1);
    if (str) {
        strcpy(cptr, str);
        cptr += strlen(str);
    }

    // 5. 循环处理剩余参数(从第2个开始)
    for (int i = 2; i <= n; ++i) {
        strcpy(cptr, “, “); // 用逗号分隔
        cptr += 2;

        const char* strParam = lua_tostring(L, i);
        if (strParam) {
            strcpy(cptr, strParam);
            cptr += strlen(strParam);
        }
    }

    // 6. 将最终字符串输出到调试器
    OutputDebugStringA(szBuffer);

    // 7. 函数返回值的个数(这里返回0个)
    return 0;
}

核心概念解释

  • lua_gettop(L):获取Lua栈顶的索引,即传入参数的个数。
  • lua_tostring(L, index):将Lua栈中指定索引的值转换为C语言风格的字符串(const char*)。无论原值是数字、布尔值还是字符串,此函数都能进行转换。
  • OutputDebugStringA():Windows API,将字符串输出到调试器的输出窗口。

3. 注册函数到Lua环境

函数实现后,需要将其注册到Lua虚拟机中,脚本才能调用。以下是注册该函数的方法。

首先,定义一个结构体数组,用于描述要注册的函数。

// 定义函数注册结构数组
static const struct luaL_Reg mylib[] = {
    {“dbgprint”, dbgprint_mine},   // 注册为 dbgprint
    {“print_debug”, dbgprint_mine}, // 注册为 print_debug
    {“调试打印”, dbgprint_mine},    // 甚至可以注册中文名
    {NULL, NULL} // 数组结束标志
};

然后,创建一个注册函数,在初始化Lua环境时调用它。

void RegisterMyFunctions(lua_State* L) {
    // 计算数组中实际函数的个数
    int count = sizeof(mylib) / sizeof(luaL_Reg) - 1; // 减去末尾的{NULL, NULL}

    // 循环注册每一个函数
    for (int i = 0; i < count; ++i) {
        lua_register(L, mylib[i].name, mylib[i].func);
    }
}

代码说明

  • luaL_Reg 结构体包含 name(函数在Lua中的名字)和 func(对应的C函数指针)。
  • lua_register(L, name, func) 将C函数 func 以名称 name 注册为Lua的全局函数。

4. 在MFC界面中进行测试

函数注册好后,我们可以在MFC应用程序的界面中添加按钮进行测试。

在对话框的头文件中,包含我们的Lua脚本头文件,并声明Lua状态机和相关函数。

// YourDlg.h
#include “LuaScript.h”
class CYourDlg : public CDialogEx {
    // ...
private:
    lua_State* m_pLuaState;
    void InitLua();
    void DoLuaFile();
};

在对话框的源文件中,实现初始化和执行脚本的功能。

// YourDlg.cpp
void CYourDlg::InitLua() {
    if (!m_pLuaState) {
        m_pLuaState = luaL_newstate(); // 创建新的Lua状态机
        luaL_openlibs(m_pLuaState);    // 打开标准库
        RegisterMyFunctions(m_pLuaState); // 注册我们的自定义函数
    }
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/49877e28e50c179a37dc571022e0e080_5.png)

void CYourDlg::DoLuaFile() {
    if (m_pLuaState) {
        // 假设脚本文件放在游戏客户端目录下
        int result = luaL_dofile(m_pLuaState, “test.lua”);
        if (result != LUA_OK) {
            CString err = lua_tostring(m_pLuaState, -1);
            AfxMessageBox(err);
        }
    }
}

为两个按钮(例如“初始化Lua”和“执行脚本”)分别绑定 InitLua()DoLuaFile() 函数。


5. 编写测试脚本并运行

最后,我们创建一个Lua测试脚本来验证调试函数是否工作正常。

创建一个名为 test.lua 的文件,内容如下:

-- test.lua
local a = “Hello”
local b = “World”
local c = 123
local d = 45.67

-- 使用我们注册的任何一个名字调用调试函数
dbgprint(a, b, c, d, 999, 88.88)
-- 或者 print_debug(a, b)
-- 或者 调试打印(a, b)

test.lua 文件复制到你的游戏客户端应用程序的同一目录下。运行你的MFC程序,点击“初始化Lua”按钮,然后点击“执行脚本”按钮。

打开调试器(如Visual Studio的输出窗口或DebugView工具),你应该能看到类似以下的输出:

[MyDebug] Hello, World, 123, 45.67, 999, 88.88

这表明我们的调试打印函数封装成功,能够正确接收并输出Lua脚本中的各种类型参数。


6. 课程总结与作业

本节课中我们一起学习了如何为Lua环境封装一个自定义的调试打印函数。我们完成了从函数实现、注册到MFC界面集成测试的完整流程。关键点在于使用 lua_tostring 进行参数转换,以及通过 lua_register 将C函数暴露给Lua脚本。

课后作业
请尝试封装一个寻路功能的函数到Lua中。目标是在Lua脚本里可以这样调用:

MoveTo(100, 200) -- 移动到坐标(100, 200)
local x = 300
local y = 400
MoveTo(x, y) -- 移动到变量指定的坐标
dbgprint(“正在移动到位置:”, x, y) -- 结合本节课的调试函数输出信息

作业要求

  1. 在C++中实现 MoveTo 函数(内部可以暂时用 dbgprint 模拟寻路逻辑)。
  2. 将该函数注册到Lua环境。
  3. 编写一个Lua测试脚本,调用 MoveTo 并打印调试信息。
  4. 确保脚本能正常执行,并在调试器中看到预期的输出。

下一节课,我们将具体讲解如何封装这个寻路函数的脚本接口。本节课就到这里。

课程 P172:183-LUA封装寻路函数FindWay 🧭

在本节课中,我们将学习如何将一个C++中的寻路功能封装成Lua脚本可以调用的函数。我们将从现有代码出发,逐步完成函数的添加、参数处理、功能封装以及最终的注册和测试。


概述与准备工作

首先,我们打开第182课的代码作为基础。

上一节我们介绍了基础框架,本节中我们来看看如何具体封装一个寻路函数。

在C++中添加Lua函数

我们转到Lua单元,在CPP文件中添加寻路函数的代码。我们需要按照Lua C API的标准来编写这个函数。

以下是函数的基本框架,我们将其命名为 FindWay

static int FindWay(lua_State* L) {
    // 函数实现
}

函数执行成功时,我们需要返回一个值。

处理Lua传入的参数

在函数内部,我们首先需要获取从Lua脚本传进来的参数。

我们定义一个变量来获取参数的个数,使用 lua_gettop 函数:

int numArgs = lua_gettop(L);

接下来对参数个数进行判断。因为寻路至少需要两个参数(X坐标和Y坐标),多余的参数我们将忽略。

如果参数小于两个,我们直接返回一个数字0或一个nil值。

if (numArgs < 2) {
    lua_pushnumber(L, 0); // 或 lua_pushnil(L);
    return 1;
}

如果参数个数正确,我们就将它们转换为X和Y坐标。参数在栈中的位置是第一个和第二个。

int targetX = (int)lua_tonumber(L, 1);
int targetY = (int)lua_tonumber(L, 2);

调用核心寻路功能

有了这两个坐标,理论上我们就可以直接调用主线程单元中的 MSG_FindXY 函数。

但为了保持统一的代码规范,我们将所有在Lua脚本中调用的函数都封装在一个专门的功能封装函数里。我们把这个函数命名为 Func_Call

这个 Func_Call 函数目前尚未封装,我们将在本单元内实现它。在CPP的 FindWay 函数中,我们不再直接调用 MSG_FindXY,而是调用这个封装函数。

// 在 FindWay 函数内部
Func_Call(/* 传递必要的参数,如 targetX, targetY */);

封装完成后,我们点击生成,确保代码没有编译错误。

声明与注册函数

接下来,我们需要将刚才编写的 FindWay 函数在头文件中进行前置声明。

这个函数是一个注册函数,我们将其放在靠前的位置。注意,我们使用小写字母开头命名。

// 在头文件中声明
int FindWay(lua_State* L);

然后,我们把这个函数注册到Lua中。在注册函数里,我们需要指定它在Lua脚本中被调用时的名字。

例如,我们可以注册一个中文名字“移动到”,也可以同时或单独注册一个英文名字 MoveTo

// 注册函数列表中添加
{“移动到”, FindWay},
{“MoveTo”, FindWay},

再次编辑并生成项目。

测试封装好的函数

我们切换到游戏的客户端目录下,修改Lua脚本来测试新函数。

首先,我们输出一条调试信息。然后调用 FindWay 函数,尝试移动到坐标 (333, 333)。

print(“开始寻路测试”)
移动到(333, 333) -- 或使用 MoveTo(333, 333)

保存脚本。

注入成功之后,挂接我们的进程并初始化。


观察调试信息和控制台输出,同时查看游戏角色是否移动。

测试发现,角色移动到了指定位置附近,但并非精确的点 (333, 333)。这可能是因为该坐标点无法到达。

我们记录下角色当前的实际坐标,例如 (361, 230),并将其设为目标地址再次测试。

修改脚本中的目标坐标并测试。这次角色成功移动到了精确的指定地址 (321, 220)。

测试函数名大小写敏感性

我们改变一下调用时函数名的大小写,测试其是否敏感。

再次移动并观察。

测试发现,如果大小写不正确,函数将无法执行。因此,如果注册的是英文函数名,在Lua脚本中调用时必须注意大小写完全一致。

当大小写正确时,函数能够正常执行。

我们再测试一下中文函数名。将脚本中英文调用注释掉,使用中文“移动到”进行测试。

测试结果显示,中文函数名也能正确执行,使角色到达目的地 (321, 220)。

课程总结与作业

本节课中,我们一起学习了如何将C++的寻路功能封装给Lua调用。主要步骤包括:在C++中添加Lua函数、处理参数、通过封装函数调用核心逻辑、以及最终在Lua中注册和测试函数。

本节暂时讨论到这里。下一节我们将继续探讨其他内容。

最后,布置一个课后作业:

作业:编写一个Lua可调用的函数,用于获取游戏角色当前的X和Y坐标。

函数的大致框架已经提供,大家需要在自己代码的指定位置添加实现逻辑,并完成函数的注册。

主要思考点是如何在C++端获取坐标,并将这两个值回传给Lua脚本端,最终在脚本中打印出来。

我们下一节可能会探讨这个问题的解决方案。再见。

课程 P173:184-LUA获取人物角色坐标GetXy 📍

在本节课中,我们将学习如何编写一个LUA脚本接口,用于安全地获取游戏中人物角色的坐标。我们将从一种简单的实现方法开始,然后探讨其潜在问题,并最终实现一个更安全、更规范的线程安全版本。


概述

我们将基于上一节课(183课)的代码进行扩展。目标是创建一个名为 getxy 的LUA函数,它能返回人物角色的X和Y坐标。我们将首先尝试一种利用全局变量的简单方法,然后改进为通过主线程消息传递的安全方法。


第一步:在头文件中声明函数

首先,我们需要在项目的头文件中声明我们的新函数。按照统一的命名习惯,我们为其添加前缀。

代码示例:

// 在相应的头文件中添加声明
int getxy(lua_State* L);

声明完成后,我们移动到对应的CPP实现单元准备添加代码。


第二步:实现简单的坐标获取(非线程安全)

上一节我们介绍了函数的声明,本节中我们来看看如何实现一个基础版本。我们可以直接读取游戏中存储角色坐标的全局变量。

这是一种简单的写法,但存在风险。如果我们在非主线程(例如脚本线程)中读取坐标,而主线程正在更新这个坐标,就可能读到不完整或错误的数据。不过,在非挂机且操作不频繁的情况下,出错的几率较小。

代码示例:

int getxy(lua_State* L) {
    // 假设 g_Character 是包含坐标的全局结构体
    lua_pushnumber(L, g_Character.x); // 将X坐标压入LUA栈
    lua_pushnumber(L, g_Character.y); // 将Y坐标压入LUA栈
    return 2; // 返回两个值
}

写好函数后,我们需要在LUA引擎中注册它,以便脚本可以调用。


第三步:注册LUA函数

为了在LUA脚本中使用,我们必须注册这个函数。为了避免因大小写导致的难以排查的语法错误,建议统一使用小写函数名。

以下是注册函数的步骤:

  1. 在LUA状态机初始化部分,添加注册代码。
  2. 可以同时注册一个中文函数名作为别名,方便使用。

代码示例:

lua_register(L, "getxy", getxy); // 注册英文函数名
lua_register(L, "获取坐标", getxy); // 注册中文函数名(可选)

注册完成后,编译并生成解决方案。


第四步:在LUA脚本中测试

函数注册成功后,我们就可以在LUA脚本中调用它了。由于我们的函数返回两个值,在LUA中需要用两个变量来接收。

以下是调用方法:

-- 调用函数,用两个变量接收返回值
local x, y = getxy()
-- 或者使用中文别名
local 横坐标, 纵坐标 = 获取坐标()

-- 打印坐标进行调试
print("X坐标: " .. x)
print("Y坐标: " .. y)

将脚本注入游戏并运行,观察控制台输出的坐标是否正确。通过移动角色,可以验证坐标是否实时更新。


第五步:实现线程安全的坐标获取

上一节我们实现的简单方法存在数据竞争的风险。本节中我们来看看如何实现一个更安全的版本,确保坐标读取操作在主线程中执行。

核心思路是:当LUA脚本调用 getxy 时,不直接读取全局变量,而是向游戏的主线程发送一个自定义消息。主线程处理该消息时,安全地读取当前坐标,并通过消息参数将结果传回。

实现步骤如下:

  1. 定义消息和数据结构: 在CPP单元中定义一个消息标识符和一个用于传递坐标的结构体(例如包含两个浮点数的数组)。
  2. 修改getxy函数: 在新的 getxy 函数中,创建结构体,向主线程发送消息并等待结果。
  3. 在主线程消息循环中处理: 在主线程的消息处理代码中,添加对我们自定义消息的处理。收到消息后,将当前角色的X和Y坐标写入传来的结构体中。
  4. 封装功能函数: 在功能封装单元,创建两个独立的函数(如 fGetXfGetY)来安全地获取坐标,它们内部调用上述线程安全机制。

安全版本的核心代码示意:

// 1. 定义消息和结构
struct Point2D { float x; float y; };
#define WM_GET_CHAR_POS (WM_USER + 100)

// 2. 修改后的getxy函数(发送消息)
int getxy_safe(lua_State* L) {
    Point2D pos;
    // 发送消息到主窗口,主线程处理后会填充pos
    SendMessage(hGameWnd, WM_GET_CHAR_POS, (WPARAM)&pos, 0);
    lua_pushnumber(L, pos.x);
    lua_pushnumber(L, pos.y);
    return 2;
}

// 3. 主线程消息处理(部分代码)
case WM_GET_CHAR_POS: {
    Point2D* pPos = (Point2D*)wParam;
    if(pPos) {
        pPos->x = g_Character.x; // 安全读取
        pPos->y = g_Character.y;
    }
    break;
}

使用此方法后,无论从哪个线程调用,坐标读取都在主线程同步完成,彻底避免了数据冲突。


第六步:最终测试与验证

实现线程安全版本后,重新编译项目并更新脚本。再次运行脚本,进行如下测试:

  1. 角色静止时,获取坐标并与游戏内显示坐标对比。
  2. 角色移动时,连续获取坐标,确认其变化流畅且正确。
  3. 可以尝试在复杂的多线程脚本环境中调用,验证其稳定性。

通过对比,确认新的 getxy 函数工作正常,且比最初版本更可靠。


总结

本节课中我们一起学习了如何为LUA脚本创建获取角色坐标的接口。

  1. 我们首先实现了一个简单的直接读取全局变量的方法,但认识到它在多线程下可能不安全。
  2. 接着,我们改进了方法,通过向主线程发送消息来安全地获取坐标,这是更推荐的做法。
  3. 我们完成了从函数声明、实现、注册到脚本调用的完整流程。
  4. 最后,我们进行了测试,验证了两种方法的有效性,并理解了线程安全编程的重要性。

掌握这种方法后,你可以安全地为游戏扩展各种需要读取核心数据的脚本功能。

课程 P174:LUA使用物品函数接口教程 🧪

在本节课中,我们将学习如何使用Lua脚本编写一个调用游戏内“使用物品”功能的函数接口。我们将从现有代码开始,逐步添加功能,处理参数,并最终实现一个可以按名称使用背包中物品的脚本函数。


打开并查看现有代码

首先,我们打开上一节课(第184课)的代码作为基础。

上一节我们介绍了代码结构,本节中我们来看看如何在此基础上添加新功能。

在Lua脚本单元注册函数

我们需要在Lua脚本单元中编写一个新的函数。这个函数将作为我们使用物品的接口。

以下是创建函数的基本步骤:

  1. 在Lua脚本单元中,声明一个名为 useItem 的函数。
  2. 这个函数需要接收参数,参数将是我们要使用的物品名称。

在C++单元添加对应源代码

Lua函数需要调用底层的C++功能。因此,我们需要在对应的C++单元中添加源代码。

上一节我们创建了Lua函数框架,本节中我们来看看如何将其与C++功能连接。

  1. 在C++单元中,添加一个与Lua函数对应的功能函数。
  2. 在这个C++函数内部,调用游戏功能封装模块中已有的“使用物品”函数。
  3. 为该函数设置一个返回值,用于向Lua脚本反馈操作结果(例如,成功或失败)。

在功能封装模块实现核心逻辑

真正的“使用物品”操作逻辑位于功能封装模块中。

以下是实现步骤:

  1. 在功能封装模块中,创建一个新的函数。
  2. 在这个函数内部,调用游戏内存结构(ms)中与物品操作相关的分类函数。
  3. 直接返回该内存操作函数的返回值。

完成以上步骤后,我们先编译生成一次程序,确保没有语法错误。

在Lua函数中处理参数

回到Lua脚本单元,我们需要完善 useItem 函数,使其能够接收并处理传入的物品名称参数。

上一节我们连接了底层功能,本节中我们来看看如何在Lua层处理用户输入。

在编写脚本时,我们期望以 useItem(“物品名字”) 的格式调用函数。因此,函数需要能够提取传入的字符串参数。

以下是参数处理逻辑:

  1. 使用 lua_gettop 函数确定传入参数的个数。
  2. 如果参数个数小于1,说明没有传入物品名,函数应直接返回一个表示失败的数值(例如 0-1)。
  3. 如果参数个数大于等于1,我们只取第一个参数作为要使用的物品名。后续参数可被忽略,或设计为支持一次性使用多个物品。
  4. 可以使用循环依次取出多个参数,实现批量使用物品的功能。
  5. 在取出每个参数后,应检查其类型是否为字符串(LUA_TSTRING)。如果不是,则跳过该参数,继续处理下一个。
  6. 对于每个有效的字符串参数,调用前面编写好的C++功能函数。

处理完参数逻辑后,再次编译生成程序。

注册Lua函数并命名

函数编写完成后,需要将其注册到Lua环境中,脚本才能调用。

以下是注册步骤:

  1. 将注册代码移动到Lua初始化部分。
  2. 为函数注册一个英文名称(如 useitem),注意使用小写字母和英文符号。
  3. 同时,也可以注册一个中文名称(如 使用物品),以支持双语调用。

注册完成后,再次编译生成程序。

优化游戏句柄获取逻辑

在主线程单元中,优化获取游戏窗口句柄的代码,增加健壮性检查。

有时获取的游戏机制(句柄)可能已过期,虽然能读到数据,但可能不是正确的主窗口句柄。

以下是优化方法:

  1. 使用Windows API函数 IsWindow 来判断获取到的句柄是否有效。
  2. 如果 IsWindow 返回假(false),则说明当前句柄已失效。
  3. 此时,可以打印一段调试信息,提示需要更新游戏机制。

添加此检查后,重新编译程序。

测试与调试

编译成功后,运行程序并进行测试。

上一节我们完成了所有代码编写,本节中我们来看看如何进行实际测试并解决遇到的问题。

首次测试与机制更新

  1. 运行游戏和我们的程序。
  2. 挂接主线程,进行初始化。
  3. 在游戏目录下创建一个测试Lua脚本(例如 test.lua),内容为调用 useItem(“金疮药小”)
  4. 执行测试脚本(dofile)。
  5. 测试时,可能会遇到错误提示“机子需要更新”。这表明游戏版本更新后,我们使用的内存地址(机制)已失效。
  6. 使用机制更新工具,找到最新的正确内存地址,并替换代码中的旧地址。
  7. 替换后,重新编译程序。

测试批量使用功能

我们的函数设计支持传入多个参数,批量使用物品。

  1. 修改测试脚本,尝试同时使用多个物品,例如:useItem(“人参”, “金疮药小”, “金疮药中”)
  2. 执行脚本,观察游戏内物品是否被正确使用。
  3. 可能会发现只有部分物品被使用。这可能是因为:
    • 参数下标错误:Lua中表的下标通常从1开始,如果循环从0开始,会少执行一次。确保循环变量初始值为 1
    • 游戏使用间隔限制:游戏可能对同一类物品的连续使用设有时间间隔(冷却时间)。短时间内频繁使用会被服务器忽略。

处理游戏使用间隔

如果确认是游戏服务器的使用间隔限制,需要在脚本中增加延迟。

以下是解决方法:

  1. 在Lua脚本中,在连续使用两个物品之间调用 Sleep 函数添加延迟。
  2. 通过测试,确定大致的间隔时间(例如,可能是200-300毫秒)。
  3. 将延迟时间加入到批量使用的逻辑中。

核心延迟代码示例:

function useItems(...)
    local items = {...}
    for i, name in ipairs(items) do
        useItem(name) -- 调用我们编写的C++接口函数
        Sleep(300)    -- 延迟300毫秒,避免服务器限制
    end
end

深入分析间隔限制(可选)

如果想进一步了解限制是本地检测还是服务器检测,可以进行分析:

  1. 思路:直接调用底层“发送数据包”的函数来使用物品,绕过常规函数调用。
  2. 判断:如果通过发包方式没有时间限制,则说明限制在服务器端;如果仍有限制,则可能本地也有检测逻辑。
  3. 此分析有助于更底层地优化脚本行为。


总结

本节课中我们一起学习了如何为游戏辅助工具创建一个Lua脚本接口,用于使用背包中的物品。

我们完成的主要工作包括:

  1. 在Lua层:编写了 useItem 函数框架,并实现了多参数处理和错误检查。
  2. 在C++层:添加了对应的功能函数,桥接了Lua脚本与游戏核心功能。
  3. 在功能模块:实现了调用游戏内存函数以操作物品的核心逻辑。
  4. 在工程层面:注册了函数,并优化了句柄获取的健壮性。
  5. 在测试中:发现了游戏服务器的物品使用间隔限制,并通过在脚本中添加 Sleep 延迟来解决该问题。

通过本教程,你掌握了为Lua脚本扩展游戏功能接口的基本流程,以及如何处理实际测试中遇到的常见问题。

课程 P175:186-LUA封装使用技能接口 🛠️

在本节课中,我们将学习如何使用Lua脚本封装一个调用游戏内技能的接口。我们将从修改现有代码开始,逐步完成接口的封装、注册与测试,最终实现通过Lua脚本控制角色释放指定技能的功能。


一、准备工作 📂

上一节我们完成了基础框架的搭建,本节中我们来看看如何封装具体的功能接口。

首先,打开上一节课(P185)的工程代码。我们需要在三个核心单元中进行修改:Lua脚本单元、功能封装单元以及主线程单元。

以下是需要修改的单元文件:

  • LuaScriptUnit:负责Lua脚本的解析与函数注册。
  • FunctionWrapperUnit:负责封装具体的游戏功能调用。
  • MainThreadUnit:负责在主线程中执行具体的功能操作。

二、封装功能函数 ⚙️

我们首先在功能封装单元中添加技能使用的核心函数。

  1. 在头文件中声明函数
    FunctionWrapperUnit.h 文件中,添加一个用于使用技能的函数的声明。注意,为了通过编译,需要为其添加 const 前缀修饰。

    // 在 FunctionWrapperUnit.h 中添加
    void UseSkill(const char* skillName);
    
  2. 在源文件中实现函数
    转到 FunctionWrapperUnit.cpp 文件,实现这个函数。其内部将调用主线程单元中实际执行技能操作的函数(例如 mg_qeradar)。

    // 在 FunctionWrapperUnit.cpp 中实现
    void UseSkill(const char* skillName) {
        // 这里将技能名参数传递给主线程的执行函数
        // 例如:CallMainThreadToUseSkill(skillName);
    }
    

    同时,需要确保该函数在头文件中的声明与在源文件中的实现保持一致,都包含 const 修饰。

完成以上步骤后,编译一下工程,确保没有语法错误。如果有错误,通常需要检查头文件是否被正确包含,以及函数签名是否完全一致。


三、暴露接口给Lua 📝

在功能单元封装好后,我们需要将其暴露给Lua脚本调用。

上一节我们介绍了如何在C++中封装功能,本节中我们来看看如何将其与Lua桥接。

  1. 在Lua单元的头文件中注册函数
    LuaScriptUnit.h 中,声明我们即将为Lua提供的函数。函数名可以自定义,例如 lua_UseSkill

  2. 在Lua单元的源文件中实现桥接函数
    转到 LuaScriptUnit.cpp,添加以下代码:

    // Lua调用使用技能的接口函数
    int lua_UseSkill(lua_State* L) {
        // 1. 获取参数个数
        int argc = lua_gettop(L);
        if (argc == 0) {
            // 如果没有参数,可以返回0或做默认处理
            lua_pushnumber(L, 0);
            return 1;
        }
    
        // 2. 获取第一个参数(技能名)
        const char* skillName = lua_tostring(L, 1);
    
        // 3. 调用我们封装好的功能函数
        UseSkill(skillName);
    
        // 4. 返回结果(例如1表示成功)
        lua_pushnumber(L, 1);
        return 1;
    }
    

    这个函数的作用是作为Lua与C++功能之间的桥梁:从Lua栈中取得参数,然后调用第二步中封装的 UseSkill 函数。

  3. 将函数注册到Lua状态机
    LuaScriptUnit.cpp 初始化Lua环境的地方(通常是某个 RegisterFunctions 函数内),添加注册代码。可以为同一个功能注册多个名字,方便调用。

    // 注册函数到Lua
    lua_register(L, "使用技能", lua_UseSkill); // 中文名
    lua_register(L, "useSkill", lua_UseSkill);  // 英文名
    

再次编译整个工程,确保所有修改都能通过编译。


四、测试与验证 🧪

接口封装并注册完成后,我们需要编写Lua脚本来测试功能是否正常。

以下是测试步骤:

  1. 在游戏客户端目录下,找到或创建一个Lua脚本文件(例如 test.lua)。

  2. 在脚本中调用我们注册的函数,并传入技能名称参数。

    -- 测试脚本:对当前目标使用技能
    -- 方式一:使用中文函数名
    使用技能("逆天降魔")
    
    -- 方式二:使用英文函数名
    -- useSkill("逆天降魔")
    
  3. 启动游戏,选中一个目标怪物。

  4. 将我们的动态链接库注入到游戏进程,并执行初始化。

  5. 运行Lua脚本(例如通过 dofile("test.lua"))。

  6. 观察游戏角色是否对目标释放了指定的技能“逆天降魔”。可以更换技能名称(如“逆天煞星”)和目标进行多次测试,以验证接口的通用性和稳定性。

测试过程中,如果遇到游戏断开连接,可能是技能调用频率或逻辑问题,需要根据实际情况调整功能函数内部的实现。


五、课程总结 📚

本节课中我们一起学习了如何为一个具体的游戏功能(使用技能)封装Lua接口。

我们回顾一下核心步骤:

  1. 功能层封装:在C++的功能单元中,实现具体的 UseSkill 函数。
  2. 桥接层实现:在Lua单元中,编写 lua_UseSkill 函数,负责参数传递和调用功能函数。
  3. 接口层注册:将桥接函数以自定义名称(如“使用技能”)注册到Lua状态机,使其能被脚本识别。
  4. 脚本层调用:在Lua脚本中直接调用注册的函数名,并传入参数,从而驱动整个功能链。

这种“功能封装 -> Lua桥接 -> 脚本调用”的模式,是扩展脚本功能的核心方法。


六、课后作业 💡

为了巩固本节课的知识,请尝试完成以下作业:

作业要求:封装一个“选择怪物”的Lua脚本接口。

以下是具体功能要求:

  • 函数在没有参数时,自动选择角色周围的任意一只怪物。
  • 函数在带有参数时,参数应为一个怪物名称的列表(如 {"怪物A", "怪物B"})。
  • 带参数时,函数应按照列表中的顺序,优先选择列表中存在的怪物。

请参照本节课的技能接口封装流程,实现这个选怪功能。

课程 P176:封装功能函数 - 获取选中怪物信息(含必杀状态) 🧙‍♂️

在本节课中,我们将学习如何封装一个功能函数,用于获取当前选中怪物的详细信息,包括其是否可以使用必杀技的状态。我们将对现有代码进行规范和整理,使用宏定义来管理偏移地址,并创建一个结构化的函数来安全地读取和返回怪物数据。


代码整理与宏定义

上一节我们介绍了怪物列表的基本结构。本节中,我们首先对代码进行整理,将硬编码的偏移地址替换为宏定义,以提高代码的可维护性。

我们将以下偏移量定义为宏,通常可以放在头文件或对应的 .cpp 文件顶部。

#define OFFSET_MONSTER_ID 0x00
#define OFFSET_MONSTER_HP 0x04
#define OFFSET_MONSTER_MAX_HP 0x08
#define OFFSET_MONSTER_TYPE 0x0C
#define OFFSET_MONSTER_CAN_ULT 0x10 // 新增:必杀技状态偏移
#define MONSTER_LIST_SIZE 100 // 附近对象列表的大小

同时,我们更新怪物信息结构体,增加一个成员变量来存储必杀技可用状态。

struct MonsterInfo {
    DWORD dwID;
    DWORD dwHP;
    DWORD dwMaxHP;
    DWORD dwType;
    BOOL bCanUseUltimate; // 新增:是否可以使用必杀技
};


封装获取选中怪物信息的函数

接下来,我们将在怪物列表管理类中,添加一个成员函数 GetSelectedMonsterInfo。这个函数的目标是安全地获取玩家当前选中怪物的完整信息。

以下是实现该函数的核心步骤:

  1. 获取选中对象ID:首先读取内存中存储的当前选中对象的ID。
  2. 有效性检查:检查ID是否有效(例如,不等于 0xFFFFFFFF 表示未选中任何对象)。
  3. 遍历列表匹配:遍历初始化好的附近怪物列表,寻找ID匹配的怪物对象。
  4. 类型验证:找到对象后,读取其类型字段,确认它确实是怪物类型(例如,类型值为1)。
  5. 信息填充与返回:如果验证通过,则将该对象的内存信息读取并填充到 MonsterInfo 结构体中返回。

我们在函数中加入了异常处理机制,以确保内存读取的安全性。

BOOL CMonsterManager::GetSelectedMonsterInfo(MonsterInfo& outInfo) {
    // 1. 获取选中ID
    DWORD dwSelectedID = ReadMemory<DWORD>(g_dwBaseAddr + OFFSET_SELECTED_OBJ_ID);
    if (dwSelectedID == 0xFFFFFFFF) {
        return FALSE; // 未选中任何对象
    }

    // 2. 初始化输出结构
    memset(&outInfo, 0, sizeof(MonsterInfo));

    // 3. 遍历怪物列表进行匹配
    for (int i = 0; i < m_nMonsterCount; ++i) {
        if (m_MonsterList[i].dwID == dwSelectedID) {
            // 4. 验证对象类型是否为怪物
            DWORD dwType = ReadMemory<DWORD>((DWORD)m_MonsterList[i].pObject + OFFSET_MONSTER_TYPE);
            if (dwType != 1) { // 假设1代表怪物类型
                return FALSE;
            }

            // 5. 安全读取并填充信息
            __try {
                outInfo.dwID = m_MonsterList[i].dwID;
                outInfo.dwHP = ReadMemory<DWORD>((DWORD)m_MonsterList[i].pObject + OFFSET_MONSTER_HP);
                outInfo.dwMaxHP = ReadMemory<DWORD>((DWORD)m_MonsterList[i].pObject + OFFSET_MONSTER_MAX_HP);
                outInfo.dwType = dwType;
                outInfo.bCanUseUltimate = ReadMemory<BOOL>((DWORD)m_MonsterList[i].pObject + OFFSET_MONSTER_CAN_ULT);
                return TRUE; // 成功获取
            }
            __except(EXCEPTION_EXECUTE_HANDLER) {
                return FALSE; // 内存读取异常
            }
        }
    }
    return FALSE; // 列表中未找到该ID
}

主线程通信与接口封装

为了让脚本或其他模块能方便地调用这个功能,我们需要在主线程单元(通常是窗口过程)中封装一个接口。

首先,定义一个自定义消息和对应的消息处理函数。

#define WM_GET_SELECTED_MONSTER (WM_USER + 101)

然后,在窗口过程 (WndProc) 中处理此消息,调用我们刚写好的 GetSelectedMonsterInfo 函数。

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) {
    switch (message) {
        case WM_GET_SELECTED_MONSTER: {
            MonsterInfo* pInfo = (MonsterInfo*)lParam;
            if (pInfo != nullptr) {
                // 调用功能单元的函数
                BOOL bSuccess = g_pMonsterMgr->GetSelectedMonsterInfo(*pInfo);
                // 可以通过返回值或设置结构体特定字段(如dwID是否为0)来传递成功状态
                return bSuccess ? 1 : 0;
            }
            break;
        }
        // ... 其他消息处理
    }
    return DefWindowProc(hWnd, message, wParam, lParam);
}

最后,在功能单元的公共接口头文件中,提供一个简单的内联函数或静态方法,供外部直接调用。

// 在公共头文件中声明
inline BOOL GetSelectedMonster(MonsterInfo& info) {
    return SendMessage(g_hMainWnd, WM_GET_SELECTED_MONSTER, 0, (LPARAM)&info);
}

功能测试

函数封装完成后,我们编写简单的测试代码来验证其功能。

void TestSelectedMonster() {
    MonsterInfo info = {0};
    if (GetSelectedMonster(info)) {
        printf("[成功] 选中怪物信息:\n");
        printf("  ID: %d\n", info.dwID);
        printf("  HP: %d/%d\n", info.dwHP, info.dwMaxHP);
        printf("  可释放必杀: %s\n", info.bCanUseUltimate ? "是" : "否");
    } else {
        printf("[提示] 未选中有效怪物。\n");
    }
}

运行测试,当选中一个怪物时,控制台会正确打印其信息;未选中或选中非怪物对象时,会给出相应提示。


总结与作业

本节课中,我们一起学习了如何封装一个健壮的功能函数来获取选中怪物的信息。我们通过使用宏定义提高了代码可维护性,通过结构体组织数据,并通过异常处理和类型验证保证了函数的稳定性。最后,我们建立了主线程通信机制,并提供了简洁的调用接口。

课后作业:基于本节课获取的怪物信息(特别是 bCanUseUltimate 字段),尝试编写一个函数,在满足条件时(例如怪物可被必杀、玩家能量足够等),自动向游戏发送指令,释放角色的必杀技。


下一节预告:我们将利用本节课的成果,实现自动判断并施放必杀技的功能。

课程 P177:190-LUA接口封装-延时函数-等待函数 ⏱️

在本节课中,我们将学习如何为Lua脚本封装一个延时等待函数。这个功能类似于编程中常见的 Sleep 函数,在游戏脚本中用于控制技能释放间隔或其他操作的等待时间。我们将从现有代码开始,添加一个Lua接口,并进行测试与优化。

封装延时函数接口

上一节我们介绍了延时函数在脚本中的用途。本节中,我们来看看如何具体实现这个Lua接口。

首先,我们需要打开第189课的代码,并展开脚本单元部分。在这里,我们将添加一个等待函数。

以下是添加Lua接口的步骤:

  1. 判断参数个数:首先对传入Lua函数的参数个数进行判断。如果没有参数传入,则默认等待100毫秒。这可以通过直接调用系统的 Sleep 函数实现。
  2. 处理带参数的情况:如果函数调用时传入了一个参数,则取出该参数的数字值,并以此值作为等待的毫秒数调用 Sleep 函数。
  3. 注册接口:接口编写完成后,需要将其注册到Lua环境中。注册时,函数名可以设置为小写的 sleep等待延迟

核心的接口逻辑可以用以下伪代码描述:

-- Lua 侧调用示例
sleep(500) -- 等待500毫秒
sleep()    -- 默认等待100毫秒

编写测试脚本

接口封装完成后,我们需要编写一个测试脚本来验证其功能。

我们创建一个名为 Test.lua 的脚本文件。这个脚本模拟一个自动攻击的循环,在每次“攻击”后等待一段时间。同时,我们定义一个全局变量用于计数。

以下是测试脚本的内容示例:

count = 0
while true do
    print(“开始攻击循环,计数:” .. count)
    -- 执行攻击操作...
    sleep(1000) -- 等待1000毫秒,即1秒
    count = count + 1
end

测试与问题发现

现在,我们挂接主线程并运行脚本来进行测试。初始测试时,可能会发现脚本没有成功执行。

通过打印调试信息,我们发现问题在于新注册的延时函数接口没有被正确识别。这通常是因为动态链接库更新后,需要重新编译生成相关的头文件,并确保脚本引擎加载了最新的模块。

解决函数注册问题后,再次测试。脚本成功执行到了循环部分,并能够每隔一秒打印一次调试信息,这证明我们的延时函数基本功能是完善的。

解决阻塞问题与优化

然而,在测试中我们发现了一个新问题:当 Sleep 函数在主线程中被调用时,会导致整个程序窗口停止响应。

这是因为 Sleep 会阻塞当前线程。为了解决这个问题,我们需要将执行Lua脚本的逻辑放到一个独立的线程中运行,这样主线程就不会被阻塞。

因此,我们需要修改执行脚本的代码,将 DoFile(执行脚本文件)的操作放入一个新创建的线程中。这样,即使脚本中的 sleep 函数造成了阻塞,也只会影响这个独立的脚本线程,而不会冻结程序主界面。

关于线程的创建和控制,我们将在后续课程中深入讨论。本节课我们主要完成将脚本执行移至独立线程的初步修改。

最终测试

最后,我们再次修改测试脚本,将等待时间改为2秒,并在独立线程的环境下进行最终测试。

打开游戏并加载脚本后,可以观察到脚本按预期每隔2秒输出一次信息,并且游戏主窗口操作流畅,不再出现卡顿或无响应的现象。这表明我们的延时函数接口以及多线程执行方案是成功的。

课程总结

本节课中,我们一起学习了如何为Lua脚本环境封装一个延时等待函数。

我们首先在C++侧实现了这个接口,处理了默认参数和指定参数两种情况。然后,我们编写了Lua测试脚本,并发现了直接在主线程调用 Sleep 会导致界面阻塞的问题。最后,我们通过将脚本执行逻辑移至独立线程,解决了阻塞问题,确保了程序的响应性。

通过本课,你掌握了为脚本引擎添加基础功能接口、进行功能测试以及利用多线程优化脚本执行体验的基本方法。

课程 P178:LUA接口封装 - 自动释放必杀技 🔧

在本节课中,我们将学习如何封装一个Lua接口,用于在游戏中自动判断并释放怪物的必杀技。我们将基于上一节课的代码,将核心逻辑封装成简洁的Lua函数,并进行测试。


准备工作 🛠️

上一节我们编写了一个自动释放必杀技的函数。本节中,我们主要进行接口封装和测试。

首先,打开第190课的代码,并展开脚本单元。我们需要在同文件中添加脚本接口,并在对应的C++文件中实现相应代码。

在此之前,我们还需要准备一个核心的功能函数。

封装核心功能函数 ⚙️

这个函数的核心逻辑是:先获取当前选中怪物的必杀技状态,如果状态可用,则使用必杀技并返回相应的数值。

以下是该功能函数的逻辑描述:

  1. 获取当前选中怪物的信息。
  2. 判断该怪物是否可以使用必杀技。
  3. 如果可以使用,则调用相应的技能,传入必杀技的名字。
  4. 如果使用成功,则返回 true

用伪代码表示如下:

function UseSkillIfAvailable(monster, skillName)
    if monster:canUseSkill(skillName) then
        monster:useSkill(skillName)
        return true
    else
        return false
    end
end

封装好这个函数后,我们接着来看Lua接口函数的封装。

封装Lua接口函数 📦

在Lua脚本中,我们希望以 UseSkill(“技能名”) 的形式调用这个功能。

以下是接口函数的封装步骤:

  1. 函数被调用时,首先获取传入参数的个数。
  2. 如果没有传递参数进来,则直接返回 false
  3. 如果参数正确,则取得必杀技的名字。
  4. 调用上面封装好的“使用必杀技”功能函数。
  5. 如果使用不成功(例如没有选中怪物,或怪物不满足使用条件),则返回 false
  6. 另一种情况就是使用成功,返回 true

函数写好后,需要在前面进行注册,然后编译生成。这样,理论上我们就能够通过Lua脚本自动进行判断和释放了。

关于设计思路的讨论:大部分功能逻辑在C++中实现。另一种方式是将条件判断的数据暴露给Lua接口,在脚本里判断后再调用技能库。但对于给一般用户使用的脚本,建议设计得简单一些,将逻辑部分放在C++中实现可能更好。当然,我们目前主要是为了探讨和学习Lua的功能。

脚本测试与调试 🧪

接口注册好后,我们接下来进行测试。我们在上一节课的脚本基础上进行修改,增加使用必杀技的功能。

修改思路如下:选中怪物后,首先判断是否需要使用必杀技。我们还可以定义一个变量来记录是否成功使用了必杀技。

以下是测试脚本的核心循环示例:

while true do
    -- 选中怪物...
    local success = UseSkill("烈焰风暴") -- 调用封装好的接口
    if success then
        print("成功释放必杀技!")
    end
    Sleep(1000) -- 等待1秒
end

注意:这个循环有一个弱点,即目前无法控制它何时结束。这个问题留作思考,大家可以在课后想一想解决方案。

我们进行了实际测试。测试过程中,当周围有怪物且其必杀技状态就绪时,脚本成功地自动使用了必杀技。通过调试信息可以看到,每次都会进入 UseSkill 核心函数,并在条件满足时释放技能。

同时,我们也发现脚本的循环退出机制设计得不够好。这引出了下一个问题:如何从C++端控制Lua脚本中循环的退出?或者说,如何给循环一个条件,让它知道需要退出线程?

这将是下一节课我们将要讨论的内容。


本节总结 📝

本节课中,我们一起学习了如何为自动释放必杀技的功能封装一个Lua接口。主要内容包括:

  1. 准备并封装了一个核心的C++功能函数,用于判断并释放技能。
  2. 将该功能封装成可供Lua脚本调用的接口函数 UseSkill
  3. 讨论了脚本逻辑放在C++端与Lua端的优缺点。
  4. 编写并测试了调用该接口的Lua脚本,实现了自动释放必杀技。
  5. 发现了当前脚本循环无法优雅退出的问题,为下一课的内容做了铺垫。

通过本课,你将掌握如何将游戏功能封装成简洁的Lua接口,使脚本编写更简单、直观。

课程 P179:封装LUA使用物品的接口 🧪

在本节课中,我们将学习如何为Lua脚本封装一个调用游戏内“使用物品”功能的接口。我们将基于已有的功能函数,编写一个Lua可调用的接口,并进行测试。


上一节我们介绍了功能封装的基础,本节中我们来看看如何将“使用物品”这个功能暴露给Lua脚本使用。

首先,我们需要打开第192课的代码文件。

然后,展开我们的Lua脚本单元。我们将在这里添加相应的接口。由于“使用物品”的功能函数已在功能封装单元中实现,我们只需在此添加一个桥接接口即可。

以下是添加接口的具体步骤:

  1. 判断参数:首先判断传入Lua接口的参数数量。如果参数数量小于1,即没有传递任何参数,则直接退出。
  2. 获取并转换参数:如果有参数传入,我们获取第一个参数。可以对其进行类型判断,但为简化,我们直接将其转换为字符串(AnsiString)。
  3. 调用功能函数:使用转换后的字符串参数,调用我们之前封装好的“使用物品”功能函数。
  4. 返回结果:如果调用成功,则返回一个真值(True)。对于无参数的情况,可以返回一个假值(False)。
  5. 注册接口:最后,我们需要在 RegisterFunction 区域添加对这个新接口的注册,这样Lua脚本才能识别和调用它。

接口代码的核心逻辑如下:

function Lua_UseItem(L: Plua_State): Integer; cdecl;
var
  ArgCount: Integer;
  ItemName: AnsiString;
begin
  Result := 0;
  ArgCount := lua_gettop(L);
  if ArgCount < 1 then Exit;

  ItemName := lua_tostring(L, 1);
  if UseGameItem(ItemName) then
    lua_pushboolean(L, True)
  else
    lua_pushboolean(L, False);
  Result := 1;
end;

完成接口编写后,我们需要将其注册到Lua引擎中。


上一节我们编写并注册了接口,本节中我们来进行测试,验证接口是否正常工作。

首先,转到游戏主程序所在的单元。

找到游戏脚本文件(例如 mod.txt)的路径,并修改其内容。我们在脚本中调用新封装的接口。

以下是测试脚本示例:

-- 使用物品“金疮药”
UseItem("金疮药")

然后,我们跨线程初始化并执行这个Lua文件。此时可以观察到,游戏角色成功使用了“金疮药”这个物品。

我们可以将脚本中的物品名改为“野山参”再次测试:

-- 使用物品“野山参”
UseItem("野山参")

每执行一次脚本,角色就会使用一次对应的物品。这个过程非常简单,因为核心功能已经封装好,接口只需负责参数转换和函数调用。


然而,在实际编写自动化脚本时,我们通常不会无条件地使用物品。我们需要为物品使用添加条件判断,例如:

  • 当生命值(HP)低于某个数值或百分比时,才使用“金疮药”。
  • 当法力值(MP)低于某个数值或百分比时,才使用“野山参”。

这就要求我们的Lua脚本能够获取到角色当前的HP、MP等状态数据。

目前,我们有两种思路来实现这种条件判断:

  1. 将角色状态数据暴露给Lua:这是更灵活、更专业的方法。我们需要额外封装接口,让Lua脚本能直接读取HP、MP等数值,从而在脚本内部进行复杂的逻辑判断。
  2. 将条件判断集成在功能函数内部:这种方法灵活性较低,例如在 UseGameItem 函数内部硬编码判断逻辑,但实现起来相对简单。

为了让脚本具备更高的灵活性,我们倾向于采用第一种方法。在下一节课中,我们将一起探讨如何将人物角色的各项状态数据封装并暴露给Lua脚本使用。


本节课中我们一起学习了如何为“使用物品”功能封装Lua接口。我们完成了从判断参数、调用功能函数到注册接口的完整流程,并成功进行了测试。同时,我们也指出了当前接口的局限性,即缺乏对游戏角色状态的感知能力,为下一节课的内容做好了铺垫。

课程 P18:029 - 完善自动打怪功能 🛠️

在本节课中,我们将学习如何完善自动打怪功能。上节课我们仅通过判断选中的ID是否等于特定值来识别怪物,这种方法不够准确。本节课我们将增加对选中对象类型的判断,以确保只攻击怪物,而非玩家或自己。


问题分析与解决思路

上一节我们介绍了通过选中ID来判断怪物的方法。本节中我们来看看如何结合对象类型进行更准确的判断。

仅判断选中ID(max f f)存在缺陷。如果选中玩家或自己,ID判断会失效。因此,需要额外判断当前选中对象的类型是否为怪物。结合两者,才能完善选怪功能。


代码实现步骤

以下是完善自动打怪功能的具体步骤。

首先,我们需要在角色对象结构中添加一个函数,用于获取当前选中对象的类型。该函数通过对象地址和特定偏移(max 8)来读取类型值。若为怪物,则返回对应标识。

1. 定义获取选中对象类型的函数

我们定义一个变量来存储返回值,并使用异常处理确保代码健壮性。

// 伪代码示例:获取选中对象类型
int GetSelectedTargetType() {
    try {
        // 步骤1: 获取当前选中的ID
        int selectedId = GetCurrentSelectedId();

        // 步骤2: 通过ID获取对象地址
        // 公式: 对象地址 = 基地址 + 4 * selectedId
        DWORD objectAddress = baseAddress + 4 * selectedId;

        // 步骤3: 从对象地址读取类型值(偏移为 max8)
        int objectType = ReadMemory(objectAddress + offset_max8);

        return objectType;
    } catch (...) {
        // 发生异常时返回一个无效值,例如0
        return 0;
    }
}

2. 将常用偏移定义为常量

为防止游戏更新导致偏移变化,我们将常用偏移(如 +8)定义为常量。

// 建议将偏移常量集中管理,例如放在初始化函数或专用头文件中
const DWORD OFFSET_MAX8 = 0x8; // 示例偏移值

3. 修改自动打怪逻辑

接下来,我们修改自动打怪的核心判断逻辑。

// 在自动打怪循环中
int targetType = GetSelectedTargetType();

if (targetType == TYPE_MONSTER) {
    // 当前选中已经是怪物,直接攻击
    ContinueAttack();
} else {
    // 当前选中的不是怪物,执行选怪操作
    SelectNewMonster();
    // 然后攻击新选中的怪物
    AttackTarget();
}

我们需要将“未选中对象”的判断也整合到 GetSelectedTargetType 函数中。如果选中ID无效,则直接返回空或无效类型。


功能测试与调试

完成代码修改后,我们进行编译和测试。

  1. 将代码注入游戏主线程。
  2. 在游戏中选中自己(非怪物)。
  3. 启动自动打怪功能进行观察。

预期结果:即使选中自己,程序也应能正确识别并重新选择怪物进行攻击,而不是攻击自己。

如果测试失败(例如仍攻击自己),请检查:

  • 代码是否编译成功并正确更新。
  • 类型判断条件(TYPE_MONSTER)的值是否正确。
  • 对象地址和偏移计算是否准确。

测试成功后,即可将完善后的逻辑放入定时器或独立线程中,实现稳定的自动打怪。


课程总结

本节课中我们一起学习了如何完善自动打怪功能。核心改进在于引入了对象类型判断,结合选中ID,确保了攻击目标的准确性。我们通过添加类型获取函数、定义偏移常量并重构攻击逻辑,使程序能够智能区分怪物与其他角色。

关键点总结:

  1. 问题:仅凭ID无法准确区分怪物与玩家。
  2. 解决方案:增加对选中对象类型的判断。
  3. 实现:编写 GetSelectedTargetType 函数,并修改攻击决策逻辑。
  4. 优化:将关键偏移定义为常量便于维护。

经过本次完善,自动打怪功能变得更加可靠。下一节课我们将继续探索其他功能的实现。

课程 P180:LUA获取人物信息 🧑💻

在本节课中,我们将学习如何在Lua脚本中获取C++或游戏内部的人物属性信息。我们将通过封装函数、注册到Lua环境,并最终编写一个自动检测血量并使用药品的脚本来实践这一过程。


上一节我们介绍了Lua与C++交互的基础。本节中,我们来看看如何具体获取游戏内的人物数据。

首先,我们需要打开第100课的代码。在全局变量单元中,存在一个包含人物属性的结构体变量。

为了在Lua脚本中使用这些数据,我们需要在脚本单元封装一个函数。这个函数将负责获取并返回人物信息。

以下是在C++单元中添加的测试代码,用于向Lua返回数据:

// 示例:返回人物信息的函数
int GetCharacterInfo(lua_State* L) {
    // 这里应填充实际获取人物属性的代码
    // 暂时返回6个测试数值
    lua_pushnumber(L, 100); // 当前HP
    lua_pushnumber(L, 1000); // 最大HP
    lua_pushnumber(L, 50); // 当前MP
    lua_pushnumber(L, 200); // 最大MP
    lua_pushnumber(L, 5000); // 金钱
    lua_pushstring(L, "PlayerName"); // 人物名称
    return 6; // 返回值的数量
}

添加代码后,需要将此函数注册到Lua环境中,函数名定为“获取人物信息”。

完成注册后,我们可以在Lua脚本中调用此函数。因为函数返回六个参数,所以在Lua中接收时也需要对应六个变量。

以下是简单的Lua脚本示例,用于接收并打印信息:

-- 调用C++端注册的函数,接收六个返回值
local currentHP, maxHP, currentMP, maxMP, money, name = 获取人物信息()
-- 打印接收到的信息
print("当前HP:", currentHP)
print("最大HP:", maxHP)
print("当前MP:", currentMP)
print("最大MP:", maxMP)
print("金钱:", money)
print("名字:", name)

将脚本保存到游戏目录并运行。如果一切正确,控制台将输出人物的各项属性值。

在最初的测试中,可能因为函数名注册错误导致没有输出。检查并确保C++中的注册名与Lua中的调用名完全一致,然后重新编译运行即可。

成功输出后,我们需要将测试返回值替换为真实的游戏数据。修改C++函数,使其从游戏的人物属性结构体中读取真实值。

注意:对于“金钱”这类可能数值较大的数据,在Lua中应使用number类型(对应C++的double)来接收,以确保64位整数的完整性。如果使用int类型,在数值过大时可能导致数据截断。

替换代码后,再次挂起主线程并初始化测试。此时,控制台输出的将是人物的真实属性,例如:当前血量、血量上限、魔法值及金钱数量。

掌握了获取人物信息的方法后,我们就可以解决上节课遗留的问题——实现条件判断逻辑。

以下是如何利用获取到的信息编写一个自动使用药品的脚本:

-- 循环检测人物血量
while true do
    -- 获取当前人物信息
    local currentHP, maxHP = 获取人物信息()
    
    -- 判断条件:如果当前血量低于1000
    if currentHP < 1000 then
        -- 执行使用金疮药的操作
        UseItem("金疮药")
        print("血量过低,已使用金疮药。")
    end
    
    -- 等待3秒后再次检测
    等待(3000)
end

运行此脚本后,当游戏人物受到攻击导致血量低于1000时,脚本会自动使用“金疮药”进行补充。


本节课中,我们一起学习了从C++向Lua暴露接口以获取游戏人物信息的方法。关键步骤包括在C++端封装并注册函数,在Lua端正确调用和接收多返回值,并利用获取的数据实现简单的游戏逻辑(如自动补血)。这为编写更复杂的游戏辅助脚本奠定了坚实基础。下一节课我们将探讨更多高级功能。

P181:195-LUA脚本错误检测与排除 🐛

在本节课中,我们将学习如何在LUA脚本中检测和排除错误。我们将通过分析dofile函数的返回值来定位错误,并打印出具体的错误信息,以帮助初学者快速找到并修复代码中的问题。


上一节我们介绍了LUA脚本的基本执行。本节中,我们来看看如何检测脚本执行过程中的错误。

首先,我们打开第194课的代码。

如果在这段LUA脚本中,我们使用了一个错误的命令,或者出现了其他运行时错误,例如调用了一个未注册的函数技能使用

我们随意使用一个技能,例如蔑视屠龙,然后执行这段代码。正常情况下,代码会执行到指定位置。但执行开始后,如果出现问题,我们很难仅凭肉眼找出错误所在。特别是当脚本越来越庞大时,检测和排除错误会更加困难。

因此,本节课探讨如何检测脚本中具体哪一行出现了错误。实际上,我们执行的dofile函数本身有一个返回值。如果其返回值不为零,就证明执行过程中出现了错误。

我们可以添加相应的检测逻辑,对dofile的返回值进行判断。如果返回值不为零,则说明存在错误。但仅仅检测到错误是不够的,我们还需要定位出错的具体信息。

在LUA中,数据通常通过环境堆栈进行传递。我们可以直接打印出相关的错误信息。出错信息是一个字符串类型,位于堆栈的顶部。

以下是实现错误检测的核心代码逻辑:

-- 执行脚本文件
local result = dofile("script.lua")
-- 检查返回值
if result ~= 0 then
    -- 从堆栈顶部获取错误信息并打印
    print(debug.traceback())
end

我们再次重新编译并生成程序。此时,我们注册LUA脚本,然后执行dofile。程序会输出一段错误信息,例如:test.lua:6: attempt to call a nil value (global '技能使用')

这段信息表明,错误发生在test.lua文件的第6行,原因是尝试调用了一个未定义的全局变量技能使用

我们查看第6行代码,恰好就是调用技能使用的这一行。为了更好地定位行号,建议使用带有行号显示功能的编辑器,例如Notepad++Visual Studio Code。这类编辑器能清晰显示行号,方便我们快速找到出错代码。

错误信息还会显示具体的调试信息,例如“变量技能使用没有注册”。


还有一种情况也会导致出错。如果我们在没有初始化LUA环境(即没有注册必要的函数)时就开始执行脚本,那么从第二行开始就会出错,并终止整个脚本的执行。因为此时连print这类输出调试信息的函数也未被注册。

因此,我们必须先完成初始化,然后再执行代码。这样,当某个函数未注册时,它就会显示出具体的出错行信息。

我们回到代码中,找到出错的那一行并将其改正。例如,将错误的技能使用改为正确的函数名,或者直接注释掉该行。纠正错误后,脚本就能顺利执行完dofile

我们检查其他函数,例如选中怪物。如果传递的参数错误,例如传递了一个不存在的怪物名,那么选中怪物函数可能不会真正执行选中操作,但脚本不会因此报语法错误。

需要注意的是,dofile的返回值主要能反馈语法层面的错误。对于逻辑错误或参数错误,它可能无法直接检测到。


本节课中,我们一起学习了如何利用dofile的返回值检测LUA脚本错误,并通过堆栈信息定位出错的具体位置和原因。掌握这一方法,能有效提升调试脚本的效率。

P182:196-LUA多线程控制 🧵

在本节课中,我们将学习如何在Lua脚本中实现多线程控制。通过将不同的功能模块(例如自动打怪和血量检测)分离到独立的线程中运行,可以使程序结构更清晰,控制更灵活。


场景假设与设计思路

上一节我们介绍了多线程的基本概念,本节中我们来看看一个具体的应用场景。

假设我们有两个Lua脚本任务:

  1. 一个脚本用于循环执行打怪操作。
  2. 另一个脚本用于持续检测角色血量,并在血量过低时自动使用补血物品。

将这两个功能写在同一个循环中会难以控制,因为它们的执行条件和等待时间可能不同。将它们设计成两个独立的线程则更为方便。例如,当需要停止打怪时,血量保护的线程可以继续独立运行。


代码结构示例

以下是两个线程脚本的功能代码示例。

自动打怪线程脚本 主要包含选择怪物、使用技能或攻击,并等待一定时间后循环执行。

-- 选择怪物
-- 使用技能或攻击
-- 等待8秒

血量检测与保护线程脚本 则持续监控血量,并在低于设定值时使用物品。

-- 获取当前HP
if HP < 500 then
    -- 使用补血物品
end

实现多线程环境

为了实现两个Lua脚本的并行执行,我们需要在宿主程序(如Java)中创建和管理多个Lua状态机。

首先,我们需要修改原有的Lua环境初始化函数。因为要使用多个Lua环境,之前的全局变量方式不再适用。我们将在注册函数时完成初始化工作。

核心步骤是创建两个独立的Lua状态环境指针。以下是关键操作的概念性代码描述:

// 创建第一个Lua状态机,用于执行脚本A
lua_State* L1 = luaL_newstate();
// 创建第二个Lua状态机,用于执行脚本B
lua_State* L2 = luaL_newstate();

// 分别为两个状态机注册所需的函数库
// ...

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/8340173d6001c2f118288da59c6b8a90_14.png)

// 分别加载并执行对应的Lua脚本文件
// ...

// 程序退出时,关闭两个状态机
lua_close(L1);
lua_close(L2);

在创建和关闭状态机时,应加入适当的异常处理,确保程序的健壮性。


总结

本节课中我们一起学习了Lua多线程控制的基本方法。我们通过一个游戏脚本的例子,演示了如何将自动打怪和血量保护两个功能模块分离到独立的线程中。实现的关键在于在宿主程序中创建多个Lua状态机(lua_State*)来并行执行不同的脚本任务,从而使程序结构更清晰,控制更灵活。

课程 P183:197-地上物品列表分析 📦

在本节课中,我们将学习如何分析游戏中的地上物品列表。我们将探讨如何定位物品对象、识别其关键属性(如名称和数值),并理解背包内物品的属性结构。这些知识是后续实现自动拾取和物品过滤功能的基础。

地上物品列表机制

上一节我们介绍了附近对象列表的概念。本节中,我们来看看地上物品列表,它本质上是“附近对象列表”的一种具体形式。

首先,我们进入游戏并观察附近的对象。此时,附近对象列表中有三个项目。

当我们向地上丢弃一个物品时,列表中会增加一个新对象。通过分析其内存结构,我们可以发现物品名称的存储位置。

在对象数据中,名称信息通常存储在特定的偏移地址处。例如,在某个偏移量(如 +0x94)的位置可以找到代表物品名称的字符串。

物品名称地址 = 对象基地址 + 0x94

此时,我们只能看到物品的名称。物品的具体属性(如攻击力、防御力)在物品位于地上时是不可见的。

物品属性的可见性

上一节我们提到地上物品只能看到名称。本节中,我们来理解属性何时变得可见。

物品的属性需要被拾取到背包中才会显示。例如,一件武器的攻击力或一块宝石的加成数值,只有在进入背包后才能在内存中访问到相应的属性值。

因此,对于地上物品的初步过滤,我们只能依赖其名称。要判断其具体属性优劣,必须先将物品拾取到背包中。

分析背包物品属性

既然属性在背包中可见,本节我们就来深入分析背包内物品对象的属性结构。

我们以背包中的“寒铁刀”和“强化石”为例。某些固定属性的物品(如寒铁刀)无需判断。我们需要关注的是像“寒玉石”、“金刚石”这类带有随机数值属性的物品。

以下是定位物品属性值(如防御力+4)的方法:

  1. 确定背包物品对象的起始地址。
  2. 在该对象的内存范围内搜索特定的属性值(例如数值4)。
  3. 通过修改找到的地址值,观察游戏内物品属性的变化,从而确认该地址对应的属性。

通过此方法,我们找到了存储防御力加成的地址偏移量 +0xD04

防御力属性地址 = 物品对象基地址 + 0xD04

修改该地址附近的其他值(如 +0xCFC),可以改变物品的其他属性,例如攻击力或生命力。这说明该区域存储了物品的一系列属性值。

攻击力属性地址 ≈ 物品对象基地址 + 0xCFC
生命力属性地址 ≈ 物品对象基地址 + 0xD00

这些属性值通常以数字代码表示(如1=攻击力,2=防御力,3=生命力)。我们可以通过判断物品名称以及这些属性的具体数值,来决定是否保留或丢弃该物品。例如,如果“寒玉石”的攻击力加成低于某个阈值,就可以将其视为垃圾物品。

通过字符串描述过滤物品

除了直接读取数值属性,我们还可以通过物品的描述文本来进行过滤。

在物品对象中,存在存储详细描述文本的地址偏移(例如 +0x230)。对于带属性的物品,此处会包含如“攻击力增加”等文本。

物品描述地址 = 物品对象基地址 + 0x230

对于不带属性的物品,则可能在另一个偏移地址(如 +0xF1)存储其简单的功能说明。

因此,我们有两种主要的过滤思路:

  1. 数值判断:读取 +0xCFC+0xD04 等地址的具体数值,进行量化比较。
  2. 文本判断:分析 +0x230+0xF1 地址的字符串内容,根据关键词进行过滤。

课程总结

本节课中,我们一起学习了地上物品列表的分析方法。

我们首先了解了地上物品列表与附近对象列表的关系,并知道只能通过物品名称进行初步识别。接着,我们明确了物品的具体属性只有在进入背包后才能被读取和分析。然后,我们深入探索了背包内物品对象的属性结构,学会了定位如攻击力、防御力等关键属性的内存地址。最后,我们介绍了通过属性数值和描述文本两种方式来过滤垃圾物品的思路。

这些分析为我们下一节课实现自动拾取和智能物品过滤功能打下了坚实的基础。

课程 P184:物品过滤与自动拾取功能封装 🧹

在本节课中,我们将学习如何封装一个具有过滤功能的自动拾取物品函数。我们将从分析游戏中的物品对象结构开始,逐步构建一个能够筛选并拾取特定物品的自动化功能。

概述

之前,我们使用游戏内置的“拾取”动作时,会按顺序拾取地上的所有物品,无法进行筛选。例如,我们可能不想拾取“金刚石”,而只想拾取其他物品。为了实现过滤拾取,我们需要编写自定义的功能函数。

实际上,我们可以利用“攻击”技能在选中物品的基础上实现拾取,从而达到过滤的目的。接下来,我们将基于第196课的代码进行扩展。

定义物品对象结构

首先,我们需要定义一个代表地上物品的对象结构。这个结构最终将用于解析游戏“附近对象列表”中的数据。地上的物品、怪物和玩家都存储在这个列表中。

物品对象结构主要包含以下有用信息:

  • 物品名称:用于过滤功能。
  • 物品在所有对象数组中的下标:用于选中物品。

以下是物品对象的结构定义代码:

struct ItemObject {
    DWORD index; // 物品在对象数组中的下标
    std::string name; // 物品名称
    // ... 其他可能的属性
};

封装核心功能函数

我们将封装几个核心函数来管理物品列表和拾取逻辑。

初始化物品列表

GetItemList 函数用于从“附近对象列表”中提取所有地上物品的信息,并存储到我们的物品对象数组中。

其核心逻辑是遍历附近对象,检查其类型偏移(+0x08)的值。如果该值等于 0x33,则代表这是一个地上物品。然后,我们从该对象中读取名称(偏移 +0x90)和下标(偏移 +0x0C),并存入列表。

void GetItemList(std::vector<ItemObject>& itemList) {
    itemList.clear(); // 清空列表,获得干净的数据
    DWORD nearbyObjBase = ReadDWORD(NearbyObjectList_Base);
    int count = ReadDWORD(NearbyObjectList_Count);

    for(int i = 0; i < count; i++) {
        DWORD objAddr = ReadDWORD(nearbyObjBase + i * 4);
        BYTE objType = ReadByte(objAddr + 0x08);
        if(objType == OBJECT_TYPE_ITEM_ON_GROUND) { // 0x33 代表地上物品
            ItemObject item;
            item.index = ReadDWORD(objAddr + OBJECT_INDEX_OFFSET); // 0x0C
            item.name = ReadString(objAddr + OBJECT_NAME_OFFSET); // 0x90
            itemList.push_back(item);
        }
    }
}

选中并拾取物品(带过滤)

PickupItem 函数是自动拾取的核心。它首先调用 GetItemList 初始化物品列表,然后遍历列表。我们可以在此处添加过滤逻辑,例如判断物品名称。如果物品符合拾取条件,则将其下标写入角色“选中目标”的偏移(0x14B8),然后执行“攻击”动作即可拾取。

以下是实现简单名称过滤的示例:

void PickupItem() {
    std::vector<ItemObject> items;
    GetItemList(items); // 初始化物品列表

    for(const auto& item : items) {
        // 过滤逻辑示例:不拾取名为“寒玉石”的物品
        if(item.name.find("寒玉石") != std::string::npos) {
            continue; // 跳过本次循环,不拾取此物品
        }

        // 选中物品
        WriteDWORD(PLAYER_BASE + TARGET_INDEX_OFFSET, item.index); // 0x14B8
        // 执行攻击动作以拾取物品
        UseSkill(ATTACK_SKILL_ID);
        Sleep(300); // 稍作延迟,避免操作过快
        break; // 每次调用只拾取一个物品,可根据需要修改
    }
}

功能测试与应用

为了测试上述功能,我们需要在全局变量中定义一个物品列表对象,并创建测试函数。

以下是两个简单的测试函数:

  1. 测试一:打印地上物品信息
    此函数调用 GetItemList 并打印出所有地上物品的名称和下标,方便我们查看和设计过滤规则。

  1. 测试二:执行过滤拾取
    此函数调用 PickupItem。每次调用会按过滤规则拾取一个符合条件的物品。通过连续调用或将其放入循环,可以自动清理地上的目标物品。

应用示例:当我们在游戏中丢弃“寒玉石”和其他物品后,运行测试二。函数会拾取除“寒玉石”外的所有物品,而“寒玉石”则会留在地上,从而实现了过滤拾取的目的。

总结

本节课中,我们一起学习了如何封装一个带过滤功能的自动拾取系统。我们首先定义了物品对象结构来存储关键信息,然后实现了初始化物品列表和带过滤逻辑的拾取函数。通过名称判断等简单的过滤条件,我们能够控制角色只拾取我们想要的物品,从而提升了自动化操作的灵活性和效率。

下一节课,我们将进一步探讨更复杂、更多样化的物品过滤规则。

课程 P185:游戏强化与合成功能调用分析 🎮

在本节课中,我们将学习如何分析一款游戏中与物品强化、合成相关的功能调用。我们将通过逆向工程的方法,定位并理解游戏客户端与服务器交互的关键函数,并尝试通过代码注入来模拟这些操作。


概述:打开NPC菜单

上一节我们介绍了如何与游戏进程交互。本节中,我们来看看如何通过代码调用打开游戏内NPC的菜单界面。

在游戏中,与名为“刀剑鞘”的NPC对话后,可以进行物品的强化与合成。此过程通常包含几个步骤:

  1. 选择目标NPC。
  2. 打开NPC对话菜单。
  3. 在菜单中选择“强化”或“合成”选项。
  4. 放置需要强化/合成的物品及所需材料。
  5. 调用最终的强化/合成功能函数。

首先,我们关注如何通过代码触发第二步,即打开菜单。

选中NPC后,调用游戏内“普通攻击”相关的函数,实际上可以打开NPC的菜单。这是因为游戏逻辑中,与NPC交互和发起攻击可能共用部分底层通信机制。当在菜单中选择“卖出”、“确认”或“合成”时,客户端会向服务器发送特定数据包。

因此,我们可以对游戏发送网络数据包的API函数下断点进行分析。


定位数据包发送函数

以下是定位关键函数的步骤:

  1. 在游戏中选中NPC,并打开其菜单。
  2. 对疑似发送数据包的函数(如 sendsendto 或游戏特定函数)下断点。
  3. 在游戏菜单中选择“强化物品”或“合成物品”。
  4. 观察断点是否被触发。

在本例中,断点命中了一个名为 WSASend 的函数。执行程序直到该函数返回,并观察其上层调用。最终,我们定位到了游戏内负责组包和发包的关键调用点(下文以 Call_SendPacket 指代)。

通过调用这个 Call_SendPacket 函数,理论上就能模拟客户端行为,向服务器发送“打开强化界面”的指令。服务器收到指令后,会返回数据给客户端,从而打开对应的窗口。

分析发现,打开商店、合成、强化等不同菜单,都调用了同一个 Call_SendPacket 函数,区别仅在于传入数据缓冲区中的某个参数值不同。

核心调用伪代码:

// 假设的函数原型
void Call_SendPacket(void* pBuffer, int bufferSize);
// 缓冲区数据示例(十六进制):
// 打开商店: 90 80 01 08 01 00 00 00 ...
// 打开合成: 90 80 01 08 06 00 00 00 ...
// 打开强化: 90 80 01 08 08 00 00 00 ...

构建并注入测试代码

为了验证我们的分析,需要编写代码注入到游戏进程中,模拟发送数据包。

我们可以使用代码注入器,在 Call_SendPacket 函数被调用前,构建正确的缓冲区并设置相关寄存器。由于在注入器中直接编写字节码不便,通常采用汇编指令顺序压栈的方式来构建缓冲区。

关键汇编注入逻辑:

// 假设调用约定为 stdcall,参数从右向左压栈
push 0x00000000        // 缓冲区部分数据
push 0x00000000
push 0x00000000
push 0x00000000
push 0x00000000
push 0x18030000        // 缓冲区部分数据
push 0x02000000        // 参数可能表示功能类型
push 0x00900180        // 缓冲区头部
// 此时栈顶即为缓冲区地址,将其存入EAX
mov eax, esp
// 接下来需要将缓冲区大小存入ECX(具体寄存器根据游戏而定)
mov ecx, bufferSize
// 最后跳转或调用原始的 Call_SendPacket 函数地址
jmp [Call_SendPacket_Address]

注意:注入代码必须保持堆栈平衡。在函数执行完毕后,需要释放临时占用的栈空间(例如 add esp, 0x18)。

经过测试,修改缓冲区中的特定字节(例如,将 01 改为 0608),可以分别成功打开商店、合成界面和强化界面。


探索上层调用函数

除了直接调用发包函数,我们还可以向上层追溯,寻找更简洁的调用方式。

Call_SendPacket 的调用者位置下断点,继续执行并返回后,我们发现了一个更上层的函数(下文以 Call_OpenMenu 指代)。这个函数参数更简单,似乎直接对应了菜单类型。

以下是分析 Call_OpenMenu 函数参数的结果:

  • 打开商店时,参数为 1
  • 打开合成界面时,参数为 6
  • 打开强化界面时,参数为 8

测试调用 Call_OpenMenu(1) 可以成功打开商店。这个函数相比底层的 Call_SendPacket 更为简洁。

两种调用方式的对比:

  • Call_SendPacket 方法:需要构建完整数据缓冲区,更接近底层通信。在游戏更新后,通常只需更新该函数地址,灵活性较高。
  • Call_OpenMenu 方法:参数简单,调用方便,逻辑更清晰。但游戏更新时,此函数本身变动的可能性更大。

具体使用哪种方法,取决于你的需求和维护策略。


总结与下节预告

本节课中,我们一起学习了如何分析游戏内强化与合成功能的调用逻辑。

我们掌握了两种关键方法:

  1. 定位并调用底层的数据包发送函数 Call_SendPacket,通过构建不同的缓冲区数据来触发不同功能。
  2. 定位并调用更上层的菜单打开函数 Call_OpenMenu,通过传入不同的整数参数来打开对应界面。

这两种方法的核心都是理解游戏客户端与服务器的交互协议,并通过逆向工程找到关键的函数入口点。

下一节课,我们将继续深入分析,研究在打开菜单后,如何模拟“放置装备”、“放置材料”以及最终执行“强化”或“合成”操作的完整过程。

课程 P186:强化合成CALL相关分析 - 放置装备和材料 🧪

在本节课中,我们将学习如何分析并实现游戏内强化合成功能中的关键步骤:将背包中的装备和材料放置到强化窗口的指定位置。我们将通过分析游戏内存机制和调用关键函数(CALL)来完成这一过程。


第一步:选中背包物品 🎯

上一节我们介绍了技能放置的分析方法,本节中我们来看看如何选中物品。放置物品的第一步与选中技能类似,需要将背包中目标物品的对象地址写入特定的游戏机制(机子)中。

在之前的课程(如第31课)中,我们分析过一个用于选中物品的机制,其地址为 228。该机制内部有一个 ECX 寄存器指向选中的对象。

以下是关键步骤:

  1. 找到游戏中代表“背包数组”的数据结构。
  2. 确定目标物品(如强化石)在数组中的下标。
  3. 将该物品的对象地址写入 228 机制指定的内存地址。

例如,若强化石在背包数组下标为1的位置,其对象可能是 F0P0。通过调用相关代码,我们可以将此对象地址写入选中机制。

核心操作伪代码:

// 假设 backpackArray 是背包数组,targetIndex 是目标下标
ItemObject* targetItem = backpackArray[targetIndex];
writeToMemory(0x228, targetItem); // 将对象地址写入选中机制

这样,我们就完成了物品的选中,为后续放置操作做好了准备。


第二步:分析放置物品的CALL 📤

选中物品后,下一步是将其放置到强化窗口。我们可以参考分析技能放置CALL的方法,或者直接追踪游戏在放置动作时向服务器发送的数据包(发包)。

通过下断点跟踪,我们发现一个发包函数在放置完成后被调用。观察其参数 EDX,发现其中包含了一段数据,其开头部分与背包物品的下标有关。

例如,放置背包中下标为3的物品时,EDX 数据开头包含数字 3。这提示我们,或许可以通过直接调用这个发包函数并构造包含下标的数据包来实现放置,从而绕过“选中”步骤。

我们尝试构建缓冲区并调用该函数:

// 构建数据缓冲区,包含物品下标等信息
char buffer[] = { /* 包含下标3等数据的字节序列 */ };
// 调用发包函数,假设函数地址为 0x6E,参数为 61
callPacketFunction(0x6E, 61, buffer);

然而,经过多次测试,直接调用此发包函数并未成功放置物品。这表明该数据包可能对应其他游戏动作,并非直接的放置指令。因此,我们需要寻找更底层的调用。


第三步:定位并调用放置CALL ⚙️

我们继续向上层追溯调用链,找到了一个与之前技能放置CALL(地址 16081B10)非常相似的函数。这个函数很可能就是负责执行放置动作的关键CALL。

通过分析发现,调用此CALL需要一个重要的前提:必须已选中物品。其参数含义如下:

  • ECX:一个关键的上下文对象,不同物品(装备、材料)来源不同。
  • 第二个参数:一个标识放置类型的数值。
    • 18:表示放置强化装备。
    • 1A:表示放置强化材料(如强化石)。
    • 15:表示放置合成材料(如活尘)。

以下是调用示例:

  1. 首先,选中背包里的一件装备。
  2. 然后,调用放置CALL,传入参数 ECX_A18,即可将装备放入强化窗口的装备槽。
  3. 接着,选中背包里的强化石。
  4. 最后,调用放置CALL,传入参数 ECX_B1A,即可将强化石放入材料槽。

关键调用逻辑:

// 放置装备
callPlaceItemFunction(ECX_for_Equipment, 18);
// 放置强化材料
callPlaceItemFunction(ECX_for_Material, 0x1A);

第四步:确定关键参数ECX的来源 🔍

ECX 参数是此CALL能否成功的关键。通过CE(Cheat Engine)搜索和对比分析,我们发现:

  1. 对于装备ECX 来源于一个全局的、固定的机制地址(例如 0x311490x311790 附近的地址)。这个地址与选中机制 228 在内存中位置相近,可能属于同一个大的游戏对象结构。
  2. 对于材料(如强化石)ECX 也来源于一个全局机制,但地址与装备的有所不同(例如 0x314DD94)。这可能是因为材料是以“宿主”或列表形式管理的。

总结ECX来源:

  • ECX_装备 = [基址] + 偏移A (一个固定的全局对象)
  • ECX_材料 = [基址] + 偏移B (另一个固定的全局对象)

在实际测试中,我们发现即使统一使用装备的 ECX 机制来放置材料,有时也能成功。这可能是因为这几个机制地址在内存中相邻,数据结构相似,游戏函数兼容处理了。但为了代码的健壮性,建议区分使用。


总结与下节预告 📝

本节课中我们一起学习了强化合成功能中放置装备和材料的完整分析流程:

  1. 选中物品:通过向 228 机制写入背包物品的对象地址来完成。
  2. 放置动作:通过调用一个关键的放置CALL(类似 16081B10)来执行。
  3. 关键参数
    • 调用前必须已选中物品。
    • 参数一(ECX)是一个上下文对象,对于装备和材料,其来源是不同的全局机制。
    • 参数二是一个标识码(18 放装备,1A 放强化石,15 放合成材料)。

我们已经成功定位了所需的函数和关键数据地址。在下一节课中,我们将开始编写C++代码,将这些分析结果封装成可调用的函数,实现自动放置装备和材料的功能。


本集完。

课程 P187:201-强化合成CALL分析-去掉提示窗口 🛠️

在本节课中,我们将学习如何分析并去除游戏强化合成装备时弹出的提示窗口。这个窗口会中断自动化操作,因此我们需要找到其显示逻辑并进行控制。

概述

强化合成装备时,游戏会弹出一个通知窗口,提示“需要额外的5%强化合成几率”。虽然可以手动勾选“下次登录前不再提示”,但在编写自动化代码时,我们需要通过分析内存和调用逻辑,直接控制该窗口的显示与隐藏,以确保后续操作(如放置强化石)能顺利进行。

分析思路

上一节我们介绍了逆向分析的基本概念,本节中我们来看看针对特定窗口的分析方法。对于这个提示窗口,我们主要有以下几种分析思路:

  1. 从窗口显示状态入手:窗口的显示与隐藏通常由一个布尔值(true/false1/0)控制。我们可以尝试搜索这个状态值。
  2. 从窗口文本字符串入手:通过游戏内存中的字符串(如“需要额外的5%强化合成几率”)回溯找到引用它的代码。
  3. 从窗口函数调用入手:窗口的创建、显示、关闭通常会调用特定的API或游戏内部函数。我们可以从这些调用点进行分析。

本节课我们将重点探讨第一种方法,即通过窗口的显示状态来定位关键数据。

定位窗口显示状态

我们的假设是:存在一个窗口对象,其内部某个偏移量处存储了一个字节(byte)类型的数据,用于表示窗口的显示状态(1为显示,0为隐藏)。

以下是定位该状态值的步骤:

  1. 首次搜索:当提示窗口显示时,在内存中搜索字节数值 1
  2. 状态改变:点击“取消”关闭窗口,此时窗口隐藏,搜索数值 0
  3. 再次触发:再次放入装备,使窗口显示,搜索数值 1
  4. 重复筛选:重复“显示-搜索1,隐藏-搜索0”的过程,直到筛选出少数几个地址。

通过此方法,我们最终定位到一个关键地址。修改该地址的值为 0 可以立即隐藏窗口,验证了其正确性。

分析上层调用与对象结构

找到状态值后,我们需要分析是哪个函数或代码在读写这个地址。使用调试器(如OD)对该地址下“写入断点”。

  1. 中断分析:当游戏写入该地址(例如写入 1 以显示窗口)时,调试器会中断。我们查看调用栈和寄存器。
  2. 发现窗口对象:中断后,发现写入指令形如 mov byte ptr [ebx+40], 1。这里的 ebx 很可能是一个“窗口对象”的基地址,+40 是显示状态的偏移量。
  3. 追溯对象来源:进一步分析 ebx 的来源,发现它来自 esi,而 esi 可能指向一个更上层的对象列表或父对象。

通过层层回溯,我们初步理清了结构:一个父对象(或管理器)在偏移 +0x31C 处存储了窗口对象的指针。而窗口对象本身在偏移 +0x40 处存储了显示状态。

定位复选框控制逻辑

我们的最终目的不是简单地隐藏窗口,而是模拟勾选“下次登录前不再提示”复选框,使其永久不弹出。这意味着需要找到控制这个复选框逻辑的变量。

  1. 分析显示判断:在调用显示窗口的函数内部,存在一个条件判断。如果某个条件满足(代表复选框已勾选),则跳过显示窗口的调用。
  2. 定位关键判断:通过调试跟踪,我们找到了一个关键跳转。该跳转依赖于一个内存值,例如 cmp byte ptr [ecx+29C], 0
  3. 验证变量作用[ecx+29C] 这个地址的值,0 代表需要显示窗口(未勾选),1 代表不显示窗口(已勾选)。修改此值可以控制窗口的后续行为。
  4. 关联对象:这里的 ecx 经分析,就是之前找到的父对象地址。因此,完整的控制链是:
    • 父对象基址 + 0x31C = 窗口对象地址
    • 窗口对象地址 + 0x40 = 即时显示状态 (1/0)
    • 父对象基址 + 0x29C = 复选框持久化状态 (1/0)

核心逻辑可以用以下伪代码表示:

// 假设 parentObj 是找到的父对象基址
if ( *(byte*)(parentObj + 0x29C) == 1 ) {
    // 用户勾选了“不再提示”,不执行显示窗口操作
    return;
} else {
    // 需要显示窗口
    windowObj = *(DWORD*)(parentObj + 0x31C);
    *(byte*)(windowObj + 0x40) = 1; // 设置窗口为显示状态
    // 调用 ShowWindow 等函数...
}

总结

本节课中我们一起学习了如何逆向分析游戏中的提示窗口:

  1. 从状态值切入:通过搜索内存中变化的状态值(1/0),快速定位到控制窗口即时显示/隐藏的地址。
  2. 向上回溯:利用调试器的断点功能,从写入该状态值的代码向上回溯,逐步分析出“窗口对象”及其“父对象”的地址和结构。
  3. 定位业务逻辑:通过分析函数内部的判断逻辑,找到了模拟“不再提示”复选框效果的关键变量及其在父对象中的偏移(+0x29C)。

通过本次分析,我们不仅掌握了关闭窗口的方法,更重要的是理解了其背后的对象关系和持久化控制逻辑。下一节课,我们将基于这些分析成果,编写代码来实现自动化的强化合成操作,并重点分析点击“确认”后执行强化合成的CALL。

课程 P188:封装强化物功能 - 放置物品部分 🧱

在本节课中,我们将学习如何封装游戏内“强化物品”功能中的“放置物品”部分。我们将分析代码逻辑,修改数据结构,并最终实现将指定物品(如装备或强化石)自动放入强化窗口的功能。


上一节我们介绍了功能封装的目标,本节中我们来看看如何具体实现放置物品的代码逻辑。

首先,我们需要打开项目代码,并在背包相关的结构单元中添加新的函数声明。

在结构单元中声明函数后,我们需要在对应的CPP实现单元中添加具体的代码。为了节省时间,核心代码已预先编写完成。

让我们一起来分析这部分代码的逻辑。函数会接收一个参数,用于标识物品性能。

强化窗口只能放置三类物品:幸运符、装备以及强化石。我们之前的分析指出,在传递参数时存在区别。

具体来说,当放置强化石时,中间参数值为 1A;当放置装备时,该参数值为 0x18。这两个参数值来源于游戏内对象列表的特定机制。

我们可以通过搜索CE(Cheat Engine)来找到这些动态地址的基址。另一种简便方法是复用之前为技能栏(F1-F10)编写的对象列表机制。

但需要注意的是,如果复用技能栏的机制,则 1A0x18 这两个参数值不能从该机制中获取,必须手动控制。本教程采用了复用技能栏基址,但手动控制关键参数值的方法。

代码中对物品类型进行了判断:

  • 如果放置的是强化石,则传入 1A
  • 如果放置的是装备,则传入 0x18

在实现放置功能前,我们还需要获取一个关键信息:背包物品的对象地址。因为选中物品时需要向特定地址(如 0x288)写入该对象的地址。

因此,我们需要修改背包物品的结构体,为其添加一个“对象地址”属性。

以下是需要添加的属性定义示例:

// 在背包物品结构体中添加
DWORD objAddress; // 物品的对象地址

在初始化背包物品列表时,我们需要将这个地址值赋值给新添加的属性。

有了对象地址后,我们就可以编译并测试代码了。放置物品的函数主要包含两个步骤:

  1. 查找并选中物品:遍历背包列表,根据物品名称找到目标物品,然后执行选中操作。
  2. 放置物品到强化窗口:将已选中的物品放入强化窗口的指定位置。

接下来我们进行功能测试。首先尝试放置一件装备(例如“青铜护手”),然后放置一个强化石。为了给游戏足够的响应时间,调用函数后需要添加适当的延迟。

首次测试可能会弹出游戏内的确认窗口。我们可以通过修改一个特定的内存地址值来关闭这个弹窗。

经过查找和测试,我们找到了控制弹窗显示的内存地址。将其值设置为 1 可以关闭弹窗,设置为 0 则会显示。

最后,我们将这个内存地址的基址添加到项目的基址单元中,并将关闭弹窗的代码整合到主逻辑里,然后重新编译项目。


本节课中,我们一起学习了如何封装“放置物品到强化窗口”的功能。我们分析了参数传递的逻辑,为背包物品结构添加了必要属性,并实现了查找、选中、放置的完整流程。同时,我们还解决了测试过程中出现的弹窗问题。下一节课,我们将继续封装强化功能的其他部分。

课程 P189:204-封装强化CALL 🛠️

在本节课中,我们将学习如何封装一个用于强化游戏物品的CALL函数。我们将把之前拼装好的功能进行完善,并学习如何用更简洁、更易维护的C++代码来调用它,替代直接的汇编代码。


上一节我们介绍了放置强化物品的相关功能。本节中,我们来看看如何调用强化CALL,并对其进行封装。

首先,我们展开结构单元。在这里,可以看到一个名为 w_send 的函数,专门用于发包。我们可以直接使用这个函数来替代我们之前编写的汇编代码。

以下是添加和调用强化CALL的步骤:

  1. 添加强化物品的CALL到背包结构
    我们需要在背包相关的结构体中,添加指向强化CALL的指针或函数声明。

  2. 在CPP单元末尾添加代理函数
    我们可以编写一个代理函数来调用这个CALL。虽然可以直接写入十六进制地址,但这种方式不便于代码维护。

实际上,我们之前已经封装了一个相应的CALL。最新的CALL地址是 0xF38840。我们可以先测试一下直接调用这个地址。

// 示例:直接调用地址(不推荐用于最终代码)
typedef void (*StrengthenFunc)();
StrengthenFunc pStrengthen = (StrengthenFunc)0xF38840;
pStrengthen(); // 调用强化

经过“放置物品”和“调用强化”这两个步骤,每执行一次,就会对目标物品进行一次强化。目前,我们还没有加入条件判断,例如只强化到特定阶段,因为相关的物品属性我们尚未分析。

直接调用可以完成一次强化,但可能会失败。为了连续强化下一个物品,我们需要将物品移到前面,或者为循环添加条件限制。否则,程序会一直尝试强化同一个物品。

此外,操作之间需要有一定的时间间隔。如果点击太快,指令可能无法正确执行,导致卡顿。因此,在放置强化物品后,最好添加一个延时。

// 示例:添加延时
Sleep(100); // 暂停100毫秒

虽然直接调用地址可行,但我们更推荐调用封装好的库函数来实现这段代码。这样更简洁,且便于后续维护。

以下是调用封装库函数的推荐方法:

// 定义缓冲区
BYTE buffer[0x61] = {0};
// 初始化缓冲区前两个DWORD数据(针对4字节系统)
*(DWORD*)&buffer[0] = 第一个数据; // 先压栈的数据在底部
*(DWORD*)&buffer[4] = 第二个数据; // 后压栈的数据在顶部

// 调用封装好的库函数进行强化
// 参数1:缓冲区地址,也可以直接传 &buffer[0]
// 参数2:缓冲区大小,这里使用 sizeof(buffer)
CallStrengthenFunction(buffer, sizeof(buffer));

buffer 的第一个 DWORD 对应最先压入栈的数据,第二个 DWORD 对应后压入的数据。传递 buffer&buffer[0] 的地址是等价的。第二个参数是缓冲区大小,固定为 0x61 可以确保调用成功,但使用 sizeof(buffer) 是更安全的做法,这样即使缓冲区大小改变,代码也无需修改。封装函数内部也应包含相应的异常处理。

我们进行测试,将缓冲区大小参数改大一些(例如 0x100),发现强化功能依然正常。这说明该参数主要表示缓冲区大小,只要不小于所需空间即可。因此,使用 sizeof 是最佳实践。

测试完成后,我们可以为另一个物品(例如“皮长靴”)添加一个测试按钮,复用上述代码逻辑。

当然,我们也可以将“放置物品”、“调用强化”、“添加延时”这三个步骤封装成一个独立的函数,例如 StrengthenItemOnce(),专门用于执行一次完整的物品强化操作。


本节课中,我们一起学习了如何封装和调用游戏中的强化CALL。我们比较了直接调用地址和使用封装库函数两种方法的优劣,并实现了通过缓冲区传递参数、调用库函数来完成物品强化的流程。我们还讨论了添加延时和条件判断的必要性。

下一节课,我们将分析物品的强化阶段属性(如+1,+2,+3,+4),为后续实现更智能的强化循环(例如强化到指定阶段后停止)打下基础。之后,我们会进一步封装更复杂的物品合成功能。

逆向工程课程 P19:030-分析技能列表 📚

在本节课中,我们将学习如何分析游戏中的技能列表数据结构。我们将通过动态调试的方法,定位技能列表在内存中的基址、偏移量以及关键属性,为后续编写功能代码打下基础。

概述与目标

上一节我们介绍了动作列表的分析方法。本节中,我们来看看技能列表的分析过程。我们的目标是找到技能列表的数组结构、每个技能对象的属性(如名称、是否已学习),并理解其内部组织方式。

分析技能列表对象

在游戏中,技能通常被组织在一个列表里。使用技能时,需要将其从列表拖拽到快捷栏。这个拖拽操作背后,必然有一个变量临时存放了被选中的技能对象。

我们可以使用调试器(如CE)搜索这个存放选中技能对象的变量。搜索思路是:初始状态为“未知数值”,选中一个技能时数值“变动”,放回列表时数值“未变动”或变为特定值(如0)。

以下是搜索此变量的步骤:

  1. 开始扫描,设置扫描类型为“未知的初始值”。
  2. 在游戏内选中一个技能,此时变量值变动,搜索“变动的数值”。
  3. 将技能放回列表,变量值可能未变或归零,搜索“未变动的数值”或“数值0”。
  4. 重复选中、放回操作,并配合搜索“增加的数值”、“减少的数值”或“精确数值”,逐步过滤出候选地址。
  5. 最终,我们得到几个以0xC开头的地址,这些地址很可能与技能对象相关。

定位技能列表数组

找到存放选中技能对象的变量后,我们需要查看是哪些代码访问或修改了它。通过下访问断点,我们可以追踪到写入该变量的汇编指令。

在关键指令处,我们发现了类似 [基址 + 下标*4 + 0x410] 的数组访问模式。这里的 0x410 偏移量与之前分析动作列表时的结构非常相似,这很可能就是技能对象数组的访问方式。

我们记录下此处的基址(例如来自 EDI 寄存器)和偏移量 0x410。这个基址就是技能列表数组的起始地址。

解析技能对象结构

获得数组基址后,我们可以遍历数组中的每个元素(即技能对象),并查看其内存结构。

通过查看不同下标对象的内存,我们发现技能对象有两种类型:

  • 类型 0x1B:代表技能分类(如同一系技能的第一本“技能书”)。
  • 类型 0x1C:代表具体的技能对象。

一个完整的技能列表可能包含多个分类和多个具体技能,它们交替排列在同一个数组中。

以下是技能对象的关键属性偏移(示例,具体游戏可能不同):

  • 对象名称偏移+0x5C 处存放技能名称的指针。
  • 技能职业偏移+0xB1 处可能表示技能所属职业。
  • 是否已学习标志偏移+0x1F6 处的一个字(2字节)可能表示该技能是否已学习(1为已学,0为未学)。

我们可以通过修改 +0x1F6 处的值来验证,将0改为1后,游戏中对应的未学习技能会变为可用状态。

总结与下节预告

本节课中我们一起学习了如何分析游戏技能列表的数据结构。我们掌握了从选中操作追踪到内存变量,再定位到整个技能数组基址的方法。同时,我们解析了技能对象的内部结构,找到了表示技能名称、类型以及是否已学习的关键内存偏移。

下一节课,我们将基于本节课的分析结果,开始编写代码来读取技能列表中的数据,并尝试实现一些自动化操作。

课程 P190:205 - 完善强化函数 🛠️

在本节课中,我们将学习如何完善游戏物品的强化功能。我们将通过分析游戏内存数据,找到物品强化次数的存储位置,并修改我们的代码,使其能够判断物品是否已达到指定的强化次数,从而避免重复强化。


概述

上一节我们实现了物品强化的基本功能。本节中,我们来看看如何控制强化的次数。我们需要找到并利用游戏内存中存储物品强化次数的属性,以完善我们的强化函数。

定位强化次数属性

在之前的课程中,我们曾分析过一个位于偏移 300 的属性,它记录了物品的强化次数。但现在这个偏移已经失效,我们需要重新搜索。

以下是重新搜索的步骤:

  1. 以第一格物品为例,其初始强化状态为 1
  2. 使用内存搜索工具,附加到游戏进程,搜索字节类型的数值 1
  3. 将物品强化一次,此时强化状态变为 2
  4. 在搜索结果中继续搜索数值 2
  5. 重复此过程,直到筛选出唯一或少数几个地址。

通过此方法,我们最终定位到存储强化次数的正确偏移地址为 D04。这意味着物品的强化次数信息存储在其基础地址加上 D04 偏移的位置。

分析物品属性结构

接下来,我们查看其他带有附加属性(如“防御力+47”)的物品,以理解其数据结构。

我们发现,从偏移 D10 开始,存在一个数组,用于描述物品的附加属性。这个数组的结构如下:

  • 它是一个 word 类型的数组。
  • 数组大小可能为 67 个元素。
  • 其中,T[0] 表示属性类型(例如,2 代表防御力,3 代表生命力)。
  • T[3] 表示该属性的具体数值。

我们可以通过修改这个数组来为物品添加或更改附加属性。例如,将一个无属性的物品的 T[0] 改为 2T[3] 改为 16,即可为其添加“防御力+16”的属性。

理解强化相关数据结构

我们注意到,偏移 D04 处的数值表示强化阶段数(例如,4 表示+4)。此外,在属性数组的起始位置(如 CF8),还有一个数值(通常为 2)可能与强化状态相关。

综合来看,一个物品的强化与属性信息可能由以下部分构成:

  1. 强化阶段计数器:位于 D04,记录物品被强化了多少次。
  2. 属性数组:从 CF8D10 开始,每个属性由一组数据描述,结构可能类似于:
    struct ItemAttribute {
        int attributeType; // 属性类型,如防御力、生命力
        int unknown1;      // 未知值
        int attributeValue;// 属性数值
        int unknown2;      // 未知值
    };
    
    强化增加的属性值也通过这个结构来体现。

完善强化函数代码

分析清楚数据结构后,我们开始修改代码。核心目标是:在强化物品前,先判断其当前强化次数是否已达到上限。

以下是修改思路:

  1. 在代码的结构定义单元中,为物品信息添加 强化次数 字段,对应偏移 D04
  2. 封装一个独立的函数,用于判断物品是否可强化。
    function CanEnhanceItem(item: TItemInfo; maxEnhanceTimes: Integer): Boolean;
    begin
      Result := item.强化次数 < maxEnhanceTimes;
    end;
    
  3. 在主强化流程中调用此函数。在遍历背包、选择要强化的物品时,增加条件判断:
    • 物品不是强化石。
    • 物品的当前强化次数小于目标次数。
  4. 如果物品已达到强化上限,则跳过该物品,寻找下一个目标。
  5. 在关键操作(如放置物品、点击强化按钮)后,添加适当的延迟,确保游戏客户端能及时响应。

修改完成后,我们的强化脚本将能够智能地识别物品状态,只对未达到强化上限的物品进行操作,从而实现了强化次数的控制。


总结

本节课中,我们一起学习了如何完善游戏物品的强化函数。

  1. 我们首先通过内存搜索,重新定位了存储物品强化次数的关键偏移地址 D04
  2. 接着,我们分析了物品附加属性的数组结构,理解了如何读取和修改属性值。
  3. 然后,我们梳理了与强化相关的数据结构,区分了强化计数器和属性数组的作用。
  4. 最后,我们修改了代码,添加了强化次数判断逻辑,使强化函数具备了控制强化次数的能力。

通过本课的学习,你的强化脚本将变得更加可靠和智能。记得在实际使用中,根据不同游戏版本的数据结构变化,适时调整偏移地址和判断逻辑。

课程 P191:206 - 打开强化与合成窗口数据分析 📊

在本节课中,我们将学习如何分析并获取游戏中打开“强化装备”与“合成物品”窗口所需的数据包。我们将通过逆向工程的方法,定位关键函数,并提取出构造数据包的必要参数。


上一节我们分析了与NPC对话和关闭窗口的数据。本节中,我们来看看如何打开强化与合成这两个核心功能窗口。

根据之前的分析经验,在游戏里打开任何功能窗口前,客户端都会向服务器发送一个特定的数据包,服务器响应后才会打开窗口。因此,我们的目标就是找到并模拟发送这个“打开窗口”的指令包。

以下是分析打开强化窗口数据包的步骤:

  1. 在发包函数上下断点。
  2. 在游戏中点击“强化”按钮,使游戏在发包时断下。
  3. 回溯调用栈,找到触发发包的准确位置。
  4. 重新在此位置下断点,并再次点击“强化”,观察并记录缓冲区中的数据。

通过此方法,我们获得了打开强化窗口的数据包结构。其缓冲区大小约为36字节(0x24),其中包含关键的NPC ID和标识“强化”动作的特定字节。


接下来,我们用同样的方法分析打开合成窗口的数据。

我们发现,打开合成窗口的数据包流程与强化窗口在同一个函数中处理。只需在游戏中点击“合成物品”,并记录下此时的缓冲区数据。

对比两个数据包,我们发现它们的结构高度相似,主要区别仅在于其中一个字节的值不同。这个字节用于区分是“强化”操作还是“合成”操作。


在获取了原始数据后,我们需要编写代码来模拟发送这些数据包。

以下是使用汇编指令手动构建并发送数据包的核心思路:

  1. 使用 push 指令按特定顺序将数据压入栈中,以构建缓冲区。
  2. 在压栈前,需要先保存 ESP 寄存器的地址,以确保栈平衡。
  3. 调用我们之前封装好的发包函数,将构建好的缓冲区地址和大小作为参数传入。

例如,构建强化窗口数据包的代码逻辑如下(伪代码表示):

; 保存原始ESP
mov eax, esp
; 开始压入数据包内容
push 0
push 0
push 0x00010000
... ; 压入其他数据
push 0x00008018
push 0x00000009
; 调用发包函数 (假设函数地址在 0x4FBBA0)
push 0x24 ; 缓冲区大小
push eax  ; 缓冲区起始地址 (当前ESP)
call 0x4FBBA0

对于合成窗口,只需修改其中标识动作的那个字节值即可。


最后,我们进行功能测试。首先确保已打开NPC对话窗口,然后注入我们编写的代码。

  1. 测试强化窗口:注入对应数据包代码,游戏成功打开了强化窗口。
  2. 测试合成窗口:修改动作标识字节后注入代码,游戏成功打开了合成窗口。
  3. 关闭窗口:使用我们之前封装的关闭NPC对话函数即可。

至此,实现自动强化与合成功能所需的所有关键数据——打开NPC、选择强化/合成、放置物品、操作装备、关闭窗口——都已找齐。


本节课中我们一起学习了如何分析并获取打开强化与合成窗口的网络数据包。我们掌握了通过对比分析找到关键差异字节的方法,并了解了使用底层代码模拟发送数据包的基本流程。下一期,我们将开始进行这些功能的代码封装与实现。

我们下期再见。

课程 P192:DLL劫持注入方法 - XP/Win7 64位通用 🛠️

在本节课中,我们将学习一种名为“DLL劫持注入”的技术。这种方法利用Windows系统加载动态链接库(DLL)的搜索顺序,将我们编写的DLL文件优先于系统DLL加载,从而实现代码注入。我们将以劫持 lpk.dll 为例,讲解其原理、实现步骤,并确保代码在XP和Win7 64位系统上通用。

概述

DLL劫持注入的核心原理是:当程序运行时,系统会按照特定顺序搜索并加载所需的DLL文件。如果我们将一个同名DLL放置在程序所在目录,系统会优先加载我们的DLL,而不是系统目录下的原始DLL。在我们的DLL中,我们既可以执行自定义代码,又可以通过“跳板”函数调用原始DLL的功能,确保程序正常运行。

劫持原理分析

上一节我们概述了DLL劫持的基本思想。本节中,我们来看看其具体的工作原理。

以游戏程序为例,当它运行时,通常会调用系统目录下的 lpk.dll。系统加载DLL的搜索顺序是:首先检查应用程序的当前目录。如果我们在游戏目录下放置一个名为 lpk.dll 的文件,系统就会加载它,而不是系统目录下的那个。这样,我们的代码就随着DLL的加载而执行了。

为了不破坏程序原有功能,我们的“假”lpk.dll 必须导出与原始DLL完全相同的函数。当游戏调用这些函数时,我们的DLL会将调用转发给真正的系统 lpk.dll。这个转发过程就是通过“跳板函数”实现的。

实现步骤

理解了原理后,我们开始动手实现。整个过程可以分为创建劫持DLL、编写跳板函数和注入自定义代码几个部分。

以下是创建劫持DLL项目的具体步骤:

  1. 使用 Visual Studio 2010 创建一个新的“Win32项目”。
  2. 在应用程序类型中选择“DLL”。
  3. 将项目命名为 my_lpk(生成后需要重命名为 lpk.dll)。
  4. 移除VS自动生成的 dllmain.cpp 文件,我们将自己编写入口函数。

编写核心代码

现在,我们来编写劫持DLL的核心代码。代码主要包含两部分:一是转发函数(跳板),二是我们自己的注入代码。

首先,我们需要知道原始 lpk.dll 导出了哪些函数。可以使用工具查看,通常 lpk.dll 导出函数较少,例如11个,这简化了我们的工作。

以下是一个关键跳板函数的代码示例。其核心是获取原始函数的地址并进行调用:

// 声明原始函数的函数指针类型
typedef int (WINAPI* TrueLpkInitialize)(...);

// 跳板函数
int WINAPI LpkInitialize(...)
{
    // 1. 获取系统目录路径
    char sysPath[MAX_PATH];
    GetSystemDirectoryA(sysPath, MAX_PATH);
    strcat(sysPath, "\\lpk.dll");

    // 2. 加载真正的系统lpk.dll
    HMODULE hRealLpk = LoadLibraryA(sysPath);

    // 3. 获取真实函数的地址
    TrueLpkInitialize pfnRealLpkInitialize = (TrueLpkInitialize)GetProcAddress(hRealLpk, "LpkInitialize");

    // 4. 调用真实函数并返回结果
    return pfnRealLpkInitialize(...);
}

我们需要为每一个导出的函数编写这样的跳板函数。

我们自己的注入代码放在哪里呢?它应该放在DLL的入口点 DllMain 中。当我们的DLL被加载时,DllMain 函数会被调用。

以下是在 DllMain 中加载我们实际功能DLL的代码:

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
    if (ul_reason_for_call == DLL_PROCESS_ATTACH)
    {
        // 禁用DLL_THREAD_ATTACH等通知,提升性能
        DisableThreadLibraryCalls(hModule);

        // 在这里执行注入代码,例如加载我们真正的功能DLL
        LoadLibraryA("MyRealCode.dll");
    }
    return TRUE;
}

处理Win7系统保护

在Windows XP上,以上步骤通常就足够了。但在Windows 7及更高版本系统中,由于系统保护机制,可能需要修改注册表才能成功劫持。

上一节我们完成了核心代码的编写,本节中我们来看看如何让劫持在Win7上生效。

以下是针对Win7系统的配置步骤:

  1. 按下 Win + R,输入 regedit 打开注册表编辑器。
  2. 导航到路径:HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs
  3. 在右侧找到 lpk 项(如果存在)。默认情况下,系统通过此项知道 lpk.dll 是系统已知DLL,会直接从系统目录加载,忽略应用程序目录。
  4. 为了劫持,我们需要删除 lpk 这个值。这样系统就会恢复正常的DLL搜索顺序,优先查找应用程序目录。

注意:修改注册表有风险,请先备份。修改后可能需要重启计算机生效。

测试与部署

所有代码和配置完成后,就可以进行测试了。

以下是部署和测试的步骤:

  1. 将编译生成的 my_lpk.dll 重命名为 lpk.dll
  2. lpk.dll 和你的实际功能DLL(例如 MyRealCode.dll)一起放到目标游戏或程序的根目录下。
  3. 如果目标系统是Win7/8/10,请确保已按上一节所述修改注册表。
  4. 启动目标程序。使用进程查看工具(如Process Explorer)检查我们的DLL是否成功注入到目标进程中。

总结

本节课中我们一起学习了DLL劫持注入技术的完整流程。我们从原理入手,理解了系统加载DLL的顺序是此技术可行的关键。然后,我们逐步实现了劫持DLL的创建、跳板函数的编写以及自定义代码的注入。最后,我们还探讨了在Windows 7系统上需要处理的额外注册表配置。

这种方法的优点是通用性较强,但需要注意目标DLL的选择和系统兼容性问题。掌握这项技术有助于你更深入地理解Windows程序的运行机制。

课程 P193:游戏辅助控制台设计原理 🎮

在本节课中,我们将学习游戏辅助控制台的设计原理。控制台是一个可以管理多个游戏窗口的界面或进程,其核心在于实现进程间的通信。我们将探讨如何使用 SendMessageCOPYDATASTRUCT 结构来实现一个简单的单向通信控制台。


控制台基本原理 🔧

上一节我们介绍了控制台的概念,本节中我们来看看其核心原理。

游戏辅助控制台的核心是进程间通信。它允许一个控制台进程向一个或多个游戏进程发送指令,从而实现批量管理,例如自动打怪或获取游戏信息。

实现进程间通信有多种方式,例如:

  • 远程过程调用
  • 套接字
  • 管道
  • 共享内存

在本教程中,我们将使用 Windows 消息机制中的 SendMessage 函数配合 WM_COPYDATA 消息来实现一个简单且有效的通信方式。


通信模型与代码结构 📡

理解了基本原理后,我们来看看具体的实现模型和代码分为哪几部分。

整个系统分为两个主要部分:

  1. 控制台端:负责发送指令的代码。
  2. 游戏进程端:负责接收并执行指令的代码。

以下是实现的关键步骤概述:

  • 控制台端查找游戏窗口句柄。
  • 控制台端封装数据并通过 SendMessage 发送 WM_COPYDATA 消息。
  • 游戏进程端安装一个钩子,拦截并处理 WM_COPYDATA 消息。
  • 游戏进程根据接收到的指令执行相应操作。

控制台端:发送指令 💻

现在,我们来详细看看控制台端如何构建和发送指令。

控制台端的关键是调用 SendMessage 函数。其函数原型如下:

LRESULT SendMessage(
  HWND   hWnd,      // 目标窗口句柄
  UINT   Msg,       // 消息类型,此处为 WM_COPYDATA
  WPARAM wParam,    // 发送消息的窗口句柄
  LPARAM lParam     // 指向 COPYDATASTRUCT 结构的指针
);

其中,COPYDATASTRUCT 结构用于封装要传递的数据:

typedef struct tagCOPYDATASTRUCT {
  ULONG_PTR dwData;  // 自定义数值,可用于区分不同命令
  DWORD     cbData;  // lpData 指向数据的大小
  PVOID     lpData;  // 指向任意数据结构的指针
} COPYDATASTRUCT;

我们可以将发送功能封装成一个简单的函数:

// 前置声明
void SendCommandToGame(HWND hGameWnd, const char* command);

void SendCommandToGame(HWND hGameWnd, const char* command) {
    COPYDATASTRUCT cds;
    cds.dwData = 1; // 自定义命令标识
    cds.cbData = strlen(command) + 1; // 数据大小,包含字符串结束符
    cds.lpData = (void*)command; // 数据指针

    // 必须使用 SendMessage 而非 PostMessage,确保数据同步送达
    SendMessage(hGameWnd, WM_COPYDATA, (WPARAM)GetActiveWindow(), (LPARAM)&cds);
}

在测试程序中,我们需要先获取游戏窗口句柄,然后调用此函数:

// 假设游戏窗口标题为“MyGameWindow”
HWND hGame = FindWindow(NULL, "MyGameWindow");
if(hGame) {
    SendCommandToGame(hGame, "START_AUTO_ATTACK");
}


游戏进程端:接收与处理指令 🕹️

发送端完成后,我们需要在游戏进程内编写接收指令的代码。

游戏端需要安装一个窗口过程钩子来拦截 WM_COPYDATA 消息。以下是核心步骤:

  1. 保存原窗口过程:获取并保存游戏窗口原有的消息处理函数地址。
  2. 安装新窗口过程:用我们自定义的窗口过程函数替换原函数。
  3. 处理消息:在新窗口过程中,检查是否为 WM_COPYDATA 消息,并进行处理。
  4. 传递其他消息:对于非自定义消息,调用原窗口过程函数,确保游戏正常运行。

以下是自定义窗口过程函数的示例框架:

// 自定义命令标识,需与控制台端 dwData 值匹配
#define MY_COMMAND_ID 1

// 原窗口过程指针
WNDPROC g_oldWndProc = NULL;

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/9155e0c30793b7fa7a6d79cdb14d4fca_21.png)

// 新的窗口过程函数
LRESULT CALLBACK NewGameWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {
    if(uMsg == WM_COPYDATA) {
        PCOPYDATASTRUCT pCDS = (PCOPYDATASTRUCT)lParam;
        if(pCDS->dwData == MY_COMMAND_ID) {
            // 接收到我们自定义的命令
            char* command = (char*)pCDS->lpData;
            // 处理命令,例如打印或执行操作
            OutputDebugStringA(command);
            // 可以在这里添加挂机、执行脚本等逻辑
            return 0; // 已处理,直接返回
        }
    }
    // 其他消息交给原窗口过程处理
    return CallWindowProc(g_oldWndProc, hWnd, uMsg, wParam, lParam);
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/9155e0c30793b7fa7a6d79cdb14d4fca_23.png)

// 安装钩子的函数
void InstallHook(HWND hGameWnd) {
    if(hGameWnd && !g_oldWndProc) {
        g_oldWndProc = (WNDPROC)SetWindowLongPtr(hGameWnd, GWLP_WNDPROC, (LONG_PTR)NewGameWndProc);
    }
}

// 卸载钩子的函数(可选)
void UninstallHook(HWND hGameWnd) {
    if(hGameWnd && g_oldWndProc) {
        SetWindowLongPtr(hGameWnd, GWLP_WNDPROC, (LONG_PTR)g_oldWndProc);
        g_oldWndProc = NULL;
    }
}

重要提示SendMessage 是同步调用。如果游戏端在处理 WM_COPYDATA 消息时发生阻塞(例如弹出一个模态对话框),控制台端的 SendMessage 调用也会被阻塞,直到游戏端处理完毕返回。因此,游戏端的消息处理逻辑应尽量高效。


总结与下节预告 📚

本节课中我们一起学习了游戏辅助控制台单向通信的基本原理与实现。

我们掌握了以下核心内容:

  • 控制台通过进程间通信管理多个游戏窗口。
  • 使用 SendMessageWM_COPYDATA 消息实现进程间数据传递。
  • 定义了 COPYDATASTRUCT 结构来封装命令和数据。
  • 在游戏端通过替换窗口过程来拦截并处理自定义命令。
  • 注意 SendMessage同步特性对程序响应的影响。

目前我们实现了从控制台到游戏进程的单向指令发送。在下一节课中,我们将探讨如何实现双向通信,即让游戏进程也能将数据(如角色血量、金币数量)回传给控制台进行显示,从而构建一个完整的交互式控制台系统。

课程 P194:游戏与控制台双向通信接口设计 🎮🔄💻

在本节课中,我们将学习如何设计一个接口,实现游戏进程与控制台程序之间的双向通信。我们将通过发送消息的方式,从控制台向游戏进程发送指令,并获取游戏内的人物属性数据返回给控制台显示。


概述

上一节我们探讨了进程间通信的基本概念。本节中,我们来看看如何具体实现游戏进程与控制台之间的数据交换。核心思路是:控制台发送一个包含指令和内存地址的消息给游戏进程;游戏进程接收到消息后,根据指令读取游戏数据,并通过跨进程写入操作,将数据写回控制台指定的内存地址中。

通信流程设计

以下是实现双向通信的主要步骤:

  1. 定义通信数据结构:首先需要定义一个结构体,用于在进程间传递人物属性信息。
  2. 控制台发送指令:控制台程序构造指令消息,并通过 SendMessage 函数发送给游戏进程窗口。
  3. 游戏进程接收与处理:注入到游戏进程的代码接收消息,解析指令,并执行读取游戏数据的操作。
  4. 数据回传:游戏进程将读取到的数据,通过跨进程写入函数写回控制台进程的内存。
  5. 控制台显示数据:控制台从自己的内存中读取并显示接收到的游戏数据。

核心代码实现

1. 定义数据结构

首先,我们定义一个结构体来存储要传递的人物属性信息。

// 人物属性结构体
typedef struct _CHAR_INFO {
    char szName[32]; // 角色名
    DWORD dwHP;      // 生命值
    DWORD dwMP;      // 魔法值
    DWORD dwGold;    // 金币数
} CHAR_INFO, *PCHAR_INFO;

2. 控制台发送指令

在控制台程序中,我们使用 SendMessage 函数向游戏窗口发送一个自定义消息。消息中通过 COPYDATASTRUCT 结构传递指令类型和目标内存地址。

// 自定义消息类型,需与游戏进程内定义一致
#define WM_GAME_COMMAND (WM_USER + 209)

// 指令类型枚举
typedef enum _CMD_TYPE {
    CMD_GET_CHAR_INFO = 1, // 获取角色信息
    // ... 可以定义其他指令
} CMD_TYPE;

// 发送获取角色信息指令的函数
void SendGetCharInfoCommand(HWND hGameWnd) {
    COPYDATASTRUCT cds = {0};
    CHAR_INFO CharInfo = {0}; // 本地结构体,用于接收回传的数据

    // 填充 COPYDATASTRUCT
    cds.dwData = CMD_GET_CHAR_INFO; // 指令类型
    cds.cbData = sizeof(PCHAR_INFO); // 传递的数据大小(这里是一个指针的大小)
    cds.lpData = &CharInfo; // 传递本地结构体的地址(指针)

    // 发送消息
    SendMessage(hGameWnd, WM_COPYDATA, (WPARAM)GetConsoleWindow(), (LPARAM)&cds);

    // 发送完成后,数据已被游戏进程写入 CharInfo 结构体
    // 可以在这里打印或处理获取到的数据
    printf("角色名: %s\n", CharInfo.szName);
    printf("生命值: %d\n", CharInfo.dwHP);
    printf("魔法值: %d\n", CharInfo.dwMP);
    printf("金币数: %d\n", CharInfo.dwGold);
}

关键点cds.lpData 传递的是控制台进程中 CharInfo 变量的地址。游戏进程需要将这个地址视为目标地址,并将游戏数据写入这个地址。

3. 游戏进程接收与处理指令

注入到游戏进程的 DLL 中,需要拦截窗口消息,处理我们自定义的 WM_COPYDATA 消息。

// 在游戏进程的消息处理循环中(例如钩子或窗口过程)
LRESULT CALLBACK GameWndProc(int nCode, WPARAM wParam, LPARAM lParam) {
    if (nCode == HC_ACTION) {
        CWPSTRUCT* pMsg = (CWPSTRUCT*)lParam;
        if (pMsg->message == WM_COPYDATA) {
            PCOPYDATASTRUCT pCDS = (PCOPYDATASTRUCT)(pMsg->lParam);
            
            // 判断指令类型
            switch (pCDS->dwData) {
                case CMD_GET_CHAR_INFO: {
                    // 1. 提取控制台传过来的目标地址
                    // pCDS->lpData 指向的是控制台进程中 CharInfo 结构体的地址
                    PCHAR_INFO pTargetCharInfo = *(PCHAR_INFO*)(pCDS->lpData);
                    
                    // 2. 获取游戏内真实的角色数据
                    CHAR_INFO LocalCharInfo = {0};
                    GetLocalCharacterInfo(&LocalCharInfo); // 假设这个函数能获取游戏数据
                    
                    // 3. 将游戏数据写入控制台进程的内存
                    HWND hConsoleWnd = (HWND)(pMsg->wParam); // 发送者窗口(控制台)
                    WriteProcessMemoryEx(hConsoleWnd, pTargetCharInfo, &LocalCharInfo, sizeof(CHAR_INFO));
                    
                    break;
                }
                // 处理其他指令...
            }
        }
    }
    // 调用原始的消息处理函数
    return CallNextHookEx(g_hHook, nCode, wParam, lParam);
}

4. 跨进程写入函数

游戏进程需要将数据写回控制台进程。这需要一个具有足够权限的跨进程写入函数。

// 一个封装了跨进程写入和权限处理的函数
BOOL WriteProcessMemoryEx(HWND hTargetWnd, LPVOID lpBaseAddress, LPCVOID lpBuffer, SIZE_T nSize) {
    DWORD dwProcessId = 0;
    HANDLE hProcess = NULL;
    BOOL bResult = FALSE;
    
    // 1. 通过窗口句柄获取进程ID
    GetWindowThreadProcessId(hTargetWnd, &dwProcessId);
    if (dwProcessId == 0) return FALSE;
    
    // 2. 以足够的权限打开目标进程(控制台进程)
    hProcess = OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION, FALSE, dwProcessId);
    if (hProcess == NULL) {
        // 如果权限不足,可能需要调整进程令牌权限或关闭UAC虚拟化
        // 例如:在清单文件中设置 requestedExecutionLevel 为 requireAdministrator
        return FALSE;
    }
    
    // 3. 执行跨进程写入
    bResult = WriteProcessMemory(hProcess, lpBaseAddress, lpBuffer, nSize, NULL);
    
    // 4. 清理
    CloseHandle(hProcess);
    return bResult;
}

注意:在 Visual Studio 2010 及以后版本,如果遇到权限问题,可能需要修改项目属性,关闭 UAC 虚拟化,或在清单文件中请求管理员权限。

5. 控制台接收与显示

控制台在调用 SendMessage 后,该函数会阻塞,直到游戏进程处理完消息并返回。此时,数据已经通过 WriteProcessMemoryEx 写入了控制台本地 CharInfo 变量中。SendGetCharInfoCommand 函数中的打印语句即可输出结果。

流程总结与注意事项

本节课中我们一起学习了游戏与控制台双向通信的接口设计。整个流程可以概括为:“发送指令-处理并回写-本地显示”

以下是几个关键注意事项:

  • 同步性SendMessage 是同步调用,控制台会等待游戏进程处理完毕。这确保了数据在打印前已被正确写入。
  • 地址传递:控制台传递的是自身进程内变量的地址。游戏进程需要解析这个地址,并将其作为 WriteProcessMemory 的目标地址。
  • 进程权限:游戏进程需要对控制台进程有写入内存的权限。务必处理好 OpenProcess 的权限问题。
  • 数据一致性:确保两端定义的结构体完全一致,包括结构体大小和成员顺序。

通过这种方式,我们建立了一个简单的游戏外挂与控制台之间的通信桥梁,可以扩展用于传输各种游戏数据。

课程 P195:控制台控制多个游戏接口设计 🎮

在本节课中,我们将学习如何设计一个控制台程序,使其能够同时控制多个正在运行的游戏实例。我们将探讨如何获取游戏窗口列表、向特定游戏发送指令,并理解其背后的代码实现原理。


获取游戏列表 📋

上一节我们介绍了课程目标,本节中我们来看看如何获取当前正在运行的所有游戏窗口列表。

核心原理是使用Windows API函数 EnumWindows 来枚举所有顶层窗口,并通过回调函数进行筛选。

以下是实现获取游戏列表的关键步骤:

  1. 编写一个函数,调用 EnumWindows 函数。
  2. 在回调函数中,判断每个窗口的标题和类名,以识别出目标游戏窗口。
  3. 将识别出的游戏窗口句柄存储在一个全局数组中。

这个全局数组建立了游戏窗口句柄与游戏实例之间的一一对应关系。获取句柄列表后,所有后续的指令操作都将基于这个容器中的内容进行。

// 伪代码示例:枚举窗口并筛选
BOOL CALLBACK EnumWindowsProc(HWND hwnd, LPARAM lParam) {
    // 判断窗口标题和类名是否为我们的游戏
    if (IsTargetGameWindow(hwnd)) {
        // 将有效句柄存入全局数组
        g_gameHandles.push_back(hwnd);
    }
    return TRUE;
}

void GetGameList() {
    // 清空旧列表
    g_gameHandles.clear();
    // 开始枚举窗口
    EnumWindows(EnumWindowsProc, 0);
}

在初始化或需要刷新列表时(例如点击“刷新列表”按钮),会调用上述函数。它会遍历所有窗口,将有效的游戏窗口句柄更新到全局变量中。之后,程序会将这些句柄转换为字符串,并进一步获取每个游戏窗口内的人物信息(例如血量、名字),最终拼接成一个完整的字符串显示在控制台的列表控件中。


发送游戏指令 📨

上一节我们学习了如何获取游戏列表,本节中我们来看看如何向选中的游戏发送控制指令。

发送指令的核心,在于利用之前获取到的游戏窗口句柄,对选中的特定窗口进行操作。

以下是向选中游戏发送指令的实现过程:

  1. 将控制台列表控件设置为支持多选模式。
  2. 获取用户选中的行下标,这些下标会保存在一个整型列表中。
  3. 遍历这个下标列表,根据下标从全局游戏句柄数组中取得对应的窗口句柄。
  4. 向每一个获取到的窗口句柄发送预设的指令(例如,获取角色信息、执行挂机脚本等)。

// 伪代码示例:向选中游戏发送指令
void SendCommandToSelectedGames() {
    // 获取列表控件中所有选中项的下标
    CArray<int, int> selectedIndices;
    m_listCtrl.GetSelectedItems(selectedIndices);

    // 遍历所有选中的下标
    for (int i = 0; i < selectedIndices.GetCount(); ++i) {
        int idx = selectedIndices[i];
        // 通过下标从全局句柄数组获取对应的窗口句柄
        HWND hGame = g_gameHandles[idx];
        if (IsWindow(hGame)) { // 确保窗口仍然有效
            // 向该窗口发送指令
            SendCustomCommand(hGame, COMMAND_GET_INFO);
        }
    }
}

通过这种方式,我们可以实现精准控制。例如,当在列表中只选中第三个游戏时,指令将只发送给第三个游戏窗口,并获取其角色信息。这为同时管理多个游戏实例(如多开挂机、批量操作)提供了基础。


总结与回顾 📝

本节课中我们一起学习了控制台程序控制多个游戏的核心设计。

我们首先介绍了如何利用 EnumWindows API 枚举并筛选出所有目标游戏窗口,将它们的句柄存储在全局数组中,形成控制的基础。接着,我们探讨了如何通过列表控件的多选功能,获取用户选择,并向选中的特定游戏窗口发送自定义指令,从而实现分控或群控。

整个原理围绕 窗口句柄 这一核心概念展开。通过维护一个实时的、有效的窗口句柄列表,并将列表与UI控件绑定,我们能够构建一个灵活的多游戏控制台。为了保持列表的准确性,在游戏窗口关闭后,需要重新调用刷新函数来更新全局数据。

通过本课的学习,你应该掌握了设计此类多实例控制接口的基本思路和关键实现步骤。

课程 P196:212 - 在线注册控制实例教程 🔐

在本节课中,我们将学习如何编写代码,实现一个基于动态链接库的登录验证功能。我们将从创建工程开始,逐步完成环境配置、初始化、用户验证等步骤。


上一节我们了解了静态库版本的基本编译。本节中,我们来看看如何使用纯动态链接库版本实现登录验证。

首先,我们需要创建一个新的工程用于测试。

创建一个新的MFC应用程序工程。在应用程序类型中,选择“基于对话框”。在“高级功能”中,确保勾选“使用Unicode库”。

接下来,在对话框资源编辑器中,添加一个静态文本控件用于显示信息,再添加一个按钮控件用于触发登录操作。

以下是界面设计完成后需要进行的步骤:

  1. 保存工程。
  2. 打开源代码目录。
  3. 将动态链接库文件(.dll)、对应的导入库文件(.lib)以及头文件(.h)复制到该目录下。

工程配置完成后,我们需要在代码中引用这些文件。在解决方案资源管理器中,右键点击项目,选择“属性”。在“链接器”->“输入”->“附加依赖项”中,添加导入库文件(.lib)的名称。

同时,需要将动态链接库文件(.dll)复制到Windows系统目录下,或者应用程序的输出目录(即.exe文件所在目录)中。

接着,我们就可以开始编写核心代码了。首先,在对话框类的头文件中包含我们复制过来的头文件。

#include “YourAuthLibrary.h”

然后,在对话框的初始化函数(如 OnInitDialog)中,进行认证组件的初始化。

// 全局标识符,用于后续操作
HANDLE g_hAuth = NULL;

BOOL CYourDlg::OnInitDialog()
{
    CDialogEx::OnInitDialog();

    // 初始化认证组件
    g_hAuth = Auth_Init(“YourProductID”);
    if (g_hAuth == (HANDLE)(-1))
    {
        MessageBox(_T(“连接认证服务器失败!”), _T(“错误”), MB_ICONERROR);
        EndDialog(IDCANCEL); // 初始化失败,退出程序
        return FALSE;
    }
    else if (g_hAuth != NULL)
    {
        // 初始化成功,可以继续
        SetDlgItemText(IDC_STATIC_INFO, _T(“服务器连接成功!”));
    }

    return TRUE;
}

代码解析:

  • Auth_Init 是初始化函数,传入产品编号。
  • 它返回一个句柄 g_hAuth,后续操作都依赖此句柄。
  • 如果返回 (HANDLE)(-1),表示连接服务器失败。
  • 如果返回非 NULL 且非 -1 的值(通常是0),表示初始化成功。

运行程序,如果看到“服务器连接成功”的提示,说明动态库加载和初始化步骤正确。如果失败,请检查.dll文件是否已放入正确目录。

上一节我们完成了组件初始化。本节中,我们来实现具体的登录验证逻辑。

为“登录”按钮添加事件处理程序。在按钮点击事件中,调用验证函数。

void CYourDlg::OnBnClickedButtonLogin()
{
    CString strUsername, strPassword;
    // 假设IDC_EDIT_USER和IDC_EDIT_PWD是输入用户名和密码的编辑框
    GetDlgItemText(IDC_EDIT_USER, strUsername);
    GetDlgItemText(IDC_EDIT_PWD, strPassword);

    // 调用验证函数
    int nResult = Auth_Check(g_hAuth, strUsername, strPassword);
    
    CString strMsg;
    switch (nResult)
    {
    case 0:
        strMsg = _T(“登录验证成功!”);
        break;
    case -1:
        strMsg = _T(“错误:注册码不存在或密码错误。”);
        break;
    case -2:
        strMsg = _T(“错误:账号已被禁用。”);
        break;
    case -3:
        strMsg = _T(“错误:绑定机器数量超限。”);
        break;
    case -5:
        strMsg = _T(“错误:注册码已过期。”);
        break;
    case -6:
        strMsg = _T(“错误:余额不足(天数用完)。”);
        break;
    default:
        strMsg.Format(_T(“未知错误,代码:%d”), nResult);
        break;
    }

    SetDlgItemText(IDC_STATIC_INFO, strMsg);
}

代码解析:

  • Auth_Check 是验证函数,传入初始化句柄、用户名和密码。
  • 函数返回一个整数代码,表示验证结果。
  • 返回 0 表示验证成功
  • 其他负数值代表不同的错误情况,如账号不存在、被禁用、过期或余额不足等。

测试时需要注意,验证所用的用户名和密码需要在服务端(用户管理界面)预先创建并设置有效天数。

以下是测试不同情况的预期结果:

  • 用户名或密码错误:返回 -1,提示“注册码不存在或密码错误”。
  • 账号未充值(天数为0):返回 -6,提示“余额不足”。
  • 账号有效且信息正确:返回 0,提示“登录验证成功!”。

除了登录验证,该动态库通常还提供其他功能接口,例如:

  • Auth_Logout:用户注销。
  • Auth_GetRemainDays:查询剩余天数。
  • Auth_KickUser:踢出在线用户。

这些功能我们将在后续课程中继续探讨。


本节课中我们一起学习了如何集成一个在线的注册控制模块。我们完成了从创建工程、配置环境、初始化认证组件到实现用户名密码验证的完整流程。核心在于理解初始化函数 Auth_Init 和验证函数 Auth_Check 的用法及其返回值含义。通过本实例,你可以掌握为软件添加基础在线授权验证功能的方法。

课程 P197:213 - 在线注册控制:注册、充值与激活 🔐

在本节课中,我们将学习如何在一个微分注册系统中实现用户注册、充值激活以及登录功能。我们将使用几个核心函数,并了解如何将它们整合到图形界面中。


界面与变量准备 🎨

上一节我们介绍了微分系统的基本概念,本节中我们来看看如何构建用户操作界面。

首先,我们需要在图形界面中添加几个按钮和编辑框,用于处理注册、充值和修改密码操作。

以下是需要添加的控件:

  • 一个用于输入用户名的编辑框。
  • 一个用于输入密码的编辑框。
  • 一个用于输入充值卡号的编辑框。
  • 三个按钮,分别对应“注册”、“充值”和“修改密码”功能。

接着,我们需要为这些编辑框关联对应的字符串变量,以便在代码中获取用户输入的数据。


实现用户注册 📝

界面准备就绪后,我们来实现用户注册功能。注册需要使用 the resist 函数。

在使用任何功能前,必须确保已成功调用初始化函数。the resist 函数的主要参数是用户名和密码,它们都需要 char 类型的指针。其他参数如用户类型、是否绑定电脑、通道数和初始点数可以按需设置。

以下是注册功能的核心代码逻辑:

  1. 将窗口编辑框中的数据更新到关联的变量中。
  2. 调用 the resist 函数,传入用户名、密码及其他参数。
  3. 处理函数的返回值(例如,0表示成功,8表示用户名重复)。
  4. 使用完毕后,释放字符串缓冲区占用的内存。

代码示例:

// 更新数据到变量
UpdateData(TRUE);

// 调用注册函数
int result = the_resist(m_strUsername.GetBuffer(200), m_strPassword.GetBuffer(200), 0, 0, 1, 1000);

// 释放缓冲区
m_strUsername.ReleaseBuffer();
m_strPassword.ReleaseBuffer();

// 处理结果
if(result == 0) {
    // 注册成功
} else if(result == 8) {
    // 用户名重复
}

操作成功后,可以在后台管理系统中看到新注册的用户。


为用户账户充值 💳

用户注册后,账户尚未激活,需要充值才能登录使用。充值功能需要使用 a t t t 函数。

以下是充值激活的核心步骤:

  1. 用户从开发者或代理商处获得充值卡(加时卡)。
  2. 在程序界面输入卡号和要充值的用户名。
  3. 调用 a t t t 函数,传入卡号、购买者(用户名)等参数。
  4. 函数会返回充值成功与否,以及充入的天数和点数。

代码示例:

// 更新数据到变量
UpdateData(TRUE);

// 定义变量接收返回的天数和点数
int daysAdded = 0, pointsAdded = 0;

// 调用充值函数
int rechargeResult = a_t_t_t(m_strCardNumber.GetBuffer(200), m_strUsername.GetBuffer(200), NULL, &daysAdded, &pointsAdded);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/21ea2d715e714ef665a9d86ba8ccd7ba_29.png)

// 释放缓冲区
m_strCardNumber.ReleaseBuffer();
m_strUsername.ReleaseBuffer();

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/21ea2d715e714ef665a9d86ba8ccd7ba_31.png)

// 处理结果
if(rechargeResult == 0) {
    // 充值成功,daysAdded和pointsAdded已被填充
}

充值成功后,该用户的剩余天数和点数会增加,账户即被激活。


登录已激活的账户 🔑

账户充值激活后,用户就可以使用用户名和密码进行登录了。登录功能我们已在上一节课讨论过。

我们需要修改登录按钮的代码,确保它使用当前界面输入的用户名和密码,而不是固定的测试数据。

核心注意事项:

  • 确保在调用登录函数前,正确地将编辑框内容更新到变量中。
  • 登录成功后,可以根据返回值在程序中开启相应的功能或界面。
  • 同样,使用完毕后需要释放字符串缓冲区。

代码示例:

// 更新数据到变量
UpdateData(TRUE);

// 调用登录函数
int loginResult = login_function(m_strUsername.GetBuffer(200), m_strPassword.GetBuffer(200));

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/21ea2d715e714ef665a9d86ba8ccd7ba_41.png)

// 释放缓冲区
m_strUsername.ReleaseBuffer();
m_strPassword.ReleaseBuffer();

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/21ea2d715e714ef665a9d86ba8ccd7ba_42.png)

if(loginResult == 0) {
    // 登录成功,执行后续操作
}

务必检查按钮消息映射的函数名是否正确,避免因函数名冲突导致执行错误的代码。


功能测试与流程整合 🧪

现在,我们将注册、充值、登录三个功能串联起来进行完整测试。

完整的用户流程如下:

  1. 注册:新用户输入用户名和密码,点击注册按钮创建账户。
  2. 充值:用户获得充值卡号,在界面输入卡号和自己的用户名,点击充值按钮为账户激活。
  3. 登录:账户激活后,用户输入用户名和密码,点击登录按钮即可成功登录并使用软件。

在测试过程中,请注意每一步的反馈信息,并确保后台管理系统中的数据同步更新。


总结 📚

本节课中我们一起学习了微分注册系统中三个核心功能的实现:用户注册、充值激活和账户登录。

我们掌握了以下关键点:

  • 使用 the resist 函数实现注册,并理解其各项参数。
  • 使用 a t t t 函数通过卡号为指定用户充值,从而激活账户。
  • 修改并完善登录逻辑,使其能够使用动态输入的用户信息。
  • 理解了注册、充值、登录这一完整的用户生命周期流程。

此外,还应注意代码中内存的管理(及时释放缓冲区)和界面控件消息映射的准确性。通过本节课的学习,你已经能够构建一个具备完整用户管理流程的软件基础模块。

课程 P198:在线注册控制 - 连接检测、有效检测、修改密码、解绑与释放 🔌

在本节课中,我们将学习微正平台提供的几个核心API接口函数。这些函数主要用于管理用户与服务器的连接状态、验证有效性、修改密码、解绑设备以及在程序退出时进行资源释放。

上一节我们介绍了在线注册的基本框架,本节中我们来看看如何具体使用这些控制函数。

1. 连接检测函数

第一个函数用于检测客户端是否与微正服务器建立了有效连接。

以下是该函数的使用要点:

  • 函数会返回一个整数值。
  • 如果已成功连接到服务器,则返回一个非零真值
  • 如果未连接到服务器,则返回数字 0

因此,在代码中可以直接使用条件语句(如 if)来判断返回值。

// 假设函数名为 CheckConnection
int connStatus = CheckConnection();
if (connStatus) {
    // 已连接
} else {
    // 未连接
}

通常,在程序初始化成功后,只要网络正常,调用此函数都会返回真值,除非后续网络断开。

2. 有效检测函数

第二个函数用于检测当前用户的登录状态是否有效,即用户名和密码是否通过了验证。

以下是该函数的使用要点:

  • 如果用户验证成功(即登录有效),函数返回一个非零真值
  • 如果验证失败或登录无效,函数返回数字 0
// 假设函数名为 CheckValidation
int validStatus = CheckValidation();
if (validStatus) {
    // 用户登录有效
} else {
    // 用户登录无效或未登录
}

此函数需要在用户执行登录操作后调用,才能准确反映验证状态。

3. 解绑函数

第三个函数用于解绑当前设备。此功能仅在创建用户时设置了“绑定机器”属性(例如,限定只能绑定一台电脑)时才有效。

以下是该函数的使用要点:

  • 调用此函数需要用户处于有效的登录状态
  • 函数返回值为 0 表示解绑成功,其他值表示失败。
  • 解绑成功后,该账号就可以在其他电脑上登录使用。
// 假设函数名为 UnbindDevice
int unbindResult = UnbindDevice();
if (unbindResult == 0) {
    // 解绑成功
} else {
    // 解绑失败
}

如果账号未解绑就在新设备上登录,原设备上的登录会被强制下线或无法在新设备登录。

4. 修改密码函数

第四个函数用于修改当前登录用户的密码,这是一个常用功能。

以下是该函数的使用要点:

  • 调用此函数需要用户处于有效的登录状态
  • 函数需要两个参数:旧密码和新密码。
  • 修改成功后,新密码会更新到服务器,但客户端可能需要重启或重新登录才能同步新密码数据。

在具体实现时,通常会弹出一个独立的密码修改窗口,包含两个输入框(旧密码、新密码)和确认按钮。

// 假设函数名为 ChangePassword
int changeResult = ChangePassword(oldPassword, newPassword);
if (changeResult == 0) {
    // 密码修改成功
} else {
    // 密码修改失败
}

5. 释放函数

最后一个函数用于在程序退出时进行清理工作,例如停止微正服务、释放动态链接库资源等。

以下是该函数的使用要点:

  • 通常在关闭游戏进程或退出应用程序前调用。
  • 建议在应用程序类(如 CWinApp)的重写退出函数(如 ExitInstance)中添加此调用,以确保资源被正确释放。

// 假设函数名为 Cleanup
// 在程序退出函数中调用
int Cleanup();

本节课中我们一起学习了微正在线注册控制的五个关键API函数:连接检测、有效检测、解绑设备、修改密码和资源释放。理解并正确使用这些函数,是构建稳定、可控的软件授权系统的关键。在后续课程中,我们将探讨其他如扣除点数、时间等更高级的接口函数。

课程 P199:216-过游戏保护分析-多开分析 🔍

在本节课中,我们将学习如何分析并绕过《热血江湖》游戏的多开保护机制。我们将重点分析其保护动态链接库的工作原理,并探讨一种比修改整个客户端目录更简便的实现多开的方法。

概述 📋

上一节我们介绍了通过修改客户端目录来实现多开的方法,但该方法较为繁琐。本节中,我们将分析游戏内置的动态链接库保护机制,并寻找更高效的绕过方案。

保护机制分析 🛡️

《热血江湖》游戏的多开及密码保护等功能,主要通过一个名为 YTP 的动态链接库实现。

以下是该动态链接库中的几个关键函数:

  • 保护初始化
  • 开始保护
  • 停止保护
  • 输入密码

我们的目标是理解这些函数的原理,特别是“开始保护”函数如何阻止多开。

“开始保护”函数原理

“开始保护”函数的第一个动作是通过窗口标题来查找游戏窗口。

// 伪代码逻辑
HWND hWnd = FindWindow(NULL, “预期窗口标题”);
if (hWnd == NULL) {
    // 未找到窗口,执行退出
    ExitProcess();
} else {
    // 找到窗口,继续执行保护逻辑
    // ...
}

如果未找到预期的窗口标题,游戏进程将直接退出。这意味着直接修改窗口标题会导致保护生效,游戏无法运行。

找到窗口后,函数会设置一个调试标志,并通过 SetWindowsHookEx 函数安装多个全局钩子。

// 安装钩子的示例
HHOOK hHook = SetWindowsHookEx(WH_DEBUG, HookProc, hMod, 0);
// 还会安装其他类型的钩子,如键盘钩子(类型可能为13)

游戏通过安装这些全局钩子(如调试钩子、键盘钩子)来监控和限制多开。我们之前遇到的卡OD(调试器)情况,也源于其对全局钩子的操作。

“停止保护”函数则负责卸载这些钩子。但经过测试,直接调用该函数停止保护并不可行。

新的多开思路 💡

既然保护机制依赖窗口标题来查找和确认“唯一”的游戏实例,我们的新思路是:让每个游戏实例拥有独立且能被保护机制正确识别的窗口标题。

具体步骤如下:

  1. 修改窗口标题:首先修改游戏客户端的窗口标题,使其不再是固定值。
  2. 修补动态链接库:在动态链接库中,找到存储或比较窗口标题的地址(例如 0x120E-setAFC 相关地址),将其内容修改为我们为每个实例设定的独立标题。这样,每个游戏进程的保护机制都会找到“自己的”窗口,从而实现多开。

关键地址示例(需在动态链接库中定位):

  • 标题存储/比较地址0x???????? (例如原内容为“热血江湖”)
  • 修改为:每个实例独立的标题,如“热血江湖_01”、“热血江湖_02”。

动态链接库的加载问题 🔄

仅仅修改内存中的标题还不够。游戏会加载 YBMen 这个动态链接库文件来实现保护。为了让多个实例加载不同的“副本”,我们需要:

  1. 为每个游戏实例准备一个 YBMen 文件的副本(如 YBMen_01.dll, YBMen_02.dll)。
  2. 通过代码,在游戏启动时拦截其加载动态链接库的请求,将其指向对应的副本文件。

以下是该思路的核心代码逻辑概述:

// 伪代码:拦截LoadLibrary,重定向到副本
HMODULE WINAPI MyLoadLibrary(LPCSTR lpLibFileName) {
    if (strstr(lpLibFileName, “YBMen.dll”)) {
        // 生成或获取当前实例对应的副本文件名,例如 “YBMen_%d.dll”
        char newPath[MAX_PATH];
        sprintf(newPath, “YBMen_%d.dll”, GetCurrentInstanceId());
        // 加载副本文件
        return OriginalLoadLibrary(newPath);
    }
    return OriginalLoadLibrary(lpLibFileName);
}

这种方法无需复制整个庞大的客户端目录,只需管理多个小的DLL副本文件,节省了大量硬盘空间。

实施与测试 🧪

根据以上分析,实施多开的操作流程如下:

  1. 定位动态链接库中关键的窗口标题地址。
  2. 编写注入代码,在游戏运行时修改该地址的值为唯一标题。
  3. 准备多个 YBMen.dll 的副本文件。
  4. 通过钩子或补丁,将游戏对 YBMen.dll 的加载请求重定向到对应的副本文件。
  5. 启动多个游戏客户端进行测试。

测试时,可以观察到每个游戏实例的窗口标题已变为独立名称,并且游戏能正常运行,表明多开成功。

总结 📝

本节课我们一起学习了《热血江湖》游戏多开保护的分析与绕过方法。

我们首先分析了其保护动态链接库的关键函数,发现它通过窗口标题查找全局钩子来阻止多开。随后,我们提出了新的解决方案:通过修改内存中的窗口标题重定向动态链接库加载路径,使每个游戏实例在保护机制眼中都是“唯一”的,从而实现了简便的多开。

下一节课,我们将基于本节课分析得到的关键地址和数据,动手编写具体的实现代码。

课程 P2-013:使用背包中指定格子的物品 📦➡️🎮

在本节课中,我们将学习如何通过逆向分析,找到并调用游戏内使用背包中任意一格物品的函数。我们将分析关键参数 ecx 的来源,并最终整理出一个可以指定物品格子的使用逻辑。


分析关键参数 ecx 的来源

上一节我们分析了使用第一个物品(金创药)的调用。本节中,我们来看看如果要使用其他格子的物品,关键参数 ecx 是如何确定的。

首先,我们回到 OD 中已定位到的函数代码处。

在此处下断点,然后向前追溯 ecx 的来源。我们发现 ecx 来源于 edi

继续向上追溯 edi 的来源。这个调用封装的功能较多,范围较大。我们可以使用 CE 来辅助查找。

在 CE 中,我们观察到 edi 来源于上级调用的 ecx。我们在调用来源处下断点并查看。

当我们在游戏中右键点击不同格子的物品时,观察传入的值:

  • 点击第 8 格物品时,参数值为 7
  • 点击第 6 格物品时,参数值为 5
  • 点击第 4 格物品时,参数值为 3

由此可见,这个调用并非固定使用金创药。其关键参数是一个下标索引(从0开始),对应背包中的物品格子位置。

通过进一步分析,我们找到了直接给出该下标值的汇编指令位置。结合之前第 11 课分析的物品属性公式,我们现在可以确定使用物品的完整逻辑。


构建使用物品的函数

结合今天分析的调用和之前获得的物品属性,我们可以编写一个函数。这个函数可以根据物品名称找到其所在背包的下标,然后调用使用函数。

以下是核心逻辑的伪代码描述:

// 假设:FindItemIndex 函数能根据物品名返回其在背包中的下标(从0开始)
// 假设:UseItemCall 是分析出的使用物品的调用函数
int itemIndex = FindItemIndex(“金创药(中)”); // 例如返回 1(第二格)
if (itemIndex != -1) {
    UseItemCall(itemIndex, 0); // 第二个参数固定为0
}

功能测试

现在,我们来测试这个逻辑。我们将不同的下标值传入调用。

以下是测试过程与结果:

  • 传入下标 0(第一格,人参):使用后,人参数量从 51 减少。
  • 传入下标 1(第二格,金创药(小)):使用成功,观察到变化。
  • 传入下标 2(第三格):使用成功。
  • 传入下标 3(第四格):使用成功(恢复血量)。
  • 传入下标 4(第五格,回城卷):使用后,角色执行回城。

测试表明,通过控制下标参数,我们可以成功使用背包中任意指定格子的物品。


下节预告与思考

本节课我们一起分析了使用指定格子物品的调用机制和关键参数。

下一节课,我们将对这个功能进行封装,编写一个完整的 C++ 函数。该函数可以接收物品名称,自动查找下标并调用使用。

大家可以提前思考如何实现 FindItemIndex 函数,其逻辑是:遍历背包,比对物品名,若存在则返回其下标。


总结

在本节课中,我们深入分析了游戏内使用背包物品的调用,明确了控制物品格子的关键参数是下标索引。我们通过测试验证了该逻辑的可行性,为下一步封装成易用的脚本函数打下了基础。

课程 P20:031-封装技能列表与选中技能 📚

在本节课中,我们将学习如何对游戏中的技能列表和技能对象进行代码封装。我们将通过分析内存结构,定义数据结构,并编写代码来读取和初始化技能数据,最终实现一个可用的技能列表封装模块。


一、添加技能列表基址与结构体 🏗️

上一节我们介绍了课程目标,本节中我们来看看具体的实现步骤。首先,我们需要在基址单元中添加技能列表的基址。

接着,我们在结构体单元中添加技能对象的结构体。该结构体包含以下属性:

以下是技能对象结构体的关键属性:

  • 分类:技能的分类标识。
  • 数组下标:该技能在所有对象数组中的索引。
  • 对象名字:技能的名称。
  • 是否已学习:一个布尔值,表示该技能是否已被角色学习。注意,该属性在内存中只占2个字节,初始化读取时需特别注意。

除了上述属性,我们还需要为技能对象添加一个属性,即它在技能列表内部数组中的下标。

保存结构体定义后,我们接下来需要定义相关的技能数据数组。


二、定义并初始化技能数据数组 🔢

我们定义了技能数据数组。通过分析,我们得知该数组的大小为32,每个元素的大小为sizeof(TSkill)

接下来,我们需要对这个数组进行初始化。我们转到设计单元,在最后部分编写初始化代码。

初始化时,我们需要读取内存中的相关数据。以下是读取和初始化技能数据的核心步骤:

  1. 获取数组基址:首先,我们读取技能列表数组的基地址。
    // 伪代码示例:读取基址
    DWORD baseAddress = ReadMemory<DWORD>(skillListBase);
    
  2. 计算元素地址:基址加上固定偏移量0x410后,得到技能对象数组的起始地址。我们需要将其转换为适当的指针类型。
    // 伪代码示例:计算数组指针
    TSkill* skillArray = (TSkill*)(baseAddress + 0x410);
    
  3. 遍历并填充数据:我们循环遍历数组(大小为32),对每个有效的技能槽位进行数据填充。
    • 下标:直接赋值为循环索引 i
    • 分类:读取 技能对象基址 + 0x08 处的值。
    • 数组下标:读取 技能对象基址 + 0x0C 处的值。
    • 名字:读取 技能对象基址 + 0x10 处的指针(该地址直接指向字符串)。
    • 是否可用:读取 技能对象基址 + 0x16 处的 WORD(2字节)类型数据。注意:必须按2字节读取,否则会得到错误数据。

在读取过程中,我们需要进行两项关键判断,以过滤无效数据:

  • 空对象判断:如果读取到的技能对象基址为空,则跳过当前循环。
  • 类型过滤:技能对象的类型标识应为 0x1C。如果读取到的类型(位于对象基址偏移处)不等于 0x1C(例如是 0x1B),我们也跳过该对象。

在开始填充数据前,我们需要对整个技能对象数组进行初始化,例如使用 ZeroMemory 函数将其清零。


三、打印技能信息与代码优化 🖨️

初始化代码编写完成后,我们添加一段代码来打印技能信息,以便验证数据是否正确。

我们使用调试信息输出函数来打印每个技能对象的以下属性:

  • 名字
  • 在技能列表中的下标
  • 类型
  • 在所有对象数组中的ID
  • 是否可用

在打印前,我们最好对调试输出系统进行一次性初始化。之后,我们可以直接使用全局变量来调用打印功能,而无需每次都指定前缀。

为了代码清晰和易于管理,我们将技能数据数组定义为一个全局变量,并为其添加 g_ 前缀以符合命名规范。

编译过程中,需要注意变量名的大小写匹配,避免因大小写不一致导致的编译错误。


四、测试与验证 ✅

我们将测试代码添加到主线程或测试单元中。在调用打印函数前,确保调试系统已初始化。

运行测试后,控制台会打印出技能信息。我们观察到,打印出的技能列表中,有些技能显示为“可用”,有些显示为“不可用”,这与游戏内的实际情况相符。同时,类型为 0x1B 的对象已被成功过滤掉,没有出现在列表中。

如果发现数据异常,例如所有技能都打印不出来,需要检查代码逻辑,特别是读取“类型”属性的偏移量是否正确(应为 对象基址 + 0x08)。

测试通过后,我们的技能列表封装就完成了。现在,我们可以通过这个封装好的模块,方便地判断哪些技能是可用的,为后续的技能调用逻辑打下基础。


五、课程总结与作业 📝

本节课中,我们一起学习了如何封装游戏的技能列表和技能对象。我们完成了从添加基址、定义结构体、读取并初始化内存数据,到打印验证的完整流程。

通过本次封装,我们获得了一个结构化的技能数据集合,可以轻松查询技能的各类属性和可用状态。

本节课的作业是:尝试分析并实现“将技能拖放到快捷栏(F1-F6)”的功能。在游戏中,技能通常需要放置在快捷栏上才能直接使用。请大家课后思考和分析,如何通过代码或内存操作,实现将技能列表中的某个技能“移动”到快捷栏指定位置的过程。这涉及到对快捷栏数据结构的分析以及可能的函数调用。


注意:本教程所有代码示例均为教学演示用途,旨在讲解编程思路和内存操作原理。实际应用需遵守相关软件的用户协议及法律法规。

课程P200:游戏多开补丁编写教程 🎮

在本节课中,我们将学习如何编写一个动态链接库(DLL)补丁,以实现特定游戏的多开功能。我们将基于上一节课分析的数据,一步步编写代码,并解释其核心原理。


概述 📋

上一节课我们探讨了游戏多开的另一种原理。本节课我们将利用分析得到的数据,编写相应的代码来实现多开功能。核心步骤包括:关闭登录器、复制保护DLL、修改内存数据以及设置新窗口标题。


第一步:创建项目与基础函数 🛠️

首先,我们需要在Visual Studio 2010中创建一个动态链接库项目。选择“动态链接库”并勾选“导出符号”选项。

接下来,我们添加一个新的源文件和头文件。主要需要编写以下几个函数:

  1. 关闭登录器进程的函数。
  2. 复制保护DLL生成临时文件的函数。
  3. 初始化全局变量和分配“座位”的函数。
  4. 设置游戏新窗口标题的函数。
  5. 修改内存中特定字符串地址的函数。

以下是实现这些功能的核心代码框架:

// 示例:关闭登录器进程的核心逻辑
HWND hWnd = FindWindow(NULL, "登录器窗口标题");
if (hWnd) {
    DWORD pid;
    GetWindowThreadProcessId(hWnd, &pid);
    HANDLE hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, pid);
    if (hProcess) {
        TerminateProcess(hProcess, 0);
        CloseHandle(hProcess);
    }
}

第二步:关闭登录器进程 🔒

要实现多开,必须关闭已存在的登录器,否则新的登录器无法启动。我们通过窗口标题找到登录器进程,并终止它。

以下是关闭登录器的具体步骤:

  • 查找窗口:使用 FindWindow 函数,通过已知的窗口标题找到登录器窗口句柄。
  • 获取进程ID:通过窗口句柄,使用 GetWindowThreadProcessId 函数获取该窗口所属的进程ID。
  • 结束进程:使用获取到的进程ID,通过 OpenProcessTerminateProcess 函数强制结束该进程。

第三步:复制与重命名保护DLL 📂

游戏客户端会加载一个特定的保护DLL(例如 ygd.dll)。为了实现多开,我们需要为每个游戏进程复制并重命名这个DLL,让每个进程加载不同的副本。

以下是实现此功能的关键点:

  • 复制文件:使用 CopyFile 函数将原始DLL复制到一个临时位置。
  • 动态命名:为每个进程的DLL副本生成唯一的名字(例如 ygd01.dll, ygd02.dll)。
  • 座位管理:使用一个共享数据节(#pragma data_seg)来管理“座位”数组,标记哪些名字已被占用,哪些空闲,以实现名字的循环复用。
// 示例:使用共享节管理“座位”
#pragma data_seg(".shared")
int seat_map[255] = {0}; // 0表示空闲,1表示占用
#pragma data_seg()

第四步:修改内存与窗口标题 🖊️

这是补丁的核心部分。我们需要修改游戏客户端内存中两处关键数据:

  1. 修改DLL加载路径:找到内存中存储原始DLL名字(如ygd.dll)的地址,将其修改为我们新生成的唯一名字(如郁金香01.dll)。
  2. 修改窗口标题:游戏保护会通过窗口标题来检测多开。我们需要将游戏窗口的标题修改为包含唯一标识的新标题(如游戏窗口_01),并同步更新内存中用于检测的标题字符串。

以下是修改内存地址的示例:

// 示例:修改内存中的字符串
DWORD baseAddr = (DWORD)GetModuleHandle(NULL) + 0x73121C; // 基址+偏移
char newTitle[] = "游戏窗口_01";
WriteProcessMemory(GetCurrentProcess(), (LPVOID)baseAddr, newTitle, strlen(newTitle)+1, NULL);

注意:在设置新窗口标题前,可能需要一个循环来等待游戏窗口创建完成。


第五步:整合与线程执行 🧵

我们将所有步骤整合到一个主函数(例如 StartMultiOpen)中。为了让补丁在游戏启动时自动运行,我们需要将这个函数的执行放在一个单独的线程中,并将该线程的启动代码放在DLL的入口函数(DllMain)中。

// 示例:在DllMain中创建线程
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
    if (ul_reason_for_call == DLL_PROCESS_ATTACH) {
        CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)StartMultiOpen, NULL, 0, NULL);
    }
    return TRUE;
}


总结 📝

本节课我们一起学习了编写游戏多开补丁的完整流程:

  1. 分析原理:理解了通过修改DLL加载路径和窗口标题来绕过单实例检测的原理。
  2. 关闭登录器:终止已存在的登录器进程,为新实例腾出空间。
  3. 复制DLL:为每个游戏进程创建唯一的保护DLL副本。
  4. 修改内存:动态修改游戏内存中的关键字符串,包括DLL名和窗口标题。
  5. 整合执行:将所有功能集成到DLL中,并通过独立线程在游戏启动时自动执行。

通过实现这些步骤,我们成功制作了一个可以让游戏客户端多开的补丁程序。掌握这些知识有助于深入理解Windows进程、内存管理和DLL注入等技术。

课程 P21:032 - 放置技能动作分析 🎮

在本节课中,我们将学习如何通过逆向分析,找到将选中的技能对象放置到游戏快捷栏(技能栏)中的关键代码和逻辑。我们将从选中的技能对象入手,逐步分析其写入快捷栏数组的过程,并最终通过代码注入实现自动化放置。


概述

前面的课程中,我们分析了一个技能数组和一个用于显示选中技能的对象数据。本节课的目标是分析如何将选中的技能对象写入到游戏下方的快捷栏中。我们将通过调试工具定位关键的内存写入操作,分析相关函数的参数,并最终用代码实现技能放置功能。


从选中对象入手

上一节我们介绍了技能数组和选中对象的数据结构。本节中,我们来看看如何从选中的技能对象开始分析。

既然我们的目标是将选中的对象写入快捷栏,那么首先需要读取当前选中的对象。通过分析,我们得知选中对象的地址偏移是 0x228

我们使用工具(如CE)添加一个指针,指向该偏移地址,以读取选中对象的数值。

// 假设基址为 BaseAddress
DWORD selectedSkill = *(DWORD*)(BaseAddress + 0x228);

当我们尝试将技能放入快捷栏时,这个选中对象的地址会被使用,然后该地址的数据会被清零。这表明,在执行完某个操作(函数调用)后,程序会对这个地址执行清零动作。


定位清零操作

为了找到放置技能的关键代码,我们需要先找到是什么指令对选中对象的地址进行了清零。

我们记下选中对象的地址(例如 0x6121380),然后使用调试器附加游戏进程,设置内存写入断点。当我们执行放置技能操作时,调试器会中断在写入该地址的指令处。

通过分析,我们发现执行写入的指令地址是 0xXXXXXX(具体地址需在动态调试中获取)。这个地址很可能就在执行放置技能功能的函数内部,或者在其附近。


分析关键函数

我们使用OD(OllyDbg)转到清零操作发生的地址。既然程序在这里清零,说明放置技能的动作很可能已经在此前完成。因此,我们需要向上回溯代码,寻找执行放置操作的函数。

在回溯过程中,我们注意到一些调试信息字符串,例如“放置错误”、“拖动放置”等。这些字符串提示我们可能已经进入了负责处理技能放置的函数内部。

我们在疑似函数入口处设置断点,然后再次执行放置技能操作。当断点触发后,我们单步执行并观察寄存器和堆栈的变化。


确定函数参数

通过多次在不同技能栏格子放置技能,我们观察到一个规律:寄存器 EBX 的值与技能栏格子的下标(从0开始)一致。例如,放在第一格时 EBX=0,放在第七格时 EBX=6

这强烈表明,EBX 是传递给该函数的一个参数,用于指定技能放置的目标格子索引。

此外,我们还发现另外两个重要的参数:

  1. 一个参数来源于一个固定的基址加上偏移 0x168
  2. 另一个参数来源于另一个固定的基址。

我们可以将这些参数整理如下:

// 假设分析得到的基址
DWORD base1 = 0xXXXXXXX; // 来源一
DWORD base2 = 0xYYYYYYY; // 来源二

// 函数参数可能类似这样:
DWORD param1 = *(DWORD*)(base1 + 0x168);
DWORD param2 = *(DWORD*)(base2);
int targetSlotIndex = ebxValue; // 目标格子下标

同时,EDI 寄存器的值也被证实是选中技能对象的地址。


代码注入测试

在明确了关键函数地址和参数后,我们可以尝试使用代码注入器进行测试。

以下是使用汇编代码调用该函数的示例:

; 设置参数
MOV ECX, [param1] ; 参数一
MOV EDX, [param2] ; 参数二
MOV EBX, [targetSlotIndex] ; 参数三:目标格子下标
MOV EDI, [selectedSkillAddr] ; 选中技能对象的地址

; 调用放置技能的函数
PUSH EBX
PUSH EDX
PUSH ECX
CALL 0xAddressOfTheFunction ; 替换为实际的函数地址
ADD ESP, 0xC ; 平衡堆栈

测试前,需要确保已选中一个技能对象。注入代码后,观察技能是否被成功放置到指定的快捷栏格子中。


实现自动化放置

通过上述分析,我们可以绕过“选中”这一步,直接从技能列表中获取技能对象并放置。

假设技能列表的基址是 0x319C90C,它是一个数组。我们可以通过下标直接获取技能对象。

DWORD skillListBase = 0x319C90C;
int skillIndex = 1; // 想获取的技能在列表中的索引
DWORD skillObject = *(DWORD*)(skillListBase + 0x410 + skillIndex * 4);

然后,我们将这个对象地址写入到“选中对象”的地址(基址+0x228),再调用我们之前分析的放置函数。

// 写入选中对象
*(DWORD*)(BaseAddress + 0x228) = skillObject;

// 然后调用放置函数(参数需提前准备好)
// ... 调用汇编代码或封装成的函数 ...

经过测试,这种方法可以成功将指定列表中的技能直接放置到快捷栏的指定格子。


封装与作业

本节课我们一起学习了定位和分析放置技能功能的全过程。下一节课,我们将把这个过程封装成更易用的C++代码。

以下是本节课的作业:

请将我们分析的放置逻辑封装成一个函数。函数原型可以参考以下格式:

bool PlaceSkillToSlot(const char* skillName, int slotIndex);

或者

bool PlaceSkillToSlot(int skillListIndex, int slotIndex);

函数功能是:将指定的技能(通过技能名或技能列表索引)放置到快捷栏的指定下标格子中。例如,调用 PlaceSkillToSlot("疾风烈火", 0) 将把“疾风烈火”技能放到快捷栏第一格。


总结

在本节课中,我们通过逆向分析,完成了以下内容:

  1. 从选中技能对象的内存地址入手。
  2. 利用调试器定位了清零操作和关键的技能放置函数。
  3. 分析了该函数所需的参数,包括目标格子下标和两个来源固定的数据。
  4. 通过代码注入测试验证了分析结果。
  5. 提出了绕过选中步骤、直接从技能列表放置技能的自动化方案。
  6. 布置了将逻辑封装成函数的作业,为后续的代码集成做准备。

通过这一系列步骤,我们掌握了在游戏中自动化管理技能栏的基本方法。下节课我们将实现代码封装,敬请期待。

课程 P22:033-封装放置技能代码 🧩

在本节课中,我们将学习如何将上一节课分析的放置技能功能,封装成可复用的C语言代码。我们将创建一个函数,能够根据技能名称,将指定的技能对象放置到游戏界面的技能快捷栏(F1-F10)中。


上一节我们分析了放置技能的功能逻辑,本节中我们来看看如何将这些逻辑转化为结构化的代码。

首先,我们需要添加一个新的基址。这个基址用于处理选中技能对象。经过测试,我们发现该基址不仅在选中技能时有效,在选中背包中的物品或装备时也会被写入对象地址。因此,它并非技能专属,而是用于处理选中的物品或技能对象。它不能用于选中玩家自身或怪物(选中怪物使用数组下标,而物品和技能使用对象地址)。

以下是添加基址的步骤:

  1. 展开基址单元。
  2. 添加用于选中物品/技能的基址。

接下来,我们开始编写功能代码。我们将上节课分析的汇编代码转化为C语言函数。

这个函数应该放置在技能列表相关的结构单元中。我们计划将其放在技能对象处理模块(F1-F10区域)。

函数需要两个参数:

  • 技能名称:用于查找对应的技能对象。
  • 放置下标:指定将技能放置到F1-F10中的哪一个格子(例如,0代表F1,1代表F2,依此类推)。

函数的核心逻辑如下:

  1. 查找技能对象:根据传入的技能名称,遍历技能列表,找到匹配的技能对象。
  2. 写入选中地址:将找到的技能对象地址,写入到特定的偏移地址(基址 + 0x288)中,以完成“选中”操作。
  3. 调用放置功能:模拟调用游戏内部的放置功能,将选中的技能放置到指定的快捷栏格子里。

在实现查找时,为了更方便地获取技能对象,我们在技能对象结构体中添加了一个新属性,用于直接返回其对象指针,避免了通过下标计算的复杂性。

以下是代码实现的关键部分(伪代码/注释形式):

// 函数:放置技能到快捷栏
// 参数:skillName - 技能名称, index - 快捷栏下标(0-9)
BOOL PlaceSkillToHotbar(const char* skillName, int index) {
    // 1. 初始化技能列表指针
    SkillList* pList = GetSkillListBase();
    if (!pList) return FALSE;

    // 2. 遍历技能列表,查找名称匹配的技能
    SkillObject* pTargetSkill = NULL;
    for (int i = 0; i < pList->count; ++i) {
        SkillObject* pSkill = pList->skills[i];
        if (!pSkill || !pSkill->id) continue; // 跳过无效项
        if (strcmp(pSkill->name, skillName) == 0) {
            pTargetSkill = pSkill;
            break;
        }
    }

    // 3. 如果未找到技能,返回失败
    if (!pTargetSkill) {
        LogDebug("未找到技能:%s", skillName);
        return FALSE;
    }

    // 4. 将技能对象地址写入“选中”基址(基址 + 0x288)
    DWORD selectAddr = GetSelectObjectBase() + 0x288;
    *(DWORD*)selectAddr = (DWORD)pTargetSkill;

    // 5. 内联汇编:调用游戏内部的放置功能
    __asm {
        mov edi, pTargetSkill      // 将技能对象放入edi
        mov ecx, index             // 将放置下标放入ecx
        push ecx                   // 参数压栈
        call [放置功能Call地址]     // 调用放置功能
    }

    // 6. 返回成功
    LogDebug("成功放置技能【%s】到位置F%d", skillName, index + 1);
    return TRUE;
}

在编写过程中,需要注意以下几点:

  • 汇编调用:调用游戏功能需要使用内联汇编,并确保参数正确传递(如将下标放入ecx寄存器并压栈)。
  • 错误处理:在遍历列表时,必须跳过无效(为空)的技能项,否则可能导致访问异常。
  • 字符串比较:比较技能名称时需要使用strcmp等函数,并确保字符串以空字符结尾。

代码编写完成后,我们进行测试。例如,调用 PlaceSkillToHotbar("疾风残影", 2) 将“疾风残影”放置到第三个技能格(F3)。

首次测试出现了异常,原因是遍历技能列表时未过滤空对象。添加空值判断后,问题解决。

后续测试成功将“疾风残影”、“疾风烈火”等技能放置到了指定的快捷栏位置,验证了封装代码的功能正确性。


本节课中我们一起学习了如何封装放置技能的代码。我们实现了一个函数,能够根据技能名称查找对象,并通过写入内存和调用游戏内部函数的方式,将技能成功放置到快捷栏。这个过程涉及了基址操作、内存读写、汇编调用和基本的错误处理。

下节课,我们将继续分析F1-F10技能快捷栏的其他功能,以及技能的使用(攻击)功能。请大家可以提前尝试分析技能使用的相关调用。

逆向教程 P23:034-快捷栏分析与技能调用 🎮

在本节课中,我们将学习如何分析游戏快捷栏的数据结构,并最终定位到调用技能的关键代码。整个过程将涉及内存扫描、数据结构分析、断点调试与代码注入等逆向工程基础技术。

概述:快捷栏数据结构分析

上一节我们介绍了游戏数据查找的基本思路。本节中,我们来看看游戏快捷栏的数据是如何组织的。游戏技能需要放置在快捷栏上,通过点击鼠标右键或按下F1-F10功能键来调用。因此,使用技能时,程序必定会访问快捷栏中对应“格子”的数据。这些数据很可能是一个数组,类似于背包或技能数组。

以下是分析快捷栏数据的基本步骤:

  1. 寻找F1格子数据:使用CE(Cheat Engine)附加游戏进程,搜索F1格子中“物品对象”的地址。通过反复“放入物品”和“移开物品”操作,配合“变动的数值”与“未变动的数值”扫描,可以逐步缩小地址范围。
  2. 分析数据结构:找到的地址值通常是一个指针(以0x开头的十六进制数)。对其下“访问该地址的代码”断点,可以找到读取该数据的代码片段。
  3. 定位数组基址:在访问代码中,通常会看到一个基址加偏移的寻址模式,例如 [模块基址+固定偏移+索引*4]。其中的模块基址+固定偏移就是快捷栏数组的基址。
  4. 验证数据结构:使用调试器查看该基址开始的内存,确认其是否为存放着一系列指针的数组,每个指针指向一个技能或物品对象。

通过以上步骤,我们找到了快捷栏数组的基址。接下来,我们需要了解数组中每个元素(即对象指针)所指向的数据结构。

技能对象结构解析

在找到快捷栏数组后,我们查看其内容。前几个元素存放着技能对象的地址。

以下是技能对象的关键属性偏移:

  • 对象名称偏移:通过尝试,我们发现技能名称存储在对象指针指向地址再偏移 +0x50 的位置。
  • 对象类型标识:在对象指针指向地址偏移 +0xC 的位置,可能存放着标识对象类型的值(例如,技能、物品等)。

快捷栏不仅可以存放技能,也可以存放背包中的物品。我们通过修改并查看不同下标对应的内存数据,验证了数组寻址公式的正确性:数组基址 + 下标 * 4 存放着对象指针。

定位技能调用函数 🎯

上一节我们分析了快捷栏的数据存储方式,本节中我们来看看游戏是如何调用这些技能的。我们的目标是找到按下快捷键(如F1)时,最终执行技能效果的函数。

以下是定位关键调用函数的步骤:

  1. 对技能对象下访问断点:在调试器中,对快捷栏中某个技能对象的地址下“内存访问断点”。
  2. 触发断点:返回游戏,按下该技能对应的快捷键(如F1)。由于游戏界面绘制也会读取技能数据,断点可能会频繁触发。我们需要关注在按下快捷键瞬间新出现的、且只执行一次的访问指令。
  3. 分析关键代码区域:记录下这些关键的访问指令地址。它们很可能位于技能调用逻辑的附近。
  4. 回溯调用栈:在这些关键地址上下断点,再次触发技能。当断点命中后,查看调用栈,返回到上层函数。这个上层函数很可能就是处理快捷键输入并准备调用技能的函数。
  5. 验证函数功能:分析该函数的参数(通常传入快捷栏的下标,如F1对应0,F2对应1等)和上下文。通过编写简单的注入代码调用该函数,并观察游戏内技能是否被成功释放,来验证其正确性。

通过此方法,我们定位到了一个核心调用函数(CALL)。该函数需要一个基址(ECX寄存器)和一个下标参数(快捷键索引),其作用就是执行快捷栏中对应位置的技能或物品。

代码封装与调用示例

我们找到了技能调用的关键函数。在实际应用中,我们需要通过代码来调用它。

以下是调用该函数的代码逻辑示例:

// 假设:
// g_QuickBarBase 是快捷栏数组的基址,通过逆向分析得到。
// g_SkillUseCall 是技能调用函数的地址。
// index 是要使用的快捷栏格子下标(0-9对应F1-F10)。

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/685895c595f4cf7cf3a866382e3c9c4b_9.png)

// 1. 获取技能对象指针
DWORD skillObjectPtr = *(DWORD*)(g_QuickBarBase + index * 4);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/685895c595f4cf7cf3a866382e3c9c4b_11.png)

// 2. 检查格子是否为空
if (skillObjectPtr == 0) {
    return; // 格子为空,无法使用
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/685895c595f4cf7cf3a866382e3c9c4b_13.png)

// 3. 准备调用(__stdcall 调用约定示例)
__asm {
    mov ecx, g_QuickBarBase // 设置ECX为基址
    push index             // 按下标参数
    call g_SkillUseCall    // 调用技能使用函数
}

注意g_QuickBarBase(即ECX的值)和g_SkillUseCall(函数地址)需要通过逆向分析动态获取,因为它们在每次游戏更新后可能会变化。此外,调用前务必确保目标格子有有效的技能或物品。

总结与下节预告

本节课中我们一起学习了如何从零开始分析游戏的快捷栏系统。

  1. 数据结构分析:我们通过CE扫描和调试,确定了快捷栏是一个存放对象指针的数组,并找到了其基址。
  2. 对象属性解析:我们探索了技能对象内部的部分属性,如名称和类型标识的存储位置。
  3. 关键函数定位:通过内存断点和调用栈回溯,我们成功定位到了执行技能调用的核心函数。
  4. 调用原理实现:我们了解了该函数的调用方式,并给出了模拟调用的代码框架。

通过本课,你掌握了分析游戏内列表型数据、定位关键功能函数的基本流程。下一节课,我们将以此为基础,对技能相关的操作进行更系统的代码封装,并探讨如何扩展更多自动化功能。

我们下节课再见。

课程 P24:035 - 快捷栏分析与技能使用封装 🛠️

在本节课中,我们将学习如何整理并封装上一节课中分析的技能使用代码。我们将创建一个函数来管理快捷栏技能,并实现通过技能名称来调用技能的功能。


回顾与准备

上一节我们分析了技能的使用机制。本节中,我们将在此基础上进行代码整理和函数封装。

我们打开第33课的代码作为基础。

在此代码基础上,我们进行修改。

首先,我们需要添加快捷栏的相关机制。这包括快捷栏数据数组(F1到F10)和放置技能所需的参数。

以下是放置技能参数的代码片段,它实际上就是我们今天要操作的技能数组。

// 放置技能参数(技能数组)
// 代码位置...

我们可以直接使用这个参数,并为其取一个更合适的名字。然后,在更新基础数据时,更新这个地址。

此外,我们还需要添加技能调用的代码(快捷栏调用函数)。我们将其添加在合适的位置。

添加完成后,我们检查并添加一个 ecx 参数,其偏移地址为 0xF41810。这里需要一个偏移计算公式,我们将其一并加入。

完成这些准备工作后,我们就可以开始封装函数了。


封装快捷栏数据读取函数

首先,我们封装一个函数来读取和处理快捷栏数据。

上一节课中,我们只是将技能放置到了F1到F4的快捷栏上。现在,我们需要完整地封装整个快捷栏(F1到F10)的数据。

快捷栏数组的大小是10。我们还需要定义一个技能栏对象结构体,其中包含对我们有用的属性:技能名技能类型

以下是定义技能栏对象和初始化数组的步骤:

  1. 获取基础数据地址。
  2. 加上数组的起始偏移 0x4100
  3. 循环遍历数组(索引0到9),读取每个技能栏的数据。
  4. 判断读取的值是否为空。如果为空,则跳过;如果不为空,则继续读取技能名和类型。
  5. 将读取到的数据赋值给我们定义的技能栏对象列表。

在复制数据前,需要进行指针类型转换和空值判断。为了方便管理,最好将常用的偏移地址集中管理在一个单元中。

初始化完成后,我们使用 memset 对数组进行清零初始化,并定义好相应的数据类型。


封装使用技能的函数

接下来,我们封装一个通过技能名称来使用技能的函数。

这个函数需要根据传入的技能名,在快捷栏数组中查找对应的下标,因为调用技能的功能函数需要下标作为参数。

查找下标的逻辑可以集成在这个函数内部。步骤如下:

  1. 遍历快捷栏技能数组。
  2. 将数组中每个技能的名字与传入的参数进行比较。
  3. 如果找到相同的名字,则记录其下标。

找到下标后,就可以调用上一节课中分析的技能调用功能。该功能调用涉及以下操作:

  • 将下标值放入 eax 寄存器。
  • 设置 ecxedx 参数。
  • 加上特定的偏移值(例如 0x227C0x374)。
  • 最后进行函数调用 call

为了代码健壮性,在调用核心功能时,应该添加异常处理。如果发生异常,则直接返回失败。

函数封装完成后,我们进行编译。


测试与调试

生成成功后,我们需要将代码注入游戏进行测试。

在测试单元中,我们添加相应的测试代码。例如,获取技能对象,然后调用 UseSkillByName 函数,并传入技能名“疾风烈火”。

首次测试可能不成功。我们需要通过调试信息来排查问题。可能的原因包括:

  • 技能名拼写错误(例如“疾风烈火”写成了“疾风灭火”)。
  • 数组中存在空数据,导致比较时出错或引发异常。
  • 代码没有正确执行到目标函数。

解决方法是在关键位置添加调试输出信息,并确保对数组中的空元素进行了判断和跳过处理。

修正所有问题后,重新编译并注入测试。此时,选中一个怪物,然后调用技能,应该可以成功释放“疾风烈火”。

我们可以再测试另一个技能,如“裂石劈天”,以确保函数的通用性。


课后作业与完善

本节课留下一个作业:完善 UseSkillByName 函数。

当前函数假设要使用的技能已经存在于快捷栏上。但如果技能不在快捷栏上,函数就会失败。

作业要求是:当在快捷栏中找不到传入的技能名,但该技能确实可用时,如何编写代码自动将该技能放置到一个空的快捷栏格子上,然后再调用它?

思路提示:

  1. 遍历快捷栏,找到第一个为空的格子。
  2. 调用之前已实现的“放置技能到快捷栏”函数,将目标技能放入该空位。
  3. 放置成功后,再调用技能使用函数。

这样,UseSkillByName 函数就更加完善和实用了。


总结

本节课中,我们一起学习了以下内容:

  1. 回顾并准备了技能使用的代码基础。
  2. 封装了读取快捷栏数据的函数,定义了技能栏对象结构。
  3. 封装了通过技能名称使用技能的 UseSkillByName 函数,包括查找下标和调用核心功能。
  4. 进行了代码的测试、调试与问题修复。
  5. 提出了进一步完善函数的作业,使其能够处理技能不在快捷栏上的情况。

通过本课的学习,你应该掌握了如何组织代码、封装功能模块,并处理简单的游戏逻辑。

课程 P25:036 - 完善技能调用功能 🛠️

在本节课中,我们将学习如何完善一个游戏中的技能调用功能。核心任务是修改代码,使得当技能不在快捷栏时,能自动将其放置到空位后再使用。

概述

上一节我们介绍了技能调用的基础逻辑。本节中,我们来看看如何完善它,主要解决技能不在快捷栏时需要先放置技能的问题。

回顾与问题分析

在上一期课程中,我们留下了一个作业:完善使用技能的函数。当时的情况是,技能需要存在于技能列表中才能被成功调用。

如果技能不存在于当前快捷栏,我们需要额外编写一段代码,先将该技能移植到快捷栏上。虽然将技能放置到指定格子的函数已经封装好,但我们需要增加判断逻辑。

需要判断快捷栏上是否存在该技能。在放置新技能时,我们需要决定将其放在哪一格:是第一格还是第二格?如果某一格已有技能,我们应将其放在第二个空位,即需要寻找空位置。

代码修改步骤

我们打开第35课的代码,并在机制单元中进行修改。

以下是修改代码的核心步骤:

  1. 添加配置文件:由于清理垃圾文件时可能删除了配置文件,需要重新添加。
  2. 修改放置技能逻辑:在放置技能的函数中,需要传入一个下标参数,用于指示将技能放置在快捷栏的哪个空位。
    • 核心概念是寻找空位。代码逻辑是遍历快捷栏,找到第一个为空的格子索引。
    • 关键代码示意:
      int findEmptySlot() {
          for (int i = 0; i < slotCount; i++) {
              if (skillSlot[i] == EMPTY) {
                  return i; // 返回空位的索引
              }
          }
          return -1; // 没有空位,返回-1
      }
      
  3. 处理技能栏已满的情况:如果findEmptySlot函数返回-1,表示技能栏已满。此时应打印调试信息,并标记技能使用失败。
  4. 整合到技能调用函数:在调用技能的主函数中,先检查技能是否已在快捷栏。如果不在,则调用findEmptySlot获取空位索引,然后调用放置技能的函数。之后,再执行使用技能的操作。

测试与发现的问题

按照上述步骤修改代码后,我们进行测试。使用技能“烈焰披天”,该技能初始并不在快捷栏上。

测试发现,技能被放置到了快捷栏,但并没有立即对怪物生效,需要第二次调用技能时才会成功攻击怪物。

问题分析与临时解决方案

分析表明,问题可能出在时序上。客户端将技能放置到快捷栏的操作需要发送到服务器端处理,这个过程需要一定时间。如果放置后立即调用技能,服务器可能尚未更新状态,导致调用失败。

以下是临时的解决方案:

  1. 连续调用两次:在代码中,放置技能后,立即再次发送使用技能的消息。这相当于手动补偿了服务器处理的延迟。
  2. 添加短暂延迟:在放置技能和使用技能之间,插入一个短暂的等待(例如使用Sleep函数)。这给了服务器处理时间。
    • 关键代码示意:
      placeSkillToSlot(skillName, emptySlotIndex); // 放置技能
      Sleep(100); // 等待100毫秒
      useSkill(skillName); // 使用技能
      

总结

本节课中,我们一起学习了如何完善技能调用功能。

  1. 我们回顾了问题:需要处理技能不在快捷栏的情况。
  2. 我们修改了代码:增加了寻找快捷栏空位并放置技能的逻辑。
  3. 我们进行了测试发现了新问题:由于服务器-客户端通信延迟,放置技能后立即使用可能会失败。
  4. 我们分析了原因并给出了临时解决方案:通过连续调用两次或添加短暂延迟来确保技能使用成功。

这个临时方案揭示了客户端与服务器同步的重要性。在下一节课中,我们将探讨如何更优雅地处理这种异步操作,例如将放置技能和使用技能拆分为两个独立的、可定时触发的操作单元。

课程 P26:037 - 修整完善技能调用 🛠️

在本节课中,我们将学习如何修复和优化游戏技能调用的代码。我们将解决上一节课中遇到的技能放置和使用问题,并通过重构代码使其更健壮、更易于使用。


概述

上一节我们介绍了技能调用的基本方法,但在测试时遇到了一些问题。本节中,我们将通过修改代码来解决这些问题,核心目标是实现一个能自动检测技能栏状态、智能放置并使用技能的函数。


修改放置技能函数

首先,我们需要修改放置技能的代码。之前的函数需要手动指定技能栏下标,现在我们要创建一个同名的新函数,让它能自动寻找空位并放置技能。

以下是修改后的函数逻辑:

  1. 检测技能是否已存在:如果技能已经在快捷栏上,则无需再次放置。
  2. 寻找空位:如果技能不存在,则寻找一个空位置。
  3. 处理栏位已满:如果没有空位,则进行相应处理(如提示或强制放置)。

我们使用 GetIndexForName 函数来检测技能是否存在。如果返回 -1,表示不存在;如果返回大于等于 0 的数值,则表示该技能已存在,其返回值就是技能在栏位中的下标。

function PlaceSkillAuto(SkillName: string): Boolean;
var
  ExistingIndex, EmptyIndex: Integer;
begin
  // 1. 检测技能是否已存在于快捷栏
  ExistingIndex := F1Hotbar.GetIndexForName(SkillName);
  if ExistingIndex >= 0 then
  begin
    // 技能已存在,直接返回
    Result := False;
    Exit;
  end;

  // 2. 寻找空位置
  EmptyIndex := FindEmptySlotInHotbar();
  if EmptyIndex = -1 then
  begin
    // 快捷栏已满,打印调试信息或进行其他处理
    DebugPrint('快捷栏已满');
    Result := False;
    Exit;
  end;

  // 3. 调用带下标参数的放置函数
  Result := PlaceSkillAtIndex(SkillName, EmptyIndex);
end;

在循环查找技能时,一旦找到匹配项,建议使用 Break 语句退出循环,这样可以优化性能,减少不必要的CPU时间占用。


简化技能使用函数

上一节我们介绍了技能调用的基本流程,本节中我们来看看如何简化技能使用的代码。

修改完放置函数后,技能使用函数也可以相应简化。我们不再需要复杂的检测逻辑,直接调用技能即可。

以下是简化后的技能使用函数示例:

procedure UseSkillSimple(SkillName: string);
var
  SkillIndex: Integer;
begin
  SkillIndex := F1Hotbar.GetIndexForName(SkillName);
  if SkillIndex <> -1 then
  begin
    // 直接使用指定下标的技能
    UseSkillByIndex(SkillIndex);
  end;
end;

这样,代码逻辑变得更加清晰和直接。


封装线程安全函数

为了保证代码在多线程环境下的安全性,我们需要将技能操作封装到主线程中执行。以下是具体步骤:

  1. 创建消息类型:定义两种消息类型,分别用于“放置技能”和“使用技能”。
  2. 封装调用函数:创建两个函数(如 PostPlaceSkillPostUseSkill),它们负责将相应的消息投递到主线程的消息队列。
  3. 主线程消息处理:在主线程的 WndProc 或消息循环中,处理这些自定义消息,并调用真正的技能放置或使用函数。

以下是投递使用技能消息的函数示例:

procedure PostUseSkillToMainThread(SkillName: string);
var
  Msg: TCustomMessage;
begin
  Msg.MsgType := MSG_USE_SKILL;
  Msg.Data := SkillName;
  PostMessage(MainWindowHandle, WM_USER_MESSAGE, 0, LPARAM(@Msg));
end;

通过这种方式,无论你在哪个线程调用 PostUseSkillToMainThread,实际的技能使用代码都会在主线程中安全执行。


整合与测试

我们将修改后的放置函数和使用函数整合起来。为了确保技能一定能被使用,我们可以创建一个整合函数:

function EnsureAndUseSkill(SkillName: string): Boolean;
begin
  // 尝试放置技能(内部会检测是否已存在)
  PlaceSkillAuto(SkillName);
  // 使用技能
  UseSkillSimple(SkillName);
  Result := True;
end;

现在进行测试:

  1. 将技能从快捷栏拖走。
  2. 选中一个怪物。
  3. 调用一次 EnsureAndUseSkill 函数。
  4. 观察技能是否被自动放置到快捷栏并成功释放。

经过测试,现在只需要调用一次,即可完成技能的放置和使用,解决了之前需要调用两次的问题。


核心要点总结

本节课中我们一起学习了如何完善技能调用系统,以下是核心要点:

  • 自动检测与放置:通过 GetIndexForName 函数智能判断技能状态,并自动寻找空位放置。
  • 代码简化:优化了技能使用逻辑,使其更直接。
  • 线程安全:通过消息投递机制,确保所有涉及游戏UI的操作都在主线程中执行,这是避免许多潜在问题的关键。公式表示为:游戏UI操作 ⊆ 主线程
  • 函数整合:创建了 EnsureAndUseSkill 这样的高层函数,为后续的自动打怪脚本提供了方便、安全的接口。

区分代码执行环境非常重要:只有通过消息机制投递到主线程消息循环(如 WndProc 中的 case 分支)的代码,才会在主线程中执行。将功能封装成函数,再通过消息调用,是最佳实践。


下节预告

在下一节课中,我们将利用本节课封装好的技能调用函数,结合之前学习的选怪功能,实现完整的自动选怪、自动打怪流程。请大家提前尝试将选怪逻辑与技能调用结合起来,我们下节课再见!👋

课程 P27:038 - 封装自动使用技能打怪功能 🎮

在本节课中,我们将学习如何修改已有的自动打怪功能,使其能够自动使用指定的技能攻击怪物。我们将基于上一节课的代码,通过添加参数和逻辑判断来实现这一功能。


上一节我们介绍了基础的自动打怪功能,它仅使用了普通攻击动作。本节中,我们来看看如何将其升级为能够使用特定技能进行攻击。

首先,我们需要在玩家角色相关的结构单元中找到已有的自动打怪函数。这个函数目前没有参数,仅执行普通攻击动作。

为了使其能够使用技能,我们将在该函数后面创建一个新的函数,并为其添加一个参数,用于接收要使用的技能名称。

以下是修改步骤:

  1. 复制原有的自动打怪函数代码。
  2. 在新函数中,将原来的普通攻击动作替换为使用指定技能的动作。
  3. 考虑到技能可能未被放置在快捷栏上,我们需要在使用技能前,先调用放置该技能的函数。

以下是核心代码修改的逻辑:

// 伪代码示例:新的自动打怪函数
void AutoAttackWithSkill(const char* skillName) {
    // 1. 尝试将技能放置到快捷栏
    bool isSkillPlaced = PlaceSkillToBar(skillName);
    
    // 2. 根据放置结果决定后续操作
    if (isSkillPlaced) {
        // 技能放置成功,使用该技能攻击
        UseSkill(skillName);
    } else {
        // 技能放置失败,回退到使用普通攻击
        NormalAttack();
    }
}

接下来,我们需要在主线程单元中调用这个新函数,并在消息处理线程中响应相应的指令。

以下是消息处理流程:

  1. 定义一个消息类型,用于触发“自动打怪使用技能”的操作。
  2. 在消息处理函数中,解析出传递过来的技能名称参数。
  3. 调用我们新封装的 AutoAttackWithSkill 函数。

在测试过程中,我们发现如果技能未修炼,即使能放置到快捷栏也无法使用。当前的代码逻辑对此情况处理不足,它仍会显示“使用技能”,但实际并未生效。

本节课中我们一起学习了如何封装一个能自动使用技能打怪的函数。我们通过添加技能参数、前置放置技能检查以及完善失败处理逻辑(回退到普攻),构建了功能的核心框架。目前代码已能处理技能放置与使用的基本流程,但对于“技能未修炼”等特殊情况,还需要后续课程进一步优化处理逻辑。

课程 P28:039 - 修改技能对象属性与分析修炼功能 🛠️

在本节课中,我们将学习如何通过分析游戏封包和内存数据,来修改技能对象的属性,并实现技能的自动修炼功能。我们将从基础的内存偏移分析开始,逐步定位到关键的调用函数。

概述

在前面的课程中,我们分析了技能的使用功能。但该功能仍有改进空间。例如,某些技能(如“劣势陀螺”)虽然满足了学习条件,但因未“修炼”而无法放置在技能栏上。本节课,我们将分析“修炼”功能的实现逻辑。通过判断技能学习条件是否满足,并调用相关函数,可以实现技能的自动修炼。

修炼技能的数据会发送到服务器,因此我们可以从封包入手进行回溯分析。发包函数通常有几种,本游戏使用的是第一种。我们将为其设置断点来截取封包,并通过封包回溯找到修炼技能的关键代码位置。

分析过程

首先,我们打开调试工具并附加到游戏进程。

附加成功后,我们在发包函数处下一个断点,然后切换回游戏并点击“修炼”按钮。

程序会在断点处中断。我们取消断点,然后执行“运行到返回”操作。

在这里可以看到程序调用了发包函数。通常需要向上返回两到三层才能找到真正的功能调用(CALL)。我们在这一层标注为“修炼技能1”,这可能是修炼技能的相关调用。

我们在此处下断点,再次执行“运行到返回”。在返回后的位置也有一个CALL,我们同样下断点。重复此过程,在更上一层的返回位置再下一个断点。

然后让程序运行起来。我们发现其中一个断点会被反复触发,即使只点击了一次“修炼”。这说明该处可能处于循环中,是修炼功能核心逻辑的可能性较小。

其他三个调用位置的可能性更大。由于当前没有更多技能可供测试,我们需要逐一分析。当我们没有可修炼技能时点击按钮,某个位置也会中断,但其可能性相对较小。

修改技能属性以触发修炼

在之前的分析中,我们曾发现一个属性(偏移 EF6)用于表示技能是否已学习。通过修改这个属性,可以尝试让“修炼”按钮重新出现。

该属性涉及两个偏移量:基址偏移 410 加上技能索引(例如第一个技能是 414),以及属性偏移 EF6(两字节)。我们需要同时修改这两个位置的数据。

以下是修改第一个技能属性的示例代码结构:

// 假设 baseAddress 是技能对象基址
*(WORD*)(baseAddress + 0x414 + 0xEF6) = 0; // 将“已学习”状态改为0

修改后返回游戏查看,此时“修炼”按钮应该会重新出现。点击该按钮,让程序在我们怀疑的CALL处中断,以便分析其参数。

分析调用参数

中断后,我们观察寄存器的值。通常 EAXECX 会包含可变参数。例如,EAX 的值可能是 0x0D,而 ECX 的值可能来源于 [ESI+0x24C]

我们需要记录下这些参数值。例如:

  • 参数1: 0x18A4CC
  • 参数2: 0x1870
  • 参数3: 0x319F

EAX 可能是一个数据缓冲区的首地址,其大小可能超过 0x13E 字节。

为了更好地区分参数,我们可以修改其他技能的属性进行测试。例如,将技能2(偏移 418)和技能3(偏移 41C)的“已学习”状态也改为0,然后分别触发它们的修炼,观察 EAX 等参数的变化规律。

测试第一个CALL时,我们发现 EAX 的值按 0x01, 0x13, 0x14 等序列变化,这可能代表技能在服务器上的ID或某种下标。

尝试调用修炼函数

基于以上分析,我们可以尝试编写代码来调用这个修炼函数。其调用形式可能类似于:

// 伪代码示例
push 0;            // 参数3
push eax_value;    // 参数2 (如 0x0D, 0x13)
push 0x3F4;        // 参数1 (可能是一个固定标识)
mov edx, [ecx];    // ECX来源于某个固定地址
call edx+4;        // 调用函数

我们将 EAX 的值(如 0x0D)和 ECX 的来源地址填入代码,然后注入游戏进行测试。如果调用成功,游戏中的技能1应该会被修炼。

用同样的方法修改参数,也可以尝试修炼技能2。

但这个调用方式存在一个麻烦:需要找到 ECX 的来源地址,并且不清楚它如何与具体的技能(如“接红灭火”、“接红断目”)关联起来。它们之间应该存在某种映射关系。

分析其他候选函数

我们还需要分析另外两个中断的CALL位置,通过对比,找出哪个调用逻辑更简洁、更易于使用。

这三个可能的位置都可以进行深入分析,以确定最终的解决方案。

总结

本节课中,我们一起学习了如何通过内存修改触发技能修炼按钮,并通过封包回溯找到了疑似修炼技能的功能调用。我们分析了该调用的参数,并尝试编写了调用代码。目前面临的主要挑战是如何建立调用参数与具体技能之间的稳定关联。课后,请大家将本节课分析的函数参数作为练习,进一步梳理其规律。我们下期再见。

逆向分析课程 P29:040-分析技能修炼功能CALL(二)🔍

在本节课中,我们将继续分析游戏中的技能修炼功能。上一节我们分析了第一个相关的CALL,但其参数与技能对象的关联性不强,使用起来较为复杂。本节我们将分析后续两个CALL,目标是找到参数更简单、与技能对象直接关联的理想调用方法。


分析第二个技能修炼CALL

我们打开调试工具,定位到第二个技能修炼功能的位置并下断点。

禁用其他断点后,我们修改技能并点击“修炼”,观察传入的参数。

以下是传入的参数分析:

  • 第一个参数 (eax):值为 0x31B9F。更换不同技能测试后,该值不变,证明它是一个固定常量,直接来源于代码中的某个地址。
  • 第二个参数 (edx):由两个数字组成(例如 187067005706)。前一个数字较小,后一个数字很大,推测后者可能是服务器ID
  • 第三个参数:是一个缓冲区的地址。

接下来,我们重点分析第二个参数 edx 的来源。

再次下断点并跟踪,发现 ecx 寄存器指向一个技能对象。查看该对象的内存,可以找到技能名称(如“疾风断木”),从而确认了对象身份。

通过分析,我们得到第二个参数的构成公式:

edx = [技能对象基址 + 0x5C] + [技能对象基址 + 0x4C]

其中 [技能对象基址 + 0x4C] 存储的就是我们推测的服务器ID


分析缓冲区与技能列表

现在分析第三个参数——缓冲区。跟踪到CALL内部,查看其对缓冲区的操作。

观察代码发现,函数内部会使用缓冲区进行数据打包并最终发包。通过检查代码中访问缓冲区的最大偏移量(如 0x142),我们可以确定缓冲区所需的最小大小。为了保险起见,我们可以分配稍大一些的空间,例如 0x150 字节。

在分析过程中,我们还发现了一个关键的技能对象数组

该数组的基址为 0x31A90C。通过下标(例如 esi)可以从中取出具体的技能对象。这与我们之前熟悉的另一个技能列表(基址 0x410)指向的是同一组数据,只是起始偏移和下标计算方式不同

验证公式如下:

对象地址 = 0x31A90C + (下标 * 0x10)

通过计算可以证实,从这个数组取出的对象与从 0x410 列表取出的对象地址相同。


编写调用代码

基于以上分析,我们可以编写调用这个理想CALL的代码。

以下是核心代码逻辑:

// 假设:skill_obj 是当前技能对象的基址
DWORD server_id = *(DWORD*)(skill_obj + 0x4C);
DWORD unknown_val = *(DWORD*)(skill_obj + 0x5C);

// 分配缓冲区
char buffer[0x150] = {0};

// 准备参数并调用CALL
__asm {
    push eax                // 缓冲区地址
    mov edx, server_id
    add edx, unknown_val    // 第二个参数
    mov eax, 0x31B9F        // 第一个参数,固定常量
    call 0xXXXXXXXX         // 技能修炼CALL的地址
    add esp, 4              // 平衡堆栈
}

代码说明

  1. 第一个参数是固定值。
  2. 第二个参数通过技能对象偏移计算得出。
  3. 第三个参数是一个足够大的缓冲区。
  4. 调用后需要平衡堆栈。

本节总结

本节课我们一起深入分析了第二个技能修炼功能CALL。

我们成功解析了其三个参数:

  1. 一个来源于代码的固定常量
  2. 一个通过技能对象基址偏移计算得出的值,其中包含了服务器ID。
  3. 一个用于数据打包的缓冲区

更重要的是,我们发现此CALL通过一个全局技能列表与技能对象直接关联,使得调用逻辑非常清晰。与上一课找到的CALL相比,此CALL更可能是直接执行修炼逻辑的底层功能,而上一个CALL可能更接近点击按钮的响应事件。两者都能实现技能修炼,但本节的CALL参数结构更优,更易于编程调用。

下一节课,我们将对此功能进行具体的代码封装和实践。

课程 P3:014 - 封装背包物品使用函数 📦➡️🎮

在本节课中,我们将学习如何封装一个用于使用游戏背包内物品的函数。我们将从通过物品下标直接使用的函数开始,逐步封装一个更便捷的、通过物品名称来使用物品的函数。课程将涵盖函数封装、异常处理、背包数据遍历与查询等核心概念。


一、课程准备与基础函数封装

上一节我们介绍了游戏功能调用的基本方法,本节中我们来看看如何封装一个稳定的物品使用函数。

首先,打开第12课的代码作为基础。我们需要添加两个关键的游戏调用机制(Call):

  1. 使用背包内指定下标格子的物品。
  2. 获取背包数据。

我们将第一个功能封装为一个成员函数,放在与背包相关的结构体中。这样可以使代码结构更清晰。

在对应的CPP代码单元中,我们实现这个函数。为了代码的健壮性,我们使用 try-catch 块来处理可能出现的异常(例如游戏更新导致调用地址失效)。

以下是该函数的实现框架:

bool UseItemByIndex(int index) {
    __try {
        // 汇编代码块:将下标存入寄存器,并调用游戏的使用物品Call
        __asm {
            mov eax, index
            push eax
            mov eax, [背包使用Call地址]
            call eax
            // ... 其他汇编指令
        }
        return true; // 执行成功
    }
    __except(EXCEPTION_EXECUTE_HANDLER) {
        OutputDebugString("UseItemByIndex函数出现异常");
        return false; // 执行失败
    }
}

这个函数直接通过物品在背包中的位置(下标)来使用它。但直接使用下标并不方便,因为我们通常不知道某个物品具体在哪一格。


二、封装按名称查询物品的函数

为了能通过物品名称来使用,我们需要先知道这个物品在背包中的位置。因此,我们需要封装另一个函数,用于根据名称查询物品的下标。

思路是遍历整个背包数组,将每一格物品的名称与我们传入的目标名称进行比较。

以下是查询函数的实现步骤:

  1. 初始化背包结构,确保能获取到最新的背包数据。
  2. 使用一个 for 循环遍历所有背包格子(例如0到35格)。
  3. 在循环中,使用字符串比较函数(如 strcmp)判断当前格子物品名是否与目标名相同。
  4. 如果找到相同名称,立即返回当前下标 i
  5. 如果遍历结束仍未找到,则返回 -1 表示物品不存在。

核心查询逻辑的伪代码如下:

int FindItemIndexByName(const char* targetName) {
    // 初始化并获取背包数据
    Backpack bp;
    InitBackpack(&bp);

    for(int i = 0; i < BACKPACK_SIZE; i++) {
        if(strcmp(bp.itemList[i].name, targetName) == 0) {
            return i; // 找到物品,返回其下标
        }
    }
    return -1; // 未找到物品
}

这个函数为我们提供了通过名称定位物品的能力。


三、整合:封装按名称使用物品的函数

现在,我们结合前两个函数,封装最终的目标函数:通过物品名称直接使用它

这个函数的逻辑非常清晰:

  1. 调用 FindItemIndexByName 函数,根据传入的名称查询物品下标。
  2. 判断返回值。如果为 -1,说明背包中没有此物品,函数直接返回 false
  3. 如果返回值是有效的下标(0到35之间),则调用 UseItemByIndex 函数,并传入该下标,从而使用物品。

以下是整合后的函数:

bool UseItemByName(const char* itemName) {
    int index = FindItemIndexByName(itemName);
    if(index == -1) {
        return false; // 物品不存在,使用失败
    }
    return UseItemByIndex(index); // 物品存在,使用它
}

通过这样的封装,我们只需要知道物品名称(如“金创药”),就可以方便地使用它,无需关心其具体位置。


四、功能测试与问题发现

我们将代码注入游戏进行测试。调用 UseItemByName("金创药"),观察游戏内金创药的数量是否减少,并查看调试输出信息。

测试发现,函数功能本身是正常的,可以成功使用物品。但是,当背包中物品位置变动后再次调用,游戏程序偶尔会发生崩溃。

这个问题并非函数逻辑错误。其根源在于我们的代码运行在独立的线程中,与游戏主线程同时操作同一块内存数据(背包数据),从而引发了多线程访问冲突。


五、总结与下节预告

本节课中我们一起学习了:

  1. 封装了通过下标使用背包物品的基础函数 UseItemByIndex,并加入了异常处理以增强稳定性。
  2. 封装了通过名称查询背包物品下标的辅助函数 FindItemIndexByName,其核心是遍历与字符串比较。
  3. 整合以上两者,封装出最终的目标函数 UseItemByName,实现了通过物品名称直接使用的便捷功能。
  4. 通过测试发现了多线程环境下的数据访问冲突问题。

目前函数逻辑已完备,但存在线程安全问题。在下一节课中,我们将通过修改代码注入方式(例如使用钩子技术将动态链接库注入到游戏主线程),来解决多线程冲突问题,使我们的物品使用功能完全稳定可靠。

课程 P30:041-封装修炼技能功能 🛠️

在本节课中,我们将学习如何将上一节课分析的“修炼技能”功能进行代码封装。我们将创建一个更通用、更易用的函数,使其既能通过技能下标调用,也能通过技能名称调用。


概述

上一节课我们分析了修炼技能的功能逻辑。本节中,我们将对该功能进行封装,使其成为一个可复用的函数。核心在于处理技能列表的基址、偏移量计算以及参数传递。

封装步骤

1. 添加相关基址

首先,我们需要在基址单元中添加修炼功能所需的基址。

以下是需要添加的基址:

  • 技能列表基址0x312A90C(已添加)。
  • 修炼技能库基址:我们将其放置在技能列表基址之后。
  • 参数技能库基址:其第一个参数(ecx)来源于此。
  • 角色对象基址0x3173C(已添加)。

添加完成后,我们转到结构单元进行下一步。

2. 在结构单元中封装

由于操作对象是角色的技能列表,我们将函数封装在技能列表结构体中。首先,我们复制相关代码到尾部,并添加异常处理机制。

代码示例(异常处理)

try {
    // 功能代码
} catch (...) {
    // 打印调试信息
    OutputDebugString("修炼技能时出现异常!");
}

3. 编写并修改汇编代码

接下来,我们将测试成功的代码逻辑转化为汇编代码。这里需要注意,代码注入器默认使用十六进制,但我们的偏移量计算需要调整。

核心公式
目标地址 = 技能列表基址 + 0x410 + (技能下标 * 4)

最初的尝试是直接将计算写入汇编,但这可能导致编译错误,因为下标是变量。

初始有问题的写法

mov eax, [技能列表基址]
add eax, 0x410
add eax, [下标]*4  ; 这种写法在汇编中不直接支持
mov ecx, [eax]

4. 修正汇编逻辑

为了解决变量问题,我们需要先将基址和偏移量加载到寄存器中,再进行计算。

修正后的汇编逻辑

  1. 将技能列表基址读入寄存器(如 eax)。
  2. 加上固定偏移 0x410,得到技能库起始地址。
  3. 将下标值乘以4(因为每个技能指针占4字节),加到地址上。
  4. 最终从计算出的地址中取出技能对象指针,作为参数调用修炼函数。

修正后的代码思路

DWORD skillListBase = *(DWORD*)0x312A90C; // 读取基址
DWORD skillSlotAddr = skillListBase + 0x410 + (index * 4); // 计算目标地址
DWORD skillObj = *(DWORD*)skillSlotAddr; // 取出技能对象
// 调用修炼函数,传入 skillObj 等参数

5. 功能测试与调试

我们通过主线程单元进行测试。设置技能下标为2(表示第二个技能),启动游戏并挂接主线程,查看调试信息。

首次测试出现了异常,调试信息显示问题出在地址计算部分。经过检查,发现是下标参与计算时的汇编指令写法有误。我们调整了计算顺序和寄存器使用方式,最终成功完成了修炼功能。

6. 函数重载:支持技能名称

为了使函数更易用,我们重载一个以技能名称为参数的函数。

实现逻辑

  1. 遍历技能列表。
  2. 将每个技能的名称与目标名称进行比较。
  3. 如果找到匹配项,则获取其下标。
  4. 调用之前封装好的、以下标为参数的修炼函数。

需要注意:在比较前,需要判断技能名称字符串是否有效(非空),以避免访问异常。

代码示例(查找技能下标)

int foundIndex = -1;
for (int i = 0; i < SKILL_COUNT; i++) { // SKILL_COUNT 应定义为常量
    char* skillName = GetSkillName(i);
    if (skillName == nullptr) continue; // 跳过空名称
    if (strcmp(skillName, targetName) == 0) {
        foundIndex = i;
        break;
    }
}
if (foundIndex != -1) {
    TrainSkillByIndex(foundIndex); // 调用下标修炼函数
}

7. 最终测试

我们将主线程中的测试调用改为使用技能名称(例如“截风列轨”)。编译代码后,挂接主线程并测试,功能成功执行。

总结

本节课中我们一起学习了如何封装“修炼技能”功能:

  1. 分析并添加了必要的内存基址
  2. 编写了以下标为参数的修炼函数,重点解决了汇编层级的地址计算问题。
  3. 通过函数重载,实现了以技能名称为参数的调用方式,提升了易用性。
  4. 加入了异常处理和空值判断,使代码更加健壮。

通过封装,我们将复杂的底层操作隐藏起来,提供了一个简洁的接口供其他功能调用。


课后练习

我们已有一个自动使用技能的函数 UseSkill。请尝试在其中加入自动修炼技能的语句,实现使用后自动修炼的功能。这个练习可以帮助你理解如何将不同的功能模块组合起来。


我们下节课再见!

课程 P31:042 - 分析修炼技能所需条件数据 📊

在本节课中,我们将学习如何分析并获取判断技能是否可修炼的必要数据。具体来说,我们将找到并理解游戏角色当前的历练值、等级,以及特定技能所需的历练和等级条件。这些数据是后续编写技能修炼判断功能的基础。


在上一节课中,我们编写并封装了修炼技能的功能,但该功能尚不完善。例如,对于“神龙破甲”技能,即使条件不满足(如力量不足或等级不够),程序也会尝试修炼,这可能导致向服务器发送非法数据而被判定为使用外挂。因此,我们必须加入条件判断逻辑。

要进行判断,我们需要获取两类数据:

  1. 角色当前的历练值和等级。
  2. 目标技能所需的历练值和等级。

本节我们将重点分析如何找到并获取这些数据。


分析当前历练值与技能需求历练

首先,我们来分析角色当前的历练值。根据之前课程(第8课)的分析,我们知道角色属性有一个基地址。

  1. 查找当前历练地址
    • 在游戏中查看当前历练值为 1555833
    • 通过内存搜索该数值,可以找到一个绿色的基地址,例如 f8619c
    • 回顾第8课,我们曾分析过角色属性的基地址为 2f860f0。计算差值 f8619c - 2f860f0 = ac
    • 因此,当前历练值的偏移量是 ac。其地址计算公式为:
      人物属性基地址 + ac

  1. 查找技能需求历练地址
    • 技能需求历练(例如200000)是技能对象的一个属性。
    • 首先找到目标技能的基地址。例如,“神龙破甲”是技能数组中的第14个技能(下标为13)。通过计算 技能数组基地址 + 下标*4 可以得到该技能对象的地址。
    • 以该技能对象地址为起点,搜索其需求历练值 200000(十六进制 0x30D40)。
    • 通过访问测试,可以找到正确的偏移量。在本例中,技能需求历练的偏移量是 268
    • 因此,获取某个技能需求历练的公式为:
      技能对象地址 + 268


分析当前等级与技能需求等级

接下来,我们分析等级相关的数据。

  1. 查找技能需求等级地址

    • 技能需求等级(例如74级)同样是技能对象的属性。
    • 在已找到的技能对象地址附近,搜索数值 74
    • 通过对比不同技能(如第13个和第14个技能)的需求等级,可以验证偏移量的正确性。在本例中,技能需求等级的偏移量是 ac
    • 因此,获取某个技能需求等级的公式为:
      技能对象地址 + ac
  2. 查找当前角色等级地址

    • 角色当前等级的数据在第8课中已经分析过。它是一个单字节数据。
    • 其地址有固定的偏移,例如 人物属性基地址 + 0x20(具体偏移需根据实际分析确定)。


数据总结与下节预告

本节课我们成功分析了四项关键数据的存储位置与偏移量:

  • 当前历练值人物属性基地址 + ac
  • 技能需求历练技能对象地址 + 268
  • 技能需求等级技能对象地址 + ac
  • 当前角色等级人物属性基地址 + 固定偏移(如0x20)

在下一节课中,我们将利用这些分析结果,编写代码来读取这些数据,并封装成一个用于判断指定技能是否可学习的函数。


课后作业 📝

请大家根据本节课分析出的偏移量,尝试构思并编写一个函数 bool CanSkillBeLearned(int skillIndex)。该函数的功能是判断指定下标的技能当前是否可以学习(即判断当前历练和等级是否均达到技能要求)。


本节课中,我们一起学习了如何通过CE工具分析并定位游戏内存中关于技能修炼条件的关键数据,包括历练和等级的当前值与需求值。掌握这些数据的获取方法是实现自动化逻辑判断的重要一步。我们下节课再见!

课程 P32:043 - 技能学习条件检测函数 IsCanStudy 🧠

在本节课中,我们将学习如何封装一个函数,用于检测游戏中的某个技能是否满足修炼条件。我们将分析所需的数据,编写判断逻辑,并将其集成到现有的技能系统中。


上一节我们分析了技能修炼所需的条件数据。本节中,我们将把这些条件判断逻辑封装成一个独立的函数。

这个函数的核心目标是:判断指定下标的技能当前是否可以被角色学习。它需要检查三个主要条件:

  1. 技能是否已经学习过。
  2. 角色的当前历练值是否达到技能要求。
  3. 角色的当前等级是否达到技能要求。

以下是实现此函数的具体步骤:

第一步:在技能对象中添加必要属性
我们需要在技能对象的数据结构中,补充技能本身的修炼要求数据。

  • EF6:技能是否已学习的标志。
  • 268:技能所需的历练值。
  • AC:技能所需的等级。

第二步:读取角色当前状态
函数需要获取角色当前的属性,以进行比较。

  • 角色当前历练值:从人物属性单元偏移 0xAC 处读取。
  • 角色当前等级:从人物属性单元偏移 0x34 处读取。

第三步:编写判断函数逻辑
函数 IsCanStudy 接收一个技能下标作为参数,其伪代码逻辑如下:

bool IsCanStudy(int skillIndex) {
    // 1. 读取技能数据
    skillData = GetSkillData(skillIndex);
    // 如果技能ID为0(无效技能),直接返回 false

    // 2. 读取角色当前数据
    currentExp = GetCharacterCurrentExp();
    currentLevel = GetCharacterCurrentLevel();

    // 3. 逐一进行条件判断
    if (skillData.isLearned == true) {
        return false; // 条件1:技能已学习,不可再学
    }
    if (currentExp < skillData.requiredExp) {
        return false; // 条件2:当前历练不足
    }
    if (currentLevel < skillData.requiredLevel) {
        return false; // 条件3:当前等级不足
    }

    // 所有条件均满足
    return true;
}

第四步:集成到技能修炼流程中
在调用“修炼技能”的功能之前,先使用 IsCanStudy 函数进行检测。

void StudySkill(int skillIndex) {
    if (!IsCanStudy(skillIndex)) {
        // 打印调试信息,如“技能已学习”、“历练不足”或“等级不足”
        return -1; // 修炼失败
    }
    // 条件满足,执行原有的修炼技能代码
    ExecuteSkillStudy(skillIndex);
}

第五步:测试与调试
编写测试代码,循环遍历所有技能下标,调用 IsCanStudy 函数,并打印出每个技能的检测结果(例如:“技能1:可学习”、“技能2:历练不足”)。通过信息查看器观察输出,验证函数逻辑是否正确。


本节课中我们一起学习了如何构建一个技能学习条件检测函数。我们通过分析游戏数据,定义了判断逻辑,并将其封装成一个可复用的函数 IsCanStudy。最后,我们将此函数集成到技能修炼流程中,并进行了测试验证。掌握这种条件检测的封装方法,对于处理游戏中的各种状态判断非常有帮助。

课程 P33:044-编写挂机选项卡 🎮

在本节课中,我们将学习如何为之前分析的功能添加一个用户界面,核心是使用选项卡控件来组织不同的功能页面。我们将创建一个包含“挂机”选项卡的窗口,并学习如何将对话框页面与选项卡控件关联起来。


界面设计基础 🖼️

上一节我们介绍了数据分析,本节中我们来看看如何为这些功能设计界面。首先,打开第43课的代码项目。

切换到资源视图,添加一个新的对话框资源。在制作外挂程序时,最常用的是 Tab Control(选项卡控件)。它便于管理多个功能页面,每个选项卡可以关联一个独立的对话框窗口。

添加控件后,修改其ID以增强代码可读性。例如,将ID改为 IDC_TAB_MAIN。同时,可以修改控件的标题和样式以符合需求。

创建挂机功能页面 ⚙️

为了关联选项卡,我们需要为每个功能创建独立的对话框页面。首先,创建一个用于“挂机”功能的对话框。

将该对话框的 Border 属性设置为 None,使其作为子页面嵌入。然后,在对话框上添加所需的控件,例如“自动挂机”复选框或按钮。为了辅助布局,可以在底部添加一个静态文本控件,并调整整个对话框的大小。

接下来,需要为此对话框创建一个对应的MFC类。以下是创建类的代码示例:

class CPageHangup : public CDialogEx
{
    DECLARE_DYNAMIC(CPageHangup)
public:
    CPageHangup(CWnd* pParent = nullptr);
    virtual ~CPageHangup();
    // 对话框数据
    #ifdef AFX_DESIGN_TIME
    enum { IDD = IDD_PAGE_HANGUP };
    #endif
protected:
    virtual void DoDataExchange(CDataExchange* pDX);
    DECLARE_MESSAGE_MAP()
};

创建类后,将对话框的ID修改为 IDD_PAGE_HANGUP,并在资源视图中将此类与对话框关联。

关联选项卡与页面 🔗

现在,我们需要在主窗口的选项卡控件中显示刚创建的页面。首先,在主对话框类(例如 CMainTabWindow)中,为选项卡控件添加一个控件变量。

CTabCtrl m_TabCtrl;

然后,重写主对话框的 OnInitDialog 函数。在此函数中,初始化选项卡控件,添加选项卡项。

以下是初始化并添加选项卡的代码:

BOOL CMainTabWindow::OnInitDialog()
{
    CDialogEx::OnInitDialog();
    // 添加选项卡
    m_TabCtrl.InsertItem(0, _T("挂机"));
    m_TabCtrl.InsertItem(1, _T("测试"));
    // ... 其他初始化代码
    return TRUE;
}

接下来,需要将“挂机”页面嵌入到第一个选项卡中。在 CPageHangup 类的初始化函数中,创建窗口并设置其父窗口为选项卡控件。

BOOL CPageHangup::OnInitDialog()
{
    CDialogEx::OnInitDialog();
    // 创建窗口,非模态显示
    Create(IDD_PAGE_HANGUP, GetParent());
    // 设置窗口位置(后续调整)
    return TRUE;
}

调整页面位置与显示 🧭

初始创建的页面位置可能不正确。我们需要计算选项卡控件客户区的位置,并将页面窗口移动到这个区域内。

在主对话框的初始化函数中,添加位置计算的代码:

// 获取选项卡控件的位置
CRect rectTab;
m_TabCtrl.GetClientRect(&rectTab);
// 调整矩形,排除选项卡按钮区域
rectTab.top += 20; // 假设按钮高度为20
rectTab.left += 2;
rectTab.right -= 2;
rectTab.bottom -= 2;
// 移动挂机页面窗口
m_pageHangup.MoveWindow(&rectTab);

通过上述调整,页面将正确地显示在选项卡内容区域。可以微调 top, left, right, bottom 的偏移值以获得最佳的视觉效果。

处理选项卡切换事件 🔄

为了使页面能响应选项卡的切换,需要处理选项卡控件的 TCN_SELCHANGE 通知消息。

以下是处理切换事件的步骤:

  1. 为选项卡控件添加事件处理函数。
  2. 在函数中,获取当前选中的选项卡索引。
  3. 根据索引显示或隐藏相应的页面。
void CMainTabWindow::OnTcnSelchangeTabMain(NMHDR *pNMHDR, LRESULT *pResult)
{
    int nSelectedTab = m_TabCtrl.GetCurSel();
    switch(nSelectedTab)
    {
        case 0: // “挂机”选项卡
            m_pageHangup.ShowWindow(SW_SHOW);
            // 隐藏其他页面...
            break;
        case 1: // “测试”选项卡
            // 显示测试页面,隐藏其他...
            break;
    }
    *pResult = 0;
}

测试与预览 👁️

编译并运行程序,点击“显示外挂”按钮。现在应该能看到一个包含“挂机”和“测试”两个选项卡的窗口。点击“挂机”选项卡,其关联的页面会正确显示在内容区域。

如果页面位置或大小仍有问题,返回调整 MoveWindow 函数中的矩形参数即可。


本节课中我们一起学习了如何使用MFC的选项卡控件来组织界面。我们创建了资源、对话框类,并将它们关联起来,最终实现了一个可切换的挂机功能界面。下一节课,我们将为此界面添加具体的功能代码,例如“开始挂机”、“停止挂机”等按钮的逻辑。

课程 P34:045-创建挂机类-封装自动打怪 🎮

在本节课中,我们将学习如何封装一个用于自动打怪的挂机类。我们将从界面设计过渡到核心逻辑的代码实现,包括创建线程、设置打怪频率以及处理用户交互。


概述 📋

上一节我们设计了挂机功能的界面。本节我们将封装一个C++类来实现自动打怪的核心逻辑。这个类将包含应用设置、开始挂机和停止挂机三个主要功能。

创建挂机类

首先,我们需要创建一个C++类来管理挂机功能。

  1. 在项目中添加一个名为 CWaterCreator 的C++类。
  2. 在头文件中定义类的三个公共成员函数:
    • ApplySettings: 应用用户设置。
    • StartWaterPlay: 开始自动挂机。
    • StopWaterPlay: 停止自动挂机。

以下是类的初步结构:

class CWaterCreator {
public:
    void ApplySettings(bool bAutoFight);
    void StartWaterPlay();
    void StopWaterPlay();
private:
    // 后续添加私有成员
};

实现自动打怪线程

自动打怪需要在后台持续运行,因此我们使用一个独立的线程来实现。

上一节我们定义了类的框架,本节中我们来看看如何实现线程内的打怪逻辑。

定义线程回调函数

我们需要一个函数作为线程的入口点,在其中循环执行打怪动作。

  1. 在类中定义一个静态成员函数作为线程回调。静态函数是创建Windows线程所必需的。
  2. 在回调函数中,我们需要循环判断“自动打怪”条件是否成立。
  3. 如果条件成立,则调用攻击函数。
  4. 每次攻击后,让线程休眠一段时间以控制攻击频率。

以下是线程回调函数的核心逻辑:

static DWORD WINAPI AutoFightThread(LPVOID lpParam) {
    while (true) {
        if (g_bAutoFight) { // g_bAutoFight 是一个全局变量,表示是否开启自动打怪
            // 调用攻击函数,例如使用“攻击”动作或特定技能
            UseSkill("攻击");
        }
        Sleep(g_nFightInterval); // g_nFightInterval 是攻击间隔,单位毫秒
    }
    return 0;
}

注意:由于线程回调函数必须是静态的,它无法直接访问类的非静态成员变量。一个常见的解决方案是使用全局变量或在创建线程时传入this指针。本教程后续采用了全局变量的方式。

启动与停止线程

定义了回调函数后,我们需要在合适的时机创建和操控这个线程。

以下是管理线程生命周期的关键步骤:

  • 创建线程:在类的构造函数中,使用 CreateThread API创建线程,并使其处于挂起状态。
    m_hThread = CreateThread(NULL, 0, AutoFightThread, NULL, CREATE_SUSPENDED, &m_dwThreadId);
    
  • 开始挂机:在 StartWaterPlay 函数中,使用 ResumeThread API恢复线程执行。
    ResumeThread(m_hThread);
    
  • 停止挂机:在 StopWaterPlay 函数中,使用 SuspendThread API挂起线程。
    SuspendThread(m_hThread);
    
  • 清理资源:在类的析构函数中,使用 TerminateThread API强制结束线程,确保程序能安全退出。
    TerminateThread(m_hThread, 1);
    

连接用户界面与逻辑

现在,我们已经封装好了挂机类。接下来需要将图形界面上的操作与这个类的功能连接起来。

上一节我们实现了后台线程,本节我们将其与前端界面控件绑定。

以下是界面控件的事件处理逻辑:

  1. “应用设置”按钮:将界面复选框(是否自动打怪)的值,更新到全局变量 g_bAutoFight 中。
  2. “开始挂机”按钮:调用全局挂机类实例的 StartWaterPlay() 方法。
  3. “停止挂机”按钮:调用全局挂机类实例的 StopWaterPlay() 方法。

关键代码示例如下:

// 应用设置按钮点击事件
void OnBtnApplySettings() {
    // 从界面控件获取值
    g_bAutoFight = IsDlgButtonChecked(IDC_CHECK_AUTO_FIGHT);
}

// 开始挂机按钮点击事件
void OnBtnStart() {
    g_pWaterCreator->StartWaterPlay(); // g_pWaterCreator 是全局的挂机类实例
}

// 停止挂机按钮点击事件
void OnBtnStop() {
    g_pWaterCreator->StopWaterPlay();
}

完善攻击功能

在测试中,我们发现直接调用技能函数可能无法处理普通攻击,因为“攻击”通常属于动作而非技能列表。

因此,我们需要修改技能调用函数,使其能区分“攻击”动作和技能。在攻击函数内部添加一个判断:如果传入的技能名是“攻击”,则改为调用角色执行攻击动作的函数;否则,按正常流程使用技能。

修改后的攻击逻辑伪代码如下:

int UseSkill(char* szSkillName) {
    if (strcmp(szSkillName, "攻击") == 0) {
        // 调用执行普通攻击的函数
        CharacterAttack();
        return 1; // 表示成功
    } else {
        // 原有使用技能的代码
        return CastSpell(szSkillName);
    }
}

测试与优化 🧪

完成编码后,进行测试是必不可少的环节。

  1. 编译并注入外挂到游戏。
  2. 在挂机界面勾选“自动打怪”,点击“应用设置”。
  3. 点击“开始挂机”,观察游戏角色是否开始自动攻击附近的怪物。
  4. 点击“停止挂机”,观察攻击是否停止。
  5. 测试时注意调整 g_nFightInterval(攻击间隔)变量,例如设置为1000(毫秒),使攻击频率合理。

如果攻击过快导致游戏断开,请检查循环中Sleep函数的参数是否有效。确保在类的构造函数中对攻击间隔等变量进行了合理的初始化。

总结 🎯

本节课中我们一起学习了如何封装一个自动打怪的挂机类。我们从创建C++类开始,实现了用于后台持续打怪的线程,包括线程的创建、执行、挂起和销毁。接着,我们将类的功能与用户界面控件相连接,使得点击按钮可以控制挂机行为。最后,我们完善了攻击逻辑以兼容普通攻击,并进行了功能测试。

通过本课,你掌握了将特定游戏功能模块化封装的基本方法,以及使用多线程实现后台持续任务的核心流程。在后续课程中,我们可以在此基础上为挂机功能添加更多可配置选项,如技能选择、打怪间隔设置等。

课程 P35:046 - 使用类成员函数作为回调函数 🧩

在本节课中,我们将学习如何将类的非静态成员函数用作线程的回调函数。上一节我们介绍了使用静态成员函数作为回调的局限性,本节我们将探讨如何突破这一限制,实现更符合面向对象封装特性的回调机制。


背景与问题回顾

在第45课中,我们封装了一个挂机类 WaterPlay。其中的回调函数使用了 static 关键字修饰。由于静态成员函数不能直接访问类的非静态成员变量,我们当时创建了一个全局变量来访问这些数据。这种方法破坏了类的封装性,使用起来并不理想。

核心目标:使用非静态成员函数

本节课的目标是探讨如何使用类的非静态成员函数作为线程的回调函数。我们将通过新建一个项目来测试这一方案。

项目搭建与初始代码

首先,我们新建一个项目,并添加一个类(例如 MyThread)。创建好头文件和源文件后,我们将其移动到项目目录下。

初始代码仿照上一课的结构编写:

  1. 包含必要的头文件 windows.h
  2. 在类中声明用于保存线程句柄的成员变量 m_hThread
  3. 声明两个成员变量(例如 m_param1, m_param2)用于传递参数。
  4. 声明一个非静态的线程回调函数 ThreadProc
  5. 声明一个用于启动线程的成员函数 StartThread

在类的构造函数中,我们对成员变量进行初始化。在 StartThread 函数中,我们调用 CreateThread API 创建线程。目前,我们先不传递参数,仅指定回调函数。

回调函数 ThreadProc 内部我们先编写简单的代码,例如打印一些信息。

// 示例代码框架
class MyThread {
private:
    HANDLE m_hThread;
    int m_param1;
    int m_param2;
    DWORD WINAPI ThreadProc(LPVOID lpParameter); // 非静态成员函数
public:
    MyThread();
    void StartThread();
};

遇到的编译问题与解决方案

当我们尝试编译时,编译器会报错。这是因为 CreateThread 期望的回调函数遵循 __stdcall 调用约定,并且是普通的函数指针。而非静态成员函数拥有一个隐藏的 this 指针参数,其调用约定通常是 __thiscall,两者不兼容。

为了通过编译器的检查,我们需要使用一点技巧。我们可以通过内联汇编代码,先将成员函数的地址读取出来,编译器不会检查汇编代码。然后,我们将这个地址赋值给一个变量,再对该变量进行强制类型转换,转换为 LPTHREAD_START_ROUTINE 类型。

// 在 StartThread 函数中
LPTHREAD_START_ROUTINE pFunc;
__asm {
    mov eax, offset MyThread::ThreadProc // 获取成员函数地址
    mov pFunc, eax
}
m_hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)pFunc, NULL, 0, NULL);

参数传递与 this 指针处理

编译通过后,理论上可以运行,但我们需要传递参数。我们计划将 this 指针作为参数传递给回调函数。

// 修改 CreateThread 调用,传入 this 指针
m_hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)pFunc, this, CREATE_SUSPENDED, NULL);

然而,直接运行仍然会出错。因为回调函数被调用时,__stdcall 约定会将参数 this 放在堆栈上传递。但在成员函数 ThreadProc 内部,编译器默认会从 ECX 寄存器(__thiscall 约定)去寻找 this 指针,此时 ECX 并未被正确初始化,导致访问失败。

为了解决这个问题,我们需要在回调函数的开头,手动将传入的参数(即 lpParameter)设置给 this 指针。

以下是两种修改 ThreadProc 函数的方法:

方法一:直接使用参数指针
将函数签名和实现改为直接使用传入的 LPVOID 参数,并在内部进行类型转换后访问成员。

DWORD WINAPI MyThread::ThreadProc(LPVOID lpParameter) {
    MyThread* pThis = (MyThread*)lpParameter; // 转换回类指针
    // 现在可以通过 pThis->m_param1 等方式访问成员变量
    printf("参数1: %d\n", pThis->m_param1);
    return 0;
}

方法二:使用汇编指令恢复 this 指针
保持成员函数的原型不变,在函数内部使用汇编指令,将传入的 lpParameter 参数值移动到 ECX 寄存器。

DWORD WINAPI MyThread::ThreadProc(LPVOID lpParameter) {
    __asm { mov ecx, lpParameter } // 将参数赋值给 ECX (this 指针)
    // 现在可以直接使用成员变量,如 m_param1
    printf("参数1: %d\n", m_param1);
    return 0;
}

使用以上任一方法修改后,程序即可正常运行,并成功在回调函数中访问类的成员变量。

应用实践:修改第45课代码

测试成功后,我们可以回过头去修改第45课的挂机类代码。

  1. 移除原回调函数的 static 关键字。
  2. 使用本节介绍的汇编技巧获取函数地址,以通过编译。
  3. CreateThread 时传入 this 指针作为参数。
  4. 在回调函数内部,采用上述方法二(汇编指令)来正确接收 this 指针。
  5. 修改后,即可删除之前为了突破封装而使用的全局变量,使代码更加整洁和符合面向对象原则。

具体的代码修改和测试工作,建议大家作为练习亲自完成。


总结

本节课我们一起学习了如何将类的非静态成员函数用作线程回调函数。我们分析了直接使用会遇到的编译和运行时问题,并介绍了通过内联汇编技巧获取函数地址,以及在回调函数内部正确处理 this 指针的两种方法。最终,我们成功实现了在保持类封装性的前提下,在回调函数中自由访问成员变量。这种方法比使用静态函数配合全局变量更加优雅和安全。

逆向工程课程 P36:047-快速更新基址方法 🛠️

在本节课中,我们将学习如何在游戏更新后,快速定位并更新失效的基址。我们将通过分析汇编指令、使用特征码搜索以及利用已知数据回溯等多种方法,以背包基址、物品使用Call和人物属性基址为例,进行实战演练。

概述

游戏更新后,代码地址会发生变动,导致之前找到的基址失效。本节课的核心目标是掌握几种高效更新基址的技巧,减少重复分析的工作量。我们将主要使用OD(OllyDbg)和CE(Cheat Engine)两款工具。

方法一:利用汇编指令特征码(OD)

上一节我们介绍了课程目标,本节中我们来看看第一种方法:在OD中利用未变动的汇编指令序列作为特征码进行搜索定位。

核心原理:游戏更新时,高层逻辑和数据结构可能变化,但底层的数组访问、计算等汇编指令模式往往保持不变。我们可以将这些不变的指令序列作为“特征码”来定位基址附近区域。

以下是利用OD查找命令序列的步骤:

  1. 在OD中附加目标游戏进程。
  2. 转到更新前基址所在的代码区域。
  3. 复制一段确信在更新后仍会存在的、独特的汇编指令序列(例如,访问数组的指令)。
  4. 在OD中使用“查找所有命令序列”功能,粘贴该序列进行搜索。
  5. 在搜索结果中,找到位于原基址地址附近的指令,其上方通常就是新的基址加载指令(如 mov ecx, [xxxxxxxx])。

代码示例(特征码指令序列):

mov edi, [ecx+0x5C]
lea esi, [edi+eax*4]

通过此方法,我们成功定位到了新的背包基址。

方法二:利用机器码特征码(CE)

除了汇编指令,我们还可以直接使用其对应的机器码(十六进制字节数组)作为特征码,在CE中进行搜索。

核心原理:汇编指令在内存中最终以机器码形式存在。提取关键指令的机器码,在CE中扫描,可以快速定位到代码位置。

以下是利用CE进行十六进制扫描的步骤:

  1. 从OD中复制目标汇编指令对应的机器码(十六进制字节)。
  2. 在CE中附加游戏进程,选择“十六进制”扫描类型。
  3. 将复制的机器码粘贴到CE的搜索框,执行扫描。
  4. 在找到的地址上,通过“查看内存”功能,并向前翻阅,即可找到加载基址的指令。

公式/代码描述(机器码特征码):

57 8B F8 8D 34 87 8B 0C B1

此方法同样有效,并且有时比搜索汇编指令更直接。

方法三:通过已知数据指针回溯基址

对于像人物属性这类可能没有现成特征码的复杂基址,我们可以通过其下属的已知数据(如生命值、经验值)指针来反向推算。

核心原理:基址与其下属数据指针之间存在固定的偏移关系。通过CE搜索到某个已知动态数据(如当前经验值)的地址,减去该数据相对于基址的偏移量,即可得到新的基址。

以下是数据回溯法的步骤:

  1. 确定要查找的基址(如人物属性基址)和其下一个已知偏移的静态数据(如 基址+0xAC = 经验值)。
  2. 在游戏中查看当前经验值,并在CE中用精确值搜索。
  3. 找到经验值的动态地址后,在CE或OD中查看该地址。
  4. 将该地址减去偏移量(如 -0xAC),得到的结果即为潜在的人物属性基址。
  5. 验证该地址:加上其他已知偏移(如 +0x84 是生命值),查看内存中的数据是否与游戏内显示一致。

公式描述
人物属性基址 = 历练值地址 - 0xAC
生命值地址 = 人物属性基址 + 0x84

通过此方法,我们验证并找到了更新后的人物属性基址。

总结与作业

本节课中我们一起学习了三种快速更新游戏基址的方法:

  1. OD指令特征码法:适用于有稳定汇编指令模式的场景。
  2. CE机器码特征法:直接高效,是特征码搜索的常见形式。
  3. 数据指针回溯法:当缺乏特征码时,通过已知数据关系逆向推导。

这些方法的核心思想都是利用更新中相对不变的部分来定位变化的部分。掌握后,可以大幅提高逆向分析效率。

课后作业
请运用本节课学到的方法,尝试更新课程示例中提到的其他基址(如任务列表、场景对象等),并为你找到的新基址提取出可供下次更新的特征码(汇编指令序列或机器码)。

课程 P37:048 - 动态定位基址技术 🎯

在本节课中,我们将学习如何通过编写代码来实现特征码的动态定位。这种方法比使用工具更高效、更灵活,能够快速更新游戏基址。


上一节我们介绍了快速定位特征码的方法。本节中,我们来看看如何自己编写代码来实现这一过程。

原理与示例

首先,我们以一个背包基址为例进行说明。假设这是背包的一个基址,我们需要定位它。我们可以提取其前一段字节作为特征码。

以下是提取特征码的步骤:

  1. 复制特征码字节。
  2. 删除中间的空格。
  3. 得到一个字符串类型的特征码。

例如,特征码字符串经过函数转换后,会搜索到匹配的起始地址。这个起始地址距离我们真正的基址还有一段偏移。

例如,通过计算,我们发现偏移为19个字节(十六进制为0x13)。定位到特征码地址后,加上这个偏移再进行读取,就能得到正确的基址。

公式表示:
基址 = 特征码匹配地址 + 偏移量

代码演示了如何读取:

// 伪代码示例
DWORD 特征码地址 = FindPattern(特征码字符串);
DWORD 基址 = ReadMemory(特征码地址 + 0x13);

这样就能准确读出基址值,例如 0x3140f1f5c

如果要定位另一个基址,只需更换特征码字符串和对应的偏移量(例如21个字节的偏移)。通过编程,可以非常方便地读取基址及其相关偏移。

代码实现:数据格式转换

接下来,我们探讨如何用代码编写这样的定位函数。核心问题在于,特征码是字符串类型,但游戏内存中的数据是字节数组。因此,我们需要统一格式以便比较。

本节中,我们先将内存中的字节数组数据转换为十六进制字符串。

我们创建一个新的控制台项目,并添加一个C++源文件。

核心任务是编写一个转换函数,将字节数组转换为十六进制字符串。因为一个字节(如0x3A)会转换成两个字符("3A"),所以输出缓冲区的大小需要是输入字节数组大小的两倍。

以下是转换函数的关键实现思路:

void BytesToHexString(const unsigned char* data, int size, char* output) {
    for (int i = 0; i < size; ++i) {
        // 将每个字节格式化为两位十六进制字符串
        sprintf_s(output + (i * 2), 3, "%02X", data[i]);
    }
    output[size * 2] = '\0'; // 字符串结尾
}

代码说明:

  1. 使用循环遍历字节数组中的每个字节。
  2. 使用 sprintf_s 函数,以 %02X 格式将每个字节格式化为两位十六进制数,不足两位用0填充。
  3. 在字符串末尾添加结束符 \0

main 函数中,我们可以定义一个字节数组进行测试:

unsigned char testData[] = { 0x33, 0x55, 0x21, 0x2C, 0xFF };
char hexString[256] = { 0 };
BytesToHexString(testData, sizeof(testData), hexString);
printf("转换结果: %s\n", hexString);
system("pause");

运行后,控制台会输出转换后的十六进制字符串,例如 "3355212CFF"。十进制数33和21被正确转换为十六进制"21"和"2C"。


本节课中,我们一起学习了动态定位基址的基本原理,并成功编写了将内存字节数据转换为十六进制字符串的函数。这是实现自主特征码定位的第一步。

课后作业 📝

为了巩固学习,请完成以下练习:
编写一个函数,用于比较两个十六进制字符串是否相等。

  • 函数原型建议int HexStringCompare(const char* str1, const char* str2);
  • 要求:如果两个字符串相等,则返回一个大于0的值;如果不相等,则返回0。
  • 请尝试独立编程实现这个字符串比较功能,我们将在下节课中继续构建特征码搜索函数。

课程 P38:049 - 动态定位技术 - HexStrCmp 🔍

在本节课中,我们将学习如何编写一个用于比较十六进制字符串的函数,并处理字符串中的大小写转换问题。这是实现后续模糊搜索和特征码定位功能的基础。


回顾与目标 🎯

上一节我们介绍了特征码定位的基本概念。本节中,我们将编写一个自定义的十六进制字符串比较函数。这个函数将允许我们进行精确的字符串比较,并为后续支持通配符的模糊搜索功能打下基础。

编写十六进制字符串比较函数

我们需要创建一个函数来比较两个十六进制字符串是否完全相等。以下是实现该函数的核心步骤。

首先,我们需要获取两个字符串的长度。可以使用标准字符串处理函数来完成这一步。

int len1 = strlen(str1);
int len2 = strlen(str2);

接下来,我们需要确保两个字符串的长度相等。如果长度不相等,我们可以选择提示错误,或者以较短字符串的长度为准进行比较。这里我们选择后一种方式。

int minLen = (len1 < len2) ? len1 : len2;

然后,我们开始循环比较每个字符。如果所有字符都相等,则函数返回 true;否则,返回 false

for (int i = 0; i < minLen; i++) {
    if (str1[i] != str2[i]) {
        return false;
    }
}
return true;

以下是完整的函数实现示例:

bool HexStrCmp(const char* str1, const char* str2) {
    int len1 = strlen(str1);
    int len2 = strlen(str2);
    int minLen = (len1 < len2) ? len1 : len2;

    for (int i = 0; i < minLen; i++) {
        if (str1[i] != str2[i]) {
            return false;
        }
    }
    return true;
}

编译并测试这个函数,可以验证它能够正确判断两个字符串是否相等。


处理大小写问题

在比较十六进制字符串时,我们可能会遇到大小写不一致的情况。例如,"A1B2""a1b2" 在逻辑上是相等的,但直接进行字符串比较会认为它们不相等。

为了解决这个问题,我们需要一个将小写字母转换为大写字母的函数。

单个字符转换函数

首先,我们编写一个函数,用于将单个小写字母转换为大写字母。

char ToUpper(char c) {
    if (c >= 'a' && c <= 'z') {
        return c + ('A' - 'a');
    }
    return c;
}

这个函数检查字符是否在小写字母 'a''z' 的范围内。如果是,则通过加上 ('A' - 'a') 的差值将其转换为大写字母。

整个字符串转换函数

接下来,我们编写一个函数,用于将整个字符串中的小写字母全部转换为大写字母。

void StrToUpper(char* str) {
    int len = strlen(str);
    for (int i = 0; i < len; i++) {
        str[i] = ToUpper(str[i]);
    }
}

这个函数遍历字符串中的每个字符,并调用 ToUpper 函数进行转换。


整合与测试

现在,我们可以在字符串比较函数之前,先调用 StrToUpper 函数将两个输入字符串都转换为大写,然后再进行比较。这样可以确保比较过程不区分大小写。

以下是整合后的比较流程:

  1. 将字符串 str1str2 复制到临时缓冲区。
  2. 调用 StrToUpper 函数将两个临时字符串转换为大写。
  3. 使用 HexStrCmp 函数比较转换后的字符串。

通过这种方式,无论输入字符串是大写、小写还是混合大小写,我们都能进行正确的比较。


总结 📝

本节课中我们一起学习了两个核心内容:

  1. 编写了十六进制字符串比较函数:该函数能够比较两个字符串是否完全相等,并支持以较短字符串为基准进行比较。
  2. 实现了大小写转换功能:通过 ToUpperStrToUpper 函数,我们解决了十六进制字符串比较中的大小写敏感问题,为后续的模糊搜索做好了准备。

下一节课,我们将学习如何读取游戏内存数据,并结合本节课编写的函数,开始实现特征码搜索的逻辑。

课程 P39:050 - 动态定位技术 - ScanFeatureCode 🔍

在本节课中,我们将学习如何编写一个用于在游戏进程内存中搜索特定特征码的主函数。我们将基于之前课程中编写的辅助函数,构建一个完整的动态定位功能。


概述 📋

上一节我们介绍了动态定位相关的辅助函数。本节中,我们将编写名为 ScanFeatureCode 的主函数。该函数的核心功能是:给定一个游戏进程句柄、一个十六进制特征码字符串以及搜索的起始和结束地址,在指定内存范围内查找该特征码,并返回其首次出现的内存地址。

函数设计与说明

该函数需要以下参数:

  • 进程句柄:目标游戏窗口的进程句柄。
  • 特征码字符串:一个十六进制格式的字符串,例如 "AF 1B C3"
  • 起始地址:在进程内存中开始搜索的地址。
  • 结束地址:在进程内存中结束搜索的地址。

这类似于CE(Cheat Engine)扫描中的“起始地址”和“结束地址”选项。我们的特征码字符串则对应CE中的字节数组。

以下是函数的核心逻辑步骤:

  1. 参数预处理:将传入的特征码字符串转换为统一的大写格式,并计算其实际的字节长度。
  2. 内存分页读取:为了避免一次性读取过多内存,我们采用分页循环读取的方式。每次读取 PAGE_SIZE(例如1024字节)的数据。
  3. 缓冲区比较:将读取到的内存数据逐字节转换为十六进制字符串,并与目标特征码进行比较。
  4. 返回结果:如果找到匹配项,则计算并返回对应的内存地址;如果搜索完整个范围都未找到,则返回空值(NULL)。

代码实现步骤

1. 添加函数声明与预处理

首先,在头文件中添加函数声明,并在CPP文件末尾开始实现。

// 函数声明示例
DWORD ScanFeatureCode(HANDLE hProcess, const char* featureCode, DWORD startAddr, DWORD endAddr);

在函数内部,首先进行特征码的预处理:

// 1. 转换特征码为大写
char upperCode[256];
strcpy(upperCode, featureCode);
// ... 调用之前编写的大写转换函数 ...

// 2. 计算特征码的字节长度
// 字符串"AF 1B"的长度是5,但字节长度为2(AF和1B各占一字节)
int codeByteLen = (strlen(upperCode) + 1) / 3; // 简单估算,实际需处理空格
// 更健壮的方法是移除空格后计算字符串长度的一半

2. 分页读取内存与搜索

接下来,我们实现循环读取内存并进行比较的逻辑。

以下是内存读取和搜索循环的核心结构:

// 定义每页读取的大小
const int PAGE_SIZE = 1024;
// 计算缓冲区大小,需容纳一页数据加上特征码长度,以确保边界数据能被搜索到
int bufferSize = PAGE_SIZE + codeByteLen + 1;
BYTE* buffer = new BYTE[bufferSize]; // 动态分配缓冲区

SIZE_T bytesRead; // 实际读取的字节数

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/08cd0e0e04aad7fd791b99d497b8adbb_10.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/08cd0e0e04aad7fd791b99d497b8adbb_12.png)

// 从起始地址开始循环读取,直到结束地址
for (DWORD curAddr = startAddr; curAddr < (endAddr - codeByteLen); curAddr += PAGE_SIZE) {
    // 读取进程内存到缓冲区
    BOOL success = ReadProcessMemory(hProcess, (LPCVOID)curAddr, buffer, bufferSize, &bytesRead);
    if (!success || bytesRead == 0) {
        continue; // 读取失败或无可读数据,跳过当前页
    }

    // 在读取到的缓冲区数据中逐字节搜索特征码
    for (int i = 0; i < PAGE_SIZE; i++) {
        // 将当前字节及其后续字节转换为十六进制字符串
        char tempHexStr[256] = {0};
        // ... 调用字节转十六进制字符串的函数,从buffer[i]开始转换codeByteLen个字节 ...

        // 比较转换后的字符串与目标特征码
        if (strcmp(tempHexStr, upperCode) == 0) {
            // 找到匹配!计算实际内存地址并返回
            DWORD foundAddr = curAddr + i;
            delete[] buffer; // 释放缓冲区内存
            return foundAddr;
        }
    }
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/08cd0e0e04aad7fd791b99d497b8adbb_14.png)

// 循环结束未找到
delete[] buffer;
return NULL;

3. 整合与测试

编写完主函数后,我们需要编写测试代码来验证其功能。

以下是测试步骤的代码框架:

// 1. 获取游戏窗口句柄
HWND hWnd = FindWindow(NULL, "游戏窗口标题");
if (hWnd == NULL) {
    printf("未找到游戏窗口。\n");
    return -1;
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/08cd0e0e04aad7fd791b99d497b8adbb_20.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/08cd0e0e04aad7fd791b99d497b8adbb_22.png)

// 2. 获取进程ID和具有读取权限的进程句柄
DWORD pid;
GetWindowThreadProcessId(hWnd, &pid);
HANDLE hProcess = OpenProcess(PROCESS_VM_READ, FALSE, pid);
if (hProcess == NULL) {
    printf("无法打开进程。\n");
    return -1;
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/08cd0e0e04aad7fd791b99d497b8adbb_24.png)

// 3. 定义要搜索的特征码(示例)
const char* myFeatureCode = "6A 19 D1 ..."; // 替换为实际特征码

// 4. 调用搜索函数
// 假设我们已知模块基址在0x400000附近,搜索范围可以设定得小一些以提高速度
DWORD startAddr = 0x400000;
DWORD endAddr = 0x7FFFFFFF; // 32位用户空间大致范围
DWORD foundAddress = ScanFeatureCode(hProcess, myFeatureCode, startAddr, endAddr);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/08cd0e0e04aad7fd791b99d497b8adbb_26.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/08cd0e0e04aad7fd791b99d497b8adbb_28.png)

// 5. 输出结果
if (foundAddress != NULL) {
    printf("找到特征码!地址:0x%X\n", foundAddress);

    // 6. (可选)根据找到的地址读取数据,例如读取基址
    DWORD baseAddr = 0;
    ReadProcessMemory(hProcess, (LPCVOID)(foundAddress + 0x21), &baseAddr, sizeof(baseAddr), NULL);
    printf("读取到的基址:0x%X\n", baseAddr);
} else {
    printf("未找到特征码。\n");
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/08cd0e0e04aad7fd791b99d497b8adbb_29.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/08cd0e0e04aad7fd791b99d497b8adbb_31.png)

// 7. 清理资源
CloseHandle(hProcess);

性能优化思考 💡

在测试中,我们发现搜索速度可能较慢。性能瓶颈很可能出现在内层循环的字节到十六进制字符串的转换部分。频繁调用 sprintf 等格式化函数在大量循环中会显著影响效率。

优化思路是:编写一个高效的自定义函数来替代通用的格式化转换。这个函数应专门针对将少量字节快速转换为十六进制字符串的场景进行优化,避免在循环中调用开销较大的库函数。

总结 🎯

本节课中我们一起学习了如何构建 ScanFeatureCode 函数来实现动态内存特征码定位。我们掌握了以下关键点:

  1. 函数设计:明确了输入参数(进程句柄、特征码、地址范围)和输出结果(找到的地址)。
  2. 核心逻辑:采用分页读取的方式遍历内存,并在每个页面内进行逐字节比较以查找特征码。
  3. 实现细节:包括字符串预处理、内存操作、地址计算和资源管理。
  4. 测试验证:通过获取游戏进程、调用搜索函数并解析结果,验证了功能的正确性。
  5. 优化方向:识别了当前实现中可能存在的性能瓶颈,并提出了通过编写高效转换函数来提升搜索速度的优化方案。

通过本课的学习,你已经掌握了动态定位技术中最核心的搜索功能实现。下节课我们将聚焦于性能优化,尝试改进代码以提升搜索效率。

课程 P4:015-DbgPrintMine 与变参函数 📝

在本节课中,我们将学习如何创建一个名为 DbgPrintMine 的自定义调试输出函数,以替代 Windows API 中的 OutputDebugString 函数。OutputDebugString 使用不便,因为它只能接受一个字符串参数,无法像 C 语言的 printf 函数那样格式化输出变量信息。我们将实现一个支持可变参数的函数,使其使用起来更加灵活方便。


认识变参函数 🔍

上一节我们介绍了课程目标,本节中我们来看看什么是变参函数。变参函数是指参数个数可以变化的函数,例如 C 语言标准库中的 printf 函数。它使用一个格式化字符串和后续的可变数量参数来输出信息。

在开始编写 DbgPrintMine 之前,我们需要先了解如何在 C/C++ 中定义和使用变参函数。

以下是定义一个简单变参函数的基本步骤:

  1. 包含头文件 <stdarg.h>
  2. 函数声明中使用省略号 ... 表示可变参数。
  3. 使用 va_list 类型定义一个参数列表。
  4. 使用 va_start 宏初始化参数列表。
  5. 使用 va_arg 宏逐个获取参数。
  6. 使用 va_end 宏结束参数获取。

下面是一个简单的示例函数 DebugPrintMan

#include <stdio.h>
#include <stdarg.h>

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/34d1fc2444035e30f39c0555c1cb731d_3.png)

void DebugPrintMan(const char* format, ...)
{
    // 定义一个参数列表
    va_list args;
    // 初始化参数列表,format 是最后一个固定参数
    va_start(args, format);
    
    // 假设我们知道有三个参数:int, int, char*
    int arg1 = va_arg(args, int);
    int arg2 = va_arg(args, int);
    char* arg3 = va_arg(args, char*);
    
    // 使用 printf 打印获取到的参数
    printf(format, arg1, arg2, arg3);
    
    // 清理工作
    va_end(args);
}

调用方式如下:

DebugPrintMan("参数:%d, %d, %s\n", 111, 222, "abc");

这种方法需要预先知道参数的个数和类型,并不灵活。


使用 vsprintf 简化变参处理 🛠️

上一节我们介绍了手动获取变参的方法,本节中我们来看看一种更简便的方法。我们可以使用 vsprintf 函数(或其安全版本 vsprintf_s)来直接处理格式化字符串和可变参数列表,这比手动使用 va_arg 逐个获取要方便得多。

以下是使用 vsprintf_s 的改进版本:

#include <stdio.h>
#include <stdarg.h>

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/34d1fc2444035e30f39c0555c1cb731d_13.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/34d1fc2444035e30f39c0555c1cb731d_15.png)

void DebugPrintMan(const char* format, ...)
{
    char buffer[1024]; // 定义一个足够大的缓冲区
    va_list args;
    
    va_start(args, format);
    // 将格式化字符串和参数列表组合到 buffer 中
    vsprintf_s(buffer, format, args);
    va_end(args);
    
    // 输出结果
    printf("%s", buffer);
}

现在,这个函数可以接受任意数量、任意类型的参数,只要它们与格式化字符串匹配即可。调用方式与 printf 完全相同:

DebugPrintMan("调试信息:%d, %s\n", 123, "测试");


整合 OutputDebugString 并添加前缀 🏷️

上一节我们实现了一个灵活的格式化输出函数,本节中我们来看看如何将其与实际的调试输出 API 结合。我们的目标是替换 OutputDebugString,因此需要将格式化后的字符串传递给 OutputDebugStringW(宽字符版本)。

同时,为了在调试器(如 DbgView)中过滤出我们自己的调试信息,避免与其他程序的输出混淆,我们为输出的字符串添加一个特定前缀,例如 [Game]

以下是整合后的 DbgPrintMine 函数:

#include <Windows.h>
#include <stdio.h>
#include <stdarg.h>

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/34d1fc2444035e30f39c0555c1cb731d_35.png)

void DbgPrintMine(const char* format, ...)
{
    char buffer[1024];
    char finalBuffer[1100]; // 预留空间给前缀
    va_list args;
    
    // 添加自定义前缀
    strcpy_s(finalBuffer, "[Game] ");
    
    va_start(args, format);
    // 将用户传入的格式和参数格式化到 buffer
    vsprintf_s(buffer, format, args);
    va_end(args);
    
    // 将格式化后的内容追加到带前缀的字符串后
    strcat_s(finalBuffer, buffer);
    
    // 调用 Windows API 输出调试信息(需要转换为宽字符)
    OutputDebugStringA(finalBuffer);
}

现在,调用 DbgPrintMine("值:%d", 100); 将在调试器中输出 [Game] 值:100。这样,我们可以在调试器中设置过滤器,只显示包含 [Game] 的日志,使输出更加清晰。


区分 Debug 与 Release 版本 🚦

上一节我们完成了核心功能,本节中我们来做最后一项优化。调试信息通常在开发调试(Debug)版本时需要,而在发布(Release)版本时不需要。我们希望在 Release 版本中彻底移除调试输出代码,以提高性能并防止他人通过调试信息分析我们的程序。

这可以通过预处理器指令 #ifdef _DEBUG 来实现。这个宏在 Debug 编译配置下会被定义,在 Release 下则不会。

以下是最终的 DbgPrintMine 函数:

#include <Windows.h>
#include <stdio.h>
#include <stdarg.h>

#ifdef _DEBUG
void DbgPrintMine(const char* format, ...)
{
    char buffer[1024];
    char finalBuffer[1100];
    va_list args;
    
    strcpy_s(finalBuffer, "[Game] ");
    
    va_start(args, format);
    vsprintf_s(buffer, format, args);
    va_end(args);
    
    strcat_s(finalBuffer, buffer);
    OutputDebugStringA(finalBuffer);
}
#else
// 在 Release 版本下,DbgPrintMine 是一个空函数,调用它不会产生任何代码
#define DbgPrintMine(...) ((void)0)
#endif

使用方式:

  • 在 Debug 模式下编译,DbgPrintMine 会正常输出信息。
  • 在 Release 模式下编译,DbgPrintMine 调用会被预处理器替换为空操作,不产生任何有效代码和字符串信息,既提升了效率,也增强了安全性。

将上述函数定义放在公共头文件(如 common.h)中,并在所有需要使用的源文件中包含此头文件,即可在整个项目中方便地使用这个增强的调试输出工具。


总结 📚

本节课中我们一起学习了如何创建和使用变参函数。我们从认识变参函数的基本原理开始,然后使用 vsprintf 简化了参数处理过程。接着,我们将格式化输出与 Windows 的 OutputDebugString API 结合,并添加了自定义前缀以便过滤日志。最后,我们利用 #ifdef _DEBUG 预处理器指令,使调试输出功能只在 Debug 版本中生效,从而优化了 Release 版本的性能与安全性。

最终,我们成功实现了一个比 OutputDebugString 更强大、更便捷的调试输出函数 DbgPrintMine,它支持格式化字符串,能输出变量信息,并且易于管理。

课程 P40:051-动态定位技术代码优化-扫描提速 🚀

在本节课中,我们将学习如何优化上一节课编写的特征码搜索函数,以解决其运行速度慢、CPU占用率高的问题。我们将通过编写一个自定义的字节转十六进制字符串函数来替换原有的sprintf调用,从而显著提升扫描效率。


概述

上一节我们介绍了特征码搜索函数的基本实现,但在测试中发现其搜索定位速度特别慢,CPU资源占用非常高。本节中,我们来看看如何通过代码优化来进行相关的提速处理。

打开第50课的代码。

经过测试发现,我们所编写的代码大部分是手写的。在循环内部,我们反复调用一个将字节数组转换为十六进制字符串的函数。这个函数是我们纯手工编写的,逻辑本身应该没有问题。问题可能出在循环外部调用的itoaBytesToHexString函数,以及在循环内部频繁使用的sprintf格式化字符串函数。本节课我们将尝试自己编写一个函数来替换sprintf

在替换的同时,必须保证功能不变。也就是说,我们需要自己实现将字节数据转换为十六进制字符串形式的功能。

在书写新函数之前,我们先做一下分析。

转换原理分析

首先,假设我们用c来表示一个字节的数值(例如255),我们要将其转换为十六进制字符串"FF"。应该如何转换呢?

我们可以将c拆分为高位和低位两部分:

  • 低位:可以通过 c % 16 取余数获得。
  • 高位:可以通过 c / 16 取整除结果获得。

例如,对于数值255

  • 255 / 16 = 15 (高位)
  • 255 % 16 = 15 (低位)

两个15在十六进制中都用F表示,因此结果为"FF"

再例如,数值10

  • 10 / 16 = 0 (高位)
  • 10 % 16 = 10 (低位)

00表示,10A表示,因此结果为"0A"

又例如,数值30

  • 30 / 16 = 1 (高位)
  • 30 % 16 = 14 (低位)

11表示,14E表示,因此结果为"1E"

了解了大致原理后,我们就可以开始编写代码了。

编写自定义转换函数

我们将函数的说明复制过来,在其前面编写新的代码。

首先,函数需要接收一个字节c,并返回其对应的两位十六进制字符串。

char* ByteToHex(char* buffer, unsigned char c) {
    // 初始化高位和低位变量
    unsigned char high = 0;
    unsigned char low = 0;

    // 计算高位和低位
    low = c % 16;
    high = c / 16;

    // 处理高位的转换
    if (high > 9) {
        // 大于9时,转换为A-F
        buffer[0] = 'A' + (high - 10);
    } else {
        // 小于等于9时,转换为0-9
        buffer[0] = '0' + high;
    }

    // 处理低位的转换
    if (low > 9) {
        // 大于9时,转换为A-F
        buffer[1] = 'A' + (low - 10);
    } else {
        // 小于等于9时,转换为0-9
        buffer[1] = '0' + low;
    }

    // 为字符串添加结束标志
    buffer[2] = '\0';

    // 返回缓冲区地址
    return buffer;
}

代码编写好后,我们需要先进行测试,确保功能正确。

测试自定义函数

我们在主函数中调用这个新函数进行测试。

// 测试代码
unsigned char c = 222;
char hexBuffer[3];
ByteToHex(hexBuffer, c);
printf("c = %d, Hex = %s\n", c, hexBuffer);

运行测试,如果转换正确,应该输出 c = 222, Hex = DE

测试结果显示222转换成了十六进制字符串DE,证明我们的计算和函数逻辑是正确的。

接下来,我们就可以用这个自定义函数替换掉原搜索函数中频繁调用的sprintf部分。

替换并验证优化效果

将原搜索循环中的sprintf调用替换为我们的ByteToHex函数。

// 替换前
// sprintf(tempHex, "%02X", data[i]);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/27eb916b60a77671287e0fe96bea88e3_32.png)

// 替换后
ByteToHex(tempHex, data[i]);

替换完成后,重新编译并运行特征码搜索程序。

优化后,搜索速度变得非常快,能够瞬间定位到特征码地址。即使不限定起始和结束范围进行全内存扫描,速度也有显著提升。

总结

本节课中,我们一起学习了如何通过优化代码来提升特征码扫描的速度。我们分析了原函数性能瓶颈在于循环内频繁调用sprintf,然后自己实现了一个高效的字节转十六进制字符串函数ByteToHex来替代它。通过这个简单的优化,我们成功解决了扫描速度慢和CPU占用高的问题,使得动态定位技术的实用性大大增强。

课程 P41:052-动态定位与通配符支持 🎯

在本节课中,我们将学习如何为特征码搜索功能添加通配符支持,实现模糊定位搜索。这将使我们的搜索功能更加强大和灵活,能够应对代码中某些字节动态变化的情况。

概述

在之前的课程中,我们实现了精确的特征码搜索。然而,在实际应用中,目标代码的某些部分(例如动态地址或变量)可能会发生变化。为了在这种情况下仍能准确定位,我们需要引入通配符,允许特征码的某些部分匹配任意字节。

通配符的概念

通配符类似于DOS命令中的*?,可以代表任意一个字符。在我们的特征码搜索中,我们将使用特定字符(如X)来表示一个可以匹配任意内容的字节。

例如,假设我们有以下特征码:
55 8B EC 83 EC 10 A1 ?? ?? ?? ?? 85 C0

其中的?? ?? ?? ??部分表示这四个字节可以是任意值,我们的搜索算法将忽略这些位置的精确匹配。

实现步骤

上一节我们介绍了通配符的概念,本节中我们来看看如何修改现有代码来实现它。核心修改点有两处:一是转换函数,二是比较逻辑。

1. 修改特征码转换函数

首先,我们需要创建一个函数,将包含通配符(如?)的特征码字符串,转换为程序内部可识别的格式(例如将?替换为X)。

以下是该函数的核心逻辑描述:

function ConvertPatternWithWildcards(var PatternStr: string): Boolean;
var
  i, Len: Integer;
  C: Char;
begin
  Len := Length(PatternStr);
  for i := 1 to Len do
  begin
    C := PatternStr[i];
    // 判断字符是否为有效的十六进制字符(0-9, A-F)
    if not IsHexChar(C) then
    begin
      // 如果不是,则将其替换为通配符标记,例如 ‘X’
      PatternStr[i] := ‘X’;
    end;
  end;
  Result := True;
end;

其中,IsHexChar函数用于判断一个字符是否属于十六进制字符集(0-9, A-F, a-f)。其逻辑可以用以下公式表示:
有效字符 ∈ {‘0’..‘9’, ‘A’..‘F’, ‘a’..‘f’}

2. 修改特征码比较逻辑

在比较两个特征码字节时,我们需要加入对通配符的判断。只要待比较的双方中,任意一方的当前字节是通配符X,我们就认为这个字节匹配成功,继续比较下一个字节。

以下是修改后的比较逻辑核心描述:

function BytesMatchWithWildcards(Byte1, Byte2: Char): Boolean;
begin
  // 如果任意一个字节是通配符 ‘X‘,则视为匹配
  if (Byte1 = ‘X’) or (Byte2 = ‘X’) then
    Result := True
  else
    Result := (Byte1 = Byte2); // 否则进行精确比较
end;

在搜索的主循环中,我们将调用这个修改后的比较函数来代替原有的精确字节比较。

功能测试

完成上述修改后,我们就可以使用带通配符的特征码进行搜索了。

例如,原本精确的特征码为:A1 F5 98 B0 00
我们可以将其改为:A1 ?? ?? ?? 00
程序在搜索时,会忽略中间三个字节的具体内容,只要首字节是A1,末字节是00,且总长度符合,就能成功定位。

这种方式的优势在于,即使游戏更新导致中间F5 98 B0这三个字节的值发生了变化,只要前后特征稳定,我们依然能够找到目标地址附近。

课后作业与下节预告

本节课我们一起学习了如何实现带通配符的特征码搜索。

为了巩固知识,留给大家一个作业:尝试编写代码,在定位到特征码后,进一步提取出该特征码之后的某个偏移量处的数据。

举个例子:
假设我们定位到的特征码结构如下:
[特征码, 占6字节] [偏移数据, 占4字节]
如果我们要读取偏移数据,就需要计算偏移量。若特征码本身长6字节,那么目标数据的偏移就是6。程序需要能自动解析这种“特征码+偏移”的结构。

在下节课中,我们将以此为基础,实现自动化提取多个“特征码与偏移”对,并最终生成可用于外部调用的、包含这些地址信息的头文件或脚本。


总结:本节课中,我们通过添加特征码转换函数和修改字节比较逻辑,成功实现了支持通配符的模糊搜索功能。这极大地增强了特征码定位的鲁棒性和适用性。

课程 P42:053 - 提取特征码和偏移 🧩

在本节课中,我们将学习如何从游戏内存中提取特征码和偏移量。这是实现自动化定位动态地址的关键步骤,能确保我们的辅助工具在游戏更新后仍能正常工作。

上一节我们已经编辑好了特征码定位函数。本节中,我们将为几个关键的游戏基址整理出相应的特征码字符串和偏移距离。

准备工作

首先,我们打开第46课的基址文件进行整理。

以下是整理基址的步骤:

  1. 将基址管理单元复制出来。
  2. 对于两个功能共用的基址,将其放到列表最后,我们只需要更新前一个即可。
  3. 复制前面需要独立更新的基址并保存。

或者,也可以将整个基址列表复制出来,然后为每一个基址抓取相应的特征码和偏移,并附在后面。

提取人物属性基址

我们从“人物属性”基址开始。首先找到它的基址,上次更新地址是 2F9400

由于我们之前没有为人物属性添加特征码,现在需要转到调试器(OD)中重新提取。

进入调试器,查看人物属性的地址。第一个地址是人物的名字。我们在底部搜索这个字符串,会得到很多结果。

此时,我们随便选择一个地址即可。例如选择其中一个,然后复制它前面或后面的一段代码,用于提取特征码和偏移。

我们复制地址 2F914D0 前面的一段代码作为特征码。提取时,我们通常选择基址前面的稳定代码段,因为游戏更新时,前面的代码变动可能性较小。

接下来计算偏移量。我们可以在调试器里计算:用基址地址减去特征码定位到的地址。计算得出偏移量为 0x18(十进制24)。

读取方式为直接读取计算出的基址地址 02F914D0

至此,我们完成了第一个基址(人物属性)的特征码和偏移提取,并将其保存。

提取背包列表基址

接下来寻找“背包列表”的基址。可以直接在调试器中搜索,因为之前的地址已更新,我们需要最新的基址。

搜索后找到多个地址,我们选择比较靠前的一个作为背包基址。

然后提取它前面的一串代码作为特征码。提取后,可以在内存扫描工具(如CE)中尝试搜索,如果搜索到的地址都是有效的,则证明特征码可用。

注意在搜索时,需要取消勾选“可写”等限制条件。

搜索到一个有效地址后,我们记录它。接着计算偏移量:用基址地址减去特征码定位到的地址。计算得出偏移量为 0x11(十进制17)。

提取使用背包的基址

现在提取“使用背包”功能相关的基址(Qord)。我们寻找最新的地址。

这个地址位于一个循环内部,我们复制循环内的一段代码来提取特征码。

以下是提取特征码的注意事项:

  • 对于像 75 ?? 这样的跳转指令,后面的偏移字节(??)容易变动,需要用通配符表示。
  • 对于不涉及跳转的普通指令(如 6A 01),一般不会变化,可以直接使用。
  • 特征码应尽可能选择稳定、不易变动的代码片段。

编辑好特征码后,在内存扫描工具中测试其唯一性。如果搜索不到或结果不对,需要核对特征码字节,检查是否有遗漏(例如,有时代码中隐藏的字节未被复制下来)。

修正特征码后再次搜索,定位到地址。然后计算偏移量:用目标基址地址减去特征码定位到的地址。计算得出偏移量为 0x18

提取游戏主窗口基址

接下来更新“游戏主窗口”的基址。我们到调试器中直接提取。

选择一段合适的代码。如果一段代码内全是地址数字,游戏更新后这些数字会变,需要大量通配符,不适合做特征码。因此我们应选择指令代码更稳定的片段。

复制一段靠前的代码,用其后半部分制作特征码。在内存扫描工具中搜索,定位到地址。

计算偏移量时需要注意:由于我们搜索的是目标地址后面的特征码来定位到前面的基址,所以偏移量可能是负数。例如,计算得出需要向前回溯4个字节,那么偏移量就是 -4

提取怪物列表基址

最后提取“怪物列表”的基址。我们使用最新更新的地址。

搜索后,选择靠前的一个地址,提取其前面的一段代码作为特征码。

制作特征码时,对于其中的地址数据和跳转偏移,使用通配符表示。编辑好一段足够长的特征码后,在内存扫描工具中定位。

计算偏移量:用怪物列表基址地址减去特征码定位到的地址。计算得出偏移量为 0xEF(十进制239)。

总结与作业

本节课中,我们一起学习了为多个游戏功能基址提取特征码和计算偏移量的完整流程。

剩下的其他基址,请大家结合前面讲解的“快速更新基址”的方法,按照相同的格式提取好特征码。

在下一节第54课中,我们将编写代码,利用这些提取好的特征码和偏移量,统一地、自动化地更新出所有的游戏基址。

作业:请根据本节课的方法,完成剩余基址的特征码提取工作。

课程 P43:054-一键更新基址 🛠️

在本节课中,我们将学习如何编写一个“一键更新基址”的函数。我们将基于之前课程中已完成的特征码定位和偏移提取功能,整合并优化代码,实现自动化读取多个游戏基址。


概述

上一节我们介绍了如何定位特征码并提取基址偏移。本节中,我们将把这些功能封装成一个完整的函数,实现一键读取并更新多个关键的游戏基址。


优化基址定位函数

首先,我们对之前编写的基址定位函数进行优化。在读取内存缓冲区时,原代码固定读取1024字节。但某些地址的数据可能没有这么多,因此我们改用实际读取的字节数作为循环结束条件,以提高效率。

// 优化循环结束条件,使用实际读取的字节数
while (bytesRead > 0) {
    // 处理逻辑...
}

封装一键更新函数

接下来,我们开始封装核心的一键更新基址函数 UpdateAllBases。该函数接收游戏的进程句柄作为参数。

void UpdateAllBases(HANDLE hProcess) {
    // 函数实现...
}

在函数内部,我们需要完成以下几件事:

  1. 定义特征码缓冲区。
  2. 定位特征码地址。
  3. 根据偏移量读取最终基址。
  4. 打印或存储读取到的基址。

以下是具体步骤:

1. 定义特征码缓冲区

首先,在函数头部定义一个缓冲区来存放特征码字符串。

char szPattern[256] = {0};

我们使用第一个基址(例如“人物属性”基址)的特征码来初始化这个缓冲区。注意,复制特征码字符串时需要删除其中的空格。

// 示例:初始化“人物属性”特征码
strcpy(szPattern, "8B 0D ?? ?? ?? ?? 85 C9 74 0F");

2. 定位特征码地址

调用之前封装好的特征码定位函数 FindPattern 来获取特征码所在的内存地址。

DWORD dwPatternAddr = FindPattern(hProcess, szPattern);
if (dwPatternAddr == 0) {
    printf("特征码定位失败。\n");
    return;
}

3. 封装基址读取函数

为了代码清晰,我们单独封装一个用于读取基址的函数 ReadBaseAddress

DWORD ReadBaseAddress(HANDLE hProcess, DWORD dwAddress) {
    DWORD dwBase = 0;
    SIZE_T bytesRead = 0;
    // 读取4字节(32位系统地址长度)
    ReadProcessMemory(hProcess, (LPCVOID)dwAddress, &dwBase, sizeof(DWORD), &bytesRead);
    return dwBase;
}

4. 计算并读取最终基址

定位到特征码地址后,需要加上特定的偏移量,然后读取该地址存储的值,这才是真正的基址。

// 假设“人物属性”基址的偏移是 0x19
DWORD dwFinalAddr = dwPatternAddr + 0x19;
DWORD dwBaseValue = ReadBaseAddress(hProcess, dwFinalAddr);
printf("人物属性基址: 0x%X\n", dwBaseValue);

测试与验证

完成上述代码后,我们进行测试。将函数集成到主程序中,并传入正确的进程句柄。

int main() {
    HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId);
    if (hProcess) {
        UpdateAllBases(hProcess);
        CloseHandle(hProcess);
    }
    return 0;
}

运行程序,控制台应能正确输出读取到的基址数值。如果数值错误,请检查特征码字符串和偏移量是否正确。


扩展更新其他基址

按照相同的模式,我们可以扩展函数,一次性更新多个基址,例如背包基址、怪物列表基址等。

以下是更新“背包基址”的示例步骤:

  1. 替换特征码:将 szPattern 缓冲区的内容替换为背包的特征码。
  2. 更新偏移量:计算并使用背包基址对应的偏移量(例如 0x11)。
  3. 输出结果:打印读取到的背包基址。
// 更新背包基址
strcpy(szPattern, "A1 ?? ?? ?? ?? 8B 4D 08"); // 背包特征码
dwPatternAddr = FindPattern(hProcess, szPattern);
if (dwPatternAddr) {
    dwFinalAddr = dwPatternAddr + 0x11; // 背包偏移
    dwBaseValue = ReadBaseAddress(hProcess, dwFinalAddr);
    printf("背包基址: 0x%X\n", dwBaseValue);
}

对于更复杂的多级指针基址(例如“怪物列表”),其读取逻辑需要额外步骤:

  • 首先读取一级指针地址。
  • 然后根据该地址再次读取,并加上二级偏移。
  • 最后才能得到真正的基址。

这需要仔细分析内存结构并调整代码逻辑。


总结

本节课中我们一起学习了如何将特征码定位与偏移读取功能整合,编写出一个“一键更新基址”的函数。我们优化了读取逻辑,封装了通用函数,并实现了对多个游戏基址的自动化读取。

关键点在于:

  • 优化循环:使用实际读取字节数作为条件。
  • 函数封装:使代码结构清晰,易于维护。
  • 模式复用:通过替换特征码和偏移量,快速扩展新基址的读取功能。

下一节课,我们将探讨如何通过编程自动生成包含这些基址的头文件,实现真正意义上的“一键更新”,无需手动修改代码。

课程 P44:055 - 一键更新基址并生成 BaseGame.h 🛠️

在本节课中,我们将学习如何完善上一课的基址更新功能,使其不仅能显示更新结果,还能自动生成一个可直接在项目中使用的头文件(如 BaseGame.h)。我们将通过添加文件操作代码来实现这一目标。


上一节我们介绍了如何编写代码来更新游戏基址并显示在屏幕上。本节中,我们来看看如何将这些数据自动写入到一个头文件中,以便后续项目直接引用。

首先,我们需要将上节课完成的、更完善的基址更新函数代码,整合到当前项目中。具体操作是定位到“基址定位单元”代码的末尾,并用新的函数替换旧版本。

替换完成后,应编译项目以确保代码无误。此时运行程序,更新后的基址数据会显示在控制台屏幕上。

然而,我们的目标是自动生成文件。此外,我们注意到生成的地址数据前需要添加 0x 前缀,这在之前的代码中被遗漏了。因此,我们需要先修正输出格式。

以下是修正输出格式的步骤,确保每个地址前都有 0x 前缀:

  1. 在格式化字符串中,将 %x 修改为 0x%x
  2. 更新所有相关的 printf 语句。

修正后,再次测试,控制台输出的地址就会带有正确的前缀格式。

但是,手动从控制台复制这些代码到头文件仍然很麻烦。因此,我们需要将程序的输出重定向到一个文件中。

在C语言中,可以使用 freopen_s 函数来实现标准输出的重定向。这个函数比旧的 freopen 更安全。它的作用是将原本输出到屏幕(stdout)的内容,转而写入到我们指定的文件中。

核心函数原型如下:

errno_t freopen_s( FILE** pFile, const char* path, const char* mode, FILE* stream );

以下是使用 freopen_s 将输出重定向到文件的关键步骤:

  1. 重定向到文件:在开始输出基址信息前,调用 freopen_s,将 stdout 流重定向到我们想要创建的文件(例如 BaseGame.h)。
  2. 执行更新操作:调用基址更新函数,此时所有 printf 的输出都会写入文件。
  3. 恢复标准输出:在基址更新完成后,再次调用 freopen_s,将 stdout 流重定向回控制台(使用特殊路径 "CONOUT$")。这样,后续如“更新完成”的提示信息就能正常显示在屏幕上了。

在实现过程中,需要注意项目生成目录的设置。为了便于找到生成的头文件,建议在项目属性中,将“输出目录”和“中间目录”设置为相同的路径(例如 .\exe\)。同时,将“调试”配置中的“工作目录”也设置为该路径。

设置好路径并添加上述文件操作代码后,重新编译并运行程序。程序执行完毕后,在设定的输出目录(如 exe 文件夹)下,就能找到自动生成的 BaseGame.h 文件。这个文件包含了所有已更新的、格式正确的基址定义。

最后,我们可以将这个新生成的 BaseGame.h 文件复制到其他游戏项目(例如第46课的代码项目)中,替换旧的头文件,并相应修改源代码中的包含语句(例如将 #include "Base.h" 改为 #include "BaseGame.h"),即可使用最新的基址。


本节课中我们一起学习了如何通过 freopen_s 函数将程序输出重定向到文件,从而实现了在更新基址后自动生成 BaseGame.h 头文件的功能。这大大提高了开发效率,使得基址更新和代码集成变得自动化。

课程 P45:056 - 更新基址后的测试与调试 🐛

在本节课中,我们将学习如何对自动更新基址后生成的头文件进行测试和调试,以发现并修复潜在的代码错误,确保外挂功能正常运行。

上一节我们实现了自动更新基址头文件的机制。本节中,我们来看看如何测试更新后的代码,并解决编译和运行时遇到的问题。

编译测试与换行符问题

首先,我们打开第55课的工程代码进行编译。

编译过程中,编译器提示了一个失败信息:“请将原文件转换为多次格式或者是unit music格式”。我们注意到,生成的头文件中换行符与其他头文件不同。

问题根源在于,我们之前生成头文件时,添加了回车(\r)和换行(\n)两个字符。实际上,在C/C++头文件中,通常只需要一个换行符(\n)即可。

以下是解决步骤:

  1. 在代码中找到负责生成头文件字符串的部分。
  2. 将包含回车换行(\r\n)的代码段剪切出来。
  3. 使用记事本的替换功能,将 \r\n 全部替换为 \n
  4. 将修改后的代码段粘贴回原处。

修改完成后,重新生成解决方案。此时编译应能通过。

路径配置与文件替换

编译成功后,需要找到生成的 exe 文件。有时输出目录可能与预期不符。

  1. 检查项目属性中的“输出目录”设置,确认 exe 文件的生成位置。
  2. 将新生成的 exe 文件复制到本课(第56课)的代码目录下,替换旧的 BaseUnit 相关文件。
  3. 替换后,再次编译整个解决方案。

运行时异常分析与修复

编译通过后,将程序注入游戏进行测试。开启挂机功能后,调试信息查看器报告了异常。

异常主要指向两个功能模块:

  • F1F10GetAddr 函数。
  • 放置技能的 Call 函数。

这表明这两个函数对应的基址或偏移量可能存在错误。

修复 F1-F10 的 GetAddr 偏移

首先检查 F1F10GetAddr 相关代码。

在头文件中,我们发现 F1F4 的基址明显错误。回顾生成单元的代码,F1F10 部分的偏移量计算有误。

正确的偏移量计算应为:
目标地址 - 基地址 = 偏移量

例如,若特征码定位到的地址是 0x17114,基地址是 0xDFD,则偏移量应为:
0x17114 - 0xDFD = 0x16317

我们需要将头文件中错误的偏移值修正为计算出的正确结果。

修复放置技能 Call 的偏移

接下来检查放置技能 Call 的异常。

在头文件中找到放置技能 Call 的偏移设置。对比之前的手动笔记,发现此处偏移量设置不正确。

例如,笔记中记录的偏移可能是 0xEC,但头文件中误写为 0x28。我们需要根据原始笔记修正这个偏移值。

修正完成后,保存所有更改。

最终测试与验证

完成上述修复后,需要重新生成基址头文件并进行最终测试。

  1. 关闭当前工程。
  2. 运行修正后的生成程序,重新生成 BaseUnit.h 头文件。
  3. 将新生成的头文件替换到工程中。
  4. 重新编译整个解决方案。
  5. 运行程序,注入游戏,再次开启挂机功能。

此时,观察调试信息查看器,不再有异常报告。切换到游戏内,验证外挂功能(如自动挂机)是否正常工作。若功能恢复正常,则说明所有基址更新和错误修复均已完成。


本节课中我们一起学习了如何对自动更新的基址进行测试和调试。我们解决了因换行符导致的编译错误,修正了输出路径,并重点分析了运行时异常,通过核对和修正偏移量修复了 F1-F10 功能与放置技能功能的错误。最终,我们完成了整个更新流程的验证,确保了外挂功能的稳定性。

课程 P46:057-寻路分析-相关数据准备 🧭

在本节课中,我们将学习如何通过分析关键数据来逆向游戏中的寻路功能。我们将重点寻找并分析三个关键数据:目的地坐标、人物角色对象和寻路状态,为后续定位寻路逻辑代码打下基础。

概述

寻路功能分析通常有两种思路:一是通过分析关键数据(如坐标)来定位相关逻辑;二是通过分析网络发包函数来回溯。本节课我们专注于第一种方法,即通过关键数据进行逆向分析。

目的地坐标分析 🎯

上一节我们介绍了分析思路,本节中我们来看看如何定位目的地坐标。目的地坐标是寻路功能的核心数据之一,通常以浮点数形式存储在连续的内存空间中。

以下是分析目的地坐标的步骤:

  1. 在游戏中记录当前位置的X坐标值。
  2. 打开CE(Cheat Engine)并附加到游戏进程。
  3. 由于坐标通常为浮点数,在CE中首次扫描时选择“浮点数”类型和“未知的初始值”。
  4. 让游戏角色开始移动或寻路,此时目的地坐标会发生变化。
  5. 在CE中搜索“变动的数值”来缩小范围。
  6. 通过多次改变目的地(如在游戏地图上点击不同位置),并交替搜索“变动的数值”和“未变动的数值”,最终精确定位到存储目的地坐标的地址。
  7. 找到的地址可能不止一个。需要区分:在寻路开始时仅访问一次的地址,很可能在寻路逻辑代码附近;而被反复访问的地址,可能在持续移动的“走路”逻辑代码中。

通过此流程,我们找到了目的地坐标的地址。其基址和偏移可表示为:[动作对象基址 + 0x298] + 0x234(X坐标)。Y坐标通常紧随其后。

人物角色对象分析 👤

接下来,我们分析寻路时是否访问了人物角色对象。该对象通常包含角色的各种属性,可能也与寻路状态相关。

以下是分析人物角色对象的思路:

  1. 利用已知的人物角色对象地址(例如,之前通过角色名找到的地址)。
  2. 在CE中查看该地址,并添加对它的访问监视。
  3. 触发寻路功能,观察是否有新的、仅在寻路时发生的对该地址的访问操作。
  4. 记录下这些只执行了一次或少数几次的访问指令地址,它们更可能位于寻路逻辑中。

分析发现,在寻路过程中确实有对人物角色对象指针的访问。其基址和偏移可表示为:[人物对象基址 + 0x18] + 0x1964,此指针指向一个包含X、Z、Y坐标的结构体。

寻路状态分析 🔄

最后,我们来寻找可能存在的寻路状态标志。这个标志可能是一个布尔值(0/1),用于指示当前是否处于自动寻路状态。

以下是分析寻路状态标志的步骤:

  1. 在未寻路时,于CE中搜索字节类型的数值“0”。
  2. 开始寻路,搜索变动的数值“1”。
  3. 停止寻路,搜索变动的数值“0”。
  4. 重复“开始-停止”寻路的过程,并交替搜索1和0,最终定位到状态标志地址。
  5. 通过对比发现,有两个地址的值会变化:一个在走路和寻路时都为1(走路状态);另一个仅在寻路时为1(寻路状态)。我们需要的是后者。

通过分析访问该寻路状态标志的代码,我们找到了其基址与偏移:[动作对象基址 + 0x298] + 0x228。该标志为单字节大小。

关键数据汇总

本节课我们通过逆向分析,找到了与寻路相关的三个关键数据及其内存结构:

  • 寻路状态字节 [动作对象基址 + 0x298 + 0x228]
  • 目的地坐标X浮点数 [[动作对象基址 + 0x298] + 0x234]
  • 目的地坐标Y浮点数 [[动作对象基址 + 0x298] + 0x238]
  • 角色坐标指针指针 [人物对象基址 + 0x18 + 0x1964] (指向{X, Z, Y}结构体)

其中,“动作对象基址”是一个待进一步明确含义的全局基址。

总结

本节课中我们一起学习了如何为逆向分析游戏寻路功能准备关键数据。我们掌握了定位并分析目的地坐标、人物角色对象以及寻路状态标志的具体方法,并将找到的数据整理成了明确的偏移公式。在下一节课中,我们将利用这些宝贵的数据作为突破口,深入追踪并分析寻路的核心逻辑代码。

逆向工程教程 P47:058-寻路CALL分析测试 🧭

在本节课中,我们将学习如何通过逆向工程分析游戏中的寻路功能,定位并测试关键的寻路CALL。我们将从之前课程标记的代码入手,逐步分析堆栈、参数,并最终编写出可用的测试代码。


分析可疑的CALL地址

上一节我们介绍了如何标记访问寻路数据的代码。本节中,我们来看看如何通过调试工具,从这些标记的地址中筛选出真正负责寻路功能的CALL。

首先,我们使用调试工具附加到游戏进程,并转到之前标记的第一个地址。这个地址存储了目的地的坐标。我们在访问该地址的代码处下断点。

当我们尝试移动角色时,断点并未触发。这表明上层的调用可能并非直接的寻路逻辑。因此,我们使用 Ctrl+F9(执行到返回)功能,逐层返回到调用者,并对每一层返回的地址都下断点并做标记。

以下是我们在堆栈中追踪到的几个关键地址:

  • 地址1:最外层的CALL,参数中包含坐标地址,可能性最大。
  • 地址2:参数中同样包含坐标数据,需要测试。
  • 地址3:参数结构与寻路相关,列为可疑对象。

我们优先测试最外层的 地址1,因为它的调用层级最高,最可能是功能入口。


确定CALL的参数结构

确定了可疑的CALL后,我们需要弄清楚它需要哪些参数。观察汇编代码,我们看到类似 push 0x3Fpush eax 的指令,但仅凭此无法确定参数总数。

有两种方法可以确定参数占用的总字节数:

  1. 在CALL执行后,观察 RET 指令后面的数字。例如 RET 0xC 表示平衡掉12字节的堆栈空间,这意味着CALL有 3个参数(每个参数占4字节)。
  2. F7 键步入CALL内部,执行到 RET 指令,同样可以观察到堆栈平衡的数值。

通过分析,我们确认了目标CALL有3个参数。参数顺序(从最后一个开始压栈)是:

  1. 一个整型常量(例如 0x54)。
  2. 一个指向数据结构的指针(存储在 eax 寄存器中)。
  3. 另一个整型常量(例如 0x3F)。

其中,第二个参数 eax 指向的结构体是难点,我们需要知道它的大小和内容。


构建并测试寻路CALL

为了调用这个CALL,我们必须构建出正确的参数,尤其是第二个结构体参数。

我们通过计算CALL内部访问该结构体的最大偏移和最小偏移,来估算其大小。公式如下:

结构体大小 ≈ 最大偏移 - 最小偏移 + 4

分析后,我们估算其大小约为 0x30(48)字节。

接下来,我们从调试器的堆栈窗口中,复制出 eax 指针所指向的 0x30 字节数据。这些数据包含了当前的坐标等信息。

然后,我们开始编写汇编测试代码。核心步骤如下:

  1. 在堆栈上分配 0x30 字节的空间(sub esp, 0x30)。
  2. 将分配的空间地址赋给 eax
  3. 将之前复制的数据,按照正确的偏移填写到 eax 指向的结构体中。关键是要填入目标坐标值
  4. 按正确顺序压入三个参数。
  5. 调用寻路CALL的地址。

以下是代码的核心片段示例:

sub esp, 0x30          ; 分配结构体内存
mov eax, esp           ; eax 指向结构体
mov [eax+0x00], 目标X坐标 ; 填写数据
mov [eax+0x04], 目标Y坐标
mov [eax+0x08], 目标Z坐标
; ... 填写其他结构体成员
push 0x54              ; 压入参数3
push eax               ; 压入参数2 (结构体指针)
push 0x3F              ; 压入参数1
mov eax, 寻路CALL地址
call eax               ; 调用寻路CALL
add esp, 0x30          ; 平衡堆栈

使用代码注入器执行上述代码后,游戏角色成功向指定坐标移动,证明我们找到了正确的寻路CALL。


优化坐标传递方式

在测试代码中,我们直接以十六进制形式填写了坐标的浮点数表示,这不够直观。我们可以使用浮点指令,将十进制的坐标值(如 -153.0)转换为浮点数并压栈,使代码更易读。

这需要用到两条指令:

  • fild:将整数加载到浮点寄存器。
  • fstp:将浮点寄存器的值存储到内存(如堆栈)。

优化后的坐标设置代码如下:

; 设置X坐标 (-153.0)
push -153              ; 将整数压栈
fild dword ptr [esp]   ; 加载整数到浮点寄存器
fstp dword ptr [eax+0x00] ; 存储浮点数到结构体
add esp, 4             ; 平衡堆栈

; 设置Y坐标 (1545.0)
push 1545
fild dword ptr [esp]
fstp dword ptr [eax+0x04]
add esp, 4

这种方法让我们能在代码中直接使用有意义的十进制坐标值。


总结与练习

本节课中我们一起学习了逆向分析寻路功能的完整流程:从追踪可疑地址、分析CALL参数、构建数据结构到最终编写并优化测试代码。

核心收获

  1. 通过堆栈回溯寻找关键CALL。
  2. 利用 RET 后的数值分析参数数量。
  3. 通过偏移计算估算结构体大小。
  4. 编写并注入汇编代码来测试功能。
  5. 使用浮点指令优化参数传递。

课后练习
请尝试分析本节课中标记的 地址2地址3,按照相同的步骤测试它们是否也是有效的寻路CALL。这能帮助你更深入地理解游戏代码的调用链。

课程 P48:059 - 寻路CALL封装与测试 🧭

在本节课中,我们将学习如何将分析得到的寻路CALL代码进行封装,并集成到我们的项目工程中进行测试。我们将从测试通过的汇编代码开始,逐步将其封装为C++函数,并确保其能在游戏主线程中安全调用。


概述

上一节我们分析了寻路CALL并获得了可用的汇编代码。本节中,我们将把这些代码封装成一个可复用的函数,并集成到现有的项目框架中。核心步骤包括:代码移植、参数处理、异常安全封装以及线程安全的调用测试。


1. 回顾与测试寻路代码

首先,我们回顾并测试在第58课中分析得到的寻路CALL代码。这段代码包含目标坐标参数。

以下是测试通过的汇编代码片段,其中包含X和Y坐标:

mov [ebp-0x14], -999
mov [ebp-0x10], 1800
...

我们打开游戏,修改坐标参数,并将代码注入游戏进行测试。测试成功后,角色开始向指定坐标移动,证明代码有效。


2. 将代码集成到项目工程

测试通过后,我们将这段汇编代码复制,准备添加到项目工程中。

我们打开第56课的工程,在“基础单元”中添加相关的机制。

涉及到的关键寄存器是 人物角色对象 的基址,这个基址我们已有定义。在汇编代码中,我们需要将对应的数据替换为该基址。

注意:不能直接在汇编代码中替换内存地址。所有十六进制数在C++中都需要添加 0x 前缀,但十进制数字9以下可以不加。

我们将代码中的坐标数据替换为变量参数:

  • -999 替换为 X坐标 变量。
  • 1800 替换为 Y坐标 变量。

替换时,需要先将变量值移动到寄存器(如 ECX),再从寄存器移动到目标内存地址,因为汇编不允许内存到内存的直接寻址。

修改后的代码结构如下:

mov ecx, [nX]      ; 将X坐标变量值放入ECX
mov [ebp-0x14], ecx ; 将ECX值存入目标地址
mov ecx, [nY]      ; 将Y坐标变量值放入ECX
mov [ebp-0x10], ecx ; 将ECX值存入目标地址

3. 封装寻路函数

接下来,我们在“结构单元”的“人物角色对象”类中封装寻路函数。

我们定义一个简单的整数类型函数,接收两个坐标参数(X和Y)。

以下是封装函数的步骤:

  1. 添加函数声明:在人物角色对象类中添加寻路函数 FindWay(int nX, int nY)
  2. 实现函数体
    • 首先进行异常处理,使用 __try__except 块捕获异常,并打印调试信息。
    • __try 块内,嵌入我们修改后的汇编代码。
    • 执行成功返回 true,发生异常则返回 false

关键实现代码如下(C++内联汇编):

__try {
    __asm {
        // ... 其他汇编指令
        mov ecx, [nX]
        mov [ebp-0x14], ecx
        mov ecx, [nY]
        mov [ebp-0x10], ecx
        // ... 调用寻路CALL的指令
    }
    return true;
}
__except(1) {
    OutputDebugString("寻路CALL调用异常");
    return false;
}

编译通过后,函数封装完成。


4. 进行初步测试

封装完成后,我们首先在测试单元进行直接调用测试。

我们注释掉其他代码,在测试函数中获取全局的人物角色对象,并调用其 FindWay 函数,传入一个近处的坐标(如 -111, 1800)进行测试。

编译并运行测试,观察到角色成功移动到指定坐标,证明基础封装正确。

注意:这种直接调用并不安全,因为它可能不在游戏主线程中执行,容易引发稳定性问题。


5. 实现线程安全调用

为确保稳定,我们需要将寻路调用封装到游戏的主线程中执行。

以下是实现线程安全调用的步骤:

  1. 定义消息和结构:在主线程模块中,定义一个新的消息类型 WM_FIND_WAY 和一个用于传递坐标的结构(这里简化为使用 int 数组,[0]存X,[1]存Y)。
  2. 添加消息处理函数:在主线程的消息循环中,添加对 WM_FIND_WAY 消息的处理。在处理函数中,从参数中解析出坐标数组,然后调用全局人物角色对象的 FindWay 方法。
  3. 提供对外接口:创建一个公开函数(如 SendFindWay),外部模块通过此函数发送 WM_FIND_WAY 消息到主线程,并附带坐标参数。

主线程消息处理关键代码:

case WM_FIND_WAY: {
    int* pPos = (int*)lParam; // 获取传入的坐标数组指针
    if (pPos) {
        g_pRoleObject->FindWay(pPos[0], pPos[1]); // 安全调用
    }
    break;
}

6. 最终集成测试

最后,我们修改测试代码,改为调用线程安全的接口 SendFindWay,并传入一个远处的坐标(如 -122888, 1800)进行测试。

编译并运行,点击测试按钮。观察到角色开始稳定地向远方坐标移动,并最终准确到达,整个过程无异常。这证明我们的寻路CALL封装是成功且线程安全的。


总结

本节课中,我们一起完成了寻路CALL从原始汇编代码到安全、可集成C++函数的完整封装流程。我们学习了如何将汇编代码与C++变量结合,如何封装带异常保护的函数,以及如何通过消息机制实现线程安全调用。这套方法不仅适用于寻路功能,也为封装其他游戏CALL提供了标准流程。下一节课,我们将在此基础上继续探索其他功能的封装。

逆向教程 P49:060-存仓库CALL相关数据收集 🧠

在本节课中,我们将学习如何通过逆向分析,定位并理解游戏中“存物品到仓库”这一功能背后的函数调用(CALL)及其相关数据结构。我们将从附加调试器开始,逐步分析数据发送、参数变化,最终尝试调用这个函数。

概述与准备

上一节我们介绍了物品背包的数据结构。本节中,我们来看看如何将背包中的物品存放到仓库。

存仓库操作通常涉及向服务器发送数据,表明某个物品已被存放。发送的数据中必然包含物品的唯一标识。这个标识可能有两种形式:一是使用背包物品栏的下标,二是访问物品对象内部的物品ID属性。无论哪种情况,在存放物品时,程序都需要访问该物品对象的属性(如下标或ID)。此外,程序还会向服务器发送相关数据。

有了这些基本认识,我们开始进行动态分析。

动态分析与下断点

首先,我们打开调试器并附加到游戏进程。

一般的游戏可以在 send 函数处下断点来拦截网络封包。但针对本游戏,我们需要在 WSASend 函数处下断点。下断后,游戏会立即中断。但此时中断并非最佳时机,因为我们还没有执行存仓库操作。

正确的做法是:先在背包中选中一个想要存放的物品。选中操作会将物品对象写入某个选中地址。然后,我们再在 WSASend 处下断点。接着,用鼠标点击仓库格子进行存放操作。此时,程序会向服务器发送存放该物品的数据包,并触发断点。

回溯调用栈

断点触发后,我们按下 Ctrl+F9(执行到返回)或利用调用栈窗口,一层层向上回溯,寻找调用发送函数的代码。目标是找到负责处理“存仓库”逻辑的函数。

以下是回溯过程中的关键步骤与备注:

  1. 第一层是发包函数本身。
  2. 继续向上返回,到达调用发包函数的地方。我们在此处备注“存仓库1”并下断点。
  3. 再次执行到返回,备注“存仓库2”并下断。
  4. 重复此过程,可以多返回几层(例如5-6层),并依次备注(如存仓库3、4、5...)。
  5. 回溯足够多层后,按 Alt+B 打开断点列表,先禁用所有断点,让游戏继续运行。
  6. 然后,只激活我们认为最可能是“存仓库”逻辑的那个断点(例如“存仓库6”)。

分析函数行为与参数

激活断点后,尝试进行存仓库操作。如果断点正确触发,我们就可以开始分析该函数的参数。

观察栈窗口,可以看到压入函数的参数。例如,我们分别尝试将物品存到仓库的第1、2、4格:

  • 存到第1格:某个参数值为 0
  • 存到第2格:该参数值变为 1
  • 存到第4格:该参数值变为 3

这个参数恰好对应仓库格子的下标(从0开始)。同时,我们发现另外两个参数的值在多次调用中保持不变。

然而,进一步测试发现,在背包内移动物品时,这个函数也会被断下。这说明它可能是一个更通用的“物品移动”函数,而非专用的“存仓库”函数。

区分移动类型

为了区分是“背包内移动”还是“存到仓库”,我们需要观察其他参数。

我们发现,有一个参数用于表示目标容器类型

  • 值为 8 时,表示移动到仓库
  • 值为 1 时,表示在玩家个人背包内移动。

此外,寄存器 ECX 的值(this 指针)在不同操作下也不同,可能指向源容器(如背包)或目标容器(如仓库)的对象。

以下是多次测试后总结的规律:

  • 从仓库取物到背包ECX 值为 0x648,目标类型参数为 1
  • 从背包存物到仓库ECX 值为 0x418,目标类型参数为 8
  • 无论存或取,仓库下标参数都正常变化,另一个参数常为 0

尝试调用函数

基于以上分析,我们可以尝试用代码调用这个“物品移动”函数来模拟存/取操作。

以下是调用示例(伪代码):

// 从仓库取物品到背包(需先选中仓库内物品)
call MoveItemFunction(ECX=0x648, 目标容器类型=1, 仓库下标, 其他参数...);

// 从背包存物品到仓库(需先选中背包内物品)
call MoveItemFunction(ECX=0x418, 目标容器类型=8, 仓库下标, 其他参数...);

核心要点

  1. 调用前必须先选中要操作的物品。
  2. ECX 值和目标容器类型参数共同决定了操作方向(存或取)。
  3. 仓库下标参数指定了物品要移动到的具体格子位置。

如果直接调用而不先选中物品,函数调用将没有效果。

总结与下节预告

本节课中我们一起学习了如何定位和分析游戏中的“物品移动”函数。我们发现,通过组合 ECX 值和目标类型参数,可以复用这个函数来实现“存仓库”和“取仓库”两种操作。但这需要前置的“选中物品”步骤。

我们找到的这个函数是一个通用的物品移动CALL。在它的内部,应该封装了更具体的“存物品到仓库”和“从仓库取物品”的逻辑。因此,它仍然是一个关键的突破口。

下节课,我们将进一步分析在回溯过程中备注的“存仓库1”等函数,看看是否存在更直接、更简洁的专用仓库操作函数,以优化我们的调用方式。

课程P5:016 - 代码运行久了游戏为何异常 🐛

在本节课中,我们将要学习一个在编写游戏外挂或修改器时常见的棘手问题:为什么代码运行一段时间后,游戏会突然崩溃或出现异常?我们将通过一个简单的C++示例来模拟问题的成因,并探讨其背后的原理——多线程访问共享数据冲突。最后,我们会介绍如何使用“临界区”来保护我们自己的代码,避免此类冲突。


问题现象与原因分析

上一节我们测试物品使用功能时,曾出现过异常。这种异常通常不会立即出现,因为当时我们注入代码的窗口线程与游戏的主线程是两个不同的线程。

当游戏主线程和我们编写的代码线程同时访问某些共享数据(如怪物列表、背包列表)时,就可能产生冲突,导致异常。这种异常具有隐蔽性,可能代码运行几小时甚至更久才会出现,造成程序不稳定。

为了说明这种异常是如何产生的,我们将使用Visual Studio 2010创建一个MFC程序来模拟。

模拟多线程数据冲突

我们通过两个按钮来模拟两个线程:“游戏主线程”和“外挂线程”。

首先,我们模拟游戏的数据,定义一个全局数组和一个指向该数组的全局指针。

// 模拟游戏数据块
int g_GameData[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
// 指向游戏数据的指针
int* g_pGameData = nullptr;

模拟游戏主线程

以下是模拟游戏主线程的代码。它在一个循环中不断初始化指针,指向数组的不同部分。

UINT GameMainThread(LPVOID pParam) {
    while (true) {
        // 模拟游戏主线程操作数据
        memset(g_pGameData, 0, sizeof(g_pGameData)); // 先将指针指向的内存清零
        for (int i = 0; i < 5; i++) { // 只初始化前5个元素
            g_pGameData = &g_GameData[i];
            // ... 模拟其他操作
        }
        Sleep(1000); // 延迟,让操作变慢
    }
    return 0;
}

模拟外挂线程(物品使用函数)

以下是模拟外挂线程调用的函数。它尝试读取并打印指针指向的所有数据。

void UseItem() {
    for (int i = 0; i < 10; i++) { // 尝试访问10个元素
        if (g_pGameData != nullptr) {
            // 打印数据
            TRACE("Data[%d]: %d\n", i, *(g_pGameData + i));
        }
        Sleep(50); // 短暂延迟
    }
}

UINT HackThread(LPVOID pParam) {
    while (true) {
        UseItem(); // 外挂线程不断调用物品使用函数
        Sleep(100);
    }
    return 0;
}

运行与异常产生

当我们只运行游戏主线程时,程序一切正常。但一旦同时启动外挂线程,问题就会出现。

外挂线程的 UseItem 函数会尝试访问全部10个数据,而游戏主线程只初始化了前5个。当外挂线程访问下标超过4(即第6个及以后)的数据时,由于那些指针尚未被正确赋值(可能为空或指向无效内存),就会引发访问违规异常,导致程序崩溃。

核心冲突点:两个线程异步地访问和修改同一个全局指针 g_pGameData,缺乏同步机制。


解决方案:使用临界区保护代码

对于我们自己编写的代码,要解决这种多线程数据冲突,可以使用系统提供的同步对象,如临界区

临界区可以确保同一时间只有一个线程能执行被保护的代码段。

临界区的使用方法

以下是使用临界区的关键步骤:

  1. 定义与初始化临界区变量
  2. 在访问共享数据前进入临界区
  3. 访问完成后离开临界区
  4. 最后删除临界区

以下是具体的代码实现:

#include <windows.h>

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/87f6741e8df76abd422916900d460da2_3.png)

// 1. 定义临界区变量
CRITICAL_SECTION g_cs;

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/87f6741e8df76abd422916900d460da2_5.png)

// 初始化函数中
InitializeCriticalSection(&g_cs);

// 游戏主线程修改后
UINT GameMainThread(LPVOID pParam) {
    while (true) {
        EnterCriticalSection(&g_cs); // 进入临界区
        // ... 操作 g_pGameData 的代码
        LeaveCriticalSection(&g_cs); // 离开临界区
        Sleep(100); // 释放时间片,让其他线程有机会进入
    }
    return 0;
}

// 外挂线程修改后
UINT HackThread(LPVOID pParam) {
    while (true) {
        EnterCriticalSection(&g_cs); // 进入临界区
        UseItem(); // 此函数内部也访问 g_pGameData
        LeaveCriticalSection(&g_cs); // 离开临界区
        Sleep(100);
    }
    return 0;
}

// 程序退出时
DeleteCriticalSection(&g_cs);

修改后的运行逻辑

经过上述修改,当游戏主线程进入临界区操作 g_pGameData 时,外挂线程会在 EnterCriticalSection 处等待。直到游戏主线程调用 LeaveCriticalSection,外挂线程才能进入临界区执行 UseItem。这样就强制了两个线程串行化访问共享数据,彻底避免了冲突。

注意:临界区保护的是代码段,所有需要访问同一共享资源的线程都必须使用同一个临界区变量。


总结与下节预告

本节课中我们一起学习了导致代码长时间运行后出现异常的一个常见原因:多线程访问共享数据缺乏同步。我们通过C++示例模拟了游戏主线程与外挂线程的冲突场景,并介绍了使用 CRITICAL_SECTION(临界区) 来保护我们自己的代码,确保线程安全。

然而,我们模拟的是自己的代码。如果要解决与游戏本身代码的冲突(例如Hook游戏函数后发生的冲突),临界区就无能为力了,因为我们无法修改游戏的代码来添加临界区保护。

下一节课,我们将探讨解决这个终极问题的方案:如何将我们的代码挂靠到游戏的主线程中去执行,从而从根本上避免与游戏主线程的异步冲突。

(字幕制作:贝尔)

课程P50:061-存仓库CALL参数分析测试 📦➡️🏦

在本节课中,我们将学习如何分析游戏内存中“存仓库”功能的关键参数,并理解其调用机制。我们将通过逆向分析,找到背包和仓库的对象基址,并最终编写出能够将物品从背包移动到仓库的代码。


概述

上一节我们分析了物品移动功能的一些数据和参数。本节中,我们将深入分析其核心机制,特别是ecxedi参数的来源。理解这些参数后,我们就能通过偏移量直接获取ebxedx的数据,从而完整地调用存仓库功能。


分析参数来源

核心参数ecx(或执行一步后变为ecxedi)是目的地(背包或仓库)的对象基址。ebxedx的数据则来源于该基址加上特定的偏移量。

以下是关键偏移量:

  • +0x410: 指向物品列表的起始位置。
  • +0x5C: 指向物品的名称或其他属性。

这些偏移量在之前分析“物品使用”功能时也出现过,形式类似,但调用的函数库不同。


定位背包与仓库基址

为了验证我们的分析,需要使用调试工具(如OD)附加到游戏进程。

  1. 首先,在背包内移动一个物品,触发我们上节课分析的断点。
  2. 记录此时ecx(或edi)寄存器的值。这个值就是背包对象基址
    • 公式:背包基址 = ecx寄存器值
  3. 接着,在仓库内移动一个物品,再次记录ecx(或edi)的值。这个值就是仓库对象基址
    • 公式:仓库基址 = ecx寄存器值

通过内存查看工具(如CE)搜索并验证这些基址,确认基址+0x410偏移处确实是物品列表。


理解调用机制

分析调用存仓库功能的汇编代码后,我们明确了其工作流程:

  1. 选中物品:首先需要选中背包中待移动的物品。
  2. 调用移动函数:然后调用一个固定的函数(CALL),传入必要的参数。

移动函数的关键参数如下:

  • 参数1 (ecx):目的地对象基址(背包基址或仓库基址)。
  • 参数2 (ebx):来源物品在背包中的下标(索引)。
  • 参数3 (edx):通常为固定值1。
  • 调用地址:一个固定的函数地址(例如 0x720A20)。

因此,存仓库的调用逻辑可以概括为:
调用 目标函数,参数为:(仓库基址, 背包物品下标, 1)


编写选中物品代码

在调用移动函数前,必须先选中物品。我们找到了游戏中“选中物品”功能的代码位置。

以下是实现选中物品的核心汇编代码片段(地址和常量需根据实际分析更新):

mov edi, [背包基址]        ; 将背包对象基址存入edi
mov ebx, [物品下标]        ; 将要选中的物品下标存入ebx
mov eax, [edi+ebx*4+0x410] ; 计算物品对象地址
mov [选中状态地址], eax     ; 写入到选中状态的内存位置

代码说明

  • 背包基址:之前找到的背包对象地址。
  • 物品下标:从0开始的物品格子索引。
  • 选中状态地址:游戏中记录当前选中物品的内存地址。

运行此代码后,游戏内对应的背包物品就会被高亮选中。


整合存仓库功能

将“选中物品”和“调用移动函数”两步结合,就构成了完整的存仓库功能。

以下是完整的操作步骤:

  1. 初始化参数

    • 获取背包基址、仓库基址。
    • 确定要移动的物品在背包中的下标。
  2. 执行选中操作

    • 调用上述“选中物品”的代码,选中目标物品。
  3. 执行移动操作

    • 调用存仓库函数,传入参数:仓库基址、物品下标、1。

代码封装建议

为了方便使用,可以将上述逻辑封装成一个函数。函数原型可以设计如下:

// 函数功能:将背包中指定下标的物品存放到仓库
// 参数:nIndex - 背包中的物品下标(0起始)
// 返回值:成功返回1,失败返回0
int StoreItemToWarehouse(int nIndex) {
    // 1. 定义常量(通过分析获得)
    DWORD dwBackpackBase = 0xXXXXXXX;   // 背包基址
    DWORD dwWarehouseBase = 0xXXXXXXX;  // 仓库基址
    DWORD dwSelectItemFunc = 0xXXXXXXX; // 选中物品CALL地址
    DWORD dwMoveItemFunc = 0x720A20;    // 移动物品CALL地址

    // 2. 选中背包物品
    // 此处需内联汇编或WriteProcessMemory实现选中逻辑
    // ...

    // 3. 调用存仓库CALL
    __asm {
        push 1                 // 参数三
        mov ecx, dwWarehouseBase // 参数一:目的地基址(仓库)
        mov ebx, nIndex        // 参数二:来源下标
        call dwMoveItemFunc    // 调用移动函数
    }

    return 1;
}

注意:此方法通常需要先打开仓库界面才能成功。后续可以探索更简洁的“一键存仓库”函数。


总结

本节课我们一起学习了如何逆向分析游戏中的存仓库功能。

  1. 我们首先分析了关键参数ecx的来源,区分了背包基址仓库基址
  2. 然后,我们理解了存仓库的两步机制:先选中物品,再调用移动函数。
  3. 接着,我们找到了选中物品的代码并理解了其原理。
  4. 最后,我们将所有步骤整合,形成了完整的存仓库逻辑,并给出了代码封装的建议。

通过本课的学习,你不仅掌握了分析特定游戏功能的方法,也加深了对游戏内存结构和函数调用约定的理解。课后作业是尝试将分析出的汇编逻辑,用C语言封装成一个可调用的函数。

课程P51:062-移动物品仓库代码封装 📦➡️🏦

在本节课中,我们将学习如何将之前分析的游戏数据封装成可复用的函数。主要内容包括封装“选中背包物品”和“移动物品到仓库”两个核心功能,并进行测试验证。


概述

上一节我们分析了游戏仓库和背包的相关数据。本节中,我们将把这些零散的汇编代码逻辑封装成结构清晰、易于调用的C语言函数。

第一步:定义基础数据与宏

首先,我们需要将仓库列表的基址以及移动物品功能相关的扩展地址定义在基础单元(头文件)中。

以下是需要定义的核心地址宏:

// 仓库列表基址
#define WAREHOUSE_LIST_BASE 0x031C0F54
// 移动物品功能扩展地址
#define MOVE_ITEM_EXT_ADDR 0xXXXXXXXX
// 背包列表基址
#define BACKPACK_LIST_BASE 0xXXXXXXXX

第二步:封装“选中背包物品”函数

上一节我们介绍了如何通过汇编代码选中背包中的物品。本节中我们来看看如何将其封装成一个C函数。

这个函数的目标是:根据传入的物品在背包中的下标(索引),选中对应的物品。

以下是该函数的实现步骤:

  1. 将背包基址替换为宏 BACKPACK_LIST_BASE
  2. 将选中物品的索引值作为参数传入。
  3. 将相关的汇编代码块放入函数体。
  4. 添加异常处理,确保代码健壮性。
  5. 函数返回选中物品的对象指针,如果选中失败则返回空值 NULL

封装后的函数代码如下:

// 函数:根据下标选中背包中的物品
// 参数:index - 物品在背包中的下标(从0开始)
// 返回值:成功选中则返回物品对象指针,失败返回NULL
void* SelectBackpackItem(int index) {
    void* pItem = NULL; // 用于指向选中物品的临时变量

    __asm {
        // 异常处理开始
        pushad
        try {
            // 汇编代码块:根据 index 计算并选中背包物品
            mov ecx, BACKPACK_LIST_BASE
            mov ecx, [ecx]
            // ... 其他计算和调用指令,使用 index 参数 ...
            mov [pItem], eax // 假设选中后物品指针在EAX中
        }
        except(EXCEPTION_EXECUTE_HANDLER) {
            // 发生异常,清理并返回NULL
            popad
            return NULL;
        }
        // 异常处理结束
        popad
    }
    return pItem; // 返回选中的物品对象
}

编译成功后,我们在主线程单元进行测试。测试时,需要先打开游戏窗口,然后调用 SelectBackpackItem(0) 尝试选中背包第一格的物品(无论是武器还是金刚石),观察游戏内是否成功选中,以验证函数功能。

第三步:封装“移动物品到仓库”函数

成功封装选中功能后,我们继续封装更复杂的操作:将选中的物品移动到仓库。

这个函数的目标是:将指定下标的背包物品移动到仓库中。

以下是该函数的实现步骤:

  1. 复制移动物品的汇编代码逻辑。
  2. 将仓库列表基址替换为宏 WAREHOUSE_LIST_BASE
  3. 将物品下标作为参数传入。
  4. 同样添加异常处理框架。
  5. 函数返回一个整型状态,1代表成功,0代表失败。

封装后的函数代码如下:

// 函数:将背包中指定下标的物品移动到仓库
// 参数:index - 要移动的物品在背包中的下标
// 返回值:1-移动成功,0-移动失败
int MoveItemToWarehouse(int index) {
    int nResult = 0; // 操作结果

    __asm {
        pushad
        try {
            // 首先,选中指定下标的物品
            // ... 调用类似 SelectBackpackItem 的逻辑或直接嵌入汇编 ...
            // 然后,执行移动到仓库的调用
            mov ecx, WAREHOUSE_LIST_BASE
            mov ecx, [ecx]
            // ... 后续移动物品的指令 ...
            mov [nResult], 1 // 执行成功,设置结果为1
        }
        except(EXCEPTION_EXECUTE_HANDLER) {
            popad
            return 0; // 发生异常,返回失败
        }
        popad
    }
    return nResult;
}

在测试这个函数时,我们遇到了问题:代码编译通过,但物品没有移动。通过调试器输出信息,我们发现函数被执行了,但未产生效果。

经过排查,问题出在仓库列表基址 WAREHOUSE_LIST_BASE 的值不正确。我们重新核对前课分析的数据,将基址修正为 0x031C0F54

修正基址后,重新编译并测试。此时,调用 MoveItemToWarehouse(3) 成功将背包第四格(下标为3)的戒指移动到了仓库中,证明函数封装成功。

第四步:更新头文件与课后作业

函数测试成功后,我们需要将最终确定的特征码和基址更新到项目的公共头文件中,方便其他模块调用。

此外,留一个课后作业供大家练习:

作业:封装一个“存放指定物品到仓库”的函数

这个函数不通过下标,而是直接搜索背包中符合条件的物品(例如特定名称或ID),然后将其存放到仓库。

实现思路:

  1. 可以借助现有的遍历背包函数(例如 GetItemByIndex)来查找物品。
  2. 找到物品后,获取其下标。
  3. 调用本节课封装的 MoveItemToWarehouse 函数完成存放。

请大家课后动手实践,完成这个函数的封装,并更新相关的特征码。


总结

本节课中我们一起学习了如何将逆向分析得到的汇编代码封装成实用的C语言函数。我们完成了两个核心功能:

  1. 选中背包物品:根据下标选中物品。
  2. 移动物品到仓库:将选中物品移入仓库。

通过封装,我们使代码更模块化、更易读、更易维护。同时,在调试中修正基址错误的过程,也巩固了逆向工程中验证数据准确性的重要方法。

课程 P52:063-装备更换功能分析 🛠️

在本节课中,我们将学习如何分析游戏中的装备更换功能。我们将基于之前物品移动功能的分析,探索如何将背包中的物品移动到装备栏,并理解其背后的数据结构和调用逻辑。


上一节我们分析了物品在背包和仓库之间的移动。本节中,我们来看看如何更换装备,例如将一件武器从背包装备到角色身上。

实际上,更换装备也是将对象从背包列表移动到装备列表的过程,它很可能调用了与物品移动相同的函数。因此,我们可以延续上一节课的数据分析基础。

我们打开第61课的相关代码,并在相应的函数调用处下断点进行尝试。

首先,我们将调试器附加到游戏进程。


我们在物品移动的函数处下断点。因为更换装备也是一种物品移动,所以可能会在此处中断。此时,程序确实中断了。

我们观察此时的 ECX 寄存器值是 680。我们记录下这个 ECX 的数值,并检查它是否指向相关数据。我们查看 ECX + 0x410 地址的内容。

此时,我们发现其指向的第一个物品是“地藏枪一”。第一个位置(下标0)对应的是装备栏中间的位置。因为所有对象(背包、仓库、装备栏、快捷栏)都使用相同的对象格式,并通过 +0x410 这个偏移量来访问物品数组。

我们再查看第二个位置(下标1)的物品,它是一个“领域副手”,属性为防御+8。第三个位置(下标2)是空的。第四个位置(下标3)才是我们的武器栏。

因此,我们知道武器在装备列表中的下标值是 3

我们继续执行,再次中断时,发现参数中传入了下标值 3。后面两个参数的值都是 0。这两个零值分别来源于装备列表指针偏移 +0x1608 处的值,以及 EBP-0xD0 处的值。

这个下标参数是必须传入的,这与移动到仓库时不同(移动到仓库时,目标位置可能由函数内部自动计算)。

了解了这些信息后,我们接下来寻找装备列表的基址指针。

我们首先使用 CE(Cheat Engine)搜索 ECX 的值。


此时搜出两个地址。一个应该是全局所有对象列表中的地址,另一个才是真实的装备列表基址指针。我们修改这个值,然后在游戏中进行验证,以确定正确的基址。

找到的基址指针会被多处代码引用。我们选择第一个结果,并将其记录下来。注意,这是装备列表的基址。

第二个地址(绿色的)通常位于数据段(.data.rdata),可能属于全局对象列表,不一定是我们需要的那个变量。

接下来,我们进行功能测试。我们退回游戏,尝试更换装备。


以下是编写调用代码的思路:

首先,我们可以参照前几节课的代码结构。实际上,我们可以在上一节课(第62课)的“移动到仓库”代码基础上进行修改。

我们需要修改的核心部分如下:

  1. 目标列表基址从仓库地址改为装备列表地址。
  2. 传入的目标下标参数固定为武器的下标 3
  3. 传入的源下标参数(背包中的位置)根据实际情况选择,例如第二个格子(下标1)。

修改后的关键代码逻辑如下(伪代码表示):

// 假设函数原型:MoveItem(源列表基址, 源下标, 目标列表基址, 目标下标, 参数4, 参数5)
DWORD equipBaseAddr = 0xXXXXXX; // 装备列表基址
DWORD backpackBaseAddr = 0xYYYYYY; // 背包列表基址
int fromIndex = 1; // 从背包第2格(下标1)取物品
int toIndex = 3;   // 移动到装备栏武器位(下标3)

MoveItem(backpackBaseAddr, fromIndex, equipBaseAddr, toIndex, 0, 0);

我们使用代码注入器进行测试,发现可以成功更换武器装备。

在编写游戏辅助程序时,这个功能会经常用到。本节课的分析就到这里。

下一节课,我们将对这个装备更换的代码进行封装。大家也可以将此作为一个练习,在课后尝试独立完成。


本节课总结
在本节课中,我们一起学习了:

  1. 装备更换功能本质上是物品移动函数的另一种调用方式。
  2. 确定了装备栏武器所在的固定下标为 3
  3. 通过CE搜索和游戏验证,找到了装备列表的基址指针
  4. 在之前移动物品代码的基础上,通过修改目标基址和目标下标,成功实现了装备更换的调用。
  5. 理解了更换装备与移动物品到仓库时,在参数传递上的主要区别。

课程 P53:064-封装更换装备代码 🛡️➡️⚔️

在本节课中,我们将学习如何封装游戏内更换装备的功能代码。我们将分析装备列表的结构,使用枚举类型提高代码可读性,并最终封装一个通用的更换装备函数。


了解装备列表与下标

上一节我们分析了更换装备的相关数据,本节中我们来看看如何封装更换装备的功能。

在封装之前,需要先了解游戏中的装备列表。更换武器时,必须将物品移动到装备列表中武器的特定下标位置。传递下标时需注意,必须是武器对应的下标。

若要更换副手装备,同样需要获取副手对应的下标位置,不能像存放仓库那样随意传递一个下标。因此,我们需要先通过调试工具确定各个装备在列表中的下标位置。

观察可知,武器的位置位于所有装备列表的左上角。

其下标可能为0,但仍需测试验证。

通过显示名称可以确认,在加 0x5C 偏移的位置。

测试发现,下标0的位置对应的是衣服。因此,我们可以将衣服定义为0。
下标1对应左边的副手。
下标3对应武器(例如“流星枪”)。

因此,我们可以按顺序定义:0-衣服,1-副手左,2-副手右,3-武器。定义时可以直接使用数字,但为了代码清晰,更推荐使用枚举常量。


使用枚举类型提高可读性

上一节我们确定了装备下标,本节中我们来看看如何使用枚举类型来管理这些下标。

枚举类型本质上是一个整数序列。如果没有指定值,它会从0开始自动编号。

以下是枚举类型的一个简单示例:

enum EquipmentIndex {
    ID1, // 值为 0
    ID2, // 值为 1
    ID3  // 值为 2
};

打印这些常量,得到的值就是0、1、2。


我们可以用枚举常量来代表装备下标:

enum EquipSlot {
    SLOT_ARMOR,      // 衣服,下标 0
    SLOT_OFFHAND_L,  // 副手左,下标 1
    SLOT_OFFHAND_R,  // 副手右,下标 2
    SLOT_WEAPON      // 武器,下标 3
};

这样,SLOT_WEAPON 就代表了数字3。使用枚举能使程序更具可读性。虽然也可以直接传递数字3,但使用有意义的常量名是更好的实践。


封装移动装备到指定槽位的函数

了解了枚举的用法后,我们开始封装核心函数。我们将基于之前“移动到仓库”的代码进行修改。

首先,在代码中添加装备列表的基址和枚举定义。然后,我们创建一个函数,用于将背包中的物品移动到装备列表的指定槽位。

核心逻辑与移动到仓库类似,但需要修改目标基址为装备列表基址,并且目标位置参数使用我们定义的枚举常量。

以下是函数的核心代码框架:

// 假设 g_pEquipmentList 是装备列表基址
BOOL MoveItemToEquipmentSlot(int backpackIndex, EquipSlot slot) {
    // 1. 选中背包中指定下标的物品
    SelectItemInBackpack(backpackIndex);

    // 2. 调用游戏内部函数,移动到装备列表的特定槽位
    // 内部调用类似:CallGameFunction(g_pEquipmentList + slot * itemSize, ...)
    // ...
    return TRUE;
}

在调用时,若要更换武器,可以这样使用:

MoveItemToEquipmentSlot(backpackItemIndex, SLOT_WEAPON);

封装更上层的“更换武器”函数

现在我们已经有了基础移动函数,可以进一步封装一个更易用的“更换武器”函数。

这个函数接收武器名称作为参数,自动在背包中查找该物品,然后将其移动到武器槽位。

以下是实现步骤:

  1. 调用 GetGoodIndexFlip 函数,根据名称获取物品在背包中的下标。
  2. 判断下标是否有效(>=0)。
  3. 调用上一步封装的 MoveItemToEquipmentSlot 函数,传入找到的下标和 SLOT_WEAPON 枚举。

代码如下:

BOOL EquipWeapon(const char* weaponName) {
    int index = GetGoodIndexFlip(weaponName); // 获取背包下标
    if (index < 0) {
        // 未找到该物品
        return FALSE;
    }
    return MoveItemToEquipmentSlot(index, SLOT_WEAPON);
}

测试与错误处理

函数封装完成后,需要进行测试。测试时发现,当背包物品位于后面某些空槽位时,代码会出现异常。

问题可能出在通过名称查找下标时,访问了空对象。为了解决这个问题,需要在遍历背包列表时加入空指针判断。

修改后的查找逻辑如下:

for (int i = 0; i < backpackSize; ++i) {
    Item* pItem = GetBackpackItem(i);
    if (pItem == nullptr || pItem->name == nullptr) {
        continue; // 跳过空槽位
    }
    if (strcmp(pItem->name, targetName) == 0) {
        return i; // 找到物品,返回下标
    }
}
return -1; // 未找到

加入判断后,代码就能稳定运行了。如果遇到其他难以定位的错误,可以在关键代码前后添加调试信息输出,帮助定位问题所在。


总结与课后作业

本节课中我们一起学习了:

  1. 分析装备列表结构,确定了关键装备(武器、衣服、副手)在列表中的下标。
  2. 使用枚举类型定义这些下标,显著提高了代码的可读性和可维护性。
  3. 封装底层函数 MoveItemToEquipmentSlot,实现了将物品移动到装备槽位的通用功能。
  4. 封装上层函数 EquipWeapon,实现了通过名称直接更换武器的便捷功能。
  5. 完善错误处理,在查找物品时加入空指针判断,使代码更加健壮。

课后作业
请参照更换武器函数的逻辑,自行封装更换其他装备的函数,例如:

  • 更换衣服 (SLOT_ARMOR)
  • 更换左边副手 (SLOT_OFFHAND_L)
  • 更换右边副手 (SLOT_OFFHAND_R)
  • 更换项链、靴子等(需先定义其对应的枚举值)

多编写、多练习是掌握编程的关键。遇到问题可以查阅相关资料或寻求帮助。

我们下节课再见。

逆向教程 P54:065-打开NPC对话菜单 🗣️➡️📜

在本节课中,我们将学习如何通过逆向工程分析并定位游戏中“打开NPC对话菜单”功能的核心调用函数(CALL)。我们将从选中NPC对象开始,逐步追踪数据访问,最终找到并验证打开对话的关键代码。


一、 分析思路与准备工作

上一节我们介绍了对象列表的访问机制。本节中我们来看看如何定位与NPC交互相关的功能。

打开NPC对话前,需要先选中目标NPC对象。游戏可能通过两种方式处理:

  1. 直接鼠标点击NPC。
  2. 通过某种方式选中NPC。

无论哪种方式,最终都需要访问NPC对象数据。不同NPC的对话菜单不同,因此功能函数很可能需要访问NPC对象内用于区分的数据。

我们已知一个公式可以获取当前选中对象的指针。公式基于角色对象基址加上偏移 0x14B8 获得对象在全局列表中的索引,再通过特定计算得到对象指针。

获取选中对象指针的公式(示例)

对象指针 = [[[基址] + 0x14B8] * 4 + 对象列表基址]

首先,我们需要验证这个公式获取的是否是NPC对象。可以通过检查对象名字段(例如偏移 0x320)来确认。


二、 定位可能的功能函数(CALL)

找到NPC对象指针后,下一步是找出哪些代码访问了这个指针。我们使用调试器对对象指针地址下“访问断点”。

以下是操作步骤:

  1. 在游戏中点击一个NPC(例如“神秘商人”)。
  2. 在调试器中,对计算出的NPC对象指针下硬件访问断点。
  3. 断点触发后,记录所有访问该地址的代码位置。

这些被记录的位置中,很可能包含打开NPC对话菜单的 CALL。我们需要逐一测试这些候选的 CALL


三、 测试与筛选候选函数

我们得到了多个访问了NPC对象指针的代码地址。接下来需要对这些地址进行测试。

首先,在调试器中为这些地址编号并下断点。

然后,在游戏中再次点击NPC。程序会在某个断点处停下。我们需要分析该断点处的代码:

  • 观察传入的参数(通常通过寄存器如 ECX, EDX 传递)。
  • 查看上层调用者,理解函数调用链。
  • 尝试手动调用这个 CALL,看是否能触发打开NPC菜单。

手动调用测试的代码框架(汇编)

MOV EDI, [角色对象基址] ; 获取角色对象
MOV ECX, [EDI+0x14B8]   ; 获取选中对象索引
... ; 其他可能的参数准备
CALL 目标函数地址       ; 尝试调用

如果调用成功打开菜单,则找到了目标函数。如果失败,则禁用当前断点,继续测试下一个候选地址。

经过多次测试和参数分析(发现其中一个关键参数是NPC的编号ID),我们最终定位到了正确的函数。

打开NPC对话的核心调用

PUSH NPC_ID    ; 参数:NPC的编号
MOV ECX, 参数值 ; 其他上下文参数
CALL 0x737BC0  ; 核心功能函数地址

调用此函数可以成功打开指定NPC的对话菜单。


四、 课程总结

本节课中我们一起学习了如何逆向分析“打开NPC对话”功能。

  1. 思路:从选中NPC对象入手,监控对其指针的访问,从而定位相关功能代码。
  2. 方法:使用调试器设置访问断点,筛选出可疑的调用点,并通过分析参数和手动调用来验证。
  3. 结果:成功找到了核心功能函数 CALL 0x737BC0,并确定其一个关键参数是 NPC的编号ID

通过本课,你掌握了从数据访问追踪到功能函数定位的基本逆向分析流程。关于函数内部具体如何根据NPC ID生成菜单的详细机制,我们将在后续课程中继续探讨。

课程 P55:066-NPC菜单选择CALL分析 🎮

在本节课中,我们将学习如何分析游戏中的NPC菜单选择功能,特别是打开仓库的CALL。我们将通过调试器下断点、回溯调用栈,最终定位到关键的函数调用和数据结构。


概述

上一节我们分析了打开NPC对话菜单的功能。本节中,我们来看看如何分析并调用NPC菜单中的具体选项,例如“打开仓库”。我们将对游戏的发包函数进行下断点,并通过回溯找到执行“打开仓库”功能的关键代码和数据。


调试准备

首先,我们需要将调试器附加到游戏进程。

附加成功后,我们在发包函数 send 处下一个断点,然后让游戏运行起来。


按下回车键下断点。当游戏执行到打开仓库操作时,如果断点被触发,说明游戏向服务器发送了数据请求。

注意:如果打开仓库时断点没有触发,可能意味着此操作不涉及发包,或者需要寻找其他关键数据进行分析。本节课中,断点成功触发,说明该功能与服务器有数据交互。


分析打开仓库的调用

我们再次在 send 函数处下断点(F2),然后执行打开仓库操作。

  1. 当断点触发后,我们执行到返回(Ctrl+F9)。
  2. 在此位置,我们可以备注为“打开仓库”。
  3. 再次按 Ctrl+F9 返回,备注为“打开仓库二”。
  4. 继续按 Ctrl+F9,在下一个位置下断点(F2),然后按 Ctrl+F7 执行。

执行到此处时,我们发现很多NPC相关操作都会断在这里。为了过滤掉无关调用,我们进行以下操作:

  1. 删除之前设置的所有断点。
  2. 仅保留我们新设置的三个断点,并先将其禁用。
  3. 然后重新激活它们,这样有助于过滤与打开仓库无关的调用。

激活断点后,程序立即断下,说明此循环与打开仓库操作有关。同时,关闭仓库时也会在此断下。


记录关键参数

为了找到规律,我们需要记录调用时关键寄存器的值。

以下是操作步骤:

  1. 打开仓库,记录 EAXESI 的值。
    • EAX = 0x319
    • ESI = 0x3
  2. 关闭仓库,再次记录 EAXESI 的值。
    • EAX = 0x62
  3. 再次打开仓库,观察参数变化。
    • EAX = 0x319 (稳定)
    • ECX 的数值发生了变化。

通过观察发现,EAX 参数(0x319)在打开仓库时是稳定的,而 ECX(对象指针)和 EDX 会变化。这提示我们,关键可能在于调用某个对象(ECX)的成员函数。

调用形式类似于:

// 伪代码表示
ECX_Object->MemberFunction(0x319);


测试功能CALL

我们找到了一个疑似打开仓库的CALL地址。接下来用代码注入器进行测试。

首先,我们需要手动打开NPC对话。然后,注入调用代码,传入我们记录的参数。

首次注入后程序崩溃,说明参数或CALL地址有误。检查后发现CALL地址写错。

重新启动游戏并附加调试器,获取当前正确的 ECX 值。

再次注入测试代码:

// 伪代码,需根据实际地址和寄存器调整
mov ecx, [正确的ECX值]
push 0x319
call 0xXXXXXX // 正确的CALL地址

测试成功!可以打开仓库。但需要注意的是,必须先执行打开NPC对话这一步,单独调用此CALL无效。这说明该功能需要服务器两步验证。


追溯ECX的来源

功能测试成功,但 ECX 值每次启动游戏都会变化。我们需要找到它的来源,即基址加偏移。

我们回溯调用栈,观察 ECX 的传递链:

  1. 当前 ECX 来源于上一层的 ESI
  2. 上一层的 ESI 又来源于其上一层的 ECX

通过多次回溯,我们发现一个规律:数据来源于一个数组结构,通过 [基址 + 偏移] 的方式访问。

最终,我们定位到一个稳定的静态地址(基址),并通过偏移计算出了 ECX 的值。

计算公式如下:

// 最终推导出的指针路径
ECX = *(DWORD*)(*(DWORD*)(Base_Address + Offset1) + Offset2);

其中 Base_Address 是找到的静态基址,Offset1Offset2 是两次解引用的偏移量。

技巧:可以使用CE(Cheat Engine)搜索访问该地址的代码,或直接在OD中通过堆栈和寄存器回溯来验证指针路径。


整合与总结

本节课中,我们一起学习了如何分析游戏内NPC菜单选择功能:

  1. 调试与定位:通过给发包函数下断点,定位到与“打开仓库”相关的代码区域。
  2. 参数分析:记录并分析函数调用时的寄存器参数,找到稳定参数(EAX = 0x319)和可变参数(对象指针 ECX)。
  3. 功能测试:通过代码注入验证找到的CALL,确认其功能。
  4. 数据溯源:回溯 ECX 对象指针的来源,找到其基址与偏移,形成稳定的指针路径。

我们成功地将打开仓库的操作分解为两个必要步骤:打开NPC对话、调用仓库CALL。这为后续编写自动化脚本(如自动存物品)打下了基础。


下节预告

下一节课,我们将对最近分析的功能进行整合。目标是编写一个完整的函数,实现“存放指定物品到仓库”的自动化操作。

函数原型设想如下:

bool StoreItemToWarehouse(const char* itemName, int count = 1);

我们将把打开NPC、选择仓库、移动物品等CALL串联起来,形成一个实用的功能函数。


课程 P56:067 - 封装测试 NPC 对话 CALL 与仓库 CALL 🧪

在本节课中,我们将学习如何封装并测试用于打开 NPC 对话和仓库功能的 CALL。我们将基于之前课程的分析,将新功能整合到现有代码框架中,并进行调试与验证。


概述

上一节我们分析了打开 NPC 对话及菜单选择(仓库功能)的 CALL。本节中,我们将在第 64 课代码的基础上,添加这些新分析的功能并进行测试。

首先,我们需要添加相关 CALL 的基址,并创建两个对应的函数:一个用于打开 NPC 对话,另一个用于打开仓库。

打开 NPC 对话函数需要一个参数来区分不同的 NPC,我们使用 NPC 的 ID。打开仓库函数则无需参数。

以下是需要在源代码单元中添加的代码步骤。


添加 CALL 汇编代码

我们将第 65 课中分析的汇编代码复制过来。其中 ECX 参数与之前使用的 F1F10 参数(即 F98C0 更新后的地址)一致。这里做了相应替换,获取地址的部分也使用了宏定义以便更新。

将上面这段代码复制并粘贴到下方。我们只需要替换其中相应的汇编代码即可。

保存后,对于打开 NPC 对话的函数,我们需要传入 NPC 的 ID 作为参数。


在主线程中进行测试

接下来,我们到主线程区域进行测试。定义一个测试分支 Txt2 来测试打开仓库功能。同时,在前面的基础上增加一个宏定义。

在 CP 区单元中,我们需要进行相应改动,并将函数名替换以便分开测试。

然后,切换到资源视图,添加两个测试按钮:“测试二”和“测试三”。同时,在头文件中添加这三个测试函数的说明。

编译成功后,启动测试程序。


测试与问题排查

首先挂接到主线程并执行打开仓库功能。此时发现仓库无法打开,第二次点击时菜单出现,但点击打开仓库仍无效。这表明两个 CALL 中有一个存在问题。

手动测试以确定问题所在:先尝试打开 NPC 对话,再尝试打开仓库。结果发现打开仓库的 CALL 功能正确,而打开 NPC 对话的 CALL 仅有显示菜单的功能,实际是无效的。

因此,我们需要重新分析并更新第 65 课中关于打开 NPC 对话的 CALL。


重新分析 NPC 对话 CALL

为了避免全面重新分析带来的巨大工作量,我们可以采用一个取巧的方法。由于打开菜单时可能也会向服务器发送数据包,我们可以在这些访问指令之前下断点,观察在哪个断点之后发送了数据。那个 CALL 可能就是真正的打开 NPC 对话功能。

我们使用调试器附加到游戏进程,在之前标记的访问 NPC 对象的位置(标记为 1, 2, 3)下断点。同时,下一个发包断点。

打开 NPC 对话后,触发断点并执行到返回。观察前后注释,发现在标记 3 的位置,访问 NPC 对象后,会判断对象是否为空。若非空,则向服务器发送数据。这可能就是打开 NPC 对话的指令。

我们需要先获取 NPC 对象,才能调用这个 CALL。也可以通过 NPC 的 ID 以数组方式取出对象。


测试可能的 CALL

在相应位置下断点,获取 NPC 对象地址(例如 ECX: 0x59839)和 CALL 地址(例如 0x4B2820)。使用代码注入器进行测试,传入相应参数。

测试多个附近的 CALL 及其不同参数(如 0x401, 0x420, 0x316),发现参数为 0x401 时,成功打开了 NPC 对话和仓库。这证实了该 CALL 是有效的。

因此,我们需要编写一个函数来获取 NPC 对象,然后调用此 CALL。可以仿照以下代码逻辑:

// 伪代码示例
ECX = NPC_Object;
EAX = *(DWORD*)(ECX + 4);
CALL EAX(ECX, 0x401); // 0x401 为打开对话参数


总结

本节课中,我们一起学习了如何封装和测试用于打开 NPC 对话与仓库的 CALL。我们发现了最初封装的 NPC 对话 CALL 无效,并通过调试找到了正确的 CALL 地址和调用方式。关键在于需要先获取 NPC 对象,并通过特定参数(如 0x401)来触发功能。

下一节课,我们将正式整理并封装这些功能代码。大家也可以将此作为练习,尝试自己先封装相关功能的代码。

课程 P57:068-获取指定NPC对象GetNpcObjForName 🎯

在本节课中,我们将学习如何编写一个名为 GetNpcObjForName 的函数,用于根据NPC的名字来获取其在游戏内存中的对象地址。这是实现与特定NPC交互功能的关键步骤。


概述与背景

上一节我们分析了打开NPC对话的库函数,发现其中一个参数要求传入指定NPC的对象地址。因此,我们需要先编写一个函数来获取这个NPC对象。

我们将在第67课代码的基础上进行修改。首先,打开基础单元并删除之前用于测试打开NPC对话的代码,因为这部分暂时用不到。


修改数据结构

接着,我们转到结构单元。NPC对象信息通常存储在“怪物列表”或“附近对象”列表中。我们需要在这个NPC结构体的成员函数区域,添加一个新的成员函数。

GetData 函数之后,我们开始编写新函数 GetNpcObjForName 的代码。这个函数的返回值类型应为 DWORD,代表一个内存地址。

首先,我们需要遍历附近的怪物列表。在遍历过程中,将列表中每个对象的名字与我们传入的目标名字进行比较。如果找到匹配项,就返回该对象的地址。为了能在函数中返回对象地址,我们需要先在NPC结构体的成员里添加一个用于存储对象地址的变量,例如 nObjAddr,并在初始化时为其赋值。


编写遍历与比较逻辑

以下是实现该功能的核心代码逻辑。我们使用一个循环来遍历列表:

DWORD GetNpcObjForName(const char* targetName) {
    // 假设 monsterList 是怪物列表的起始地址
    // listSize 是列表的大小,这里暂定为100
    const int listSize = 100;

    for (int i = 0; i < listSize; ++i) {
        // 获取列表中第i个对象的地址
        DWORD currentObjAddr = monsterList + i * objectSize;
        // 获取该对象的名字字符串
        const char* currentName = GetObjectName(currentObjAddr);

        // 进行安全性判断:如果当前对象名字指针为空,则跳过
        if (currentName == nullptr) {
            continue;
        }

        // 比较当前对象名字与目标名字
        if (strcmp(currentName, targetName) == 0) {
            // 找到目标NPC,返回其对象地址
            return currentObjAddr;
        }
    }
    // 遍历完毕未找到,返回空值
    return NULL;
}

代码说明

  1. 我们通过循环遍历一个固定大小(示例中为100)的列表。
  2. 对于列表中的每个对象,获取其名字。
  3. 使用 strcmp 函数比较对象名字与目标名字。
  4. 如果找到匹配项,则立即返回该对象的地址。
  5. 如果遍历完整个列表都未找到,则返回 NULL

注意:使用固定列表大小(如100)的算法可能不够优化。在实际项目中,更科学的方法是使用动态数组(如 std::vector)来准确获取列表大小。


函数测试与调试

代码编写完成后,我们进行编译和测试。同时,需要清理上一节课留下的、现在不再需要的打开NPC对话的库函数及相关头文件声明。

在测试时,我们在主线程的初始化部分调用 GetData 函数,并尝试获取指定NPC的对象,然后将返回的地址值打印到调试信息查看器中。

首次测试时,发现调试信息没有输出。通过逐步添加调试信息定位问题,发现是在字符串比较时,如果当前对象的名字指针为空,会导致比较函数出错。因此,我们在循环内添加了空指针判断,如果 currentName 为空,则使用 continue 跳过本次循环。

修复此问题后,再次编译并注入游戏测试。成功获取到了指定NPC(例如“平一指”)的对象地址(如 0x2DF25598)。通过内存查看器验证,该地址偏移特定位置后确实存储着NPC的名字和相关信息,确认函数功能正确。


总结

本节课中,我们一起学习了如何实现 GetNpcObjForName 函数。我们修改了NPC结构体,编写了遍历附近怪物列表并比较名字的逻辑,并通过调试解决了空指针导致的问题,最终成功获取到了指定NPC的对象地址。

这个函数是后续实现与游戏内NPC进行交互(如对话)的基础。下一节课,我们将利用这个获取到的NPC对象地址,来调用打开对话的功能。

课程P58:069-打开指定NPC对话 🗣️➡️💬

在本节课中,我们将学习如何封装一个函数,通过NPC的名字来打开与其对应的对话窗口。我们将基于上一节课获取NPC对象的功能,进一步实现交互操作。


封装打开NPC对话的函数

上一节我们介绍了如何通过NPC名字获取其对象地址。本节中,我们来看看如何利用这个对象来打开对话。

首先,我们需要在代码的结构单元中添加一个名为 OpenNPCDialog 的函数。这个函数可以封装在与怪物列表相关的模块中,因为它与游戏中的对象操作紧密相关。

以下是实现该函数的核心步骤:

  1. 获取NPC对象地址:调用上一节课封装的函数,传入NPC名字,获取对应的对象地址。
  2. 调用游戏内部功能:使用汇编代码块,将获取到的NPC对象地址作为 this 指针(通常存放在 ECX 寄存器中),调用游戏内打开对话的函数(Call)。
  3. 添加异常处理:在调用前后添加异常处理逻辑,确保程序稳定性。如果获取的NPC对象地址为空,则直接返回失败。
  4. 可选:确保对象被选中:某些游戏内部函数可能依赖于“当前选中目标”。为了兼容性,可以在打开对话前,先调用选中目标的功能。

核心的汇编调用逻辑可以用以下伪代码表示:

MOV ECX, [NPC对象地址]
CALL [打开NPC对话的函数地址]

代码实现与调试

我们将代码添加到三个指定位置(92、112等)。在主线程单元进行测试。

测试时需要注意,传入的NPC名字必须完全正确。首次编译测试时,程序出错了。错误可能源于没有选中任何游戏内对象,导致内部函数访问了无效指针。

为了解决这个问题,我们优化了流程:

以下是优化后的步骤列表:

  • 步骤一:在打开对话前,先调用一个 SelectNPC 函数来选中目标NPC。
  • 步骤二SelectNPC 函数会遍历周围对象列表,比对名字,找到对应NPC的ID并执行选中操作。
  • 步骤三:选中成功后,再执行打开对话的调用。

经过修正和测试,函数最终能够稳定地打开指定NPC的对话窗口(例如仓库管理员)。


代码优化与维护建议

为了使代码更清晰、易于维护,我们进行了以下优化:

  1. 分离关注点:将“选中NPC”的功能独立封装成 SelectNPC 函数,使 OpenNPCDialog 函数逻辑更简洁。
  2. 集中管理偏移量:将游戏数据的偏移量(如对象ID偏移 +0C)定义在常量或结构体中。未来游戏更新时,只需修改这些偏移量定义即可。
  3. 增强健壮性:在循环比对NPC名字时,增加对空字符串的判断,避免无效操作。

优化后的调用流程如下:

// 伪代码示例
if SelectNPC(NPC名称) then
begin
  OpenNPCDialog(NPC名称);
end;

本节课中我们一起学习了如何通过NPC名字打开其对话。关键点在于:获取对象地址、通过汇编调用游戏内部函数、以及通过预先选中目标来保证调用的稳定性。通过将功能模块化(选中、打开对话),代码变得更具可读性和可维护性。下一节课我们将继续探索其他游戏功能的实现。

逆向工程课程 P59:070-存放N个物品到仓库 🧳

在本节课中,我们将学习如何分析并实现一次性将多个相同物品存入游戏仓库的功能。我们将通过逆向分析找到关键的数据包发送函数,并理解其中物品数量和标识的存储方式。


分析目标与思路

上一节我们介绍了基本的物品操作,本节中我们来看看如何批量存放物品。

游戏在存放如药品、回城符等可堆叠物品时,需要输入数量。我们的目标是找到并调用一个函数,实现一次性存放指定数量物品到仓库,避免多次移动物品的繁琐步骤。

分析可以从两个方向入手:

  1. 服务器通信:存放物品必然要向服务器发送数据包。
  2. 关键数据“数量”:逆向追踪数量数据的处理流程,最终在发包函数中找到存放数量的关键数据位置。

定位发包函数

首先,我们需要定位负责发送存放物品数据包的函数。

  1. 在数据包发送函数上设置断点。
  2. 在游戏内输入要存放的物品数量(例如33个),然后点击“确定”。
  3. 此时游戏会中断在发包函数。取消断点,使用 Ctrl+F9 执行到返回。
  4. 观察调用栈,发包操作通常在其上一层的函数中完成。我们在那个调用位置下断点,并备注为“存仓库”。
  5. 再次执行存放操作,程序会中断在备注处。这很可能就是我们要找的“存放物品”的函数。

分析数据包结构

找到疑似函数后,我们需要分析其发送的数据包结构,特别是物品数量和标识的存储位置。

我们通过改变存放数量来观察数据变化:

  1. 第一次存放时,记录下数据缓冲区(例如地址 18A488)的内容。
  2. 改变存放数量(例如从9个改为7个),再次中断并对比缓冲区数据。
  3. 可以发现,在缓冲区起始地址偏移 +0x1A 的位置,数据从 09 变为了 07。这很可能就是物品数量的存储位置(低位)。
  4. 同时,在偏移 +0xB7 的位置,数据也从 2A 变为了 3A,这个值可能与其他计数机制相关。

为了找到物品的唯一标识,我们需要用不同物品进行测试。

以下是测试不同物品时的数据变化规律:

  • 使用物品A(金创药)存放3个:偏移 +0x80 附近的一长串数据(例如 67 CA 9A 3B ...)发生变化,这串数据很可能就是该物品在服务器端的唯一编号。
  • 使用物品B(雪原声)存放3个:同样是偏移 +0x80 附近的另一串不同数据(例如 7A ...)发生变化,而 +0x1A 处的数量值仍为 03

通过对比可以初步确定:

  • 偏移 +0x1A:存储要存放的物品数量
  • 偏移 +0x80 附近:存储该物品的唯一标识符

构造调用代码

分析出关键数据位置后,我们可以构造代码来调用这个函数。

首先,我们需要准备一个数据缓冲区。观察发现,整个数据包长度大约为 0x90 个字节。

我们将捕获到的数据包字节数组进行处理,使其符合C语言数组的格式,并预留出修改数量的位置。

unsigned char data_buffer[0x90] = {
    0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, // 数量位于 0x1A (第27个字节)
    // ... 后续是物品ID等其他数据
};

接下来,我们编写汇编指令来调用找到的函数。假设函数地址存储在 ecx 寄存器中。

push 0x86          ; 可能是一个参数或标识
push 0x00          ; 另一个参数
mov eax, [ebp-0x18] ; 获取数据缓冲区地址
push eax           ; 压入缓冲区指针作为参数
call ecx           ; 调用存放物品的函数

在C代码中,我们需要获取函数的地址并组织调用。

// 假设 function_addr 是我们找到的“存放物品”函数的地址
DWORD function_addr = 0xXXXXXXX; // 需替换为实际地址
__asm {
    mov ecx, function_addr
    push 0x86
    push 0x00
    lea eax, data_buffer
    push eax
    call ecx
}

功能测试

将编写好的代码注入游戏进行测试。

  1. 打开游戏仓库界面。
  2. 执行我们的测试代码。
  3. 观察仓库内目标物品的数量是否增加了指定的个数(例如每次增加3个)。

如果测试成功,则证明我们正确找到了存放N个物品的函数并成功调用。


总结

本节课中我们一起学习了如何逆向分析游戏批量存放物品的功能。

我们首先定位了关键的数据包发送函数,然后通过对比测试,分析了数据包中物品数量(偏移+0x1A)物品唯一标识(偏移+0x80附近)的存储位置。最后,我们构造了数据缓冲区并编写调用代码,成功实现了一次性存放多个物品到仓库的功能。

这为后续分析更复杂的物品操作(如取出、移动)打下了基础。下节课我们将对数据包中的其他参数进行更详细的分析。

课程 P6:017 - 将代码注入游戏主线程 🎮

在本节课中,我们将学习如何将外部代码安全地注入到游戏的主线程中运行。这样做的主要目的是为了避免在多线程环境下访问共享数据(如全局变量)时可能引发的冲突和不稳定问题。通过将代码挂载到游戏主线程,我们可以确保操作的同步性和稳定性。


获取游戏窗口句柄

上一节我们讨论了多线程冲突的问题,本节中我们来看看如何获取游戏窗口句柄,这是注入代码的第一步。

首先,我们需要找到游戏存放窗口句柄的内存地址。通过内存扫描工具,我们可以定位到存储窗口句柄的地址。例如,我们可能找到地址 F010-6574,它存放着游戏窗口的句柄。

为了在代码中使用这个地址,我们定义一个函数来读取其中的值。以下是相关的代码示例:

// 假设 g_hWndAddr 是存放窗口句柄地址的变量
DWORD GetGameWindowHandle() {
    DWORD hWnd = 0;
    __try {
        // 从指定地址读取窗口句柄
        hWnd = *(DWORD*)g_hWndAddr;
    } __except(EXCEPTION_EXECUTE_HANDLER) {
        // 异常处理,返回0
        hWnd = 0;
    }
    return hWnd;
}

获取到窗口句柄后,我们可以进一步获取游戏主线程的ID。


安装Windows钩子

有了游戏窗口句柄和线程ID,接下来我们可以使用 SetWindowsHookEx 函数将我们的代码挂载到游戏主线程。

SetWindowsHookEx 函数用于安装一个应用程序定义的钩子过程到钩子链中。以下是该函数的基本用法:

HHOOK SetWindowsHookEx(
  int       idHook,      // 钩子类型
  HOOKPROC  lpfn,        // 钩子过程回调函数
  HINSTANCE hmod,        // 包含钩子过程的DLL句柄(全局钩子需要)
  DWORD     dwThreadId   // 目标线程ID
);

在我们的场景中,我们将使用 WH_GETMESSAGE 钩子类型,并将回调函数运行在游戏主线程中。


编写钩子回调函数

钩子安装后,系统会在特定事件发生时调用我们定义的回调函数。以下是回调函数的基本结构:

LRESULT CALLBACK GetMsgProc(int code, WPARAM wParam, LPARAM lParam) {
    // 判断是否为我们需要处理的消息
    if (code == HC_ACTION) {
        // 将 lParam 转换为 MSG 结构指针
        MSG* pMsg = (MSG*)lParam;
        
        // 检查消息是否发送到我们的游戏窗口
        if (pMsg->hwnd == g_hGameWnd) {
            // 处理自定义消息
            if (pMsg->message == g_MyMessageCode) {
                // 在这里执行注入的代码
                OutputDebugString("自定义消息已收到。");
                return 0; // 消息已处理,不再传递
            }
        }
    }
    // 将消息传递给钩子链中的下一个钩子
    return CallNextHookEx(g_hHook, code, wParam, lParam);
}

在回调函数中,我们检查传入的消息是否为我们自定义的消息。如果是,则执行相应的操作;否则,将消息传递给下一个钩子。


发送自定义消息到主线程

为了触发我们注入的代码,我们需要从外部向游戏主线程发送自定义消息。可以使用 SendMessage 函数来实现:

// 发送自定义消息到游戏窗口
void SendCustomMessageToGame() {
    // g_hGameWnd 是游戏窗口句柄
    // g_MyMessageCode 是注册的自定义消息ID
    // 0 和 0 是附加参数(可根据需要传递数据)
    SendMessage(g_hGameWnd, g_MyMessageCode, 0, 0);
}

SendMessage 会等待消息处理完毕才返回,确保操作的同步性。


整合与测试

现在,我们将上述步骤整合起来,并创建一个简单的测试界面。

以下是创建测试界面的步骤:

  1. 在资源窗口中添加三个按钮:“挂接主线程”、“测试消息”和“卸载钩子”。
  2. 为“挂接主线程”按钮编写代码,调用 SetWindowsHookEx 安装钩子。
  3. 为“测试消息”按钮编写代码,调用 SendCustomMessageToGame 发送消息。
  4. 为“卸载钩子”按钮编写代码,调用 UnhookWindowsHookEx 卸载钩子。

测试流程如下:

  1. 点击“挂接主线程”按钮,将钩子安装到游戏主线程。
  2. 点击“测试消息”按钮,观察调试输出是否显示“自定义消息已收到”。
  3. 点击“卸载钩子”按钮,卸载钩子。
  4. 再次点击“测试消息”按钮,确认消息不再被接收。

通过这个测试,我们可以验证代码是否成功注入并在游戏主线程中运行。


扩展应用:处理复杂操作

在实际应用中,我们可能需要执行更复杂的操作,例如使用游戏内的物品。我们可以通过扩展自定义消息的参数来实现。

首先,定义一系列操作类型:

#define ACTION_USE_ITEM 1
#define ACTION_SKILL_CAST 2
// ... 其他操作

然后,修改消息发送函数,传递操作类型和参数:

void SendGameAction(int actionType, DWORD param) {
    SendMessage(g_hGameWnd, g_MyMessageCode, actionType, param);
}

在钩子回调函数中,根据操作类型执行相应的代码:

LRESULT CALLBACK GetMsgProc(int code, WPARAM wParam, LPARAM lParam) {
    if (code == HC_ACTION) {
        MSG* pMsg = (MSG*)lParam;
        if (pMsg->hwnd == g_hGameWnd && pMsg->message == g_MyMessageCode) {
            int actionType = pMsg->wParam; // 操作类型
            DWORD param = pMsg->lParam;    // 参数
            
            switch (actionType) {
                case ACTION_USE_ITEM:
                    UseGameItem(param); // 调用使用物品的函数
                    break;
                // 处理其他操作...
            }
            return 0;
        }
    }
    return CallNextHookEx(g_hHook, code, wParam, lParam);
}

这样,我们就可以通过发送不同的消息来触发游戏主线程中的各种操作。


总结

本节课中我们一起学习了将代码注入游戏主线程的完整流程。我们首先通过内存扫描获取游戏窗口句柄,然后使用 SetWindowsHookEx 函数安装全局钩子,将自定义的回调函数挂载到主线程。通过发送自定义消息,我们可以在主线程中安全地执行代码,从而避免多线程冲突。最后,我们通过一个测试界面验证了注入效果,并探讨了如何扩展以处理更复杂的游戏操作。

这种方法能有效提升外部程序与游戏交互的稳定性,是游戏辅助开发中的一项重要技术。

课程 P60:071-物品存放CALL 缓冲区结构解密 🔍

在本节课中,我们将深入分析并解密存放物品功能CALL所使用的缓冲区数据结构。上一节我们测试了存放多个物品的CALL,但未解析其参数结构。本节我们将重点剖析这片数据,明确每个数值的含义,以便在存放不同物品时能正确传递参数。


打开游戏与调试环境

首先,我们打开游戏和调试工具,并设置断点来捕获存放物品时的数据。



我们以存放“雪原声”为例,并设置断点来查看 ECX 寄存器中的数据。缓冲区起始地址为 EBP - 0x2818。我们可以使用命令 dd dd dd ebp-0x2818 来显示缓冲区的所有数据。

以下是存放不同数量物品时捕获的数据片段:

  • 存放三个雪原声: 复制缓冲区前90个字节。
  • 存放不同数量物品: 再次复制缓冲区数据进行比较。

通过比较发现,数据前段大部分相同,只有特定位置(如 0x670x6A)的数值随操作变化。我们的目标是找出这些变化数据的来源和意义。


分析缓冲区写入点

为了理解结构,我们需要在OD中查找所有向缓冲区(即 EBP 相关偏移地址)写入数据的位置。

以下是主要的写入点及其对应的缓冲区偏移和写入内容:

  1. 写入点 1 (EBP - 0x2812):

    • 偏移:缓冲区首地址 + 0x06
    • 写入:0 (双字)
  2. 写入点 2 (EBP - 0x2816):

    • 偏移:缓冲区首地址 + 0x02
    • 写入:固定常量 0x840094 (可能代表“存放到仓库”的指令)
  3. 写入点 3 (EBP - 0x2801):

    • 偏移:缓冲区首地址 + 0x17
    • 写入:从源地址复制 0x80 (128) 个字节的数据。
  4. 写入点 4 (EBP - 0x27F6):

    • 偏移:缓冲区首地址 + 0x22
    • 写入:来源于某个基址的数值。
  5. 写入点 5 (EBP - 0x27F2):

    • 偏移:缓冲区首地址 + 0x26
    • 写入:0 (双字)

其中,写入点 3 复制的数据量最大,也最关键。其数据来源于 EBX + 0x178C


追踪关键数据来源

我们需要找到 EBX 的来源。通过搜索和比对,发现 EBX 指向一个仓库背包对象的基址。

公式表示:

关键数据块来源地址 = 仓库背包对象基址 + 0x178C

该地址存放的数据会被复制到缓冲区的 +0x17 偏移处,共 0x80 字节。

进一步调试发现,当向仓库拖动物品时,程序会向 仓库背包对象基址 + 0x1794 的地址写入数据,而写入的来源正是当前选中物品对象的地址

核心逻辑:
存放物品时,CALL会从背包中选中的物品对象里提取大量信息,填充到缓冲区中。


解析物品对象与缓冲区的映射关系

通过单步跟踪,我们可以逐步建立物品对象属性与缓冲区特定偏移的对应关系。

以下是已分析出的部分映射关系(偏移均为相对于缓冲区首地址):

  • 偏移 0x10 - 0x11 (4字节): 来源于 物品对象地址 + 0x4C
  • 偏移 0x30 - 0x31 (4字节): 同样来源于 物品对象地址 + 0x4C(与上方连续8字节)。
  • 偏移 0x44 (1字节): 来源于 物品对象地址 + 0x44(可能是物品数量等属性)。
  • 其他属性: 跟踪发现,程序还从物品对象的 +0x28, +0x40, +0x50, +0x54, +0x58, +0xAC, +0xEF4 等多个偏移读取数据,并填充到缓冲区的 0x17B0, 0x17C8 等对应位置。

简单来说,缓冲区的填充是一个复杂但有序的过程:

  1. 写入固定的指令头(如 0x840094)。
  2. 从仓库背包对象复制一段固定数据。
  3. 最关键的一步: 从当前要存放的背包物品对象中,提取大量属性(ID、数量、类型等),分散写入缓冲区的各个指定偏移。

两种实现思路

理解了结构后,我们有两种方式来实现调用:

  1. 手动填充缓冲区: 彻底分析物品对象所有必要属性与缓冲区偏移的对应关系,然后自行构造完整的数据块。这种方法透明、可控,但初期分析工作量大。
  2. 调用游戏内部函数: 定位并直接调用游戏中用于初始化这块缓冲区的库函数。我们只需传入物品对象地址等少量参数,由游戏函数完成复杂的填充工作。这种方法更简洁,但需要找到正确的函数入口点。

总结

本节课我们一起深入分析了存放物品CALL的缓冲区结构。

  • 我们确定了缓冲区的基本布局,包含固定头、仓库数据和物品详情三大部分。
  • 我们发现了关键数据来源于背包中的物品对象,并追踪了部分属性(如物品ID、数量)在缓冲区中的映射位置。
  • 我们提出了两种实现该功能调用的可行思路。

虽然完整的映射关系非常复杂,但核心原理已清晰:存放物品的本质,就是将背包内指定对象的详细信息,按照特定格式打包到一个缓冲区中,然后调用功能CALL。这为我们后续实现自动存放物品功能奠定了坚实的基础。

课程 P61:072-物品存放CALL 缓冲区结构解密2 🔍

在本节课中,我们将继续深入分析游戏内物品存放操作的缓冲区结构。我们将通过对比存放不同物品时的数据变化,定位出缓冲区中与物品ID、数量、类型等关键信息相关的偏移位置。


上一节我们介绍了缓冲区的基本概念和初步分析方法,本节中我们来看看如何通过对比数据变化来精确定位关键信息。

首先,我们进入游戏,对存放物品的操作进行下断点。我们先存放3个人参到仓库,并抓取相关的缓冲区数据。

// 示例:存放人参后的部分缓冲区数据(十六进制)
... (前90字节) ...
68 00 00 00 23 00 00 00 ...

接着,我们存放35个血人参到仓库,并再次抓取数据。

// 示例:存放血人参后的部分缓冲区数据
... (前90字节) ...
68 00 00 00 0B 00 00 00 ...

最后,我们存放11个金创药(小)到仓库,并抓取第三次数据。

// 示例:存放金创药后的部分缓冲区数据
... (前90字节) ...
68 00 00 00 2B 00 00 00 ...

为了方便比较这三组数据的差异,我们将它们放入文本编辑器中进行高亮标记。通过对比发现,从第二行开始出现差异,特别是以下几处数据段发生了明显变化:

  1. 一段约12字节的数据(从偏移 0x2A 开始)。
  2. 一个4字节的数据块。
  3. 表示物品数量的数据(例如 0x230x0B0x2B 分别对应十进制35、11、3)。

接下来,我们重点分析那串12字节的关键数据。通过调试器追踪,我们发现这段数据来源于一个计算出的地址:

公式:数据来源地址 = [ebx] + 0x178C + (0x2A - 0x0A)

我们通过CE(Cheat Engine)搜索这串特定数据,发现它只存在于一个动态地址中,这证实了它是组合数据,并非直接来源于单一的静态地址。其根源是 ebx 寄存器所指向的一个基址。

这段数据会随着存放物品的不同而改变,说明有代码在向这个地址写入数据。通过逆向跟踪写入指令,我们发现写入的数据来源于另一个地址的计算:

公式:写入数据 = [eax] + 0x54

进一步分析 [eax] + 0x54 指向的内存,我们发现这里存放着物品的关键标识信息。例如,对于金创药,这里的数据是 09 44 1D 19

以下是缓冲区中已分析出的关键偏移与物品对象属性的对应关系列表:

  • 偏移 0x11 处(1字节):表示物品在容器中的下标索引。来源于物品对象地址 + 0x1F4
  • 偏移 0x12 处(4字节)与 0x32 处(4字节):都来源于物品对象地址 + 0x4C,可能表示物品类型或分类ID。
  • 偏移 0x1A 处(8字节):核心的物品标识ID。来源于物品对象地址 + 0x54
  • 偏移 0x2A 处(12字节):组合数据,动态生成,与物品唯一标识相关。
  • 偏移 0x3A 处(2字节):表示该组物品的堆叠数量上限。来源于物品对象地址 + 0xC4
  • 偏移 0xEA 处(2字节):表示本次存放或取出的物品数量。


本节课中我们一起学习了如何通过对比和逆向跟踪,逐步解密物品存放CALL的缓冲区结构。我们成功定位了物品下标、ID、类型、数量上限以及当前操作数量等多个关键信息在缓冲区中的具体偏移位置。

目前,除了上述动态变化的关键数据外,缓冲区其余部分多为固定不变的填充或协议头数据。在下一节课中,我们将利用本节课得出的偏移结论,构建完整的缓冲区数据,并进行实际的调用测试,以验证我们的分析是否正确。

课程P62:073-物品存放CALL测试 🧪

在本节课中,我们将学习如何对游戏中的“物品存放CALL”进行手动测试。我们将通过修改内存参数,实现将背包中的不同物品(如金创药、人参)成功存入仓库。课程将详细讲解关键偏移地址的提取与修改方法。


上一节我们分析了物品存放CALL的缓冲区参数。本节中,我们来看看如何通过手动修改这些参数,来测试存放不同物品的功能。

首先,打开第70课的代码,并切换到主线程单元。本节课的目标是修改相关结构,以便存放背包中的其他物品。

我们需要关注几个关键的内存偏移地址,它们决定了存放哪个物品以及存放多少。

以下是需要提取和修改的关键数据偏移:

  • 物品对象偏移 +0x4C 处的4字节:与物品ID相关。
  • 物品对象偏移 +0x54 处的8字节:与物品身份相关。
  • 物品对象偏移 +0xEA 处的2字节:代表要存放的物品数量。
  • 物品对象偏移 +0x3A 处的2字节:代表物品的当前数量上限。
  • 背包下标偏移 +0xEF 处的1字节:代表物品在背包中的位置。

为了方便提取参数,我们先用调试器附加到游戏进程,并手动读取这些值。

我们以“金创药小”为例进行首次测试。首先,读取其相关数据:

  1. 读取 +0x4C 偏移的4字节:值为 0x6C9A3B6C(注意字节序)。
  2. 读取 +0x54 偏移的8字节:值为 0x1A2D64970x1A2D6497(前后4字节相同)。
  3. 读取 +0xEA 偏移的2字节:当前数量为97个(0x0061)。
  4. 背包下标:第一个物品,下标为0。

接下来,我们修改代码中的参数,尝试存放2个金创药小。修改逻辑如下:

// 假设 ecx 是物品对象基址
// 1. 写入存放数量 (偏移 +0xEA)
mov word ptr [ecx+0xEA], 0x0002

// 2. 写入当前数量上限 (偏移 +0x3A),金创药小为97个
mov word ptr [ecx+0x3A], 0x0061

// 3. 写入物品ID相关数据 (偏移 +0x12),来源于物品对象 +0x4C
mov dword ptr [ecx+0x12], 0x6C9A3B6C

// 4. 写入物品身份数据 (偏移 +0x2A 的8字节),来源于物品对象 +0x54
mov dword ptr [ecx+0x2A], 0x1A2D6497 // 前4字节
mov dword ptr [ecx+0x2A+4], 0x1A2D6497 // 后4字节

// 5. 写入背包下标 (偏移 +0x43)
mov byte ptr [ecx+0x43], 0x00

修改完成后,编译并注入测试。成功的话,每执行一次,仓库会增加2个金创药小。


成功测试金创药小后,我们再来测试存放“人参”。关键区别在于物品ID和背包下标。

以下是“人参”的关键数据:

  1. 物品ID相关 (+0x4C):值为 0x6C9A3B68(与金创药差 0x04)。
  2. 物品身份数据 (+0x54):前4字节不同,后4字节相同。
  3. 背包下标:第二个物品,下标为1。

因此,在代码中需要相应修改:

  • 将写入 +0x12 偏移的4字节改为 0x6C9A3B68
  • 将写入 +0x2A 偏移的前4字节改为人参对应的值。
  • 将写入 +0x43 偏移的下标改为 0x01

再次测试,成功将人参存入仓库。


通过以上测试,我们发现几个关键点:

  1. 物品ID相关数据(+0x4C, +0x54)和背包下标(+0x43)是决定存放哪个物品的关键。
  2. 存放数量(+0xEA)可以自由指定。
  3. 物品的当前数量上限(+0x3A)似乎不是关键校验数据,只要不小于存放数量即可。


本节课中我们一起学习了如何手动测试游戏中的物品存放CALL。我们提取并修改了关键的内存偏移地址,成功实现了存放金创药小和人参到仓库。关键步骤包括定位物品对象、读取关键数据、修改参数并注入测试。

下一节课,我们将把这些手动操作封装成一个通用的函数,以便更灵活地调用。

课后作业
编写一个函数 SaveItemToDepot,实现以下功能:

  • 函数原型bool SaveItemToDepot(const char* item_name, int count)
  • 参数
    • item_name: 要存放的物品名称。
    • count: 要存放的数量。
  • 返回值
    • 存放成功返回 true
    • 物品在背包中不存在则返回 false
  • 前提条件:调用此函数前,需要确保已打开游戏内的仓库界面。

这个函数将是我们第74课要实现的内容。

课程 P63:074-物品存放CALL缓冲区结构化 📦

在本节课中,我们将对上一节课的代码进行优化。我们将为存放物品的CALL定义结构,并为背包对象添加新的属性,以提高代码的可读性和结构化程度。


上一节我们介绍了物品存放的基本调用。本节中,我们来看看如何通过定义结构来优化代码。

首先,我们打开第73课的代码。

我们移动到结构单元,然后定位到背包对象。现在,背包对象新增了几个属性。

以下是新增的属性及其偏移量:

  • 偏移 4C:这是一个四字节属性,可以命名为 id1。可以用 intDWORD 类型表示。
  • 偏移 54:这是一个八字节属性,可以命名为 id2。可以用 DWORD 数组或 QWORD 类型表示。
  • 偏移 C44:这是一个已有的属性,表示物品数量。
  • 偏移 EF4:这是一个一字节属性,表示背包下标。可以用 charBYTE 类型表示。

添加好属性后,我们需要在背包的初始化函数中添加读取这些数据的代码。

以下是初始化代码的关键部分:

// 读取 id1 (4字节)
背包对象->id1 = *(DWORD*)(背包基地址 + 0x4C);

// 读取 id2 (8字节)
背包对象->id2 = *(QWORD*)(背包基地址 + 0x54);

// 读取背包下标 (1字节)
背包对象->下标 = *(BYTE*)(背包基地址 + 0xEF4);

接下来,我们要定义一个结构体,用于组织存放物品CALL所需的缓冲区数据。这样做可以使代码更专业、更具可读性。

我们可以在结构单元文件的末尾添加这个结构体。

以下是该结构体的定义:

#pragma pack(push, 1) // 1字节对齐,确保偏移准确
struct 存放物品缓冲区 {
    BYTE  未知数据1[0x16]; // 填充前0x16个字节
    DWORD id1;           // 偏移 0x16,对应背包对象的 id1
    DWORD 数量;          // 偏移 0x1A,要存放到仓库的数量
    DWORD 当前数量;      // 偏移 0x1E,物品的当前数量
    BYTE  未知数据2[0x20]; // 填充数据
    BYTE  背包下标;      // 偏移 0x3F,背包中的位置
    QWORD id2;           // 偏移 0x40,对应背包对象的 id2
    // ... 后续可能还有其他未知数据
};
#pragma pack(pop)

定义好结构体后,我们就可以在主线程单元的代码中进行优化了。

以下是优化后的赋值逻辑:

// 创建缓冲区结构体实例并赋值
struct 存放物品缓冲区 缓冲区数据;

缓冲区数据.id1 = 背包对象->id1; // 设置 id1

// 组合 id2 (来自背包对象偏移 0x54 的8字节数据)
缓冲区数据.id2 = ((QWORD)高四字节 << 32) | 低四字节;

缓冲区数据.数量 = 3;           // 设置本次要存放的数量,例如3个
缓冲区数据.当前数量 = 背包对象->数量; // 设置物品当前数量
缓冲区数据.背包下标 = 背包对象->下标; // 设置背包下标

// 调用存放物品CALL,直接传入结构体指针
存放物品CALL(&缓冲区数据);

这种使用结构体的方法,明显比直接操作原始字节的代码具有更高的可读性,尽管两者的实际功能完全相同。选择哪种方法取决于你的个人偏好。

现在,让我们测试优化后的代码。

我们挂接程序到主线程并进行测试。假设当前物品(例如“金疮药(大)”)数量为469。

每次按下测试按钮,代码会尝试存放指定数量(例如11个)的物品到仓库。

以下是测试过程的要点:

  1. 确认背包中目标物品的 id1id2 和下标值。
  2. 运行程序,点击测试按钮。
  3. 观察游戏中该物品的数量是否按预期减少,并成功存入仓库。

测试成功后,可以看到物品数量从469开始递减,证明我们的结构化调用是有效的。


本节课中,我们一起学习了如何通过定义结构体来优化物品存放CALL的缓冲区操作。我们为背包对象添加了必要属性,并创建了一个清晰的结构来描述调用参数,从而提升了代码的可维护性和可读性。

下一节课,我们将进一步封装这个功能,将其完善成一个独立的、可复用的函数。

课程 P64:075-封装函数 SaveGoodsToDepot 🧳➡️🏚️

在本节课中,我们将学习如何封装一个名为 SaveGoodsToDepot 的函数。这个函数的功能是将背包中的指定物品存放到仓库中。我们将基于第74课的代码进行构建,并确保函数能够正确获取物品属性、处理异常,并最终完成物品的转移操作。


函数功能与参数说明

上一节我们介绍了函数的基本概念,本节中我们来看看 SaveGoodsToDepot 函数的具体职责。

该函数接收两个参数:

  1. goodsName:背包中物品的名称。
  2. count:要存放到仓库的物品数量。

函数的目标是找到背包中名为 goodsName 的物品,并将指定 count 数量的该物品移动到仓库。


代码实现步骤

接下来,我们将分步实现这个函数。首先需要打开第74课的代码,并在背包结构单元中添加新的成员函数。

1. 添加成员函数

在背包的结构单元中,添加 SaveGoodsToDepot 作为成员函数。为其添加适当的前缀以便于代码管理和复制。

2. 获取物品下标与属性

要操作物品,首先需要获取它在背包数组中的位置(下标)以及其他关键属性。

以下是获取物品下标的代码示例:

itemIndex := GetGoodsIndexForName(goodsName);

我们通过调用现有的 GetGoodsIndexForName 函数来获取下标。如果函数返回 -1,则表示背包中没有该物品,此时应返回 False 并可能输出调试信息。

如果成功获取下标(itemIndex >= 0),我们就可以通过背包对象数组访问该物品的详细信息。

3. 准备调用数据

获得下标后,需要提取调用仓库存储功能所需的具体数据。

所需数据包括:

  • 物品ID1backpackList[itemIndex].id1
  • 物品ID2backpackList[itemIndex].id2
  • 当前数量backpackList[itemIndex].currentCount
  • 物品下标itemIndex

其中,要存储的数量由参数 count 直接提供。

4. 调用主线程存储功能并处理异常

核心的存储操作需要调用主线程中的一段特定代码。我们将那段代码复制到新函数中。

为了保证代码的健壮性,使用 try...except 块进行异常处理。

以下是包含异常处理的调用框架:

try
  // 调用主线程的仓库存储代码
  CallDepotSave(id1, id2, count, itemIndex);
  Result := True; // 操作成功
except
  on E: Exception do
  begin
    PrintDebugInfo('保存到仓库时出错: ' + E.Message);
    Result := False; // 操作失败
  end;
end;

5. 编译与修正错误

完成代码编写后,进行编译。编译器可能会提示一些错误,例如成员变量名不匹配。我们需要根据提示逐一修正这些错误,例如将 itemCount 修正为 currentCount


测试函数功能

编译成功后,我们需要对函数进行测试,以确保其按预期工作。

测试准备

回到主线程单元,将之前用于测试的旧代码注释掉。然后,直接调用新封装的 SaveGoodsToDepot 函数进行测试。

以下是测试调用示例:

// 示例:将3个名为“人参”的物品存入仓库
SaveGoodsToDepot('人参', 3);

执行测试

打开游戏,并将脚本挂接到游戏主线程。执行测试代码,观察背包中“人参”的数量是否减少了3个,同时仓库中是否增加了3个“人参”。可以尝试不同的物品名称和数量进行多次测试,以验证函数的通用性和稳定性。


课后作业与扩展

本节课我们一起学习了如何封装 SaveGoodsToDepot 函数。为了使其更易于在主线程以外的其他上下文中调用,建议完成以下扩展练习:

课后作业:封装一个MS级的函数,将 SaveGoodsToDepot 挂接到主线程。这个新函数应接收相同的参数(goodsNamecount),并在内部调用我们今天实现的成员函数。这能提供一个更清晰、更安全的接口供外部代码使用。


本节课中,我们逐步实现了将背包物品存入仓库的封装函数,涵盖了从获取数据、调用核心功能到异常处理和测试的完整流程。通过这个练习,你应该对如何封装一个具有实际功能的函数有了更深入的理解。

课程 P65:076 - 分析仓库取物功能 📦➡️🎒

在本节课中,我们将分析从游戏仓库中取出物品的功能。我们将通过逆向工程,对比存放物品的数据包,找出取物功能的关键数据结构和指令,并对上一节课的代码进行必要的修改和补充。

概述与准备工作

上一节我们分析了存放物品到仓库的功能。本节中,我们来看看如何从仓库取出物品。首先,我们需要对第75课的代码进行修改,因为当时没有为存放物品的相关发包机制进行定义。现在我们来补上这部分。

首先,在代码的机制单元中,添加一个向服务器发送数据包的函数。然后,转到结构单元,用新定义的机制替换上一节课所写函数的相关部分。最后,重新编译并保存代码。

使用OD附加游戏进行分析

现在,我们使用OD(OllyDbg)附加到游戏进程,开始分析取物功能。

  1. 在游戏中,尝试从仓库取出一个物品,输入数量,但在点击确认按钮之前,先在OD中下一个断点。
  2. 点击确认后,程序会断下。首次断下的通常是心跳包,我们忽略它,继续运行。
  3. 当再次断下时,才是我们要找的取物数据包。此时,取消断点,并执行到返回(Ctrl+F9)。
  4. 我们发现,取物功能的调用返回到了与存物功能相同的地方。这表明存放和取出物品可能使用的是同一个函数入口。

对比存取物品的数据包

为了找出差异,我们需要对比存放和取出物品时,数据缓冲区的内容。

以下是分析取物数据包的关键步骤:

  1. 让游戏继续运行,我们为这个函数命名为“存取仓库物品”。
  2. 再次尝试从仓库取出一个物品(例如人参)。
  3. 由于机制变化,ECX寄存器可能已改变,我们通过查看栈地址(如 ESP+4EBP-2818)来定位缓冲区数据。假设地址是 18A488
  4. 查看该地址开始的数据,直到 0x90(数据包结束标志)。这是取物时的数据。
  5. 接着,我们存放11个人参到仓库,并查看存放时的数据。

通过对比,我们发现取物数据包比存物数据包更简单。关键差异在于几个特定的4字节数据段。

识别关键数据字段

通过多次存放和取出不同物品(如人参、金创药),我们对比数据包,可以识别出以下关键字段:

  • 存取指令:数据包中有一段4字节数据,在存放和取出时完全不同,但同一种操作下是固定的。
    • 存放物品指令(十六进制):24 17 0C 2B
    • 取出物品指令(十六进制):A4 B3 C6 B2
  • 物品真实ID:另一段4字节数据,随物品不同而变化,但在同一次存取操作中保持不变。
  • 物品数量:表示本次操作涉及的数量。
  • 来源对象偏移:数据包中的 0x12 偏移处的4字节,来源于物品对象的 +0x4C 偏移。
  • 对象标识数据:数据包中的8字节数据,在取物时来源于仓库物品对象的 +0x54 偏移;在存物时则来源于背包物品对象的相同偏移。

以下是数据包结构的核心部分示意(以取物为例):

struct TakeItemPacket {
    DWORD opCode;        // 操作码,例如 0xB2C6B3A4 (取物)
    DWORD itemRealId;    // 物品真实ID
    WORD  count;         // 本次操作数量
    DWORD unk_12;        // 来源于物品对象+0x4C
    BYTE  objData[8];    // 来源于仓库物品对象+0x54
    DWORD currentStock;  // 仓库中该物品当前剩余数量
    BYTE  gridIndex;     // 物品在仓库格子中的下标
    // ... 其他字段
};

确定数据来源

为了验证 objData[8] 的来源,我们在OD中搜索取物数据包中的这8个字节。

  1. 计算仓库中某物品(如人参)的对象地址。
  2. 在内存中搜索该8字节序列。
  3. 搜索结果指向一个地址,通过检查该地址附近的数据(如 +0x44 偏移处的物品数量),确认它正是仓库物品列表中的对象地址。

这证实了我们的判断:取物时,数据来源于仓库物品对象;存物时,数据则来源于背包物品对象。对象不同,但它们在各自容器中的数据结构是相似的。

总结与下节预告

本节课中,我们一起学习了如何分析从仓库取出物品的功能。

我们通过OD动态调试,对比了存取操作的数据包,识别出了代表“存放”和“取出”的固定指令码,并明确了数据包中关键字段(如物品ID、数量、对象数据)的来源。最重要的是,我们区分了存物和取物时操作的对象源不同(背包 vs 仓库),但数据包主体结构一致。

下一节课,我们将根据本节课的分析结果,修改和完善代码中的相关结构定义,并编写测试功能来验证我们的实现是否正确。

好的,我们下期再见。

课程 P66:077-封装仓库列表结构及取仓库物品函数 📦➡️🎒

概述

在本节课中,我们将学习如何封装仓库列表的数据结构,并实现一个从仓库中取出物品的函数。这是实现自动化仓库管理功能的重要一步。


封装仓库列表结构

上一节我们分析了从仓库取物品的功能逻辑。本节中,我们来看看如何为仓库数据定义合适的数据结构,以便后续操作。

首先,我们需要修改缓冲区结构。根据上一节课的分析,从偏移 0x0C 开始的四个字节很可能是一个用于区分“存”或“取”动作的指令。

因此,我们在结构定义中,将缓冲区的前12个字节保留为未知数据,然后定义四个字节来存放这个指令。

代码示例:缓冲区结构

TBufferStruct = packed record
  Unknown1: array [0..$B] of Byte; // 前12个未知字节
  Command: Cardinal;               // 存取指令 (偏移 0x0C)
  // ... 其他字段
end;

其中,存放物品和取出物品会使用不同的 Command 值来区分。

修改并保存结构后,接下来需要定义仓库列表本身的结构。我们发现,仓库内物品对象的偏移和类型(0xEA)与背包物品对象完全一致。

代码示例:仓库列表结构

// 背包物品对象(复用)
TItemObject = packed record
  // ... 物品相关字段(如ID、数量等)
end;

// 仓库列表结构
TDepotList = packed record
  Items: array [0..59] of TItemObject; // 仓库共60格
end;

由于对象类型相同,我们可以复用背包的物品对象结构来定义仓库列表。仓库大小为60格。


初始化仓库列表

定义好结构后,我们需要一个初始化函数来获取仓库列表的指针。

以下是初始化函数的实现步骤,我们仿照背包列表的初始化代码进行编写。

代码示例:初始化仓库列表

function GetDepotList: Pointer;
var
  ListPtr: PCardinal;
begin
  // 通过游戏内固定地址或调用获取列表指针
  ListPtr := PCardinal($GameBase + $DepotListOffset);
  if (ListPtr <> nil) and (PCardinal(ListPtr^ + $TypeOffset)^ = $EA) then
    Result := Pointer(ListPtr^ + $ItemStartOffset)
  else
    Result := nil;
end;

此函数会验证指针有效性并返回仓库物品数组的起始地址。


实现取物品功能函数

初始化工作完成后,我们就可以实现核心的“从仓库取物品”函数了。

以下是该函数的关键步骤,它需要构建一个包含正确指令的缓冲区,并发送给游戏服务器。

代码示例:从仓库取物品

function TakeItemFromDepot(ItemIndex: Integer; Count: Integer): Boolean;
var
  Buffer: TBufferStruct;
begin
  Result := False;
  // 1. 初始化缓冲区
  FillChar(Buffer, SizeOf(Buffer), 0);
  
  // 2. 设置指令:取出物品(例如 0x5)
  Buffer.Command := $5;
  
  // 3. 设置要操作的物品下标和数量
  Buffer.ItemIndex := ItemIndex;
  Buffer.ItemCount := Count;
  
  // 4. 调用游戏发包函数
  if SendPacket(@Buffer, SizeOf(Buffer)) then
    Result := True;
end;

函数会填充缓冲区,其中 Command 字段用于告诉服务器这是“取”操作,然后发送数据包。


功能测试与调试

编写完代码后,必须进行测试以确保功能正常工作。我们通过注入动态链接库(DLL)并调用函数来测试。

测试过程中,我们使用调试器(如OD)下断点,观察发送的数据包是否与游戏正常操作时发出的包一致。

调试发现的问题与解决:

  1. 首次测试失败:发送包后游戏提示“无法转移物品”。
  2. 对比数据包:通过OD截取游戏正常“取物品”操作的数据包,与我们代码发送的包进行对比。
  3. 发现差异:正常数据包中,Command 字段后的某些字节(可能是另一个分类标识)与我们预设的不同。正常“取”操作为 5,“存”操作为 3
  4. 修正代码:根据分析结果,修正 Buffer 结构中相应字段的值,最终测试成功。

这个调试过程强调了逆向工程中对比验证的重要性。


总结

本节课中我们一起学习了:

  1. 封装数据结构:定义了用于仓库操作的缓冲区和仓库列表结构。
  2. 初始化指针:编写了获取仓库列表地址的初始化函数。
  3. 实现核心功能:完成了从仓库取出指定数量物品的函数 TakeItemFromDepot
  4. 测试与调试:通过实际测试和调试,修正了数据包中的关键指令字段,确保了功能的正确性。

通过本课,我们掌握了封装游戏数据和实现基础交互功能的方法,这是构建更复杂自动化工具的基础。下一节课,我们可以在此基础上实现存放物品、整理仓库等更多功能。

课程 P67:078-物品购买功能分析 🛒

在本节课中,我们将学习如何分析游戏中的物品购买功能。我们将通过逆向工程的方法,定位购买物品时发送给服务器的数据包,并解析其数据结构,最终实现一个可以调用购买功能的代码片段。


概述

物品购买是游戏中的常见功能。当玩家与NPC交互并选择购买物品时,客户端会向服务器发送一个数据包。本节课的目标是找到这个数据包的发送函数,分析其数据结构,并理解各个字段的含义。

上一节我们介绍了逆向分析的基本思路,本节中我们来看看如何具体分析购买物品的数据包。


定位购买物品的数据包发送函数

首先,移动到NPC处并打开商店。选择购买物品(例如“金创药小”)并点击确认。此时,游戏客户端会向服务器发送购买请求。

为了分析这个请求,我们需要在发包函数处设置断点。通过调试发现,点击确认按钮后,程序会中断在一个特定的函数调用处。这个函数负责处理购买物品的逻辑。

以下是定位到的关键函数调用:

; 示例调用指令
call dword ptr [eax+0x28]

在这个调用之前,程序会准备一个缓冲区,其中包含了购买物品所需的所有信息。


分析购买物品的数据结构

取消断点后,让程序继续运行,然后再次触发购买。程序会中断在同一个位置。此时,我们可以检查传递给函数的缓冲区内容。

通过分析缓冲区数据,我们发现其结构如下:

+0x00: 指令码 (4字节)
+0x04: 未知字段 (4字节,常为0x01)
+0x08: 物品ID (4字节)
+0x0C: 物品数量低位 (2字节)
+0x0E: 物品数量高位 (2字节)
+0x10: 后续数据...

以下是缓冲区数据的示例:

00 00 00 09  // 指令码
01 00 00 00  // 未知字段,常为1
B7 A3 A9 4A  // 物品ID (例如金创药小)
07 00 00 00  // 物品数量 (7个)
...          // 其他数据

通过对比购买不同物品和不同数量时的缓冲区,我们确认了物品ID和数量字段的位置。指令码用于区分操作类型(例如购买、出售、存入仓库等)。


与仓库操作数据结构的对比

之前分析仓库存取物品时,也遇到了类似的数据结构。两者都使用相同的函数进行处理,但通过指令码来区分具体操作。

仓库存取物品的缓冲区结构如下:

+0x00: 指令码 (例如0x12表示存入)
+0x04: 物品ID
+0x08: 物品数量
...

购买物品的指令码不同,并且缓冲区中多了一个常为0x01的字段。这可能是用于标识操作来源或类型。


出售物品数据结构的初步观察

为了更全面地理解,我们也尝试分析了出售物品的数据包。发现出售物品的缓冲区更长、更复杂,可能包含了物品存在性验证等信息。

出售物品的指令码与购买不同,并且其数据结构不能简单地用购买物品的结构清零后复用。这表明出售逻辑可能包含额外的检查步骤。

由于出售物品的分析更为复杂,我们将在下一节课中详细讨论。本节课我们专注于购买功能。


实现购买物品的代码

根据以上分析,我们可以编写代码来模拟购买物品的操作。以下是使用汇编语言实现的示例:

; 分配缓冲区空间
sub esp, 0x90

; 设置缓冲区数据
mov eax, [ebp-0x2818]
mov dword ptr [eax+0x00], 0x09000000  ; 指令码
mov dword ptr [eax+0x04], 0x01000000  ; 未知字段
mov dword ptr [eax+0x08], 0x4AA9A3B7  ; 物品ID (金创药小)
mov dword ptr [eax+0x0C], 0x0C000000  ; 物品数量 (12个)

; 调用发包函数
push 0x86
push eax
call dword ptr [0xXXXXXXXX]  ; 替换为实际函数地址

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/20f72bb5accc4408ea2ec479b92a5493_18.png)

; 释放缓冲区空间
add esp, 0x90

将上述代码注入游戏并执行,可以成功购买指定数量的物品。这验证了我们对数据结构的分析是正确的。


作业与下节预告

本节课我们分析了购买物品的数据包结构并实现了购买功能。然而,目前的实现依赖于硬编码的物品ID,这在实际使用中并不方便。

以下是本节课的作业:

  1. 结构化缓冲区:将购买物品的缓冲区定义为一个清晰的结构体。
  2. 封装函数:尝试将购买逻辑封装成一个可调用的函数。
  3. 寻找物品列表:为了动态获取物品ID,需要找到游戏中商店物品列表的数据结构。

在下一节课中,我们将:

  • 详细分析出售物品的数据结构。
  • 寻找并解析商店的物品列表。
  • 封装一个完整的物品购买函数,使其可以通过物品名称和数量进行调用。


总结

本节课中我们一起学习了如何逆向分析游戏中的物品购买功能。我们定位了关键的数据包发送函数,解析了购买数据包的结构,并通过编写代码成功模拟了购买操作。理解这些底层数据交互是进行游戏功能修改和自动化的重要基础。

通过动手实践,我们不仅巩固了逆向分析的方法,也为后续更复杂的功能实现打下了基础。

课程 P68:079-分析商店列表 📚

在本节课中,我们将学习如何分析游戏中的商店物品列表。我们将通过逆向工程的方法,找到存储商店物品数据的地址和访问公式,为后续实现自动化购买功能打下基础。


回顾与目标 🎯

上一节我们分析了物品购买功能的数据结构,其中偏移 0x01 处用于表示物品的类型(Item Type)。物品类型用4个字节表示,这与仓库中表示唯一物品ID的8个字节不同。

本节中,我们将重点分析商店物品列表对象。我们的目标是找到一个稳定的公式或地址,能够准确读取商店中每个物品的类型编号。


分析思路 💡

商店物品列表的分析方式与我们之前分析背包列表类似,可以从一个关键数据作为突破口。这个关键数据就是当我们选中商店中某个物品时,游戏会写入内存的特定地址。

以下是分析步骤:

  1. 定位选中物品的地址:首先,我们需要找到当玩家选中商店物品时,游戏写入的对象地址。这个地址通常是一个基址加上一个固定偏移。
  2. 搜索物品列表:利用两个不同但相邻的物品对象地址,在内存中搜索包含它们的字节数组,从而定位到物品列表的起始地址。
  3. 推导访问公式:通过分析访问列表的汇编代码,推导出通过下标(Index)访问列表中特定物品对象的完整公式。
  4. 验证公式稳定性:重启游戏,验证推导出的公式中的偏移量是否固定不变。

详细分析过程 🔍

第一步:定位选中物品的地址

通过之前的分析,我们知道选中技能或物品时,会向一个特定地址写入数据。这个地址的计算公式为:
基址 + 0x28

在本次游戏版本中,我们找到了最新的基址。通过测试,确认选中商店物品时,访问的是同一个基址。例如,选中“金创药(小)”时,其对象地址可以通过 基址 + 0x5C 访问到名字信息。

第二步:搜索物品列表

为了找到存储这些物品对象的列表,我们可以搜索连续的两个物品对象地址。

以下是具体操作:

  1. 记下“金创药(小)”的对象地址:0x80DBDE52D
  2. 记下“金创药(中)”的对象地址:0x081A12D
  3. 在内存中搜索包含这两个地址的字节数组(注意字节序,需从低位到高位排列)。
  4. 搜索到的结果很可能就是商店物品列表的相对地址。

第三步:推导访问公式

通过分析访问上述列表地址的汇编代码,我们可以推导出完整的访问公式。

代码分析显示,物品对象的地址通过以下方式计算:
[[[基址] + 0x210*4 + 4] + 0xE670] + 0x410 + 4 * 下标

其中:

  • [[基址] + 0x210*4 + 4] 计算出一个中间地址。
  • + 0xE670 是从该中间地址读取出的另一个基址。
  • + 0x410 是商店列表的起始偏移。
  • + 4 * 下标 用于定位列表中的特定物品。下标从0开始。
  • 最终地址指向一个物品对象。在该对象中,偏移 +0x4C 处存储的4字节数据即为物品的类型编号

第四步:验证公式

为了确保公式的可靠性,我们进行了以下验证:

  1. 使用公式计算不同下标对应的地址,确认能正确读出“金创药(小)”、“金创药(中)”、“回城卷轴”等物品。
  2. 退出并重启游戏,重新附加进程。
  3. 再次使用相同的公式和基址进行计算,确认仍能正确访问商店列表中的物品。

验证结果表明,在当前游戏版本中,该公式是稳定有效的。公式中的偏移量(如 0x210, 0xE670, 0x410, 0x4C)在本次重启后没有发生变化。


核心概念与公式 📝

本节课的核心是找到了访问商店物品列表并获取物品类型编号的公式。

最终访问公式如下:

物品类型 = ReadMemory4Byte([[ReadMemory4Byte(基址 + 0x210*4 + 4)] + 0xE670] + 0x410 + 4 * 下标 + 0x4C)

代码描述(伪代码):

DWORD baseAddress = 0xXXXXXXX; // 游戏基址
DWORD intermediate = ReadMemory4Byte(baseAddress + 0x210*4 + 4);
DWORD listBase = ReadMemory4Byte(intermediate + 0xE670);
DWORD itemObjectAddress = listBase + 0x410 + 4 * index;
DWORD itemTypeId = ReadMemory4Byte(itemObjectAddress + 0x4C);

总结与下节预告 📌

本节课中,我们一起学习了如何逆向分析游戏中的商店物品列表。

我们首先回顾了物品类型与物品ID的区别,然后通过“选中物品”这一关键操作定位到内存地址。接着,我们通过搜索连续对象和逆向汇编代码,成功推导出访问商店列表及获取物品类型编号的完整内存公式,并对其进行了稳定性验证。

下一节课,我们将基于本节课找到的公式,开始封装具体的读取函数。我们将编写代码来遍历商店列表,获取所有物品的类型编号,并将其与物品名称关联起来,为最终实现指定物品的购买功能做好准备。当然,也可以选择将物品类型编号硬编码,但动态读取列表的方式更具通用性和可维护性。


课程要点回顾:

  • 物品类型(4字节)不同于物品唯一ID(8字节)。
  • 商店列表可通过 基址 -> 中间层 -> 列表基址 -> 下标偏移 的链式公式访问。
  • 物品对象中,偏移 +0x4C 处存储的是物品类型编号。
  • 分析的关键在于找到稳定的基址和偏移量。

课程 P69:080-遍历商店物品 🛒

在本节课中,我们将学习如何分析并遍历游戏中的商店物品列表。我们将定位商店列表的结构,读取物品的分类编号、名称以及其在列表中的下标,并编写代码将这些信息显示出来。


商店列表结构分析 🔍

上一节我们介绍了背包物品的分析方法,本节中我们来看看如何分析商店物品列表。首先,我们需要确定商店列表在内存中的基址和结构。

通过分析,我们得知商店列表的基址可以通过以下公式计算得出:

商店列表基址 = [[[游戏基址] + 偏移1] + 偏移2] + 偏移3

其中,具体的偏移值需要通过调试工具(如CE)动态获取。在我们的案例中,完整的计算路径是:先取游戏基址,加偏移 0x210 并乘以4,再加偏移 0x4,最后加偏移 0x410

商店列表中的每个物品对象,其结构与背包物品对象类似。我们需要关注的偏移量如下:

  • 物品分类编号偏移0x4
  • 物品名称偏移0x8
  • 物品在列表中的下标偏移0x1F4

商店列表的最大容量为60个物品。


代码实现:定义与初始化 🛠️

接下来,我们将把上述分析转化为代码。我们将基于已有的背包物品结构,创建商店物品的相关结构体和函数。

首先,在头文件中定义商店列表的大小和全局变量指针:

#define SHOP_ITEM_MAX 60 // 商店最大物品数量
extern ShopItemManager* g_pShopItemMgr; // 全局商店物品管理器指针

然后,我们创建商店物品管理类。其初始化函数 Init 的核心任务是计算并存储商店列表的基址。

bool ShopItemManager::Init() {
    DWORD dwBase = 0;
    DWORD dwAddr = 0;
    // 计算商店列表基址
    __try {
        dwBase = *(DWORD*)(GAME_BASE_ADDR);
        dwAddr = *(DWORD*)(dwBase + 0x210 * 4 + 0x4);
        m_dwShopListBase = *(DWORD*)(dwAddr + 0x410);
    } __except(EXCEPTION_EXECUTE_HANDLER) {
        m_dwShopListBase = 0;
        return false;
    }
    // 初始化物品列表数组
    for(int i = 0; i < SHOP_ITEM_MAX; i++) {
        m_ItemList[i].dwTypeID = 0;
        memset(m_ItemList[i].szName, 0, sizeof(m_ItemList[i].szName));
    }
    return true;
}

代码实现:遍历与信息获取 🔄

初始化完成后,我们需要能够获取列表中特定下标的物品信息。以下是获取物品信息的函数:

// 根据下标获取商店物品信息
bool ShopItemManager::GetItemByIndex(int index, DWORD& dwTypeID, char* szName) {
    if(index < 0 || index >= SHOP_ITEM_MAX || m_dwShopListBase == 0) {
        return false;
    }
    DWORD dwItemAddr = m_dwShopListBase + index * ITEM_OBJECT_SIZE; // 计算物品对象地址
    __try {
        dwTypeID = *(DWORD*)(dwItemAddr + 0x4); // 读取分类编号
        strcpy_s(szName, NAME_MAX_LEN, (char*)(dwItemAddr + 0x8)); // 读取名称
    } __except(EXCEPTION_EXECUTE_HANDLER) {
        return false;
    }
    return true;
}

为了验证我们的代码是否正确,我们编写一个测试函数来遍历并打印所有商店物品的信息:

以下是遍历商店物品并打印信息的步骤:

  1. 调用 Init 函数初始化商店管理器。
  2. 循环从 0SHOP_ITEM_MAX
  3. 在循环中调用 GetItemByIndex 获取每个位置物品的类型ID和名称。
  4. 如果名称不为空,则打印该物品的索引、类型ID和名称。
void TestShopItems() {
    if(g_pShopItemMgr->Init()) {
        for(int i = 0; i < SHOP_ITEM_MAX; i++) {
            DWORD typeID = 0;
            char name[256] = {0};
            if(g_pShopItemMgr->GetItemByIndex(i, typeID, name)) {
                if(strlen(name) > 0) { // 只打印有名称的物品
                    printf("下标[%d] -> 类型ID: 0x%X, 名称: %s\n", i, typeID, name);
                }
            }
        }
    }
}

运行测试函数后,控制台会输出商店中所有有效物品的信息,例如:

下标[0] -> 类型ID: 0x65, 名称: 小型生命药水
下标[1] -> 类型ID: 0x66, 名称: 中型生命药水
...

这证明我们成功地遍历并读取了商店列表数据。


总结与后续 🎯

本节课中我们一起学习了如何定位和分析游戏中的商店物品列表结构。我们通过动态计算得到了列表基址,并编写代码实现了商店列表的初始化、遍历以及物品信息的读取。

关键点在于理解多层指针寻址计算基址的方法,以及复用类似结构(如背包物品)来定义商店物品。通过测试函数,我们验证了代码的正确性,成功获取了商店内所有物品的分类编号和名称。

下一节课,我们将利用本节课获得的物品信息,进一步封装完整的物品购买函数。

课程P7:018-分析怪物列表 🧟‍♂️

在本节课中,我们将学习如何通过逆向分析工具(如Cheat Engine和OllyDbg)来定位和分析游戏中的怪物列表、怪物对象及其关键属性。我们将从怪物的血量入手,逐步找到怪物对象在内存中的地址,并分析出如名字、等级、坐标、状态等关键属性的偏移量。


分析思路与准备工作

上一节我们介绍了课程的目标。本节中我们来看看具体的分析思路和准备工作。

分析怪物数组的思路是从怪物对象入手。要分析出怪物对象,可以从怪物的属性来入手,例如怪物的名字和血量。这两个属性比较明显。名字数据可能是固定的,且相同名字的怪物较多。具有唯一性的属性是血量,因为血量在受到攻击时会改变。

打开Cheat Engine工具,先附加到游戏进程里。

附加进去之后,先选中一个怪物。以离我们最近的怪物为例选中它。现在它的血量没有变动,是一个固定值。

这个值大致在100~2000这个范围之间。先搜索一下这个范围的数值。

搜索之后,再搜索一下未变动的数值,多过滤几次。然后让角色走动两步,再搜索变动的数值。

搜索几次之后,对这个怪物进行攻击。攻击之后它的数值就变动了,搜索一下变动的数值。然后搜索未变动的数值。不知道这个怪物是否回血。

再次让它的血量改变一下。然后搜索减少的数值。再进行一下范围过滤。

最后收到这几个地址。再次对它进行攻击,攻击后血量减少,再搜索一下减少的数值。

这时它的数字可能为零,因为怪物死掉了,血量消失。过一会儿怪物的血量数值会刷新。这时可以通过它的血量来找到这个对象,附加调试器上去。

怪物可能刷新在别处。对它攻击之后,看一下产生变化的数字。应该是这个范围,或者怪物被其他玩家消灭掉了。这时数值为零。怪物死掉了,也可能是被周围其他玩家杀掉了。

这时可以得到两个对象。一个偏移是+0x54esi加上;另一个是+0x5C,它们之间相差了一个0x8的偏移。

先看一下后面这个。记一下,这是我们找到的一个基址+0x5C,这是怪物的血量。另外还有一个基址在这个地方,复制一下。

这时我们的esi的值等于这个地址,它加上的是+0x54,怪物血量。这两个对象,看哪一个更适合于获取怪物的信息。

接下来的分析再搜索一下。因为怪物列表就是用来保存怪物对象的数组。这两个对象如果真正是对象,就便于搜索。这两个数据都存放在怪物列表里面。

再通过它来搜索一下怪物列表的周围。怪物列表一般是一个全局的。这两个数据在变,实际上是在堆栈里面。

刚才搜索到的是这两个地址。但这个地址也变动了,再重新扫描一下。

这两个地址离得比较近。反正这几个都是基址,先把它记录一下。

另外还有一个0x690的地址,也来搜索一下。这个没有搜索出结果,可能是另外某个对象里面的。这个暂时先保留。

先看一下这两个基址里面有没有所需要的东西。没有需要的就关掉。这个数字也在不断变化,这里已经有一个相同的数字。等一会就进去看一下,都进去看一下。

打开OllyDbg。

先把OllyDbg里的这个进程先取消掉。

再用OllyDbg附加进程。附加进来之后,把Cheat Engine里所找到的这两个地址先记录一下。

0x31D34233,这是第一个,在这里看一下。

这时在这里边发现了很多地址,很多很多的地址。这些地址可能就是怪物对象,也可能是其他的东西。

先进去看一下。先进入怪物对象,刚才+0x54,这里是血量1300,这是我们搜索到的。看血量附近有没有怪物的名字。怪物的名字这个属性也可能在这里。

这里有个大概在+0x320左右的地址,有一个字符串名字。取下+0x320,用dc来显示一下。

还在后面+0x365左右,再后+0x320。从这里来确定的话,它的偏移就是+0x320。当然是从+0x54下面一个对象和偏移来算,这里是+0x320,是怪物的名字。

如果这是一个怪物的列表的话。

那么在后面+0x4这个位置的话,他也应该是怪物的名字。这里可以看到是“大白条山贼”这样的字符串。

后面的数组的表现形式是[edi*4]。再看一下,[edi*4],修复三句,这模型已删了。那么[edi*4]的话,这里是“终极的奇幻书”。说明这个对象列表里面,除了怪物列表,在这个地址可能是空的,没有东西。“金刚石”也是游戏里的物品。

说明所找到的这里也是一个对象的列表。但从目前的分析来说,再来看一下这个基址。再在上面下一个访问的断点,看一下能不能找到所谓的基地址。

在这里就能够找到一个数组,就是0x31CE740,这里一个槽,一个槽。这里就是所谓的对象列表。把它备注一下,把这个删掉。先把这个复制一下。

看还有没有其他的地方。

这个地方也有访问ebx[esi+ebx*4]。那么ebx的来源向上找一下,ebx来源于这个0x31CE740,就是来源于这个地方。这里能够看到的对象没有奇迹。

这里是数组的形式。

这里直接出现了一个基址。删掉这个相应的断点。

接下来再看一下另外找到的基址0x1144A3F3

从这里来看的话,里面的对象不断在变化,不断在更新。这里可以看到。进去看一下,加上一个括号,进去看一下。先用看一下对象的名字。在这个地方大概也是+0x320左右,能够看到“大块头山贼”。

加上数组的访问方式,[0],这里也是“大块头山贼”。[1],这里是“大骨头山贼”。[2],聚不上去。[3][4][5][6],怪物七。其实这里没有尺寸,所以看不到。[7][8],后面都没有了[9]

说明这个地方全部都是怪物对象。这个才应该是周围的怪物列表。从这个形式上来看,可能[0]归零的这个地方可能就是基址。在这里下一个内存访问断点,然后断到了ecx这里。看一下ecx这个数字的来源。

再让它跑一下,看一下有没有其他的地址。其实他在这里直接现了一个数组出来,直接现了一个数组出来,也就是这个+0xA88这个地方。也就是这里是数据。这时它会清零了。先删掉这个断点,让它跑起来。

这里可能才是周围的怪物列表。对象列表基址里面放的才是怪物对象。把这个地址记下来。

这时找到两个对象,一个是周围的怪物,另外一个是其他的对象列表。这个内存基址里面,但现在分析的是怪物列表的基址,暂时用不上它,先做一个记号。

这个就是怪物列表的一个基址。可以把它整理一下。整理一下,把基址放在前边,加上数组里的下标就行了。它是用eax代表了下标。这里从零开始。

这就是访问到对象。

第一个对象看一下。刚才分析了+0x320这个地方是它的名字。还有一个+0x54这个地方是它的血量。

血量,把它记录一下。

另外还有一个属性,这里有个+0x20,它是3232代表的恰好是它的一个等级。很有可能这个32就是它的等级。修改一下,看有没有效果。当然现在不知道所选择的这个怪物对象究竟是哪一个。

可以先改它的名字,看看有没有效果。用dc来显示一下,对了就是选中的这个,它的名字就变化了。还有一个是等级+0x28,看改一下它的等级,改成30了,它的级别也变了。说明这个地方。

+0x28这个地方是怪物的等级。

还有一些是怪物的坐标。看一下,用浮点数来看一下。要看一下当前坐标是多少,218.1。怪物的坐标也不会相差太多。这里有一个坐标,221.9。对了,201201.81,它是217.1。这个是负的。

那么这个就是怪物的坐标了。把它记录一下。当然前面还有一个坐标,这里一共有两个坐标。+0x1018,看一下+0x1018

这两个坐标都是怪物坐标。因为怪物有两个来回移动的点,可能是它在移动。再看一下,日,要求前任要求,哈哈哈,是这个怪物,它在移动在变化。看一下哪一个数值是变化的。走到它跟前去,218.7198.4

这样198.4这个离得比较近一点,应该是后面这个。这是70,我这个是80。这时怪物的坐标与我们重合198.4218.7。而要放弃,然后985这个是84

应该是后面一个坐标离我们比较近。那就是这两个坐标之间,到时候大概是过滤,都把它记下。怪物的坐标有两个,可能是来回移动的两个点。一个是+0x1018,是X坐标。还有一个是+0x101C,是Y坐标。

+0x1018,这个是+0x101C,这个是+0x1020。再看一下这个视频,还有一个坐标是+0x1024的地方,还有一个坐标是+0x102C这个地方。

这就是常用的几个属性。

当然还有一个怪物的状态。再看一下它的血量这个地方。当前攻击的这个怪物来说,是刚才改动的那一个怪物。这个才是改动的怪物。

另外怪物应该还有一个死亡的状态。这个数值可以通过Cheat Engine搜一下。先移动到它的基址这个地方。

搜索的时候来看,就从基址开始搜索,0x30233C9。它活着的时候,这时已经被杀死掉了。应该有一个状态是0或者是1两种状态。搜索两个状态之间的一个数字,01

这个地方,一般来说,再搜一下未变动的。但不知道它是用1表示死亡还是用1表示未死亡。这一点不了解。

不了解的话,这时搜一下未变动的数据,变动的数值。然后攻击它,让它死亡之后,选择变动的数值。换一把武器,然后死亡之后,这个数字变动,搜索变动的数值。然后再搜一个两者之间的数字,01之间的。

看它有没有复活。它已经复活了。复活之后这个数值应该再次变动,变动的数值。离得比较近的这两个,它活着如果是一的话,那么极有可能就是这两个数字代表它的状态。

数字之间的再扫一下,FASB这两个地方。

当然一个是哪个为0,一个是为1。看一下偏移是多少。

+0x314,记一下这个。离他很近,这个地方+0x314。另外还有一个地方。

这个地方离得比较远一点。离得太远的话,可能性就比较低了。因为对象不会太大,应该不会到这个位置,不会有前多个字节。就应该是刚才的这个位置,在+0x314,在这个地方。

再次攻击它,让它死亡,看一下。死亡之后这里就为0了。所以说这个东西,怪物的状态,活着为1

是否,好久。死亡就为0。很多时候在攻击的时候,要判断怪物是否死亡了。如果怪物死亡了,就对他继续进行攻击了。

今天这节课就分析到这里。另外当然还有一个对象的列表,也把它整理出来。这个是对象列表。这个对象列表的话,暂时还不知道。

目前所知道的而言,下节课再编写代码,把怪物的这些属性偏移找出来。怪物的名字、血量、等级、是否死亡,这些属性在编写代码,然后把它封装好相应的数据。


关键属性偏移总结

以下是分析得出的怪物对象关键属性偏移量:

  • 怪物列表基址0x1144A3F3
  • 怪物对象数组访问基址 + 0xA88 + 索引 * 0x4
  • 怪物名字偏移+0x320
  • 怪物血量偏移+0x54
  • 怪物等级偏移+0x28
  • 怪物坐标X1偏移+0x1018
  • 怪物坐标Y1偏移+0x101C
  • 怪物坐标X2偏移+0x1024
  • 怪物坐标Y2偏移+0x1028
  • 怪物存活状态偏移+0x314 (1=存活,0=死亡)

其他对象列表

另外发现一个全局对象列表,基址为 0x31CE740,其结构可能与怪物列表类似,但包含游戏内其他类型的对象(如物品)。此列表暂不深入分析,仅作记录。


总结

本节课中我们一起学习了如何定位和分析游戏中的怪物列表。我们从怪物血量这个动态属性入手,使用Cheat Engine进行多次变化搜索,定位到怪物对象地址。随后,利用OllyDbg进行深入分析,找到了怪物列表的基址和访问方式,并逐一分析出了怪物对象的名字、血量、等级、坐标以及存活状态等关键属性的内存偏移量。这些偏移量为后续编写读取怪物信息的代码打下了坚实的基础。下节课我们将基于这些发现,开始编写代码来读取并封装这些怪物数据。

课程 P70:081-购买物品函数 BuyGoodsForName 封装 🛒

在本节课中,我们将学习如何封装一个名为 BuyGoodsForName 的函数,用于根据物品名称和数量购买游戏中的物品。我们将基于第八次课的代码进行扩展,完成购买功能的实现。


上一节我们介绍了商店列表的查询功能,本节中我们来看看如何封装一个完整的购买函数。

首先,打开第八次课的代码,并在此基础上进行编写。

第一件事是添加相关的扩展机制。展开基础单元后,我们发现发包扩展和参数 700 的扩展已经添加。因此,我们需要替换掉原有的调用部分。替换时,需要先将数据存入计时器或变量,再进行调用,并对参数进行替换。

实际上,之前的调用部分可以单独封装成一个函数。我们先完成本节课的内容,后续再进行代码优化。

首先,我们需要添加一个新的结构体。之前用于存放仓库物品的缓冲区结构体在此不适用。

以下是购买物品所需的结构体定义:

TBuyGoodsBuffer = packed record
  // 前两个字节占位
  Placeholder1: Word;
  // 指令类型,4字节
  CommandType: DWORD;
  // 购买/出售标识,4字节占位
  Placeholder2: DWORD;
  // 物品分类编号,4字节
  GoodsType: DWORD;
  // 后续80字节缓冲区,用于防止数据溢出
  Buffer: array [0..$7F] of Byte;
end;

结构体定义好后,接下来在商店列表单元中封装购买函数。

我们在商店列表类的末尾添加一个成员函数,并加上作用域限定前缀。函数说明如下:根据传入的物品名称搜索商店列表,如果找到该物品,则调用购买功能;如果未找到,则返回错误值。

以下是函数实现的核心步骤:

  1. 通过物品名称调用查询函数,获取物品在列表中的下标。
  2. 如果下标小于0(即未找到),则直接返回。
  3. 如果找到物品,则进行购买操作,此过程涉及指针操作,需添加异常处理。
  4. 在异常处理中打印调试信息,若出现异常则返回错误值。

购买操作需要填充我们定义的结构体:

// 初始化结构体
FillChar(BuyBuffer, SizeOf(BuyBuffer), 0);
// 设置指令类型
BuyBuffer.CommandType := $80092;
// 设置操作为购买
BuyBuffer.Operation := BUY_OPERATION; // 假设 BUY_OPERATION 是定义为购买的红
// 设置购买数量
BuyBuffer.Quantity := AQuantity;
// 通过下标获取物品类型并设置
BuyBuffer.GoodsType := FGoodsList[ItemIndex].GoodsType;
// 将结构体地址传递给发包函数
SendPacket(@BuyBuffer);

编译成功后,我们转到主线程单元进行测试。我们设置了三个测试按钮:

  • 测试一:购买“金疮药(小)”,数量为2。
  • 测试二:购买“人参”,数量为1。
  • 测试三:购买“秘制金疮药”,数量为1。

进入游戏并挂接主线程后,分别点击测试按钮。可以观察到,商店中对应物品的数量成功减少,说明购买功能封装成功。


本节课中我们一起学习了如何封装 BuyGoodsForName 函数。我们定义了购买所需的结构体,实现了根据名称查询并购买物品的逻辑,并通过多个测试案例验证了功能的正确性。这个函数为后续自动化商店操作提供了基础。

课程 P71:082-分析出售物品封包结构 📦➡️💰

在本节课中,我们将学习如何分析网络游戏中“出售物品”这一操作所对应的数据包结构。我们将通过对比之前学习的“购买物品”和“仓库存取”的封包,来理解出售物品封包的特点,并最终通过代码进行测试验证。


概述

出售物品、购买物品以及仓库存取等交易行为,在游戏底层通常调用同一个功能库,并通过向服务器发送特定结构的数据包来实现。本节课的核心目标是解析出售物品时发送的数据包(封包)格式。

上一节我们分析了购买物品的封包结构,本节中我们来看看出售物品的封包有何异同。


封包指令分析

首先,我们确认出售物品所使用的指令。通过调试工具观察内存,发现出售物品时,向服务器发送的指令与商店购物、仓库存取是同一个。

核心指令代码

8000 92

虽然写入内存的字节顺序可能不同(例如 09 00 80 00),但组合起来依然是 8000 92 这个指令。这证实了商店的买卖操作共享同一套底层通信指令。


缓冲区数据结构对比

接下来,我们分析存放出售物品数据的缓冲区。其起始地址通常为 2818(十六进制)。通过对比出售不同物品时的缓冲区数据,我们可以找出固定不变的部分和需要动态填充的部分。

以下是缓冲区关键偏移量的分析:

  • 偏移 +02:存放指令 8000 92
  • 偏移 +06:存放操作类型。02 代表“出售”。
  • 偏移 +0A+0D:通常为一串固定值,例如 26 0F 1B 28。在初始化缓冲区时可以直接填充。
  • 偏移 +16:存放要出售的物品数量。这是一个4字节(DWORD)整数。
  • 偏移 +26+2D:共8个字节,来源于游戏内物品对象在内存中 +54 偏移处的数据。这8个字节唯一标识了一个物品。
  • 偏移 +3F:存放该物品在背包中的下标位置(从0开始)。这是一个1字节的整数。

通过出售不同物品(如“金创药小”和“人参”),并对比缓冲区变化,可以确认以上偏移量是正确且关键的。


代码实现与测试

理解了数据结构后,我们就可以用代码来构造并发送这个封包。以下是实现步骤:

  1. 定义缓冲区:首先,我们需要定义一个字节数组,并用一次出售操作捕获的完整缓冲区数据来初始化它。这确保了所有固定字段都被正确设置。
    BYTE buffer[144] = {
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        // ... 更多初始化数据
    };
    

  1. 修改关键数据:然后,我们只需要修改缓冲区中几个特定的位置,即可完成一次新的出售请求。

    ; 假设 edx 指向了我们的缓冲区 buffer
    mov dword ptr [edx+0x02], 0x00800092 ; 写入指令
    mov byte ptr [edx+0x06], 0x02        ; 写入操作类型:出售
    mov dword ptr [edx+0x16], 0x03       ; 写入出售数量:3个
    ; 写入物品标识 (8字节,来自物品对象+54偏移)
    mov eax, [物品对象地址+0x54]
    mov dword ptr [edx+0x26], eax
    mov eax, [物品对象地址+0x58]
    mov dword ptr [edx+0x2A], eax
    mov byte ptr [edx+0x3F], 0x0B        ; 写入背包下标:11
    
  2. 调用发送函数:最后,像之前课程一样,调用游戏内发送网络封包的函数,将我们构造好的 buffer 发送给服务器。

通过上述代码测试,可以成功实现出售指定数量物品的功能。


总结

本节课中我们一起学习了出售物品的封包结构。我们了解到:

  1. 出售物品与购买、存取使用相同的网络指令 (8000 92)。
  2. 出售封包在缓冲区 +06 偏移处用 02 标识操作类型。
  3. 封包中需要动态填充的关键信息包括:物品数量 (+16)、物品的唯一标识符 (+26,来自物品对象) 以及背包下标 (+3F)。
  4. 通过初始化一个完整的缓冲区模板,然后仅修改少数几个字段,即可高效地构造出售请求。

课后作业:请参考第81课(购买物品)的代码结构,封装一个出售物品的函数。函数原型可设计为 SellItem(物品名称, 出售数量),以提高代码的可读性和复用性。


下节预告:在下一节课中,我们将对这个出售功能进行更完善的函数封装,并融入错误处理机制。

课程 P72:083-封装物品出售函数SellGoodsForName 📦➡️💰

在本节课中,我们将学习如何封装一个向商店出售物品的函数。我们将基于上一节课对物品出售封包结构的分析,编写并测试一个名为 SellGoodsForName 的函数,该函数可以根据物品名称和数量出售背包中的物品。


回顾与准备

上一节我们分析了物品出售封包的结构并计算了相关偏移。本节中,我们将基于这些偏移来封装出售函数。

首先,我们需要打开第82课的代码作为基础。第82课的代码包含了物品出售相关的初步分析。

接下来,我们转到结构体单元,检查是否需要重新定义结构。我们需要整合之前用到的偏移,并添加新的数据。

修改结构体定义

我们需要对结构体进行修改,以包含出售操作所需的所有字段。以下是关键偏移的整合与计算:

  • +2: 需要修改,代表某种类型标识。
  • +6: 代表出售操作码。
  • +1+2: 代表物品类型。
  • +16: 代表物品数量。
  • +26: 代表物品对象的8字节标识。
  • +21: 代表买卖类型,占4字节。
  • +3F: 代表背包物品下标,占1字节。

基于以上偏移,我们重新计算并定义结构体。核心是确保从 +2E+3F 的字节空间被正确划分。经过计算,我们在 +21(4字节)之后,需要预留13个字节的空间,才能到达 +3F 这个1字节的下标字段。

因此,结构体定义大致如下(使用伪代码表示核心布局):

struct SellPacket {
    // ... 其他前置字段 ...
    DWORD type_flag;      // 偏移 +2
    DWORD op_code;        // 偏移 +6
    WORD  item_type1;     // 偏移 +1, +2
    // ... 中间字段 ...
    QWORD item_count;     // 偏移 +16
    // ... 中间字段 ...
    QWORD item_id;        // 偏移 +26, 8字节
    DWORD trade_type;     // 偏移 +21, 4字节
    BYTE  reserved[13];   // 填充字节,用于对齐
    BYTE  bag_index;      // 偏移 +3F, 1字节
    // ... 可能的后置字段 ...
};

编译结构体,确保没有错误。

封装出售函数

结构体定义完成后,我们开始封装函数。我们将函数添加到与背包相关的模块中。

函数的主要逻辑步骤如下:

  1. 根据传入的物品名称,在背包中查找对应的物品下标。
  2. 如果下标小于0,表示背包中没有该物品,函数直接返回。
  3. 如果找到物品,则构造出售封包并发送。

以下是函数实现的核心步骤:

首先,我们定义并初始化一个结构体变量来存放封包数据。由于编译器可能不支持直接的字节集初始化,我们改用指针操作来填充缓冲区。

接着,我们填充结构体的各个字段:

  • 设置操作类型(+2)。
  • 设置出售操作码(+6)。
  • 设置物品类型(+1, +2)。
  • 设置要出售的物品数量(+16),数量由函数参数传入。
  • 设置物品ID(+26)。
  • 设置买卖类型(+21)。
  • 最关键的一步,设置背包中该物品的下标(+3F)。这个下标来自第一步的查询结果。

对于指针操作的部分,我们将其移到汇编代码调用之外进行,以确保安全性和可读性。

测试函数

函数封装完成后,我们进行测试。

首先,在主线程单元中,我们找到背包的全局变量。然后,我们调用新封装的 SellGoodsForName 函数,例如尝试出售1个人参。

设置好工作目录并挂接主线程后,我们转到游戏内进行测试。注意:出售操作需要先打开正确的NPC商店窗口(例如平一指)。

如果出售没有成功,我们需要按以下步骤排查:

  1. 检查封包缓冲区的参数是否正确。
  2. 对比我们代码中使用的偏移量与之前分析的结果是否一致。
  3. 在出售操作触发时,检查游戏状态和我们的数据是否同步。

通过调试和对比,逐步修正可能存在的问题,直到出售功能正常工作。


本节课中我们一起学习了如何封装一个完整的物品出售函数 SellGoodsForName。我们从修改结构体定义开始,逐步实现了根据物品名称查找、构造封包并发送的完整逻辑,最后进行了测试和问题排查。这个函数封装了底层细节,使得通过名称出售物品变得简单直接。

课程 P73:084-1级任务分析_接任务 📝

在本节课中,我们将学习如何分析并实现游戏内接取任务的逻辑。我们将以一级任务“天下第一虎空”为例,通过分析数据包和调用过程,理解接任务的核心机制。


任务接取流程概述

上一节我们介绍了任务相关的界面和NPC交互。本节中,我们来看看如何具体接取一个任务。

接取任务通常涉及与服务器通信。其基本流程如下:

  1. 找到并打开发布任务的NPC。
  2. 在任务列表中确认要接取的任务。
  3. 点击“接受”按钮,向服务器发送接取请求。

我们将重点分析第三步中发送的数据包。


定位接任务的数据包

首先,我们需要使用调试工具(如OD)来拦截接取任务时发送的网络数据包。

以下是定位关键调用点的步骤:

  1. 在游戏中打开NPC“京香玉”的任务列表。
  2. 对“天下第一虎空”任务点击“接受”。
  3. 在调试器中,程序会中断在发送网络封包的关键函数处。
  4. 通过分析堆栈和寄存器,我们可以找到存放任务数据的缓冲区地址。

核心的调用代码通常类似于一个通用发包函数,其参数可能如下:

PUSH 0C          ; 数据包长度
PUSH ECX         ; 数据缓冲区地址
CALL SEND_PACKET ; 调用发包函数

分析任务数据缓冲区

当程序中断在发包函数时,我们需要检查 ECX[ESP+4] 所指向的缓冲区数据。

以“天下第一虎空”任务为例,接取时捕获到的缓冲区数据(十六进制)可能如下:

83 00 00 00 01 F4 00 00 00 01 00 00

这段数据仅有12个字节。

我们需要理解这些字节的含义。通常,任务数据包的结构是固定的:

  • 前4个字节:代表任务ID。例如 83 00 00 00 表示任务ID是0x83(131)。
  • 中间4个字节:可能代表NPC ID或其他标识。01 F4 00 00 可能对应NPC“京香玉”。
  • 最后4个字节:代表操作类型。00 01 00 00 可能表示“接取”操作(1),而 00 02 00 00 可能表示“放弃”操作。

在内存中,数据以小端序(Little-Endian)排列,因此我们在构造数据时需要注意字节顺序。


构造并测试接任务调用

理解了数据结构后,我们可以尝试用汇编代码模拟发送这个数据包,以实现自动接任务。

以下是构造调用的一种方法:

  1. 在堆栈上分配空间并填充任务数据。
  2. 将缓冲区地址放入 ECX
  3. 压入数据长度和缓冲区地址参数。
  4. 调用发包函数。
  5. 平衡堆栈。

对应的汇编代码示例如下:

SUB ESP, 10h                ; 在堆栈上分配16字节空间
MOV DWORD PTR [ESP], 83000000h ; 填充任务ID (小端序: 0x83)
MOV DWORD PTR [ESP+4], 0000F401h ; 填充NPC ID
MOV DWORD PTR [ESP+8], 00000001h ; 填充操作码 (接取)
MOV DWORD PTR [ESP+0Ch], 0       ; 填充剩余部分为0
LEA ECX, [ESP]                  ; ECX指向缓冲区
PUSH 0Ch                        ; 参数2: 数据长度=12
PUSH ECX                        ; 参数1: 缓冲区地址
CALL 发送封包函数地址           ; 调用发包函数
ADD ESP, 20h                    ; 平衡堆栈 (16+4+4=24=0x18,需根据实际情况调整)

重要提示:在实际测试时,为了安全起见,可以先修改缓冲区数据(例如将一个字节改为无效值),让第一次调用失败,以避免重复接取已完成的任务。确认逻辑无误后,再使用正确的数据进行成功调用。


关键点与常见问题

在分析和测试过程中,需要注意以下几点:

  • 操作步骤:接取任务通常是两步操作。第一步点击任务列表中的任务名(数据包操作码可能为1),第二步点击“接受”按钮(数据包操作码可能为2)。我们分析的是第二步。
  • 数据长度:不同任务的数据包长度可能不同,需根据实际情况调整。
  • 字节顺序:所有多字节数据(如ID)都必须转换为小端序格式。
  • 堆栈平衡:调用函数后,必须正确恢复堆栈指针,否则会导致程序崩溃。

总结

本节课中,我们一起学习了如何分析游戏接取任务的功能。

我们首先回顾了接任务的整体流程,然后使用调试工具定位到发送接任务请求的关键代码和数据缓冲区。通过分析缓冲区的十六进制数据,我们解读了任务ID、NPC ID和操作类型等核心信息的存储格式。最后,我们学习了如何用汇编代码构造并发送这个数据包,以模拟接任务的操作。

掌握这一分析过程,是理解游戏网络通信机制和实现自动化功能的重要基础。在后续课程中,我们将运用类似的方法分析其他游戏功能。

逆向工程教程 P74:085-1级任务分析_交任务 🎮

在本节课中,我们将要学习如何分析并完成游戏中的任务提交过程。我们将通过逆向工程的方法,定位并理解客户端向服务器提交任务时发送的数据包结构。

上一节我们介绍了如何分析并接取任务,本节中我们来看看如何将已完成的任务提交给NPC。

任务提交流程分析

接取任务后,任务物品栏中会出现“金镶玉的书信”。按 Ctrl+Q 打开任务列表,可以看到“天下第一武功”任务,描述要求我们带着书信去找盟主。

我们需要找到盟主NPC的位置。

到达盟主位置后,点击“天下第一武功”即可完成任务提交。

数据包断点分析

为了分析提交任务时发送的数据,我们在发包函数 46690 处下断点。点击提交任务后,程序会在此处中断。

返回上一层调用,我们发现接任务与交任务调用了同一个函数,使用了相同的结构。

以下是分析步骤:

  1. 首先,转到 [esp+4] 地址处,此处存放了数据包缓冲区的地址。
  2. 数据包前8个字节可能表示任务操作类型(接取/提交),其后的数据可能包含任务编号。
  3. 在本例中,任务编号为 1001

我们先将此处的数据破坏(例如用0填充),让程序继续运行,然后使用代码注入器进行测试。


关键数据包定位

任务提交可能涉及多个数据包。我们再次下断点并点击提交,捕获到了另一组数据。

这可能是真正的提交任务数据包,或者需要与之前的数据包组合才能生效。我们让程序继续运行,并观察堆栈变化。

程序可能发送了四到五个数据包来完成整个提交过程。

多账户测试验证

由于当前角色的任务物品已消失,我们换一个新账户进行测试。

新账户的“天下第一武功”任务显示未完成。我们使用代码注入器,依次注入之前捕获的各个缓冲区数据进行测试。

以下是测试的数据包示例:

  • 数据包1: 1001...
  • 数据包2: 00 52 59 20 ... 00 0B ...
  • 数据包3: 01 00 ... EF 04 10 05 ... 83 00



测试发现,数据包3 成功触发了任务提交。这表明任务提交可能需要多个步骤的数据交互。

完整流程模拟测试

我们新建一个账号,模拟完整流程:

  1. 第一步: 注入接任务的数据包(不打开NPC对话也可接取任务)。
  2. 第二步: 控制角色移动到盟主位置。
  3. 第三步: 尝试直接注入提交任务的数据包(数据包3),发现无效。
  4. 第四步: 打开NPC对话框后,再注入数据包3,仍然无效。
  5. 第五步: 在打开NPC对话框后,先注入一个交互数据包(可能是“任务二”或第一步的数据),再注入提交数据包(数据包3),成功获得任务奖励“金链子”。

这说明完整的任务提交可能需要至少两个阶段的数据交换。

总结

本节课中我们一起学习了游戏任务提交的逆向分析过程。我们通过下断点捕获了客户端与服务器通信的数据包,并通过多轮测试,初步确定了提交任务可能需要多个数据包按顺序发送才能成功,例如一个用于初始化NPC交互,另一个用于确认提交。更详细的包结构分析和组合逻辑,建议大家在课后自行深入测试。

课程 P75:086-封装发包函数及测试1级任务代码 📦➡️🎯

在本节课中,我们将学习如何封装一个通用的发包函数,并利用它来测试一个游戏中的1级任务流程。我们将从调整程序权限开始,逐步封装函数,并最终实现自动接取、对话和提交任务的完整代码。

概述

我们将修改第83课的代码,首先解决程序权限问题以确保注入成功。接着,封装一个通用的发包函数以简化多处调用。最后,利用这个封装函数,构建并测试一个自动完成1级任务的脚本。

调整程序权限

由于权限问题,代码有时无法注入到目标程序。我们可以通过调整链接器设置来解决。

以下是具体步骤:

  1. 打开项目属性。
  2. 转到“链接器” -> “清单文件” -> “UAC执行级别”。
  3. 将其设置为 requireAdministrator(即“管理员”级别)。

这样设置后,生成的注入程序(exe)部分便会以管理员权限运行,使注入操作更加方便。

封装通用发包函数

上一节我们解决了权限问题,本节中我们来看看如何封装一个通用的发包函数。这个函数将被多处调用,它接收两个参数:数据缓冲区的指针和缓冲区的大小。

首先,我们转到结构定义单元。在文件最前面,我们添加一个参数定义,其类型为 void*,表示缓冲区指针;另一个参数是 DWORD 类型,表示缓冲区大小。

接着,我们转到源代码单元。以下是封装函数的实现步骤:

  1. 添加异常处理机制。
  2. 编写内联汇编代码,将传入的参数(缓冲区大小和指针地址)分别加载到合适的寄存器中。
  3. 调用目标发包函数(地址为 0x4A6690)。
  4. 函数需要返回值:如果出现异常则返回 false,成功则返回 true

以下是核心的汇编代码逻辑:

mov ecx, [缓冲区指针]
push [缓冲区大小]
call 0x4A6690

封装完成后,我们编译测试一下。之后,我们就可以在代码中直接调用这个封装函数,例如:

bool result = SendPacket(pBuffer, bufferSize);

修改现有功能调用

封装好通用函数后,我们可以用它来简化之前编写的功能代码。

以下是需要修改的功能点列表:

  • 出售物品:直接调用封装函数,传入物品数据结构的指针和固定大小 0x86
  • 存入仓库:同样调用封装函数,传入仓库操作数据结构的指针和大小 0x86
  • 取出仓库物品:此功能原使用 move 指令实现,现改为调用封装函数。注意,这里需要传递结构体的地址(使用 & 操作符取址)。

逐一修改并编译通过后,这些功能的实现将更加简洁和统一。

构建并测试1级任务脚本

现在,我们利用封装好的函数来构建一个自动完成1级任务的脚本。该任务流程分为三步:接任务、打开NPC对话框、提交任务。

首先,我们在主线程单元中编写测试代码。以下是每一步的具体实现:

  1. 接任务
    • 构建一个特定的数据结构,填充任务ID(例如 0xEF4006)等信息。
    • 调用封装函数 SendPacket 发送这个数据包。

  1. 打开NPC对话框
    • 此步骤需要先选中名为“门主”的NPC。
    • 通过遍历游戏对象列表,比较名称来找到目标NPC。注意:我们发现游戏中的NPC名称后可能带有一个空格字符(ASCII 0x20),在比较字符串时必须包含它,否则无法正确识别。
    • 找到NPC后,发送打开对话框的封包。

  1. 提交任务
    • 提交任务需要发送两个连续的封包。
    • 构建两个略有不同的数据结构(主要是任务状态标识不同)。
    • 依次调用两次 SendPacket 函数发送。

编写完成后,我们创建一个新的游戏角色进行测试。依次执行三步操作,观察角色是否成功接取任务、打开NPC对话框并获得任务奖励(如经验值),以验证脚本功能。

总结

本节课中我们一起学习了如何封装通用发包函数并应用于实际游戏任务自动化。我们首先通过调整UAC权限解决了注入问题,然后封装了一个接收缓冲区和大小参数的函数,简化了多处调用。接着,我们用这个函数重构了出售、存仓等已有功能。最后,我们设计并实现了一个完整的1级任务自动化脚本,涵盖了接任务、寻路对话、交任务的全流程,并通过新建角色进行了成功测试。下一节课,我们将把这个任务流程进一步封装成一个独立的、可复用的函数。

逆向课程 P76:087-分析可执行任务列表 📋

在本节课中,我们将学习如何分析游戏中的可执行任务列表数据。我们将通过动态调试,定位并解析任务列表在内存中的存储结构与读取逻辑。


一、定位任务列表数据缓冲区

上一节我们介绍了任务相关的数据分析,本节中我们来看看如何定位未执行任务列表的数据来源。

首先,在游戏中按下 Ctrl+Q 打开“全部任务”列表。我们选择一个任务(例如“保护农田”)作为分析目标。

使用调试器附加到游戏进程,并搜索任务名称字符串。修改字符串后返回游戏观察变化,可以定位到显示任务名的内存地址。通过反复打开/关闭任务列表并修改字符串,可以确定任务列表数据的来源缓冲区地址。

最终,我们定位到一个关键地址,它是任务相关数据的缓冲区起点。我们将以此为基础进行回溯分析。


二、回溯读取任务名的代码

定位到数据缓冲区后,我们需要找到读取这些数据的代码。

在调试器中查找访问了该关键地址的代码。通常会有多处读取操作。我们将这些代码地址记录下来。

以下是访问该地址的几处关键代码(均为读取操作):

  1. 地址 A: 读取一个字节的任务数据。
  2. 地址 B: 另一处读取操作。
  3. 地址 C: 可能涉及任务列表更新的读取。

写入操作可能发生在任务状态改变(如完成任务)后,重新打开列表时服务器更新数据。

接下来,我们使用 OD 附加游戏,转到第一个读取代码处下断点。


三、分析任务名获取公式

当游戏断下后,我们观察堆栈和寄存器,追踪任务名数据的来源。

代码显示,任务名来源于一个基址加上一个计算出的偏移。分析汇编代码,其逻辑可以概括为以下公式:

任务名指针 = [基址 + 偏移 * 0x40 + 0x4]

其中:

  • 基址 是一个全局的游戏对象指针。
  • 偏移 是从任务列表结构体中循环读取的一个索引值(每次循环增加 8)。
  • 0x40 是每个任务名数据块的大小。
  • + 0x4 是任务名字符串在数据块内的偏移。

核心的汇编代码逻辑如下:

mov edx, [edi]          ; 从EDI指向的内存取出偏移值
imul edx, edx, 0x40     ; 偏移值乘以 0x40
add edx, [ecx+0x4]      ; 加上基址ECX中的地址再加4
mov eax, [edx]          ; 最终得到任务名字符串指针

我们的目标是找到 基址 和存储 偏移数组 的源头。


四、追踪基址与循环结构

通过逐步回溯寄存器 ECX(基址)和 EDI(偏移数组指针)的来源,我们最终定位到:

  • 基址 来源于一个我们之前已知的全局游戏对象指针,例如 [GameObj + 0x2A4]
  • 偏移数组的起始地址 计算公式为:起始地址 = [某个指针 + 0x44]
  • 偏移数组的结束地址 计算公式为:结束地址 = 起始地址 + 0x48

代码通过一个循环遍历这个数组,每次取出一个偏移值,代入上述公式计算,从而获取列表中的每一个任务名。循环结束的条件就是当前指针 EDI 是否等于结束地址。

关键循环与结束判断代码如下:

; 循环开始
add edi, 0x8                ; 每次循环,指针增加8字节
cmp edi, esi                ; ESI 是结束地址(起始地址+0x48)
jl loop_start               ; 如果未到达结束地址,继续循环

五、验证与总结

本节课中我们一起学习了如何分析游戏任务列表的数据结构。

  1. 定位数据:通过内存修改与对比,找到任务列表的显示缓冲区。
  2. 回溯代码:找到读取任务数据的关键代码片段。
  3. 解析公式:分析出获取单个任务名的核心计算公式:任务名 = [[基址] + (偏移 * 0x40) + 0x4]
  4. 理解结构:明确了任务列表是以一个“偏移值数组”的形式存储,程序通过遍历该数组,结合基址和固定步长(0x40)来获取所有任务名。

我们得到了以下核心信息:

  • 基址指针:通常是一个全局游戏对象。
  • 偏移数组起始[某指针 + 0x44]
  • 偏移数组结束起始地址 + 0x48
  • 任务名计算:对数组中每个值 N,任务名地址为 [[BasePtr] + (N * 0x40) + 0x4]

在下一节课中,我们将基于本节课分析出的公式和地址,编写代码来自动遍历并打印出所有的可执行任务列表。

课程 P77:088-遍历可做任务列表 📋

在本节课中,我们将学习如何遍历游戏中的任务列表。我们将基于上一节课分析的数据结构,编写代码来读取并打印出任务名称。课程将涵盖关键的内存偏移计算、汇编代码的封装与调试,并区分“可执行任务”与“所有任务”列表。


回顾与分析数据 📊

上一节课我们分析了任务列表的相关数据结构。

以下是任务名称的读取公式:

任务名指针 = [[[基址] + 偏移1] + 偏移2] + 偏移3

具体来说:

  • 任务名称来源于一个特定的内存地址链。
  • 遍历任务列表时,存在一个起始条件(偏移 +4C4)和一个结束条件(偏移 +4C8)。
  • 每次遍历,指针会增加 8 字节

有了这些数据,我们就可以开始编写遍历代码。


编写遍历函数 💻

我们将打开第86课的代码,在测试单元中封装一个遍历函数。

首先,转到结构单元,在末尾添加一个遍历函数。函数需要处理日常的数据准备和地址计算。

以下是函数的核心步骤概述:

  1. 定义遍历的起始地址和结束地址。
  2. 使用循环,每次指针增加8字节,直到到达结束地址。
  3. 在循环体内,根据分析出的公式读取当前任务名称。

关键的内存读取操作使用内联汇编实现,以确保精确性。我们需要初始化相关寄存器(如 EDI, ECX),并处理两种可能的名字读取路径(取决于某个偏移值是否大于 0x18)。

循环的判断条件是:当前地址(EDI)加上8字节后,是否小于结束地址。如果不满足,则继续循环。


调试与问题排查 🔍

编写完初步代码后,我们注入游戏进行测试。发现打印出的任务列表与预期不符,只显示了部分“可执行任务”,而非“所有任务”。

通过对比OD(OllyDbg)中的调试数据,我们发现了两者的关键区别:

“可执行任务”列表的起始偏移是 +4C4
“所有任务”列表的起始偏移是 +444

我们修改代码中的偏移值为 +444+448,重新编译并测试。这次成功遍历并打印出了完整的“所有任务”列表。


遗留问题与下节预告 🔮

目前我们的代码成功获取了任务名称。但观察游戏内完整的任务列表,会发现每个任务前还有一个等级标识

这个等级信息的偏移地址,我们在OD调试时已经看到过(一个格式化字符串处理的位置)。这留作一个练习,请大家课后尝试分析并找出其偏移。

下节课,我们将一起完成这个等级信息的读取,完善我们的任务列表遍历功能。


总结 ✨

本节课我们一起学习了:

  1. 回顾了任务列表的数据结构,明确了任务名的读取公式。
  2. 编写并封装了遍历函数,使用内联汇编实现了内存地址的精确读取和循环遍历。
  3. 进行了调试与问题排查,发现了“可执行任务”与“所有任务”列表的关键偏移差异(+4C4 vs +444),并修正了代码。
  4. 提出了下一步目标,即分析并获取任务前的等级标识信息。

通过本课,你掌握了遍历复杂游戏数据结构的基本方法,包括分析、编码和调试的全过程。

课程 P78:089-分析任务等级需求并测试 📊

在本节课中,我们将学习如何分析游戏中的任务等级数据,并通过编写代码来读取任务名称和等级信息。我们将从定位相关数据开始,逐步理解其结构,并最终实现一个简单的测试程序。


概述

本节课的目标是分析游戏任务列表的数据结构,特别是任务名称和等级信息的存储方式。我们将通过逆向工程的方法,定位关键的内存偏移量,并编写代码来读取这些数据。


定位任务列表数据

首先,我们需要在游戏中找到任务列表的显示位置。通过调试工具,我们可以在任务列表的显示代码处设置断点,以观察相关数据的读取过程。

在游戏内打开所有任务列表,并在此处下断点。使用快捷键 Ctrl+Q 可以显示出所有任务列表。列表中,每个任务前都有一个括号内的等级数字,例如 (25),表示需要达到25级才能接取该任务。

我们的目标是找到存储这些任务等级数据的内存地址和结构。


分析等级数据来源

通过观察断点处的汇编代码,我们发现任务等级的显示与一个格式化字符串有关。代码中出现了类似 %d 的格式化字符串,用于将等级数值嵌入到显示的文本中。

进一步分析,我们发现等级数据来源于一个特定的内存偏移量。在代码中,等级值通过类似 movzx eax, byte ptr [ecx+edx+0x20] 的指令读取,其中 0x20 是关键偏移。

同时,任务名称的地址也存储在附近,通常位于 [ecx+edx+0x4] 的位置。但这里有一个判断条件:需要检查 [ecx+edx+0x18] 处的值是否大于 0x10,以决定名称是直接存储的字符串还是一个指向字符串的指针。

以下是关键偏移的总结:

  • 任务名称指针/字符串:偏移 +0x4
  • 名称类型判断:偏移 +0x18(用于判断 +0x4 处是直接字符串还是指针)
  • 任务等级:偏移 +0x20

编写测试代码

在理解了数据结构后,我们将在现有代码的基础上进行修改,添加读取任务等级的功能。

上一节我们分析了任务数据的关键偏移,本节中我们来看看如何用代码实现读取。

我们需要新增一个变量来存储任务等级。由于等级通常是一个字节(BYTE),我们需要使用 movzx 指令来正确读取并扩展它。

以下是修改代码的核心步骤:

  1. 在读取任务名称地址之前,先读取任务等级。
  2. 将等级值存储到一个局部变量中。
  3. 在输出任务信息时,将等级一并打印出来。

关键代码片段如下:

; 假设 ebx 保存了基地址,edx 保存了索引偏移
; 读取任务等级 (BYTE)
movzx eax, byte ptr [ebx+edx+0x20]
mov [local_task_level], eax ; 存储到局部变量

; 读取任务名称地址/字符串
mov eax, [ebx+edx+0x4]
; ... 后续判断和名称处理代码 ...

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/980339975b153d468847d7217de1fac6_9.png)

; 打印时包含等级
invoke wsprintf, addr buffer, addr szTaskFormat, [local_task_name], [local_task_level]

测试与发现问题

编译并运行修改后的代码进行测试。我们发现,成功注入了代码并能够读取到任务列表。

然而,测试过程中发现了两个重要问题:

  1. 代码执行顺序导致数据错误:最初,读取等级的代码被放在了错误的位置,导致使用的基地址 eax 已经变化,从而读到了错误的内存地址。必须确保在正确的上下文(即基地址还未被改变时)读取等级数据。
  2. 数据初始化依赖:我们的代码能够遍历到的任务列表数据,似乎依赖于游戏内任务界面是否被主动打开过(例如按过 Ctrl+Q)。如果游戏刚启动,从未打开过任务列表,则遍历会失败或得到空数据。这表明任务列表数据可能是在界面打开时才从服务器获取或进行本地初始化的。

总结与思考题

本节课中我们一起学习了如何分析游戏任务等级的数据结构,并通过实践编写代码来读取任务名称和等级信息。我们掌握了关键的内存偏移量(+0x4, +0x18, +0x20)及其作用,也遇到了实际编码和测试中的典型问题。

目前我们实现的功能还依赖于游戏内任务列表已被初始化。留给大家的思考题是:如何主动触发或模拟这个初始化过程?例如,是否可以找到游戏内部调用打开任务列表的函数(Call),并在我们的代码中调用它,以确保数据总是可用?请大家尝试分析和寻找这个更新任务数据的相关调用。

课程 P79:090-更新任务列表分析 📝

在本节课中,我们将学习如何分析并更新游戏中的任务列表数据。我们将通过逆向工程的方法,定位初始化任务列表的关键代码,并理解其数据结构与调用流程。


概述

游戏中的任务列表分为“执行中的任务”和“可执行的任务”两类,它们分别存储在不同的内存偏移地址中。我们的目标是找到初始化这些任务列表数据的函数,以便能够通过代码直接获取或更新任务信息,而无需手动点击游戏界面。

上一节我们分析了任务列表的基本结构,本节中我们来看看如何定位并调用初始化任务列表数据的核心函数。


任务列表的内存偏移

在游戏中,按下特定快捷键(如Ctrl+C)打开任务界面时,任务列表数据会被初始化并写入内存。

以下是两类任务列表对应的关键内存偏移地址:

  • 执行中的任务列表
    • 起始地址:0x4C4
    • 结束地址:0x4C8

  • 可执行的任务列表
    • 起始地址:0x4D4
    • 结束地址:0x4D8

通过在这些地址下断点,可以观察到当切换任务选项卡(如“全部任务”、“适合自己”)时,游戏会向这些地址写入数据,从而触发断点。


定位数据写入点

为了找到初始化列表的代码,我们从数据写入点开始逆向分析。

在地址 0x4D8 处下内存写入断点,然后点击“全部任务”选项卡。游戏会断在向该地址写入数据(例如 0x0A)的代码处。

同样,点击“适合自己”选项卡时,也会向同一地址写入另一个数据(例如 0x36)。

核心观察:写入 0x4D8 地址的数值(如 0xFB, 0x36)似乎是一个标识,用于关联当前显示的是哪种任务列表。

我们的策略是:通过分析这个写入动作的代码,向上层回溯,找到负责初始化整个任务列表的函数。


分析初始化循环

从写入点向上层回溯代码,我们找到了一个关键的函数,其中包含一个循环结构。

以下是该循环中与数据结构相关的关键汇编指令:

MOV ECX, 0xC0          ; 每次循环增加 0xC0 偏移,这很可能是一个任务结构体的大小
INC EDI                ; EDI 寄存器递增,类似于循环计数器 i++
MOV [ESI+EDI*4], EDI   ; 将 EDI 的值写入缓冲区,这可能是在填充任务ID或索引

分析

  • 0xC0 这个值频繁出现,它很可能代表单个任务数据结构体的大小
  • EDI 寄存器在循环中递增,其值可能被用作任务数组的索引
  • 数据来源 ESI 指向一个基址(例如 0xD6XXXXXX),这个基址加上索引乘以结构体大小,就能遍历所有任务数据。

这个循环函数的作用是:遍历一个包含所有任务原始数据的结构体数组,根据某些条件(如等级要求、是否完成)进行筛选,然后将符合条件的任务信息写入到任务列表的显示缓冲区中。


寻找调用入口

我们最终的目标是找到两个关键的调用入口:

  1. 选项卡点击函数:模拟用户点击界面选项卡,会触发界面更新。
  2. 列表初始化函数:直接更新任务列表数据,无需界面交互。

通过进一步回溯,我们找到了一个可能是选项卡处理的函数。该函数被调用时,EAX 寄存器会传入不同的参数值(如 2332, 2333, 2335),这些值对应不同的任务选项卡。

我们可以尝试直接调用这个函数:

// 伪代码示例
DWORD dwECX = 0x6F2FD0; // 对象基址,每次启动游戏可能不同
DWORD dwEAX = 2332;     // 参数,“全部任务”可能对应此值
CallFunction(dwECX, dwEAX); // 调用找到的函数

测试发现,调用此函数可以成功切换游戏内的任务选项卡界面。这说明我们找到了界面层的调用点。

然而,如果游戏任务窗口没有打开,直接调用此函数可能会因为 ECX(对象指针)未初始化而失败。


更优方案:直接的数据更新函数

因此,更稳健的方案是寻找介于“数据写入”和“选项卡调用”之间的那个数据更新函数。这个函数直接负责将筛选后的任务数据填充到 0x4C40x4D4 开始的内存列表中,而不依赖界面状态。

通过分析,我们找到了另一个函数,它内部调用了我们之前分析的循环筛选函数。这个函数可能才是直接更新任务列表数据的核心。

关键点:此函数同样需要一个有效的 ECX 值(对象指针),但这个指针的来源更为稳定,可能来自游戏的全局管理对象。


总结与下节预告

本节课中我们一起学习了:

  1. 区分了游戏中“执行中”与“可执行”任务列表的内存位置。
  2. 通过下内存写入断点,定位了任务列表数据的更新点。
  3. 逆向分析了一个关键循环,理解了任务数据如何被遍历和筛选(结构体大小可能为 0xC0)。
  4. 找到了模拟点击任务选项卡的调用函数。
  5. 确定了下一步目标是找到更稳定的、直接更新任务列表数据的函数,并获取其所需的 ECX 对象指针。

在下节课中,我们将着重寻找这个稳定的 ECX 对象指针的来源,并编写完整的代码来测试直接调用任务列表更新函数,实现不依赖游戏界面即可获取任务列表信息的功能。


字幕由 Amara.org 社群提供。

课程P8:019-封装怪物对象及怪物列表 🧟‍♂️

在本节课中,我们将学习如何封装怪物对象和怪物列表。我们将基于上一节课的分析,将怪物属性转化为代码结构,并编写函数来初始化和打印怪物信息,以验证我们的分析是否正确。


上一节我们分析了怪物列表和怪物的基本属性。本节中,我们来看看如何将这些属性封装成代码。

首先,我们在头文件中添加找到的基址,并定义怪物结构体。

struct Monster {
    BOOL isDead;        // 是否死亡
    wchar_t* name;      // 怪物名字指针
    DWORD hp;           // 怪物血量
    DWORD level;        // 怪物等级
    float x;            // 坐标X
    float y;            // 坐标Y
    // 注:坐标具体哪个是当前坐标尚不确定
};

接下来,我们定义一个怪物列表。由于目前尚不清楚列表的确切大小,我们先使用一个静态数组进行测试,后续再修改为动态数组。

#define MONSTER_COUNT 20
Monster monsterList[MONSTER_COUNT];

以下是封装怪物列表功能所需的函数。

首先,我们需要一个初始化函数来填充怪物列表的数据。

Monster* InitMonsterList() {
    // 异常处理
    __try {
        DWORD baseAddr = /* 怪物列表基址 */;
        for (int i = 0; i < MONSTER_COUNT; i++) {
            // 计算当前怪物对象的首地址
            DWORD monsterAddr = baseAddr + i * 0x4;
            if (monsterAddr == 0) {
                // 如果地址为空,可能列表结束,跳出循环
                break;
            }
            // 读取怪物名字
            monsterList[i].name = (wchar_t*)(monsterAddr + 0x320);
            // 读取怪物血量
            monsterList[i].hp = *(DWORD*)(monsterAddr + 0x5B4);
            // 读取怪物等级
            monsterList[i].level = *(DWORD*)(monsterAddr + 0x5B8);
            // 读取怪物坐标(示例,偏移待确认)
            monsterList[i].x = *(float*)(monsterAddr + 0x1018);
            monsterList[i].y = *(float*)(monsterAddr + 0x101C);
            // 读取死亡状态
            monsterList[i].isDead = *(BOOL*)(monsterAddr + /* 死亡状态偏移 */);
        }
    } __except(EXCEPTION_EXECUTE_HANDLER) {
        // 异常处理代码
    }
    return monsterList;
}

初始化完成后,我们需要一个函数来打印怪物信息,以便测试。

BOOL PrintMonsterInfo() {
    for (int i = 0; i < MONSTER_COUNT; i++) {
        // 如果等级为0,说明该位置无有效怪物数据,跳过
        if (monsterList[i].level == 0) {
            continue;
        }
        // 打印怪物信息:下标、名字、等级、血量、坐标、死亡状态
        wprintf(L"下标[%d] 名字:%s 等级:%d 血量:%d 坐标(%.2f,%.2f) 死亡状态:%d\n",
                i,
                monsterList[i].name,
                monsterList[i].level,
                monsterList[i].hp,
                monsterList[i].x,
                monsterList[i].y,
                monsterList[i].isDead);
    }
    return TRUE;
}

为了测试上述功能,我们需要在代码中调用这些函数。

void TestMonsterList() {
    // 1. 初始化怪物列表
    InitMonsterList();
    // 2. 打印怪物信息
    PrintMonsterInfo();
}

最后,在程序界面中添加一个按钮来调用这个测试函数。


本节课中我们一起学习了如何封装怪物对象和怪物列表。我们定义了怪物的结构体,编写了初始化列表和打印信息的函数,并进行了初步测试。测试结果显示列表中可能包含其他对象(如玩家或物品),且部分属性偏移(如死亡状态)可能需要修正。我们将在下一节课中继续改进代码并深入分析怪物属性。

逆向工程教程 P80:091-分析任务对象基址及封装任务更新函数 🧩

在本节课中,我们将学习如何分析游戏任务列表更新函数的参数来源,并封装一个可调用的任务更新函数。我们将通过动态调试找到关键数据结构的基址,并用代码实现功能。


概述

本节课的目标是定位并分析游戏任务列表更新函数中 ecx 参数的来源。我们将通过调试找到存储任务对象的数据结构基址,并最终封装一个可以更新“全部任务”或“适合自己任务”列表的函数。


分析 ecx 参数来源

上一节我们介绍了任务列表更新的调用点。本节中,我们来看看传入的 ecx 参数是如何计算出来的。

首先,在调试器中转到调用更新任务列表的 call 指令地址,并下断点。

打开游戏内的任务列表选项卡,程序会在断点处中断。中断后,我们返回到调用该函数的上一层代码。

我们需要寻找 ecx 数据的来源。观察代码发现,ecx 可能来源于 [esi+4] 或另一个内存地址。我们在这些可能的位置分别下断点,以追踪数据流。

尝试读取“所有任务”时,ecx 的值是 0x450。但此值在后续操作中会改变。为了找到稳定来源,我们转而追踪 esi 寄存器的来源。

esi 的值加上偏移量 4,得到 ecx 的一个可能来源地址 0x4420。我们需要将此地址附加到游戏进程中进行观察。

我们验证这个地址是否有效。对该地址设置内存访问断点,程序再次中断。最终,数据流指向一个全局对象数组 0x3116640

观察 eax 寄存器的数据,它等于 0x445。我们可以尝试从这个值推导出任务对象。关键在于,游戏重启后,这个下标值 0x43A 是否保持不变。

让程序继续运行,然后再次触发断点。

当前的 ecx 值变为 0xD18。我们检查其属性,发现其下标依然是 0x43A,这证实它属于同一个全局对象数组。

实际上,我们可以通过两个公式获得任务对象:

  1. 对象地址 = [[0x3116640] + 0x43A * 4]
  2. 或通过之前找到的中间地址间接获取。

这两个公式本质是等价的。我们需要重启游戏,验证下标 0x43A 是否固定。

关闭并重新启动游戏,然后用调试器附加进程。

再次触发任务列表更新,观察 ecx 的值。

测试表明,下标 0x43A 在游戏重启后没有改变,可以直接用于定位任务对象。如果此方法失效,也可以通过遍历对象列表,比较特定属性或函数成员来定位。


封装任务更新函数

解决了 ecx 的来源问题后,我们现在可以编写代码来封装任务更新功能。

打开第90课的代码项目,我们在主线程函数中进行测试。原代码已能打印任务列表数据,但现在需要为其添加参数,以区分更新“全部任务”还是“适合自己任务”。

首先,我们需要拦截游戏调用,获取参数值。对更新函数下断点,然后点击游戏内的“适合自己任务”选项。

观察栈数据,发现参数值为 0x2333。再点击“全部任务”,参数值为 0x2332。我们将使用这两个值作为参数。

以下是实现步骤:

  1. 定义参数常量
    在代码中定义两个宏,代表两种任务类型。

    #define ALL_QUEST 0x2332
    #define SUITABLE_QUEST 0x2333
    
  2. 计算任务对象地址
    使用找到的公式,从全局对象数组中获取任务对象基址。

    // 假设 g_pObjectArray 是全局对象数组基址 0x3116640
    DWORD dwTaskObject = *(DWORD*)(*(DWORD*)g_pObjectArray + 0x43A * 4);
    
  3. 封装更新函数
    创建一个函数,接收任务类型参数,调用游戏内部的任务列表更新函数。

    void UpdateQuestList(DWORD dwQuestType) {
        DWORD dwTaskObject = GetTaskObjectBase(); // 封装获取对象基址的函数
        __asm {
            mov ecx, dwTaskObject
            push dwQuestType
            mov eax, [UpdateQuestListFunc] // 游戏内部函数地址
            call eax
        }
    }
    
  4. 整合与测试
    将上述代码整合到主线程中,并添加调试信息输出。编译并注入到游戏进程进行测试。

在测试前,请确保从调试器中退出,并清除所有断点,以免影响游戏或注入器运行。

如果之前修改了游戏代码(例如下了 INT 3 断点),需要将其恢复为原始字节。

例如,将 0xCC (INT 3) 恢复为原来的 0xFF 0xD2 (call edx) 指令。

设置好编译和注入环境后,重新编译DLL并注入游戏。

运行测试。调试信息应能正确显示“所有任务”或“适合自己任务”的列表内容,表明函数封装成功。


总结

本节课中,我们一起学习了如何通过动态调试分析游戏功能函数的参数来源。我们定位到了任务对象在全局数组中的固定下标,并利用此发现封装了一个可接收参数的任务列表更新函数。通过定义 ALL_QUESTSUITABLE_QUEST 常量,我们可以方便地控制游戏更新不同类型的任务列表。

关键步骤如下:

  1. 使用调试器定位关键 call 指令和参数传递。
  2. 逆向追踪参数来源,找到稳定的对象基址计算公式。
  3. 将分析结果转化为代码,实现功能封装。
  4. 进行测试验证,确保功能正确。

请大家课后将封装的函数代码整理规范,为后续更复杂的操作打下基础。

下节课我们将在此基础上,探索如何读取并解析具体的任务信息。

课程P81:092-挂机选项卡功能封装-低血保护 🛡️

在本节课中,我们将学习如何为挂机功能封装一个低血量/低魔法值保护模块。该功能的核心是:当角色生命值(HP)或魔法值(MP)低于设定的百分比时,自动使用指定的物品进行恢复。

概述

上一节我们完成了挂机功能的基础框架。本节中,我们将在挂机类中添加成员变量,并在用户界面上创建对应的控件,以实现低血保护功能的参数配置。

修改挂机类头文件

首先,我们需要打开第91课的代码,并转到挂机类的头文件。

以下是需要在挂机类中添加的成员变量:

  • m_bAutoUseHP: 一个布尔值,用于控制是否在HP低于阈值时自动使用物品。
  • m_bAutoUseMP: 一个布尔值,用于控制是否在MP低于阈值时自动使用物品。
  • m_nHpPercent: 一个整数值,代表触发自动使用HP物品的生命值百分比阈值。
  • m_nMpPercent: 一个整数值,代表触发自动使用MP物品的魔法值百分比阈值。
  • m_strHPItemName: 一个字符串,代表当HP低于阈值时要使用的物品名称。
  • m_strMPItemName: 一个字符串,代表当MP低于阈值时要使用的物品名称。

这些变量的设计基于百分比,这通常比固定数值更灵活。当然,你也可以根据需求设计为低于某个固定数值。

设计用户界面控件

为了将上述参数传递给程序,我们需要在挂机选项卡的窗口界面上添加对应的控件。

转到挂机页面,我们将添加以下控件:

  • 两个复选框(Check Box):分别对应“启用低HP保护”和“启用低MP保护”的开关。
  • 两个编辑框(Edit Control):用于输入HP和MP的百分比阈值(1-100)。
  • 两个静态文本(Static Text):用于在编辑框后显示“%”符号。
  • 两个组合框(Combo Box):以下拉列表的形式,供用户选择要使用的HP和MP物品名称。

你可以按住 Ctrl 键并用鼠标拖动控件来快速复制,以提高布局效率。

关联控件与变量

界面上的六个控件需要与我们之前在头文件中定义的六个成员变量相关联。

以下是操作步骤:

  1. 使用开发工具(如Visual Studio的MFC类向导)为每个控件添加对应的成员变量。
  2. 建议使控件关联的变量名与类成员变量名保持一致,这样在后续数据更新时会更加方便直观。
  3. 首先为两个复选框控件关联布尔型变量(如 m_ctlAutoUseHP, m_ctlAutoUseMP)。
  4. 接着为两个编辑框控件关联整型变量(如 m_ctlHpPercent, m_ctlMpPercent)。
  5. 最后为两个组合框控件关联字符串型变量(如 m_ctlHPItemName, m_ctlMPItemName)。
  6. 在布局时,可以使用对齐工具使界面更加整洁。

总结

本节课中,我们一起学习了低血保护功能封装的第一步:数据结构设计与界面搭建

我们首先在挂机类的头文件中定义了六个核心成员变量,分别用于控制功能开关、设置百分比阈值以及指定要使用的物品。接着,我们在用户界面上创建了对应的复选框、编辑框和组合框控件,并将它们与类成员变量相关联。

下一节,我们将学习如何编写代码,使界面上的参数能够正确地保存到成员变量中,并在挂机循环中实现实时的血量检测与物品自动使用逻辑。

课程P82:低血保护功能封装2 🛡️

在本节课中,我们将继续上一节课的内容,编写代码以实现低血保护功能。我们将从初始化界面数据开始,逐步实现自动使用药品的逻辑,并进行测试和调试。

界面数据初始化

上一节我们介绍了低血保护功能的基本框架,本节中我们来看看如何初始化挂机页面的相关数据。

首先,我们打开第92课的代码。在挂机页面中,有一个下拉列表选项。每一项可以用分号来分割。例如,第一项可以是“金创药小”。我们需要查看游戏内有哪些物品,例如在等级5时有一个“小九转丹”。如果该物品存在,也可以添加进去。

目前,我们可以暂时添加这两个物品:“金创药小”和“金创药中”。此外,我们还可以选择“人参”、“野山参”、“雪莲”等物品。这种做法效率较高。另一种做法是遍历背包,但设计起来相对复杂。

百分比设置也需要进行相应的初始化。这些初始化操作将在“初始化函数”中完成。

编写初始化函数

以下是初始化函数的具体步骤:

  1. 找到挂机选项卡的初始化函数。
  2. 设置是否自动使用这些物品,这里全部设置为“真”。
  3. 设置两个百分比变量的初始值,例如全部设置为50%。
  4. 在窗口设置中,为百分比变量设置最小值和最大值。最小值可以设置为1,最大值可以设置为99或100,不超过110。例如,范围可以设置为10%到99%。
  5. 为另一个属性添加变量,最小值设为10,最大值设为99。

完成上述步骤后,我们进入游戏窗口查看效果。如果发现选项没有被选中或数字没有被初始化,需要检查变量设置是否正确,并更新窗口数据。

调整界面控件

切换到资源视图,调整下拉列表的大小。在类初始化中设置默认数值。重新编译代码,并在应用设置中做相应改动。获取缓冲区数据后,需要释放缓冲区。

再次编译并注入显示外挂,此时数据应该能够正确初始化。如果控件大小不合适,可以继续调整。同时,禁用控件的排序功能,以确保参数能够正确传递。

实现自动使用物品逻辑

参数传递完成后,我们切换到挂机类,在主循环中添加判断逻辑。

以下是实现自动使用物品的核心代码逻辑:

// 检查生命值百分比
if GetHpPercent() < LowHpThreshold then
begin
  // 使用生命值药品
  UseItem(‘金创药小’);
end;

// 检查魔法值百分比
if GetMpPercent() < LowMpThreshold then
begin
  // 使用魔法值药品
  UseItem(‘小九转丹’);
end;

每次使用物品后,可以设置一个时间间隔。这个间隔可以设置为参数,也可以不设置。为了测试方便,我们将技能“逆天杀星”改为“普通攻击”。

功能测试与调试

开始测试时,可能发现没有自动打怪。需要检查应用设置、开始挂机以及主线程挂机的代码是否被执行。

重新编译并注入外挂,应用设置,然后开始挂机。测试时,可能会发现程序在不断尝试使用“金创药小”,但没有实际使用。这表示代码逻辑可能存在问题。

需要检查物品使用函数和百分比判断函数。在结构单元中,添加计算百分比的函数。

以下是计算生命值百分比的函数示例:

function GetHpPercent: Integer;
var
  CurrentHp, MaxHp: Integer;
begin
  CurrentHp := GetCurrentHp;
  MaxHp := GetMaxHp;
  Result := (CurrentHp * 100) div MaxHp; // 乘以100以获得百分比整数
end;

重新编译测试,如果百分比小于设定值,会有使用药品的提示,但药品可能没有被消耗。这可能是因为调用游戏函数(Call)的地址发生了变化。

通过调试发现,函数只执行到了头部,说明Call的地址可能已失效。需要重新搜索特征码,更新物品使用函数的Call地址。

更新地址后,重新编译测试。此时,药品应该可以被正常使用。如果百分比设置过小,程序可能会反复使用药品,这说明还需要在代码中加入百分比判断。

修改代码,只有当当前百分比小于设定百分比时,才使用药品。同时,修正百分比计算函数,确保返回正确的百分比值。

最终测试与总结

重新编译代码,进行最终测试。当生命值百分比低于设定值(如95%)时,外挂应能自动使用指定的药品(如“金创药小”)。

本节课中我们一起学习了如何初始化低血保护功能的界面数据,编写并调试了自动使用药品的逻辑代码。通过测试,我们确保了功能能够在游戏角色生命值较低时自动使用恢复物品,从而实现对角色的保护。

课程 P83:094-定点打怪功能封装 🎯

在本节课中,我们将学习如何为游戏挂机功能封装一个“定点打怪”模块。该功能的核心作用是确保角色在死亡回城后,能够自动返回预设的挂机地点,从而提升自动化效率。

上一节我们完成了挂机功能的基础框架,本节中我们来看看如何为其增加定点返回的能力。

功能需求与设计思路

很多时候,由于角色死亡或其他原因,角色可能会偏离原有的打怪地点。因此,我们需要编写一个定点打怪功能。当角色死亡回城后,它能自动跑回预设的挂机地点。

要实现这个自动定点打怪功能,我们需要在挂机类中添加几个相关的成员变量。

以下是需要添加的四个核心参数:

  • 是否开启定点打怪:一个布尔值,用于在界面上接收复选框的选择,决定是否启用此功能。
  • 定点打怪半径:一个数值,代表允许角色活动的范围半径。
  • 定点坐标X:预设挂机地点的X坐标。
  • 定点坐标Y:预设挂机地点的Y坐标。

代码实现:添加成员变量

首先,我们在挂机类(例如 CAutoFight)的内部添加这四个成员变量。

class CAutoFight {
    // ... 其他现有成员
    BOOL m_bFixedPointFight; // 是否开启定点打怪
    DWORD m_dwFixedRadius;   // 定点打怪半径
    LONG m_lFixedPosX;       // 定点坐标X
    LONG m_lFixedPosY;       // 定点坐标Y
    // ... 其他现有成员
};

界面设计:关联控件与变量

添加成员变量后,我们需要切换到资源视图,调整挂机设置窗口,并添加控件来接收用户输入的参数。

以下是需要在对话框中添加的控件及其关联:

  1. 复选框:用于启用“定点打怪功能”。我们将其与成员变量 m_bFixedPointFight 关联,变量类型选择 BOOL(值类型)。
  2. 静态文本和编辑框:用于输入“定点打怪半径”。前面放置一个静态文本控件显示“半径:”,后面跟一个编辑框。将此编辑框与成员变量 m_dwFixedRadius 关联,变量类型选择 DWORD(值类型)。
  3. 静态文本和编辑框:用于输入“定点坐标X”。将其与成员变量 m_lFixedPosX 关联,变量类型选择 LONG(值类型)。
  4. 静态文本和编辑框:用于输入“定点坐标Y”。将其与成员变量 m_lFixedPosY 关联,变量类型选择 LONG(值类型)。

完成控件添加和变量关联后,切换到主窗口或对话框的“应用设置”相关函数中。我们需要将界面上的数据更新到成员变量里。

void CMyDlg::OnApplySettings() {
    // 更新数据:从对话框控件到成员变量
    UpdateData(TRUE);
    // 现在,m_bFixedPointFight, m_dwFixedRadius, m_lFixedPosX, m_lFixedPosY 已包含用户输入的值
    // 可以将这些值传递给挂机类实例
    // g_autoFight.SetFixedPointParams(m_bFixedPointFight, m_dwFixedRadius, m_lFixedPosX, m_lFixedPosY);
}

这样,我们就能把用户设置的参数传递到挂机类的内部。之后,程序将根据这些参数来限定角色的活动半径,最终实现定点打怪。

核心逻辑与下节预告

定点打怪的最终实现逻辑,需要在自动打怪的回调函数中完成。

其核心流程如下:

  1. 范围检测:判断角色当前位置是否超出定点范围。
  2. 条件寻路:如果超出范围,则自动寻路到定点坐标;如果未超出,则跳过寻路,继续原有打怪逻辑。

为了实现这个流程,我们需要编写两个辅助函数:

  • BOOL IsOutOfRange():用于判断是否超出定点范围。
  • void MoveToFixedPoint():用于执行寻路到定点坐标的操作。

本节课我们主要完成了界面和基础变量的搭建。具体的函数设计与实现,我们将留到下一节课一起完成。现在,请重新编译一下项目,确保界面修改生效。

课后作业 📝

为了巩固理解,请大家尝试编写上面提到的 范围检测函数 IsOutOfRange 的框架。

这是一个挂机类的成员函数。它的作用是判断角色当前坐标与传入的定点坐标之间的距离,是否超过了指定的半径。

函数设计提示:

  • 输入:可以直接使用类内的成员变量 m_lFixedPosX, m_lFixedPosY, m_dwFixedRadius 作为判断依据。也可以设计为接收目标坐标和半径作为参数,更具通用性。
  • 逻辑:获取角色当前位置的 (currX, currY)。计算其与定点 (m_lFixedPosX, m_lFixedPosY)距离
    • 距离公式:distance = sqrt( (currX - fixedX)^2 + (currY - fixedY)^2 )
  • 输出:返回一个布尔值。如果 distance > m_dwFixedRadius,表示超出范围,返回 TRUE;否则,返回 FALSE

本节课中我们一起学习了定点打怪功能的界面封装与设计思路。我们添加了必要的成员变量,并在对话框中创建了对应的控件,完成了数据的绑定。下一节课,我们将深入核心逻辑,实现范围判断与自动寻路函数。

课程 P84:095-实现定点打怪功能 🎯

在本节课中,我们将学习如何为自动打怪功能添加“定点”限制,使角色只在以指定坐标为中心、特定半径的圆形范围内活动。一旦超出范围,角色将自动寻路返回中心点。


上一节我们介绍了自动打怪的基本框架,本节中我们来看看如何为其添加地理范围的约束。

首先,我们需要在挂机类中添加一个成员函数,用于判断角色当前位置是否超出了设定的定点范围。

该函数的核心逻辑是计算角色当前位置与设定中心点之间的距离,并与允许的半径进行比较。以下是实现此功能的代码:

bool IsWithinRange(int centerX, int centerY, float limitDistance) {
    // 获取玩家当前坐标
    float currentX = g_pPlayer->GetPosX();
    float currentY = g_pPlayer->GetPosY();
    
    // 计算两点间的距离
    float distance = CalculateDistance(currentX, currentY, (float)centerX, (float)centerY);
    
    // 判断距离是否在限制范围内
    if (distance > limitDistance) {
        return false; // 超出范围
    } else {
        return true; // 在范围内
    }
}

其中,CalculateDistance 函数可利用游戏引擎或数学库中现有的函数实现,计算公式为:
distance = sqrt( (x2 - x1)^2 + (y2 - y1)^2 )


接下来,我们需要在自动打怪的主循环线程中集成这个范围检测功能。

在打怪循环的起始部分,我们调用上述函数进行检查。以下是集成后的逻辑流程:

while (bHooking) {
    // 1. 检查是否在定点范围内
    if (!IsWithinRange(fixedX, fixedY, radius)) {
        // 如果超出范围,则寻路返回中心点
        g_pPlayer->PathFindTo(fixedX, fixedY);
        Sleep(2000); // 等待2秒,让角色移动
        continue; // 跳过本次循环,等待下一次检测
    }
    
    // 2. 如果在范围内,则执行正常的打怪逻辑
    // ... (原有的选择目标、攻击等代码)
}

现在,让我们进行功能测试,验证定点打怪是否按预期工作。

以下是测试步骤:

  1. 在设置中指定中心点坐标(例如 X: 1996, Y: 1866)和半径(例如 100)。
  2. 启动挂机功能。
  3. 手动控制角色跑出设定的半径范围。
  4. 观察角色是否自动寻路返回中心点,并恢复自动打怪。

测试时,你可能会发现角色在边界处来回移动。这是正常现象,因为检测和移动需要时间。更复杂的改进(如加入缓冲区域或状态机)我们将在后续课程探讨。


本节课中我们一起学习了如何实现定点打怪功能。我们通过添加一个范围检测函数,并在主循环中根据检测结果控制角色的移动,成功地将打怪活动限制在了指定区域内。

目前,坐标和半径需要手动输入。作为课后练习,请你尝试添加一个按钮,点击后可自动获取玩家当前坐标并填充到设置中,以提升用户体验。我们下节课将对这个功能进行进一步的改进和优化。

课程 P85:096 - 编写函数统计背包HP药品数量 📦💊

在本节课中,我们将学习如何编写一个函数,用于统计游戏背包中所有HP(生命值)药品的总数量。这个功能是实现自动补给逻辑的关键部分,当药品数量低于某个阈值时,程序可以自动回城购买。

概述与目标

上一节我们学习了如何查询背包中特定物品的信息。本节中,我们将在其基础上进行扩展,编写一个能统计所有HP药品数量的函数。我们将通过遍历药品列表并累加数量的方式来实现。

前期准备与思路分析

游戏商店中目前共有6种HP药品,分别是:精冲药(小)、精冲药(中)、精冲药(大)、密制精冲药、药纤精冲药和特制精冲药。

我们的实现思路分为两步:

  1. 首先,编写一个函数,用于统计背包中某一种指定药品的数量。
  2. 然后,编写另一个函数,循环调用第一步的函数,累加所有6种HP药品的数量。

我们将基于已有的 get_goods_indexedflare 函数进行修改。

第一步:统计单种药品数量

我们首先在背包结构体中添加一个成员函数,用于查询指定名称的药品在背包中的数量。

以下是该函数的实现思路:

  • 遍历背包物品列表。
  • 将每个物品的名称与目标药品名称进行比较。
  • 如果找到匹配项,则返回该物品的 数量 属性。
  • 如果遍历结束仍未找到,则返回 0

int TBag::get_specific_hp_goods_number(const std::string& goods_name) {
    // 可选:初始化背包列表。若在外部已初始化,此行可省略。
    // update_bag_list();

    for (const auto& item : bag_list) {
        if (item.name == goods_name) {
            return item.count; // 找到物品,返回其数量
        }
    }
    return 0; // 未找到该物品,数量为0
}

第二步:统计所有HP药品总数量

接下来,我们创建第二个函数,用于计算所有HP药品的总和。我们需要定义一个包含所有HP药品名称的列表,然后循环调用上一步的函数。

以下是该函数的实现代码:

int TBag::get_all_hp_goods_number() {
    // 定义所有HP药品的名称列表
    std::vector<std::string> hp_goods_names = {
        "精冲药(小)",
        "精冲药(中)",
        "精冲药(大)",
        "密制精冲药",
        "药纤精冲药",
        "特制精冲药"
    };

    int total_number = 0;
    for (const auto& name : hp_goods_names) {
        total_number += get_specific_hp_goods_number(name);
    }
    return total_number; // 返回统计的总数量
}

功能测试与调试

编写完函数后,我们需要在主线程单元进行测试,以确保其正确性。

  1. 将函数调用集成到主逻辑中。
  2. 运行程序并挂载到游戏进程。
  3. 打开调试信息查看工具,输出 get_all_hp_goods_number() 函数的返回值。
  4. 手动核对背包中各类HP药品的数量,验证程序计算结果是否正确。

在测试过程中,如果发现统计数字错误,应检查以下方面:

  • 药品名称字符串是否与游戏内完全一致。
  • 在累加过程中是否有逻辑错误或笔误。
  • 背包列表是否在调用前被正确初始化。

例如,若发现总数不对,可以在 get_specific_hp_goods_number 函数中打印每次找到的物品名称和数量,进行逐步排查。

课程总结

本节课中,我们一起学习了如何编写函数来统计背包中HP药品的总数量。我们首先实现了查询单一药品数量的基础函数,然后通过遍历药品列表并累加,实现了整体统计功能。

这个功能是构建自动化游戏脚本(如自动补给)的重要基石。

课后练习

为了巩固所学知识,请大家独立完成以下练习:

编写一个函数,用于统计背包中所有MP(魔法值)药品的数量。

目前游戏中的MP药品包括:人参、血源参、医源参、秘制医源参和药纤医源参。请参考本节课的方法,实现相应的统计函数。


下节课再见! 👋

课程 P86:097-Mp药品数量统计与背包状态判断 🎒

在本节课中,我们将完成上一节课的作业,即统计MP药品的数量,并编写一个新函数来判断游戏背包是否已满。


完成上节课作业:统计MP药品数量 💊

上一节我们介绍了如何统计金创药的数量,本节中我们来看看如何统计MP药品的数量。

我们首先打开第96课的代码。

然后,我们转到背包的结构单元。在统计金创药数量的代码后面,添加统计MP药品数量的函数。

我们复制前面统计药品的函数,并将其名称改为 mp

以下是需要统计的MP药品名称列表:

  • 人参
  • 雪原参
  • 一元参
  • 秘制人参
  • 药仙
  • 野山参

其中,野山参在三邪关和领证官才有相应的MP药品。

变量名可以修改,也可以保持不变。

保存代码。完成这个作业只需对原有代码进行少量修改。


编写新函数:判断背包是否已满 📦

接下来,我们需要添加一个新函数,用于判断背包是否已满。当背包满时,我们通常需要执行回城、补给、清理垃圾物品出售给商店等操作。

我们在MP药品统计函数的后面添加这个新函数,然后转到相应位置编写代码。

如果背包的每一格都存放了物品对象,则返回 true,表示背包已满。

这里我们需要使用一个循环来遍历背包。背包的大小由一个名为 in size 的成员变量定义。

我们需要为这个变量添加前缀,以表明它是背包结构的成员,这样才能使用其内部的成员变量。

在循环中,我们对每一格物品的数量进行判断。如果某个格子的物品数量等于零,表示该格子没有物品,我们直接返回 false

如果整个循环遍历完成,从下标0到35共36个格子,所有格子的物品数量都不为零,则说明背包已满,返回 true

如果中途发现某一格数量为零,则返回 false,表示背包未满。


测试与验证 ✅

现在,我们来测试一下MP药品数量的统计功能。

启动测试,查看药品数量的统计结果。

我们也可以在游戏中购买一些其他药品进行测试。

再次执行统计。结果显示:人参数量、雪原参数量、一元参数量、药仙数量等,共计30个。

核对结果:取原生10个,人参4个,一元32个,秘制一元3个,药仙1个,野山参3个,总计30个。统计正确。


课程总结与作业 📝

本节课内容比较简单。我们一起学习了如何统计MP药品数量,以及如何编写函数判断背包是否已满。

下一节课我们将继续分析相关数据。

这里留一个作业:当金创药的总数小于某个特定数值(例如小于10)时,让角色自动回城并购买指定数量(例如100个)的金创药。这是一个助力练习,大家有时间可以尝试完成。

好的,本节课就到这里。

课程 P87:098 - 回城补给功能设计与补给条件检测 IsRequireSupply 🧳

在本节课中,我们将学习如何设计一个自动回城补给的功能,并重点完成补给条件的检测逻辑。这个功能是游戏自动化中较为复杂的一环,涉及到状态判断、路径导航和物品交互等多个步骤。

概述

在游戏挂机过程中,背包中的药品等消耗品会逐渐减少。当物品数量低于某个阈值时,角色需要自动返回城镇商店进行补充。本节课,我们将首先梳理整个回城补给的流程,然后实现核心的补给条件检测函数 IsRequireSupply

回城补给功能设计思路

上一节我们介绍了功能的目标,本节中我们来看看实现它的整体流程。回城补给功能比之前的功能更为复杂,其大致执行顺序如下:

  1. 判断补给条件:检测背包中特定物品(如HP/MP药品)的数量是否低于预设的最低保有量。
  2. 保存当前位置:记录角色当前的坐标,以便补给完成后能返回。
  3. 寻路至商店NPC:调用寻路功能,导航至城镇中商店NPC所在的坐标。
  4. 与NPC交互:到达指定坐标后,打开与NPC的对话,并打开商店界面。
  5. 出售垃圾物品:将背包中的无用物品出售给商店。
  6. 购买补给品:计算并购买所需数量的药品,使其达到预设的最大保有量。
  7. 关闭界面并返回:关闭商店和NPC对话框,使用保存的坐标寻路返回挂机地点。

接下来,我们将从第一步“判断补给条件”开始实现。

实现补给条件检测函数

以下是实现 IsRequireSupply 函数的核心步骤,我们将逐一封装这些逻辑。

首先,我们需要定义一些配置变量来控制补给行为。这些变量可以在游戏界面中设置。

// 补给功能开关与配置
bool g_bAutoSupplyHP;      // HP药品低于阈值时,是否启用回城补给
int  g_nHPMinThreshold;    // HP药品的最低保有量(触发补给的阈值)
int  g_nHPMaxCount;        // HP药品的最大保有量(补给后达到的数量)
int  g_nHPItemID;          // 需要补给的HP药品ID

bool g_bAutoSupplyMP;      // MP药品低于阈值时,是否启用回城补给
int  g_nMPMinThreshold;    // MP药品的最低保有量
int  g_nMPMaxCount;        // MP药品的最大保有量
int  g_nMPItemID;          // 需要补给的MP药品ID

接着,我们实现核心的 IsRequireSupply 函数。该函数会检查各种条件,只要满足其一,就返回 true,表示需要回城。

bool IsRequireSupply()
{
    // 1. 检查HP药品是否需要补给
    if (g_bAutoSupplyHP) {
        int nCurrentHPCount = GetItemCountByID(g_nHPItemID); // 获取当前HP药品总数
        if (nCurrentHPCount < g_nHPMinThreshold) {
            return true; // 条件成立,需要回城
        }
    }

    // 2. 检查MP药品是否需要补给
    if (g_bAutoSupplyMP) {
        int nCurrentMPCount = GetItemCountByID(g_nMPItemID); // 获取当前MP药品总数
        if (nCurrentMPCount < g_nMPMinThreshold) {
            return true; // 条件成立,需要回城
        }
    }

    // 3. 检查负重是否过高(作为扩展条件)
    // if (GetBurdenPercent() > 90) {
    //     return true; // 负重超过90%,需要回城清理
    // }

    // 4. 检查背包是否已满(作为扩展条件)
    // if (IsBagFull()) {
    //     return true; // 背包已满,需要回城清理
    // }

    // 所有条件均不满足
    return false;
}

代码说明

  • GetItemCountByID(int itemID):这是一个假设已存在的函数,用于根据物品ID统计其在背包中的总数量。
  • 函数按顺序检查HP补给条件、MP补给条件。未来还可以轻松扩展,加入负重检测 (GetBurdenPercent) 和背包满检测 (IsBagFull) 等逻辑。
  • 只要任一条件满足,函数会立即返回 true,表示需要执行回城补给流程。

在挂机循环中集成检测

最后,我们需要在主挂机逻辑中调用这个条件检测函数。

void MainLoop()
{
    while (true) {
        // ... 其他挂机逻辑,如打怪、拾取 ...

        // 检测是否需要回城补给
        if (IsRequireSupply()) {
            ExecuteSupplyRoutine(); // 执行回城补给流程(下节课实现)
        }

        Sleep(100); // 适当延迟,避免循环过紧
    }
}

总结

本节课中,我们一起学习了回城补给功能的整体设计思路,并完成了最基础的补给条件检测函数 IsRequireSupply。我们定义了控制补给行为的配置变量,并实现了通过比较当前物品数量与预设阈值来判断是否需要回城的逻辑。

下一节课,我们将着手实现 ExecuteSupplyRoutine 函数,具体完成寻路至NPC、打开商店、买卖物品等一系列操作,将整个补给流程串联起来。

课程 P88:099 - 回城补给功能设计相关函数寻路到 FindToWay 🧭

在本节课中,我们将学习如何封装一个“寻路到”功能函数,并将其集成到回城补给功能的设计中。我们将创建新的头文件和源文件来组织代码,并确保其与主线程模块正确交互。


概述

上一节我们讨论了寻路功能的基础。本节中,我们将把寻路逻辑封装成一个独立的函数,并为回城补给功能(GoTo 设定或计划)添加部分代码。首先,我们需要在开始寻路前保存当前坐标。

第一步:添加成员变量

由于第一步需要在执行寻路前保存当前坐标,我们先添加两个与补给相关的成员变量。

第二步:创建功能封装模块

接下来,我们需要编写“寻路到”功能。我们将在游戏主线程项目中创建一个新目录,用于实现功能封装。然后添加一个头文件和一个源文件。

在创建时,可以直接选择“C++ 类”,它会自动添加对应的头文件和源文件,完成函数或游戏功能的封装。点击“完成”。

将新建的两个文件移到“功能封装”目录内。

也可以将其独立放在外部。本功能封装模块的约定是:所有涉及游戏扩展的功能都不直接调用,而是通过调用主线程模块中的功能来实现。这意味着这里的函数最终可以被直接调用来实现功能。

第三步:配置头文件依赖

由于该模块依赖于我们的主线程单元,因此需要包含相应的头文件。同时,它也会用到基础单元和全局变量单元。

我们将这些依赖写在头文件中。为了防止头文件被重复包含,我们可以使用一段宏定义来进行封装。

#ifndef FUNCTION_ENCAPSULATION_H
#define FUNCTION_ENCAPSULATION_H

// 包含必要的头文件
#include "MainThreadUnit.h"
#include "BaseUnit.h"
#include "GlobalVariables.h"

// 函数声明将放在这里

#endif // FUNCTION_ENCAPSULATION_H

这样就能保证头文件只被包含一次。

第四步:定义寻路函数

现在,我们在头文件中定义相应的“寻路到”函数。可以为其添加一个特定的前缀,以便未来可以不挂接主线程而直接调用,因为其内部实现依赖于主线程单元的函数。

函数原型可能类似于:

bool FindToWay(int targetX, int targetY);

第五步:切换到资源视图进行测试

首先,我们需要在测试代码中包含相关的头文件,然后调用我们的FindToWay函数。这里我们使用一个预设的坐标进行测试。

重新编译项目。

第六步:挂接主线程并测试

输入测试代码,首先挂接主线程。

查看当前坐标,然后点击测试。观察相关的信息输出,此时应显示相应的时间信息。

重新测试一次。如果之前有一段错误消息,可能是因为传递的参数类型不正确。我们需要到主线程代码中检查。

发现在打印信息时,传入的参数不是字符串类型,导致了一段错误。我们可以这样修改:

// 将参数正确转换为字符串或使用正确的格式说明符
printf("坐标信息: %d, %d\n", x, y);

总结

本节课中,我们一起学习了如何将寻路逻辑封装成独立的FindToWay函数,并为其创建了专门的模块。我们添加了必要的成员变量,配置了头文件依赖和防重复包含机制,并进行了初步的测试与调试。

下一节课,我们将继续完善回城补给功能,并进一步集成这个寻路函数。


字幕由 Amara.org 社区提供,感谢大家的支持。

课程 P89:100-GoToCityForSupply与msgOpenNpcTalkForName 🛒➡️🏙️

在本节课中,我们将学习如何完善游戏辅助功能中的“回城补给”函数。我们将重点封装打开NPC对话的功能,并初步构建回城补给的逻辑流程。


封装打开NPC对话的函数

上一节我们回顾了基础代码,本节中我们来看看如何封装打开NPC对话的核心函数。

首先,切换到游戏的主线程单元,开始封装打开NPC的函数。需要定义相关参数和消息。

以下是封装步骤:

  1. 复制前序课程的相关代码作为基础。
  2. 函数只需传送一个参数:NPC的名字。需要传送该名字指针的地址。
  3. 定义相关的消息宏。在寻路相关的消息定义之后,添加一条用于打开NPC对话的新消息。
  4. 为与之前的消息区分,新消息的编号可以延续序列,例如使用123456。
  5. 将新消息的类型改为打开NPC对话的特定类型。

接着,切换到主线程单元,处理新定义的消息。

以下是消息处理逻辑:

  1. 调用怪物列表对象中相应的成员函数来打开NPC。
  2. 首先对相关对象进行初始化。
  3. 然后调用 OpenNpcOpenNpcForName 函数。
  4. 从消息参数中取出NPC的名字。
  5. 将参数指针类型转换为字符指针类型。
  6. 完成函数调用。

完成代码编写后,首先进行编译,确保没有语法错误。

完善回城补给函数

打开NPC对话的功能完成后,我们就可以开始完善回城补给函数的代码。

现在,移动到功能封装的单元。回城补给函数应当添加到挂机类中。

在挂机类的回调函数前,添加回城补给的代码。首先,将设计思路的注释复制到该位置。

以下是回城补给的核心步骤:

  1. 保存当前坐标:记录角色回城前的坐标位置。坐标变量类型应使用浮点数(float)或有符号整数(int),以确保能表示负坐标。直接使用浮点类型效率更高。
  2. 执行寻路到城:调用寻路函数,让角色移动到指定的城市坐标。这需要包含相应的头文件。
  3. 打开NPC对话:角色到达NPC身边后,调用之前封装的函数打开NPC对话窗口。
  4. 进行补给操作:此步骤涉及打开商店、购买物品等,将在下节课实现。
  5. 关闭NPC对话:补给完成后,需要关闭NPC对话窗口。我们将在下节课封装此功能。
  6. 返回原坐标:最后,使用保存的坐标,让角色寻路返回原来的位置。

代码初步完善后,再次进行编译。

功能测试与线程注意事项

现在,我们可以在挂机类中调用回城补给函数进行测试。

将回城补给的测试代码关联到测试按钮上。回城补给功能属于自动玩单元 cg,通过全局变量 waterPlay 对象来调用。

测试前,必须先将功能挂接到游戏的主线程。如果不这样做,测试代码中的循环(特别是寻路函数的循环判断)会卡住测试窗口所在的线程,导致界面无响应。这是因为测试按钮的代码运行在界面线程中。

如果将该函数放在自动打怪的回调函数中调用,则不会卡住界面,因为自动打怪运行在另一个独立的线程中。

重新注入游戏进行测试。先挂接到主线程,然后点击测试按钮。测试成功时,角色会跑到NPC身边并打开对话窗口。


本节课中我们一起学习了如何封装打开NPC对话的函数,并构建了回城补给功能的基本框架。我们实现了坐标保存、寻路进城和打开NPC对话的步骤,同时强调了多线程环境下调用函数的注意事项。下一节课,我们将继续完善打开商店、购买物品以及关闭NPC对话的功能。

课程P9:020-分析怪物对象属性 🔍

在本节课中,我们将学习如何修正上一节课中发现的怪物属性分析错误,并深入分析怪物对象的更多关键属性,例如对象类型和死亡状态。我们将通过对比数据、修改代码结构来完成这一过程。


修正怪物死亡状态分析

上一节我们分析了怪物列表和怪物对象的基本属性,但在测试代码时发现部分属性(如死亡状态)分析有误。

首先,我们需要打开调试工具并加载游戏进程。


这是上一节课分析的对象结构。在怪物列表中,下标为3的位置出现了玩家角色的名字,这表明对象列表中包含了玩家自身。

现在,我们使用相对地址表示,并复制一段数据出来进行比较。

同时,我们选择一个怪物对象(例如狂牛),也用相对地址表示并复制一段数据。

调整数据大小后,我们进行对比。

观察不同之处。偏移+8+C的位置存在差异。对于怪物,+8处的值常为0x21,而玩家此处为0x31+C处的值则是变动的编号。

这表明+8位置极有可能是对象的分类编号。

我们暂时记录:+8偏移处为对象类型分类编号


分析玩家对象属性

在分析怪物属性的同时,我们也可以顺便解析玩家对象的部分属性。

+18偏移处看起来是玩家名字的指针。

+18偏移处是玩家对象的名字

后面可能还有喊话内容等数据,目前用不到。其他如等级等属性,我们后续再分析。


确定怪物死亡状态

在制作自动打怪功能时,需要判断怪物是否死亡,以避免攻击已死亡的怪物。

首先,我们选中一个怪物并修改其名字,以便于追踪。

找到当前选中怪物的地址后,我们从偏移+70附近开始搜索其死亡状态。通常,状态值可能用0(死亡)和1(存活)表示。

攻击该怪物使其死亡,观察数值变化。

怪物刷新后,存活状态值恢复为1

我们发现,之前分析的偏移+314处的值,实际上表示的是是否被选中是否显示血条,而非死亡状态。

我们需要换一种方式搜索。搜索01,攻击怪物使其死亡,然后搜索变化的值。

最终,我们定位到偏移+398处的值能准确反映死亡状态:死亡时为1,存活时为0

另一个偏移+768处也可能相关,但测试表明+398更稳定。

总结分析结果:

  • +8偏移:对象分类编号(怪物为0x21,玩家为0x31)。
  • +398偏移:死亡状态(1为死亡,0为存活)。

修改代码结构

根据以上分析,我们需要修改上一节课的代码结构。

打开上一课的代码,切换到结构体单元进行修改。

以下是需要修改的核心部分:

  1. 在结构体中添加死亡状态属性,偏移为+398

    // 怪物对象结构体示例
    typedef struct {
        // ... 其他属性
        BYTE deathStatus; // 偏移 +0x398,1=死亡,0=存活
        // ... 其他属性
    } MonsterObject;
    
  2. 在初始化代码中,为+398偏移赋值。

  3. 在遍历怪物列表时,增加条件判断:仅当对象分类编号(+8偏移)等于0x21(怪物)时,才处理该对象。

    if (pObject->typeClass == 0x21) { // 0x21 代表怪物
        // 处理怪物逻辑,如读取死亡状态、坐标等
        if (pObject->deathStatus == 1) {
            // 怪物已死亡,跳过攻击
        }
    }
    
  4. 对象分类编号的偏移+8可以定义为宏,方便后续使用。

    #define OFFSET_OBJECT_TYPE 0x8
    

进行这些修改后,重新编译并测试代码。

打开调试信息接收工具,查看输出。此时,消息应清晰显示怪物的坐标、等级、血量及死亡状态,且没有乱码。


总结与下节预告

本节课中,我们一起学习了:

  1. 修正了分析错误:明确了偏移+314处是选中/血条显示状态,而非死亡状态。
  2. 发现了关键属性
    • +8偏移处的对象类型分类编号0x21=怪物,0x31=玩家)。
    • +398偏移处的死亡状态1=死亡,0=存活)。
  3. 修改了代码结构:在结构体中添加了新属性,并在逻辑中增加了对象类型判断,使代码更健壮。

通过本课的学习,我们完善了怪物对象属性的分析,为后续实现自动打怪等功能打下了基础。

下节课,我们将开始分析如何攻击怪物、使用技能以及选中怪物等交互逻辑。

课程P90:101-打开/关闭商店与关闭NPC对话 🛒

在本节课中,我们将学习如何分析并实现游戏内打开商店、关闭商店以及关闭NPC对话的功能。我们将通过分析网络数据包,找到关键代码,并将这些功能封装成易于使用的函数。


分析打开商店功能

上一节我们介绍了课程目标,本节中我们来看看如何分析打开商店的功能。

首先,打开调试工具并附加到游戏进程。

通过之前的网络分析,我们知道打开NPC商店时需要向服务器发送特定数据包。发送数据包的函数在代码中有相应的机制。

我们转到发包函数所在的单元。这是最新的发包函数。

我们转到发包函数处,并在打开商店时下断点。先与NPC对话,准备打开买卖商店,然后在此处下断点,并切换回游戏。

此时未点击商店,断点未触发。现在点击打开商店,断点触发。在调用堆栈中按回车键,返回到发包的地址。

此处就是打开商店的代码。现在EAX寄存器指向数据缓冲区。数据较少时,可以直接用DWORD形式查看。

我们将缓冲区的参数提取出来。后面是大片的零,无需关注。有数据的前20个字节(十六进制0x14)是我们需要的。

以下是打开商店的分析结果,包括其缓冲区和相关代码。先让程序继续运行。


分析关闭NPC对话功能

上一节我们分析了打开商店,本节中我们来看看关闭功能。

同样在发包函数处下断点,但尝试关闭商店时断点未触发。接下来分析结束NPC对话。

在代码中查找关闭NPC对话的部分。此处是关闭NPC对话的代码。查看缓冲区,地址在EDX

从这里看,前面一共12个字节(十六进制0x0C)。

这段数据就是关闭NPC对话的代码。我们将其复制出来。这里的参数也是赋给了EAX


功能测试

接下来,我们对打开商店和关闭NPC对话进行测试。理论上,关闭NPC对话会自动关闭商店。

我们先退出调试分析。然后编辑相应代码进行测试。

在主线程单元中,Txt3部分有一个提交任务的数据发送缓冲区。我们可以在此基础上修改,只需将缓冲区设置得更大一些。

我们将缓冲区大小设为0x868。因为DWORD类型每个元素占4字节,除以4后大约为538,缓冲区足够大。但实际有用的只是前面一段数据。

我们将分析得到的数据移植到这个缓冲区中进行测试。首先注释掉原来的测试代码。

以下是测试步骤:

  1. 将打开商店的缓冲区数据复制进去。参数是1E
  2. 在它的基础上,再修改出关闭NPC对话的缓冲区。这里只需改为0C,其他数据如03和后面的00只需少量修改。

接下来进行关联测试。编译这段代码,并设置调试目录。

挂接到主线程,然后进入游戏。先打开NPC对话,再打开商店。最后测试关闭NPC对话,主要观察它是否能关闭商店。测试成功,说明我们的数据是正确的。


封装功能函数

测试成功之后,我们将其封装成函数形式。

回到主线程单元。因为不同的NPC,其打开对话的代码可能有所区别,所以关闭NPC的代码需要与NPC名字关联。

我们先封装打开商店的函数。打开商店的代码可以单独成函数,也可以关联到外部列表。理论上通过对象的成员函数操作也能实现相同目的。

我们添加相应代码。当时打开NPC对话时,调用了NPC对象+4位置的函数。关闭NPC时可能也会调用此函数,但本节课暂不深入分析。我们先对已分析好的数据进行封装测试。

接着封装关闭NPC对话的函数。可以在上面代码的基础上修改。

编译生成。这里还需要定义相应的局部变量。

生成成功后,在主线程单元中进行代码封装。我们先定义这两个函数:一个是打开商店,一个是关闭NPC对话。

同时定义两个可调用的接口:一个是关闭商店(通过NPC名字),另一个是打开NPC对话。我们在其基础上进行修改。

然后转到主线程单元,添加对这两个消息的响应。这里应该是关闭NPC。转到NPC的定义处。

在调用相应函数的地方,参数都是NPC的名字。我们在原有代码基础上修改。

再次测试时,调用新定义的这两个函数进行测试:打开NPC、打开商店、关闭商店。这三个函数再进行相应测试。

目前,NPC的名字参数尚未被实际利用。这是为后续预留接口。如果后续仍通过发送数据包方式实现NPC打开,可以建立一个结构,根据不同的NPC名字判断并填充不同的缓冲区数据。具体实现将在后续课程中完成。

我们再次进行相关测试,重新编译并挂接到主线程。然后依次执行:打开NPC、打开商店、关闭NPC对话。

至此,我们只差卖出商品和买入所需物品这两个步骤。关闭商店和关闭NPC对话是同一过程,关闭NPC对话时就关闭了商店。

基本上所需功能已经完成。出售物品和购买物品时还需要一个循环过程。


课后作业与总结

本节课中我们一起学习了如何分析并封装打开/关闭商店以及关闭NPC对话的功能。

布置一个作业:大家可以尝试分析,不通过发送封包的形式,而是通过调用对象内部成员函数的形式,看能否实现对话的关闭以及商店的打开。这只是一种尝试,不一定成功,但有很大可能性可以通过向成员函数传递不同参数来实现。如果这种方式可行,封装函数会更加方便,代码量也会更少。

还有一个任务:将今天相关的代码添加到自动挂机逻辑中。这可以在挂机单元中完成。在设计时,可以在执行前加上条件判断,如果条件不成立则直接返回,后面就不执行。这样,在挂机逻辑中直接调用函数即可,函数内部会自动进行条件检测。

我们下节课再见。本节课就讨论到这里。

课程 P91:102-完善回城补给功能 GoToCityForSupply 🛒

在本节课中,我们将继续完善自动挂机系统中的“回城补给”功能。我们将重点实现根据药品数量判断是否需要补给、计算购买数量、执行购买流程以及返回挂机点等逻辑。


概述

上一节我们完成了打开商店的功能。本节中,我们将在此基础上,实现完整的回城补给流程。这包括判断补给条件、计算购买数量、执行购买、关闭商店窗口以及返回原坐标。


完善回城补给函数

首先,我们打开第101课的代码,并定位到挂机类中的回城补给函数 GoToCityForSupply

以下是实现该功能的核心步骤:

  1. 判断补给条件:检查当前HP药品数量是否低于预设的最低保有量。
  2. 计算购买数量:如果需要补给,则计算需要购买的数量(目标保有量减去当前数量)。
  3. 执行购买:调用之前封装的打开商店和购买物品函数。
  4. 关闭界面:购买完成后,关闭商店和NPC对话窗口。
  5. 返回挂机点:使用寻路功能,让角色返回之前的坐标点。

以下是关键代码逻辑的实现:

void HangUpClass::GoToCityForSupply()
{
    // 1. 判断是否需要补给
    if (!NeedSupply())
    {
        return; // 条件不满足,直接返回
    }

    // 2. 计算需要购买的药品数量
    int currentHPItemCount = GetCurrentHPItemCount(); // 获取当前HP药品总量
    int supplyCount = m_maxHPItemCount - currentHPItemCount; // 计算差值

    // 3. 执行回城、打开商店、购买物品
    GoToCity();
    Sleep(500); // 等待动作响应
    OpenShop();
    Sleep(500);
    BuyItem(m_hpItemName, supplyCount); // 传入药品名称和需要购买的数量

    // 4. 关闭商店/NPC窗口
    CloseShop();
    Sleep(100); // 短暂延迟确保窗口关闭

    // 5. 返回原挂机坐标
    ReturnToOriginalPosition();
}

补给条件判断函数

我们需要一个函数来判断当前是否达到了需要回城补给的条件。

以下是 NeedSupply 函数的一个简单实现示例:

bool HangUpClass::NeedSupply()
{
    // 获取当前身上指定HP药品的总数量
    int currentCount = GetItemCount(m_hpItemName);

    // 判断当前数量是否低于要求的最低保有量
    // 如果低于,则需要补给
    if (currentCount < m_minHPItemCount)
    {
        return true;
    }
    return false;
}

公式说明
补给条件可以表示为:
当前药品数量 < 预设最低保有量
当此条件为真时,触发回城补给流程。


参数初始化与测试

为了使功能正常运行,我们需要在类的构造函数中初始化相关变量。

以下是需要初始化的成员变量示例:

HangUpClass::HangUpClass()
{
    // 回城补给相关参数初始化
    m_minHPItemCount = 330;  // 当药品低于330个时,触发补给
    m_maxHPItemCount = 400;  // 补给后,药品持有量目标为400个
    m_hpItemName = "金创药(小)"; // 要补给的药品名称
    // ... 其他初始化代码
}

在测试时,可以暂时调整 m_minHPItemCount 的值(例如设为比当前数量稍高的值),以便快速触发补给逻辑进行功能验证。


集成到自动挂机循环

最后,我们需要将回城补给判断集成到主挂机循环中。

在挂机的主逻辑函数(例如一个循环或定时器回调)中,定期调用条件判断:

void HangUpClass::MainHangUpLoop()
{
    while (m_isHangingUp)
    {
        // 1. 判断是否需要进行回城补给
        if (NeedSupply())
        {
            GoToCityForSupply();
            // 补给完成后,继续挂机循环
            continue;
        }

        // 2. 正常的打怪逻辑
        AttackMonster();
        // ... 其他挂机操作

        Sleep(100); // 短暂休眠,防止CPU占用过高
    }
}

调试与问题处理

在测试过程中,可能会遇到寻路被卡住、条件判断不准等问题。以下是常见的解决思路:

  1. 添加调试信息:在关键步骤(如寻路开始、购买完成、返回时)输出日志,方便追踪执行流程。
  2. 调整延迟时间:适当增加 Sleep 的等待时间,确保游戏客户端有足够时间响应操作。
  3. 处理寻路超时:为寻路过程设置超时机制。如果超过一定时间(如30秒)仍未到达目标点,则判定为寻路失败,执行备用逻辑(如记录日志或尝试重新寻路)。
  4. 确保坐标正确:仔细检查返回的坐标值是否正确,并确认游戏内该坐标点是否可达。

总结

本节课中,我们一起学习了如何完善自动挂机系统的回城补给功能。我们实现了从条件判断、数量计算、购买执行到返回原地的完整闭环。通过将这部分逻辑集成到主挂机循环,我们的脚本现在具备了基本的自我补给能力,能够更长时间地自动运行。

需要注意的是,在实际应用中,还需要考虑网络延迟、游戏环境变化(如被其他玩家阻挡)等因素,并增加更多的异常处理和容错机制,才能使功能更加稳定可靠。我们将在后续课程中继续对这些细节进行优化。

课程P92:103-解决寻路问题,完善回城补给功能GoToCityForSupply 🧭

在本节课中,我们将要学习如何诊断和修复寻路函数的问题,并完善自动回城补给的功能。我们将通过分析代码、调试测试以及修改逻辑,确保角色能够正确寻路并完成补给。


概述

上一节我们测试了回城补给函数 GoToCityForSupply,发现它无法到达目的地。本节中,我们来看看问题出在哪里,并着手修复寻路功能。


测试寻路函数

首先,我们打开第102课的代码,单独测试寻路相关的函数。我们在测试窗口上添加一个按钮和两个坐标输入框,用于验证寻路功能。

以下是测试按钮的点击事件代码:

// 假设变量定义
float targetX, targetY;
// 从UI控件更新目标坐标
targetX = GetFloatFromEditControl("editTargetX");
targetY = GetFloatFromEditControl("editTargetY");

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/d5b78ea0bf56cfbc6d6db91b22a1ec7a_5.png)

// 调用寻路函数
bool result = PathFindTo(targetX, targetY);

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/yujinxiang/img/d5b78ea0bf56cfbc6d6db91b22a1ec7a_7.png)

// 输出调试信息
float distance = CalculateDistance(GetCurrentX(), GetCurrentY(), targetX, targetY);
DebugPrint("寻路结果: %d, 当前距离: %f, 目标坐标: (%f, %f)", result, distance, targetX, targetY);

编译并运行后,我们观察调试信息。初始测试显示寻路成功,角色移动到了指定坐标。


发现寻路卡住问题

然而,当角色移动到某些特定位置时,寻路会被卡住。调试信息显示距离不再减少,程序陷入循环。

通过观察游戏画面,我们发现角色被障碍物阻挡。寻路算法在遇到无法绕过的障碍时,会持续尝试,导致循环超时(默认60秒)。


分析并显示寻路路径

为了更直观地分析问题,我们尝试在小地图上显示寻路路径(红色标记点)。通过逆向分析,我们找到了控制路径显示的代码位置。

关键的内存写入指令如下:

; 示例汇编代码,标记路径显示
mov byte ptr [esi+0x228], 0x01

这段代码将特定内存地址的值设为1,从而激活小地图上的路径显示。我们通过修改此值(例如设为0)验证了其功能:路径显示消失。

因此,我们确定此机制用于控制寻路路径的可视化。


定位路径标记的内存来源

我们需要找到 esi 寄存器中地址的来源,以便在我们的代码中控制路径显示。通过回溯调用栈和分析数据流,我们发现该地址来源于一个全局的游戏对象管理结构。

最终,我们找到了一个相对稳定的基址偏移:

基址: 0x00F8840
偏移: +0x298

通过访问 [基址 + 0x298] 可以获得控制路径显示的关键指针。我们在寻路函数调用前,手动向该地址写入1,以启用路径显示。

// 启用小地图路径显示
*(BYTE*)(*(DWORD*)(0x00F8840) + 0x298) = 0x01;

解决循环调用导致的卡顿

测试发现,在循环中频繁调用寻路函数 PathFindTo 容易导致角色在复杂地形卡住。一个解决方案是减少调用频率,或者只调用一次并依赖游戏内部的寻路AI完成剩余路径。

我们修改了 GoToCityForSupply 函数中的逻辑:

  1. 在开始寻路前,只调用一次 PathFindTo
  2. 不再在循环中反复调用,而是等待角色移动或通过距离判断是否到达。
// 修改后的寻路逻辑片段
void GoToCityForSupply() {
    // 1. 停止当前所有动作(如打怪)
    StopAllActions();

    // 2. 启用路径显示
    EnablePathDisplay();

    // 3. 单次调用寻路到目标坐标
    PathFindTo(targetX, targetY);

    // 4. 等待到达或超时
    WaitForArrivalOrTimeout(30000); // 等待30秒
}

完善回城补给功能

在测试回城补给时,我们发现两个新问题:

  1. 坐标传递错误:传入 PathFindTo 的目标坐标不正确,导致角色走向错误位置。
  2. NPC误判为怪物:在寻路过程中,如果靠近NPC,自动战斗逻辑可能将其选中为攻击目标。

对于第一个问题,我们检查并修正了补给函数中目标坐标的赋值。对于第二个问题,我们需要在自动战斗逻辑中增加NPC过滤。

在开始补给前,必须清除当前选中的目标:

// 在补给函数开始时,清除选中目标
ClearSelectedTarget();

同时,在自动战斗的选怪逻辑中,需要排除NPC类型的对象:

// 修改后的选怪逻辑,需判断对象类型
GameObject* target = FindNearestMonster();
if (target != nullptr && target->type != OBJECT_TYPE_NPC) {
    SelectTarget(target);
}

测试与验证

完成上述修改后,我们重新编译并进行测试。

  1. 角色在药品不足时,成功触发回城补给逻辑。
  2. 寻路过程顺畅,小地图正确显示路径。
  3. 角色能够到达补给NPC处,完成购买后返回挂机点。
  4. 自动战斗逻辑不再攻击NPC。

测试结果表明,寻路卡住问题和补给功能的主要缺陷已得到解决。


总结

本节课中我们一起学习了如何诊断寻路函数的问题,通过逆向分析找到了控制路径显示的机制,并修正了寻路逻辑以减少卡顿。同时,我们完善了回城补给功能,解决了坐标错误和NPC误攻击的问题。

关键点总结:

  • 寻路调试:通过输出距离和坐标信息定位问题。
  • 路径显示:通过内存写入 *(BYTE*)(*(DWORD*)(0x00F8840) + 0x298) = 0x01 控制小地图路径。
  • 逻辑优化:将循环寻路改为单次调用,避免卡死。
  • 功能完善:在补给前清除目标,在战斗逻辑中过滤NPC。

下节课,我们将进一步优化自动战斗系统,确保其稳定性和效率。

课程 P93:104-打怪过滤NPC 🎯

在本节课中,我们将学习如何区分游戏中的NPC与怪物,并修改自动选怪逻辑,使其能够正确过滤NPC,避免辅助功能在NPC附近卡住。


分析NPC与怪物的区别 🔍

上一节我们介绍了自动选怪功能。本节中,我们来看看如何区分NPC和怪物。如果无法区分,当怪物周围存在NPC时,辅助可能会错误地攻击NPC,导致功能异常。

以下是分析过程:

  1. 首先,我们打开调试工具并附加到游戏进程。
  2. 游戏中的附近对象列表包含了玩家、NPC、怪物以及地面物品。其数据结构中有一个偏移量用于标识对象类型。
  3. 为了找到区分特征,我们分别查看怪物和NPC的对象属性。
  4. 通过对比发现,怪物对象的等级偏移量(例如 +0x54)存储的值大于零,而NPC的同一偏移量存储的值为零。

核心判断逻辑:可以通过检查对象的等级值来区分。如果 对象等级 == 0,则该对象是NPC,应被跳过。


修改自动选怪函数 ⚙️

在分析了区别之后,我们需要修改自动选怪的代码,加入NPC判断逻辑。

以下是修改步骤:

  1. 打开第103课的代码,找到自动选择最近怪物的函数。
  2. 在遍历附近对象并选择目标的循环中,添加一个条件判断。
  3. 判断条件为:如果当前遍历到的对象的等级等于0,则使用 continue 语句跳过该对象,继续检查下一个。
  4. 修改完成后,重新编译并注入到游戏中进行测试。

关键代码修改示例

// 伪代码示例
for (每个附近对象) {
    int monsterLevel = 读取对象等级偏移量();
    if (monsterLevel == 0) {
        continue; // 跳过NPC
    }
    // ... 原有的选怪逻辑 ...
}

测试与问题排查 🐛

修改代码后,我们进行了挂机测试,并触发了自动回城补给功能。但在测试过程中,发现购买药品的功能未能成功执行。

以下是问题排查过程:

  1. 检查补给函数的代码,发现其调用了“打开NPC商店”和“购买物品”的函数。
  2. 通过输出调试信息,发现购买函数未能正确找到商店中的物品,返回了无效索引(-1)。
  3. 怀疑问题出在传递的物品名称参数或商店列表的基址上。
  4. 进一步检查发现,用于定位商店物品列表的内存基址可能已经失效,导致无法正确查询物品。

结论:当前补给功能失败的主要原因是商店列表的基址需要更新。我们将在下一节课中解决这个问题。


总结 📝

本节课中我们一起学习了:

  1. 如何区分NPC与怪物:通过分析游戏内存,发现NPC的等级值为0,而怪物的等级值大于0。
  2. 修改自动选怪逻辑:在选怪循环中加入等级判断,成功过滤掉NPC,避免了误攻击。
  3. 遇到了新问题:在测试自动补给功能时,发现由于商店列表基址失效,导致无法购买药品。

核心收获是掌握了通过内存属性区分游戏对象类型的方法,并实践了代码修改。下一节课,我们将重点更新商店列表的基址,以修复自动补给功能。

课程 P94:105-获取商店列表基址 GetShopListBase 🛒

在本节课中,我们将学习如何动态获取游戏商店列表的基址。我们将分析现有代码的不足,探索通过内存访问和条件判断来稳定定位商店列表基址的方法,并最终编写一个可靠的函数来实现此功能。


回顾上节课内容

上一节我们介绍了在商店列表中查询物品并返回其对象ID的基本方法。我们来看一下104课中的相关代码。

在结构单元中有一个函数,用于在商店列表中查询物品栏是否存在特定物品,并返回其具体信息。

以下是该函数在购买物品时被调用的代码片段,它需要一些特定的数值作为参数。


现有方法的不足与目标

现在我们需要找到一种方法来准确获取商店列表的基址。上一节课中使用的计算公式在某些情况下会失效,无法正确偏移出有效数据。

我们先回顾一下这个公式。

有时这个公式有效,但有时它无法偏移出正确的基址。首先,我们需要分析其相关属性。

我们需要记录这些属性,并理解如何计算出地址的前半部分。复制整个数据进行分析。

最终计算出的位置是一个相对基址。

通过这个地址,加上偏移量 0x412+40x410,组成了商店列表的完整公式。


尝试通过内存访问定位基址

我们查找访问该地址的代码。观察 ESI 寄存器的值 0xF299C。代码通过一个循环不断偏移 ESI 的值,像是在遍历一个大小为 0x1900 的数组。

分析另一处代码,它没有条件判断指令,也没有 CALL 指令,最终取出数值。

我们尝试关闭商店,观察何时访问这个关键数字(即加上 0x410 偏移后的地址)。为此,我们添加一个内存访问断点。

首先用 CE 工具查找。理论上,打开商店时会访问这个数字。我们先取出这个数值。

因为该数值加上 0x410 偏移,再加上 4 * 下标,就对应商店里的物品。

因此,我们需要找到这个对象的来源,并分析其判断条件,看它是如何取出这个数字的。由于之前的公式不稳定,我们先用 CE 尝试,同时退出 OD 调试器。

在所有对象列表中也可能存在该对象,我们也可以通过偏移对象列表来实现。最新的所有对象基址是 0x640 这个数值。但偏移对象列表需要其属性作为判断依据。

我们先尝试其他方法。这里有很多列表,我们观察打开商店时的访问情况。

打开商店时没有立即访问,但有一个比较指令。然而,这个地址的访问并非只在打开商店时触发,因此可以排除。

我们将所有相关地址添加进来观察。有些地址在打开商店时没有反应,有些则被频繁访问。我们复制一个可能相关的地址 0x22C(EDI 寄存器的值)备用。

删除无关地址,对可疑地址下断点,然后打开商店。如果没有反应,则删除该地址。

我们发现很多数据都断在同一个代码地址 0x626A39。这些地址都很相似,通过它们分析出其他基址或偏移的可能性较小。

我们尝试之前可能性较大的那个地址,查看其 EDI 数值,并搜索该对象,看是否能找到指向商店列表的其他基址或偏移。

搜索从 0x311 开始,这可能是所有对象数组的基址,因此不予考虑。从 0x17 开始的数据是堆栈数据,也不予考虑。

我们查看后续数字,尝试找到其他基址。打开商店列表,从第一个地址找到一个 0x28 的偏移,记录下来。同时复制 EDX 的数值。

继续从 EDI 查找,可以看到 ESP 和 EBP 从 0x17 开始,因此之前两个数据不需要。关闭商店,这个数据可能比较重要。

从 EDX 查找对其的访问,发现也有从 0x311 开始的访问。我们以之前的偏移 0x22C 命名,找到了 EDI 的数字。

将这些新对象添加进来,数量较多。然后查找对这些对象的访问,打开商店,对没有访问的地址做标记。

每个地址段都可以尝试标记。观察其 EDX,发现它跳转到另一个数组里去了,这样查找比较复杂。

我们寻找更简单的方法,能否直接找出偏移。这一串地址都从 0x1A 开始,找一个不同的可能性更大。结果又跳转到 0x66A39 这个对象里去了。

所有查找都进入了同一个循环。因此,除了之前分析的基址可能有用外,当前这种查找基址的方法作用不大。

我们需要另想办法来获得商店对象的基址,即 0x10300x78 这两个数字。此时游戏卡死了,需要重新启动。

重新打开游戏后,用 OD 附加到进程。


通过条件判断和偏移获取基址

既然不能直接找到偏移或基址,我们可以通过现有列表进行偏移,但需要加上一些条件判断来确认正确的基址。

再次打开商店列表。

回顾公式。

目前这个公式又可以使用了。我们可以先使用这个公式,同时准备一个备用方案。

备用方案是直接从下标开始进行偏移,但需要附加条件判断。例如,判断商店第一个物品是否是“金创药小”。或者,因为不同 NPC 的第一个商品可能不同,我们可以通过物品类型来判断。

物品类型偏移是 +0x08,判断其值是否为 0x1A(代表物品)。我们还需要判断该对象是商店物品、背包物品还是地面物品,这可能需要一个特定的标志位。

先看看背包物品的情况。打开背包,查看其最新基址。

查看背包最新基址。

发现背包物品的 +0x08 偏移也是 0x1A。我们需要找出它们的区别。将“金创药小”放到背包第一个,比较相同物品间的差异。

区别可能在数量上。

背包物品数量不同,查看偏移 0x244 处。

背包中“金创药小”的数量是 59。

商店物品的 +0xC4 偏移是 0xC40

查看 +0xC4 偏移。

商店物品的数量显示为 0xFF(即 -1)。+0xC8 偏移也是 -1。我们可以利用这两个属性进行判断。

对于商店物品,+0xC4+0xC8 偏移都等于 -1。因此,判断条件可以是:+0x08 等于 0x1A,且 +0xC4+0xC8 都等于 -1

可能还有其他属性(如所属关系)来区分对象位置,但这需要更深入的分析。目前利用现有数据已可进行区分。

复制商店物品和背包物品的数据进行比较。

这是背包物品数据。

这是商店物品数据。

比较发现,第一个数值相同,但 0x28 偏移处不同。0x1C0x200x24 可能是坐标,0x380x3C 也不同。0x44 偏移处相同,这是物品的分类编号 ID。

物品类型在 +0x08 偏移处,肯定相同。

有了这些条件,我们可以编写一个函数来偏移并获取商店列表基址。


编写获取商店列表基址的函数

打开第104课的代码,在其基础上进行优化修改。

在结构单元的商店模块中添加一个新函数 GetShopListBase,用于获取商店基址。

我们所说的商店列表基址,就是公式中的那个数字。复制该公式,转到相关成员函数前添加代码。

我们需要一个循环来进行偏移。参考现有公式,但可以简化。

首先,需要一个 for 循环。因为坐标在 0x210 附近变化,我们暂时将范围设大一些,例如 0x310,或者从 Base - 0x100Base + 0x100 进行搜索。

定义循环变量 i,起始值为 -100,结束值为 100,实现前后搜索。

开始进行偏移。首先需要取出公式中的数值。定义指针指向对象。

基址需要加上 i * 4。公式中的 +4 可以省略,因此偏移是 0x211。简化后,公式为 *(DWORD*)(Base + i*4),这样可以取出里面的对象地址。

取出对象后,需要判断它是否指向商店列表。

首先将取出的数值转换为指针类型,并加上 +4 偏移(也可以在后面实现)。然后读取该地址的值。

判断该值是否为空。如果为空,则继续下一次循环。

如果不为空,则继续判断。此时我们已经读取出前半段地址。

然后需要加上 0x410 偏移来获取商店列表中的对象。公式为 *(DWORD*)(Address + 0x410 + 4*Index)。为了可读性,我们取第一个物品(Index = 0)。

取出数据后,再次判断对象是否为空。如果为空,继续下一次循环。

如果不为空,则分别读取 +0x08(类型)和 +0xC4(数量)的值进行比较。

定义变量 TypeCount,初始化为0。

如果对象不为空,则尝试读取其类型和数量。这些指针操作可能引发异常,需要进行异常处理。出现异常则继续下一次循环,也可以打印调试信息。

正常情况下,读取类型(+0x08)和数量(+0xC4)。可以定义宏来代表这些偏移。

判断条件:如果对象类型等于 0x1A(物品),且数量等于 -10xFFFFFFFF),则返回该对象的基址(注意,是基址,不是对象地址)。

如果循环结束仍未找到,则返回 NULL,表示获取失败。

先编译测试,打印出获取的数值,检查是否正确。

在主线程单元进行测试。

注入游戏进行测试。

测试失败。

首先挂起主线程测试。最终得到的基址是 0x19FB6918

检查该对象是否正确。有可能偏移到了仓库或背包(但背包物品数量不为-1)。仓库物品有数量,也可能被偏移到。

附加到游戏进程。

用旧公式验证。

基址 0x19FB6918 加上 0x410,再根据下标 i 加上偏移 0xC,可以找到“金创药小”等物品。

基本正确。

接下来,利用这个基址修改商店相关函数,主要是购买物品函数 BuyItem

可能还需要修改 GetItem 函数的初始化部分,用新函数 GetShopListBase 返回的基址替代原来的计算。

恢复主线程,退出。

测试是否可以购买物品。使用购买“金创药小”功能测试。卸载主线程模块,重新注入。

挂起主线程,购买“金创药小”。发现没有反应,返回结果仍是 -1,说明还有问题。

查看代码,发现获取基址时出现了异常,但最后返回的基址是正确的。可能是条件判断不够准确。

缩小搜索范围,前后各10个对象进行偏移。卸载主线程模块。

检查购买物品函数 BuyItemGetItem。在调用时已经初始化,但代码中可能仍有问题。

转到 GetItem 的初始化函数查看。显示其获取的基址是否正确。

代码中再次将基址加了4,然后加上 0x410。实际上,ForestCoBase 应该等于我们获取的基址(即 0x410 之前的部分),这一步应该省略。

重新编译测试。挂起主线程,购买“金创药小”。仍然不正确。

返回的下标是正确的,但出现了异常。异常可能出现在 BuyItem 函数内部。添加调试语句,定位异常发生的位置。

在可能出错的代码段前后添加编号输出,观察执行流程。重新编译注入测试。

通过调试信息发现,异常发生在某个消息框代码附近。注释掉消息框代码,重新测试。

还需要添加一个判断,当读出的对象为空时,忽略异常。


总结

本节课中,我们一起学习了如何动态获取游戏商店列表的基址。我们分析了直接计算公式的不稳定性,探索了通过内存访问断点和条件判断来定位基址的方法。最终,我们编写了一个 GetShopListBase 函数,通过循环偏移和属性验证(类型为物品、数量为-1)来可靠地获取商店基址,并尝试将其集成到购买物品的功能中。虽然过程中遇到了异常处理等问题,但为后续稳定调用奠定了基础。

课程P95:106-回城补给功能测试与补给选项卡界面添加 🛠️

在本节课中,我们将首先测试上一节课修改的回城补给功能,然后为这个功能添加一个专门的设置界面,即“补给”选项卡。我们将学习如何创建新的对话框资源、关联控件变量,并将其集成到主界面的选项卡控件中。


功能测试 🧪

上一节课我们修改了回城补给的功能,并修正了商店列表的机制。现在,我们先来测试一下这个功能。

首先,我们查看游戏内角色的药品数量。当前数量是418。为了触发回城补给,我们需要将补给条件设置为一个较大的数值,例如410。这意味着当药品数量低于410时,角色会执行回城补给。

接下来,我们挂接主线程并获取当前坐标。确认周围有怪物存在后,应用设置并开始挂机。然后,我们使用快捷键(如按F键)消耗药品,模拟药品数量减少。

此时,我们观察到角色已经生成了回城路径,准备返回城镇购买物品。打开调试信息查看器,可以看到角色购买了7个金创药,然后返回了挂机地点。

现在,角色已经开始打怪,并且系统正在统计药品数量并进行判断。从购买7个金创药的结果来看,回城补给功能基本可以正常运行。


添加补给选项卡界面 🖥️

测试通过后,我们为补给功能添加一个专门的设置页面。首先,停止挂机,卸载主线程并退出。

目前,补给相关的参数(如成员变量)存储在代码中,我们需要一个窗口界面来提供设置接口,就像已有的“挂机”选项卡一样。在“挂机”选项卡中,我们可以设置坐标、打怪模式、低血保护等参数,这些设置最终会修改代码中的成员变量。

本节的目标就是创建一个类似的“补给”选项卡。

创建对话框资源

  1. 在资源视图中插入一个新的对话框。
  2. 更改其ID,例如改为 IDD_SUPPLY
  3. 调整窗口大小,并将窗口样式设置为无边框。

设计界面控件

我们需要提供以下设置接口:

  • 两个复选框:用于选择是否在HP或MP药品数量低于阈值时触发回城补给。
  • 两个编辑框:用于输入HP和MP药品的触发阈值。
  • 一个列表框:用于管理需要补给的物品列表(考虑到不同NPC出售不同物品,如药品、弓箭、回城符等)。
  • 相关静态文本标签用于说明。

以下是具体操作步骤:

  1. 添加复选框和编辑框

    • 添加两个复选框控件,文本分别为“HP药品数量低于”和“MP药品数量低于”。
    • 在每个复选框旁边添加一个编辑框,用于输入具体的数值阈值。
    • 添加静态文本“时回城补给”作为说明。
  2. 添加补给物品列表

    • 首先添加一个分组框控件,将其文本设置为“补给列表”。
    • 在分组框内,添加静态文本标签,如“药店”和“仓库”,代表不同的补给地点。
    • 在每个地点标签后,添加一个组合框(ComboBox),用于选择要补给的物品(如“金创药”、“魔法药”)。
    • 在组合框右侧,添加一个编辑框,用于输入需要购买的数量。
    • 最后,添加一个“添加”按钮,用于将当前选择的物品和数量加入到下方的列表框中。
    • 添加一个列表框(ListBox)控件,用于显示所有已添加的补给物品信息。

关联控件变量

在关联变量之前,需要为这个新对话框创建一个类(例如 CSupplyPage)。

创建类完成后,为各个控件关联成员变量:

  • HP药品阈值编辑框:关联一个 DWORD 类型的变量,如 m_dwHpThreshold
  • MP药品阈值编辑框:关联一个 DWORD 类型的变量,如 m_dwMpThreshold
  • “药店”物品组合框:关联一个 CComboBox 类型的控件变量,如 m_ctlDrugStoreCombo
  • “药店”数量编辑框:关联一个 DWORD 类型的变量,如 m_dwDrugStoreQty
  • “仓库”物品组合框和数量编辑框同理。
  • 补给列表列表框:关联一个 CListBox 类型的控件变量,如 m_ctlSupplyList

初始化与数据添加

在对话框类的 OnInitDialog 函数中,我们需要进行初始化:

  • 为HP/MP阈值编辑框设置默认值(例如10)。
  • 为“药店”和“仓库”的组合框添加默认物品选项(如“金创药”、“魔法药”、“回城符”)。
  • 其他数值初始化为0。

“添加”按钮的响应函数需要执行以下操作:

  1. 调用 UpdateData(TRUE) 将界面数据更新到关联的成员变量中。
  2. 从组合框中获取选中的物品名称。
  3. 从数量编辑框中获取数量。
  4. 将物品名称和数量格式化为一个字符串(例如“金创药 x 10”)。
  5. 使用 m_ctlSupplyList.AddString(strItemInfo) 将该字符串添加到列表框中。

集成到主选项卡

界面设计好后,需要将其显示在主窗口的选项卡控件中。

  1. 在主对话框头文件中

    • 包含新创建的 CSupplyPage 类的头文件。
    • 添加一个 CSupplyPage 类型的成员变量,如 m_pageSupply
  2. 在主对话框的初始化函数中

    • 创建选项卡页。复制类似“挂机”选项卡的创建代码并进行修改。
    • 关键代码示例:
      m_pageSupply.Create(IDD_SUPPLY, &m_tabCtrl);
      m_tabCtrl.InsertItem(1, _T("补给")); // 假设索引1是补给页
      CRect rect;
      m_tabCtrl.GetClientRect(&rect);
      rect.DeflateRect(2, 30, 2, 2); // 调整位置
      m_pageSupply.MoveWindow(&rect);
      m_pageSupply.ShowWindow(SW_HIDE); // 初始隐藏
      
  3. 处理选项卡切换事件

    • 在选项卡控件的选择改变事件处理函数(如 OnTcnSelchangeTab)中,根据当前选中的选项卡索引,显示或隐藏对应的页面。
      int nSel = m_tabCtrl.GetCurSel();
      m_pageSupply.ShowWindow(nSel == 1 ? SW_SHOW : SW_HIDE); // 假设索引1是补给页
      // 同时隐藏其他页面...
      

界面调整与后续工作

编译并运行程序,点击“补给”选项卡,检查界面显示。可能需要进一步调整对话框和控件的大小以适应选项卡。

目前,列表框中的数据是以字符串形式存储的(如“金创药 x 10”)。在实际功能中,我们需要解析这些字符串,提取出物品名和数量。

因此,我们需要设计一个结构体来存储补给信息,例如:

struct SupplyItem {
    CString strItemName; // 物品名称
    DWORD dwQuantity;    // 物品数量
};

并创建一个该结构体的列表(如 CList<SupplyItem, SupplyItem&>std::vector<SupplyItem>)。在“添加”按钮点击时,不仅要将字符串加入列表框,还要将解析后的数据存入这个结构体列表。同时,也需要编写函数将列表框中的字符串解析回结构体数据。

这些内容我们将在下一节课中详细设计和实现。


总结 📝

本节课中,我们一起完成了以下工作:

  1. 测试了回城补给功能,确认其基本逻辑运行正常。
  2. 设计并创建了“补给”选项卡界面,包括阈值设置和补给物品列表管理。
  3. 为界面控件关联了变量,并实现了初步的数据添加功能。
  4. 将新页面成功集成到主窗口的选项卡控件中

通过本节学习,我们掌握了为外挂功能添加配置界面的基本流程。下一节课,我们将完善补给功能的数据结构,实现列表数据的解析与同步,使补给设置能够真正生效。

课程P96:容器vector的使用与补给页面代码实现 🧰

在本节课中,我们将学习C++标准库中的容器vector,并利用它来重构游戏外挂中补给物品列表的数据存储方式。我们将设计一个结构体来存放补给信息,并实现从UI界面到内存数据的转换与存储。


设计补给物品的数据结构

上一节我们介绍了项目的基本框架,本节中我们来看看如何设计一个合适的数据结构来存放补给物品的信息。

一个补给物品通常包含以下核心信息:

  1. 补给地点:例如“仓库”或“药店”,甚至可以细化到具体NPC的坐标或名称。
  2. 物品名称:需要购买的药品或物品的名字。
  3. 补给数量:需要购买该物品的数量。

因此,我们可以定义一个结构体 SupplyItem 来封装这些信息:

struct SupplyItem {
    char location[64];  // 补给地点
    char name[64];      // 物品名称
    DWORD count;        // 补给数量
};


使用vector容器存储动态列表

在之前的代码中,我们可能使用了固定大小的数组来存储补给列表。但补给物品的数量是不固定的,使用固定数组要么浪费空间,要么可能溢出。本节我们将引入 vector 容器来解决这个问题。

vector 是C++标准模板库(STL)中的一个动态数组容器。它可以根据需要自动调整大小,非常适合存储数量不定的数据集合。

要使用 vector,首先需要在文件开头包含头文件并指定命名空间:

#include <vector>
using namespace std;

接着,我们可以在挂机类的成员变量中,用 vector 来替换旧的固定数组:

// 旧的成员变量(将被替换)
// char supplyHPName[64];
// char supplyMPName[64];

// 新的成员变量:使用vector存储补给物品列表
vector<SupplyItem> supplyList;

这样,supplyList 就成了一个可以动态增删 SupplyItem 结构体的容器。


从UI界面读取并转换数据

设计好数据结构后,我们需要将用户在补给选项卡中设置的信息,转换并存储到 vector 容器中。这个过程在用户点击“应用设置”按钮时触发。

以下是实现该功能的核心步骤:

首先,我们需要一个辅助函数来替换字符串中的特定字符,以便于后续分割字符串。

// 函数功能:将字符串str中第一个出现的字符ch替换为结束符'\0'
void ReplaceCharWithZero(char* str, char ch) {
    char* pos = strchr(str, ch); // 查找字符ch的位置
    if (pos != nullptr) {
        *pos = '\0'; // 将其替换为字符串结束符
    }
}

接下来,在“应用设置”按钮的响应函数中,编写数据转换与填充的逻辑:

// 1. 清空现有的补给列表,准备装入新数据
supplyList.clear();

// 2. 获取UI中补给列表控件的行数
int itemCount = m_supplyList.GetItemCount();

// 3. 临时变量,用于存储从每行文本中解析出的数据
SupplyItem tempItem;
char szText[256]; // 存储从列表控件中获取的一行文本
char tempBuffer[256]; // 临时字符串缓冲区

// 4. 循环遍历列表的每一行
for (int i = 0; i < itemCount; ++i) {
    // 获取第i行的文本
    m_supplyList.GetItemText(i, 0, szText, sizeof(szText));

    // 示例文本格式:"药店;金创药(小);(11)"

    // 4.1 解析“补给地点”
    strcpy(tempBuffer, szText);
    ReplaceCharWithZero(tempBuffer, ';'); // 找到第一个分号并截断
    strcpy(tempItem.location, tempBuffer); // 复制到结构体的location字段

    // 4.2 解析“物品名称”
    // 跳过“地点”部分(中文字符占2字节,分号占1字节)
    strcpy(tempBuffer, szText + strlen(tempItem.location) + 3); // +3 跳过两个中文字符和一个分号
    ReplaceCharWithZero(tempBuffer, ';'); // 截断第二个分号
    strcpy(tempItem.name, tempBuffer); // 复制到结构体的name字段

    // 4.3 解析“补给数量”
    // 找到左括号'('的位置
    int j = 0;
    for (j = 0; szText[j] != '\0'; ++j) {
        if (szText[j] == '(') {
            break;
        }
    }
    // 从左括号的下一个字符开始复制
    strcpy(tempBuffer, &szText[j + 1]);
    // 将右括号')'替换为结束符
    ReplaceCharWithZero(tempBuffer, ')');
    // 将字符串形式的数字转换为整数
    tempItem.count = atoi(tempBuffer);

    // 5. 将解析好的结构体数据“压入”vector容器
    supplyList.push_back(tempItem);
}

关键操作解析supplyList.push_back(tempItem)
push_back()vector 的成员函数,其作用是将一个元素添加到容器的末尾。你可以将其想象为向一个“栈”的顶部压入数据。随着我们不断解析新的行,新的 SupplyItem 会被依次压入 vector


访问vector中的数据

数据存入 vector 后,我们需要知道如何将其取出并使用。以下是两种常见的访问方式。

方式一:使用迭代器遍历(推荐)

这种方式不会破坏容器内的数据,适合常规的读取操作。

// 定义一个指向SupplyItem的迭代器it,它从vector的起始位置开始
for (vector<SupplyItem>::iterator it = supplyList.begin(); it != supplyList.end(); ++it) {
    // 通过‘->’操作符访问结构体成员
    // 例如:打印补给地点和物品名
    printf("补给地点:%s, 物品:%s, 数量:%d\n", it->location, it->name, it->count);
}
  • begin(): 返回指向容器第一个元素的迭代器。
  • end(): 返回指向容器最后一个元素之后的迭代器,作为循环结束的标志。
  • 迭代器 it 每次递增 (++it),就会指向容器中的下一个元素。

方式二:结合back()和pop_back()访问(栈式操作)

这种方式会从容器末尾取出并移除元素,适用于需要消耗数据的场景(在本例中仅作演示)。

// 当容器不为空时循环
while (supplyList.size() > 0) {
    // 1. 获取容器末尾的元素(栈顶元素)
    SupplyItem& topItem = supplyList.back();
    printf("取出:地点-%s, 物品-%s, 数量-%d\n", topItem.location, topItem.name, topItem.count);

    // 2. 将末尾元素从容器中移除
    supplyList.pop_back();
}

重要提示pop_back() 会永久移除数据。在我们的补给系统中,数据需要被反复使用,因此不应采用此方法,这里仅用于展示 vector 的栈操作特性。


测试与验证

为了验证代码是否正确工作,我们可以在界面上添加一个测试按钮,并在其响应函数中写入遍历 supplyList 的代码(使用第一种迭代器方式)。

测试步骤:

  1. 在补给选项卡UI中,添加几条补给信息(例如:药店 -> 金创药(小) -> 5)。
  2. 点击“应用设置”按钮,数据将被转换并存入 vector
  3. 点击“测试”按钮,观察输出窗口是否按顺序正确打印出所有已添加的补给信息。

如果输出结果与UI设置一致,则证明我们的 vector 容器和数据转换逻辑工作正常。


课程总结

本节课中我们一起学习了以下核心内容:

  1. 设计数据结构:定义了 SupplyItem 结构体,合理规划了补给物品的信息单元。
  2. 引入vector容器:使用 vector<SupplyItem> 替代固定数组,实现了补给列表的动态、安全存储。
  3. 实现数据转换:编写了从UI控件文本中解析并填充结构体的逻辑,关键点是字符串的分割与转换。
  4. 掌握数据访问:学会了使用迭代器安全遍历 vector,也了解了 back()pop_back() 的栈式操作及其适用场景。

通过本课实践,你将 vector 这一重要的STL容器应用到了实际项目中,解决了动态数据存储的需求,为后续实现自动补给功能打下了坚实的数据基础。

课程 P97:108-回城补给-背包物品处理界面设计 📦➡️🎒

在本节课中,我们将学习如何设计游戏外挂中的两个关键界面:仓库补给列表和背包物品处理界面。我们将从修改现有代码开始,逐步添加新的界面元素和功能,最终实现一个能够管理物品来源(仓库或商店)和去向(出售或存放)的完整系统。


仓库补给列表的修改与初始化 🏪

上一节我们完成了基础界面的搭建,本节中我们来看看如何为仓库添加补给功能。

首先,我们需要打开第107课的代码,并在其基础上进行修改。核心任务是初始化仓库物品列表,并添加从仓库补给的选项。

以下是初始化仓库物品(如精创药)的关键代码片段:

// 假设的初始化代码,将物品添加到仓库列表
m_warehouseList.AddItem("精创药");
m_warehouseList.AddItem("生命药水");
// ... 添加其他物品

接着,我们需要复制之前为商店补给编写的代码结构,并修改其“去向”关键字。将原来的“从商店购买”改为“从仓库补给”,并关联到仓库的成员变量。

修改后的代码逻辑如下:

  1. 从界面获取选中的物品。
  2. 设置其来源为 仓库
  3. 将其添加到补给执行列表中。

完成代码修改后,我们编译并测试。运行游戏,注入外挂,打开界面。此时在补给列表中,除了“从药店”的选项,应该能看到新增的“从仓库”补给选项,这表明数据已成功添加到补给列表。


设计背包物品处理界面 🗂️

在实现了基础补给功能后,我们发现背包中常有许多待处理的物品(例如垃圾装备)。这些物品可能需要存回仓库或出售给商店。因此,我们需要设计一个独立的界面来处理这些操作。

由于原界面空间不足,我们决定新增一个窗口。在资源编辑器中,插入一个新对话框,将其ID命名为“物品处理”。

在这个新窗口中,我们将使用 CListCtrl 控件(而非简单的 ListBox)来以表格形式更清晰地展示物品,因为它支持网格线等更丰富的格式。

以下是界面布局的核心组件列表:

  • 物品分类:一个静态文本和一个 ComboBox 下拉框,用于筛选物品类型(如武器、防具、药品)。
  • 物品名称:一个静态文本和一个编辑框,用于输入或显示具体物品名。
  • 功能按钮:包括“添加”、“删除选中项”和“返回”按钮。

布局完成后,我们需要在代码中创建对应的类(例如 CItemProcessDlg)来管理这个新窗口。


实现列表控件与窗口管理 ⚙️

界面设计好后,我们需要编写代码使其工作。首先,要在 CItemProcessDlg 类的初始化函数中,对 CListCtrl 控件进行设置,使其显示为报表(网格)样式。

以下是初始化 CListCtrl 核心样式的关键代码:

// 获取当前样式并添加报表视图和网格线
DWORD dwStyle = m_listCtrl.GetExtendedStyle();
dwStyle |= LVS_EX_REPORT; // 设置为报表视图
dwStyle |= LVS_EX_GRIDLINES; // 添加网格线
dwStyle |= LVS_EX_FULLROWSELECT; // 设置整行选择
m_listCtrl.SetExtendedStyle(dwStyle);

// 然后插入列,如“名称”、“类型”、“操作”等
m_listCtrl.InsertColumn(0, _T("物品名称"), LVCFMT_LEFT, 100);
m_listCtrl.InsertColumn(1, _T("物品类型"), LVCFMT_LEFT, 80);
// ... 插入其他列

接下来,需要管理这个新窗口的显示与隐藏。

  1. 在主补给界面类(如 CSupplyDlg)中,添加 CItemProcessDlg 类型的成员变量 m_dlgItemProcess
  2. 在主窗口初始化时,创建 m_dlgItemProcess 对话框,并设置其父窗口为主窗口的选项卡控件,以确保它们在同一层级显示。
  3. 为“背包物品处理”按钮添加事件处理函数,在其中调用 m_dlgItemProcess.ShowWindow(SW_SHOW) 来显示处理窗口。
  4. 在处理窗口的“返回”按钮事件中,调用 ShowWindow(SW_HIDE) 隐藏自身。

经过这些步骤,我们就实现了一个弹出式的背包物品处理界面,它可以与主补给界面协同工作。


总结 📝

本节课中我们一起学习了如何扩展游戏外挂的界面功能。

我们首先修改并完善了仓库补给列表,使程序能够识别并从仓库获取补给物品。

接着,我们设计并实现了一个新的背包物品处理界面,使用 CListCtrl 控件以表格形式管理物品,并提供了分类、添加、删除等操作功能。

最后,我们完成了新窗口的创建、显示与隐藏逻辑,将其集成到现有的主界面框架中。

通过本课的学习,你应该掌握了为程序添加复杂子界面并实现其基本交互逻辑的方法。

课程 P98:109 - 修改回城补给界面和更新窗口数据到对象列表 🛠️📋

在本节课中,我们将学习如何修复一个程序错误,并设计一个物品处理结构。核心任务是将回城补给界面(NIST country控件)中的数据,更新到玩家挂机位的一个动态对象列表中。


查找并修复错误 🔍

上一节我们介绍了物品处理界面的基本操作。本节中,我们来看看如何定位并修复上一课代码中的一个潜在错误。

首先,我们打开第109课的代码。错误可能出现在处理NIST country控件的函数中。经过分析,大部分API函数调用没有参数或指针访问,出错概率低。最可能出错的地方是涉及坐标访问的代码段,它可能导致数组下标越界。

为了验证,我们先将可能出错的代码段注释掉(使用return提前返回),并修复一些编译器警告,例如添加强制类型转换和使用安全版本的函数。这样可以使调试信息更清晰。

测试显示,当我们单击列表中的某一项时,相关处理函数会被执行多次(例如三次),这既不高效,也可能因重复执行导致异常。因此,我们需要优化事件关联。

以下是优化步骤:

  1. 将处理逻辑从LVN_ITEMCHANGED消息迁移到NM_CLICK(鼠标左键单击)消息中。
  2. 在单击事件的处理函数中,获取当前点击的单元格坐标。
  3. 添加边界检查,确保坐标不越界,并且X坐标大于0(避免修改第一列的物品名标签)。

经过这些修改,代码执行效率得到提升,且错误得以修复。


设计物品处理数据结构 🧱

修复错误后,我们需要设计一个结构,用于存储界面中设置的物品处理方式。

这个结构需要包含物品名和一个DWORD类型的标记变量。标记变量利用二进制位来表示不同的处理方式:

  • 0:不处理
  • 1:存仓库(二进制第1位)
  • 2:卖商店(二进制第2位)
  • 4:不拾取(二进制第3位)

其原理是利用位运算。例如,数字 5(二进制101)表示同时选择了“存仓库”(1)和“不拾取”(4)。这种方式可以高效地在一个整数中组合多种状态。

我们定义结构体和动态数组(Vector)来存储这些数据:

// 定义物品处理结构
struct ItemProcess {
    TCHAR szItemName[256]; // 物品名
    DWORD dwFlag;          // 处理方式标记
};

// 在玩家挂机位数据中,添加一个动态数组成员
std::vector<ItemProcess> m_vItemProcessList;

将界面数据更新到对象列表 🔄

上一节我们设计了数据结构,本节中我们来看看如何将NIST country控件中的每一行数据,提取并保存到我们刚创建的std::vector容器中。

逻辑是在用户点击“应用设置”时触发。我们需要遍历控件中的所有行,提取每行的物品名和三个复选框的状态,然后组合成一个DWORD标记,最后将整个结构体存入Vector。

以下是核心代码逻辑:

  1. 获取NIST country控件的指针。
  2. 循环遍历控件的每一行(i < pList->GetItemCount())。
  3. 对于第i行:
    • 取出第0列(物品名)的文本,存入结构体的szItemName
    • 初始化dwFlag为0。
    • 取出第1列(存仓库)的文本,如果是“勾选”状态,则执行 dwFlag |= 1
    • 取出第2列(卖商店)的文本,如果是“勾选”状态,则执行 dwFlag |= 2
    • 取出第3列(不拾取)的文本,如果是“勾选”状态,则执行 dwFlag |= 4
  4. 将填充好的ItemProcess结构体通过 m_vItemProcessList.push_back(item) 加入容器。

这样,界面上的所有设置就被完整地转换并存储到了内存中的对象列表里。


测试数据读取功能 ✅

数据保存完成后,我们需要验证是否能正确地从Vector中读取并还原出设置信息。

测试方法是通过一个循环遍历m_vItemProcessList,对每个元素的dwFlag进行位与运算(&),检查特定位是否为1,从而判断当初勾选了哪些选项。

以下是测试代码的关键片段:

for (auto& it : m_vItemProcessList) {
    CString strStorage = (it.dwFlag & 1) ? _T("勾选") : _T("");
    CString strSell = (it.dwFlag & 2) ? _T("勾选") : _T("");
    CString strNoPickup = (it.dwFlag & 4) ? _T("勾选") : _T("");
    // 输出或记录 strStorage, strSell, strNoPickup
}

通过此测试,我们可以确认从界面设置到数据存储,再到数据读取的整个流程是正确无误的。这为后续根据这些设置自动处理背包物品的去向(存仓库、卖商店、丢弃)打下了基础。


总结 📝

本节课中我们一起学习了:

  1. 调试与优化:定位并修复了因事件重复触发和数组越界导致的错误,将处理逻辑关联到更高效的鼠标单击事件。
  2. 数据结构设计:创建了ItemProcess结构体,利用位运算在一个整数中紧凑地表示多种物品处理状态。
  3. 数据同步:实现了将图形界面(NIST country控件)中的数据遍历、解析,并更新到内存中的std::vector<ItemProcess>动态数组。
  4. 功能验证:通过位运算读取存储的标志,成功测试了数据保存与还原的正确性。

这些工作为自动化游戏角色物品管理功能实现了核心的数据支撑。下一节课,我们将基于这些数据,实现具体的物品处理逻辑。

课程 P99:110 - 回城补给设计与打开/关闭仓库 🧳

在本节课中,我们将学习如何设计游戏中的回城补给功能,并重点封装打开仓库和关闭仓库这两个核心操作。

概述

回城补给功能可以划分为两个主要部分。第一部分是访问仓库,进行物品的存取操作。第二部分是访问商店,进行物品的购买与出售。本节课我们将专注于仓库部分的设计与实现。

上一节我们介绍了回城补给的整体设计思路,本节中我们来看看如何具体实现打开和关闭仓库的功能。

分析打开/关闭仓库的数据包

首先,我们需要分析游戏客户端与服务器通信时,打开和关闭仓库所发送的数据包。

以下是分析步骤:

  1. 附加调试器到游戏进程。
  2. 在发包函数地址处设置断点。
  3. 在游戏中执行打开仓库和关闭仓库的操作,触发断点。
  4. 记录下断点触发时,传递给发包函数的缓冲区数据。

通过分析,我们得到了关键数据:

  • 打开仓库的缓冲区数据:55 00 18 00 01 00 00 00
  • 关闭仓库的缓冲区数据:02 00 18 00 01 00 00 00

封装打开仓库函数

有了缓冲区数据,我们可以参照之前封装“打开商店”函数的代码,来封装“打开仓库”函数。

以下是封装 OpenWarehouse 函数的代码示例:

// 函数声明 (头文件中)
BOOL OpenWarehouse(DWORD dwBase, const char* szNpcName);

// 函数定义 (源文件中)
BOOL OpenWarehouse(DWORD dwBase, const char* szNpcName) {
    // 构造打开仓库的数据包
    BYTE aOpenWarehouse[] = {0x55, 0x00, 0x18, 0x00, 0x01, 0x00, 0x00, 0x00};
    
    // 调用通用的发包函数,这里假设 SendPacket 是已封装的底层函数
    BOOL bRet = SendPacket(dwBase, aOpenWarehouse, sizeof(aOpenWarehouse));
    
    // 异常处理与日志记录
    if (!bRet) {
        // 记录错误信息:打开仓库失败
    }
    return bRet;
}

封装关闭仓库函数

同样地,我们封装关闭仓库的函数。

以下是封装 CloseWarehouse 函数的代码示例:

// 函数声明 (头文件中)
BOOL CloseWarehouse(DWORD dwBase);

// 函数定义 (源文件中)
BOOL CloseWarehouse(DWORD dwBase) {
    // 构造关闭仓库的数据包
    BYTE aCloseWarehouse[] = {0x02, 0x00, 0x18, 0x00, 0x01, 0x00, 0x00, 0x00};
    
    // 调用通用的发包函数
    BOOL bRet = SendPacket(dwBase, aCloseWarehouse, sizeof(aCloseWarehouse));
    
    // 异常处理与日志记录
    if (!bRet) {
        // 记录错误信息:关闭仓库失败
    }
    return bRet;
}

功能测试

函数封装完成后,我们需要编写测试代码来验证其功能是否正常。

以下是测试流程:

  1. 初始化游戏对象列表(例如怪物列表)。
  2. 与仓库NPC对话。
  3. 调用 OpenWarehouse 函数打开仓库。
  4. 执行仓库物品存取操作(例如,调用已封装的 GetItemFromDepot 函数)。
  5. 调用 CloseWarehouse 函数关闭仓库。

测试代码示例如下:

void TestWarehouseFunctions() {
    // 1. 初始化
    GetNpcList(); // 假设此函数用于初始化NPC列表
    
    // 2. 与NPC对话 (需要先定位到仓库NPC)
    // OpenNpcDialog(dwNpcId); 
    
    // 3. 打开仓库
    OpenWarehouse(g_dwGameBase, "仓库管理员");
    
    // 4. 从仓库取物品 (例如:金创药(小),每次取2个)
    GetItemFromDepot(g_dwGameBase, "金创药(小)", 2);
    
    // 5. 关闭仓库
    CloseWarehouse(g_dwGameBase);
}

通过多次执行取物品操作,可以观察背包内物品数量的变化,从而验证整个流程是否畅通。

总结

本节课中我们一起学习了回城补给功能中仓库模块的实现。我们首先分析了打开和关闭仓库的网络数据包,然后基于这些数据封装了 OpenWarehouseCloseWarehouse 两个关键函数,并完成了功能测试。

目前我们封装的是直接调用的函数版本。下一节课,我们将把这些功能进一步封装成通过窗口消息触发的形式,以便更好地集成到自动化主线程中,使代码结构更清晰、更易于管理。

课后作业

请尝试完成以下练习:

  1. 根据示例,在您的工程中成功实现并测试打开与关闭仓库的功能。
  2. (进阶)思考并尝试将 GetItemFromDepot(从仓库取物)函数也改造成通过发送消息来调用的形式。
posted @ 2026-02-05 08:55  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报