2020 年底,Adobe 正式宣布 Flash Player 退役,一代人的网页游戏回忆也随之落幕。但总有办法让老游戏活过来——用 Ruffle,一个用 Rust 写成的 Flash 模拟器,编译成 WebAssembly,在浏览器里不装任何插件就能直接跑 .swf。这篇文章记录我如何用 Docker 自建一个 Flash 老游戏在线库,以及过程中最硬核的一段经历:给游戏打字节补丁。
一、为什么是 Ruffle
- 开源、纯前端,不依赖 Flash 插件,2026 年的浏览器照样能跑 2008 年的游戏;
- WebAssembly 编译,性能尚可,AS1/AS2 兼容性已经很完整,AS3 在 nightly 版本上不断进步;
- 本地自托管(ruffle.js + core + wasm),不依赖任何第三方 CDN,断网也能玩。
二、整体架构
一个 Docker Compose 搞定,nginx:alpine 静态托管,一行配置解决 swf/wasm 的 MIME 与缓存,外网走面板反向代理到 127.0.0.1:13400:
# /www/docker_data/games/docker-compose.yml
services:
games-nginx:
image: mirror.ccs.tencentyun.com/library/nginx:alpine
restart: always
ports:
- "127.0.0.1:13400:80"
volumes:
- ./www:/usr/share/nginx/html
首页是带搜索/分类/收藏的索引页,播放页 game.html?id=<id> 负责按游戏 id 加载对应 swf 与封面。
三、游戏从哪来:4399
写了 add_game.sh 一键抓取:解析游戏页的 _strGamePath → 真实文件其实在 szhong.4399.com/4399swf/ → 下载 swf 和封面 → 注册进 list.json 索引。几个坑:
- 部分游戏
_strGamePath指向.htm加载器页,得进页面再找真实.swf; - 封面是
//开头的协议相对地址,curl 要手动补http:; - MochiAds 等广告 SDK 已被 Ruffle 拦截,带广告的游戏可能卡死,这类直接放弃。
四、最硬核的部分:给游戏打字节补丁
不是所有游戏都能直接跑。狂暴飞车(110167) 就卡死在加载页:它的预加载器只有 8 帧,但 PLAY 按钮要第 30 帧才出现,而且 Ruffle 里 framesLoaded >= totalFrames 这个门控永远不成立,于是加载动画永远转下去。
排查过程:用 FFDec 反编译 swf,发现它是 AS3 0.6 版本、内嵌了 Alchemy/C++ 编译的 Flixel 引擎,入口是 A0 预加载器类,流程为 A0 → init() → loadBytes(_swfP) → startMainClass()。真正的浮动画按钮,是一个内嵌的 193 帧加载动画在每帧被 play() 重播造成的。
于是我对 A0 的 ActionScript Bytecode(ABC)手工打了 6 处 AVM2 补丁:
| 作用 | 改动 |
|---|---|
| 加载门控 | iffalse 判断改成恒进入 |
| 淡出等待 | screenFader.alpha<1 条件跳转改成无条件 jump |
| 帧数门控 | currentFrame>=totalFrames 恒真 |
| 淡入属性 | 读取 _fadeP 改成 pop + push true |
| 按钮动画 | 把播放逻辑改成 playButton.stop() |
| 动画循环 | callpropvoid play 改成 stop,加载画面停在第一帧 |
补丁后 PLAY 按钮终于停住可点。一个关键教训:FFDec 导出的十六进制行号并不等于原始 ABC 偏移,字节定位必须用搜索模式而不是行号;而且 Ruffle 对 FFDec 重编译的代码不执行,只能直接改字节。
五、成果
现在的游戏库:黄金矿工、森林冰火人、死神VS火影、拳皇、是男人就下100层、双人飙车……童年的快乐在浏览器里复活了。技术上还挂了一台 Chromium 无头浏览器做自动化回归,防止哪天 Ruffle 更新把游戏跑挂了都不知道。
./add_game.sh "http://www.4399.com/flash/8940.htm" # 黄金矿工
./add_game.sh "http://www.4399.com/flash/20082.htm" # 森林冰火人
Flash 死了,但回忆可以复活。