Block Blast は広告収入のためにどうプレイヤーを搾り取っているのか

サーバーから落ちてくるルールが、あなたの駒とスコアと広告を決める。その一つは高額な広告を待ってから、どこにも置けない一手を要求してくる。

日本語
コピー
ayoships.com 的题图:左侧是文章标题 How Block Blast harvests its players for ad money,右侧是 Block Blast 的棋盘和三个红色方块组,标着 No space left

動画を 1 本出したところ、再生回数が 1500 万を超えた。なので、コードがどうなっているのか、どうすれば自分で再現できるのかを、技術的な分解記事としてまとめておきたい。

まず、なぜ注目に値するのか。2026 年 1 月から 5 月までに、Block Blast は広告で約 1 億 2,700 万ドルを稼いだ。同じ期間の Candy Crush Saga は 8,110 万ドルだ(Sensor Tower の推計)。

Block Blast の中身の大半は、インストールしたアプリの中にはない

Android 版 10.8.2 から見ていく。Java 側は jadx で、ネイティブの Cocos ライブラリは Ghidra で逆コンパイルしたが、ストアから落としてきたパッケージの中に読む価値のあるものはほとんどなかった。Block Blast は小さなモジュールの寄せ集めで、コード内ではこのモジュールを trait、各 trait の変種を arm と呼んでいる。アカウントにログインせずクリーンな Android 14 エミュレータで動かし、キャッシュと logcat を眺めれば仕組みは見えてくる。起動時にサーバーが設定を配信し、自分がどの trait のどの arm を受け取ったかを示す。アプリはその arm を暗号化された JavaScript としてダウンロードする。今回のインストールで受け取った設定には約 700 個の trait が並び、そのうち 254 個が選択済み、101 個はインストール後にダウンロードされたものだった。

CTAgloReviveTrait
CTReviveTimeOutSkipSplashTrait
ClassNewDeathOptimizeTrait
DeadlyToHardTrait
DecreaseReviveUIShowByLimitHighScoreTrait
DifferentStrategyDieTrait
EndlessDeathBoardTrait
FastHandPunishTrait
GLBFDieReplayTrait
IsCacheReviveNumTrait
IsClassADReviveNoInerestingTrait
IsOpenInAdvanceDeathByLowEfficiencyGiveReviveTrait
IsOpenRandomToRandomNoDieTrait
LevelReviveCondiTrait
LevelReviveCountTrait
LevelReviveTrait
MABXiBaiTrait
MultiClearBoostThenKillTrait
RepateCPlusPlusRandomNoDeathTrait
ReviveBuffTrait
ReviveCleanBoardTrait
ReviveCountTrait
ReviveLimitTrait
RewardHighValueAdHarvestTrait
AdLevelTrait
AdPlusMinusStrategyTrait
AddClearTipsExTrait
AddMoreGameTrait
AdjustPercentChTrait
AlgoFillSortEdgeTrait
AlgoSDK_modifyBlockPosTrait
AlgoSDK_useAlgoSoAloneTrait
AlgoSameBlockShangZengTrait
AlgoSymmetryBlockTrait
BComboScoreChangeTrait
BCommentDisplayTrait
BPuzzleTimeTrait
BReplaceBinaryTrait
BackSpaceClickLimitTrait
BaseplateConditionDiffTrait
BaseplateLaneTrait
BaseplateTrait
BlockLimitClassicsTrait
BlockLimitLevelTrait
BlockPoolRoundAdjustV2Trait
BlockPoolStrategyExperimentTrait
ButtonClickAddShakeTrait
CTAdvertisementMultipleCallTrait
CTAdvertisementNoHideTrait
CTAgloNameSpecialTrait
CTAgloNameTrait
CTAgloStrategyTrait
CTBAlgorithmGaohuibaoARGTrait
CTChapterScoreComboTrait
CTChapterSkinBonesTrait
CTChapterWinOverTrait
CTClassSkinBonesTrait
CTComboDotTrait
CTCommentskinWithFeedBackTrait
CTDotClickGameEndInfoTrait
CTDotClickUsrDataGameEndTrait
CTDotDeadReportToUsrDataGameEndTrait
CTDotFirstClickDataGameEndTrait
CTEffectAccTrait
CTGBMClearTrait
CTGBMEmptySurfaceGameStartDotTrait
CTGameComboScoreTrait
CTGameEndStatusDotTrait
CTLowDeviceEffectTrait
CTMergeBlocksAgloNameTrait
CTMergeBlocksSkinBonesTrait
CTSkinBonesTrait
ChapterAlgoStrategyTrait
ChapterCollectScoreFixedTrait
ChapterWinShowRainbowTrait
ClassGuideEndNoAwaitNextFrameTrait
ClassicsRandomReplaceTrait
CleanSceneRandomSkinTrait
CleanSceneUseSequenceSkinTrait
ClearBoardHeartRippleAniTrait
ClearBoardPlusBlockPosTrait
ClearBoardPlusTrait
ComboAddRemoveNumTrait
ComboModeCtrlTrait
ComboRainbowTrait
ComboScoreSplitTrait
ComboTimeTrait
Combo_tips_lv_blockTrait
ComboflyScoreTrait
ConsecutiveLossRecoveryTrait
ContinueSameMoreRoundLimitTrait
ControlHighestScoreRhythmTrait
CrossClearHardTrait
CtrlAlgoLimitTimeTrait
DailyPerformanceReviewTrait
DailyWinStreakStateTrait
DifficultChangeColorTrait
EightHoursTravelTrait
ExceedAimScoreTrait
ExpendBoardScoreTrait
FastNearTkxcClassTrait
FastNearTkxcTrait
FeatclearscreenbombTrait
FeatclearscreenbombfixTrait
FillCleanAlgoTrait
FillExcludeTrait
FirstGameScoreTrait
FirstHardMoreClearAlgoTrait
FiveRoundStatisticsTrait
GLBFMiddleTipsViewTrait
GLBFProgressTrait
GLGameAdStrategyOptimizeTrait
GLResultView6Trait
GLShowAdsByGameTimeTrait
GameInfoDisplayTrait
GameLobbyNewEntryCtrlTrait
GameLobbyTrait
GameLobbyUnlockByBreakHighScoreTrait
GameOverShowMiniGameIconTrait
GateWinStreakStateTrait
GpEmptyBoardRatioGuardTrait
HighProgressShowBannerTrait
HighScoreBreakthroughBoostTrait
HighScoreLimitByUserAbilityTrait
HighValueInterstitialReliefTrait
HighWeightSessionQuestionReplacerTrait
HoleFillPriorityTrait
HotAreaAdaptScreenBottomStateTrait
HotAreaClickUtilTrait
InitBoardDelayTrait
InitBoardSnakeProStyleProTrait
InitialBoardDisableTrait
IsOpenBottomCoverChangeTrait
IsOpenChangeSkinTrait
IsOpenClassEncourageEffectByScoreTrait
IsOpenComboContinuousTrait
IsOpenComboInterruptionStrategyReplaceBlockTrait
IsOpenExtraReportTrait
IsOpenGLHallMoreGamesPopupTrait
IsOpenGlLoadAllGameBackTrait
IsOpenLevelOverTipsChangeTrait
IsOpenMoreCleanChangeTrait
IsOpenMoreCleanTrait
IsOpenNoVideoNoRevivieTrait
IsOpenOperaPosTrait
IsOpenRainbowPutBlocksEffectTrait
IsOpenRightPutTrait
IsOpenShang1ToShang3Trait
IsOpenVibrateTrait
IsOpenZhiJueToKunNanTrait
IsOpenZiMuShangZengTrait
IsPuzzleTimeTrait
IsShowAchievementTrait
IsStatisticalDataCollectTrait
IsTravelMoreDiamondAwardTrait
IsTurnAroundSurfaceTrait
IsWarmhandsTrait
JewelNewOutBlockTrait
LDEnableNoSyncHighScoreRollingTrait
LWMDualConditionHardPuzzleTrait
LaneSchemeTrait
LevelConfigTrait
LevelExpAlgorithmTrait
LevelLoopTrait
LevelNoThemeTrait
LevelRandomReplaceTrait
LevelRemoteThemeTrait
LevelWayHelpConfigTrait
LevelWayHelpTrait
LimitHighComboTrait
LimitNoviceBlockSizeTrait
LineDynamicSendTrait
LoadingTieringTrait
LocalThemeSaveTrait
LoginTimeFirstDifficultyRatioTrait
LongThinkClearAssistTrait
LowFeedbackPleasurableOfferTrait
LowHardSetFps30Trait
LowWeightFillBlockSortAdjustAlgoTrait
LowWeightFillBlockSortTrait
MaturityDevelopmentV2Trait
MergeBlocksIsPuzzleTimeTrait
MergeBlocksLaneSchemeTrait
MoreClearStopComboTrait
MoreTimeToOpreateStateTrait
MultiElementCollectTrait
NewAbilityModelTrait
NewBottomOfferTrait
NewPlayerContinuePlayIncentiveTrait
NewSeasonNotifyTrait
NewStructTrait
NewUserLevelTrait
New_player_direct_guide_to_thirdTrait
NewbieScoreRegulationTrait
NoBannerAdNewUserByDaysTrait
OpenChangeContinueHardTrait
OpenComboModelTrait
OpenLevelModelTrait
OriginSkinShowGreyDefultSetBtnTrait
OverHighScoreClearBoardTrait
ParallelClearHardTrait
Param230012Trait
PixelBitControllerTrait
PlaceBackAdaptTrait
PlaceHabitAdaptPathTrait
PlaytimeNoInterstitial1Trait
PostGameToServerTrait
PreClearScreenAnimOptimizeTrait
ProblemIntervalTimeRandomBetaTrait
PromotionMoreGamesInBlockCtrlTrait
PutBlockOptimizeTrait
Puzzle100Trait
Puzzle100WeightConditionTrait
PuzzleAfterTimingTrait
PuzzleBeforeTimingTrait
PuzzleConditionDiffTrait
PuzzleFail100Trait
PuzzleSuccess100Trait
PuzzleTiming100Trait
PuzzleTimingMaxPriorityTrait
RatioAdjustTrait
RatioDownTrait
ReduceTravelSettlementTrait
ReplaceBoardTrait
ReplaceClearnScreenAniTrait
RightPutHintCtrTrait
RobotModelEventDataTrait
SequencePredictDynamicDifficultyTrait
ServerControl_board_init_v2Trait
SetAddDefultSkinBtnTrait
SettleFireworkTrait
SetupNewTrait
ShangZengAddRandomTrait
Shangzeng3ReplaceZimushangzengTrait
ShowSSSWhenSkinChangeTrait
ShowTravellClearAnimaTrait
SimpleMultiSolutionTuning2Trait
SkinChangeAblePushTrait
SkinPreEliminateGoodPerformanceTrait
SkinRandomTrait
SkinThree24NewColorsTrait
SkinThreeOptimizationsTrait
SkinThreeStageTrait
SlowPlayerAnimTrait
SmallgameLocalResTrait
SolidColorTrait
TerminateRefreshScoreTrait
TheMoreEliminateTheMoreGoodEffectTrait
TravelEntryClockShadowFixTrait
TravelEntryClockShadowTrait
TravelHappyOverTrait
TravelNoSkinTrait
TravelResultOptimizingTrait
TravelScoreCutoffByProgressTrait

Android のテスト機に割り当てられた 254 個の trait すべて。

つまり、隣同士で並んで遊んでいても、走っているゲームは別物かもしれない。スタジオはリリースを出さずに、あなたの那份を書き換えられる。254 個の大半はチュートリアル、アニメーション、効果音といったありふれたものだ。少数だが、いつ負けるかを決めているものがある。そのうちの 1 つが広告を扱っている。

高単価広告を待ち構えるルール

名前は RewardHighValueAdHarvestTrait。ゲームがネイティブライブラリ内でこれらのファイルを復号している箇所を突き止め、そこから 15,616 バイトの圧縮された普通の JavaScript へと解いた。パラメータはコンストラクタで設定される。

RewardHighValueAdHarvestTrait.js · 63–67 行目

t.HIGH_PRICE_MULTIPLIER = 3.5;
t.MEDIUM_PRICE_MULTIPLIER = 1.5;
t.MIN_HISTORY_AD_WATCH_COUNT = 3;
t.RECENT_AD_NO_ESCAPE_COUNT = 3;
t.TARGET_AD_LEVEL = "high";

3 つの条件が同時に成立するのを待つ。直近 3 本のリワード広告をちょうど見終えたところで、いま読み込まれている広告の価値が前回の 3.5 倍以上あり、スコアがハイスコアを 1000 点超えている。3 つとも同じ関数で判定する。以下のコードは圧縮後の null チェックをオプショナルチェーンに書き換えたもので、ロジックは変えていない。

shouldTriggerDeath · 262–267 行目(整形済み)

shouldTriggerDeath() {
 if (!this.canTrigger()) return false; // finished your last 3 rewarded ads
 const d = this.storageData;
 return d.currentGameNum === hs.classGameInfo?.gameNum // same game
 && !d.currentGameHasTriggeredDeath // hasn't fired yet
 && !(hs.classScoreInfo?.score < this.currentScoreThreshold) // past high score + 1,000
 && !!this.isTargetAdReady(); // loaded ad ≥ 3.5× the last one
}

判定の中身を順に見ていく。

1. 直近 3 本のリワード広告を最後まで見ている。 リワード広告の再生が始まるたび、ルールは false をリストに積み、直近 3 件だけを残す(174–183 行目)。広告 SDK が報酬獲得を返せば、該当エントリを true に書き換える(191–199 行目)。判定はこの 3 件がすべて true であることを要求する。

canTrigger · 268–275 行目

t.prototype.canTrigger = function() {
var e = this.storageData;
if (e.totalRewardShowCount < this.MIN_HISTORY_AD_WATCH_COUNT) return !1;
var t = e.recentRewardResults.slice(0, this.RECENT_AD_NO_ESCAPE_COUNT);
return !(t.length < this.RECENT_AD_NO_ESCAPE_COUNT) && t.every(function(e) {
return !0 === e;
});
};

このフラグは Java 層の AppLovin MAX の onUserRewarded コールバックから来るので、途中で閉じた広告は false のまま残る。

2. いま読み込まれている広告の価値が前回の 3.5 倍以上。 ルールは NativeAd.callNativeGetMaxEcpmNumber("2") で、読み込み済みの最良リワード広告の価値を読む(304 行目)。Java 層ではこの呼び出しが準備完了のリワード広告枠をすべて走査し、AppLovin MAX が報告する収益に 1,000 を掛け、その最大値を返す。前回のリワード広告の価値にも同じ×1,000 を適用する(310 行目)ので、両者の尺度は揃っている。

classifyLoadedRewardLevel · 290–293 行目

t.prototype.classifyLoadedRewardLevel = function(e) {
var t = this.storageData.lastRewardShowEcpm;
return !Number.isFinite(t) || t <= 0 ? "none" : e >= t * this.HIGH_PRICE_MULTIPLIER ? "high" : e >= t * this.MEDIUM_PRICE_MULTIPLIER ? "medium" : "none";
};

3. 元のハイスコアを超えている。 各ゲームの開始時に、ルールはハイスコアを保存する(145 行目)。しきい値はこのハイスコアに scoreDeltaStep を足したもの(107–110 行目)。プレイヤーごとに分岐があり、自分の分岐 332243014 は 1,000、アプリ同梱の設定には 500 と 750 もある。ルールが発動したあとに復活すると、しきい値はさらに一段上がるので、同じゲーム中に再び発動しうる。

そして致命パズルを要求する。 次のブロックセットを配る前に、ゲームは「ボトムオファー」ルールのリストを順に実行する。このルールがヒットすると、リストを単一のジェネレータに差し替える。

onBottomOfferAlgo · 245–251 行目

t.prototype.onBottomOfferAlgo = function() {
if (this.shouldTriggerDeath()) {
hs.algorithmBottomSequenceInfo.setAlgorithmBottomList([ this.deathAlgorithm ]);
this.storageData.currentGameHasTriggeredDeath = !0;
this.saveStorageData();
}
};

deathAlgorithm は hs.OFFER_TYPE.ZHI_SI_TI を返す(102 行目)。ゲームはこの要求をパズルエンジンに送り、返ってきた 3 つのブロック ID を出す(メインスクリプト 45062–45121 行目、15074–15123 行目)。エンジンがセットを見つけられなければ、ルールはリセットされ、次の配布時に再試行する。

実際にどう判定するのかを見るため、ダウンロードしたファイルを一切改変せずそのまま実行し、テスト用の広告とスコアの値だけを与えた。致命パズルを要求したのは、3 条件がすべて成立したときだけだった。28 ケース、たとえば広告が 3 本ではなく 2 本しかない、1 本が途中で閉じられた、スコアが 1 点足りない、広告価値が 3.5 倍をわずかに下回る、といったケースを含む。

続いて実機環境で動かした。Apple シミュレータ上で動く iPhone アプリのテストコピーで、遊びながらルールの入力を読み取った。記録にはすでに有効なリワード広告が 3 本あった。スコアはしきい値を超えていた。Google AdMob のリワード広告が 1 本読み込み済みだった。ルールの付けた価格は 72.39、前回は 37.41 で、1.94 倍。必要なのは 3.5 倍だ。つまり iPhone 上では、このルールはすでに有効で、実際の広告価格を読み、前回の 3.5 倍に届く広告を待っている。

致命的な一手

この名前は、メインスクリプト内の 3 つのテーブルを通したマッピングを経ないと突き止められない。

main/index.45fec.js(元の行番号)

23878 e[e.ZHI_SI_TI = 9] = "ZHI_SI_TI";
16092 … r[n.OFFER_TYPE.ZHI_SI_TI] = i.AlgoClassTypeConst.algo_must_death, …
258441 e[e.algo_must_death = 24001] = "algo_must_death";

アルゴリズム 24001 は 2 か所に存在する。1 つは JavaScript、もう 1 つはゲームのネイティブ C++ライブラリで、そちらの関数名は func_death_blcok(原文のままの綴り)。どちらも同じ探索を行う。以下は JavaScript 側の核心部分。

rC · main/index.45fec.js 318602–318678 行(抜粋)

for (var d = 0; d < n.length; d++) if (41 != d) {
var m = n[d];
r.canPut(m) ? o.push(m) : i.push(m); // o: fits now, i: doesn't fit now
}
…
iC(r, o[p], o[f], i[_], h); // try every order of two pieces that fit
iC(r, o[p], i[_], o[f], h); // and one that doesn't, clearing lines
iC(r, o[f], o[p], i[_], h); // after each placement
iC(r, o[f], i[_], o[p], h);
if (0 == h.length) { // no order worked: take this set
u = [ o[p], o[f], i[_] ];
…
a.names = [ "致死题", "致死题", "致死题" ]; // "lethal puzzle" ×3

探すのは、その場で置ける 2 つのブロックと、置けない 1 つのブロック。どの順序、どの位置の組み合わせでも 3 つすべてを置けない場合に限り、その手札を保持する。計算には途中で消した行もすべて含める。3 つのうち 2 つは配られた時点で置けるので、実際に試すまでこの手札は詰みには見えない。

実際の様子はこうだ。ゲーム自身のコードがあなたのブラウザで動いており、1 手ずつ進められる。

元の記事には、ゲーム自身の生成コードをブラウザで実行する 5 ステップのインタラクティブなデモがある。

元のデモでは「New random board」を押すと新しい中盤の盤面に切り替わり、ゲームのコードがその盤面用に手札を選び直す。証拠パッケージ内のテスト盤面では、ゲームの生成器は縦向きのドミノ 1 つ、横向きのドミノ 1 つ、3×3 のブロック 1 つを致命的な手札と判定した。171 通りの配置をすべて試したが、盤面を空にできるものはなかった。一方、空の盤面なら同じ 3 つのブロックはどの順序でも 67,680 通りの置き方がある。

これを呼び出すのは広告ルールだけではない

致命的な一手は生成器であり、どのルールからでも呼び出せる。広告ルールは呼び出し元の 1 つにすぎない。iPhone で実機検証したところ、2 つ目の呼び出し元がカメラの前で発動した。それが PersonalMarginBreakTrait だ。ハイスコアを 10%超えると、配札のたびに 1 回抽選が行われ、そのうち 20%で同じ致命的な一手の生成が要求される。

PersonalMarginBreakTrait · onAlgorithmStrategyPriority、153–159 行 · オンライン設定:prob 20、recordPercent 10、replaceAlgoId 9

t.prototype.onAlgorithmStrategyPriority = function() {
this.hasReplacedThisGame = !1;
if (this.state.isFirstGame) ; else if (this.isReviveRound()) ; else if (this.state.isBreakHighScore && this.isCurrentScoreOverRecordPercent()) if (100 * Math.random() >= this.prob) ; else {
var e = this.replaceAlgoId; // 9, the lethal puzzle
…
if (this.callNewAlgorithm(e)) ; else {

こちらの記録は 1,304 だったので、しきい値は 1,434。3,356 点で発動し、ゲームは横 5 マスの横棒、縦 5 マスの縦棒、ギザギザブロックを配った。ゲーム自身の配札ログはこのルールを呼び出し元として記録し、アルゴリズム 9 が要求され、かつ使用されたことを示し、3 つのブロックすべてを致命パズルとして記録していた。置ける場所はどこにもない。2 回の独立した全探索の結論は一致した。配置は 0 通り。このルール、オンライン設定、そしてこの配札は、すべてオンラインの iPhone パッケージに入っている。

3,356 点時の配札、Revive ボタンまで。Apple シミュレータ上の iPhone アプリのテストコピーで録画。しきい値に早く到達するため、録画前に保存済みのハイスコアをリセットした。

どこで負けるかを決めるモデル

ここまでのルールはすべて手書きだった。ゲームはさらに、学習済みモデルをダウンロードしてあなたの端末上で動かす。そのうちの 1 つが、致命パズルがステージのどこに現れるかを決める。

これはゲームのステージモード(コード内では Chapter)用の、木 500 本の XGBoost 分類器。ファイル名は predict_travel_difficulty_dead で、52 個の入力の大半はあなたの成績に関するものだ。現在のステージ、試行回数、今週クリアした数、アプリを入れてからの期間など。ゲームは 12 個の選択肢、つまり sw_process という設定の 4 つの値と 3 つの難易度の組み合わせを入力し、モデルのスコアが最も高いものを採用する。

ClientDieModelLoadTrait · doPredictFeature、1286–1312 行(抜粋)

if (0 == e) for (var i = 1; i <= 4; i++) for (var l = 1; l <= 3; l++) {
var c = this.getInputFeature(this._jsonModel.learner.feature_names, i, l);
if ((u = this.xgb_clf_predict(c, …)) > n) { // keep the highest-scoring option
n = u;
a = i;
o = l;
}
}
…
storage.setItem("DieModel_last_predict_info", { swProcess: a, travelLvDifficulty: o, pre: n });

別のルールがこの選択をステージ内の区間に変換する。前半 30%、30〜80%、あるいは最後の 20%だ。その区間に到達したとき、あなたが普段よく復活を使う(75%超)なら、追加の復活を 1 回与える。そしてその後の 4 回目の配札から、致命パズルの出現を要求する。

IsOpenDeathChangeReviveCountTrait · 224–229 行および 181–184 行

var t = hs.chapterConfigInfo.getChapterProgress(), i = e.getLastPredictInfo(this.traitName), r = i.swProcess, …;
…
1 == r ? s = [ 0, .3 ] : 2 == r ? s = [ .3, .8 ] : 3 == r ? s = [ .8, 1 ] : 4 == r && (s = [ 1, -1 ]);
return t < s[0] ? a.First : t <= s[1] ? a.Second : a.Third; // before, inside or after that stretch
…
if ((n = this.saveData).needZs && n.zsRoundNum > 3) {
hs.algorithmStrategyInfo.setAlgorithmPriorityList([ hs.OFFER_TYPE.ZHI_SI_TI ]); // the lethal puzzle
hs.algorithmStrategyInfo.setAlgorithmFailList([ hs.OFFER_TYPE.SHANG_ZENG_3 ]);
}

同じ選択を逆に解釈するルールもあり、4 番目の選択肢に落ちたときはステージを簡単にする。

あなたに値段をつけるモデル

2 つ目のモデルはあなたに値段をつける。ゲームは自社サーバーから xgboost_regression_model111.json をダウンロードし、あなたの端末上で動かす。

XGBoost の回帰モデルで、決定木 59 本、最大深さ 8、入力 27 個。大半は金銭に関わる。18 個の入力は、ゲームが今日と昨日あなたに読み込んだ広告だ。残りはあなたの位置情報、端末、インストール時期、そして初回プレイの出来。決定木が最も重視するのは、初回スコア、直近の広告の価格、メモリ、端末モデル、国。

コードはこの出力を finalEcpm と呼ぶ。eCPM は広告業界で広告表示 1,000 回の価値を表す言葉で、ゲームが広告価格を保存するときの単位でもある。つまりこのモデルが推定するのは、広告としてのあなたの価値だ。

GPT-6 Astra がゲームの入力コードと木の走査ロジックを移植し、移植版はゲーム自身の出力を小数第 1 位まで再現した。架空の米国の新規プレイヤー、初回スコア 1,000 で試した。App Store からインストールした iPhone 15 の場合と Android の場合を用意し、その後は 1 つずつ入力を変えていった。

変更する項目広告価値(iPhone 15)広告価値(Android)
変更なし(米国、初回スコア 1,000)25.4848.96
ブラジル在住に変更8.0817.90
インド在住に変更5.5914.83
初回スコア 50025.7953.57
初回スコア 2,00021.1252.64
初回スコア 10,00020.3458.26

同じプレイヤーが米国からブラジルに移ると、価値はおよそ 3 分の 1 に下がり、インドならさらに下がる。初回スコアも影響するが、線形ではない。iPhone では初回スコア 500 のほうが 2,000 や 10,000 より高い値がつく。

ゲームはこの価格を使って、どんな種類の広告を何本出すかを決めている。最も分かりやすいのは、サーバーに全画面広告とリワード広告の 2 つの価格を問い合わせ、それを比べる箇所だ。

IsOpenInterAndRewardAdvDecideReviveTrait · checkEcpm, mainTraits/index.88dc4.js 38529–38534 行

t.prototype.checkEcpm = function() {
var e, t, o, r = this.saveData.interEcpm, i = this.saveData.rewardEcpm; // your full-screen and rewarded prices
…
return (…isReverse) ? r <= i * (…multiple) : i >= r * (…multiple);
};

リリース版のアプリでは、リワード広告の価値が全画面広告の 2 倍以上あれば(他のバージョンでは 1.5 倍または 2.5 倍)、復活の機会が 1 回増え、その復活ではリワード広告が流れる。全画面広告のほうが稼げる相手なら、この機会はない。

他のルールも同じ理屈だ。FullScreenAdAddByEcpmTrait は価格が高いときに全画面広告を余分に差し込む。IsOpenEcpmTooLowNoInterTrait はほとんど価値がないときに全画面広告を飛ばす。リワード広告が十分に値打ちのある相手なら、復活を早めに開放したり、3 回目の復活を出したりするルールもある。

元の投稿にはこのモデルのインタラクティブ版がある。ここをクリック。架空の米国プレイヤー、iPhone 15、初回スコア 1,000 で試すと、返ってくる finalEcpm は 25.47779944720001 だった。

以下はゲーム内で木をたどる部分のコードだ。ごく普通のループで、根ノードから始めて特徴を 1 つ入力するたびに左か右へ進み、最後に葉の重みを合計する。

EcpmFeatureBaseInfo · runModel, main/index.45fec.js 63141–63175 行(抜粋)

for (var m = parseFloat(a + ""), u = 0; u < t.length; u++) { // m starts at the base score; t is the 59 trees
for (var p = t[u], f = 0, _ = 0; _ < o; ) {
_ += 1;
var h = p.left_children[f], g = p.right_children[f], y = p.split_conditions[f], C = p.default_left[f], v = s[e[p.split_indices[f]]], T = -1;
(null === v && 1 === C || v < y) && (T = h);
(null === v && 1 !== C || v >= y) && (T = g);
if (-1 === T) {
m += parseFloat(p.base_weights[f].toString()); // reached a leaf: add its weight
break;
}
f = T;
}
if (_ >= o) throw new Error("[EcpmFeature] 树的遍历超过最大深度,可能存在死循环。");
// "Tree traversal exceeded max depth, possible infinite loop."
}

明日はあまり遊ばないと予測するモデル

1,000 本の木からなる LightGBM モデルもあり、93 個の特徴を入力に、翌日にあなたがより不活発になるかどうかを予測する。最も重視するのはインストールからの経過期間、最高スコア、メモリ容量、平均スコアだ。スコアが 0.69 以上になると、対応するルールが復活ボタンをピンクに変え、復活時の広告をスキップする。

NewNextDayLowActivityTrait 1010 行 · NextDayLowActivityFirstReviveFreeTrait 112–117 行

return this.state.predictionScore >= this._lowActivityThreshold; // 0.69
…
if (hs.tp.isClassAdvertisement_RewardProxyShieldPlayAdvertisement(e) || …) {
var r = cc.isValid(this._pinkReviveNode);
if (!this.isTrigger || !r) return;
e.returnValue = !0;
e.args[0] = !0; // shield the ad: the revive goes through without one
this.saveNextDayLowActivityReviveTime(Date.now());
}

効果は 1 日 1 回。

サーバー上のモデル

データを送信し、返ってきたスコアに従って動くルールもある。スコアはサーバー上のモデルが算出するもので、churn、LTV、revive-click、xDeepFM といった名前が付いている。重みはサーバー側に残るため、端末が何を送っているかしか示せない。/v1/ecpm_prediction へのリクエストの 1 つは 40 個のフィールドを伴い、通信事業者、ストレージ容量、現在の時刻、直近の広告価格の統計などが含まれていた。

ゲームに影響する他のルール

駒やスコアを変えるルールは広告だけではない。iPhone 上で実際に動作しているものを 1 つ捕捉した。MaturityDevelopmentV2Trait は各セッションに 3 つの要素からなる「成熟度」スコアを付ける。習慣(10.98)、スキル(14.66)、広告履歴(19.27)だ。私たちの場合は合計 44.91 で、閾値の 65 を下回ったため、本来出すはずの難しいパズルの一部を簡単な埋め合わせに差し替えた。別のルール FailStreakStrategyTrait は、2 ゲーム続けて記録を下回ったところで介入し、より簡単な駒を出せと要求してきた。

これらは Android で広告ルールを追跡中に見つかったもので、いずれも私の調査ファイルに同種の証拠がある。

ルールコードの動作
MultiClearBoostThenKillTraitユーザー ID を 2 つのサーバーエンドポイントに送り、2 つのタグを取得する。コードはこの 2 つのティアをどちらも「怼死不敏感」と名付けている。この 2 つのティアに対しては、大きな消去を 1 回待ち、数ラウンド後、プレイ時間がいつもの 1 ゲーム分に近づいたところで、あの致命パズルを要求する。
MABXiBaiTrait致命パズルをいつ要求するかを試す 6 群の実験。要求しない、または 4 分後、6 分後、10 分後、約 17 分後、30 分後。各群は、15 分以内に新しいゲームを始めたか、復活の提案をどの割合で受け入れたかを報告する。
FastHandPunishTrait直近 6 ゲームの平均が 1 ゲーム 4.2 秒未満で、かつ最高スコアの 93%を超えているとき、難しいブロックを要求する。
AdPlusMinusStrategyTrait復活の提案を 1 回余分に出した後、現在のスコアに最高スコアの 10%を上限とするランダムなボーナスを加える。

よくある質問

iPhone でも同じか?

同じだ。iPhone 版の Block Blast、バージョン 7.4.9 も分解し、別の GPT-6 Astra の実行でこれらの発見をゼロから検証し直した。更新サーバーから同じルール一式をダウンロードしており、ファイルは 1 行を除いて Android 版と完全に一致する。残りはすべて同じだ。広告 3 回、3.5×、スコアバー、そして同じ致命パズル生成器。

iPhone アプリには、このルールの第 2 版 RewardHighValueAdHarvestV2Trait も含まれている。この版では広告履歴のチェックをオフにできる。

RewardHighValueAdHarvestV2Trait.js (iOS) · 323〜331 行目

t.prototype.canTrigger = function() {
var e = this.storageData, t = this.viewCountThreshold;
if (e.totalRewardShowCount < t) return !1;
if (!this.requireRecentNoEscape) return !0;
var r = e.recentRewardResults.slice(0, t);
return !(r.length < t) && r.every(function(e) {
return !0 === e;
});
};

アプリには全部で 12 バージョンが入っている。うち半分はこのチェックをオフにしており、必要とする広告本数は 1〜3 本とばらつく。つまりバージョンによっては、途中で閉じたリワード広告 1 本だけで条件を満たしてしまう。価格のしきい値はアプリの利用期間で変わる。最初の 1 週間はそのセッションで最初に読み込まれた広告の 3.2 倍、30 日目には 2.7 倍、それ以降は最後の広告の 3 倍だ。

Wi-Fi なしで遊ぶとどうなる?

このルールはダウンロード後に端末へ保存されるため、オフラインでも動作する。ただし発火するのはリワード広告が読み込まれた後だけであり、広告の読み込みにはネットワークが必要だ。広告が 1 本も読み込まれなければ発火しない。他のルールは通常どおり動く。私の Android 端末では、記録に近づいて速く打っているときに難しいピースを要求するルールが完全に端末上で動いていた。どのプレイヤーが「やられても気にしない」かをサーバーに問い合わせるルールは、オフラインでは答えが返らないため、そのセッションでは一切効かなかった。

広告ブロッカーは効く?

どの種類のブロッカーかによる。iPhone や Android のブラウザ用広告ブロッカーはブラウザ内でしか働かず、ゲームには影響しない。端末全体をカバーするブロッカー、たとえば Android の Private DNS や、両プラットフォームの DNS ベース・ VPN ベースのブロッカーならリワード広告の読み込みを止められる。するとこのルールは、オフライン時と同じ理由で発火しない。広告に依存しないルールは通常どおり動く。高速プレイのルール、致命的パズルを要求できるタイミングを計る実験、そしてオンラインであることを前提にサーバーへ問い合わせるルールも含まれる。

記録を更新した直後に負けるのはこれが原因?

これらのルールはまさにそこを狙っている。我々は PersonalMarginBreakTrait がオンラインでそうしているのを確認した。記録を 10%超えた時点で、毎ゲーム 20%の確率で致命的パズルになる。広告ルールのほうは記録を 1,000 点超えるまで待ち(他のバージョンでは 500 点か 750 点)、復活後にしきい値を引き上げて再び発火できる。

回避できる?

ほぼできない。Android では元のルールが直近 3 回のリワード広告すべての付与成功を要求するので、1 本を途中で閉じれば確かに止まる。ただし再び 3 回連続で満額を受け取るまでは。一方 iPhone では V2 版がこのチェックを完全に飛ばすため、閉じた広告であっても 1 本見れば発火する。タイミング実験や大きなマッチを待つルールなど、致命的パズルを要求する他のルールは広告を一切見ていない。全プラットフォームでこのルールを止められる唯一の条件は、読み込み済みのリワード広告が存在しないこと、つまりオフラインか端末全体の広告ブロッカーを入れている場合だけだ。

これって適応型難易度では?

適応型難易度はプレイ内容に応じてピースを変える。このルールはピースの置き方がうまいかどうかを見ていない。入力は直近の広告結果、メモリ上にあるその広告の価格、そして記録に対するスコアだ。しかも要求する生成器が返すのは、より難しいピース群ではなく、どう置いても解けないピース群である。

こんなものが許される?Apple はアプリを審査しないの?

Apple が審査するのはストアからダウンロードするアプリそのもので、このルールは審査の対象外だ。インストール後、アプリはゲームサーバーからこのルールをダウンロードし、どのプレイヤーがどのバージョンを受け取るかはサーバーが決める。Apple は実際、アプリの性質を変えないことを条件に、こうした JavaScript の更新をダウンロードすることを認めている。Expo Updates のようなツールがそうだ。広告価格に応じて駒を調整することがこれに当たるかどうかは、Apple が判断する。

なぜサーバーではなく端末上で動くの?

誰がどのルールをどのバージョンで受け取るかを決めるのは確かにサーバーだ。だがその判断には、その時点で端末しか知らない情報が要る。盤面、次に出るピース、そしてその瞬間に読み込まれている広告だ。だからこの部分は端末上で動いており、だからこそ分解して分析できる。

本当だとどう確かめるか

  • アプリ本体。 Android:APKPure から 2026 年 10 月 11 日にダウンロードした 10.8.2 の XAPK。すべての split APK が署名検証に通り、バージョン番号も APKMirror 上の公開項目と一致した。iOS:公開ミラーから取得した 7.4.9 の IPA。メインスクリプトとカタログファイルを含む 4,670 個のゲームアセットすべてが Apple のコード署名検証に通った。
  • ルールは彼らのサーバーから来ている。 Android アプリのキャッシュには、bband1.afafb.com からこのルールをダウンロードした記録があり、すべての traits もこのホストから取得している。我々がその URL を別途リクエストしたところ、ファイルはアプリのキャッシュ内のコピーとバイト単位で一致した。iOS アプリの署名済みカタログには iOS 向けルールが 2 件記載されており、アプリはそれらを bbios1.afafb.com から取得する。10 月 15 日時点で 3 つの URL は同じファイルを返しているので、自分で検証できる(コマンドは下記)。
  • ルールは配信され、読み込まれている。 アカウントのない新品の Android 14 エミュレータ上で、2026 年 10 月 11 日 23:01:15 UTC にアプリは設定の準備を完了し、このルール用に arm 332243014 を選択、モジュールは読み込まれてストレージの初期化を終え、接続先の下部オファーパイプラインも有効になっていた。
  • デコード結果は正確。 traits は XXTEA で暗号化され、さらに Brotli で圧縮されている。デコード後のルールのハッシュは下記のとおり。ネイティブライブラリについては、オフラインでのデコード結果が実行中アプリのメモリダンプと 25,317,680 バイトにわたり完全に一致し、不一致はゼロだった。
  • Android 版ゲーム自体には一切手を加えていない。 唯一の変更は、エミュレータ上でゲームを描画するためのレンダリング上の回避策だ。スコア、広告、フラグ、設定値は一切編集していない。

自分で検証する

証拠パッケージは 3 つ。Android(113 KB、SHA-256 bd55151e6a1527f628459c2fffa115d5b14a765a3fb5a3f729387fbac6f707c8)と iOS(2.7 MB、SHA-256 02bdb0e0e9b02d77844efbcf4b82f48e17f2b688fc48cbf84f83e75ae289df7d)、それに iPhone 実機でのパケットキャプチャ(270 KB、SHA-256 5f36863daabd2b500c8062d005f41e6c5e7dcea5a238c89e558e19a928dcc57f)だ。中身はデコード済みのルール、暗号化された元ファイル、両アプリの残りの部分の抜粋(元の行番号付き)、テストスクリプト、出所の記録。必要なのは Node.js と Python 3 だけだ。

ターミナル · Android 証拠パッケージ

# the rule, run unmodified with fake inputs: 28 checks, prints "passed": true
node proof/verify_reward_harvest.js

# brute-force search of the test board: 0 solutions in every order
python3 proof/verify_unsolvable.py

# the game's own generator on that board: picks pieces [2, 3, 13]
node proof/verify_packaged_algorithm.js

ターミナル · iOS 証拠パッケージ

# both iOS rules, the generator, two solvers and the code-signature check
python3 proof/run_all.py

ターミナル · サーバー上の暗号化ルールが証拠パッケージと一致

curl -s https://bband1.afafb.com/update/block_android_online/remoteRes/0/remote/RewardHighValueAdHarvestTrait/index.700f5.js | shasum -a 256
# dec91aa4001f651a74bf101d512587b9a364cb71ea2a81a9cc9dbbcd2444b477
curl -s https://bbios1.afafb.com/update/block_ios_online/remoteRes/0/remote/RewardHighValueAdHarvestTrait/index.ff35e.js | shasum -a 256
# 61ac70045995f44b3c6bb22b2d062a386593ce6a632fbcec3863a51110850cc2
curl -s https://bbios1.afafb.com/update/block_ios_online/remoteRes/0/remote/RewardHighValueAdHarvestV2Trait/index.677f6.js | shasum -a 256
# e38953430980f4b6a10b29337ec9a41634bb732ee84dd5997bfe78507b26ee1e
  • Android ベース APK · a520fcc1d1ba1ff0c643e30b059c87a01fde650ec46be5c85fde5d815ec82447
  • Android XAPK · 91988cf6e92cd0d346b869df0a8c6740f3a5fd8b722e21779e045426c7553015
  • Android ルール(デコード済み)· f94a5d54d7f6ab0af097f408bda09b091e7767b17749f8ba828b41b638b330ad
  • Android メインスクリプト · 8dce6532cafc25d982f2af3659647e0e0017c3902cbc31f872d375804dc3678c
  • iOS IPA · 7fc9b47e10dc8303f662e1c0ad88b93e7119e2846972eddb1a18bd205b54056a
  • iOS ルール(デコード済み)· 179bf50ae3c0c4a9bd5457281e4876faae728778f6181dbb0d3799b42ec70d61

鍵と、この 2 つのアプリの保護を回避する手順は証拠パッケージに含まれていない。記者や研究者でデコードの工程を検証したい場合は、メールを送ってほしい。

出典: ayoships.com← ホームへ戻る