Nook in the Lunar Mare

Noah Fantasy诺亚幻想/诺亚学园的墓碑

Nov 6, 2020

手头有两个版本的apk,最早的那个仅有几十MB,从pureApk上毛来的。作为备用资源。

音乐和美术资源

分析

用jadx对apk进行解包,找到resources文件夹。其下的assets/res-encrypt按英雄、场景等划分文件夹,包含大量png资源,只是里面的png都无法打开,文件夹命名为res-encrypt,应是对资源进行加密了。不过Audio部分都是明文形式,可以直接拿。

asset_dir

加密数据一般在二进制层面可能存在一些特征,因此找了comics下的文件打开看看

$ r2 101.png
[0x00000000]> px
- offset -   0 1  2 3  4 5  6 7  8 9  A B  C D  E F  0123456789ABCDEF
0x00000000  7361 6e70 6a67 616d 6573 366c 0eec 902d  sanpjgames6l...-
0x00000010  a4b1 9607 180c 7ab2 1e7a e519 faaf 45f0  ......z..z....E.
0x00000020  7d60 782c 2574 3db4 c260 3e65 5248 6bb1  }`x,%t=..`>eRHk.
0x00000030  d2de 2a2e ccca e830 9e12 16a7 9b86 c500  ..*....0........
0x00000040  0395 5738 e3f4 fc06 70e7 552d 206a 4705  ..W8....p.U- jG.
0x00000050  98ba 125e 75d8 f771 b528 fac8 d110 94dc  ...^u..q.(......
0x00000060  9fc2 0f8d c027 f86f 2ec4 87a0 b5f3 7665  .....'.o......ve
0x00000070  79ce 4c83 1613 a082 cf57 47ec 9c76 6c16  y.L......WG..vl.
0x00000080  4064 6286 ec2e 9d86 4850 d61d aa72 ad06  @db.....HP...r..
0x00000090  e4cd c746 7b0d acd7 2a36 10e3 dae9 0146  ...F{...*6.....F
0x000000a0  e73e 91ea 0b23 586c 98d2 2475 6b82 5f33  .>...#Xl..$uk._3
0x000000b0  87fb 8be3 190c db2a 9d8b 214a e64c ce2a  .......*..!J.L.*
0x000000c0  bdb2 af4c 61bd 0407 cdb1 9b1d c68d 18ae  ...La...........
0x000000d0  1e48 0eb8 87f3 33a2 6cdc b1a8 8394 62a5  .H....3.l.....b.
0x000000e0  d1db cdee df50 9d02 0f51 3930 a644 568d  .....P...Q90.DV.
0x000000f0  54c2 3d89 48d8 9ed6 5a74 376a c0ca 5418  T.=.H...Zt7j..T.

开头很明显地找到了sanpjgames,搜索结果直接定向至一篇博客,直接使我从0了解到cocos2d的默认加密方式,后续又读了引用中的几篇文章,确认最新版本中依旧对资源采用的cocos2d默认xxtea。

解决方案

确认一下key,由于有前人对默认加密的分析,这一步变得很容易,实际上就是找libcocos2dlua.so里的字符串。

cd resources/lib/armeabi
rabin2 -z libcocos2dlua.so > coco.txt
head coco.txt 
[Strings]
nth   paddr      vaddr      len  size section type    string
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
0     0x01473f00 0x01473f00 4    5    .rodata ascii   main
1     0x01473f05 0x01473f05 22   23   .rodata ascii   cocos_android_app_init
2     0x01473f1c 0x01473f1c 7    8    .rodata ascii   PetGirl
3     0x01473f24 0x01473f24 31   32   .rodata ascii   GL_NV_shader_framebuffer_fetch 
4     0x01473f44 0x01473f44 14   15   .rodata ascii   threepjsecrets
5     0x01473f53 0x01473f53 10   11   .rodata ascii   sanpjgames
6     0x01473f5e 0x01473f5e 33   34   .rodata ascii   res-encrypt/cocos_precompiled.zip

sanpjgames上面就是key

可以直接用Ref1里的脚本批量解密,也可以写shell脚本,这里的shell脚本是Ref2中wmsuper编写的。进行批量处理这种与系统交互的轻量工作用shell是真的方便,这里限定了使用场景为.luac后缀。

#!/bin/sh
process_file(){
        for file in `ls -a $1`
        do
                if [ x"$file" != x"." -a x"$file" != x".." ];then
                 if [ -d "$1/$file" ];then
                         process_file "$1/$file" $2 $3
                 else
                         #You should backup your file
                         if [ x"${file##*.}" = x"luac" ];then
                           ./lua_decrypt "$1/$file" "$1/$file" $2 $3
                         fi
                 fi
                fi
        done
}
if [ $# != 3 ]; then
   echo "error..  example:\ndecode.sh srcdir sign key"
   exit 1
fi
process_file $1 $2 $3

若使用Ref1里的key和sign进行解密的。C文件也要更改一下key和sign,重新编译(以后修改一下这两个文件,采用命令行传参)。

# coding=utf-8
from __future__ import print_function
import os
import argparse
import glob
import fnmatch


def valid(input_path):
    file = open(input_path, 'rb')
    magic = file.read(10)
    print(magic)
    return magic == b'sanpjgames'


def decrypt(input_path):
    path, file = os.path.split(input_path)
    file_split = file.split('.')
    if len(file_split) == 1:
        fname = file_split[0]
        ext = ''
    else:
        fname = file_split[0]
        ext = '.' + file_split[1]
    output_path = os.path.join(path, fname + '_decrypt' + ext)
    if not valid(input_path):
        print('[x] Not an encrypted file: {}'.format(input_path))
        return
    if os.name == 'posix':  # Unix system need prefix
        os.system('./decrypt {} {}'.format(input_path, output_path))
    else:
        os.system('decrypt {} {}'.format(input_path, output_path))


def find_files(directory, pattern):
    for root, dirs, files in os.walk(directory):
        for basename in files:
            if fnmatch.fnmatch(basename, pattern):
                filename = os.path.join(root, basename)
                yield filename


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        'file', help='Path of file or directory to be decrypted', type=str)
    p = parser.parse_args()
    path = p.file
    if os.path.isfile(path):
        decrypt(p.file)
    else:
        if not path.endswith(os.path.sep):
            path += os.path.sep
        flist = find_files(path, '*.luac')
        for f in flist:
            decrypt(f)


if __name__ == '__main__':
    main()

没找到水母姐姐,放只米斯特上来

kanban

Reference

1 live2d extract, Nice ! -> 脚本地址

2. 总结Cocos2d的默认加解密,提供解密工具

3. 解密deemo图片的例子

4. 解密血族手游的例子

5. Apk解密时常用工具

文本资源

本节的内容其实是以下面的内容为基础的,个人喜好原因,排版时提前。 诺亚的剧情很棒,解包的最初目的就是想看剧情。

解析

文本在shared/conftable下,内嵌在lua脚本里。反编译后可以直接看到。打算直接正则匹配

import re

# accept keys, alist contains key words to parse lua
# return list of re compiled object 
def create_reC(keys):
    result = []
    pattern_template = '{} *= *["\[](.*?)["\]],' # xxx = "yyyyy -- etc" or "]"

    for key in keys:
        cur_pattern = pattern_template.format(key)
        rec = re.compile(cur_pattern, re.MULTILINE+re.DOTALL)
        result.append(rec)

    return result

def create_reC_general(keys):
    result = []
    pattern_template = '{} *= *(.*),' # xxx = yyyyy -- etc.

    for key in keys:
        cur_pattern = pattern_template.format(key)
        rec = re.compile(cur_pattern)
        result.append(rec)

    print(result)
    return result


def find(string, keywords):
    result_dict = {}
    recs = create_reC(keywords)

    for i in range(len(keywords)):
        rec = recs[i]
        result = re.findall(rec, string)
        result_dict[keywords[i]] = result
    
    return result_dict

解析book

import sys
from read_lua import *

def bookfy(titles, contents, types):
    books = [{},{},{}]

    # define types
    for i in range(len(titles)):
        cur_title = titles[i]
        cur_type = int(types[i].replace(' ', '')[0])

        book = books[cur_type - 1]
        
        if cur_title in book:
            t = cur_title.replace('"', '')
            book[t] = book[cur_title] + contents[i].replace('[', '').replace('"','').replace(']','')
        else:
            t = cur_title.replace('"', '')
            book[t] = contents[i].replace('[','').replace(']','')
    
    return books

if __name__ == '__main__':

    if(len(sys.argv) < 3 ):
        print("Usage:\n 1. python extract.py [book index] [lua filename]")

    filename = sys.argv[2]
    book_index = int(sys.argv[1])

    with open(filename, 'r') as f:
        data = f.read()

    keys = ["content", "tittle", "res"]
    res = find(data, keys)

    books = bookfy(res['tittle'], res['content'], res['res'])
    book = books[book_index]

    for title in book:
        print(title)
        print(book[title])

图书馆中的手册

探索记录(监管纪要)和风土人情暗含游戏主线。摘录最后一段探索的剧情(略微排版):

未知结界与异动
●灰岩镇东郊
    ▲面对突然笼罩在失落之原上空的迷之结界,我们终于找到了临时打开结界的方法。
    ▲位于失落之原南部的灰岩镇东边的主干道上,突击小组发现了大量徘徊的傀儡聚集。道路四周的惨状难以直视,看起来曾经发生过极为惨烈的战。
    ▲在东郊的灰岩矿坑遭遇傀儡克梵诺、布拉德,她们似乎也被困在了结界中,目前正在寻找结界的“主人”。
    ▲值得注意的是,她们似乎正在调查地上的某个衣着华丽的红衣傀儡。
    ▲经确认,灰岩镇的东面、北面的主干道上均有大量的游荡傀儡徘徊。但是,傀儡们似乎并没有进一步动作的打算。
    ▲从灰岩镇镇长洛伦那里得知:事发当时突然出现的傀儡们封锁了灰岩镇四周的主干道,情势十分危险,无奈之下才选择武装突围。只可惜事与愿违…

●红石镇&洛奇山
    ▲一路上,很多原本安静的野生动物们纷纷变得躁动不安、并开始袭击路人。▲在失落之原最北方的红石镇,不仅傀儡,连荒蛮原人都直接现身于这里。被破坏的城镇街道场面十分混乱、血腥。可以说,这里是目前为止在失落之原异动中遭受破坏最严重的地方。
    ▲红石镇某处,突击组意外地发现:某个身着红衣的无脑傀儡和一群荒蛮原人交战了起来。虽然这场闹剧在无可抗拒的外力作用下戛然而止,场面依然令人深思。
    ▲突然现身于红石镇的傀儡露比和芙尔斯声称是追寻着傀儡克梵诺的足迹来到失落之原的。据她们提供的情报判断,最近大陆各地的傀儡越来越少很可能和克梵诺的诡异行踪脱不了干系。
    ▲突击组推测——所谓的“主人”设下结界正是为了将这里化为一座孤岛并肆意暴虐,大概是个极度扭曲的家伙。▲突击组在红石镇某处发现了当地的秘密避难地窖。目前镇中的绝大多数居民都躲藏在那里,状况还算稳定,但附近的傀儡和荒蛮原人的清理工作依然十分严峻。

●拉什矿坑
    ▲在拉什矿坑外围,突击组再度发现了十数名活跃的荒蛮原人。考虑到荒蛮原人喜暗的特性,这种状况十分不合理。
    ▲突击组推测,荒蛮原人以及其它野生生物的异常生理行为很可能和失落之原上空的结界对光的屏蔽作用有关。
    ▲目前来看,拉什矿坑及附近的庇护营的惨状并不比红石镇好多少。相比之下,西边的洛奇山和南部的灰岩镇的状况明显要好很多。
    ▲拉什矿坑东面的山峰上,突击小组遭遇了死而复生的红衣傀儡攻击。从现场的情形看,红衣傀儡似乎正代表某个所谓的“主人”执行制裁,明显与克梵诺身边的无脑傀儡不同。
    ▲突击组进一步推测——或许红衣傀儡和它的“主人”设下结界是因为发现有不明身份的外来傀儡入侵,那么他们应该更像是失落之原的守护者一样的存在了。
    
●圣地克罗斯
    ▲据悉,位于失落之原中心地带的克罗斯山地下有一个很大的迷宫,迷宫的尽头隐藏着控制失落之原结界的开关。或许,“主人”正是在这里干涉了开关,才制造出了笼罩在失落之原上空的结界。
    ▲越接近山顶,荒蛮原人的数量就越多。结界干涉导致荒蛮原人生理习性失衡的推测也进一步得到了验证。同时这个结论也反过来证明了“主人”就在附近了。
    ▲迷之“主人”自称大蛇,自诩为失落之原的主宰——既不是疯狂的暴虐着,也不是失落之原的守护者,而是一切的法则。虽然她展现出了极为恐怖的破坏力,最终在还是在突击组的共同努力、尤其是辻次正同学的大活跃下,被突击组就地执行了处决裁定。
    ▲由于突击组晚一步到达山顶,很遗憾错过了傀儡们和大蛇的对峙,双方恩怨的详细情况并不清楚。但有一点是肯定的——傀儡之间似乎也存在微妙的嫌隙。
    ▲基于突击组搜集到的全部情报,目前可以做出如下推演:大蛇唤醒了她的仆从并制造了失落之原上空的结界。然而,大蛇自身从何而来依然成疑。
    ▲目前失落之原周围的结界虽然尚未消失,力量已经越来越弱,完全消失应该只是迟早的事情。异动的紧急处理暂时告一段落,但是失控傀儡以及荒蛮原人的清理工作依然棘手,失联的小组同样令人担心,需要处理的事情还有很多很多。
    ▲据推测,傀儡克梵诺等人应该在很早以前就秘密潜入失落之原并执行着某个“计划”。“计划”很可能与这次的矿难以及不明结界有关。
    ▲从她的只言片语及临场表现看,计划的幕后黑手似乎另有其人,暂不排除是失落之原的地方势力所为。另外,傀儡们是如何做到大规模进入失落之原的同样令人感到困惑。

●失联的小组
    ▲经过千辛万苦的搜索、打探、联络,终于顺利找到了全部4名失联的学员。
    ▲据4人回忆,当时飞空艇在突如其来的结界下失去了平衡,坠毁在了失落之原某处。小组先后经历了坠机后的失散、在拉什矿坑重聚、营救矿工、再度分散寻求救援并寻找结界的原因、重新集合共同对抗突然降临的傀儡、以及最后四散前往失落之原各地支援协助等一系列惊心动魄的过程。
    ▲从她们那里得到的情报和后续纷至沓来的调查组别无二致,看来我们对这次异动的整体掌握情况还差得很远很远。

怪物手册也可相应解出,作为设定补充。怪物文本的排列方式是三段式,每段累增,随着探索程度加深解锁。

大蛇

在拉什矿坑矿难事件后突然出现在失落之原的迷之傀儡。浑身上下萦绕着神秘的魔力气息,给人以极强的压迫感。据说已经沉睡了很长很长的时间,诞生的年代现已经不可考。\n性格十分高傲,如其所言视所见的一切皆为蝼蚁。言语中完全察觉不到任何感情波动,让人感到毛骨悚然、不寒而栗

在拉什矿坑矿难事件后突然出现在失落之原的迷之傀儡。浑身上下萦绕着神秘的魔力气息,给人以极强的压迫感。据说已经沉睡了很长很长的时间,诞生的年代现已经不可考。
性格十分高傲,如其所言视所见的一切皆为蝼蚁。言语中完全察觉不到任何感情波动,让人感到毛骨悚然、不寒而栗。
不知为何没有右臂,只以左臂挥舞着名为草薙剑的武器战斗。力量非常强大,甚至可以割裂空间并召唤出奇怪的幻化之物。据调查,矿难发生后笼罩在失落之原上空的迷之结界就是她的杰作。
从她的右臂的断面推测来看,很可能曾经经历过惨烈的战斗或者力量的暴走。或许在遥远的过去,她尚且“活着”的时候,也曾经出现过类似的“失落之原事件”。如果这个推测能得到印证的话,傀儡意识的存在就并非空穴来风,而是古已有之了。

在拉什矿坑矿难事件后突然出现在失落之原的迷之傀儡。浑身上下萦绕着神秘的魔力气息,给人以极强的压迫感。据说已经沉睡了很长很长的时间,诞生的年代现已经不可考。
性格十分高傲,如其所言视所见的一切皆为蝼蚁。言语中完全察觉不到任何感情波动,让人感到毛骨悚然、不寒而栗。
不知为何没有右臂,只以左臂挥舞着名为草薙剑的武器战斗。力量非常强大,甚至可以割裂空间并召唤出奇怪的幻化之物。据调查,矿难发生后笼罩在失落之原上空的迷之结界就是她的杰作。
从她的右臂的断面推测来看,很可能曾经经历过惨烈的战斗或者力量的暴走。或许在遥远的过去,她尚且“活着”的时候,也曾经出现过类似的“失落之原事件”。如果这个推测能得到印证的话,傀儡意识的存在就并非空穴来风,而是古已有之了。
据傀儡克梵诺称,其灵魂的来源是她正在寻找并收集的“那位大人”残余的13份灵魂之一,不知此事是否和两年前的傀儡讨伐作战有所关联。

成就系统(包含Medal部分以及Player Style)

修改部分代码,记录一些有趣的成就。

工作我就输了!---累计超过7天没有上线
不打卡就输了!---连续100天打卡出勤
夜生活是自己的!---连续7天,都是白天上线游戏
夜生活才刚刚开始…---连续7天,都是晚上上线游戏
又双叒叕…---累计进出学员们的房间100次
噫——---连续7天,每天都进入过学员的房间
搬运社萌新---首次使用分享功能
搬运社社长---累计分享超过100次
神秘代码Get!---使用兑换码兑换一次
温泉基地建成!---解锁琥珀之泉的所有位置
究竟哪个精灵最本命呢?---解锁源之屋所有的共鸣石锻造间
一粒灰尘都别留!---解锁所有的值日任务栏
迷迷糊糊开始的异世界生活~---触发全部的强制新手教学
拥抱我自己!---在瑞亚的带领下进入Noah大陆
尽管如此,依然…---在提亚的带领下进入Noah大陆
不一样的烟火~---在泰西丝的带领下进入Noah大陆
我就是我!---在芙比的带领下进入Noah大陆
命运的耦合?!---在特弥斯的带领下进入Noah大陆
冷酷到底!---起一个1个字的名字
不长能火吗?---起一个8个字的名字
失忆后遗症---首次更改姓名
我是谁?我来自哪里?---累计更改50次姓名
记忆只有一天---连续3天每天至少更改一次姓名
感觉自己美美哒~---首次更改自我介绍
传说中的96字---写一组96字的自我介绍
感觉自己萌萌哒~---累计更改100次自我介绍
感觉自己棒棒哒~---连续7天每天至少更改一次自我介绍
生日什么的…是很重要的!!---设定生日
你是最棒的!---首次获得勋章
老校长,带带我!!---累计获得10枚勋章
校长自恋有什么错?!---自定义一次勋章
心爱的人啊,值日吧!---累计安排学员进行10000次校园值日
嘘~要虔诚---任意一天首次许愿时候等够10秒钟
一旦拒绝了这种设定…---连续7天没有进行任何一次许愿池投币操作
壕!泥壕!---往许愿池投入累计超过5000枚钻石
Interseting---累计触发50位不同学员的值日生许愿池台词
漫无止境的7日…---连续7天许愿池第一次许愿都是一个结果
====== Medal ===
代理校长---连续30在P.E.T.S.签到
大建筑师---完成琥珀之泉、值日板、源之屋的全部拓展工程
征服王---打倒全部六章冒险的所有大BOSS

而玩家的每日的游戏风格则是根据权重来判断的,这边拿出四个,这些次数将会影响风格偏向

治愈系~prpr---泡澡次数
体罚专家---累计完成值日次数
大忽悠家---源之屋招生人数
冷血校长---源之屋劝退人数

邮件文本

学员和校长的互动做的很好,我玩的时候感觉我真的是校长。补两个函数打印邮件。

def letteralize(sender, title, recver, content):
    mailbox = []

    for i in range(len(title)):
        t = title[i]
        s = sender[i]
        r = recver[i]
        c = content[i]

        letter = {}
        letter['send'] = s
        letter['title'] = t
        letter['recv'] = r
        letter['content'] = c

        mailbox.append(letter)
    
    return mailbox

def print_letter(mailbox):
    for letter in mailbox:
        print("=" * 30)
        print("Title : " + letter['title'])
        print("Send From : " + letter["send"])
        print("To : " + letter["recv"])
        print("Content :\n" + letter["content"])

匿名信摘录:

==============================
Title : 感谢关照
Send From : 轻音部某学员
To : 致亲切的校长先生:
Content :
最近似乎在不知不觉间和您亲近了起来,好像不错呢!您可比某些凶巴巴的老师好多了,谢谢您的关照!

实名信摘录

==============================
Title : 要考试了,该怎么办啊?
Send From : 某小只
To : 给校长先生!
Content :
怎么办怎么办?马上要考试了,我还没有开始复习,有什么办法能够快速进入状态吗?
==============================
Title : 即使问我…也…
Send From : 无奈的校长
To : 库莉莉同学:
Content :
即使问我…也…完全没有作用啊!因为我什么都不会啊!
==============================
Title : 玩命吧!
Send From : 打气的校长
To : 库莉莉同学:
Content :
当然是玩命看书啊!这个时候哪怕是多看一个字,考试的时候也能多赚一分。

敏感词

算是意外收获?NLP训练集+1(x)只是里面底下这些。。。居然也是敏感词?

北原爱子
平丸久美子
太恩
伦公
马钱子碱
长谷川
张林
回教
世界风
安南

游戏主体

目录结构

从resource里拿到的,游戏逻辑相关Lua文件都在此处

codes
├── app
│   ├── barrage
│   ├── layerCtrl
│   └── scenes          # 重要,包含各场景中的逻辑
├── check
├── config
├── const
├── datacontrol
├── datamodel
├── gameConfig          # 大量的config parser
├── generated
├── git_commit
├── guide               # guide 部分
├── io
├── libs
│   └── xi
├── logic               # 游戏逻辑,如战斗
│   ├── battle
│   ├── condition
│   └── unit
├── network             # 网络
├── openjudge
├── platform
├── pub                 # API center
├── pubui
│   ├── editbox
│   ├── ext
│   ├── live2d
│   ├── scroll
│   └── tooltip
├── richlabel
│   └── labels
├── shader
├── shared              # 重要的文本等资源,在conftable下
│   └── conftable
├── sprotos
├── statistic
│   └── context
├── text
└── utils

反编译Luac

Noah看来是一个基于lua脚本的游戏,同样也是用xxtea加密,如上进行解密后即可拿到干净的luac文件。一开始使用了luadec和unluac都无法解析,看了下文件头signature不对,可辨识的仅有LJ(0x1b 0x4c 0x4a – 0x1b4c4a),搜索得知这是LuaJit的magic number。

shell脚本

#!/bin/bash
function read_dir()
{
  for file in `ls $1`
  do
    if [ -d "$1/$file" ]
    then
      read_dir "$1/$file"
    else
      cp --parent "$1/$file" "unlua"
      luajd "$1/$file" > "unlua/$1/$file"
      echo "$1/$file"
    fi
  done
}

read_dir $1

命令,尽可能地进行解析,中间肯定会有一些无法解析的。可以考虑后面用另一套方案生成,不过目前看来大部分lua脚本还是被成功还原了。

mkdir unlua
find codes -type f -not -name '*decrypt.luac' -delete # clean encrypted

vscode打开时发现没有高亮,改下后缀

find codes -type f -name '*.luac' -print0 | xargs -0 rename .luac .lua

大破解锁

原本的诺亚是有游戏内大破图的,只是后期被广电河蟹了。但摸鱼组给大家留了个彩蛋,连续切换游戏设置可以触发崩溃,重启后大破图即可开启。

local NormalTips = "该功能尚未开启,敬请期待!"
local CommandTips = {
    ABABABABABA = "警告!警告!点击过于频繁!!!",
    ABABABABABABBABABA = "游戏即将崩溃!!!",
    ABABABABABAB = "迷途知返吧!",
    ABABABABABABBABAB = "警告!警告!点击过于频繁!!!",
    ABABABABABABBABABABABA = "游戏已崩溃!!!请尝试重新登录!!!",
    ABABABABABABB = string.format("%s!!!!!", NormalTips),
    ABABABABABABBA = string.format("%s!!!!!", NormalTips),
    ABABABABABABBAB = string.format("%s!!!!!!", NormalTips),
    ABABABABABABBABA = string.format("%s!!!!!!", NormalTips),
    ABABABABABABBABABAB = string.format("%s!!!!!!!", NormalTips),
    ABABABABABABBABABABA = string.format("%s!!!!!!!", NormalTips),
    ABABABABABABBABABABAB = string.format("%s!!!!!!!!", NormalTips),
    
    getTips = function (pattern)
        local text = pattern.getText(pattern)
        local textLen = string.len(text)
        local tips = nil

        for i = textLen, 11, -1 do
            local key = string.sub(text, -i)
            print(key)
            tips = CommandTips[key]
            if tips then
                break
            end
        end

        if tips then
            return tips
        else
            return NormalTips
        end

        return 
    end
}
return CommandTips

悄咪咪放张大破图

dapo

主逻辑

函数入口点在main.lua 下,创建了GameApp实例(app/GameApp.lua中),运行其中的run()方法。整个游戏的设计核心应该是SceneCtrl,通过场景进行逻辑划分。SceneCtrl负责场景切换,采用的应该是栈模型对场景(Scene)进行管理,Scene中对UI界面进行控制(除了逻辑划分方便外,应该也是考虑到各场景UI的Sprite切分问题),UI的控制方法在layerCtrl/GameUICtrl中,GameUIDefine则是定义了各个UI的宏(就是对应表)。

SceneCtrl类的定义可以从构造函数中看出端倪。拥有四个成员(名,场景实例,是否可变更,场景栈)

local SceneCtrl = class("SceneCtrl")
SceneCtrl.ctor = function (self)
    self.curSceneName = nil 
    self.curScene = nil
    self.changeEnable = true
    self.sceneStack = {}

    return 
end

lua的开头定义了每个场景的切换选项

    Shop = {
        marquee = true,
        chat = true,
        path = "shop.scene"
    },

关键的switch方法:

SceneCtrl.switchScene = function (self, name, params, transitionParams, callback)
    -- not changeable 
    if not self.changeEnable then
        return 
    end

    if self.curScene ~= nil then
        addSwalllowLayer(self.curScene)
    end

    PubUI.uncacheAllAudio()
    cc.Director:getInstance():getActionManager():removeAllActions()
    
    -- clear cached textures
    if #self.sceneStack == 1 then
        PubUI.clearUnusedTextures()
    end
    
    -- create current scence according to given name
    local scene = self._newScene(self, name, params)
    
    -- display Current Scene first
    if transitionParams and not transitionParams.noAction then
        display.replaceScene(scene, transitionParams.style, transitionParams.time, transitionParams.more)
    else
        display.replaceScene(scene)
    end

    if type(callback) == "function" then
        scene.performWithDelay(scene, callback, 0)
    end
    
    -- then set current scene to the top of stack
    self._popSceneFromStack(self)
    self._pushSceneToStack(self, name, scene)

    return 
end

有个比较关键的东西暂时都找不到–display的类定义(看起来是全局的东西?),display通过全局的搜索观察属性和方法应该是类似于Window之类的东西,可以加载图片,有宽和高。(之后尝试用不同工具生成代码再观察。先分析感兴趣的Scene。)

UI Layer的控制核心是GameUICtrl,对对应的事件进行注册。

function GameUICtrl:ctor(scene, sceneName, zOrderLevel)
    self:setNodeEventEnabled(true)

    self.scene = scene
    self.name = sceneName or ""
    self.zOrderLevel = zOrderLevel or 9999

    self:setNodeEventEnabled(true)

    self.openAllUI = {}
    self.invisibleUI = {}
    self.importUI = {}
    self.bindedEventTags = {}
    self._listeners = {}

    for k, v in pairs(EVENTS) do
        local handle = game:addEventListener(k, handler(self, self[v]))
        table.insert(self._listeners, handle)
    end
end

核心的功能函数也是最常用的一个是addUIlayer,在后面分析scene的时候会经常看到,简单来说就是将当前UI Controller替换为对应UI lua脚本中写好的Controller,实现功能上的切换。

function GameUICtrl:addUILayer(uiId, ...)
    local uiCtrlPrams = GameUIDefine.getUIPrams(uiId)

    if uiCtrlPrams == nil then
        return false
    end
    
    --- Import the specified UI lua script ---
    if self.importUI[uiId] == nil then
        self.importUI[uiCtrlPrams.luaName] = import(uiCtrlPrams.luaName)
    end
    local curLayerCtrl = self.importUI[uiCtrlPrams.luaName]
    if curLayerCtrl == nil then
        return false
    end

    local zorder = nil
    -- don't know what's order
    if uiCtrlPrams.zorder then
        zorder = self.zOrderLevel + uiCtrlPrams.zorder
    else
        zorder = self.zOrderLevel
        self.zOrderLevel = self.zOrderLevel + 1
    end
    
    -- check open UI
    local curLayer = self.openAllUI[uiId]
    
    -- no open UI, use our new UI
    if not curLayer then
        exportSMsgMethods(curLayerCtrl)
        
        -- call the ctor func in UI_*.lua
        -- normally init
        self.openAllUI[uiId] = curLayerCtrl.new(...)
        curLayer = self.openAllUI[uiId]
        curLayer.UIID = uiId
        
        -- restore VM ? not familiar to lua
        function curLayer.disposeSelf()
            self:delUILayer(uiId)
        end
        
        -- some dirty work about Graph and audio
        curLayer:align(display.CENTER):addTo(self, zorder, uiId)

        if uiCtrlPrams.inMusicOn then
            curLayer:performWithDelay(function ()
                local music = uiCtrlPrams.inMusicPath or "jiemianqiehuan"

                playUiSound(string.format("%s%s.mp3", MUSIC_RES, music))
            end, 0.1)
        end
    else
        self.openAllUI[uiId]:setLocalZOrder(zorder)
    end
    
    -- get guide if needed
    local guidelayer = GuideCtrl:getGuideLayer(uiId)

    self:addGuideLayer(guidelayer)
    
    -- update and visible
    if curLayer.updateUI then
        curLayer:updateUI(...)
    end

    if uiCtrlPrams.isFullScreen then
        self:unShowBehindUI(curLayer)
    end

    curLayer:setVisible(true)

    return true, curLayer
end

(补一个链接,这个的decompiler的效果不错)

Login

虽然对于游戏机制也很好奇,但还是想先看看登录这块的本地部分。

通过SceneCtrl的机制跳转到Login Scene中,其中对应逻辑都直接写在LoginScene的构造函数里。先进行了版本检查,之后调用了Locale的onEnterLogin,这一块应该是为了语言国际化设计的。

function LoginScene:ctor(params)
    self.uiCtrl = GameUICtrl.new(self, "loginUI")

    self.uiCtrl:addTo(self)

    self.params = params or {}

    game:setVersionCheckEnable()
    game.playerData:clearAllData()
    self:performWithDelay(function ()
        NoticeUtil.getMaintainNotice(function (notice)
            if notice and next(notice) then
                SceneCtrl:addUILayer(GameUIDefine.UI_MAINTAIN_NOTICE, function ()
                    Locale.onEnterLogin(self.uiCtrl, self.params)
                end, notice)
            else
                Locale.onEnterLogin(self.uiCtrl, self.params)
            end
        end)
        PubEnv.playBGM("login")
    end, 0)
end

----------------------------------------Locale------------------------------------
onEnterLogin = function (uiCtrl, params)
    if params.hasLogin then
        uiCtrl:addUILayer(GameUIDefine.UI_LOGIN, params)
    else
        uiCtrl:addUILayer(GameUIDefine.UI_COPYRIGHT, function ()
            uiCtrl:addUILayer(GameUIDefine.UI_LOGIN, params)
        end)
    end
end

还记得前面提到的UIDefine吗,那里面记录了UI_*的对应关系,可以从中找到对应lua脚本的路径。UI_LOGIN对应的是loginUI.lua。这个脚本里主要是处理Layer Touch、Click这些事件,以及对不同的登录方法、渠道服进行区分(比如布卡、斗鱼、sina、bilibili这些渠道)。

UI Login的构造函数如下,init()代码也附在底下。其中只有腾讯的登录方式比较奇特,单独存在if判断中。其余的是通过resetLogin()->initTouchLayer 注册clickBackgroud的按钮事件->clickBackGround()处理不同登录这样的逻辑完成的。

function LoginUI:ctor(params)
    self.hasLogin = params and params.hasLogin

    self:setNodeEventEnabled(true)
    self:initUi()
end

function LoginUI:initUi()
--- .... some statements
    if PJ_PROVIDER == "tencent" then
        local function showButtons()
            print("传给java funcId 。。。")
            self:initTencentButton()
        end
        self:u8sdkLogin("auto")
        self.touchLogin:setVisible(false)
    else
        -- init Touch Layer
        self:resetLogin()
    end
    
--- ... some statements
end

clickBackGround()如下,我们只关注摸鱼组的登录方式就好,其它渠道暂时忽略。


function LoginUI:clickBackground()
    self.backLayer:setTouchEnabled(false)
    self.touchLogin:stopAllActions()
    self.touchLogin:setVisible(false)

    if self.hasLogin then
        self:updateServers() -- update server info
    elseif device.platform == "android" and PJ_PROVIDER ~= "noah" then
        if PJ_PROVIDER == "vivo" then
            self:vivoLogin()
        elseif PJ_PROVIDER == "diyidan" then
            self:diyidanLogin()
        elseif PJ_PROVIDER == "buka" then
            self:bukaLogin()
        elseif PJ_PROVIDER == "sina" then
            self:sinaLogin()
        elseif PJ_PROVIDER == "biligame" then
            self:bilibiliLogin()
        elseif PJ_PROVIDER == "yuewen" then
            self:yuewenLogin()
        elseif PJ_PROVIDER == "stargame" then
            local ok, sdkversion = luaj.callStaticMethod(JAVA_CALL_NAME, "getSDKVerion", {}, "()Ljava/lang/String;")

            if ok and sdkversion == "1.8.0" then
                self:stargameLogin()
            else
                self:u8sdkLogin("other")
            end
        else
            self:u8sdkLogin("other")
        end
    else
        self:moyuLogin()
    end
end

moyu Login进行了一次UI切换,切换到如下的UI

function MoyuLoginUI:ctor(params)
    self._uiStack = {}

    function self._onSwitch(name, opts)
        return function (...)
            if opts and opts.replace then
                self._uiStack[#self._uiStack] = nil
            end

            self:switchUI(name, ...)
        end
    end

    if params and params.change then
        self:switchUI("UserHome")
    else
        self:autoLogin()
    end
end

autologin判断了登录类型,分别是’passport’(口令验证)和’guest’(Token验证)方法,一些语句已删去

function MoyuLoginUI:autoLogin()
    -- something else ....
    if token ~= "" then
        if info.provider == "guest" then
            loginLogic.autoGuestLogin(info.account, onSignIn, onNotSignIn)
            return
        end

        if info.provider == "passport" then
            loginLogic.loginEvent(info.account, info.password, function (ok){}
            return
        end
    end
end
-------------------------------------------------------------------------
function loginLogic.loginEvent(account, password, callback)
    local params = {
        provider = "passport",
        token = password,
        uid = account
    }
    callback = callback or NULL_FUNC

    PubFunc.postCenterAPI(CENTER_V2, "auth", params, nil, {
        onSuccess = function (body)
            GameData = GameDataAll:getGameData(body.id)
            GameData.masterInfo.tokenId = body.access_token
            GameData.masterInfo.uuid = tonumber(body.id)
            GameData.masterInfo.account = account
            GameData.masterInfo.password = password
            GameData.masterInfo.provider = body.provider
            GameData.masterInfo.phone = body.phone

            updateMasterInfo(body.idcard_auth_code)
            GameDataAll:save()
            callback(true)
        end,
        onFail = function ()
            callback(false)
        end
    })
end
            
---------------------------------------------------------------------------
        
function loginLogic.autoGuestLogin(guestToken, onSuccess, onFail)
    onSuccess = onSuccess or NULL_FUNC
    onFail = onFail or NULL_FUNC
    local params = {
        provider = "guest",
        token = guestToken
    }

    PubFunc.postCenterAPI(CENTER_V2, "auth", params, nil, {
        onSuccess = function (body)
            GameData = GameDataAll:getGameData(body.id)
            GameData.masterInfo.tokenId = body.access_token
            GameData.masterInfo.uuid = tonumber(body.id)
            GameData.masterInfo.account = guestToken
            GameData.masterInfo.password = guestToken
            GameData.masterInfo.provider = body.provider

            GameDataAll:save()
            onSuccess()
        end,
        onFail = onFail
    })
end

简单来说其把数据打包发给PubFunc里的某个API,看起来没有hash。函数签名如下,诺亚所用的API应该经历过一次较大的版本更新,有些地方有修修补补的痕迹,造成函数风格不太一致。总之第一个是post的url,第二个为api名称。

function PubFunc.postCenterAPI(url, api, params, method, opts)

可以把API的url恢复出来,分别由3个宏控制,存在appconfig下,里面还有各平台对应的apikey以及其他信息,感觉可以用来泄漏一些数据或者测试?

SYSTEM_HTTP_SCHEMA = “http”

CENTER_HOST = SYSTEM_HTTP_SCHEMA .. “://center.noah-fantasy.com.cn”

CENTER_V2 = CENTER_HOST .. “/api/client/v2/”

===> http://center.noah-fantasy.com.cn/api/client/v2

该函数实质上就是将该url与api字符串(本例中是"auth")拼接为完整的api地址,然后向API center发一个http请求,函数定义写在这里了。

local function sendHTTPRequest(url, params, method, opts)
    local onSuccess = opts and opts.onSuccess or NULL_FUNC
    local onFail = opts and opts.onFail or NULL_FUNC
    local retry = opts and opts.retry or 3
    method = method or "POST"
    local urlParam = params and httpUrl.buildQuery(params) or ""
    local keyValue = string.toArray(CENTER_V2_API_KEY, ":")
    local postParam = ""

    if urlParam ~= "" then
        if method == "POST" then
            postParam = urlParam
        elseif method == "GET" then
            url = string.format("%s?%s", url, urlParam)
        end
    end

    local retryCount = 0

    local function newRequest()
        local xhr = cc.XMLHttpRequest:new()
        xhr.responseType = cc.XMLHTTPREQUEST_RESPONSE_JSON

        xhr:registerScriptHandler(function ()
            print(xhr.readyState)

            if xhr.readyState == 4 then
                xhr:unregisterScriptHandler()

                local code = xhr.status

                if code == 200 or code == 201 then
                    onSuccess(xhr.response, code)
                else
                    onFail(xhr.response, code)
                end
            elseif xhr.readyState == 0 then
                xhr:unregisterScriptHandler()

                retryCount = retryCount + 1

                if retryCount < retry then
                    newRequest()
                else
                    onFail(nil, 0)
                end
            end
        end)
        xhr:setRequestHeader(keyValue[1], keyValue[2])
        xhr:open(method, url)
        xhr:send(postParam)
        print("send request, url:", url)
    end

    newRequest()
end

试试构造相应的请求,根据上面函数来看,有如下几个限制(opt无需关注)

  • method置为nil则默认使用post方法
  • request header中要设置api key的key字段:Api-Key:18a9c596b19ceec530f4c8bf46200811285
  • param经过httpUrl.buildQuery(params)编码,该函数写在独立脚本里,可以直接运行打印结果。我们要的是口令登录方式,uid可以从游戏里看到3541045
local httpUrl = require("httpUrl")

local my_params = {
    provider = "passport",
    token = "password",
    uid = "3541045"
}

print(httpUrl.buildQuery(my_params))

-->>> output is "provider=passport&token=password&uid=3541045"

firefox或者postman构造对应的post请求

[Header]
POST /api/client/v2/auth HTTP/1.1
Host: center.noah-fantasy.com.cn
User-Agent: Mozilla/5.0 Gecko/20100101 Firefox/82.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 46
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0, no-cache
Api-Key: 18a9c596b19ceec530f4c8bf46200811285
Origin: http://center.noah-fantasy.com.cn
Pragma: no-cache

[Body]
provider=passport&token=P%40ssw0rd&uid=3541045

最后返回了一个帐号不存在,可能是根据key区分服务器,我当初这个号是买的初始号,或许是B服?看了一下lib/cocos2d/lua/AppActivity里的isProvider是"noah",另外两个函数不存在。apikey应该为65a42e68807a7fdaf91745d3ad70e116e5a,然而还是帐号不存在orz。最后检查代码发现所谓uid其实还是用户名,麻了。

<?xml version="1.0" encoding="UTF-8"?>
<hash>
  <id type="integer">3541045</id>
  <created-at type="integer">1586698106</created-at>
  <username>dwd2658</username>
  <nickname>dwd2658</nickname>
  <provider>passport</provider>
  <access-token>EpIu+fGjCyyKYgbR+w99jZWhG03VP7l2VFNS8BjnD80=</access-token>
  <refresh-token>4a3541045a662a552e4f19e16deb23347a1127e25d</refresh-token>
  <expired-in type="integer">67265</expired-in>
  <phone>AAA****AAAA</phone>
  <idcard-auth-code>1000</idcard-auth-code>
</hash>

另外,直接访问API Center会返回一份比较详细的API描述,应该是直接买的某个API平台,后面让我了解了一些信息(

只支持游客 (guest) 和通行证 (passport) 两种 provider。

使用 guest 时,token 是游客令牌。
使用 passport 时,uid 是用户名,token 是密码。如果账号已经绑定过号码可以不填密码。

如果账号已经绑定过号码,则号码必须一致。

返回结果字段说明如下

- `send_in (integer)` 下次可发送需要等待的秒数。

可能返回错误

- `400` 参数错误,也可能是传参错误
- `401` 不允许发送,号码不符合,或者是账号还没有绑定,使用 passport 但是没有传 token,或者是 token 不正确。
- `412` App 没有配置短信参数
- `402` 发送频率或者次数超过限制,可以通过返回的 JSON 确定错误

402 会返回字段

- `send_in (integer)` 需要等待发送的时间,如果等于 -1 表示没有确定的时间能再次发送
- `error (string)` 错误消息,可以显示给用户

游戏中发送成功后需要根据 `send_in` 倒计时,计时结束前不能再次发送。而且必须对 402 进行处理。如果 `send_in` 不等于 -1 需要显示倒计时,计时结束才能再次发送。如果等于 -1 必须等待 5 分钟后再次重试。

那么服务器信息还有其他一些信息又是从哪里来的呢?前面ClickBackGround的地方还有一个updateServer()语句,其函数调用较多,追溯过程掠过,记录一下里面比较关键的一个语句

--- click the login button ---
LoginUtil.onLoginConnect(servers, lastServerID)
Api-Key: 65a42e68807a7fdaf91745d3ad70e116e5a

该函数的定义如下, 首先检查服务器是否正常以及是否很短时间前已登录过,然后如果是已有角色就加载"Loading"的UI,否则的话激活/创建角色。

function LoginUtil.onLoginConnect(servers, lastServerID)
    local message = LoginUtil.isServerEnterable(servers, lastServerID)

    if message and message ~= "" then
        showMessage({
            text = message,
            sure = function ()
                SceneCtrl:switchScene("Login", {
                    hasLogin = true
                })
            end
        })

        return
    end

    local now = os.clock()

    if sLastLoginTime and now - sLastLoginTime < 2 then
        return
    end

    sLastLoginTime = now

    print("token id", GameData.masterInfo.tokenId)
    print("server id", game.serverInfo.id)
    PubFunc.sendMsg("profile_exists", nil, function (data)
        if data.code == 0 then
            SceneCtrl:addUILayer(GameUIDefine.UI_GAME_LOADING, {})
            platform.getDeviceInfo(function (deviceInfo)
                PubFunc.sendMsg("set_device_info", deviceInfo)
            end)
        elseif ACTIVATION_ID then
            SceneCtrl:addUILayer(GameUIDefine.UI_ACTIVE_CODE, {
                onActive = LoginUtil.createRole,
                serverID = game.serverInfo.id
            })
        else
            LoginUtil.createRole()
        end
    end, "errnone")
end

战斗逻辑

不详细分析了,仅描述结果,scene总揽全局,logic下有细致逻辑。

简单来说是剧场模型,PVP和PVE等是不同的舞台剧一场战斗就是一出舞台剧,加入所有左右角色。采用buffer决定攻击顺序。

rank的判定

学员属性

  • atk 100 : integer – 攻击
  • def 101 : integer – 防
  • hp 102 : integer
  • max_hp 103 : integer
  • agi 104 : integer – 敏捷速度
  • crit 105 : integer – 暴击
  • critMul 106 : integer – 暴击倍率
  • eva 107 : integer – 闪避
  • incHurt 108 : integer – 增伤
  • avoHurt 109 : integer – 减伤
  • liResi 110 : integer – 光抗
  • daResi 111 : integer – 暗抗
  • wiResi 112 : integer – 风
  • fiResi 113 : integer – 火
  • waResi 114 : integer – 水
  • curedProb 115 : integer – 治愈率?
  • stunResi 117 : integer – 眩晕抗性
  • slientResi 118 : integer – 沉默抗性
  • reboundProb 119 : integer – 反击/反伤概率
  • reboundRatio 120 : integer – 反击倍率?
  • hitRate 121 : integer – 攻击频率

战斗的话会连接服务器,并通过随机数的方式验证

.BattleEventResult {
  # 0 - 失败
  # 10 - B
  # 20 - A
  # 30 - S
  rank 0 : integer

  # 如果有 BOSS, 返回 BOSS 剩余 HP 百分比 0 ~ 100,向上取整
  boss_hp_percent 1 : integer

  # 返回队员血量和伤害等记过信息信息
  member_infos 2 : *BattleResultMemberInfo

  # 随机种子 要求和服务发放的随机种子一致
  current_event_seed 4 : integer

  # 以下不参与战斗验证:
  # 返回击杀的怪物cid 按照击杀顺序填
  .Monster{
    # 怪物id
    monster_id 0 : integer
    # 击杀者使用的技能id
    skill_id 1 : integer
    # 击杀者unit_的id
    unit_id 2 : integer
  }
  monsters 10 : *Monster

  # 角色使用技能次数
  .Skill{
    # 技能id
    skill_id 0 : integer
    # 角色 id
    unit_id 1 : integer
    # 使用次数
    count 2 : integer
  }
  skills 11 : *Skill
  # 回合数
  turn_num 12 : integer

  # 本场战斗使用的全局buffid列表
  buff_ids 13 : *integer
  # 本场战斗使用的个人buff列表
  personal_buff_ids 14 : *integer
  
  # 客户端输入指令
  commands 15 : *BattleCommand

  # 战斗认输指令
  give_in_step_index 16 : integer
   
}

学员召唤

俗称大建,对应的在recruit下,unit_summon和recruit_queue与之相关。

主要的入口点在recruitUnitUI下,_onBtnStart里是召唤按钮的行为,recruit_timesUI就是召唤时弹出来的小框框(补图),大部分函数都通过传参嵌入了,最终的逻辑在recruiQueueUI的_refresh()。不过这里只是控制队列,依旧没有相应的概率、影响因素等。结果是存在一个全局的BuildSlot里。

BuildSlot的属性

.BuildSlot {
  # 主键id 取值 1,2,3 对应 3 个槽
  id 0 : integer
  # 开始时间, Linux timestamp
  started_at 1 : integer
  # 结束时间, Linux timestamp
  completed_at 2 : integer
  # 需要总时间
  duration 3 : integer
  # 建造结果,即战斗角色的配置表 ID
  cid 4 : integer
  # 建造槽状态 0=空闲 1=完成 2=等待排队中 3=建造进行中 注意等待状态下没有started_at和complete_at
  status 5 : integer
  # 精灵
  sprite 6 : integer
}

除了调配资源计算折扣交给本地,其他的比如:

  1. 提交四类元素数量
  2. 加速后的结果(加速动画在本地完成)
  3. 完成召唤

每一步都是提交到服务器完成的,这边只负责一些状态修改。

探险事件

似乎也是交由服务端处理的,需要解析conftable以获取所有条件,这一块有能人已整理。

探险事件结果
.ExploreEvent {
  # 主键 事件 id
  id 0 : integer
  # 事件开始时间
  started_at 1 : integer
  # 事件结束时间
  completed_at 2 : integer

  .ExploreSubEvent {
    # 主键 子事件 id
    id 0 : integer
    # 子事件开始时间
    started_at 1 : integer
    # 子事件结束时间
    completed_at 2 : integer
    # 子事件执行结果
    result 3 : boolean
    # 奖励
    rewards 4 : *Loot(id)
    # 句子总数
    sum_sentences 5 : integer
  }
  # 子事件
  sub_events 3 : *ExploreSubEvent(id)
  # 奖励
  rewards 4 : *Loot(id)
  # 完成度
  complete_porgress 5 : integer
  # 句子总数
  sum_sentences 6 : integer
}

其它

  • 澡堂、Battle也是queue模型。

  • sproto/common和text下记录了大量的游戏文本,以及一些变量的说明,能泄漏很多信息。

后记

Lua这块的反汇编以及反编译工具还不是很齐全,照理来说应该有相应需求。luac的反编译比较成熟一些,而luajit的则缺失严重,基本都是自改自用,Lua反编译器可以考虑作为一个插件实现。应该会很有用

  • disassemble
  • decomiler
  • both luac & luajit
  • can shift between diffrent JIT versions