关闭页面特效

自写一个植物大战僵尸修改器

这也是学习游戏安全的一部分(确信

主要是体会一下游戏里面的各种数据修改大概是个什么样子。先拿这个比较 ez 的练练手,后面写点更有趣的(指 fps 的挂

希望写出来不是屎山。现在暂时用命令行,写完主要功能后用 imgui 重写一遍

游戏版本:Plants vs. Zombies GOTY Edition(steam 的这玩意也没更新过吧

github地址,更到哪写到哪

1|0获取进程 id 和基址


这一步和游戏本身暂时没关系

Windows提供了CreateToolhelp32Snapshot函数来创建系统中进程快照,以及Process32FirstProcess32Next函数来遍历系统中的所有进程。

我们创建一个系统中所有进程的快照后,使用Process32First获取第一个进程信息,并通过Process32Next来遍历系统中的其他进程。每个进程的信息都保存在PROCESSENTRY32结构体中。遍历过程中,将每个进程的名字与 PlantVSZombie.exe 进行比较。如果找到匹配的进程名,则返回该进程的ID。这里使用wcscmp函数对进程名进行比较。

// 根据进程名获取进程 ID DWORD GetProcessIDByName(const wchar_t *processName) { HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); // 进程快照 if (snapshot == INVALID_HANDLE_VALUE) { return 0; } PROCESSENTRY32W pEntry = {0}; // PROCESSENTRY32 结构体,用于存储进程信息 pEntry.dwSize = sizeof(PROCESSENTRY32W); // 不初始化的话下面的 Process32First 函数会失败 if (!Process32FirstW(snapshot, &pEntry)) // 获取第一个进程 { CloseHandle(snapshot); return 0; } // 遍历进程列表 do { wchar_t currentProcessName[MAX_PATH] = {0}; // 存储当前进程名 wcscpy(currentProcessName, pEntry.szExeFile); // 将当前进程名拷贝到 currentProcessName if (wcscmp(currentProcessName, processName) == 0) // 如果当前进程名和目标进程名相同,返回进程 ID { CloseHandle(snapshot); return pEntry.th32ProcessID; } } while (Process32NextW(snapshot, &pEntry)); // 获取下一个进程 CloseHandle(snapshot); // 没有找到目标进程,关闭句柄 return 0; }

在多模块的进程中,获取目标模块的基址是访问模块内存的前提。我们可以使用EnumProcessModules函数枚举出目标进程的所有模块,然后通过GetModuleFileNameEx函数匹配出目标模块,最终使用GetModuleInformation获取模块基址。

// 根据进程 ID 和模块名获取基址 LPVOID GetModuleBaseAddress(DWORD ProcessID, LPCWSTR moduleName) { LPVOID lpBaseAddress = NULL; HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, ProcessID); // 打开进程句柄 if (hProcess == NULL) { return lpBaseAddress; } HMODULE hMods[1024]; // 存储模块句柄 DWORD cbNeeded; // 存储模块句柄数量 if (EnumProcessModules(hProcess, hMods, sizeof(hMods), &cbNeeded)) // { DWORD dwModuleCount = cbNeeded / sizeof(HMODULE); // 模块数量 for (DWORD i = 0; i < dwModuleCount; i++) { wchar_t szModuleName[MAX_PATH] = {0}; // 存储模块名 if (GetModuleFileNameEx(hProcess, hMods[i], szModuleName, sizeof(szModuleName) / sizeof(wchar_t))) // 获取模块名 { if (wcsstr(szModuleName, moduleName) != NULL) // 如果模块名包含目标模块名 { MODULEINFO moduleInfo = {0}; if (GetModuleInformation(hProcess, hMods[i], &moduleInfo, sizeof(moduleInfo))) // 获取模块信息,保存到 moduleInfo { lpBaseAddress = moduleInfo.lpBaseOfDll; // 获取模块基址 break; } } } } CloseHandle(hProcess); // 关闭进程句柄 } return lpBaseAddress; }

2|0修改&锁定阳光


这当然是最基本的一个功能

首先得把阳光的偏移给找出来,用我们最爱的 Cheat Engine

[自写一个植物大战僵尸修改器] 1

这里可以看见已经可以改阳光了,现在我们得找出修改阳光数值的基址在哪了,右键 -> 找出是什么访问了这个地址,

[自写一个植物大战僵尸修改器] 2

这里可以看见,两个偏移都是 0x5578,那随便看哪个都行,比如我们看这个 add eax

[自写一个植物大战僵尸修改器] 3

这里可以看见,edx 是 0x29C39328,那我们再搜这个值

[自写一个植物大战僵尸修改器] 4

这下地址就很多了,一个简单的筛查方法是,看地址的前2 3位,如果这几位相同的比较多,那大概率没啥用。这样我们就看到 0x3BBC1C0 这个地址,用它试试咯,依然是重复上面的过程

[自写一个植物大战僵尸修改器] 5

这次我们看见了绿色的基址,而且这个东西不是啥系统进程的,它就是 PVZ 进程里面的。我们随便挑一个试试

[自写一个植物大战僵尸修改器] 6

两层偏移,分别是 5578 和 868,然后确定,指针的结果就是我们地址栏里面最下面那一行,可以看见数值和阳光的值是一样的,那么这里就是修改阳光的基址了,0x400000 + 0x331CDC = 0x731CDC。

其实这四个绿色的基址都是修改阳光的,用同样的偏移都能指到 233 这个值

现在我们知道阳光的偏移是 +868 +5578了。

为什么我们要这么费力去找这个基址呢,我们重开一下这把游戏看看

[自写一个植物大战僵尸修改器] 7

可以看到,我们最开始改阳光的那个地址还是 233,没随着游戏重开变成 50,但是我们用基址+偏移找到的这个值随着改变了,我们修改这个50,现在的阳光也会随着改变。

现在我们右键看是什么改写了该指针指向的地址,然后种一个植物,对找到的汇编查看反汇编窗口

[自写一个植物大战僵尸修改器] 8

很简单的汇编,此处 sub 就是消耗阳光的汇编

更进一步的话,esi 保存的是当前的阳光的值,复制到了 eax 里面,ebx 是当前植物种植所需的阳光

这里就有几个想法了。我们把 sub 改成 add,就能实现电表倒转,或者直接把这个 sub 给 nop 掉,种植就不再需要阳光了。

这又衍生出一个想法,我们再找到增加阳光的代码,把那里的 add 给 nop 掉,是不是就完成了锁定阳光

[自写一个植物大战僵尸修改器] 9

这里就是增加阳光的地方了。

综上,我们修改阳光的方法有两种:

一种是让用户输入一个值,把阳光改到这么多完事,另一种就是把阳光锁定在一个值上。

锁定阳光的话也有两种写法,一种是把增加阳光和减少阳光全给 nop 掉,另一种就是设一个时钟,每隔半秒或者一秒改一次阳光的值。这里就全 nop 了吧

enum editType { Sunlight }; // 修改某类值 BOOL EditTypeValue(HANDLE hProcess, DWORD BaseAddress, editType type, int value) { DWORD addr = BaseAddress; switch (type) { case Sunlight: addr += 0x331CDC; ReadProcessMemory(hProcess, (LPVOID)addr, &addr, sizeof(DWORD), NULL); addr += 0x868; break; default: break; } ReadProcessMemory(hProcess, (LPVOID)addr, &addr, sizeof(DWORD), NULL); addr += offsetTable[type]; return WriteProcessMemory(hProcess, (LPVOID)addr, &value, sizeof(DWORD), 0); } // 获取某类值 int GetTypeValue(HANDLE hProcess, DWORD BaseAddress, editType type) { DWORD addr = BaseAddress; switch (type) { case Sunlight: addr += 0x331CDC; ReadProcessMemory(hProcess, (LPVOID)addr, &addr, sizeof(DWORD), NULL); addr += 0x868; break; default: break; } ReadProcessMemory(hProcess, (LPVOID)addr, &addr, sizeof(DWORD), NULL); addr += offsetTable[type]; int value = 0; ReadProcessMemory(hProcess, (LPVOID)addr, &value, sizeof(DWORD), 0); return value; } // 锁定阳光 BOOL LockSunlight(HANDLE hProcess, DWORD BaseAddress, int value) { unsigned char newCode_addSunlight[6] = {0x90, 0x90, 0x90, 0x90, 0x90, 0x90}; unsigned char newCode_subSunlight[2] = {0x90, 0x90}; BOOL flag = 1; flag &= EditTypeValue(hProcess, BaseAddress, Sunlight, value); flag &= WriteCodeToProcessMemory(hProcess, (LPVOID)(BaseAddress + 0x1F4D0), newCode_addSunlight, 6); flag &= WriteCodeToProcessMemory(hProcess, (LPVOID)(BaseAddress + 0x1F634), newCode_subSunlight, 2); return flag; } // 解除阳光锁定 BOOL UnlockSunlight(HANDLE hProcess, DWORD BaseAddress) { unsigned char origCode_addSunlight[6] = {0x01, 0x88, 0x78, 0x55, 0x00, 0x00}; unsigned char origCode_subSunlight[2] = {0x2B, 0xF3}; BOOL flag = 1; flag &= WriteCodeToProcessMemory(hProcess, (LPVOID)(BaseAddress + 0x1F4D0), origCode_addSunlight, 6); flag &= WriteCodeToProcessMemory(hProcess, (LPVOID)(BaseAddress + 0x1F634), origCode_subSunlight, 2); return flag; }

3|0植物卡槽


3|1卡槽无 CD


每个植物在种植之后,其卡槽会进入冷却。我们可以看见卡槽在 CD 中是有动画效果的,这个不好截图,就是它的颜色在从下到上变,最后发光那个过程。那么这里肯定会有一个时钟在确定卡槽的 CD。

每个卡槽的 CD 肯定是不互通的,我们先来找第一个卡槽的 CD。在 CE 里面先扫未知的初始值,然后看着卡槽的 CD 动画,每变一点就扫一次变动的数值,到最后 CD 好了再一直扫未变动的数值。

扫到最后应该就几个值了,这个时候再种一下第一个植物,就会发现有一个值变了

[自写一个植物大战僵尸修改器] 10

那这里就是第一个卡槽冷却的地方了,修改这个值就能看见冷却在变化了。

现在再把向日葵种下去,观察这个数值的变化,可以发现大概在 750 左右的时候就变成 0 了。

我们试试把它改一个更大的值,比如说 1000

[自写一个植物大战僵尸修改器] 11

可以看见向日葵的 CD 一下子就好了。但是这个时候取消一下暂停又会发现,1000 的值迅速地变为 0 了

尝试了一下用 CE 锁定这个地址的值,把它锁定到 1000 的时候是一直无 CD 的,锁定到 0 反而是一直在 CD 中,没法种植。

现在考虑怎么改。首先,这个植物 CD 时钟的函数肯定是各个卡槽共用的,只不过是 CD 不同而已,我们只用找到修改第一个卡槽的 CD 的地方,把这里的东西修修改改就行。

[自写一个植物大战僵尸修改器] 12

依然是直接跳到汇编,这个 inc 是汇编的 +1,刚刚我们观察卡槽 1 的 CD 的时候,也是发现了 CD 的值是一路 +1 到某个值后清零的。jle 是 前操作数小于等于后操作数就 jmp,那这里很明显了,eax 里面存的是当前 cd 到哪了,[edi+28] 存的是当前植物的 CD 上限,到上限后就 CD 完毕。那这里把这个 jle nop 了就行,写法和之前一样的。

BOOL CardslotUnCoolDown(HANDLE hProcess, DWORD BaseAddress) { unsigned char newCode[2] = {0x90, 0x90}; return WriteCodeToProcessMemory(hProcess, (LPVOID)(BaseAddress + 0x958C5), newCode, 2); } BOOL CardslotCoolDownRollback(HANDLE hProcess, DWORD BaseAddress) { unsigned char origCode[2] = {0x7E, 0x14}; return WriteCodeToProcessMemory(hProcess, (LPVOID)(BaseAddress + 0x958C5), origCode, 2); }

3|2更改卡槽植物


这个功能就是想修改某个卡槽的植物变为其它植物。有一个比较特殊的就是,坚果保龄球里面卡槽是滚动的,而且里面有大坚果和爆炸坚果,这个植物图鉴里面是没有的。一个好消息是,植物的 ID 和下面这个植物图鉴里面的序号是一样的,从左往右从上往下数即可,这一点可以在改卡槽无 CD 的时候浏览内存区域发现(当时忘写了,懒得补了,在这里做到了再写一下)

[自写一个植物大战僵尸修改器] 13

那我们就在坚果保龄球里面看看吧,记得开坚果保龄球 2,因为 1 里面我记得是没有爆炸坚果和大坚果的。

上面的传送带开滚的时候大概是这样

[自写一个植物大战僵尸修改器] 14

这个时候卡槽从左往右是 1 2 3 槽。那么我们如果把卡槽 1 的爆炸坚果用掉,卡槽 2(坚果)就顺延成卡槽 1 ,卡槽 3 就顺延成卡槽 2(大坚果)了。相信大家写类似的代码的时候不会把卡槽无限开吧,毕竟传送带里面的卡也是有上限的(

那么我们这样找。按图鉴看,普通坚果编号是 3 ,那么当卡槽 1 是普通坚果的时候,我们一直扫描 3,然后把后面的普通坚果用掉,直到卡槽 2 变成爆炸坚果或者大坚果。这个时候我们把卡槽 1 的普通坚果用掉,那么卡槽 1 就变成特殊坚果了。现在由于我们不知道特殊坚果的编号,但是我们知道卡槽 1 的植物从普通坚果变成特殊的坚果了,这个值变动了,所以直接搜变动的值,然后可以等一会后再搜未变动的值,或者再把卡槽 1 匀一个普通坚果出来。

[自写一个植物大战僵尸修改器] 15

这样我们就找到卡槽 1 了。现在我们可以随意的试一下,可以发现模仿者是 48,爆炸坚果是 49,大坚果是 50

其它一些特殊的是:反向的双重射手 52,弹坑 55(这玩意点一下就崩溃了谔谔)53 改了就会崩溃,咱也不知道是个啥,51 叫幼苗,我反正没想起来这玩意在哪见过,长这样,如果有懂哥可以在博客下面评论一下

[自写一个植物大战僵尸修改器] 16

60就是普通僵尸了。

让我们来看看内存区域,箭头指向的地方可以很容易发现,这仨分别是卡槽 1 2 3 里面放的植物的地址。其实在写卡槽无 CD 的时候这里还能看见每个卡槽里面植物的当前 CD 和 CD 上限,有兴趣的话可以自己去对比一下。

[自写一个植物大战僵尸修改器] 17

然后就是和阳光一样找卡槽的基址了,这里我们放了 5 次坚果

[自写一个植物大战僵尸修改器] 18

我们在计数是 5 的偏移里面一点一点找下去,找找 +5C,每次放一两个坚果,然后对着计数一样的偏移找下去,这后面会很卡,好在也就 3 层偏移

[自写一个植物大战僵尸修改器] 19

有没有发现最后这里的 0x731CDC 很熟悉?我们上面改植物的时候也是这个基址出发的。

那么卡槽植物的偏移也找到了,+868 +15C +5C,两个卡槽植物间隔 0x50

[自写一个植物大战僵尸修改器] 20

这里还能看见一个有趣的东西是,卡槽 1 没植物的时候值是 -1,也就是 0xFFFFFFFF

BOOL SetCardslotPlant(HANDLE hProcess, DWORD BaseAddress, DWORD slot, DWORD plant) { DWORD cardslotAddress = BaseAddress + 0x331CDC; ReadProcessMemory(hProcess, (LPVOID)cardslotAddress, &cardslotAddress, sizeof(DWORD), NULL); cardslotAddress += 0x868; ReadProcessMemory(hProcess, (LPVOID)cardslotAddress, &cardslotAddress, sizeof(DWORD), NULL); cardslotAddress += 0x15C; ReadProcessMemory(hProcess, (LPVOID)cardslotAddress, &cardslotAddress, sizeof(DWORD), NULL); cardslotAddress += 0x5c; cardslotAddress += slot * 0x50; return WriteProcessMemory(hProcess, (LPVOID)cardslotAddress, &plant, sizeof(DWORD), NULL); }

4|0自动收集


就是自己去点阳光和金币

先看阳光。阳光没点的时候就一直呆在那,过一会就消失,点了就播放动画跑到右上角的阳光总数那然后把阳光加进去。这里就涉及到阳光有没有被点击的问题,是一个 bool 变量,没被点和被点了就是 0 和 1 的问题。

于是就按这个去搜,阳光出现并且没被点击的时候初值 0 ,不点它然后一直搜未变动,然后在新的阳光出来之前再把这个阳光点了搜精确值 1,不止一个地址就重复上述操作

然后找出是什么改写了这个地址

[自写一个植物大战僵尸修改器] 21

中间两行是阳光在往下掉的同时计数器一直在变,暂停游戏后就不变了。然后第三行 cmp 的是 bl,不是固定值,也不是我们要找的,所以看第四行就行

[自写一个植物大战僵尸修改器] 22

很显然,jne 是在说值不是 0 则跳转,也就是被点击了就跳转,那我们强制改成 jmp 程序就会认为我们一直在点击阳光了,试了一下还真是。种了个金盏花想试试自动收集硬币的时候发现这里好像不止是收集阳光,硬币也一起点掉了,那省事了。

#include <windows.h> #include "processOption.h" BOOL AutoclickSunlightAndCoin(HANDLE hProcess, DWORD BaseAddress) { unsigned char newCode[1] = {0xEB}; return WriteCodeToProcessMemory(hProcess, (LPVOID)(BaseAddress + 0x352F2), newCode, 1); } BOOL AutoclickRollback(HANDLE hProcess, DWORD BaseAddress) { unsigned char origCode[1] = {0x75}; return WriteCodeToProcessMemory(hProcess, (LPVOID)(BaseAddress + 0x352F2), origCode, 1); }

写完后试了下,休闲花园产的硬币钻石也可以用这个自动收集,看来全是一套代码。

5|0植物加强


5|1子弹多发


比如说豌豆射手射出去的子弹 biu biu biu 一次发射很多发那种

我们可以看见,每种植物发射一发子弹都有一个间隔的 CD,CD 到了才会发射下一发子弹。那么这里的思路就和之前卡槽无 CD 一样,找植物发射子弹的 CD 即可。

说是即可,其实找了很久,最后还是用的卷心菜投手观察的。最后发现,这个子弹射出去后,也就是投手上没有卷心菜的时候那个值一直为 1,在投手上有卷心菜的时候一直为 0。也有其它的值,但是变化的非常快。

现在我们来看什么访问了这个地址,然后继续观察

[自写一个植物大战僵尸修改器] 23

上面 5 个全是时钟一样的值,一直在加,倒数第二个是每射一发子弹就 +1。至少可以确定是改时钟的指令,试了下,在改第二条指令的时候,我们用 CE 写了个 hook

[自写一个植物大战僵尸修改器] 24

把 eax 的值固定为 0 会发现根本没有子弹射出去,然后固定为 1 会发现一直在射子弹

[自写一个植物大战僵尸修改器] 25

后面又做了次 CE 发现了点不同的东西

[自写一个植物大战僵尸修改器] 26

中间 4 条指令是在卷心菜投手有发射子弹的动作的时候才有这个时钟变化,之前那次不知道为什么一直在变,没有这个区别。还记得我们之前说的

最后发现,这个子弹射出去后,也就是投手上没有卷心菜的时候那个值一直为 1,在投手上有卷心菜的时候一直为 0。也有其它的值,但是变化的非常快。

那么这里应该是有一个射击动作的判断,如果处于射击动作就会为 1,否则为 0,这里的时钟刚好有一个 cmp 1 和一个 cmp 0,我们在 cmp 1 看看

[自写一个植物大战僵尸修改器] 27

把 jne 给 nop 掉会发现,卷心菜投手处于射击动作的时候会一直射卷心菜,处于预备动作的时候就不射了。

[自写一个植物大战僵尸修改器] 28

比之前那个观感好一些,估计也不那么容易崩溃了。

这又引发一个问题,要是想把 eax 直接改成 1 不能直接改,会把 6 字节的指令变成 10 字节,于是选择新写一个 Hook 函数,新开辟一个空间,把原指令也存到这个空间里面,方便解除 Hook

// 开辟新空间进行 Hook LPVOID Hook(HANDLE hProcess, LPVOID aimAddr, LPCVOID hookCode, SIZE_T hookSize, SIZE_T origSize) { memset(origCode, 0, origSize + 5); BYTE jumpCode[5] = {0xE9, 0x00, 0x00, 0x00, 0x00}; if (!ReadProcessMemory(hProcess, aimAddr, origCode, origSize, NULL)) // 保存原代码 { return NULL; } LPVOID allocAddr = VirtualAllocEx(hProcess, NULL, hookSize + origSize + 5, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); // 申请内存,保存原代码,Hook 代码和返回代码 if (!allocAddr) return NULL; *(DWORD *)(jumpCode + 1) = (DWORD)aimAddr + 5 - ((DWORD)allocAddr + hookSize + origSize + 5); // 计算返回地址 if (!WriteCodeToProcessMemory(hProcess, allocAddr, origCode, origSize) // 写入原代码 || !WriteCodeToProcessMemory(hProcess, (LPVOID)((DWORD_PTR)allocAddr + origSize), hookCode, hookSize) // 写入 Hook || !WriteCodeToProcessMemory(hProcess, (LPVOID)((DWORD_PTR)allocAddr + origSize + hookSize), jumpCode, 5)) // 写入返回地址 { VirtualFreeEx(hProcess, allocAddr, 0, MEM_RELEASE); return NULL; } *(DWORD *)(jumpCode + 1) = ((DWORD)allocAddr + origSize) - ((DWORD)aimAddr + 5); if (!WriteCodeToProcessMemory(hProcess, aimAddr, jumpCode, 5)) // 在原地址处写入跳转代码 { VirtualFreeEx(hProcess, allocAddr, 0, MEM_RELEASE); return NULL; } if (origSize > 5) // 原代码长度大于 5 时要 nop 多余字节 { DWORD length = origSize - 5; memset(nopCode, 0x90, sizeof(nopCode)); if (!WriteCodeToProcessMemory(hProcess, (LPVOID)((DWORD_PTR)aimAddr + 5), nopCode, origSize - 5)) { VirtualFreeEx(hProcess, allocAddr, 0, MEM_RELEASE); return NULL; } } return allocAddr; } // 解除 Hook BOOL UnHook(HANDLE hProcess, LPVOID aimAddr, LPVOID allocAddr, SIZE_T origSize) { if (!ReadProcessMemory(hProcess, allocAddr, origCode, origSize, NULL)) return FALSE; if (!WriteCodeToProcessMemory(hProcess, aimAddr, origCode, origSize)) return FALSE; if (!VirtualFreeEx(hProcess, allocAddr, 0, MEM_RELEASE)) return FALSE; return TRUE; }

那么这里的子弹多发就可以这样写了,SafeMode 就是子弹射的少的那个方法

LPVOID MultipleBullets(HANDLE hProcess, DWORD BaseAddress) { BYTE hookCode[] = { 0xC7, 0x87, 0x90, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00 }; SIZE_T origSize = 6; SIZE_T hookSize = sizeof(hookCode); return Hook(hProcess, (LPVOID)(BaseAddress + 0x6ACBA), hookCode, hookSize, origSize); } BOOL MultipleBulletsRollback(HANDLE hProcess, DWORD BaseAddress, DWORD allocAddr) { return UnHook(hProcess, (LPVOID)(BaseAddress + 0x6ACBA), (LPVOID)allocAddr, 6); } BOOL MultipleBulletsSafeMode(HANDLE hProcess, DWORD BaseAddress) { unsigned char newCode[6] = {0x90, 0x90, 0x90, 0x90, 0x90, 0x90}; return WriteCodeToProcessMemory(hProcess, (LPVOID)(BaseAddress + 0x6AEC4), newCode, 6); } BOOL MultipleBulletsSafeModeRollback(HANDLE hProcess, DWORD BaseAddress) { unsigned char origCode[6] = {0x0F, 0x85, 0x98, 0xFE, 0xFF, 0xFF}; return WriteCodeToProcessMemory(hProcess, (LPVOID)(BaseAddress + 0x6AEC4), origCode, 6); }

5|2重叠种植


一个格子种多种植物

绝赞更新中...


__EOF__

作  者iPlayForSG
出  处https://www.cnblogs.com/Here-is-SG/p/18408806
关于博主:编程路上的小学生,热爱技术,喜欢专研。评论和私信会在第一时间回复。或者直接私信我。
版权声明:署名 - 非商业性使用 - 禁止演绎,协议普通文本 | 协议法律文本
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!

posted @   iPlayForSG  阅读(143)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
点击右上角即可分享
微信分享提示