首页
论坛
课程
招聘
[原创][分享]编写单机游戏连连看辅助的全过程
2020-10-6 12:15 6292

[原创][分享]编写单机游戏连连看辅助的全过程

2020-10-6 12:15
6292

本人师从于15PB,以下分析的是一个小作业。分析过程中,遇到不懂的问题,参考了15PB薛老师录制的视频。

可能有很多更加便捷快速的方法可以制作辅助,直接通关游戏,但是这里主要目的是为了分析程序的功能和函数,目的还是学习。

去除广告

打开游戏

打开qqllk.exe。查看进程列表:发现点击开始游戏之后会新建一个qqllk.ocx的进程,点击继续之后,该进程结束,并且打开了游戏程序

点击继续之后:qqllk.ocx进程结束了,kyodai.exe进程被打开,也就是游戏程序

也就是说qqllk.ocx的功能就是打开游戏程序。但是为什么我们自己却不能打开程序?

介绍下面的API:

以挂起的形式创建进程

 

STARTUPINFO ie_si = {0};
PROCESS_INFORMATION ie_pi;
ie_si.cb = sizeof(ie_si);
CreateProcess(NULL,szBuffer,NULL,NULL,FALSE,
      CREATE_SUSPENDED,NULL,NULL,&ie_si,&ie_pi);    //参数:CREATE_SUSPENDED
//恢复执行
ResumeThread(ie_pi.hThread);

以挂起的形式创建进程,此时创建出来的是一个4GB的虚拟空间,程序还没有跑起来,在这期间可以对这4GB的内存空间做一些操作。比如:修改内存,或者直接卸载这4GB的内存,将另一个程序的内存放入这4GB的空间中。

这里猜测可能是使用了挂起的方式创建进程,然后修改内存,将游戏的内存修改为正确的值之后,再恢复进程的执行

CrateProcessA下断点

writeprocessmemory下断点之后,点击qqllk窗口中的继续,断下来了。注意参数

使用010打开该程序,搜索地址43817a-400000=3817a

修改为程序要求的字节:00,另存为一个新的文件


双击打开程序:直接运行游戏了


分析源程序

一、寻找突破口

1.1直接分析新文件,OD打开,点击练习,会随机生成新地图,猜测使用了随机函数rand

搜索rand函数,下断点,点击练习按钮,看看是否会断下来。结果证明确实断了下来。

1.2点击K,进入栈回溯窗口,查看调用堆栈,发现我们自己的程序0041A080调用了0041CAF2,0041CAF2调用了rand()

1.3双击进入,在这两个地方设置断点

1.4再次点击"练习按钮",断在了第一次调用的函数,发现这是一个thiscall,ECX时this指针,是一个C++对象的调用。单步F7进入查看

1.5发现了一个文件,在本地磁盘中打开该文件试听一下,发现这是点击练习之后会播放的音乐。继续往下走

1.6单步没有能发现什么能认识的东西,直到CALL了第二个调用,并且注释说明,调用了函数:memset(void*dest , const void* src ,  int n)

这几行代码的意思,猜测一下:首先调用随机数函数rand(),然后调用memcpy,将src的内容拷贝给dest,一共拷贝0xDC个字节的内容。

1.7接下来F8单步,然后查看内存,验证一下

1.8调用前EAX的内存情况                

调用前EDX的内存情况


调用后EAX的内存情况

1.9首先一看到这样的布局,比较容易联想到这是一张地图。多走几次,发现前8字节都是固定不变的,那么先不看前8字节,看一下能否与游戏地图对接上

地址栏CTRL+G跳转:EAX+8,直接运行起来,发现这就是游戏地图。应该是每一种图案都对应一个数字。并且在游戏中消除了方块之后,地图对应的位置也会清零

那么联想上面的操作:初始化地图,01就代表这个位置有物品,00代表空地,然后后面会对01位置进行赋值。

1.10继续往下走,发现紧跟着的一个函数调用就是对01位置的赋值操作,因为CALL了之后,01的位置全部都变化了

1.11由此可以确认EAX就是地图,但是前面还有8个字节的内容不知道是什么,但是不管是随机了多少次,前8个字节都是固定不变的,并且又是以偏移的形式得到的地图,那么前8字节很可能就是地图的基址。基址+偏移得到地图。

1.12看一下上图中的EAX是从哪里来的,一直向上查找,会发现ESI就是ECX赋值过来的,也就说ESI就是this指针

1.13继续分析,有一个疑问,上面分析得出,memcpy函数将地图从EDX中copy到EAX上,那么EDX又是从哪里来了?

查看的方法是下内存断点,继续点击练习,让它断下来,然后内存中跳转到EDX+8,选中一段地图,右键,断点,内存写入,然后重新点击练习按钮

1.14断下来之后,点击K查看栈回溯

1.15 逐个双击进去查看,发现又回到刚刚分析的地方了,说明在调用memcpy函数之前,程序就预先初始化了一张地图,这张地图里面存储的是0和1。

1.16 但是我发现,这个初始化之后的地图全都是固定不变的,不管初始化多少次,地图仍然是不会变,那么后面的地图为什么又会变呢?跟进去看看

1.17 发现使用了一个文件,在本地磁盘中打开看看,发现这个正是一个地图文件。所以程序应该是预先设置好了地图,然后随机数应该是将位置为1的设置为随机图案。

1.18 再看之前的记录:调用rand()随机数,执行CDQ指令,获取偏移EDX(随机值),加上基址,获取地图,再赋值给EAX。

1.19 到此为止,我们得到了内存中的地图位置,并且知道了游戏是在什么时候初始化地图的,什么时候随机地图的,可以直接修改地图内存,写一个简单的破解了:

只需要在游戏初始化地图之后,将地图设置为0000即可消除所有方块,这里我设置为如下:

在设置两个图案进去

运行起来,整个游戏就只剩下两个图案了,直接点击就可以完成游戏了。

在OD里面测试:以下代码其实可以优化,直接写JMP会比较简单,但是写完后才想起,就懒得改了

在地图赋值完成之后,插入一段代码,将地图的第一个位置和第二个位置设置为图案,其他的全部设置为00,设置完之后,jcc跳转,跳转到0044af44处执行原本的代码,执行完原本代码之后,再跳转回41cb4a处继续执行代码

44AF44地址是一块程序没有使用到的空间,在这里我利用这段空间作为一个跳板,执行原代码。由于插入之后,会覆盖原本的代码,所以需要先备份一下原本的代码

在44AF44处添加原本的代码:

在OD中修改完成之后:选中修改的代码,复制到可执行文件。这样会生成一个新的文件。我测试的时候一共生成了两次文件,第一次是在44AF44处添加原本代码的时候,选中修改的代码,生成了新的文件

第二次是在41CB25处,添加代码时生成的。

执行完之后,双击打开exe,效果图:点击"练习按钮"整个游戏就剩下一对相同的图案了

以上的就是一个简单的破解,直接修改原程序,在原程序中插入我们自己的代码,然后让程序继续正常跑起来。这种方法比较无脑简单,也没有太多的技术性,有点类似爆破。接下来我们继续分析程序的算法,编写一个辅助工具

我们发现,在游戏中可以使用指南针道具,直接找到两个相同的图案,那么尝试一下通过这个道具入手,达到自动无限次使用道具。

二、寻找指南针道具函数

2.1 首先,指南针也不可能预先知道哪里有相同的图案,也是需要访问地图才能查找到相同的图案,那么思路有了,

程序运行起来之后,在内存中的地图下一个内存访问断点,然后使用指南针道具,看看程序是在哪里调用了指南针功能的函数:

2.2 断下来之后,点击K查看调用堆栈,一共有5个调用,全部下断点,断下来之后再分析哪个函数真正调用了指南针函数


2.3 第五层执行了多次,指南针函数应该不会调用多次,逻辑上应该是使用一次指南针,调用一次指南针函数,取消断点

2.4 当点击游戏图案时,第一层和第二层会断下来,肯定不是调用指南针的,取消断点。

从这里开始,分析函数的功能,首先看看这个函数有几个参数,具体是什么。查看函数CALL之前,PUSH了几个参数,以及函数RET时返回了多少个字节,有些时候有可能有一些参数很早就PUSH了,中间又调用了其他函数,这样就容易混淆到底哪个才是参数,所以通过返回值和PUSH的个数一起确认参数是最靠谱的。

一直往下翻,这个函数特别长,翻得我怀疑人生....最终总算找到了,返回0xC的字节,说明PUSH了3个参数进栈,查看堆栈

调用之前PUSH了3个参数

2.5 返回时,RET了12字节。可以确定函数的参数就是这三个外加一个this指针。

2.6 查看参数:通过测试,并且查看这三个参数,发现这三个参数都是固定的,不会动态变化,

2.7 也就是说,我们只要调用这个函数,并且传三个参数(0 , 0 , 0xF0),就可以模拟程序调用这个函数了,只要可以随心所欲的调用这个函数,就可以随心所欲的使用指南针道具了

继续向下分析,分析第四层:分析函数的关键:①查看返回值变化②查看参数变化③查看自身变化

看参数,0x189D84,发现这样的形式特别像地址,在数据窗口查看,F8运行完之后,发现这两个参数被修改为08 01 和02 01

2.8 与游戏对比:发现这两个数字正好是相同图案的坐标:X=[8] ,Y=[1]         X=[1],Y=[2]

    这里留意一下,this的值是:  传进来的this指针  +  0x19F0

        传进来的this指针又是上层函数传进来的this指针+0x494

2.9 目前已有信息:知道哪个函数会可以使用指南针功能参数为(this,0,0,F0),调用它即可获取两个形同图案的坐标

现在还差一个this指针,返回第三层查找this指针是哪里来的

逐步向上分析:

第三层

ECX=ESI+0x494

ESI=ECX

第二层

ECX=ESI

ESI=ECX

第一层

    ECX=ESI

    ESI=ECX

再上一层

    ECX=EDI

    EDI=ECX

    ECX=EBP-4

........太难找了调用堆栈太多,放弃从调用堆栈找的方法了

使用CE查找:

ECX=ESI+0x494,ECX很可能是某个对象中的成员,也就是说是某个对象中的成员对象,所以可以尝试一下,理解为ECX=基址+0x494,基址就是ESI,值为0x18A1F4

2.11 打开CE,附加游戏程序,搜索十六进制0x18A1F4,搜索发现4个常量基址,这4个基址是不会改变的,很可能就是这四个,首先排除77D84278,这里是系统高2GB的内核空间,不可能被3环程序访问

2.12 剩下3个,右键查找所有常量

0045DCF8:双击进去发现跳转到0041D153,排除

0045DEBC:继续看剩下的是什么情况再判断

2.13 0047FDE0:同样是有不少引用,尝试在赋值给ECX处下断点,但是两个基址都没断下来,那么就一个一个尝试吧

暂时先尝试使用0045DEBC,如果不对再换。

2.14 结合以上得知this指针ecx为:

mov eax,[45DEBC]    lea ebx,[eax+0x494]     ecx=ebx+19F0(指南针函数使用的this指针)

现在我们有了这些参数,就可以知道下一个可以消除的图案坐标在哪里了,只需要调用这个函数,并且传参:(0,0,F0)即可。

2.15 通过测试,得知这个函数其实是使用道具的函数

    当参数为(0,0,F0)时,是使用指南针

    当参数为(0,0,F4)时,是使用炸弹

    当参数为(0,0,F1)时,是使用重列

三、寻找消除图案的函数

3.1 那么现在可以信息都有了:知道哪个函数会可以使用指南针功能参数为(this,0,0,F0),调用它即可获取两个形同图案的坐标

我们想更加完善功能:让程序自动调用消除图案的函数,也就是说帮我们自动点击相同的图案,完成自动消除图案。

那么消除图案功能应该也是一个函数的调用,并且需要传递两个相同图案的X和Y坐标,调用之后需要将地图修改为00,由此,在内存中的地图下内存写入断点,然后在游戏中消除图案

在下断点的过程中,消除图案但是,发现并没有断下来,可能是由于内存跨页了,而我只设置了某一页的断点,所以我选中所有地图,下内存写入断点

3.2 此时断下来了,查看调用堆栈,有一个地址是72A63B7D,系统空间的地址,直接pass。

3.3 与之前一样,全部下断点,逐个排除,分析,目的是找到哪里调用了消除图案的那个CALL,同理,排除后剩下3个CALL存在可能性

接下来,从这三个函数的参数作为突破口,分析这些函数的参数与消除图案的函数功能是否相匹配。确定参数个数的时候,查看RET配合PUSH可以准确的确定函数的参数

3.4 接下来与之前的分析一致,分析函数,看参数,返回值,自身是否改变,变成什么

首先看第3层,传了4个参数,但是没有特别有意义的地址,有一个是X和Y坐标,但是有些时候这个地址中的坐标的偏移会发生改变,现在还不能确定,但是可能性应该不会很大,先看后面的

3.5 第4层,在内存地址中,发现这一层的参数中有坐标,多次测试后,参数3、4每一次与游戏中的地图图案都对应上了,那么基本上可以断定就是这个函数调用了消除图案的功能了。但是参数5比较容易混淆

3.6 参数5很疑惑,看着像是一个76字节的地图数组中的两个坐标,并且这个坐标是我们点击的第一个图案的坐标

3.7 回溯上层函数查看:是局部变量,发现追踪起来特别费劲,很容易跟丢。。

3.8 考虑从其他方法找突破口:首先看内存,这里面存的是一个地址,看看上下文,发现一个很眼熟的数字:18A688

3.8 这个地址正是之前2.14中使用的  ecx+0x494==18A688  18A688+0x19F0又是指南针道具函数的this指针,我们不妨进去指南针this指针看看里面的内容

3.9 这里面存储了一堆地址,我们发现,第一个成员加上0x30的值就等于参数5的值。

因为不同的电脑,基址有可能不同,即0233有可能不同,但是BC10这个是偏移,偏移是不会变的

所以我们不能直接使用0233BC10这个地址,而是选择在程序中动态获取。

3.10 查找参数6的值,参数6是LOCAL.3是上层函数的参数3,回到上层函数,发现参数3是EDI,溯源EDI,找到EDI是一个函数返回值赋值的

进入函数,发现EAX:

 

MOV ECX,DWORD PTR DS:[ESI+0x1E84]

MOV EAX,DWORD PTR DS:[ECX+0x50]

3.11 此时,已经找到了消除图案功能的函数,以及堆栈中的参数实际值,假设通过2.14得到了偏移坐标,编写代码模仿调用这个函数:

 

struct XY{
    int x;
    int y;
}
struct XXYY{
    XY XY1;
    XY XY2;
}

堆栈中的参数:

00189BA8   00000000   参数1

00189BAC   0018BB50  参数2

00189BB0   00189BDC  参数3

00189BB4   00189BE4   参数4

00189BB8   023DBC40  参数5

00189BBC   00000004   参数6




地址: eax+494
需要执行的指令:
mov ecx,this指针
push 参数6 
push 参数5 
push 参数4
push 参数3
push 参数2
push 参数1
 伪代码:
mov ecx,[0045DEBC]  //CE找到的基址
push 0x4                      //参数6
mov ebx,[ecx+0x494]                     //参数5
mov ebx,[ebx+0x19f0]
add ebx,0x30
push ebx                      //参数5

lea eax,[XYXY]          //参数4
push eax
lea eax,[XYXY+8]       //参数3
push eax
lea eax,[ecx+0x195C]  //参数2
push eax                      //CE找到的基址+0x195C
push0                          //参数1
call  41C68E

3.12 在OD里面测试代码

点击游戏中空白的区域,发现成功了,在坐标为(5,5)和(6,6)的地方消除了图案。验证了我们上面的猜测。现在只需要在调用消除函数之前,调用指南针函数,将坐标获取出来就可以完成破解了。

3.13 致此,已经获取的信息:

①道具函数的地址、道具函数的参数

②消除函数的地址、消除函数的参数

有了以上信息,我们就可以为所欲为了~~

编写辅助程序,使用以下功能:

    ①单次消除

    ②消除所有

    ③使用各种道具

四、编写辅助工具

  


添加Dialg对话框

 


//_beginthreadex函数的回调函数
unsigned __stdcall  ThreadProc() {

	CMyDlg  Dlg;
	Dlg.DoModal();
	return 0;
}

//原窗口回调
WNDPROC gOldProc = NULL;

//窗口回调
LRESULT CALLBACK MyWinProc(_In_ HWND hWnd, _In_ UINT Msg, _In_ WPARAM wParam, _In_ LPARAM lParam)
{
	//使用指南针
	if (Msg == WM_DATA1)
	{	
		__asm {
			mov ecx, 0x45debc
			mov ecx,[ecx]
			lea ecx, dword ptr ds : [ecx + 0x494]
			push 0xf0
			push 0
			push 0
			mov eax, 0x41e691		//EAX+0x28在内存中的值
			call eax
		}
		return CallWindowProc(gOldProc, hWnd, Msg, wParam, lParam);
	}
	//单次消除	
	if (Msg == WM_DATA2)
	{
		struct XY {
			int X;
			int Y;
		};
		XY XY1;
		XY XY2;
		//获取坐标
		__asm {
			mov ecx, 0x45DEBC
			mov ecx, [ecx]
			lea ecx, dword ptr ds : [ecx + 0x494]
			mov ecx, dword ptr ds : [ecx + 0x19F0]
			lea eax, XY1.X
			push eax
			lea eax, XY2.X
			push eax
			mov eax, 0x42923F			//调用获取坐标的函数
			call eax
		}
		//坐标都为0时
		if (XY1.X == 0  && XY1.X == 0 == XY2.X)
		{
			return -1;
		}
		//调用消除CALL
		__asm {
			mov ecx,0x0045DEBC
			mov ecx, [ecx]					//CE找到的基址
			mov eax, dword ptr ds : [ecx + 0x1e84]
			mov eax, dword ptr ds : [eax + 0x50]
			push eax								//参数6
			lea ebx, [ecx + 0x494+0x19f0]			//参数5
			mov ebx,[ebx]
			add ebx, 0x30
			push ebx						 //参数5			
			lea eax, XY2.X					 //参数4
			push eax
			lea eax, XY1.X					 //参数3
			push eax
			mov ecx, 0x0045DEBC
			mov ecx, [ecx]
			lea eax, [ecx + 0x195C]			 //参数2
			push eax						 //CE找到的基址+0x195C
			push 0							 //参数1
			mov eax,0x41C68E
			call eax
		}
		return DefWindowProc(hWnd, Msg, wParam, lParam);
	}
	
	//炸弹
	if (Msg == WM_DATA4){
		__asm {
			mov ecx, 0x45debc
			mov ecx, [ecx]
			lea ecx, dword ptr ds : [ecx + 0x494]
			push 0xf4
			push 0
			push 0
			mov eax, 0x41e691		//EAX+0x28在内存中的值
			call eax
		}
		return CallWindowProc(gOldProc, hWnd, Msg, wParam, lParam);
	}
	//重列
	if (Msg == WM_DATA5){
		__asm {
			mov ecx, 0x45debc
			mov ecx, [ecx]
			lea ecx, dword ptr ds : [ecx + 0x494]
			push 0xf1
			push 0
			push 0
			mov eax, 0x41e691		//EAX+0x28在内存中的值
			call eax
		}
		return CallWindowProc(gOldProc, hWnd, Msg, wParam, lParam);
	}
	//返回原窗口回调函数
	return CallWindowProc(gOldProc, hWnd, Msg, wParam, lParam);
}
// CMFCLLKApp 初始化
BOOL CMFCLLKApp::InitInstance(){
	CWinApp::InitInstance();
	//1.获取窗口句柄
	m_hWnd = ::FindWindow(NULL, L"QQ连连看");
	if (m_hWnd == NULL) {
		OutputDebugString(L"没有找到QQ连连看的窗口");
		return FALSE;
	}
	//2.设置窗口回调函数,返回值为旧回调
	gOldProc = (WNDPROC)SetWindowLong(m_hWnd, GWL_WNDPROC, (LONG)MyWinProc);
	if (gOldProc == NULL) {
		OutputDebugString(L"回调函数设置失败");
		return FALSE;
	}
	//3.创建一个线程弹出对话框
	_beginthreadex(0, 0, (_beginthreadex_proc_type)ThreadProc, 0, 0, 0);
	return TRUE;
}

全部消除功能截图:



【看雪培训】《Adroid高级研修班》2022年夏季班招生中!

最后于 2020-11-29 12:49 被三一米田编辑 ,原因:
上传的附件:
收藏
点赞4
打赏
分享
最新回复 (14)
雪    币: 2774
活跃值: 活跃值 (694)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
baobao雅 活跃值 1 2020-10-6 14:02
2
0
支持支持,眼熟的两个南瓜
雪    币: 23
活跃值: 活跃值 (250)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
靴子 活跃值 2020-10-6 17:17
3
0
雪    币: 23
活跃值: 活跃值 (250)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
靴子 活跃值 2020-10-6 17:19
4
0
分析的不错~源码也发一下吧 学习一下!
雪    币: 7113
活跃值: 活跃值 (125727)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
HlccFu 活跃值 2020-10-6 19:01
5
0
支持楼主
雪    币: 4268
活跃值: 活跃值 (3582)
能力值: ( LV9,RANK:160 )
在线值:
发帖
回帖
粉丝
三一米田 活跃值 2 2020-10-7 18:18
6
0
靴子 分析的不错~源码也发一下吧 学习一下!
void CMyDlg::OnBnClickedButton3()
{
	for (int i = 0; i < 100; i++)
	{
		int n = ::SendMessage(m_hWnd, WM_DATA2, 0, 0);
		if (n==-1)
		{
			break;
		}
	}
	
	// TODO: 在此添加控件通知处理程序代码
}


void CMyDlg::OnBnClickedButton4()
{
	::SendMessage(m_hWnd, WM_DATA4, 0, 0);
	// TODO: 在此添加控件通知处理程序代码
}


void CMyDlg::OnBnClickedButton5()
{
	::SendMessage(m_hWnd, WM_DATA5, 0, 0);
	// TODO: 在此添加控件通知处理程序代码
}

其他的都在上面附上了

上传的附件:
雪    币: 23
活跃值: 活跃值 (250)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
靴子 活跃值 2020-10-7 20:11
7
0
三一米田 void&nbsp;CMyDlg::OnBnClickedButton3() { for&nbsp;(int&nbsp;i&nbsp;=&nbsp;0;& ...
3Q!
雪    币: 2469
活跃值: 活跃值 (576)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
夜航星 活跃值 2020-10-11 09:58
8
0
不愧是大佬,比我以前写的那个直接检测数组用算法消除的强多了。
雪    币: 1009
活跃值: 活跃值 (61)
能力值: ( LV2,RANK:15 )
在线值:
发帖
回帖
粉丝
lynxtang 活跃值 2020-10-11 14:07
9
0
拜读~~大佬
雪    币: 4268
活跃值: 活跃值 (3582)
能力值: ( LV9,RANK:160 )
在线值:
发帖
回帖
粉丝
三一米田 活跃值 2 2020-10-11 16:36
10
0
夜航星 不愧是大佬,比我以前写的那个直接检测数组用算法消除的强多了。

大佬牛皮!~早我们N年呀

最后于 2020-10-11 16:41 被三一米田编辑 ,原因:
雪    币: 7873
活跃值: 活跃值 (1294)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
xie风腾 活跃值 2020-10-13 18:52
11
0

都是大牛淫,学习了
喜欢视频式,好保存
雪    币: 45
活跃值: 活跃值 (15)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
stefanieyk 活跃值 2020-11-10 23:50
12
0
学习了!
雪    币: 110
活跃值: 活跃值 (299)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
公章你又胖了 活跃值 2020-11-17 18:03
13
0
 一个简单的游戏,分析就这么复杂,好厉害
雪    币: 4462
活跃值: 活跃值 (2274)
能力值: ( LV6,RANK:81 )
在线值:
发帖
回帖
粉丝
KingSelyF 活跃值 1 2020-11-17 18:34
14
0

大佬牛逼,我写的是图像识别的辅助

最后于 2020-11-17 18:34 被KingSelyF编辑 ,原因:
雪    币: 220
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
QZ2019 活跃值 2021-8-24 19:52
15
0
郁金香第一个WG教程。不过实现模式不一样
游客
登录 | 注册 方可回帖
返回