大航海时代online v12005 遍历周围人物算法分析

2021-07-31 357点热度 0人点赞 5条评论

最近突然对大航海又来了兴趣,故捡起了AFK 10年的大航海时代online。顺便也研究一下汇编、自动化脚本,提高技术知识。

前期工作

10年前就阅读过遍历人物思路,以及写过遍历周围人物代码。10年前的思路如下:

qingye在以前发过《几个关于对象的地址》,用这地址可得到时TAB选择对象时(NPC、玩家或场景)的代码(ID)。最近学习OD,参考zard110用商品ID得到商品名的思路及OD的使用方法,经过几天的跟踪解析,终于搞清了算法,由NPC或玩家的ID得到了名字。具体算法如下:
   1、先给出个基址:addrbase=&H00af89ac
   2、读取基址的内存值记为A
   3、对象ID除于16,取整数,记为C 
   4、C除于[基址+4]的内存值,取余数,记为B
   5、[A+B*4]得到addr
   6、[addr]<>ID的话,再取[addr+8]的值作为addr,再继续这一步计算,直到[addr]=ID
   7、[[[addr+4]+3c]+0]的值就是TAB选择对象的名字

因为游戏内存结构一般不会变化,可以从现在版本里,其他好查找的内存地址入手,推测出现在的基址是:0x0119E630。但是把脚本里的基址改了以后,脚本无法运行了。看来是10年来游戏的内部结构变化还是很大,需要重新找算法。

首先观察一下0119E630这个地址的数据

F0F4F8FC
0119E63010437FC0000000110000000E17D4B8E4
内存地址

经过一些操作比如换场景、进出港口,可以观察出来:每次换场景,除了0119E634不变,其他3个地址的值先变0,再变有数值。其中0119E638是场景的对象数量,0119E63C每次对象数量变化,也会跟着变化。

正式开始

从基址倒推

使用CheatEngine,在0119E630下“找出是什么访问了地址”,之后CE会立刻弹出来信息框,显示有好几条地址不停的在访问0119E630。

由于之前看到人数是实时变化的,那么程序应该无限循环读取基址。所以我们看看计数最多的2条0597066和0599BD8。

00597066

00597066这个地址的代码,上面是好几条跳转指令,而且还有xmm0这种浮点数运算指令,从经验上来说,不太可能。因为基址是一个指向其他内存地址的数值,对象ID也肯定是正整数,即便基址需要经过运算,那也一定是正整数的运行,因为其他内存地址也一定是正整数。

00599BD8

00599BD8这个地址,代码位于函数开头,直接读取基址数值,然后判断跳转,看起来比较像。为什么呢?因为基址之所以是“基址”,就是基址是需要再经过二次运算,才能找到对象ID之类的数据,那么基址的运算一般是位于函数最开头部分的。

用x64dbg跟踪

基址代码段解析

现在换x64dbg进一步跟踪。直接CTRL+G跳转到00599BD8,下断点。这个代码段不多,但我水平不高,也耗费了不少时间分析。这里我就直接贴分析结果了。

00599BD0 | 55                | push ebp                            | 根据活动对象基址计算活动对象内存指针的函数
00599BD1 | 8BEC              | mov ebp,esp                         |
00599BD3 | 53                | push ebx                            |
00599BD4 | 56                | push esi                            |
00599BD5 | 57                | push edi                            |
00599BD6 | 8BF9              | mov edi,ecx                         |
00599BD8 | 8B5F 04           | mov ebx,dword ptr ds:[edi+0x4]      |
00599BDB | 85DB              | test ebx,ebx                        |
00599BDD | 74 6B             | je gvo.599C4A                       |
00599BDF | 8B75 08           | mov esi,dword ptr ss:[ebp+0x8]      | 读取堆栈的堆栈,也就是上次计算出来的下一个节点地址,也就是当前的节点地址
00599BE2 | 8B36              | mov esi,dword ptr ds:[esi]          |
00599BE4 | 85F6              | test esi,esi                        |
00599BE6 | 74 67             | je gvo.599C4F                       |
00599BE8 | 83FE FF           | cmp esi,0xFFFFFFFF                  |
00599BEB | 75 1B             | jne gvo.599C08                      |
00599BED | 8B57 08           | mov edx,dword ptr ds:[edi+0x8]      | [edi+0x8]=11 固定值
00599BF0 | 33C9              | xor ecx,ecx                         | 计数器置0
00599BF2 | 85D2              | test edx,edx                        |
00599BF4 | 74 12             | je gvo.599C08                       |
00599BF6 | 8BC3              | mov eax,ebx                         |
00599BF8 | 8B30              | mov esi,dword ptr ds:[eax]          | 循环读取eax=eax+4;esi=eax,直到esi值!=0
00599BFA | 85F6              | test esi,esi                        |
00599BFC | 75 0A             | jne gvo.599C08                      |
00599BFE | 41                | inc ecx                             |
00599BFF | 83C0 04           | add eax,0x4                         |
00599C02 | 3BCA              | cmp ecx,edx                         |
00599C04 | 72 F2             | jb gvo.599BF8                       |
00599C06 | EB 4C             | jmp gvo.599C54                      |
00599C08 | 8B4E 08           | mov ecx,dword ptr ds:[esi+0x8]      | 下一个节点的地址
00599C0B | 85C9              | test ecx,ecx                        |
00599C0D | 75 20             | jne gvo.599C2F                      |
00599C0F | 8B7F 08           | mov edi,dword ptr ds:[edi+0x8]      |
00599C12 | 8B46 0C           | mov eax,dword ptr ds:[esi+0xC]      |
00599C15 | 33D2              | xor edx,edx                         |
00599C17 | F7F7              | div edi                             |
00599C19 | 42                | inc edx                             |
00599C1A | 3BD7              | cmp edx,edi                         |
00599C1C | 73 11             | jae gvo.599C2F                      |
00599C1E | 8D0493            | lea eax,dword ptr ds:[ebx+edx*4]    | 当前[0119e630]再往后edx(从0开始算)个,直到有数据
00599C21 | 8B08              | mov ecx,dword ptr ds:[eax]          |
00599C23 | 85C9              | test ecx,ecx                        |
00599C25 | 75 08             | jne gvo.599C2F                      |
00599C27 | 42                | inc edx                             |
00599C28 | 83C0 04           | add eax,0x4                         |
00599C2B | 3BD7              | cmp edx,edi                         |
00599C2D | 72 F2             | jb gvo.599C21                       |
00599C2F | 8B45 08           | mov eax,dword ptr ss:[ebp+0x8]      |
00599C32 | 5F                | pop edi                             |
00599C33 | 8908              | mov dword ptr ds:[eax],ecx          | 链表下一个节点地址放到0019F8F4
00599C35 | 8B0E              | mov ecx,dword ptr ds:[esi]          |
00599C37 | 8B45 0C           | mov eax,dword ptr ss:[ebp+0xC]      |
00599C3A | 8908              | mov dword ptr ds:[eax],ecx          | ecx:L"\n"
00599C3C | 8B4E 04           | mov ecx,dword ptr ds:[esi+0x4]      |
00599C3F | 8B45 10           | mov eax,dword ptr ss:[ebp+0x10]     |
00599C42 | 5E                | pop esi                             |
00599C43 | 8908              | mov dword ptr ds:[eax],ecx          | 上层的EBP+8赋值
00599C45 | 5B                | pop ebx                             |
00599C46 | 5D                | pop ebp                             |
00599C47 | C2 0C00           | ret 0xC                             |
00599C4A | E8 30D97800       | call gvo.D2757F                     |
00599C4F | E8 2BD97800       | call gvo.D2757F                     |
00599C54 | E8 26D97800       | call gvo.D2757F                     |

代码用python翻译一下大概意思如下:

# sub599BD0部分
ecx = 0x0119E62C
edi = ecx
ebx=[0119E630]
if ebx !=0:
    esi = [[ebp+8]] or 0xFFFFFFFF  # 从上次循环读取esi的值,或者当第一次循环时esi=FFFFFFF
    if esi !=0:
        if esi == 0xFFFFFFFF:
            edx = [0119E634]  # 循环次数 =11
            ecx = 0x0  # 计数器
            if edx !=0:
                eax = ebx
                while ecx < edx:
                    # 循环,读取[0119E630]之后首个有数据的地址。也即链表首个节点的内存地址
                    esi = [eax]
                    if esi == 0:
                        ecx += 1
                        eax = eax + 0x4
                    else:
                        break
        # 此时ESI是链表当前节点的内存地址。[ESI]=当前对象ID。[ESI+8]=链表下一个节点
        ecx = [esi+0x8]
        # 如果ecx==0也就是链表下一个节点=0,则进一步处理
        if ecx ==0:
            edi = [edi+0x8]  # 固定值11
            eax = [esi+0xC]  # 地址计算所需的数据
            edx = 0  # 计数器
            eax = eax / edi
            edx = eax mod edi  # 余数放edx  是个<0X11的数 其实就是[119e630]往后读了多少次
            edx += 1
            if edx < edi:
                eax = [ebx+edx*4]  # 当前[0119e630]再往后edx(从0开始算)个,直到有数据
                while edx < edi:
                    ecx = [eax]
                    if ecx ==0:
                        edx += 1
                        eax = eax +0x4
        # 链表下一个节点地址ecx放到0019F8F4
        # eax = dword ptr ss:[ebp+8]=[0019F8E0]=0019F8F4
        ecx = [esi]  # 这里是对象ID
        # ecx = 放到堆栈0019F904
        ecx = [esi+4] # 这里是对象数据地址指针
        # ecx = 放到堆栈0019F8F8

这个函数是根据基址计算第一个数据内存地址、或者根据当前数据地址计算下一个数据内存地址的函数。代码主要分两部分,if esi == 0xFFFFFFFF部分是第一次循环时,esi=FFFFFFF,再读取[0119E630]之后首个有数据的地址。也即链表首个节点的内存地址;或者读取上次循环的值。

之后再ecx = [esi+0x8]读取下一个数据的值。如果[esi+0x8] == 0,那再进行一下除法和余数什么的运算,读当前[0119e630]再往后edx(从0开始算)个,直到有数据。

再由上层代码来控制循环结构,追踪后发现是固定循环[0119E634]次。

也就是说,程序可能按照不同对象的属性分类,一共有17条链表,每条链表里面有不定数量的节点,每个节点就是一个活动对象。

链表数据结构

用CE打开[0119E630]的内存区域看看。

数据

这明显是一个链表数据。第一个4字节是当前对象ID,第二个4字节是对象详细数据的内存指针,里面有坐标、海拔、中文名之类的、第三个4字节是下一个链表节点的内存地址。第四个4字节是当下一个链表节点数值为0时,用来重新计算链表节点内存地址的(eax = [ebx+edx*4])。

对象详细数据分析

这一块不详细写了,用x64dbg跟着一步步F7/F8就走到了。

活动对象详细数据结构

直接上图,005E14A0就是处理人物详细数据的函数。ESI的16F7DF08就是当前要处理的人物数据结构的起始内存地址。

起始内存地址的值始终为00EE0C00(不知道为什么)。之后是对象ID,[+2C]是中文名指针,下面是坐标。其他的数据没研究出来有什么名堂。

程序自己处理中文名的流程也是非常复杂严谨:

005DFDA5是否有中文名的判断流程
if [0119E66C+4] !=0
  if [[0119E66C+4]+1C] == 0xB
    eax = 0
if eax == 0
  if [119e030+36] != 30
    ecx = esi = 0119E030
    eax = [ecx+510]
    eax = eax and 0x1
    if eax == 1
      ecx = esi = 0119E030
      eax = [ecx+510]
      eax = eax shr 0x1
      eax = eax and 0x1
      if eax ==1
        eax = [esi] =[119e030]
        #  004F89A5 | FF50 0C           | call dword ptr ds:[eax+0xC]        |
	eax = [ecx+0x4] = [119e030+4]
        if eax != 0
          al = [esi+5D1] = [119e030+5D1]
          al = not al
          movzx eax,al
          eax = eax shr 1
          eax = eax and 1
          if eax != 0
            中文名 = [esi+2c]

总结

最开始代码尝试用按键精灵写,但自从学会python后,回过头再用按键精灵,发现按键精灵不仅程序响应慢、功能极少、文档缺失,最关键由于航海是UTF16编码的,按键精灵各种进制转换、编码转换浪费了我一大半编写时间。所以以后我换python写脚本了。

顺便推荐两个python库,写脚本必备:pywinauto后台键鼠库,pymem读写内存库。这两个库基本都是对win32api的再封装,有了这两个库,连大漠都不需要了。

遍历思路

航海程序自己遍历有非常多的限制和分支,我开始模仿航海程序写脚本,头都大了。后来我发现我可以暴力一点:直接从对象基址0119E630读取链表地址,然后暴力读取每一条链表不就行了吗?至于中文名,再暴力读取每一个节点的中文名地址,如果地址数值>0,那不就肯定是有中文名吗?

当然这样无法区分NPC、玩家、以及书台这种可互动的对象。但那没关系,我们只要知道附近的情况已作出判断就好了。

读人名代码

从我python脚本里复制出来的,我已修改,直接能用。

效果如图(战列舰里斯本人就是多!)

import pymem

PID = 1234  # 你需要自己获取指定航海的PID
pm = pymem.Pymem()
pm.open_process_from_id(PID)


def readaddr(pymem_instance, address: int, data_type: str = None, offset_list: list = None):
    # 如果有偏移地址,则循环读取偏移,得出最终要读的内存地址。没有偏移地址,最终地址=基址+地址
    if offset_list is not None:
        try:
            len(offset_list)
        except TypeError:
            return None
        else:
            if len(offset_list) > 0 and type(offset_list) == list:
                offset_num = 1
                for offset in offset_list:
                    address = pymem_instance.read_int(address) + offset
                    offset_num += 1
            else:
                return None

    if data_type == "unicode":
        result = ""
        _temp_address = address
        while True:
            _temp = pymem_instance.read_bytes(_temp_address, 2)
            if _temp == b'\x00\x00':
                break
            else:
                try:
                    _temp = _temp.decode('utf-16')
                except UnicodeDecodeError:
                    _temp = ascii(_temp)
                finally:
                    result += _temp
                    _temp_address += 2
    elif data_type == "float":
        result = pymem_instance.read_float(address)
    elif data_type == "hex":
        result = hex(pymem_instance.read_int(address)).upper()
    else:
        result = pymem_instance.read_int(address)

    return result


def enum_liveobj() -> tuple:
    """
    枚举附近的活动对象

    :return: tuple
        枚举出来的附近所有活动对象,包括没名字的、非人类、人类等;
        活动对象数量;
        人类对象数量;
    """
    liveobj_list = []
    found_num = 0
    human_num = 0
    baseaddr = 0x0119E630
    for i in range(readaddr(pm, baseaddr + 4)):  # +4是算法内置的循环次数
        nodeaddr = readaddr(pm, readaddr(pm, baseaddr) + i * 4)
        if nodeaddr == 0:  # [baseaddr]为0,下一个循环
            continue
        temp_list = []
        while True:
            ID = readaddr(pm, nodeaddr)
            data = readaddr(pm, nodeaddr + 4)
            name = readaddr(pm, nodeaddr + 4, data_type='unicode', offset_list=[0x2c, 0])
            cur_pos_x = readaddr(pm, nodeaddr + 4, data_type='float', offset_list=[0x13c])
            cur_pos_y = readaddr(pm, nodeaddr + 4, data_type='float', offset_list=[0x144])
            cur_altitude = readaddr(pm, nodeaddr + 4, data_type='float', offset_list=[0x140])
            dst_pos_x = readaddr(pm, nodeaddr + 4, data_type='float', offset_list=[0x15c])
            dst_pos_y = readaddr(pm, nodeaddr + 4, data_type='float', offset_list=[0x164])
            dst_altitude = readaddr(pm, nodeaddr + 4, data_type='float', offset_list=[0x160])
            next = readaddr(pm, nodeaddr + 8)
            if len(name) > 0:
                temp_list.append(
                    {'ID': ID,
                     'name': name,
                     '当前坐标X': cur_pos_x,
                     '当前坐标Y': cur_pos_y,
                     '目的坐标X': dst_pos_x,
                     '目的坐标Y': dst_pos_y,
                     '当前海拔': cur_altitude,
                     '目的海拔': dst_altitude,
                     }
                    )
                human_num += 1
            found_num += 1
            if next == 0:
                liveobj_list.append({i: temp_list})
                break
            else:
                nodeaddr = readaddr(pm, nodeaddr + 8)

    return liveobj_list, found_num, human_num

liveobj_list, found_num, human_num = enum_liveobj()
print(liveobj_list)
print(f'一共 {found_num} 个活动对象,{human_num} 个人类')

wking

不管博客型博主

文章评论

  • 小鱼干

    最近在研究航海找call,下了bp WSASend断点gvo没断下来 0.0
    博主能否出个找从码头进城里的call教程呢

    2021-08-05
    • wking

      @小鱼干 AD9CF0就是码头进城CALL了。航海用的不是WSAsend,你用bp ws2_32.send就可以断下来。不过有很多心跳包你得过滤

      2021-08-12
  • 小鱼干

    请问博主,除了ctypes win32api模块调用call之外,如果用pymem调用的话,有什么好的办法么?
    在查询了pymem的资料后,发现有pm.assemble,但是测试发现不可用

    2021-08-13
    • wking

      @小鱼干 pymem调用汇编代码不好使。我用的 这个

      2021-08-13