| 单机游玩 4399 Flash 游戏
Play an 4399 Flash Game Offline
by Duramente
CAUTION
本文仅供学习交流使用,请勿用于商业用途。
前言 #
最近突然有了想玩 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 文件的功能。
注意,在得到 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 插件以获取更好的体验。
配置好项目后(参考附录 - 搭建环境)便可以得到代码提示和交叉引用等功能。
System4399Manager #
在 FFDec 中点击 Tools
-> Go to document class
,跳转到入口类 System4399Manager
。
可以发现, 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
的真正游戏主文件,并通过 setHold
或 mainHandler
接口接入游戏。
package
{
[Embed(source="/_assets/3_L4399Main_gamefile.bin", mimeType="application/octet-stream")]
public class L4399Main_gamefile extends ByteArrayAsset
{
public function L4399Main_gamefile() {super();}
}
}
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);
}
}
}
}
依赖 #
直接导出游戏文件运行试试。
得到如下报错:
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
文件,并将文件所在文件夹路径写入。
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 文件:
![]() |
![]() |
---|---|
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 层面劫持 URLLoader
类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);
}
}
}
- 将 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"))
- 将所有
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 服务器接收它们。
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
实例,游戏通过它来调用平台接口。
虽然我们可以修改装载器来使它能离线运行。这里,为了避免分析原有代码,让我们参考原有的接口写一个新的装载器:
package l4399.loader
{
import mx.core.ByteArrayAsset;
[Embed(source="gamefile.bin", mimeType="application/octet-stream")]
public class LOurLoaderMain_gamefile {}
}
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
中。如:
public function getServerTime() : void
{
ctrl.getServerTime();
}
ctrl
我们先前在 System4399Manager
中见过,加载地址如下:
private var urlCtrl:String = "http://cdn.comment.4399pk.com/control/ctrl_mo_v5.swf?200";
下载下来分析一下,能看到其中包含所有2 API 的真正实现。
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
注册回调来接收返回值。例如:
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。
结语 #
搞定了这些,游戏终于能运行了。
当然,这只是一个框架。如果你愿意,也可以修改游戏的逻辑,比如增加爆率或者调整出怪量。虽然有些游戏会使用很多外部非 4399 的 API,产生更复杂的逻辑,使分析更花时间,但这个流程应该是通用的。
附录 #
搭建 ActionScript 编译环境 #
下载 Apache Flex® 和 playerglobal.swc。
解压 Apache Flex
,将 playerglobal.swc
放入 frameworks/libs/player/32.0
。
接着创建 as3 工程,新建 asconfig.json 文件,细节参考 VSCode ActionScript & MXML Wiki。
注意事项:
- 类全名不要和已有名称冲突,建议放在名字独特的包中。
- 帧率和尺寸需要于游戏一致,否则会出现白边或显示不全。
{
"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 的常量池中。
我们要做的便是替换找到的指向 URLLoader
的 multiname
,使得
import flash.net.URLLoader;
new URLLoader();
成为
import our.namespace.OurURLLoader;
new OurURLLoader();