Flash 拆檔分析:靈夢的賽錢箱

某天我無意中翻到了一個 Flash 的小遊戲:靈夢的賽錢箱(霊夢の賽銭箱)。簡單來說,就是點博麗靈夢的賽錢箱。金額多少,靈夢的反應就會不同。

我就想,靈夢到底會有哪些反應呢?如果能捐一萬,會不會就可以推倒靈夢?甚至十萬 SM 玩起來?

我很好奇。

準備

正好我有用 JPEXS Free Flash Decompiler,決定試試看。另外想重溫 Flash 的話,可以試試 Ruffle 喔。

總之,需要準備以下軟體:

  1. Java 以執行 JPEXS Free Flash Decompiler。這裡使用 Eclipse Adoptium 實做的 OpenJDK
  2. JPEXS Free Flash Decompiler
  3. 能執行 .swf 檔案的程式。這裡用 Ruffle
  4. Flash 檔案。也就是咱的主角靈夢的賽錢箱。原始檔名是 saisen.swf。

準備好以後用 JPEXS Free Flash Decompiler 打開 saisen.swf 檔案。應該會出現以下畫面。

主畫面

啊,對,有點吵。所以快移到下面吧。

簡單看看

來看看有什麼東西吧──啊,最底下 script 耶。應該要先看這個?但……

script 資源

……但不知道東西怎麼跑耶,還是先看看別的吧。那麼多可以看的資源,先看 shapes 如何?

資源

那來看看 shapes 資源吧。

shapes 資源,選到了 shape 97 號

shapes 這張 97 號。似乎是評分的反應?那就來看看吧。

shape 97 號的資訊

Dependent Frames 2 裡面會連到 frames,但裡面沒有什麼實際有用的資訊。應該是幾個 sprites 合在一起?而 Dependent Characters 能連到 sprites 的 98, 123, 124 這三個。但這三個要看哪個?

先講 98 與 124 這兩個:98 就一張 sprite、124 是主畫面,直接讀會出問題。我這邊重開了三次吧。

那麼 123 呢?

sprite 123 號的資訊

很幸運,那似乎是遊戲結束,準備算結果的畫面。而且還很親切地標好了畫面名稱!texts 裡面也提供有用的線索。

texts 資源,選到了 text 100 號

對裡面的東西有信心了,就可以讀程式碼啦。

程式

剛剛第一張圖片的 script 裡面,程式是這樣寫的:

package saisen_fla
{
    import flash.display.MovieClip;
    import flash.events.Event;   
    public dynamic class MainTimeline extends MovieClip
    {
        public var bar_mc:MovieClip;
        public var totalBytes:*;
        public function MainTimeline()
        {
            super();
            addFrameScript(0,frame1);
        }      
        public function Loading(param1:Event) : void
        {
            var _loc2_:* = undefined;
            var _loc3_:Number = NaN;
            _loc2_ = this.root.loaderInfo.bytesLoaded;
            _loc3_ = _loc2_ / totalBytes;
            bar_mc.scaleX = _loc3_;
            if(totalBytes == _loc2_)
            {
                removeEventListener(Event.ENTER_FRAME,Loading);
                gotoAndStop(2);
            }
            else
            {
                gotoAndStop(1);
            }
        }
        internal function frame1() : *
        {
            stop();
            totalBytes = this.root.loaderInfo.bytesTotal;
            addEventListener(Event.ENTER_FRAME,Loading);
        }
    }
}

比較有疑問的是 addFrameScript 這個。看了下是幹嘛,答案好像與觸發指定 frame 有關。不過總之,這程式看來是做遊戲載入的。我們想知道的賽錢結果沒在裡面。

這裡是神社。你懂的吧?

那麼 saisen_fla.Timeline_2 呢?

package saisen_fla
{
    import adobe.utils.*;
    import flash.accessibility.*;
    import flash.display.*;
    import flash.errors.*;
    import flash.events.*;
    import flash.external.*;
    import flash.filters.*;
    import flash.geom.*;
    import flash.media.*;
    import flash.net.*;
    import flash.printing.*;
    import flash.system.*;
    import flash.text.*;
    import flash.ui.*;
    import flash.utils.*;
    import flash.xml.*;
    
    [Embed(source="/_assets/assets.swf", symbol="saisen_fla.Timeline_2")]
    public dynamic class Timeline_2 extends MovieClip
    {
        public var replay_btn:SimpleButton;
        public var resultSaisen:int;
        public var resultReimu_mc:MovieClip;
        public var startDate:*;
        public var light_mc:MovieClip;
        public var help_btn:SimpleButton;
        public var _coin:Coin;
        public var limitTime:*;
        public var saisenCount_mc:MovieClip;
        public var saisenbako_btn:SimpleButton;
        public var saisen:int;
        public var counter_mc:MovieClip;
        public var toTitle_btn:SimpleButton;
        public var return_btn:SimpleButton;
        public var resultText:TextField;
        public var start_btn:SimpleButton;
        public function Timeline_2()
        {
            super();
            addFrameScript(0,frame1,10,frame11,110,frame111,120,frame121,150,frame151);
        }
        internal function frame151() : *
        {
            stop();
            if(resultSaisen == 0)
            {
                resultReimu_mc.gotoAndPlay("結果1");
            }
            else if(resultSaisen < 1200)
            {
                resultReimu_mc.gotoAndPlay("結果2");
            }
            else if(resultSaisen < 1700)
            {
                resultReimu_mc.gotoAndPlay("結果3");
            }
            else if(resultSaisen < 2000)
            {
                resultReimu_mc.gotoAndPlay("結果4");
            }
            else if(resultSaisen < 2200)
            {
                resultReimu_mc.gotoAndPlay("結果5");
            }
            else if(resultSaisen < 2400)
            {
                resultReimu_mc.gotoAndPlay("結果6");
            }
            else if(resultSaisen < 2700)
            {
                resultReimu_mc.gotoAndPlay("結果7");
            }
            else if(resultSaisen < 3200)
            {
                resultReimu_mc.gotoAndPlay("結果8");
            }
            else if(resultSaisen < 5400)
            {
                resultReimu_mc.gotoAndPlay("結果9");
            }
            else if(resultSaisen >= 5400)
            {
                resultReimu_mc.gotoAndPlay("結果10");
            }
            else
            {
                resultReimu_mc.gotoAndPlay("結果11");
            }
            resultText.text = resultSaisen.toString();
            toTitle_btn.addEventListener(MouseEvent.CLICK,goToTitle);
            replay_btn.addEventListener(MouseEvent.CLICK,replayGame);
        }
        internal function frame1() : *
        {
            stop();
            start_btn.addEventListener(MouseEvent.CLICK,startGame);
            help_btn.addEventListener(MouseEvent.CLICK,helpGame);
        }
        public function startGame(param1:MouseEvent) : void
        {
            gotoAndPlay("カウントダウン");
        }
        internal function frame111() : *
        {
            stop();
            limitTime = 30;
            startDate = new Date();
            addEventListener(Event.ENTER_FRAME,timeCount);
            saisen = 0;
            _coin = new Coin();
            light_mc.mouseEnabled = false;
            saisenbako_btn.tabEnabled = false;
            saisenbako_btn.addEventListener(MouseEvent.CLICK,saisenCount);
        }
        public function replayGame(param1:MouseEvent) : void
        {
            gotoAndPlay("カウントダウン");
        }
        public function goToTitle(param1:MouseEvent) : void
        {
            gotoAndPlay("タイトル画面");
        }
        public function timeCount(param1:Event) : void
        {
            var _loc2_:* = undefined;
            var _loc3_:Number = NaN;
            var _loc4_:uint = 0;
            var _loc5_:Number = NaN;
            _loc2_ = new Date();
            _loc3_ = (Number(startDate) - Number(_loc2_)) / 1000 + limitTime;
            _loc4_ = _loc3_;
            counter_mc.second.text = String(_loc4_);
            _loc5_ = _loc3_ - _loc4_;
            counter_mc.millisecond.text = String(_loc5_).substring(1,4);
            if(_loc3_ <= 0)
            {
                removeEventListener(Event.ENTER_FRAME,timeCount);
                gotoAndPlay("ゲーム終了");
            }
        }
        public function returnToTitle(param1:MouseEvent) : void
        {
            gotoAndPlay("スタート画面");
        }
        internal function frame121() : *
        {
            resultSaisen = saisenCount_mc.saisenText.text;
        }
        internal function frame11() : *
        {
            stop();
            return_btn.addEventListener(MouseEvent.CLICK,returnToTitle);
        }
        public function helpGame(param1:MouseEvent) : void
        {
            gotoAndStop("説明画面");
        }
        public function saisenCount(param1:MouseEvent) : void
        {
            var _loc2_:SoundChannel = null;
            ++saisen;
            _loc2_ = _coin.play();
            saisenCount_mc.saisenText.text = saisen * 10;
        }
    }
}

唉呀,似乎有些有趣的玩意出來囉。感覺那個 frame151 method 裡面的結果有點像是我們要找的東西耶。

再對照一下 sprites 的話,saisen_fla.Timeline_2 會對應到出問題的主畫面 124。

sprite 124 號裡面 frame 111 號的資訊

我想我們找到答案了。

internal function frame151() : *
{
    stop();
    if(resultSaisen == 0)
    {
        resultReimu_mc.gotoAndPlay("結果1");
    }
    else if(resultSaisen < 1200)
    {
        resultReimu_mc.gotoAndPlay("結果2");
    }
    else if(resultSaisen < 1700)
    {
        resultReimu_mc.gotoAndPlay("結果3");
    }
    else if(resultSaisen < 2000)
    {
        resultReimu_mc.gotoAndPlay("結果4");
    }
    else if(resultSaisen < 2200)
    {
        resultReimu_mc.gotoAndPlay("結果5");
    }
    else if(resultSaisen < 2400)
    {
        resultReimu_mc.gotoAndPlay("結果6");
    }
    else if(resultSaisen < 2700)
    {
        resultReimu_mc.gotoAndPlay("結果7");
    }
    else if(resultSaisen < 3200)
    {
        resultReimu_mc.gotoAndPlay("結果8");
    }
    else if(resultSaisen < 5400)
    {
        resultReimu_mc.gotoAndPlay("結果9");
    }
    else if(resultSaisen >= 5400)
    {
        resultReimu_mc.gotoAndPlay("結果10");
    }
    else
    {
        resultReimu_mc.gotoAndPlay("結果11");
    }
    resultText.text = resultSaisen.toString();
    toTitle_btn.addEventListener(MouseEvent.CLICK,goToTitle);
    replay_btn.addEventListener(MouseEvent.CLICK,replayGame);
}

剛剛那個評分的反應,第一張在 Sprite 123 的 Frame 30 左右?

sprite 123 號裡面 frame 30 號的資訊

……嗯,對。那邊的 Frame 是 結果1。官方手冊也有說 gotoAndPlay 就是把 SWF 的 Frame 叫出來跑。

所以就是說,賽錢是 0 的話,會跑 結果1 的 frame 這樣?那就試試看啥都不點吧。

賽錢是 0 的實際結果:あんた、冷やかしに来たの?私はお茶飲むのに忙しいんだから、邪魔しないでよね

沒錯。所以按照其他程式碼弄下去,應該就是這種結果。

賽錢進了沒ーー?賽錢ーー!

OK, 所以 resultSaisen 哪裡來?frame121 是說:

internal function frame121() : *
{
    resultSaisen = saisenCount_mc.saisenText.text;
}

所以來看看 saisenCount_mc 怎麼來吧。

public function saisenCount(param1:MouseEvent) : void
{
    var _loc2_:SoundChannel = null;
    ++saisen;
    _loc2_ = _coin.play();
    saisenCount_mc.saisenText.text = saisen * 10;
}

這裡就很明白了:遊戲畫面會發出聲音、saisen 會加上去、saisenCount_mc 的文字會出現 saisen 乘上 10。

另外 saisenCount 方法會在 frame111 呼叫,也就是對應遊戲畫面(ゲーム画面)。

妳說妳什麼都能做,是吧?

好啦。到這裡很明顯了。計算賽錢的就在 saisen_fla.Timeline_2saisenCount 方法裡面。

那我就改 saisenCount 這個方法,把 saisenCount_mc.saisenText.text = saisen * 10; 改為 saisenCount_mc.saisenText.text = saisen * 100; 如何?

呀,迫不及待把靈夢推倒了。

賽錢是 16000 的實際結果:ズルしないで、自分の手でお賽銭を入れなさい。バチが当たるわよ。

啊。

不過也是。剛剛程式不就說賽錢超過 5400 就會出現 結果10 嗎?

結語

嗯,雖然 Flash 已經被淘汰了,不過用 Decompiler 玩逆向很過癮。發現 script 可以對應 sprites 對未來拆解 flash 也有幫助。

對了對了,靈夢對賽錢的反應是這樣的:

金額 原文 中譯
0 あんた、冷やかしに来たの?私はお茶飲むのに忙しいんだから邪魔しないでよね。 你小子,來這是要戲弄我嗎?我喝茶很忙,別來煩我。
10~1200 ・・・まぁ、感謝するわご利益があるといいわね・・・ ………啊,感謝您。祝您好運……
1210~1700 お賽銭感謝するわご利益があるといいわね 感謝您的賽錢。祝您好運。
1710~2000 お賽銭ありがとうきっとご利益があると思うわ 感謝您的賽錢。您好人一定有好報。
2010~2200 お賽銭ありがとう暇ならお茶でも飲んでいかない? 感謝您的賽錢。您有空的話,想喝點茶嗎?
2210~2400 お賽銭ありがとうよかったら一緒にお茶でもどう?おいしい羊羹があるのよ 感謝您的賽錢。您想喝點茶嗎?我這還有好吃的羊羹喔。
2410~2700 お賽銭ありがとう!よかったらご飯でも食べてったら?ごちそうするわ 感謝您的賽錢!您想吃點飯嗎?請往這邊走。
2710~3200 お賽銭ありがとう!せっかくだしお風呂にでも入ってさっぱりしていきなさいな 感謝您的賽錢!既然您特地到此一遊,要不要到浴室那沖點澡呢。
3210-5400 お賽銭ありがとう!よかったら泊まっていかない?遠慮しないでいいから♥ 感謝您的賽錢!您今晚想過夜嗎?請別客氣 ♥
5410+ ズルしないで自分の手でお賽銭を入れなさいバチが当たるわよ 請不要作弊。請用自己的手,把賽錢丟進去。你這樣會被懲罰喔。

然後如果出現「なんかエラーが出てるっぽいわよ」(好像出現錯誤了)的提示,代表你把遊戲程式搞壞了……

參考資料

圖片授權

JPEXS Free Flash Decompiler 的作者以 GPL 3.0 協議授權軟體。因此軟體截圖以 GPL 3.0 授權。

軟體截圖內的遊戲作者是KMTステーション。該遊戲版權不明,假定版權所有,以合理使用論。