• PlayStation 2 Game Reverse Engineering: Ape Escape 3


    游戏简介

    捉猴啦 3(Ape Escape 3)是一款动作类游戏,发布时间为 2005 年 7 月,游戏厂商为 SCE,代理厂商为 SCEH。其游戏平台为 PlayStation 2。

    游戏解包

    首先下载游戏文件 Ape Escape 3 (USA).iso,直接解压缩可以得到四个文件:

    DATA.BIN:打包的数据

    IOPRP300.IMG:IOP Realtime Kernel 内核文件

    SCUS_975.01:游戏主程序,MIPS 32 位可执行文件

    SYSTEM.CNF:系统引导文件

    其中 DATA.BIN 的前四个字节为 VFI\0,查一下可以找到对应的 QuickBMS 解压脚本。

    解压之后有两个目录,debug 文件夹下存放的是游戏的资源文件,irx 文件夹下存放的是运行时加载的动态库文件。

    其中图像文件以 tim2 文件格式储存,可以使用 Rainbow 工具进行读取。

    玩家状态

    使用 IDA 载入游戏主程序 SCUS_975.01

    首先从存档入手,简单看一下可以找到一个有趣的字符串 bool <unnamed>::McAccess::decodeData(int),通过交叉引用可以找到对应的函数位于 0x531F18

    看一下可以在里面找到几个比较关键的调用。

    第一个调用目标是 0x36CC44,通过特征可以看出来这里是 zlib 的解压函数。

    第二个调用目标是 0x270144,这个函数对解压的数据进行解析,随后将玩家信息存储到位于 0x649910Data 结构体中:

    // local variable allocation has failed, the output may be wrong!
    int __fastcall McAccess::decodeDataInner(Data *out, int mc_temp)
    {
      int *out_; // $s2
      int v17; // $s3
      _DWORD *v18; // $s0
      int v19; // $s3
      int *v20; // $s0
      int *v21; // $s0
      int i; // $s1
      int v23; // $s3
      int *v24; // $s0
      int *v25; // $s0
      int j; // $s1
      int v27; // $s3
      int *v28; // $s0
      int *v29; // $s0
      int v30; // $s3
      int v31; // $s3
      char *v32; // $s0
      int k; // $s1
      char *v34; // $s0
      _BYTE v39[32]; // [sp+0h] [-70h] BYREF
      int v40[4]; // [sp+20h] [-50h] BYREF
    
      __asm
      {
        sd      $s1, 0x70+var_38($sp)
        sd      $s2, 0x70+var_30($sp)
        sd      $s0, 0x70+var_40($sp)
        sd      $s5, 0x70+var_18($sp)
      }
      LODWORD(_$S2) = out;
      __asm
      {
        sd      $ra, 0x70+var_10($sp)
        sd      $s3, 0x70+var_28($sp)
        sd      $s4, 0x70+var_20($sp)
      }
      sub_371290(v40);
      if ( sub_3713E0(v40, (char *)mc_temp) >= 0 )
      {
        v17 = 0;
        strcpy(v39, "player_type");                 // d
        *out_ = sub_3713F8(v40, (int)v39);
        strcpy(v39, "player_left");                 // d
        out_[1] = sub_3713F8(v40, (int)v39);
        strcpy(v39, "player_life");                 // f
        out_[2] = sub_3713F8(v40, (int)v39);
        strcpy(v39, "player_costume");              // d
        out_[3] = sub_3713F8(v40, (int)v39);
        strcpy(v39, "player_energy");               // f
        out_[5] = sub_3713F8(v40, (int)v39);
        strcpy(v39, "player_energy_capacity");      // f
        out_[6] = sub_3713F8(v40, (int)v39);
        strcpy(v39, "costume_timer");               // f
        out_[4] = sub_3713F8(v40, (int)v39);
        do
        {
          sub_26F9A0(v39, (int)"costume_possess");
          v18 = &out_[v17++ + 3];
          v18[4] = sub_3713F8(v40, (int)v39);
        }
        while ( v17 < 8 );
        v19 = 0;
        out_[7] = 2;
        v20 = out_ + 15;
        do
        {
          sub_26F9A0(v39, (int)"gmecha_possess");
          ++v19;
          *v20++ = sub_3713F8(v40, (int)v39);
        }
        while ( v19 < 12 );
        v21 = out_ + 27;
        for ( i = 0; i < 4; ++i )
        {
          sub_26F9A0(v39, (int)"gmecha_assign");
          *v21++ = sub_3713F8(v40, (int)v39);
        }
        strcpy(v39, "gmecha_currentkey");
        v23 = 0;
        out_[31] = sub_3713F8(v40, (int)v39);
        strcpy(v39, "ammo_type");
        out_[32] = sub_3713F8(v40, (int)v39);
        do
        {
          sub_26F9A0(v39, (int)"ammo_count");
          v24 = &out_[++v23];
          v24[32] = sub_3713F8(v40, (int)v39);
        }
        while ( v23 < 3 );
        v25 = out_ + 36;
        for ( j = 0; j < 3; ++j )
        {
          sub_26F9A0(v39, (int)"ammo_max");
          *v25++ = sub_3713F8(v40, (int)v39);
        }
        strcpy(v39, "rccar_type");
        v27 = 0;
        v28 = out_ + 40;
        out_[39] = sub_3713F8(v40, (int)v39);
        do
        {
          sub_26F9A0(v39, (int)"rccar_poss");
          ++v27;
          *v28++ = sub_3713F8(v40, (int)v39);
        }
        while ( v27 < 4 );
        v29 = out_ + 45;
        strcpy(v39, "dance_type");
        v30 = 0;
        out_[44] = sub_3713F8(v40, (int)v39);
        do
        {
          sub_26F9A0(v39, (int)"dance_poss");
          ++v30;
          *v29++ = sub_3713F8(v40, (int)v39);
        }
        while ( v30 < 4 );
        v31 = 0;
        strcpy(v39, "chip_count");
        out_[49] = sub_3713F8(v40, (int)v39);
        strcpy(v39, "max_chip_count");
        out_[50] = sub_3713F8(v40, (int)v39);
        do
        {
          sub_26F9A0(v39, (int)"capture_flag");
          v32 = (char *)out_ + v31++;
          v32[204] = sub_3713F8(v40, (int)v39);
        }
        while ( v31 < 570 );
        for ( k = 0; k < 259; ++k )
        {
          sub_26F9A0(v39, (int)"shop_item");
          v34 = (char *)out_ + k;
          v34[774] = sub_3713F8(v40, (int)v39);
        }
      }
      sub_37132C(v40);
      __asm
      {
        ld      $s0, 0x70+var_40($sp)
        ld      $s1, 0x70+var_38($sp)
        ld      $s2, 0x70+var_30($sp)
        ld      $s3, 0x70+var_28($sp)
        ld      $s4, 0x70+var_20($sp)
        ld      $s5, 0x70+var_18($sp)
        ld      $ra, 0x70+var_10($sp)
      }
      return ((int (*)(void))_$RA)();
    }
    

    通过这段代码可以恢复出 Data 结构体定义:

    struct __attribute__((packed)) __attribute__((aligned(1))) Data
    {
      int player_type;
      int player_left;
      float player_life;
      int player_costume;
      float costume_timer;
      float player_energy;
      float player_energy_capacity;
      int costume_possess[8];
      int gmecha_possess[12];
      int gmecha_assign[4];
      int gmecha_currentkey;
      int ammo_type;
      int ammo_count[3];
      int ammo_max[3];
      int rccar_type;
      int rccar_poss[4];
      int dance_type;
      int dance_poss[4];
      int chip_count;
      int max_chip_count;
      char capture_flag[570];
      char shop_item[259];
    };
    

    把结构体定义应用到 0x649910,接下来根据交叉引用修改相关函数的声明(比如静态成员函数),手动进行类型传播。

    随后按 Ctrl+Alt+X 查看 Data::player_lifeGlobal cross references,可以找到一个关键的 Write 位置:

    函数内容如下:

    void __fastcall ChangeHP(Data *this)
    {
      _$F1 = 0.0;
      _$F2 = 100.0;
      __asm
      {
        max.s   $f0, $f1
        min.s   $f0, $f2
      }
      this->player_life = _$F0;
    }
    

    这里的等价形式是:

    void ChangeHP(Data *this, float delta)
    {
      this->player_life = max(min(this->player_life + delta, 100.0), 0.0);
    }
    

    但是 IDA 对这里的传参识别有点问题,需要手动指定传参时使用的寄存器,按 Y 修改函数声明:

    void __usercall ChangeHP(Data *this@<$a0>, float@<$f12>)
    

    之后再按 X 查看 ChangeHP 的交叉引用,IDA 就可以辅助分析第二个参数的内容了。

    通过交叉引用可以找到位于 0x33DB70 的关键函数,这里传入的 delta 正好是我们在游戏中被攻击时受到的伤害:

    // local variable allocation has failed, the output may be wrong!
    int __usercall DecreaseHP_TRUE@<$v0>(int a1@<$a0>, float a2@<$f12>)
    {
      float v5; // $f20
      Data *v6; // $f21
      int v10; // $s0
      float v13; // $f12
      float v14; // $f0
      float v15; // $f0
      float v16; // $f20
      int v18; // $v0
      int v19; // $a0
      Data *v29[2]; // [sp+10h] [-10h]
    
      v29[1] = v6;
      __asm
      {
        sd      $s0, 0x20+var_20($sp)
        sd      $ra, 0x20+var_18($sp)
      }
      *(float *)v29 = v5;
      _$F0 = a2;
      if ( a2 <= 0.0 )
      {
        __asm { ld      $s0, 0x20+var_20($sp) }
        goto LABEL_13;
      }
      v10 = *(_DWORD *)(a1 + 32);
      _$F20 = 20.0;                                 // mtc1    zero, f20
                                                    // 0033DBA4 00 00 F4 C5
                                                    // 0033DBA4 00 A0 80 44
      __asm { min.s   $f20, $f0, $f20 }
      sub_344EA4(v10, 0.0);
      sub_168D5C();
      v13 = -_$F20;
      if ( (*(_DWORD *)(v10 + 1136) & 0x10) == 0 )
        v13 = 0.0;
      if ( v13 != 0.0 )
      {
        v14 = s_Data.player_life - (float)((float)(int)(float)(s_Data.player_life / 20.0) * 20.0);
        if ( v14 > 0.0 )
          v13 = -v14;
      }
      ChangeHP(v29[0], v13);
      v16 = v15;
      sub_29157C();
      sub_2916D4(v16);
      if ( v16 > 0.0 )
      {
        __asm { ld      $s0, 0x20+var_20($sp) }
    LABEL_13:
        __asm { ld      $ra, 0x20+var_18($sp) }
        return ((int (*)(void))_$RA)();
      }
      v18 = sub_350988();
      v19 = v10;
      if ( v18 )
      {
        __asm
        {
          ld      $ra, 0x20+var_18($sp)
          ld      $s0, 0x20+var_20($sp)
        }
        return sub_326128(v19);
      }
      else
      {
        __asm
        {
          ld      $ra, 0x20+var_18($sp)
          ld      $s0, 0x20+var_20($sp)
        }
        return sub_33E158(v19);
      }
    }
    

    于是可以在这里下断点,当玩家受到攻击时这里的断点会被触发,印证了我们上面的推测。

    外挂编写

    知道扣除血量代码的所在位置之后,我们可以对这里的代码进行修改,从而达到锁定血量的效果。

    这里直接把伤害数值从 20.0 修改为 0.0 即可。

    具体而言就是将 0x33DBA4 处的浮点数加载指令 lwc1 f20, (t7) 修改为 mtc1 zero, f20

    PCSX2 模拟器提供了 pnach 作弊脚本,可以直接使用脚本修改内存中对应的字节码。

    将下面的代码另存为 7571AAEE.pnach 即可,这里的 7571AAEE 对应游戏文件的 CRC 校验码:

    gametitle=Ape Escape 3 (USA)
    comment=Patches by Byaidu
    // Disable HP Decrease
    patch=1,EE,0033DBA4,word,4480A000
    

    关卡逻辑

    游戏中涉及到非常复杂的机关设计,这是很难完全使用 C++ 进行实现的。

    因此游戏采用了 Lua 脚本对场景中的对象进行管理。

    在游戏启动时,程序会调用位于 0x21A93C0x3854C8 的两个函数来注册 Lua 脚本中的自定义函数。

    int SetupBindLauncher()
    {
      _DWORD *v0; // $s0
      _DWORD *v1; // $s0
      _DWORD *v2; // $s0
      _DWORD *v3; // $s0
      _DWORD *v4; // $s0
      _DWORD *v5; // $s0
      int v7; // $a0
    
      v0 = (_DWORD *)malloc(0x30u);
      CreateBind(v0, "BindTester");
      *v0 = off_6ADF78;
      sub_255DA4((int)v0);
      v1 = (_DWORD *)malloc(0x30u);
      CreateBind(v1, "createFeatureLauncher");
      *v1 = s_SetSelectFortune;
      sub_255DA4((int)v1);
      v2 = (_DWORD *)malloc(0x30u);
      CreateBind(v2, "createWarpGateLauncher");
      *v2 = s_SetSelectStage;
      sub_255DA4((int)v2);
      v3 = (_DWORD *)malloc(0x30u);
      CreateBind(v3, "createTutorialExit");
      *v3 = s_SetSelectTutorial;
      sub_255DA4((int)v3);
      v4 = (_DWORD *)malloc(0x30u);
      CreateBind(v4, "createMGSGenerator");
      *v4 = off_6ADF00;
      sub_255DA4((int)v4);
      v5 = (_DWORD *)malloc(0x30u);
      CreateBind(v5, "LocaleInfo_getID");
      __asm { ld      $ra, var_s8($sp) }
      v7 = (int)v5;
      *v5 = off_6ADF18;
      __asm { ld      $s0, var_s0($sp) }
      return sub_255DA4(v7);
    }
    
    int SetupBindFlag()
    {
      _DWORD *v0; // $s0
      _DWORD *v1; // $s0
      _DWORD *v2; // $s0
      _DWORD *v3; // $s0
      _DWORD *v4; // $s0
      _DWORD *v5; // $s0
      _DWORD *v6; // $s0
      _DWORD *v7; // $s0
      _DWORD *v8; // $s0
      _DWORD *v9; // $s0
      _DWORD *v10; // $s0
      int v12; // $a0
    
      v0 = (_DWORD *)malloc(0x30u);
      CreateBind(v0, "gw_set_flag");
      *v0 = off_6B70F0;
      sub_255DA4((int)v0);
      v1 = (_DWORD *)malloc(0x30u);
      CreateBind(v1, "gw_get_flag");
      *v1 = off_6B70D8;
      sub_255DA4((int)v1);
      v2 = (_DWORD *)malloc(0x30u);
      CreateBind(v2, "gw_get_string");
      *v2 = off_6B70C0;
      sub_255DA4((int)v2);
      v3 = (_DWORD *)malloc(0x30u);
      CreateBind(v3, "lw_set_flag");
      *v3 = off_6B7090;
      sub_255DA4((int)v3);
      v4 = (_DWORD *)malloc(0x30u);
      CreateBind(v4, "lw_get_flag");
      *v4 = off_6B7078;
      sub_255DA4((int)v4);
      v5 = (_DWORD *)malloc(0x30u);
      CreateBind(v5, "area_set_flag");
      *v5 = off_6B7060;
      sub_255DA4((int)v5);
      v6 = (_DWORD *)malloc(0x30u);
      CreateBind(v6, "area_get_flag");
      *v6 = off_6B7048;
      sub_255DA4((int)v6);
      v7 = (_DWORD *)malloc(0x30u);
      CreateBind(v7, "get_costume_possess");
      *v7 = off_6B7018;
      sub_255DA4((int)v7);
      v8 = (_DWORD *)malloc(0x30u);
      CreateBind(v8, "get_costume_possess_ninja");
      *v8 = off_6B7000;
      sub_255DA4((int)v8);
      v9 = (_DWORD *)malloc(0x30u);
      CreateBind(v9, "stage_var_get_int");
      *v9 = off_6B70A8;
      sub_255DA4((int)v9);
      v10 = (_DWORD *)malloc(0x30u);
      CreateBind(v10, "is_survival_mode");
      __asm { ld      $ra, var_s8($sp) }
      v12 = (int)v10;
      *v10 = off_6B7030;
      __asm { ld      $s0, var_s0($sp) }
      return sub_255DA4(v12);
    }
    

    在每个关卡文件夹下都可以找到 area.luc 文件,用 file 看一下可以知道这是 Lua 5.0 Bytecode 文件,这里使用 unluac 对其进行反编译:

    script_description = "area script 0.2"
    function brk_wbox_l(a_name, se_name, num, a_spawn)
      lua_param({namespace = a_name})
      lua_param({len_add_sensor = 10000})
      lua_param({len_add_col = 10000})
      new_ent("Breakable", {
        name = a_name,
        model_name = "wbox_l",
        tag_name = a_name,
        effect_after_broken = "fx_stg_com_smoke_boxL",
        model_name_particle_0 = "a_wes_d_box_piece1",
        model_name_particle_1 = "a_wes_d_box_piece2",
        model_name_particle_2 = "a_wes_d_box_piece3",
        shake_force = 0.2,
        particle_num = 6,
        flg_save = false,
        piece_desc_namespace = "piece_00"
      })
      new_ent("Controller", {name = se_name}, c_order(1, c_ctrl("trig_wait", {name = a_name, flag = true}), c_ctrl("se", {
        name = "st_brk_woodbox",
        target_name = a_name
      }), c_order(num, c_ctrl("message_b", {
        message = "spawn",
        target_name = a_name,
        param = a_spawn
      }))))
    end
    lua_param({namespace = "piece_00"})
    lua_param({
      piece_type = "CUBIC",
      piece_num = 9,
      piece_model_0 = "wbox_l_p0",
      piece_model_1 = "wbox_l_p1",
      center_altitude = 20,
      piece_size = 13,
      piece_scale = 25 / 13
    })
    function setupArea(opts)
      if true then
        repeat
          new_ent("Player", {
            name = "player",
            tag_name = opts.spawn_tag
          })
          new_ent("Monkey", {name = "ape_cty_01", tag_name = "ape_01"})
          new_ent("Monkey", {name = "ape_cty_02", tag_name = "ape_02"})
          new_ent("Monkey", {
            name = "ape_cty_03a",
            tag_name = "ape_03b"
          })
          new_ent("Monkey", {name = "ape_cty_19", tag_name = "ape_cty_19"})
          new_ent("DemoLauncher", {
            name = "intro_cty_a",
            intro = true,
            flg_save = true,
            cam_1 = "op_cam3_cty_a_path",
            cam_2 = "op_cam2_cty_a_path",
            cam_3 = "op_cam1_cty_a_path",
            time_enabled = 0,
            time_disabled = 0
          }, {
            "ape_cty_02",
            "ape_cty_03b",
            "ape_cty_19",
            "ape_cty_04"
          })
          lua_param({
            namespace = "democam_superape_cty_a"
          })
          lua_param({
            cam_1 = "democam_cut_cty_a_path",
            cam_2 = "democam_cut_pan_cty_a_path",
            cam_3 = "democam_super2_cty_a_path"
          })
          new_ent("DemoLauncher", {
            name = "democam_superape_cty_a",
            watch_trigger_name = "sw_kabe_cty_a",
            cam_1 = "democam_cut_cty_a_path",
            cam_2 = "democam_cut_pan_cty_a_path",
            cam_3 = "democam_super1_cty_a_path",
            time_enabled = 0.2,
            flg_katinko = true,
            gflg_save = true,
            time_disabled = 1.6
          }, {
            "ape_cty_03a",
            "ape_cty_03b",
            "ape_cty_19",
            "ape_cty_04"
          })
          if get_capture_flag(29) == false then
            l_ratio_v_pl_commin = 0.8
            lua_param({
              namespace = "monkey_car_00"
            })
            lua_param({ratio_v_pl_comming = l_ratio_v_pl_commin})
            new_ent("Car", {
              name = "monkey_car_00",
              model_name = "a_bay_b_sarucar_y",
              path_name = "a_cty_a_sarucar_path",
              monkeycar = true,
              init_pos = 1,
              v_runaway_ratio = 1,
              a_runaway_ratio = 0.5,
              hp = 3,
              ape_name = "ape_cty_05"
            })
          end
          if get_capture_flag(28) == false then
            l_ratio_v_pl_commin = 0.9
            lua_param({
              namespace = "monkey_car_03"
            })
            lua_param({ratio_v_pl_comming = l_ratio_v_pl_commin})
            new_ent("Car", {
              name = "monkey_car_03",
              model_name = "a_bay_b_sarucar_y",
              path_name = "a_cty_a_sarucar_path",
              monkeycar = true,
              init_pos = 12,
              v_runaway_ratio = 1,
              a_runaway_ratio = 0.5,
              hp = 3,
              ape_name = "ape_cty_04"
            })
          end
      ...
    end
    

    其中 Breakable 描述可以打破的箱子,VehicleCar 描述可以乘坐的载具,ChangeArea 描述不同地点直接的切换,Collidable 描述具有碰撞体的踏板,Button 描述可以被触发的按钮。

    控制电车运动的代码片段如下,两辆电车 train0train1 先加速运行 1 s,然后匀速运动 4.5 s,再减速运行 0.75 s,接下来改变方向循环往复:

        new_ent("Controller", {
          name = "se_train_cty_a"
        }, c_order(1, c_ctrl("value", {
          entity_name = "se_train_cty_a",
          func_name = "reqLim",
          argv0 = 0,
          argv1 = 6.25
        }), c_ctrl("value", {
          entity_name = "se_train_cty_a",
          func_name = "reqImm",
          argv0 = 0
        }), c_order(-1, c_ctrl("value", {
          entity_name = "se_train_cty_a",
          func_name = "reqVelocity",
          argv0 = 1
        }), c_ctrl("wait", {time = 1}), c_ctrl("se", {
          name = "st_tra_towntrain_run",
          target_name = "train0"
        }), c_ctrl("se", {
          name = "st_tra_towntrain_run",
          target_name = "train1"
        }), c_ctrl("wait", {time = 4.5}), c_ctrl("se", {name = "stopAll", target_name = "train0"}), c_ctrl("se", {name = "stopAll", target_name = "train1"}), c_ctrl("wait", {time = 0.75}), c_ctrl("value", {
          entity_name = "se_train_cty_a",
          func_name = "reqVelocity",
          argv0 = -1
        }), c_ctrl("wait", {time = 1}), c_ctrl("se", {
          name = "st_tra_towntrain_run",
          target_name = "train0"
        }), c_ctrl("se", {
          name = "st_tra_towntrain_run",
          target_name = "train1"
        }), c_ctrl("wait", {time = 4.5}), c_ctrl("se", {name = "stopAll", target_name = "train0"}), c_ctrl("se", {name = "stopAll", target_name = "train1"}), c_ctrl("wait", {time = 0.75}))))
    

    作弊代码

    在游戏标题界面按下 L1+L2+R1+R2 可以进入作弊界面,通过输入密码来触发特定的功能。

    可以在程序中找到 passwd_chip_full 等相关字符串:

    执行 grep -r passwd 可以找到相关的文件 debug/us/static/common_txt.bin

    完整的作弊码列表如下:

    Name Password Function
    passwd_ape0 grobyc Unlock SAL-1000
    passwd_ape1 blackout Unlock Dark Master
    passwd_ape2 redmon Unlock Pipotron Red
    passwd_ape3 coolblue Unlock Pipotron Blue
    passwd_ape4 yellowy Unlock Pipotron Yellow
    passwd_ape5 2nd man Unlock Shimmy
    passwd_ape6 krops Unlock Spork
    passwd_ape7 SAL3000 Unlock SAL-3000
    passwd_mgs MESAL Get Mesal Gear
    passwd_apethrow MonkeyToss Get Super Monkey Throw Stadium
    passwd_apefirst KUNGFU Get Ultim-ape Fighter
    passwd_mgs_theater 2 snakes Get Movie Tape And Movie File
    passwd_ratchet AEAcademy Get Special Movie Tape
    passwd_millimon millimon Get Mystery Movie Tape
    show_survival survive Get Survival Mode
    passwd_chip_full RICH Get 9999 Gotcha Coins
    passwd_mecha_full Gadget Get All Gadgets
    passwd_costume_full Transforms Get All Morphs
    password_type_a ARAKURE Unlock Wild West Town Stage
    password_type_b NINNIN Unlock Emperor's Castle Stage
    password_type_c DANSU Unlock Mirage Town Stage
    password_type_d ATAMAYARAHASAKANA Unlock All Three Morphs

    参考链接

    https://apeescape.fandom.com/wiki/Ape_Escape_3

  • 相关阅读:
    (十三)学习CSS之两个class连一起隔空格和逗号
    (十二)学习CSS之box-sizing 属性
    (五)学习JavaScript之firstChild 属性
    (四)学习JavaScript之className属性
    (三)学习JavaScript之getElementsByTagName方法
    两张表一对多的连接,取多记录表中最新的一条数据
    oracle 快照
    Linux常用命令大全
    baidu 快递查询API
    oracle 触发器实现主键自增
  • 原文地址:https://www.cnblogs.com/algonote/p/15678057.html
Copyright © 2020-2023  润新知