Board logo

标题: [原创改版教程] nds 3D游戏hack入门(2) 3D矩阵篇 [打印本页]

作者: enler    时间: 2013-3-14 01:33     标题: nds 3D游戏hack入门(2) 3D矩阵篇

nds有完整的矩阵运算体系,这些矩阵不仅将不同的坐标系进行转换,缩放、旋转、平移等,均可以通过矩阵实现,还有专门的矩阵栈用来存储矩阵
矩阵是相当重要的一块,掌握好这一块,可以做到很多事情,本文将在编写的过程中穿插一些hack实例

定点数
nds的3D引擎不采用浮点数,而采用嵌入式系统常见的定点数,这里有必要做个介绍
定点数以固定的位保存小数,固定的位保存整数的方式存储一个小数,3D矩阵的元素用的定点数是
1bit符号 19bit整数 12bit小数
这样的一种格式
*如果你知道定点数如何转换等问题,下面请直接pass

引用
复制内容到剪贴板
代码:
#define fix2float(v)    (((float)((s32)(v))) / (float)(1<<12))
这是desmume中用于将定点数转换为浮点数的表达式,我觉得这个挺赞的,稍微讲解一下
我们都知道,10进制中,你把一个数除以10,就相当于小数点向前移动1位,比如53,除以10,就是5.3,除以100就是移动2位
在二进制中也满足这个规律,除以(1<<12),就相当于把小数点向前移动12位

*如果这部分看懂了,下面请直接pass

接下来是手动计算,以win7的计算器为例,因为win7的计算器中,程序员模式是不支持小数运算的,只有标准型跟科学型支持,所以第一步就是将16进制转换成10进制了
在转换的时候,请把左下角的格式设置为“双字”,这样诸如0xFFFFFFFF的数字,可以被正确地转换为-1
[attach]30360[/attach]
接着把值复制到粘贴板上(编辑->复制),计算器的模式转成科学型,把这个数值粘贴进去,除以4096,即可得到转换后的小数了
比如0xFFFFFC00,转成10进制,就是-1024,除以4096,就是-0.25

*如果这部分看不懂的话,请重复阅读直到看懂为止,谢谢

将小数转为定点数存储
首先要把小数部分跟整数部分分开,然后小数部分,连续12次乘以2,在连乘的过程中,整数部分舍弃,如果遇到小数部分刚好为0,则停止运算,后面自动补0
每连乘一次,根据运算结果,将二进制数0/1添加到小数点后面,如果运算结果超过1,添加1,没有超过,添加0
举个例子,0.25
0.25 * 2 = 0.5 -> 小数点后面添加0
0.5 * 2 = 1.0 -> 小数点后面添加1,因为小数点后面为0,停止运算,接着补0
所以小数部分是010000000000b,整数部分是0,所以最后的结果就是0x400

这个还是要掌握的,游戏内的相关API,会要求你用定点数作为参数传递

3D矩阵简介
NDS的3D矩阵采用4x4矩阵,以行优先的方式存储,矩阵的乘法都是右乘,如果记原矩阵为C,要乘以的目标矩阵记为M,则有M*C

引用
复制内容到剪贴板
代码:
   _      4x4 矩阵         _
  | m[0]  m[1]  m[2]  m[3]  |
  | m[4]  m[5]  m[6]  m[7]  |
  | m[8]  m[9]  m[10] m[11] |
  |_m[12] m[13] m[14] m[15]_|
接着有几种特殊的矩阵

引用
复制内容到剪贴板
代码:
   _      单位矩阵         _
  |  1.0   0     0     0    |
  |  0     1.0   0     0    |
  |  0     0     1.0   0    |
  |_ 0     0     0     1.0 _|

   _      4x3 矩阵         _        _       平移矩阵        _
  | m[0]  m[1]  m[2]   0    |      |  1.0   0     0     0    |
  | m[3]  m[4]  m[5]   0    |      |  0     1.0   0     0    |
  | m[6]  m[7]  m[8]   0    |      |  0     0     1.0   0    |
  |_m[9]  m[10] m[11]  1.0 _|      |_m[0]  m[1]  m[2]   1.0 _|


   _      3x3 矩阵         _        _       缩放矩阵        _
  | m[0]  m[1]  m[2]   0    |      | m[0]   0     0     0    |
  | m[3]  m[4]  m[5]   0    |      |  0    m[1]   0     0    |
  | m[6]  m[7]  m[8]   0    |      |  0     0    m[2]   0    |
  |_ 0     0     0     1.0 _|      |_ 0     0     0     1.0 _|
这几个矩阵,NDS的3D引擎有提供相应的I/O寄存器以简化运算

然后NDS的3D引擎中,存在着4种矩阵,分别是
1.投影矩阵
2.position矩阵(ModelView矩阵)
3.directional矩阵
4.纹理矩阵

其中,投影矩阵将世界坐标系的点变换到camera坐标系的CVV中,position矩阵将模型坐标系的点变换到世界坐标系中,
directional矩阵似乎是用在光照特效中,用来调整光线的方向等,这个系列的教程不进行讨论,
纹理矩阵是把输入进去的坐标转换成完全与纹理相对应的纹理坐标,很多游戏中这个矩阵都是单位矩阵,可以无视,NDS的纹理坐标可以做到±2048的寻址范围,所以一般也用不到这个

一般一个点的坐标要乘以position矩阵,然后再乘以投影矩阵,将坐标变换到CVV,然后CVV再进一步变换就是我们在屏幕上看到的图像了
坐标的平移、旋转、缩放等变换,都可以通过构造特定的矩阵,接着通过矩阵的乘法运算完成
因为矩阵的乘法满足结合律,记点的齐次坐标为C,平移矩阵为T,position矩阵为P,(C * T) * P = C *(T * P)
上文提到,NDS的矩阵乘法是右乘,因此在实际应用中,可以先把坐标变换的矩阵乘以position矩阵,然后坐标再统一乘过去

这几个矩阵可以在nds的I/O map窗口中的LCD-3D'中确认到

[attach]30361[/attach]

矩阵运算的I/O寄存器

引用
复制内容到剪贴板
代码:
  4000440h 10h MTX_MODE - 设置矩阵模式(W)
  4000454h 15h MTX_IDENTITY - 读取单位矩阵到当前矩阵(W)
  4000458h 16h MTX_LOAD_4x4 - 读取4x4矩阵到当前矩阵(W)
  400045Ch 17h MTX_LOAD_4x3 - 读取4x3矩阵到当前矩阵(W)
  4000460h 18h MTX_MULT_4x4 - 将4x4矩阵乘以当前矩阵(W)
  4000464h 19h MTX_MULT_4x3 - 将4x3矩阵乘以当前矩阵(W)
  4000468h 1Ah MTX_MULT_3x3 - 将3x3矩阵乘以当前矩阵(W)
  400046Ch 1Bh MTX_SCALE - 将缩放矩阵乘以当前矩阵(W)
  4000470h 1Ch MTX_TRANS - 将平移矩阵乘以当前矩阵(W)
注意,这些I/O寄存器都是只写的,矩阵运算可以针对上文提到的4个矩阵进行,由MTX_MODE进行设置
MTX_MODE的参数如下

引用
复制内容到剪贴板
代码:
  0-1   矩阵模式 (0..3)
         0  投影矩阵
         1  Position矩阵(Modelview矩阵)
         2  似乎是运算同时作用于position矩阵与directional矩阵的模式
         3  纹理矩阵
  2-31  未使用
关于模式2的话,我自己也做了一些测试,这是白金版中模式2的情况

[attach]30362[/attach]

这是模式1的情况

[attach]30363[/attach]

可以看出,模式1的颜色明显偏暗,由于对directional矩阵没有研究,在这里还不能下任何结论,现阶段知道它对光照特效有影响就行

好了,接下来讲解一下几个矩阵运算的I/O寄存器的用法

比如MTX_LOAD_4x4,读取一个4x4到当前矩阵,例子如下

代码
汇编语言
复制内容到剪贴板
代码:
ldr r0,=0x4000458
ldr r1,=p_MTX_4x4//指向一个有效的4x4矩阵的指针
mov r2,0x0
loop:
ldr r3,[r1],0x4
str r3,[r0]
add r2,r2,0x1
cmp r2,0x10
blt loop
C语言
复制内容到剪贴板
代码:
for(int i = 0;i < 0x10;i++ )
        *((int*)0x4000458) = *(p_MTX_4x4++);
其他几个都是一样的,就是矩阵格式变了一下,格式请参考3D矩阵简介那部分
关于MTX_IDENTITY,这个比较特殊,写入任意值即可完成操作,毕竟单位矩阵是固定的

相关的API
复制内容到剪贴板
代码:
void G3_MtxMode(GXMtxMode mode);
void G3_Identity();
void G3_LoadMtx43(const MtxFx43* m);
void G3_LoadMtx44(cosnt G3Mtx44* m);
void G3_MultMtx43(const MtxFx43* m);
void G3_MultMtx44(const G3Mtx44* m);
void G3_MultMtx33(const MtxFx33* m);
void G3_MultTransMtx33(const MtxFx33* m);
void G3_Scale(fx32 x, fx32 y, fx32 z);
void G3_Translate(fx32 x, fx32 y, fx32 z);//这个跟G3_MultTransMtx33的作用是一样,这里的fx32指的是定点数
有人想必注意到了,为什么没有旋转矩阵?nds的3D I/O寄存器不直接提供关于旋转矩阵的接口,旋转矩阵需要通过构造一个3x3矩阵来完成
引用
复制内容到剪贴板
代码:
       绕X轴旋转             绕Y轴旋转               绕Z轴旋转
  | 1.0  0     0   |     | cos   0    sin |     | cos   sin   0   |
  | 0    cos   sin |     | 0     1.0  0   |     | -sin  cos   0   |
  | 0    -sin  cos |     | -sin  0    cos |     | 0     0     1.0 |
这里的正弦与余弦,必须对应同一个角度的
很幸运的是,SDK里有提供相关的API
复制内容到剪贴板
代码:
void G3_RotX(fx32 s, fx32 c);//绕X轴旋转
void G3_RotY(fx32 s, fx32 c);//绕Y轴旋转
void G3_RotZ(fx32 s, fx32 c);//绕Z轴旋转
参数s是目标角度的正弦,参数c是目标角度的余弦

简析投影矩阵与position矩阵

先来投影矩阵,投影矩阵的主要任务,就是将camera坐标系的视锥体(view volume)中的坐标变换到camera坐标系中的正规化可视空间上(canonical view volume,缩写CVV)
视堆体可以理解为我们用肉眼观察到的空间,CVV是个长宽高均为2的正方体,其中2个点位于(1,1,1)跟(-1,-1,-1),下面的图并没有体现出CVV,请脑补
投影有2种,一种是正交投影,一种是透视投影,2种投影有不同的视堆体

引用
复制内容到剪贴板
代码:
         透视投影                   正交投影
                  ___                  __________
       top ___----   |            top |          |
          |   view   |                |   view   |
  Eye ----|--------->|        Eye ----|--------->|
          |__volume  |                |  volume  |
     bottom   ----___|          bottom|__________|
        near        far             near        far
[attach]30364[/attach]

透视投影

[attach]30365[/attach]

正交投影

透视投影比较符合人的肉眼所观察到的图像,远小近大,3D游戏基本上用这个,正交投影常见于某些2D游戏,比如pokemon ranger系列,用正交投影的经典例子

视堆体中,靠近camera的那一面叫做近截面(near clip plane),离camera最远的那一面叫做远截面(far clip planes),
camera与近截面的垂直距离记为n,与远截面的垂直距离记为f,近截面面向camera方向的右上角坐标记为(r,t,n),这里的r t分别为right与top的首字母
近截面面向camera方向的左下角坐标记为(l,b,n),l b分别为left与bottom的首字母

对于正交投影,存在如下矩阵将视锥体的坐标转换到CVV中
复制内容到剪贴板
代码:
  | (2.0)/(r-l)       0             0            0     |
  |      0       (2.0)/(t-b)        0            0     |
  |      0            0        (2.0)/(n-f)       0     |
  | (l+r)/(l-r)  (b+t)/(b-t)   (n+f)/(n-f)      1.0    |
对于透视投影,存在如下矩阵将视锥体的坐标转换到CVV中
复制内容到剪贴板
代码:
  | (2*n)/(r-l)       0             0            0     |
  |      0       (2*n)/(t-b)        0            0     |
  | (r+l)/(r-l)  (t+b)/(t-b)   (n+f)/(n-f)     -1.0    |
  |      0            0       (2*n*f)/(n-f)      0     |
具体的推导方式请查阅文末的参考资料

此外,对于透视投影,上面那种可以理解为透视投影的一般形式,记做Frustum透视投影(Frustum有平截头体的意思,但连起来的中文我不知道)
此外还有种Perspective透视投影(你说Perspective就是透视的意思,没错啊,很别扭是吧,我也觉得,GBATEK就是这么描述,我也费解)的特殊形式的投影
SDK内是把一般形式的透视投影记为Frustum投影,而把特殊形式的透视投影记为Perspective投影

Perspective透视投影有如下特征,r = -l,t = -b,Z轴正好垂直并穿过近截面跟远截面的中心
如图所示
[attach]30366[/attach]

[attach]30367[/attach]

这是侧面看过去的样子,注意θ,下面的运算要用到它

接着把r = -l t = -b,代入到上面的式子内,得到
复制内容到剪贴板
代码:
  |     n/r           0             0            0     |
  |      0           n/t            0            0     |
  |      0            0        (n+f)/(n-f)     -1.0    |
  |      0            0       (2*n*f)/(n-f)      0     |
先考虑n/t,根据三角函数的定义有,n/t = cotθ = cosθ/sinθ
然后是n/r,r跟t的关系,可以用近截面的宽高比来转换,记高宽比asp = height/width,r = t * asp
代入进去,n/r = n/(t *asp) = (n/t) * (1/asp),n/t的话,上面有了,就是cosθ/sinθ,所以最终的式子就是cosθ/(asp*sinθ)

最终的矩阵
复制内容到剪贴板
代码:
  | cos/(asp*sin)     0             0            0     |
  |      0         cos/sin          0            0     |
  |      0            0        (n+f)/(n-f)     -1.0    |
  |      0            0       (2*n*f)/(n-f)      0     |
好了,终于可以介绍相关的API了,上面写那么多,都是为了这个
复制内容到剪贴板
代码:
void G3_Frustum(fx32 t,fx32 b,fx32 l,fx32 r,fx32 n,fx32 f,MtxFx44 * mtx);
void G3_Perspective(fx32 fovySin,fx32 fovyCos,fx32 aspect,fx32 n,fx32 f,MtxFx44 * mtx);
void G3_Ortho(fx32 t,fx32 b,fx32 l,fx32 r,fx32 n,fx32 f,MtxFx44 * mtx);

void G3_FrustumW(fx32 t, fx32 b, fx32 l, fx32 r, fx32 n, fx32 f, fx32 scaleW, MtxFx44 *mtx);
void G3_PerspectiveW(fx32 fovySin, fx32 fovyCos, fx32 aspect, fx32 n, fx32 f, fx32 scaleW, MtxFx44 *mtx);
void G3_OrthoW(fx32 t, fx32 b, fx32 l, fx32 r, fx32 n, fx32 f, fx32 scaleW, MtxFx44 *mtx);
其中t b l r n f,这6个参数,没错,跟上面提到的定义完全一样,mtx存储计算完毕的矩阵
fovySin fovyCos aspect这几个就是上文提到的sinθ cosθ 高宽比
带W后缀的API表示按参数scaleW进行缩放,实质上是把投影矩阵乘以一个缩放矩阵

好了,做点实际的hack,巩固一下这部分知识吧,rom是白金版,游戏运行到大地图中,观察它的投影矩阵,可以发现它的投影矩阵属于Perspective投影
观察可知,修改第一列的第一个元素与第二列的第二个元素,可以调整近截面的大小,从而调整整个视野

[attach]30368[/attach]
先在内存里搜索到这个矩阵,地址在0x21c4e94(我这是日版,其他版本可能不一样),把那2个元素改成0x3333
然后,可以发现,视野被调整了

[attach]30369[/attach]

接着是camera的调整,camera的调整通过把一个平移矩阵或者旋转矩阵乘以投影矩阵进行,下面通过调用G3_RotZ将camera绕Z轴旋转90°
示例中的代码是通过hook函数sub_20B1EEC完成的,代码从0x20AEC28跳转过来的

汇编代码,thumb下的
复制内容到剪贴板
代码:
push r4, lr
blx 0x20B1EEC//调用原函数
ldr r4,=0x4000440
mov r1,0x0
str r1,[r4]
ldr r0,=0x1000//sin90°= 1;cos90°= 0
blx 0x20BF808//调用G3_RotZ
mov r1,0x2
str r1,[r4]
pop r4,pc
C代码
复制内容到剪贴板
代码:
void hookSub_20B1EEC(int unk_1,int unk_2,int unk_3)//代码从0x20AEC28跳
{
        void (*unk_fun)(int,int,int) = 0x20B1EEC;
        unk_fun(unk_1,unk_2,unk_3);
        *((int*)0x4000440) = 0;//设置当前矩阵
        void (*g3_rotZ)(int,int) = 0x20BF808;
        g3_rotZ(0x1000,0);//让它绕Z轴转个90°
        *((int*)0x4000440) = 2;//还原
}
结果

[attach]30373[/attach]

有没有想到啥?对,就是反转世界

再来谈谈position矩阵,这个矩阵比上面的投影矩阵简单多了

position矩阵将模型坐标系的点变换到世界坐标系中,这里简单地用一个hack介绍一下,这个hack跟上面那个相比,意义并不是很大,随便看看就好
以空之探险队为例,空之探险队的对话框是用3D引擎渲染的,在3D查看器中可以观察到,每次绘制对话框的多边形之前,都会有一个缩放矩阵乘以position矩阵

[attach]30371[/attach]

因为矩阵的乘法满足结合律,修改这个缩放矩阵的话,就可以调整对话框的比例,跟踪到这个缩放矩阵,然后修改成0x20000,0x20000,0x1000

[attach]30372[/attach]

剩下2个矩阵,directional矩阵不会介绍,纹理矩阵将在纹理贴图篇进行介绍

[ 本帖最后由 enler 于 2013-3-15 21:53 编辑 ]
作者: enler    时间: 2013-3-14 01:52

矩阵栈
接下来介绍NDS 3D引擎内的矩阵栈,栈是种数据结构,一般用来临时存储各种变量,矩阵栈就是用来临时存储矩阵的
NDS一共有3种矩阵栈,投影矩阵栈、position矩阵栈,directional矩阵栈
其中,投影矩阵栈只有1个空间,position矩阵栈跟directional矩阵栈有32个空间,它们的栈指针可以通过读取I/O寄存器0x4000600的3D状态获取
position矩阵栈跟directional矩阵栈公用一个栈指针

引用
复制内容到剪贴板
代码:
  矩阵栈              有效的栈空间        栈指针
  Projection Stack    0..0  (1 entry)     0..1  (1bit) (GXSTAT: 1bit)
  Coordinate Stack    0..30 (31 entries)  0..63 (6bit) (GXSTAT: 5bit only)
  Directional Stack   0..30 (31 entries)  (与position矩阵栈公用栈指针)
  Texture Stack       One..None?          0..1  (1bit) (GXSTAT: N/A)
插一句,这里的Coordinate Stack就是position矩阵栈,gbatek的3D部分有好几处用词不一致
有人应该注意到了,position矩阵栈的栈指针最大的取值范围到63,也就是6bit,而GXSTAT中仅用低5bit记录栈指针
栈的有效空间只有32,那为什么栈指针的值可以达到63呢,因为如果你用32 33 .. 63去访问的话,会自动给你转到0 1 .. 31的位置上,这里存在着镜像的关系
同样的,投影矩阵栈只有1个空间,你用1去访问的话,访问到的也是位置0的矩阵
栈指针的初始值均为0


引用
复制内容到剪贴板
代码:
4000444h - Cmd 11h - MTX_PUSH - 将当前矩阵入栈 (W)
4000448h - Cmd 12h - MTX_POP - 按指定的偏移量出栈 (W)
400044Ch - Cmd 13h - MTX_STORE - 将矩阵存储到栈中指定的位置上 (W)
4000450h - Cmd 14h - MTX_RESTORE - 从栈中指定的位置上读取矩阵 (W)
MTX_PUSH无参数,就是将当前矩阵入栈,并且栈指针+1

MTX_POP接收一个参数,这个参数是个6bit的有符号整数,范围是-31到+31
栈指针减去这个参数,然后从栈中读取矩阵到当前矩阵上,一般情况下用1,栈指针-1,然后读取矩阵,此时跟普通的出栈没什么两样
如果用0,则直接从栈中读取矩阵,不修改栈指针
此外,因为position矩阵栈跟directional矩阵栈,公用栈指针的关系,为了方便操作它们,可以指定大于1或者小于0的参数
操作这个I/O寄存器可能会出现大于31的栈指针,对应关系上面已经列举出来了

MTX_STORE跟MTX_RESTORE的参数一样,都是5bit(0..31)的栈偏移,这2个I/O寄存器提供更为灵活的矩阵栈的操作,允许你像访问数组一样操作栈

此外,因为投影矩阵栈只有1个空间,对于MTX_POP MTX_STORE MTX_RESTORE这3个I/O寄存器而言,任何参数都将无效化,直接访问栈的offset 0

最后还有一点,访问投影矩阵栈的offset 1与position矩阵栈的offset 0x1f与offset 0x20到offset0x3f,栈溢出flag会变为true
虽然position矩阵栈的offset 0x1f依旧在有效范围内,但栈溢出flag依旧会变成true

SDK内对应的API是
引用:
void G3_PushMtx();
void G3_PopMtx(int num);
void G3_StoreMtx(u32 num);
void G3_RestoreMtx( u32 num );
读取当前裁剪矩阵与directional矩阵

先说说裁剪矩阵,裁剪矩阵等于 position矩阵 * 投影矩阵,通过裁剪矩阵,可以直接把模型坐标变换到CVV中哦

引用
复制内容到剪贴板
代码:
4000640h..67Fh - CLIPMTX_RESULT - 读取当前的裁剪矩阵 (R)
4000680h..6A3h - VECMTX_RESULT - 读取当前的directional矩阵 (R)
用法举例

C语言
复制内容到剪贴板
代码:
//读取裁剪矩阵
int * CLIPMTX_RESULT = (int*)0x4000640;
for(int i = 0;i < 16; i++)
        *(p_4x4_mtx++) = *(CLIPMTX_RESULT++);

//读取directional矩阵
int * VECMTX_RESULT = (int*)0x4000680;
for(int i = 0;i < 9; i++)
        *(p_3x3_mtx++) = *(VECMTX_RESULT++);
注意,读取directional矩阵只能读取它的三行三列共9个元素
此外,有个技巧,因为任意矩阵乘以单位矩阵都保持不变,所以可以将position矩阵或者投影矩阵设置为单位矩阵
然后接着读取裁剪矩阵的话,读到的就是投影矩阵或者position矩阵了

SDK内对应的API为
引用:
s32 G3X_GetClipMtx(MtxFx44 * m);
int G3X_GetVectorMtx(MtxFx33 * m);
Q&A

问题1:世界坐标系与camera坐标系是重合的吗?
回答:要看投影矩阵,如果投影矩阵只是纯粹地将视锥体的坐标转换到CVV中,那么世界坐标系与camera坐标系是重合的,上文没有特别说明的话,都是重合的
但是,如果把一个平移矩阵或者旋转矩阵乘以投影矩阵的话,会移动camera的位置,此时当然不重合,此时投影矩阵可以理解为做了2个操作
一个是将世界坐标系变换到camera坐标系,一个是将视锥体的坐标变换到CVV中

问题2:数学白痴表示看不懂,教练求恶补线代
回答:看这里吧,孩子
http://blog.csdn.net/vagrxie/article/details/4960473
http://blog.csdn.net/vagrxie/article/details/4974985
http://blog.csdn.net/vagrxie/article/details/5016143

问题3:求投影矩阵的详细推导过程
回答:透视投影矩阵的传送门http://www.cnblogs.com/cg_ghost/archive/2011/10/13/2210168.html
不过要注意,opengl的矩阵乘法是左乘,里面涉及到透视投影的矩阵,跟上文提到的那个互为转置关系
正交投影矩阵请自行谷歌

问题4:坐标变换有哪些需要注意的
回答:首先得明确一点,矩阵乘法不满足交换律,所以在进行矩阵乘法的时候,得明确顺序
比如想将坐标进行 平移 然后 旋转,此时得先把旋转矩阵乘以position矩阵,此时矩阵的运算结果是 旋转矩阵 * position矩阵(注意右乘的问题)
然后再把平移矩阵乘以position矩阵,此时矩阵的运算结果是 平移矩阵 * 旋转矩阵 * position矩阵

问题5:...
回答:...

[ 本帖最后由 enler 于 2013-3-22 22:19 编辑 ]




欢迎光临 口袋社区-Poke The BBS (https://www.poketb.com/) Powered by Discuz! 6.1.0F