Block Blast 如何收割玩家以赚取广告费
从服务器下载的规则决定你的棋子、得分和广告。其中一条会等到一条高价广告出现,然后给你一副无论怎么放都放不下的方块。
中文
复制

我发过一条关于这款游戏的视频,播放量超过 1500 万,所以想再写一篇完整的技术拆解,讲清代码是怎么回事,以及你怎么自己复现。
先说清楚它为什么值得关注。2026 年 1 月到 5 月,Block Blast 靠广告赚了约 1.27 亿美元,同期 Candy Crush Saga 是 8,110 万美元(Sensor Tower 的估算)。
Block Blast 的大部分内容并不在你安装的那个应用里
我们从 Android 版 10.8.2 入手。用 jadx 反编译了 Java 那一侧,用 Ghidra 反编译了原生的 Cocos 库,结果发现从应用商店下载到的这个包里几乎没有什么值得看的东西。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 个里大部分都很普通,无非教程、动画、音效。少数几个管的是你什么时候输。其中一个管的是广告。
一条等着高价广告的规则
它叫 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.5 倍,你的分数已经超过最高分 1,000 分。这三个条件都在同一个函数里检查。下面这段把压缩后的 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. 最近三次激励广告都看完了。 每次激励广告开始播放,规则就把 false 压入一个列表,只保留最近三条(第 174–183 行)。如果广告 SDK 回报奖励已获得,就把对应条目改成 true(191–199)。检查要求这三条都恰好是 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。如果规则触发后你复活了,门槛会再往上抬一档,所以同一局里它可能再次触发。
然后它索要致命谜题。 在发下一组方块之前,游戏会按顺序跑一遍“底部 offer”规则列表。这条规则命中时,它把列表替换成单个生成器。
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 行)。游戏把这个请求发给谜题引擎,然后发出拿回来的三个方块 ID(主脚本第 45062–45121 行和第 15074–15123 行)。如果引擎找不到一组,规则会重置,并在下一次发牌时重试。
为了看它实际怎么判断,我们原样运行了下载下来的文件,没有做任何改动,只喂了测试用的广告和分数值。它恰好在三个条件全部成立时索要致命谜题,28 个用例,包括只有两条广告而不是三条、一条广告提前关闭、分数差 1 分,以及广告价值略低于 3.5 倍。
接着我们在真机环境里跑了一遍。在 Apple 模拟器中运行的 iPhone 应用测试副本里,一边玩一边读取规则的输入。记录里已经有三个有效的激励广告。分数已经过线。一条来自 Google AdMob 的激励广告已加载就绪。规则给它的定价是 72.39,而上一条是 37.41,相当于 1.94 倍。它需要 3.5 倍。所以在 iPhone 上,这条规则已经生效,读取的是真实广告价格,正等着一条价值达到你上一条 3.5 倍的广告。
致命牌局
这个名字要经过主脚本里的三张表映射才能对上。
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 存在两份。一份在 JavaScript 里,一份在游戏的原生 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
它要找的是两块当下就能放下的方块,加一块放不下的,并且只有在任何顺序、任何位置组合都无法把三块全部放下时,才保留这组牌,计算时把沿途消掉的每一行都算进去。三块里有两块在发牌时就能放下,所以这手牌在你真正去试之前,看起来并不像无解。
下面就是它的样子,游戏自己的代码正跑在你的浏览器里,可以一步一步走。
原帖在这里有一个可交互的 5 步演示,在浏览器中运行游戏自己的生成器代码。
在原版演示里,你可以点“New random board”换一个全新的中局棋盘,游戏代码会为它重新挑一手牌。在证据包里的测试棋盘上,游戏的生成器把一块竖向多米诺、一块横向多米诺和一个 3×3 方块判定为致命手牌。我们试了全部 171 种摆放,没有一种能清空棋盘。而在空棋盘上,同样这三块方块在每种顺序下都有 67,680 种摆法。
要它出手的不止广告规则
致命牌局是一个生成器,任何规则都可以调用它。广告规则只是其中一个调用方。我们在 iPhone 上实测时,第二个调用方在镜头前触发了,就是 PersonalMarginBreakTrait。一旦你的分数超过最高分 10%,它就会在每次发牌时掷一次,其中 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 分时它触发了,游戏发了一根五格长的横条、一根五格高的竖条和一个锯齿块。游戏自己那次发牌的记录把这条规则标为调用方,显示算法 9 既被请求也被使用,并把三个方块都标为致命谜题。它们根本无处可放。两次独立的穷举搜索结论一致,都是零种摆法。这条规则、线上设置和这次发牌都在线上 iPhone 包里。
3,356 分时的那次发牌,一直到 Revive 按钮。在 Apple 模拟器中的 iPhone 应用测试副本里录制,录制前我们重置了它保存的最高分,以便快速到达那条线。
决定你在哪里输的模型
到目前为止的规则都是手写的。游戏还会下载训练好的模型并在你的手机上运行,其中一个模型决定致命谜题出现在关卡的哪个位置。
这是一个用于游戏关卡模式(代码里叫 Chapter)的 500 棵树 XGBoost 分类器。它的文件名为 predict_travel_difficulty_dead,52 个输入大多关于你的表现,包括当前关卡、尝试次数、本周完成的局数、装了应用多久。游戏喂给它 12 个选项,即一个叫 sw_process 的设置的四个取值乘以三个难度等级,然后保留模型打分最高的那个。
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% 的情况),它就给你一次额外复活。然后,从之后的第四次发牌开始,它要求出现致命谜题。
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 ]);
}
其他规则对同一个选择作相反解读,当它落在第四个选项上时把关卡调简单。
给你定价的模型
第二个模型给你标了价。游戏从自己的服务器下载 xgboost_regression_model111.json,在你的手机上运行。
这是一个 XGBoost 回归模型,59 棵决策树,最深 8 层,27 个输入。其中大部分和钱有关。18 个输入是游戏今天和昨天给你加载的广告。其余的是你所在的位置、你的手机、安装时间,以及你第一局打得怎么样。决策树最看重的是你的首局得分、最近一条广告的价格、你的内存、手机型号和国家。
代码把它的输出叫作 finalEcpm。eCPM 是广告行业对一千次广告展示值多少钱的叫法,也是游戏存储广告价格时用的单位。所以这个模型估算的是你在广告上值多少钱。
GPT-6 Astra 移植了游戏的输入代码和树遍历逻辑,移植版复现游戏自身的输出,精确到最后一位小数。我们拿一个虚构的美国新玩家跑了一遍,首局得分 1,000,一次是从 App Store 安装的 iPhone 15,一次是 Android,然后每次只改一个输入。
| 只改这一项 | 广告价值,iPhone 15 | 广告价值,Android |
|---|---|---|
| 什么都不改(美国,首局得分 1,000) | 25.48 | 48.96 |
| 改为住在巴西 | 8.08 | 17.90 |
| 改为住在印度 | 5.59 | 14.83 |
| 首局得分 500 | 25.79 | 53.57 |
| 首局得分 2,000 | 21.12 | 52.64 |
| 首局得分 10,000 | 20.34 | 58.26 |
同一个玩家从美国搬到巴西,价值降到大约三分之一,搬到印度降得更多。你的第一局也有影响,但不是线性的。在 iPhone 上,首局得分 500 比 2,000 或 10,000 更值钱。
游戏用这样的价格来决定你拿到什么类型的广告,以及拿多少。最清楚的一处是向服务器要两个价格,一个针对全屏广告,一个针对激励广告,然后做比较。
IsOpenInterAndRewardAdvDecideReviveTrait · checkEcpm, mainTraits/index.88dc4.js lines 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);
};
在应用发布的版本里,如果你的激励广告价值至少是全屏广告的两倍(其他版本里是 1.5 倍或 2.5 倍),你就能多一次复活机会,而复活会播放一条激励广告。如果全屏广告在你身上更赚钱,就没有这个机会。
其他规则也是同样的逻辑。FullScreenAdAddByEcpmTrait会在你的价格高时额外插入一个全屏广告。IsOpenEcpmTooLowNoInterTrait会在你几乎不值钱时跳过全屏广告。还有几条规则会在你的激励广告足够值钱时,提前开放一次复活或给出第三次复活机会。
原帖里有这个模型的交互版本,点这里。对一个虚构的美国玩家,用 iPhone 15、首局得分 1,000,它返回的 finalEcpm 是 25.47779944720001。
下面是游戏里遍历树的那部分代码。就是一个普通循环,从根节点出发,每输入一个特征就往左或往右走,最后把叶子权重加起来。
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());
}
每天生效一次。
服务器上的模型
有些规则会把你的数据发出去,再根据返回的分数行事,这些分数来自服务器上的模型,名字有 churn、LTV、revive-click、xDeepFM 之类。权重留在服务器上,所以我们只能展示手机端发出去的内容。其中发往 /v1/ecpm_prediction 的一个请求带了 40 个字段,包括运营商、存储空间、当前小时,以及你近期广告价格的统计。
其他影响游戏的规则
改变棋子或分数的规则不止广告这一条。我们在 iPhone 上抓到过一条正在生效的。MaturityDevelopmentV2Trait会给每个会话打一个由三部分组成的“成熟度”分数,三部分是习惯(10.98)、技巧(14.66)和你的广告历史(19.27)。我们那次加起来是 44.91,低于它 65 的阈值,于是它把本该发的一些困难谜题换成了简单填充。另一条规则 FailStreakStrategyTrait 在我们连续两局低于纪录后介入,要求发更简单的棋子。
这些是在 Android 上追踪广告规则时发现的,每一条在我的研究文件里都有同类证据。
| 规则 | 代码做了什么 |
|---|---|
MultiClearBoostThenKillTrait | 把你的用户 ID 发给两个服务器端点,取回两个标签。代码把这两个档位都命名为“怼死不敏感”。对这两个档位,它会等一次大的消除,然后在几轮之后、游戏时长差不多接近你平时一局的时候,请求那个致命谜题。 |
MABXiBaiTrait | 一个六组实验,测试多快能请求致命谜题,从不请求,或在 4、6、10、约 17、30 分钟后请求。每组都会报告你是否在 15 分钟内开了新一局,以及你接受了多大比例的复活提议。 |
FastHandPunishTrait | 当你最近六组平均每组低于 4.2 秒,且已超过最高分的 93% 时,请求困难方块。 |
AdPlusMinusStrategyTrait | 在一次额外复活提议之后,给你当前分数加上一个随机奖励,最高为最高分的 10%。 |
大家问的问题
iPhone 上也是一样吗?
一样。我们也拆了 iPhone 版 Block Blast,版本 7.4.9,并由另一次 GPT-6 Astra 运行从零复核了这些发现。它从自己的更新服务器下载同一份规则,文件除了一行之外和 Android 版完全一致。其余全部相同,三次广告、3.5×、分数条,以及同一个致命谜题生成器。
iPhone 应用还列出了这份规则的第二个版本 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 不等,所以在某些版本里,一条你提前关掉的激励广告就够了。价格条取决于你用这个应用多久了。第一周是那次会话中加载的第一条广告的 3.2×,到第 30 天是 2.7×,之后是你最后一条广告的 3×。
没有 wifi 玩会怎样?
这份规则下载后会保存在手机上,所以离线时仍然会运行。但它只在激励广告加载后才会触发,而广告要通过网络加载,所以一旦没有加载任何广告,它就无法触发。其他规则照常运行。在我的 Android 安装上,那条在你接近纪录、打得很快时请求困难方块的规则完全在手机上运行。那条向服务器询问哪些玩家“不介意被怼死”的规则离线时拿不到答案,所以那次会话里它一直不生效。
广告拦截器有用吗?
取决于用的是哪种拦截器。iPhone 或 Android 上的浏览器广告拦截器只在浏览器内生效,影响不到游戏。覆盖整台手机的拦截器,比如 Android 上的 Private DNS,或两个平台上基于 DNS 或 VPN 的拦截器,可以阻止激励广告加载,于是这条规则就无法触发,原因和离线游玩一样。不依赖广告的规则照常运行,包括快速游玩那条、给致命谜题可被请求的时间点计时的实验,以及因为你仍在线、向服务器询问你的那条。
这就是我每次破纪录后立刻输掉的原因吗?
这些规则针对的正是这一点。我们抓到 PersonalMarginBreakTrait 在线上这么干。一旦你超出纪录 10%,每一局都有 20% 的概率是致命谜题。广告规则则等到超出纪录 1,000 分(其他版本是 500 或 750),复活之后它会把门槛抬高,并且可以再次触发。
我能躲开吗?
基本不能。在 Android 上,原始规则要求你最近三次激励广告都发放成功,所以提前关掉一个确实能把它关掉,直到你重新连续拿到三次完整奖励为止。但在 iPhone 上,V2 版本可以完全跳过这项检查,看一个广告就触发,哪怕是你关掉的那个。而其他请求致命谜题的规则,比如计时实验和等待大消除的那条,根本不看你的广告。唯一能在所有平台上让这条规则停下的,是没有已加载的激励广告,也就是离线或装了覆盖整机的广告拦截器时的情况。
这不就是自适应难度吗?
自适应难度根据你的玩法改变方块。这条规则不看你的方块放得好不好。它的输入是你最近的广告结果、内存中那条广告的价格,以及你相对纪录的分数。而且它请求的生成器给出的并不是更难的一组方块,而是一组怎么放都无解的方块。
这也能被允许?Apple 不审核 App 吗?
Apple 审核的是你从商店下载的那个 App,而这条规则并不在审核范围内。你安装之后,App 会从游戏服务器下载这条规则,由服务器决定哪些玩家拿到哪个版本。Apple 确实允许 App 这样下载 JavaScript 更新,Expo Updates 这类工具就是这么做的,前提是更新不改变 App 本身的性质。根据广告价格调整你的棋子是否属于这种情况,由 Apple 说了算。
为什么它跑在手机上,而不是他们的服务器上?
服务器确实决定谁拿到规则、拿到哪个版本。但做这个决定需要一些只有你手机在当时才知道的东西,比如你的棋盘、它即将发出的下一组棋子,以及此刻已加载的广告。所以这部分跑在手机上,这也是它能被拆开分析的原因。
我怎么确认这是真的
- App 本身。 Android 版来自 APKPure 的 10.8.2 XAPK,下载于 2026 年 10 月 11 日。它的所有 split APK 都通过了签名校验,版本号也与 APKMirror 上的公开条目一致。iOS 版来自公开镜像的 7.4.9 IPA。其中 4,670 个游戏资源(包括主脚本和目录文件)都通过了 Apple 的代码签名校验。
- 规则来自他们的服务器。 Android App 的缓存显示,它从
bband1.afafb.com下载了这条规则,而它所有 traits 也都从这个主机获取。我们单独请求了该 URL,文件与 App 缓存中的副本逐字节一致。iOS App 的签名目录中列出了两条 iOS 规则,App 从bbios1.afafb.com获取它们。截至 10 月 15 日,三个 URL 提供的仍是同样的文件,所以你可以自己验证(命令见下)。 - 规则被下发并加载了。 在一个没有账号的全新 Android 14 模拟器上,2026 年 10 月 11 日 23:01:15 UTC,App 已准备好配置,为这条规则选中了 arm 332243014,模块已加载并完成存储初始化,它接入的底部报价流水线也已开启。
- 解码结果是精确的。 traits 用 XXTEA 加密,再用 Brotli 压缩。解码后规则的哈希见下。对于原生库,我的离线解码结果与运行中 App 的内存转储在 25,317,680 字节上完全一致,零处不符。
- Android 游戏本身没有任何改动。 唯一的改动是一处渲染变通方案,让游戏能在模拟器上绘制。没有编辑任何分数、广告、标志位或配置值。
自己动手验证
证据包一共三份。Android(113 KB,SHA-256 bd55151e6a1527f628459c2fffa115d5b14a765a3fb5a3f729387fbac6f707c8)和 iOS(2.7 MB,SHA-256 02bdb0e0e9b02d77844efbcf4b82f48e17f2b688fc48cbf84f83e75ae289df7d),外加一份 iPhone 实机抓包(270 KB,SHA-256 5f36863daabd2b500c8062d005f41e6c5e7dcea5a238c89e558e19a928dcc57f)。里面有解码后的规则、加密的原始文件、两个 App 其余部分的摘录(保留原始行号)、测试脚本和来源记录。需要 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
密钥以及绕过这两个 App 防护的步骤不在证据包里。如果你是记者或研究人员,想验证解码这一步,给我发邮件。