Duramente

| 单机游玩 4399 Flash 游戏

Play an 4399 Flash Game Offline

by Duramente


前言 #

最近突然有了想玩 4399 游戏的冲动,但安装 Flash 插件很麻烦(尤其当我在用 Firefox 时)。本来我希望使用 Ruffle 来代替,但是 Ruffle 目前依然不好用,基本上大部分游戏都无法正常运行。而且,4399强制登录和实名认证也让我很不爽。所以我决定换一种方法来玩。毕竟本来就是些单机游戏。

准备工作 #

网上有很多现成的油猴脚本可以用来屏蔽登录和下载SWF,例如:一键下载 flash 游戏防沉迷终结者

手动实现的步骤如下。

屏蔽登录弹窗 #

4399 的登录以及 Anti Developer Console 都集成在外部文件中,Block 掉就可以绕过。规则如下(使用 uBlock Origin):

||www.4399.com/antijs/Antiindulgence.js

下载 SWF 文件 #

最快的方法是暴力搜索网页源代码或查看浏览器网络请求。例如:

//flash加载
/***
 var host=window.location.host;
 var protocol=window.location.protocol;
 var path=protocol+"//"+host+'/images/zmhj/flash/';
 var swf='/images/zmhj/flash/main.swf?path='+path;
 $("#flash22").find("PARAM").attr("VALUE",swf);
 $("#flash22").find("embed").attr("src",swf);
 */
var game_url_arr=new Array();
game_url_arr[1]='//sbai.4399.com/4399swf/upload_swf/ftp5/hanbao/20110624/3/v10260.htm';
game_url_arr[2]='//sbai.4399.com/4399swf/upload_swf/ftp6/hanbao/20110927/4/v23825.htm';
game_url_arr[3]='//sda.4399.com/4399swf/upload_swf/ftp7/hanbao/20120107/6/v3760.htm';
game_url_arr[4]='//sda.4399.com/4399swf/upload_swf/ftp15/csya/20150127/1/m4v4610.htm';
game_url_arr[5]='//sda.4399.com/4399swf/upload_swf/ftp22/csya/20170622/1/s5v3480.htm';
function chgFlashGame(kn){
location.href='zmhj.htm?g='+kn;
}

更优雅的方式是使用 Ruffle,它提供了右键下载 SWF 文件的功能。

ruffle download swf

注意,在得到 SWF 文件地址后,需要正确设置 Referer 才能访问,否则将得到反盗链提示文件。

import requests
swf = 'https://sda.4399.com/4399swf/upload_swf/ftp29/haibo/suwei/y2/main.swf'
headers = {'Referer': 'https://www.4399.com/'}
response = requests.get(swf, headers=headers)
with open('main.swf', 'wb') as f:
    f.write(response.content)

装载器 #

在得到游戏文件后,直接打开并不能运行,让我们看看是什么原因。

将文件拖入 jpexs FFDec 打开,得到反编译代码,可以看到并未进行混淆。这里推荐将脚本整体导出,使用VSCode ActionScript & MXML 插件以获取更好的体验。

配置好项目后(参考附录 - 搭建环境)便可以得到代码提示和交叉引用等功能。

setup workspace

System4399Manager #

在 FFDec 中点击 Tools -> Go to document class,跳转到入口类 System4399Manager

Go to document class

可以发现, System4399Manager 执行流程为加载开屏动画 -> 载入广告和游戏 -> 进入 L4399Main

flowchart TD
    A[System4399Manager] --> B[loadResInfo]
    B --> |load ads| C[executeCode]
    C --> D[gameLoadFromCache]
    D --> |if ads end|E[initApplication]
    E --> |load ctrl|F[initGame]
    F --> G[L4399Main]

L4399Main #

L4399Main 会加载嵌入的类名为 L4399Main_gamefile 的真正游戏主文件,并通过 setHoldmainHandler 接口接入游戏。

L4399Main_gamefile.as
package
{
    [Embed(source="/_assets/3_L4399Main_gamefile.bin", mimeType="application/octet-stream")]
    public class L4399Main_gamefile extends ByteArrayAsset
    {
        public function L4399Main_gamefile() {super();}
    }
}
L4399Main.as
package
{
    public class L4399Main extends Sprite
    {
        private static const gamefile:Class = L4399Main_gamefile;

        private function assetLoaded(event:Event) : void
        {
            this.game = event.target.content;
            // omit
            else if(this.game is AVM1Movie) // ActionScript2
            {
                this._password = loaderInfo.url;
                this.tryLinkAs2();
                this._isLoadAs2 = true;
                if(this._isLoadCtrl)
                {
                    ctrl["mainHandler"] = this;
                }
            }
            else // ActionScript3
            {
                this.game.setHold(this);
                this.gameHold.addChild(this.game as DisplayObject);
            }
        }
    }
}

依赖 #

直接导出游戏文件运行试试。

export 3_L4399Main_gamefile.bin

得到如下报错:

SecurityError: Error #2148: SWF 文件 file:///C|4399/L4399Main_gamefile.bin 不能访问本地资源 file:///C|/4399/OtherMat_v10.swf。只有仅限于文件系统的 SWF 文件和可信的本地 SWF 文件可以访问本地资源。
	at flash.net::URLStream/load()
	at flash.net::URLLoader/load()
	at loader::Aloader/doDecrypt()
	at loader::Aloader/next()
	at loader::Aloader/init()
	at GMain()

这是 Flash Player 的安全策略导致的。参考Flash Player 安全设置,在 %appdata%\Macromedia\Flash Player\#Security\FlashPlayerTrust 新建一个 .cfg 文件,并将文件所在文件夹路径写入。

\FlashPlayerTrust\TrustMe.cfg
C:\4399

再次运行,又有新的报错:

Error #2044: 未处理的 ioError:。 text=Error #2032: 流错误。 URL: file:///C|/4399/OtherMat_v10.swf
	at loader::Aloader/doDecrypt()
	at loader::Aloader/next()
	at loader::Aloader/init()
	at GMain()

显然,我们没有完整的下载所有游戏文件,因此游戏找不到 OtherMat_v10.swf。如何一次性得到所有依赖呢?

A. 手动分析找出所需文件 #

搜索 URLRequest,定位到 Aloader 类,找到两个加载函数:

public function doDecrypt(url:String, after:Function, currentFileIdx:int) : void
{
    this.after = after;
    this.currentFileIdx = currentFileIdx;
    this.uloader = new URLLoader();
    this.uloader.dataFormat = URLLoaderDataFormat.BINARY;
    this.uloader.addEventListener(Event.COMPLETE,function(param1:*):*{/* omit */});
    this.uloader.load(new URLRequest(url));
}

以及

public function loadByName(url:String, after:Function) : void
{
    this.after = after;
    this.afterName = url;
    this.uloader = new URLLoader();
    this.uloader.dataFormat = URLLoaderDataFormat.BINARY;
    this.uloader.addEventListener(Event.COMPLETE,this.loadCompleteHandler);
    this.uloader.load(new URLRequest(this.afterName + ".swf"));
}

通过交叉引用搜索找出所有需要的 SWF 文件:

dependencies example 1-0 dependencies example 2
doDecrypt loadByName

找出后将文件按照先前方法下载即可。

L4399Main_gamefile.bin
│  10.swf
│  11.swf
│  1_v6.swf
│  2.swf
│  3.swf
│  4.swf
│  5_v7.swf
│  6.swf
│  7.swf
│  8.swf
│  9.swf
│  backpack_v5.swf
│  Common_v6.swf
│  Main.swf
│  Music.swf
│  OtherMat_v10.swf
│  Pig9.swf
│  Role_v6.swf

为了避免每个游戏都要手动分析,下面介绍一种自动化的方法。

B. 劫持请求重定向 #

由于游戏使用相对路径加载资源,Flash 会对于将请求加上文件系统路径前缀,比如 “AAA.swf” 变为 file:///C|/4399/AAA.swf,我们需要一种方法来进行劫持。

或许 Hook ReadFile 等系统 API 是一种可行的方案,但为了避免处理底层代码可能遇到的麻烦,这里我选择利用 FFDec Lib 来在 SWF 层面劫持 URLLoader1。具体原理请查看附录 - 劫持原理

大体步骤如下:

  1. 创建一个新的 SWF 类继承 URLLoader,并将其编译为 debug.swf
package l4399.interceptor
{
    import flash.net.URLLoader;
    import flash.net.URLRequest;

    public class InterceptorURLLoader extends URLLoader
    {
        public override function load(request:URLRequest):void
        {
            request.url = "http://localhost:8888/" + request.url; // redirect to proxy
            super.load(request);
        }
    }
}
  1. 将 debug.swf 合并入游戏主文件中
val swf = SWF(gameFile, false)
val debugSWF = SWF(debugSWFFile, false)
// Insert ABCs in debugSWF to swf
for (ds in debugSWF.abcList) {
    swf.addTagBefore(ds, firstAbc as Tag)
    val ft = swf.fileAttributes
    ft.useNetwork = true // enable network to connect to our proxy
    ft.isModified = true
}
swf.saveToFile(File("game_merged.swf"))
  1. 将所有 flash.net.URLLoader 替换为我们的 l4399.interceptor.InterceptorURLLoader
val swf = SWF(gameFile, false)
// Replace `flash.net.URLLoader` qualified name.
// NOT work for dynamic loading(e.g. through `getClass`)
for (ct in swf.abcList) {
    for (i in 0 until a.constants.multinameCount) {
        val rawNsName = a.constants.getMultiname(i).getNameWithNamespace(a.constants, true).toRawString()
        when (rawNsName) {
            "flash.net.URLLoader" -> {
                m.namespace_index = a.constants.getNamespaceId("l4399.interceptor", true)
                m.name_index = a.constants.getStringId("InterceptorURLLoader", true)
                (ct as Tag).isModified = true
            }
        }
    }
}
swf.saveTo(File("game_injected.swf"))

这样,所有外部请求便都会发送到 http://localhost:8888/{url}。让我们开一个简单的 HTTP 服务器接收它们。

server.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import requests

SWF_HOST_BASE = "https://sda.4399.com/4399swf/upload_swf/ftp29/haibo/suwei/y2/"

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path.endswith(".swf"):
            # handle swf file
            url = SWF_HOST_BASE + self.path
            self.send_response(200)
            message = requests.get(url, headers={'Referer': 'https://www.4399.com/'}).content
            self.wfile.write(message)
        else:
            # other requests
            pass

with socketserver.TCPServer(("", 8888), Handler) as httpd:
    httpd.serve_forever()

运行可以看到请求进来了。

127.0.0.1 - - [01/Jan/2000 00:00:01] "GET /MagicWeaponv1240.swf HTTP/1.1" 200 -
127.0.0.1 - - [01/Jan/2000 00:00:10] "GET /Commonv3720.swf HTTP/1.1" 200 -
127.0.0.1 - - [01/Jan/2000 00:00:11] "GET /petEIconv1450.swf HTTP/1.1" 200 -
127.0.0.1 - - [01/Jan/2000 00:00:12] "GET /EIconv3420.swf HTTP/1.1" 200 -
...

实践中,为了处理嵌套依赖,最好对每一个返回的 SWF 都进行注入。此外,为了避免问题,最好不只是劫持 URLLoader,还要替换 getQualifiedClassName 等反射方法。

平台接口 #

处理完依赖,成功进入游戏,但点开始游戏会直接卡住。

unhandled exception: TypeError: Error #1009: 无法访问空对象引用的属性或方法。
	at export::GameMenu/continueClick()

使用 FFDec 加载调试符号得到准确位置:

private function newGameClick() : void
{
    // omit
    if(gc.isHideDebug)
    {
        if(GMain.serviceHold)
        {
            gc.logInfo = GMain.serviceHold.isLog;
        }
        if(gc.logInfo == null)
        {
            GMain.serviceHold.showLogPanel(); //<-- Error #1009
            if(!gc.loginAlert)
            {
                gc.loginAlert = AUtils.getNewObj("LoginAlertInterface");
                gc.loginAlert.name = "LoginAlert";
                this.addChild(gc.loginAlert);
            }
        }
    }
    // omit
}

搜索一下,看看 serviceHold 在哪里被赋值:

package
{
    public class GMain extends MovieClip
    {
        public function setHold(param1:*) : void
        {
            serviceHold = param1;
        }
    }
}

还记得前面的提到过的 setHold 吗?这里的 serviceHold 就是装载器传入的 L4399Main 实例,游戏通过它来调用平台接口。

虽然我们可以修改装载器来使它能离线运行。这里,为了避免分析原有代码,让我们参考原有的接口写一个新的装载器:

LOurLoaderMain_gamefile.as
package l4399.loader
{
    import mx.core.ByteArrayAsset;
    [Embed(source="gamefile.bin", mimeType="application/octet-stream")]
    public class LOurLoaderMain_gamefile {}
}
LOurLoaderMain.as
package l4399.loader
{
	public class LOurLoaderMain extends Sprite
	{
		private static const gamefile:Class = LOurLoaderMain_gamefile;

		public function LOurLoaderMain(params:Object = null):void
		{
			addEventListener(Event.ADDED_TO_STAGE, this.loadGameFile);
		}
		private function loadGameFile(e:Event):void
		{
			this._loader.loadBytes(new gamefile() as ByteArray, this._loaderContext);
			this._loader.contentLoaderInfo.addEventListener(
                Event.COMPLETE, 
                this.assetLoaded, 
                false, 0, true
            );
		}
		private function assetLoaded(e:Event):void
		{
			this.game = e.target.content;
            this.game.setHold(this);
			this.gameHold.addChild(this.game as DisplayObject);
		}
        /* v---------------API-----------------v */
		public function get isLog():Object
		{
			return {
                uid: 1,
                name: "test"
            };
		}
        public function showLogPanel():void
        {
            // do nothing
        }
    }
}

编译后打开,游戏成功运行,但在进入游戏时又遇到了报错:

ReferenceError: Error #1069: 在 LMain 上找不到属性 getServerTime,且没有默认值。
	at config::Config/getGameTime()
	at export::GameMenu/newGameGetTime()

看来我们还需要很多 API 要补……

  • 存档:getList, getData, saveData
  • 支付:getBalance, decMoney_As3
  • 登录:showLogPanel, userLogOut
  • 其他:getServerTime

这些 API 在 L4399Main 中的实现都是转发,真正的逻辑在 crtl 中。如:

L4399Main.as
public function getServerTime() : void
{
    ctrl.getServerTime();
}

ctrl 我们先前在 System4399Manager 中见过,加载地址如下:

System4399Manager.as
private var urlCtrl:String = "http://cdn.comment.4399pk.com/control/ctrl_mo_v5.swf?200";

下载下来分析一下,能看到其中包含所有2 API 的真正实现。

ctrl_mo_v5.swf -> MainProxy.as
1114public function getServerTime() : void
1115{
1116    var _loc1_:URLVariables = new URLVariables();
1117    _loc1_.gameid = this.gameID;
1118    _loc1_.uid = this.userID;
1119    var _loc2_:String = AllConst.URL_GET_SERVERTIME + "&ran=" + String(Math.random() * 1000000);
1120    this.logServerTime = new LogData(LogData.API_OTHER,"getServerTime");
1121    LoaderManager.loadBytes(_loc2_,this.getServerTimeComplete,_loc1_);
1122}
1123private function getServerTimeComplete(evt:Event) : void
1124{
1125    this.logServerTime.submit(true);
1126    var timeObj = com.adobe.serialization.json.JSON.decode(String(evt.target.data));
1127    this.realStage.dispatchEvent(new DataEvent(
1128        "serverTimeEvent",
1129        false,
1130        false,
1131        String(timeObj.time)
1132    ));
1133}

能看到 getServerTime 并没有直接返回时间,而是通过 dispatchEvent 将回调事件分发到舞台上。

在游戏中,会使用 addEventListener 注册回调来接收返回值。例如:

game.swf -> GMain.as
277// ...
278stage.addEventListener("netSaveError",this.netSaveErrorHandler,false,0,true);
279stage.addEventListener(SaveEvent.SAVE_GET,this.saveProcess);
280stage.addEventListener(SaveEvent.SAVE_SET,this.saveProcess);
281stage.addEventListener(SaveEvent.SAVE_LIST,this.saveProcess);
282stage.addEventListener("logreturn",this.saveProcess);
283stage.addEventListener("MVC_CLOSE_PANEL",this.closePanelHandler,false,0,true);
284stage.addEventListener("userLoginOut",this.onUserLogOutHandler,false,0,true);
285stage.addEventListener("serverTimeEvent",this.onGetServerTimeHandler,false,0,true);
286stage.addEventListener(PayEvent.LOG,this.onPayEventHandler);
287stage.addEventListener(PayEvent.INC_MONEY,this.onPayEventHandler);
288stage.addEventListener(PayEvent.DEC_MONEY,this.onPayEventHandler);
289stage.addEventListener(PayEvent.GET_MONEY,this.onPayEventHandler);
290stage.addEventListener(PayEvent.PAY_MONEY,this.onPayEventHandler);
291stage.addEventListener(PayEvent.PAY_ERROR,this.onPayEventHandler);

有了这些信息,来着手简单实现一下 API:

import unit4399.events.*;

public function getServerTime():void
{
    stage.dispatchEvent(new DataEvent(
        "serverTimeEvent", 
        true, 
        false, 
        "2000-01-01 00:00:00"
    ));
}
public function getData(ui:Boolean = true, index:Number = 0):void
{
    stage.dispatchEvent(
        new SaveEvent(
            SaveEvent.SAVE_GET, 
            SharedObject.getLocal("gameData").data.save[index], 
            true, false
        )
    );
}
public function getList():void
{
    stage.dispatchEvent(
        new SaveEvent(
            SaveEvent.SAVE_LIST, 
            SharedObject.getLocal("gameData").data.save, 
            true, false
        )
    );
}
public function saveData(title:String, data:Object, ui:Boolean, index:int):void
{
    SharedObject.getLocal("gameData").data.save[index] = {
        "index": index,
        "title": title,
        "data": data,
        "datetime": "2000-01-01 00:00:00"
    };
    stage.dispatchEvent(new SaveEvent(SaveEvent.SAVE_SET, true, true, false));

}
public function getBalance():void
{
    stage.dispatchEvent(new PayEvent(
        PayEvent.GET_MONEY, 
        {balance: 1000}, 
        true, false
    ));
}
public function decMoney_As3(param1:int):void
{
    stage.dispatchEvent(new PayEvent(PayEvent.DEC_MONEY, null, true, false));
}

注意,在实际中应使用 setTimeout 适当延迟返回。如果返回太快,游戏大概率出现异步 BUG。

结语 #

搞定了这些,游戏终于能运行了。

game play

当然,这只是一个框架。如果你愿意,也可以修改游戏的逻辑,比如增加爆率或者调整出怪量。虽然有些游戏会使用很多外部非 4399 的 API,产生更复杂的逻辑,使分析更花时间,但这个流程应该是通用的。

附录 #

搭建 ActionScript 编译环境 #

下载 Apache Flex®playerglobal.swc

解压 Apache Flex,将 playerglobal.swc 放入 frameworks/libs/player/32.0

接着创建 as3 工程,新建 asconfig.json 文件,细节参考 VSCode ActionScript & MXML Wiki

注意事项:

  • 类全名不要和已有名称冲突,建议放在名字独特的包中。
  • 帧率和尺寸需要于游戏一致,否则会出现白边或显示不全。
asconfig.json
{
	"compilerOptions": {
		"source-path": [
			"src"
		],
		"output": "Main.swf",
		"swf-version": 32,
		"target-player": "32.0",
		"default-size": {
			"width": 940, // depends on game
			"height": 590 // depends on game
		},
		"default-frame-rate": 30 // depends on game
	},
	"mainClass": "lnoloader.LMain" // note: avoid conflict
}

劫持原理 #

在 SWF 文件中,每个类的实例化都会首先通过 multiname 来进行名称解析。multiname 包括命名空间和具体名称。举个例子,下面代码:

import flash.net.URLLoader;
var loader:URLLoader = new URLLoader();

编译为 P-code:

findpropstrict QName(PackageNamespace("flash.net"), "URLLoader")
constructprop QName(PackageNamespace("flash.net"), "URLLoader"), 0

所有 multiname 都保存在对应 ABC tag 的常量池中。

multiname in ABC

我们要做的便是替换找到的指向 URLLoadermultiname,使得

import flash.net.URLLoader;
new URLLoader();

成为

import our.namespace.OurURLLoader;
new OurURLLoader();

  1. 劫持方法参考了 FFDec 调试功能的实现 ↩︎

  2. 实际上并不是所有,有些游戏会用其他 API。 ↩︎