CatGunner CookbookBardCat 1.1.59 · L1 ANALYSIS · tài liệu học
Trang chủ › Tài liệu framework › 10 — Kiếm tiền: Quảng cáo (IAA) + Mua trong ứng dụng (IAP)
Nguồn: 10-monetization-iaa-iap.md · Cập nhật lần cuối: 2026-09-13 21:18 +07 · sha256 nguồn 70575d114490

10 — Kiếm tiền: Quảng cáo (IAA) + Mua trong ứng dụng (IAP)

Nguồn: chưng cất từ restore SOT com.Chodun.CatGunner 1.1.59 (BardCat). Mọi C# / công thức ở đây là L1 ANALYSIS — đọc từ ARM64 / Hex-Rays của libil2cpp.so tại RVA ghi kèm, từ IL2CPP metadata đã giải mã, từ Main.unity, và từ hai lượt probe trên thiết bị: (quan sát IAA dưới segment 1 trên bản .sot — save riêng, đã pm clear sau khi xong). Không phải source gốc.

Ranh giới R1 / R9. SDK quảng cáo, IAP, attribution, offerwall KHÔNG được tích hợp vào bản restore — chúng nằm sau interface fail-closed (IAdService, IPurchaseService, …) và không bao giờ được cấp thưởng, entitlement hay purchase. Tài liệu này chưng cất THIẾT KẾ kiếm tiền (placement, trigger, gate, offer, timing), không chưng cất nội tại SDK. Không có đoạn nào ở đây mô tả cách giả mạo mua hàng hay bỏ qua quảng cáo.

Liên quan: 01 Kinh tế · 09 Prestige · 07 Tiến trình · [liveops catalog: LIVEOPS_SURFACE.md] · [liveops catalog: UNLOCK_GATES.md] · [liveops catalog: PROBE_RESULT.md] · [liveops catalog: PROBE_RESULT.md]


1. Vì sao monetization là "song sinh" của core loop

Trong game idle, người chơi chờ. Mọi thứ rút ngắn cái chờ đó là hàng hoá. CatGunner bán đúng 4 thứ:

Hàng hoá Bán bằng quảng cáo (IAA) Bán bằng tiền (IAP)
Nhân thu nhập / sát thương tạm thời Buff ×3, 300 s/lần buff_pack → ×3 VĨNH VIỄN
Nhân phần thưởng một lần Bonus sau Mine/Boss/Hunt/Challenge, Mission, Fish gacha PlusPack (×2 Monster/Boss, ×1.5 Mine)
Nhảy cóc tiến trình Rebirth tăng cường ×3 (1 lần/ngày) catgunner_rebirth_single (thêm 1 lần/ngày, mở ở stage ~100)
Bỏ ma sát FreeCash link → hết phải xem reward-ad remove_forced_ads / remove_all_ads

Hai cột không cạnh tranh nhau — chúng xếp tầng: cột IAA là cách người chơi F2P nếm trải giá trị của multiplier; cột IAP bán chính cái multiplier đó ở dạng vĩnh viễn. Đó là toàn bộ ý đồ thiết kế. Điểm nối kỹ thuật của hai cột là Shop_manager.Is_Remove_AD_All / Is_Buff_Pack — hai cờ non-consumable đọc trực tiếp bởi tầng quảng cáo (§4.1, §5).


2. Stable truths

Mức bằng chứng: disasm-proven = ARM64/Hex-Rays tại RVA ghi kèm · metadata-proven = IL2CPP metadata đã giải mã (dump.cs / field-default-value blob) · scene-YAML = đọc từ Main.unity · runtime-verified (live) = đo trên thiết bị bằng probe đã disasm · UNKNOWN = chưa biết, không được thay bằng số hợp lý.

2.1 Tầng quảng cáo (IAA)

Claim Mức Nguồn
Lớp Admob KHÔNG dùng Google AdMob — nó bọc SDK ADX (adop): AdxRewardedAd, AdxInterstitialAd, AdxBannerAd, ADXConfiguration. Tên lớp là di sản metadata-proven dump.cs:585348-585700
3 định dạng quảng cáo: rewarded · interstitial · banner. Mỗi định dạng có 1 ad-unit id cho Android + 1 cho iOS (6 hằng) + 2 App ID metadata-proven dump.cs:585362-585379, :585392, :585421-585422
10 AD_Type (0..9) = 10 loại phần thưởng của rewarded ad metadata-proven dump.cs:584959; §3
Dispatch phần thưởng nằm trong một coroutine: Admob.Reward_Get() → switch(ad_type) → gọi đúng 1 manager disasm-proven <Reward_Get>d__63.MoveNext RVA 0x2A5F0B0
Sau mỗi rewarded ad: AD_Cool = 20.0f (hằng ghi thẳng 0x41A00000), Sound_manager.PlaySfx(7), và chỉ log AppsFlyer khi !Is_Remove_AD_All disasm-proven 0x2A5F0B0 @0x2a5f2d8/0x2a5f30c
AD_Cool đếm ngược bằng Time.deltaTime trong LateUpdate, kẹp ≥ 0 ⇒ 20 s giãn cách giữa 2 rewarded ad disasm-proven Admob.LateUpdate RVA 0x2A58CC8
Bấm nút reward khi AD_Cool > 0 ⇒ UI_manager.Alert("Try in {0} seconds") disasm-proven ShowRewardAd(AD_Type) 0x2A59294; literal 0x5E97D28
Đường tắt entitlement: nếu Shop_manager.Is_Remove_AD_All \|\| FreeCash_manager.RewardAdRemoved thì ShowRewardAd trao thưởng ngay, không hiện quảng cáo disasm-proven 0x2A59294 @0x2a59310-0x2a59334
Shop_manager.Is_RewardBonus_Auto = Is_Remove_AD_All \|\| FreeCash_manager.RewardAdRemoved — cùng vị ngữ, dùng để tắt interstitial và đổi nút "xem QC" thành nút "nhận luôn" disasm-proven 0x2B2D510; Mine_manager.SweepNoAds_Btn_obj dump.cs
5 Admob.Inter_Place: Rebirth=0, Mine=1, Challenge=2, Boss=3, Hunt=4 — khớp 1-1 với 5 field inter_daily_cnt_* của ServerConfig metadata-proven + disasm-proven dump.cs:584976; Inter_DailyLimit 0x2A5CC04
Inter_DailyLimit(place) chuyển thẳng sang getter ServerConfig tương ứng (tail-jump 0x2AC7E64/EC0/F1C/F78/FD4), default 0 disasm-proven 0x2A5CC04
Bộ đếm interstitial nằm ở PlayerPrefs, không phải SaveData: khoá "inter_place_cnt_" + (int)place, tổng "inter_place_cnt_whole", ngày "inter_place_date" (yyyyMMdd) disasm-proven Inter_CountKey 0x2A5C784, Inter_DayCheck 0x2A5C7F0, Inter_CountUp 0x2A5D644; literal 0x5EA3168/70/78
Inter_DayCheck() reset mọi bộ đếm khi PlayerPrefs["inter_place_date"] != today (duyệt Enum.GetValues(Inter_Place)) disasm-proven 0x2A5C7F0
Cổng interstitial (7 điều kiện, theo đúng thứ tự) — xem §4.2 để có pseudocode disasm-proven Inter_Placement_Able 0x2A5CD34
Is_Interstitial_Blocked() = is_Interstitial_Disable \|\| Shop_manager.Is_Remove_AD_forced \|\| Is_Remove_AD_All disasm-proven 0x2A5B4D4
Is_Banner_Blocked() = Is_Remove_AD_forced \|\| Is_Remove_AD_All (banner không có cờ tắt riêng trong Inspector) disasm-proven 0x2A5BC70
Banner chỉ chạy trong trận Boss / Hunt / Raid: ShowBannerAd gọi từ Boss_manager.Enter_Cor, Monster_manager.Enter_Cor, Raid_manager.Enter_Cor; HideBannerAd từ 3 Game_Exit/StopAllGameCoroutines tương ứng disasm-proven (xrefs) xrefs 0x2A5BCF4 / 0x2A5BD58
Banner: BANNER_HEIGHT_DP = 50, BANNER_RETRY_DELAY = 30 s, có bannerPlaceholder RectTransform + Refresh_SafeArea metadata-proven dump.cs:585392-585404
Khi trạng thái IAP đổi, Admob.Handle_InAppPurchaseChanged() huỷ ngay banner / reward-ad đã nạp / interstitial đã nạp nếu bị chặn disasm-proven 0x2A5C2CC
Hằng độ bền quảng cáo: REWARD_RETRY_BASE 15 s, REWARD_RETRY_MAX 120 s, REWARD_AD_EXPIRE_MIN 50 phút, LOAD_HARD_RESET_SEC 60 s metadata-proven dump.cs:585374-585377
Giá trị serialize trong scene: is_AD_Disable_Version 0, is_Interstitial_Disable 0, isTestMode 0, AD_Cool 0, adLoadTimeout 12, interLoadingSeconds 1.2, interLoadMaxAttempts 1, interLoadWaitPerAttempt 3 scene-YAML Main.unity:20647245-20647268 (MonoBehaviour &120003, GO 1737)
Bảng ánh xạ AD_Type → mã log AppsFlyer (m_ads_reward) = [5,6,1,2,3,4,7,8,9,10] disasm-proven (đọc .rodata) mảng 0x109EAE4, dùng tại 0x2A5F0B0

2.2 Tầng IAP

Claim Mức Nguồn
34 product id được TryApplyPurchase xử lý, ánh xạ sang 33 chỉ số offer (1..30, 36..38; vip_pass và vip_pass_2 dùng chung chỉ số 12; 31..35 bỏ trống) disasm-proven ItemIdxFromProductId RVA 0x2B2DE44 — bảng đầy đủ ở §6.1
Backend IAP = Unity StoreController (Unity IAP mới), có xác thực receipt phía server qua 2 Cloud Function (verifyInAppPurchaseApplePBV5 / verifyInAppPurchaseGoogleYooSu) metadata-proven dump.cs:600320-600321; ValidateAndComplete 0x2B33DAC
Chống trùng giao dịch: _inflightTxns (HashSet), ProcessedTxnIds (List, ProcessedTxnIdsMax = 50), MarkProcessed/ReleaseInflight metadata-proven + disasm-proven dump.cs:600334-600337; 0x2B33B94 / 0x2B33D38
6 non-consumable là cờ static của Shop_manager: Is_Remove_AD_forced@0x8, Is_Remove_AD_All@0x9, Is_AutoUpgrade_Pack@0xA, Is_Buff_Pack@0xB, Is_Dia_Pack@0xC, Is_Mine_Pack@0xD metadata-proven dump.cs:600306-600311
ApplyNonConsumable trao: autoupgrade_pack → +500 dia; buff_pack → +1000 dia; dia_pack → +16000 dia; mine_pack → +500 dia + reload Mine_Start_UI; remove_forced_ads/remove_all_ads → không dia, nhưng DiaPig.Init() nếu đây là cờ đầu tiên disasm-proven 0x2B311EC
Shop_manager.DiaRewards = [240, 600, 1500, 3500, 8000] cho dia_0..dia_4 metadata-proven (field-default-value blob) <PrivateImplementationDetails>.F68B1971…AD97D2 trong global-metadata.decrypted.dat
Khuyến mãi mua-lần-đầu = DỊCH TRÁI 1 BIT: amount = DiaRewards[i] << (Is_Dia_FirstBuy(i) ? 1 : 0) ⇒ đúng gấp đôi, một lần cho mỗi bậc, cờ lưu trong Dia_FirstBuy_Used[], có DiaFirstBuy_Season_Current = 1 (mùa) disasm-proven ApplyDiaReward 0x2B32D90 @0x2b32e94; dump.cs:600315-600318
Shop_manager.Core_Amount_return = [100, 500, 1050, 3300, 750, 1750, 6000] cho 7 InApp_Core_Type; "Core" được trao qua Adventure_manager.Core_Get(Core_Type.<3>, amount) disasm-proven (đọc .rodata 0x109EB1C) 0x2B30018, ApplyCoreReward 0x2B304BC
3 gói core có nhịp thời gian: _daily_750/_weekly_1750/_weekly_6000 ghi LastDailyCorePurchase_dateTime / LastWeekly… (static 0x10/0x18/0x20) ⇒ mua lại theo chu kỳ (Is_Core_Special_Available, Core_Special_Remain_Duration) disasm-proven + metadata-proven 0x2B304BC; dump.cs:600312-600314; 0x2B24F9C / 0x2B25348
vip_pass + vip_pass_2 → ApplyVipPass(): Mission_manager.is_Vip = true, +500 dia disasm-proven 0x2B32C50
catgunner_luxury_pass và catgunner_luxury_upgrade → thân hàm giống hệt nhau: is_Vip = is_Luxury = true (ghi word 257), +1000 dia, Mission_manager.Exp_Get(300) = 3 cấp battle-pass (Exp_balance = 100, Max_Lv = 25) disasm-proven 0x2B32AA4 / 0x2B328F8; dump.cs:587346-587350
catgunner_rebirth_single → ApplyRebirthSingle() → Rebirth_manager.UpgradeRebirth_Start_By_IAP(); nếu Rebirth_manager null thì hoãn, thử lại lần chạy sau (không mất hàng) disasm-proven 0x2B3280C; literal 0x5E9AAD8
StepUp.Gun_Rewards = [23, 28, 33, 39, 44], StepUp.Dia_Rewards = [0, 200, 400, 600, 1000] (5 bậc) metadata-proven (blob) <PrivateImplementationDetails>.EA64A4C1… / .AA838700…
DailyGun.MaxCounts = [2, 2, 1], PetCoin_Rewards = [300, 700, 1500], GunNum_Base = [20, 30, 35], WeekEpoch = 2024-01-01 metadata-proven (blob + cctor) DailyGun.cctor 0x2B26894
is_DiaPig_Unlock() = Is_Remove_AD_All \|\| Is_Remove_AD_forced; DiaPig_TickAmount() = (Is_Remove_AD_forced?1:0) + (Is_Remove_AD_All?1:0) ⇒ heo kim cương là phần thưởng đi kèm của gói xoá quảng cáo, tick 1 hoặc 2 dia mỗi 60 s disasm-proven 0x2B27764 / 0x2B2823C; TICK_SECONDS = 60
InappCntForServer() = max(PurchaseCount, PurchaseHistory.Count), nếu 0 thì HasEverPurchased() ⇒ số lần mua được gửi lên server phân khúc quảng cáo disasm-proven 0x2B36528; ConfigReq.inapp_cnt
place (chuỗi) đi kèm mọi lệnh mua để log AppsFlyer m_offer_page: store, popup, hud, banner, banner_reward, banner_enter, banner_weapon, auto_weapon/auto_pet/auto_fish disasm-proven Purchase* 0x2B2F618/0x2B2FF0C/0x2B2FF68/0x2B2FFC0; literal 0x5E9E7E8/F0/F8/800
vip_pass_2 không phải sản phẩm Play — được TryApplyPurchase/ItemIdxFromProductId xử lý nhưng không có trong 33 id store trả giá ⇒ 34 id trong code, 33 id đang bán negative evidence (live) iap_prices_store.json
remove_forced_ads 79.000đ là SKU rẻ nhất; dia_4 và dia_pack cùng 1.300.000đ là đắt nhất; các bậc Core (26k/105k/210k/631k) và StepUp (26k/130k/261k/523k/1.05M) là thang hình học ≈ ×2 derived (từ 33 giá đo) iap_prices_store.json
ĐÍNH CHÍNH LIVEOPS_SURFACE.md §6.1: mine_ticket, fish_free, banner_enter, banner_reward, banner_weapon, sp_iap, reward_hub, f_cash KHÔNG phải product id — chúng là chuỗi place/khoá analytics/tên màn Almedia disasm-proven (xrefs literal) Mine_manager.Ticket_Reload, Fish_manager.FreeGacha_Reload, PlusPack_Inapp_item.OnClick, AppsFlyerInit.AddSpecValues, AlmediaScreenExtensions, AppsFlyerInit.SendLog_Launch

2.3 Phân khúc (segment) — công tắc tổng của IAA

Bản giải thích dân dã đầy đủ: docs/functions/segment-lieu-ops.md — segment là gì bằng ví dụ đời thường, vòng đời một người chơi (cài → attribution → cấu hình → placement → đo → chọn cấu hình thắng), phân biệt ba tầng segment / ServerConfig / placement, danh sách cạm bẫy khi tự dựng, và mục "Nếu bạn muốn tự làm" 13 bước. Viết cho người không đọc disasm; mọi số và RVA ở đó lấy từ mục này.

ĐÍNH CHÍNH (QD-28, 2026-09-06): ba nút Seg 0/1/2 của panel debug gốc KHÔNG phải R1-out — docs/functions/cheat-panel.md từng xếp nhầm chúng vào cột "sẽ không dựng". AppsFlyerInit.Cheat_SetSegment (0x2A600CC) không gọi hàm SDK AppsFlyer nào: nó đặt Segment (static 0x8) + SegmentCheatOverride (0x21), ghi PlayerPrefs["af_segment"] / ["af_segment_cheat"], đổi nhãn segmentText, rồi gọi ServerConfig_manager.Cheat_OnSegmentChanged() (0x2AC96D0 — phát OnConfigApplied + SaveSystem.Save). Đã dựng 1:1, không lấn R1. Cái thật sự R1-out là đường AppsFlyer gán segment từ mạng (OnUserSegmentReceived → ApplyServerSegment), xem §9.3.

Claim Mức Nguồn
ServerConfig_manager.ConfigResp có đúng 15 field, 12 trong đó là số điều khiển quảng cáo. Không có field kinh tế nào metadata-proven + runtime-verified (live) dump.cs:589438; LIVEOPS_SURFACE.md §2.2; payload production ở PROBE_RESULT.md §1.1
EmbeddedDefaults(segment) (0x2AC7B20): segment 1 = [freecash_stage_min 15, freecash_stage_max_hud 35, offerwall_stage_min 10, inter_stage_min 12, inter_cooltime 0, rebirth 6, mine 2, challenge 0, boss 0, hunt 0, whole 99999, weapon_02 1]; segment ≠ 1 = 4 ngưỡng stage 99999, còn lại 0 ⇒ tắt sạch disasm-proven (.rodata) LIVEOPS_SURFACE.md §2.3; chứng minh offset ở §2.3a
Cả hai nhánh của EmbeddedDefaults đều đặt rule_version = "0" (literal 0x5E82F08) và reason = "client_embedded_default" (literal 0x5E9F3E0) ⇒ nhìn reason là biết config đến từ client hay server disasm-proven 0x2AC7B20
Server phân khúc theo: bundle_id, segment, uuid, country_code, platform, version, campaign, media_source, install_date, inapp_cnt, reward_ad_cnt, test, app_version (13 field ConfigReq) ⇒ nguồn cài đặt (AppsFlyer) + quốc gia + lịch sử chi tiêu quyết định bạn thấy bao nhiêu quảng cáo metadata-proven dump.cs:589413; LIVEOPS_SURFACE.md §2.1
AppsFlyerInit.Cheat_SetSegment(seg) ghi AppsFlyerInit.Segment (static 0x8), bật SegmentCheatOverride (0x21), lưu PlayerPrefs["af_segment"]/["af_segment_cheat"], rồi gọi ServerConfig_manager.Cheat_OnSegmentChanged() disasm-proven 0x2A600CC; dump.cs:585772-585783
Segment đổi HÀNH VI, không chỉ đổi số: cặp đo có đối chứng trên Pixel (cùng save, cùng thao tác hợp nhất, chỉ khác segment) — segment 1 cho mượn súng hạng B 1800 s (AD_Gun_SlotIndex=6, AD_Gun_Num=14, đồng hồ 29:59 trên lưới), segment 0 không cho gì và không tiêu cờ AD_Gun_Event_Given runtime-verified (device) G1739; [report: f025_weapon02_seg1_vs_seg0.png]
Hiệu lực tức thì trong cùng phiên chơi đến từ Config => _config ?? EmbeddedDefaults(Segment) được tính lúc đọc, KHÔNG phải từ OnConfigApplied — ép segment giữa phiên là lần hợp nhất kế tiếp đã theo bảng mới disasm-proven + runtime-verified (device) G1739; ServerConfig_manager.get_Config 0x2AC7A40
ServerConfig_manager.OnConfigApplied có đúng HAI người nghe trong cả game: FreeCash_manager.Awake (0x2A6C6E4) và OfferWall_manager.OnEnable (0x2B28D4C) — cả hai đều R1-out ⇒ trên bản học tập sự kiện này không vẽ lại được gì, và đó là ĐÚNG thiết kế chứ không phải nợ restore disasm-proven (xrefs add_OnConfigApplied 0x2AC7888) G1740

2.3a Chứng minh ánh xạ 12 số → 12 field (đọc theo OFFSET, không theo thứ tự tên)

Đây là chỗ dễ sai nhất của cả tài liệu, nên ghi lại nguyên văn phép ghi bộ nhớ. EmbeddedDefaults(1) (0x2AC7B20) ghi ba khối 16 byte vào object ConfigResp:

*(_OWORD *)(obj + 40) = xmmword_105B0E0;   // 0x28 .. 0x37
*(_OWORD *)(obj + 56) = xmmword_105D5A0;   // 0x38 .. 0x47
*(_OWORD *)(obj + 72) = xmmword_105B890;   // 0x48 .. 0x57

Giá trị đọc từ .rodata (IDA get_dword): 105B0E0 = [15, 35, 10, 12] · 105D5A0 = [0, 6, 2, 0] · 105B890 = [0, 0, 99999, 1]

Ghép với offset field của ConfigResp (dump.cs:589438):

Offset Field Khối Giá trị segment 1
0x28 freecash_stage_min 105B0E0[0] 15
0x2C freecash_stage_max_hud 105B0E0[1] 35
0x30 offerwall_stage_min 105B0E0[2] 10
0x34 inter_stage_min 105B0E0[3] 12
0x38 inter_cooltime 105D5A0[0] 0
0x3C inter_daily_cnt_rebirth 105D5A0[1] 6
0x40 inter_daily_cnt_mine 105D5A0[2] 2
0x44 inter_daily_cnt_challenge 105D5A0[3] 0
0x48 inter_daily_cnt_boss 105B890[0] 0
0x4C inter_daily_cnt_hunt 105B890[1] 0
0x50 inter_daily_cnt_whole 105B890[2] 99999
0x54 weapon_02_ad_onoff 105B890[3] 1

Nhánh segment != 1 ghi vdupq_n_s32(0x1869F) = 99999 vào cả 4 ô của khối đầu và 0 vào 8 ô còn lại; segment = 2 nếu tham số là 2, ngược lại 0.

interstitial ở 12, FreeCash bật ở 15 và ẩn ở 36. Nếu ánh xạ bị lệch một nhịp (offerwall ← 35, inter ← 15) thì OfferWall phải lật ở 35 và interstitial ở 15 — đo được là không. Hai kênh độc lập (byte trong .rodata + hành vi trên thiết bị) cùng chỉ về một bảng.

Claim Mức Nguồn
Mô tả tiếng Việt của buff_pack xác nhận IsPermanentBuff áp cho CẢ hai buff Power và Money runtime-verified (live) phaseA/01_shop.png; đối chiếu IsBuffActive 0x2AE4AF8
Claim Mức Nguồn

3. Danh mục AD_Type — 10 giá trị và mỗi giá trị mở cái gì

public enum AD_Type — dump.cs:584959. Dispatch: Admob.Reward_Get() 0x2A5F0B0.

# AD_Type Điểm gọi ShowRewardAd (RVA) Hàm nhận thưởng khi xem xong (RVA) Phần thưởng Mã AppsFlyer
0 Money_Buff Buff_Panel.AD_Watch 0x2AE4F38 Buff_manager.AddBuff(0) 0x2AE4510 moneyBuffTime = min(+300, 900) + Gamemanager.InvalidateMoneyCache() 5
1 Power_Buff Buff_Panel.AD_Watch 0x2AE4F38 Buff_manager.AddBuff(1) 0x2AE4510 powerBuffTime = min(+300, 900) 6
2 SpeedRun_Bonus SpeedRun_manager.Bonus_AD_Show 0x2B40E08, SweepBonus_AD_Show 0x2B41A04 SpeedRun_manager.Bonus_Get thưởng bonus của chế độ Play/Challenge 1
3 Mine_Bonus Mine_manager.Bonus_AD_Show 0x2AF562C, SweepBonus_AD_Show 0x2AF5B28 Mine_manager.Bonus_Get thưởng bonus mỏ 2
4 Boss_Bonus Boss_manager.Bonus_AD_Show 0x2AF21A8, SweepBonus_AD_Show 0x2AF2D94 Boss_manager.Bonus_Get thưởng bonus boss 3
5 Monster_Bonus Monster_manager.Bonus_AD_Show 0x2AFB094, SweepBonus_AD_Show 0x2AFAEC4 Monster_manager.Bonus_Get thưởng bonus Hunt (PetCoin) 4
6 Mission_Reward Mission_manager.Reward_Get 0x2A71A34 Mission_manager.Reward_Get_By_Ad nhận thưởng mission (kiểm FindFirstEmptyOwned != -1 trước) 7
7 Fish_Gacha Fish_manager.FreeGacha 0x2B00AB4 Fish_manager.FreeGacha_RewardGet 1 lượt gacha cá miễn phí 8
8 Upgrade_Rebirth Rebirth_manager.UpgradeRebirth_Start_By_IAP 0x2B23454 (tail @0x2b23668) Rebirth_manager.UpgradeRebirth_Start_By_Ad 0x2B233D0 → Rebirth_FX.Start_Internal bật is_UpgradeRebirth ⇒ thưởng Rebirth ×3 9
9 AD_Reward_Gun Gun_manager.AdRewardGun_TryWatch 0x2B0C484 Gun_manager.AdRewardGun_Acquire 0x2B0C508 giữ vĩnh viễn khẩu súng thưởng đang đếm ngược 10

Ghi chú quan trọng: IsBuffActive(type) trả true cho mọi type khi IsPermanentBuff — nghĩa là buff_pack bật vĩnh viễn cả hai buff, không phải một (0x2AE4AF8). GetBuffRemainingTime trả -1 khi vĩnh viễn (0x2AE4B4C) → Buff_Panel.Update (0x2AE4D4C) hiểu -1 là "ẩn nút xem QC, fill = 1.0".


4. Bề mặt IAA — 6 loại placement, 28 điểm kích hoạt quảng cáo trong code

|---|---|---|---| | Rewarded | 13 điểm gọi ShowRewardAd(AD_Type) (+1 overload ShowRewardAd(int) nối từ scene) | AD_Cool, _isShowing, is_AD_Disable_Version, mạng | Chạy (rewarded KHÔNG bị segment tắt) | Chạy | | Interstitial | 9 (5 Inter_Place) | 7 điều kiện §4.2 | Tắt (inter_stage_min = 99999) | Đủ tư cách chỉ Rebirth (6/ngày) + Mine (2/ngày) từ Stage 12 | | Banner | 3 show / 3 hide | Is_Banner_Blocked | Chạy khi vào Boss/Hunt/Raid | như segment 0 (không bị ServerConfig gate) | | OfferWall (Tapjoy) | 2 (Open_Hud, Open_Shop) | offerwall_stage_min | Tắt (99999) | BẬT từ Stage 10, vĩnh viễn | | FreeCash (AlmediaLink) | 1 panel + HUD + banner shop | freecash_stage_min + trạng thái Almedia | Tắt (99999) | Cửa sổ Stage 15 → 35, ngoài khoảng thì ẩn | | Ad-gun weapon_02 | (dùng lại 1 trong 13 điểm rewarded; CheckAdGunEvent là trigger cấp súng thử, không phải điểm hiện quảng cáo) | weapon_02_ad_onoff | Tắt (0) | BẬT (1) |

4.1 Rewarded — Admob.ShowRewardAd(AD_Type) 0x2A59294

ShowRewardAd(AD_Type type):
    if (Shop_manager.instance == null) -> NRE
    // ĐƯỜNG TẮT ENTITLEMENT (không có quảng cáo nào được hiện)
    if (Shop_manager.Is_Remove_AD_All || FreeCash_manager.RewardAdRemoved):
        ad_type = type; StartCoroutine(Reward_Get()); return       // @0x2a59310..0x2a59334
    if (is_AD_Disable_Version)            -> Alert("Try Later"); return
    if (_pendingShow || _isShowing)       -> Alert("Try Later"); return
    if (AD_Cool > 0)                      -> Alert("Try in {0} seconds", (int)AD_Cool); return
    ad_type = type
    if (rewardAd != null && rewardAd.IsLoaded() && Is_LoadedAdExpired())   // > 50 phút
        Log("[ADX] Loaded rewarded ad expired -> discard & reload"); Discard_RewardAd()
    if (rewardAd != null && rewardAd.IsLoaded())  -> TryShowLoadedAd()
    else if (!_sdkReady)                          -> Alert("Try Later")
    else if (Application.internetReachability == NotReachable) -> Alert("Check Network Connection")
    else { _pendingShow = true; ShowAdLoadingPanel(); StartLoadTimeout(); LoadRewardedAd(); }

Reward_Get() (0x2A5F0B0) — chạy sau khi SDK báo earned:

yield return null                                   // 1 frame
AD_Cool = 20.0f                                     // giãn cách toàn cục
Sound_manager.PlaySfx(7)
if (!Shop_manager.Is_Remove_AD_All)
    AppsFlyerInit.SendLog_AdsReward(AF_CODE[(int)ad_type])   // [5,6,1,2,3,4,7,8,9,10]
switch ((int)ad_type) { 0,1 -> Buff_manager.AddBuff(ad_type); 2 -> SpeedRun.Bonus_Get(); ... }

Bài học thiết kế: phần thưởng KHÔNG được trao trong callback của SDK mà trong một coroutine của game — nên đường "đã mua xoá quảng cáo" tái dùng đúng cùng một đường trao thưởng. Một điểm trao thưởng duy nhất = một điểm để kiểm toán. (Trong bản restore, đường này nằm sau IAdService fail-closed: NullAdService.ShowRewarded() trả failure, coroutine trao thưởng không chạy.)

4.1a Nhánh Granted — SỐ ĐO THẬT đầu tiên trên thiết bị (F024, 2026-09-06)

Cho tới 06-09, mọi thứ trong §4.1 là đọc từ binary: bản dựng lại đi qua IAdService, và NullAdService (fail-closed theo R1) luôn trả NotAvailable, nên không một nhánh nhận thưởng nào từng chạy trên máy. F024 (QD-23, và R1a trong quy tắc dự án (nội bộ)) thêm một provider quân xanh (SimulatedAdService, chỉ có dưới CATGUNNER_LEARNING_DEBUG, chỉ bind khi bật công tắc) để chạy được nhánh đó và đo. Quân xanh KHÔNG chứng minh luồng quảng cáo đã restore 1:1 — cái được nghiệm thu 1:1 là phía game xử lý callback.

Placement Mine_Bonus (AD_Type 3) — đo trên Pixel 8 Pro, APK f024-sim:

Bước Điều kiện / thời điểm Số đo (đọc từ save, không đọc bằng mắt)
Vào Mỏ cần 1 vé (TicketUse_Count < 2); ván dài 60 s vé 2/2 → 1/2
Panel Game End mở Reward_Panel_Reload 0x2AF5404 vừa vẽ vừa PHÁT Reward_Value Dia 980 → 1078 (+98 = Reward_Value)
Panel quảng cáo nhãn Phần thưởng = 4 × Reward_Value hiện 392 (= 4×98) ✔
Bấm Nhận ×4 → xem hết → Granted Bonus_Get 0x2AF5634 cộng 3× (vì panel đã phát 1× lúc mở) Dia 1078 → 1372 (+294 = 3×98) ✔
Ngay sau khi cấp Bonus_Get gọi thẳng Game_Exit tự thoát về HUD chính ✔
Đối chứng: bấm Bỏ qua → Denied quân xanh trả Denied Dia 1468 → 1468, panel giữ nguyên, 0 exception ✔

⇒ Xác nhận bằng số đo cái mà trước đây chỉ suy từ binary: tổng tiền của một ván Mỏ có xem quảng cáo đúng bằng 4 × Reward_Value, và hệ số của riêng nhánh quảng cáo là 3×, không phải 4×. Sai lầm dễ mắc khi chỉ đọc UI: nhãn ghi ×4 nhưng hàm chỉ cộng 3×.

Placement Fish_Gacha (AD_Type 7) — đo trên Pixel 8 Pro, APK g1722, hồ sơ p54 (2026-09-11):

Bước Điều kiện / thời điểm Số đo (đọc từ save sau force-stop)
Bấm Free Fish Draw lần 1-3 FreeGachaCount 0,1,2 (< 3) ⇒ cấp THẲNG, không quảng cáo FishInventory [] → [0,0,0]
Bấm lần 4 FreeGachaCount == 3 ⇒ ShowRewarded(Fish_Gacha) logcat [CGSIM] rewarded Fish_Gacha -> opened simulated screen (5,0s)
Claim reward → Granted cấp bằng FreeGacha_RewardGet() — CÙNG hàm với nhánh cho thẳng FishInventory → [0,0,0,0], FreeGachaCount → 4
Cổng trên nó Inventory.Count >= 20 ⇒ Alert Alarm/MaxFish; FreeGachaCount >= 5 ⇒ Alert Alarm/MaxFreeGacha (chưa chạm trong lượt đo)

⇒ Bài học placement đáng chép: quảng cáo ở đây KHÔNG mở thêm lượt — nó chỉ được cài vào 2 lượt CUỐI của 5 lượt miễn phí mỗi ngày. Lượt xem quảng cáo vẫn cộng FreeGachaCount y như lượt free, nên trần vẫn là 5/ngày. Đây là mẫu "gate muộn": người chơi nhận 3 lượt không ma sát để hình thành thói quen, rồi mới bị hỏi xem quảng cáo cho 2 lượt còn lại — khác hẳn mẫu Mine_Bonus (quảng cáo NHÂN phần thưởng lên 4×). Hai mẫu này nên được phân biệt rõ khi thiết kế: gate lượt vs nhân thưởng. SPEC: [evidence codegen: SPEC-F024-T4-fish-gacha.md].

Placement Power_Buff (AD_Type 1) / Money_Buff (0) — đo trên Pixel 8 Pro, APK g1722 (2026-09-11):

Đường bấm: biểu tượng x3 trên HUD mở popup Buff (hai thẻ Power/Money, mỗi thẻ một đồng hồ và một nút +5 min có icon video). Bấm +5 min → ShowRewarded → Granted → Buff_manager.AddBuff 0x2AE4510.

Bước Số đo
[CGSIM] rewarded Power_Buff -> Granted save PowerBuffTime 0.0 → 288.27
[CGSIM] rewarded Money_Buff -> Granted save MoneyBuffTime 0.0 → 282.23 (cùng khuôn, cộng thêm Gamemanager.InvalidateMoneyCache())
Đối chiếu L1 powerBuffTime = Mathf.Min(powerBuffTime + 300f, 900f) ⇒ cấp 300 s, phần hụt (~12 s) là thời gian trôi giữa lúc cấp và lúc ghi save — buff đếm ngược THEO THỜI GIAN THỰC
Trần 900 s: xem lần thứ tư trong 15 phút không thêm được gì (nút vẫn bấm được, giá trị bị kẹp)

⇒ Mẫu placement thứ ba, khác cả Mine_Bonus (nhân thưởng) lẫn Fish_Gacha (gate lượt): đây là "nạp thời gian có trần" — phần thưởng cộng dồn nhưng bị kẹp, nên người chơi được khuyến khích xem ĐỀU (3 lần/15 phút) chứ không xem dồn. Hai chi tiết 1:1 dễ bỏ sót: AddBuff gọi Cat_manager.Balance_Reload() ở MỌI AD_Type kể cả loại không phải buff; và GetBuffRemainingTime dùng −1f làm sentinel "vĩnh viễn" cho gói Buff IAP. SPEC: [evidence codegen: SPEC-F024-T4-power-buff.md].

Placement SpeedRun_Bonus (AD_Type 2) — đo trên Pixel 8 Pro, APK g1722, p344 (2026-09-11):

Bước Số đo
HUD Chơi → Stage 2 — Phá hủy mọi cây trong 1 phút! → Thử thách ván tự chạy, không cần điều khiển tay (khác Mỏ/Săn)
~60 s → panel VICTORY Phần thưởng hiện 120, nút Nhận ×4, dòng từ chối Không, cảm ơn 30
[CGSIM] rewarded SpeedRun_Bonus -> Granted save Dia 290 → 410 (+120), SpeedRun_Stage_Now 1 → 2

⇒ Cùng khuôn Mine_Bonus: Reward_Value = 30 (hằng duy nhất trong .cctor 0x2B42214), panel trả 1× lúc mở, Bonus_Get (0x2B40E10) trả thêm 3× ⇒ nhãn ×4 là TỔNG, nhánh quảng cáo chỉ cộng 3×. Tổng +120 là số đo; phần tách 30+90 là suy từ L1 (chưa chụp riêng Dia lúc panel mở). Bốn mẫu placement đã phân biệt được: nhân thưởng (Mine_Bonus, SpeedRun_Bonus), gate lượt (Fish_Gacha), nạp thời gian có trần (Power/Money_Buff), mở nhánh khác (Upgrade_Rebirth). SPEC: [evidence codegen: SPEC-F024-T4-speedrun-bonus.md].

Placement Boss_Bonus (AD_Type 4) — đo trên Pixel 8 Pro, APK g1722 (2026-09-12):

Đây là placement đầu tiên đo được CẢ HAI nhánh bằng số, nên nó là chỗ chốt cho khuôn "nhân thưởng" mà Mine_Bonus/SpeedRun_Bonus mới chỉ suy từ L1:

Lượt Hành động Dia trước → sau Δ
1 thắng → Nhận ×3 (xem quảng cáo) 290 → 740 +450
2 thắng → Không, cảm ơn (bỏ qua) 740 → 890 +150

⇒ thắng cấp 1× (150), quảng cáo cấp thêm 2× (300); nhãn 450 trên panel là TỔNG, nút từ chối ghi đúng phần đã cấp. Khớp Boss_manager.Reward_Value = 150 (.cctor 0x2AF2F14), Game_Win 0x2AF1C2C (Get_Dia(reward), Bonus_txt = reward*3), Bonus_Get 0x2AF21B0 (Get_Dia(reward*2) rồi Game_Exit()).

Bài học dàn cảnh (đắt, đã trả giá hai phiên): chế độ Boss vô hiệu hoá Upgrade Power/Speed (panel vào Boss ghi thẳng ra), và máu boss theo Boss_manager.Stage_Now (save Boss_Stage) chứ không theo Stage_Value_Max. Muốn thắng để chạm nút quảng cáo thì nâng rebirth và hạ Boss stage, đừng nâng Upgrade. Trigger interstitial cũng lặp lại đúng mẫu Mỏ: bỏ qua thì có, đã xem rewarded thì không. SPEC: [evidence codegen: SPEC-F024-T4-boss-bonus.md].

Placement Mission_Reward (AD_Type 6) — đo trên Pixel 8 Pro, APK g1722 (2026-09-12):

Mẫu thứ năm, và là mẫu dễ bị xếp nhầm nhất: quảng cáo ở đây không nhân phần thưởng, nó là một cổng đặt trên mốc battle-pass.

Lv (0-based) Ô Normal Nhận bằng Dia
0 / 1 200 / 240 dia bấm thẳng 890 → 1090 → 1330
2 súng Single bấm thẳng không đổi
3 320 dia + 📽 xem quảng cáo 1330 → 1650 (+320)

Nhận đúng mệnh giá in trên ô, không hơn một dia. is_AD_Reward (nội tuyến trong Reward_Get 0x2A71A34) = Lv >= 3 && Lv % 3 == 0 && degree == Normal, và đã kiểm bằng mắt: sau khi nhận Lv 3 thì ô mang biểu tượng phim kế tiếp là Lv 6 — đúng chu kỳ 3.

Kèm theo là một chi tiết UX đáng chép: mốc bị khoá tuần tự (is_Reward_SequenceLocked, Alert Mission/SequenceLocked), và mỗi lần nhận xong dải phần thưởng tự cuộn tới mốc chưa nhận đầu tiên. Người chơi vì thế luôn nhìn thấy đúng một "việc kế tiếp", và cứ 3 việc thì có 1 việc đòi quảng cáo — nhịp ép xem quảng cáo được gài vào tiến trình, không gài vào kết quả trận đấu như nhóm *_Bonus. SPEC: [evidence codegen: SPEC-F024-T4-mission-reward.md].

Placement AD_Reward_Gun (AD_Type 9) — mẫu THỨ SÁU: cho mượn rồi đòi lại. Đo trên Pixel 8 Pro, APK g1726 (2026-09-12), sau khi dựng L1 cả cụm Gun_manager (xem SPEC).

Đây là mẫu sắc sảo nhất trong sáu mẫu, vì nó không bán một phần thưởng — nó bán quyền giữ lại thứ bạn đã cầm trong tay:

  1. Ngay sau lần hợp nhất súng đầu tiên, game đặt khẩu súng số 14 (hạng B, tốt hơn hẳn thứ người chơi đang có) vào thẳng kho, kèm đồng hồ đếm ngược 1800 s hiện trên ô.
  2. Chạm vào nó, trang bị nó, hay hợp nhất nó — cả ba đều bật lời mời xem quảng cáo (Gun_Item.OnPointerUp, Gun_Item_List.EquipToSlot, Gun_Item_List.FuseItems).
  3. Xem xong: đồng hồ biến mất, súng ở lại. Không xem: hết 1800 s, Update (nhịp 0,25 s) gọi AdRewardGun_Expire và rút súng khỏi kho.

So với "nhân thưởng", đòn bẩy tâm lý đảo chiều: người chơi không cân nhắc được thêm bao nhiêu mà sợ mất cái đang có. Hai chi tiết cho thấy thiết kế được mài kỹ: Update hoãn thu hồi khi quảng cáo đang mở (Admob.IsRewardAdBusy) — không ai xem hết quảng cáo để nhận về ô trống; và CheckAdGunEvent bỏ qua người đã có súng hạng ≥ B (đặt cờ mà không tặng gì) — mồi chỉ dành cho người nó thật sự cám dỗ được.

Cổng bật/tắt nằm ở liveops: weapon_02_ad_onoff == 1, và bản production đang TẮT cho phân khúc 0 (đo được từ ServerConfig_Cache của hồ sơ store thật). Bảng nhúng của client chỉ bật ở phân khúc 1. SPEC: [evidence codegen: SPEC-F024-T4-ad-reward-gun.md].

Sáu mẫu placement đã phân biệt được (xương sống để chép sang game khác):

Mẫu Đại diện Quảng cáo làm gì Người chơi mất gì nếu không xem
Nhân thưởng Mine_Bonus, Boss_Bonus, SpeedRun_Bonus ×3–×4 phần thưởng cuối trận vẫn giữ 1×
Cổng mốc tiến trình Mission_Reward mở mốc mỗi 3 bậc, không nhân mốc đứng đó, tiến trình tắc
Gate lượt Fish_Gacha mở 2 lượt cuối của 5 lượt free/ngày mất 2 lượt
Nạp thời gian có trần Power_Buff, Money_Buff +300 s buff, trần 900 s không có buff
Mở nhánh khác Upgrade_Rebirth mở lượt rebirth tăng cường đi nhánh thường
Cho mượn rồi đòi lại AD_Reward_Gun giữ khẩu súng đã đặt sẵn trong kho mất thứ đang cầm sau 1800 s

Trigger phụ đo được cùng lượt — interstitial khi thoát Mỏ. Mine_manager.Game_Exit bắn interstitial chỉ khi _bonusAdWatched == false (cờ +0x72, do Bonus_Get bật). Đo: - thoát bằng Không, cảm ơn (chưa xem thưởng) → có interstitial; - thoát bằng đường Bonus_Get (đã xem thưởng) → không interstitial.

Đây là một mẫu thiết kế đáng chép: người chơi vừa xem một rewarded thì được miễn interstitial ngay sau đó — tránh dí hai quảng cáo liên tiếp vào đúng người vừa hợp tác.

Trạng thái đo của 10 placement. Tới 2026-09-12 (phiên 5aad97a9): 10/10 đã chạy nhánh Granted trên máy VÀ 10/10 có SỐ. Bảng này giờ không còn ô trống:

AD_Type Đo được? Ghi chú
Mine_Bonus, Fish_Gacha, Power_Buff, Money_Buff, SpeedRun_Bonus, Boss_Bonus, Mission_Reward, Upgrade_Rebirth ĐÃ ĐO có số 8/10
AD_Reward_Gun ĐÃ ĐO dựng L1 cả cụm 2026-09-12; cổng liveops đang TẮT ở production phân khúc 0
Monster_Bonus ĐÃ ĐO có số (G1733, 2026-09-12) ô cuối cùng được lấp. Hoá ra KHÔNG cần điều khiển tay: mèo tự bắn, PetCoin tự hút, để yên ~60 s là qua vài cấp. Cộng dồn 30 PetCoin/cấp cấp NGAY lúc chơi; Nhận ×2 cấp THÊM đúng 1× (ví 240 → 270 với cộng dồn 30); bỏ qua cấp 0 thêm + bắn interstitial (cộng dồn 210 → ví giữ 210). Chi tiết: docs/functions/san-monster-song-va-petcoin.md

Bài học phương pháp từ ô cuối này: dòng cũ ghi "Săn là chế độ điều khiển tay ⇒ PetCoin_Accum = 0 khi để mèo tự đứng" — sai. Giả thiết "chế độ tay thì không tự tiến được" chưa từng được thử; thử ĐÚNG MỘT LẦN (vào màn, đợi 60 s, thoát) là ra số. Trước khi ghi một ô là "không đo được vì điều kiện màn", hãy bỏ 60 giây đứng yên trong màn đó.

Chi tiết bộ giả lập + cổng an toàn (bản identity không chứa một byte nào của nó): docs/functions/gia-lap-quang-cao-iap.md.

4.1b Mẫu "quota ngày có phần cho không + phần gắn quảng cáo" — placement Fish_Gacha (F008 T5b)

Đây là kiểu placement thứ hai của game, khác hẳn §4.1/§4.1a. Mine_Bonus là nhân phần thưởng vừa nhận (một lần, ở cuối một ván). Fish_Gacha là một quota ngày có hai đoạn: đoạn đầu cho không, đoạn sau gắn quảng cáo. Đo trọn từng nấc trên Pixel 8 Pro, APK f008-t5b2, 0 exception.

FreeGacha():                                      // Fish_manager 0x2B00A24 — CHỈ định tuyến
    FreeGacha_Reload()                            // nạp lại quota nếu sang ngày mới — doc 07 §3.7
    if (Inventory.Count >= MAX_FISH)  -> Alert(MaxFish);      return    // trần kho chặn TRƯỚC
    if (FreeGachaCount >= 5)          -> Alert(MaxFreeGacha); return    // hết quota
    if (FreeGachaCount >= 3)          -> ShowRewarded(Fish_Gacha); return
    FreeGacha_RewardGet()                                              // cấp NGAY, không quảng cáo
FreeGachaCount Bấm nút Nhãn x{5−n} Biểu tượng quảng cáo
0, 1, 2 thưởng ngay, không quảng cáo x5 → x4 → x3 tắt
3, 4 phải xem rewarded rồi mới nhận x2 → x1 bật
≥ 5 Alert(MaxFreeGacha) x0, nút sang màu disable tắt lại

Đo được: ba lượt đầu ra thưởng ngay (x5 → x4 → x2, chỉ số cộng đúng bảng); lượt thứ tư hiện biểu tượng và đi nhánh rewarded, Granted → thưởng vào kho, nhãn x1; sau lượt thứ năm nhãn x0, biểu tượng biến mất, nút đổi màu disable. Save đối chứng: FreeGachaCount 3 → 5.

Ba quy tắc rút ra:

  1. Phần cho không phải đứng TRƯỚC phần gắn quảng cáo, không được trộn lẫn. Ba lượt đầu là dạy giá trị: người chơi thấy phần thưởng rơi ra và thấy chỉ số nhích lên ngay trên cùng màn hình. Tới lượt thứ tư, câu hỏi "xem quảng cáo chứ?" mới là câu hỏi có giá đã niêm yết. Gắn quảng cáo từ lượt đầu biến bề mặt thành máy bán hàng cho một món chưa ai biết giá. Ngoài ra, phần cho không giữ lý do quay lại hằng ngày cho cả nhóm người không bao giờ xem quảng cáo — bề mặt IAA không đuổi ai đi. Và biên trên (5) giữ cho nó không thành máy cày.
  2. Đặt cổng đếm bằng HAI hằng số trên MỘT bộ đếm, không bằng hai bộ đếm: FreeGacha_MaxCount = 5 và FreeGacha_NoAdCount = 3 cùng đọc FreeGachaCount. Đổi tỉ lệ cho-không/gắn-quảng-cáo chỉ là đổi một số; không có trạng thái thứ hai để lệch pha với save.
  3. Hiển thị biểu tượng quảng cáo theo DẢI, không theo NGƯỠNG. csharp AD_Draw_Icon_obj.SetActive((uint)(FreeGachaCount - 3) < 2u); // count ∈ {3,4} Một phép trừ + một so sánh unsigned bắt trọn cả hai biên. Viết count >= 3 thì biểu tượng dính lại vĩnh viễn sau khi hết quota và người chơi bấm vào một nút hứa quảng cáo để nhận về một thông báo lỗi. Tổng quát: visible = (uint)(used − free) < adSlots.

Cùng bề mặt còn có một nút trả tiền, và nó KHÔNG mua tỉ lệ tốt hơn — nó mua một SÀN cao hơn. Cả hai nút dùng chung một bảng tỉ lệ cộng dồn — {60, 30, 10, 0} đọc từ scene (Main.unity:20930271); nút miễn phí trả baseGrade + i với baseGrade = 0, nút 200 Dia trả baseGrade = 1. Vì mỗi bậc mạnh gấp ~3, dịch một bậc đã là gấp ba — không cần bảng tỉ lệ thứ hai. Ưu điểm kiến trúc: một bảng duy nhất phục vụ mọi nút, thêm một hạng nút chỉ là Roll(base + 1), không có hai bảng để lệch nhau. (Đo: Dia 1368 → 1168.)

Nút baseGrade Phân phối
miễn phí 0 hạng 0 (60%) · 1 (30%) · 2 (10%) · ~~3 (0%)~~
200 Dia 1 hạng 1 (60%) · 2 (30%) · 3 (10%) · ~~4 (0%)~~

Hai điều đáng đọc trong bảng này. (a) Phân phối lệch mạnh về bậc nền và đuôi bị cắt cụt — gacha ở đây là máy rót nguyên liệu, không phải máy tạo bất ngờ; toàn bộ cảm giác tiến bộ được đẩy sang mạch hợp nhất. (b) Ô thứ tư giữ chỗ nhưng để 0: bậc thưởng cao nhất có sẵn slot, bật lại chỉ là đổi một số trong scene, không phải sửa code — một mẹo vận hành rẻ cho live-ops.

⚠️ BẪY ĐÃ LÀM SAI MỘT LẦN Ở CHÍNH TÀI LIỆU NÀY (G1682). GachaChances là field public ⇒ được serialize ⇒ giá trị trong Main.unity thắng lúc chạy. Ctor 0x2B01D08 ghi {40, 30, 20, 10}; bản đầu của §4.1b chép con số đó và publish ra dashboard. Bảng thật là {60, 30, 10, 0}. Cái bắt được lỗi là một quan sát trên máy: năm lượt Epic liên tiếp đều ra hạng thấp nhất — 0,6⁵ ≈ 7,8% (bình thường) so với 0,4⁵ ≈ 1% (đáng nghi). Quy tắc: với mọi bảng tỉ lệ / bảng giá / bảng cân bằng, kiểm loại field trước khi tin con số — xem doc 07 §4 mục 16. Ctor vẫn phải chép vào code (initializer là nợ 1:1 thật), nhưng viết con số của ctor vào tài liệu như cân bằng của game là sai. Hai việc khác nhau.

Thứ tự cổng có ý nghĩa đạo đức, không chỉ kỹ thuật: nhánh trả tiền kiểm kho đầy TRƯỚC khi kiểm tiền, nên kho đầy thì không bị trừ Dia. Đảo lại là ăn cắp.

Bẫy dựng lại (G1681): chép thẳng Admob.instance.ShowRewardAd(...) từ binary nổ NRE ở đúng lượt thứ tư vì Admob là lớp R1-out (instance không bao giờ được gán). Mọi lời gọi tới lớp R1-out phải đi qua ServiceLocator.Ads.ShowRewarded(placementId, cb) và cấp thưởng trong nhánh Granted (§9.3). Cổng kiểm cho mọi lô mới: grep Admob.instance|IAP_manager.instance| Tapjoy_manager.instance trong file vừa dựng phải rỗng.

Chi tiết đầy đủ của hệ này: docs/functions/ca-fish.md.


4.2 Interstitial — chuỗi cổng đầy đủ (Inter_Placement_Able 0x2A5CD34)

bool Inter_Placement_Able(Inter_Place place):
 1. if (is_AD_Disable_Version)                                   -> false   // @0x2a5ce5c
 2. if (Is_Interstitial_Blocked())                               -> false   // @0x2a5ce98
        = is_Interstitial_Disable || Is_Remove_AD_forced || Is_Remove_AD_All
 3. if (_interShowing || _interPlacementRunning)                 -> false   // @0x2a5cf34
 4. stageDisplay = Tree_manager.instance != null
                   ? Tree_manager.instance.Stage_Value_Max / 10 + 1 : 0     // @0x2a5d080
    if (stageDisplay < ServerConfig.InterStageMin)               -> false   // @0x2a5d0b0
 5. cool = ServerConfig.InterCooltime
    if (cool >= 1 && _lastInterShownUtc != DateTime.MinValue
        && (UtcNow - _lastInterShownUtc).TotalSeconds < cool)    -> false   // @0x2a5d1d4
 6. Inter_DayCheck()                                                        // @0x2a5d270
    cnt = PlayerPrefs.GetInt("inter_place_cnt_" + (int)place)
    if (cnt >= Inter_DailyLimit(place))                          -> false   // @0x2a5d29c
 7. whole = PlayerPrefs.GetInt("inter_place_cnt_whole")
    if (whole >= ServerConfig.InterDailyCntWhole)                -> false   // @0x2a5d2e0
    return true

ShowPlacementInterstitial(place, onClosed) (0x2A5D508) → nếu không able thì gọi thẳng onClosed() (không bao giờ chặn luồng game). Nếu able → PlacementInterCo (0x2A5EADC):

_interPlacementRunning = true; interLoadingPanel.SetActive(true)
yield WaitForSecondsRealtime(interLoadingSeconds)          // scene: 1.2 s
for (attempt = 0; attempt < interLoadMaxAttempts; attempt++) {   // scene: 1
    if (IsInterstitialReady) break
    if (interstitialAd.IsLoaded() && Is_InterExpired()) Discard_InterAd()
    LoadInterstitialAd()
    t = 0; while (t < interLoadWaitPerAttempt) {                 // scene: 3 s
        if (IsInterstitialReady) break
        if (_interLoading) { t += unscaledDeltaTime; yield null } else yield WaitRealtime(0.5) }
}
interLoadingPanel.SetActive(false); _interPlacementRunning = false
if (IsInterstitialReady) { ShowInterstitialAd(onClosed); if (_interShowing) Inter_CountUp(place) }
else { LogWarning("[ADX] Placement 전면광고 로드 실패({0}회) → 스킵 — {1}"); onClosed?.Invoke(); LoadInterstitialAd() }

Inter_CountUp(place) (0x2A5D644): DayCheck → +1 cho khoá của place → +1 cho whole → PlayerPrefs.Save() → _lastInterShownUtc = UtcNow → log.

Stage_Value_Max MaxStageDisplay OfferWall_Btn FreeCash_Btn Interstitial đủ tư cách Ngưỡng chạm
0 – 80 1 – 9 ✗ ✗ –
90 10 ✓ BẬT ✗ – offerwall_stage_min = 10
100 11 ✓ ✗ –
110 12 ✓ ✗ Rebirth ✓ · Mine ✓ inter_stage_min = 12
130 14 ✓ ✗ Rebirth · Mine
140 15 ✓ ✓ BẬT Rebirth · Mine freecash_stage_min = 15
150 – 340 16 – 35 ✓ ✓ Rebirth · Mine
349 35 ✓ ✓ (cạnh cuối) Rebirth · Mine freecash_stage_max_hud = 35
350 36 ✓ ✗ ẨN LẠI Rebirth · Mine vượt max_hud
400 · 990 41 · 100 ✓ ✗ Rebirth · Mine

is_AD_Disable_Version = 0 · is_Interstitial_Disable = 0 · Is_Interstitial_Blocked = Is_Remove_AD_All || Is_Remove_AD_forced = 0 · reentrancy = 0 — chỉ duy nhất cổng stage (gate 4) đổi trạng thái theo tiến trình. Challenge / Boss / Hunt không xuất hiện ở bất kỳ hàng nào — chúng bị chặn thuần tuý bởi trần/ngày = 0 (gate 6), không phải bởi cổng stage. Ở segment 1, toàn bộ ngân sách interstitial nằm ở Rebirth (6/ngày) + Mine (2/ngày): đúng hai khoảnh khắc "vừa nhận một khoản thưởng lớn", nơi người chơi ít khó chịu nhất.

BIÊN QUAN SÁT (đừng đọc quá): phiên đo chỉ chứng minh ĐIỀU KIỆN ĐỦ TƯ CÁCH (Inter_Placement_Able trả true/false). IsInterstitialReady = 0 suốt phiên vì mediation không có fill trên bản .sot sideload ⇒ không quảng cáo nào được hiện, Inter_CountUp chưa bao giờ chạy, và việc bộ đếm/cooldown có được thực thi lúc hiện hay không vẫn UNKNOWN

9 điểm kích hoạt (tất cả đều là "đóng màn kết quả", không bao giờ giữa trận):

Inter_Place Điểm gọi (RVA) Điều kiện bổ sung tại điểm gọi
Rebirth (0) Rebirth_manager.Rebirth @0x2b22dfc !(rebirthWasBoosted \|\| reviewPopupOpened) — xem §7.4
Mine (1) Mine_manager.Sweep_Close @0x2af5c1c !_bonusAdWatched && !Shop_manager.Is_RewardBonus_Auto
Mine (1) Mine_manager.<Exit_Cor>d__38 @0x2af6514 _clearedThisRun && !_bonusAdWatched
Challenge (2) SpeedRun_manager.Sweep_Close @0x2b41af8 tương tự Mine
Challenge (2) SpeedRun_manager.<Exit_Cor>d__28 @0x2b42650 tương tự Mine
Boss (3) Boss_manager.Sweep_Close @0x2af2e88 tương tự Mine
Boss (3) Boss_manager.<Exit_Cor>d__32 @0x2af3308 tương tự Mine
Hunt (4) Monster_manager.SweepBonus_Get @0x2afb030 nhánh bị chặn: hàm đặt cờ +194 = 1 rồi mới kiểm !cờ ⇒ thực tế không bao giờ chạy
Hunt (4) Monster_manager.<Exit_Cor>d__46 @0x2afba4c tương tự Mine

Luật vàng nhìn thấy được: nếu người chơi vừa xem một rewarded ad (_bonusAdWatched), hoặc đã mua/được miễn quảng cáo (Is_RewardBonus_Auto), thì KHÔNG chèn interstitial. Game không bao giờ "phạt" người vừa tự nguyện xem quảng cáo.

4.3 Banner

  • Bật: Admob.ShowBannerAd() 0x2A5BCF4 ← Boss_manager.<Enter_Cor>d__27, Monster_manager.<Enter_Cor>d__36, Raid_manager.<Enter_Cor>d__29.
  • Tắt: HideBannerAd() 0x2A5BD58 ← Boss_manager.Game_Exit, Monster_manager.StopAllGameCoroutines, Raid_manager.Game_Exit, và Handle_InAppPurchaseChanged.
  • Chặn: Is_Banner_Blocked() = Is_Remove_AD_forced || Is_Remove_AD_All (0x2A5BC70).
  • Update_BannerPlaceholder() + Refresh_SafeArea() đẩy layout lên 50 dp để banner không che UI.
  • ⇒ Banner chỉ tồn tại trong 3 chế độ trận đấu có thời lượng cố định, không có ở màn idle chính. Raid_manager xuất hiện ở đây là bằng chứng độc lập rằng Raid là nội dung đã code nhưng chưa mở (khớp UNLOCK_GATES §3 "Raid — KHÔNG có cổng nào").

4.4 OfferWall (Tapjoy, placement offerwall_aos)

(khối mã nội bộ đã lược bỏ)

  • Phần thưởng: kim cương, qua currency của Tapjoy → OnEarnedCurrency → GrantGem(amount) (0x2B2AB4C).
  • Chống mất thưởng: nếu save chưa nạp, ghi PlayerPrefs["ow_pending_gem"] = amount và trao ở lần chạy sau (RecoverPendingGem 0x2B29740). Timeout claim ClaimTimeoutSec = 20.
  • 2 nơi mở: Open_Hud() (place hud) và Open_Shop() (0x2B2A0F4/0x2B2A13C).
  • 3 object UI theo event: OfferWall_Hud_obj, Event_Hud_obj, Event_Shop_obj.
  • Đo sống (segment 1): OfferWall_Btn bật ở MaxStageDisplay = 10 và giữ bật vĩnh viễn tới

(khối mã nội bộ đã lược bỏ)

vẫn không làm nút hiện; chỉ khi ghi off_5E27948 + 0x38 = 1 nó mới bật. Mặc định trên máy fresh là 3.

Đính chính nhãn (có bằng chứng tĩnh): trường đó không phải AppsFlyerInit.Segment. script.json/ScriptMetadata ánh xạ off_5E27948 → AlmediaLink.AlmediaLinkSDK_TypeInfo, và AlmediaLinkSDK._almediaStatus nằm đúng ở static 0x38 (dump.cs:1108434). AppsFlyerInit.Segment là static 0x8 của off_5E27E20 (dump.cs:585775), do AppsFlyerInit.Cheat_SetSegment 0x2A600CC ghi — một trường hoàn toàn khác. enum AlmediaStatus = NotInitialized 0, Eligible 1, Linked 2, NotAvailable 3, Blocked 4, Disabled 5 (dump.cs:1110114) ⇒ mặc định 3 = NotAvailable, khớp chính xác quan sát "máy fresh không bao giờ thấy FreeCash dù ServerConfig có bật". Kết luận hành vi KHÔNG đổi — chỉ nhãn cơ chế được sửa: bản cài mới, hoặc thị trường mà Almedia không phục vụ, thì FreeCash không bao giờ lộ diện, bất kể ServerConfig nói gì.

  • Cửa sổ nổi: HUD chỉ hiện trong khoảng [freecash_stage_min, freecash_stage_max_hud] (segment 1: stage 15 → 35). Banner trong shop thì không có trần trên. Đo sống: Stage_Value_Max = 349 (Stage 35) ⇒ hiện; 350 (Stage 36) ⇒ ẩn lại
  • Phần thưởng: CURRENT_REWARD = RewardType.RemoveRewardAd (1) ⇒ TryGiveLinkReward (0x2A6D3C4) đặt isRewardAdRemoved = true và gọi Admob.On_FreeCash_RewardAdRemoved() (0x2A5A37C) để huỷ quảng cáo đang nạp. Từ đó ShowRewardAd trao thưởng thẳng (§4.1). (RewardType.Gun = 2 + REWARD_GUN_NUM = 25 tồn tại nhưng không được chọn ở 1.1.59.)
  • Nếu save chưa nạp: pendingReward = true, trao sau (0x2A6D3C4 @0x2a6d3d8).
  • place lưu ở PlayerPrefs["fc_link_place"], xoá sau khi trao; log AppsFlyer m_freecash_link.
  • Hằng độ bền: INIT_MAX_RETRY 20, INIT_RETRY_DELAY 1 s, LINK_CHECK_TIMEOUT 10 s.

4.6 Súng thưởng quảng cáo weapon_02 — Gun_manager

CheckAdGunEvent()   0x2B0C74C          // gọi từ Gun_Item_List.FuseItems (sau khi người chơi GHÉP súng)
  if (AD_Gun_Event_Given) return
  if (!ServerConfig.Weapon02AdOn) return                    // weapon_02_ad_onoff
  if ( Has_Degree_Or_Higher(2)                              // đã có súng bậc >= 2 -> khỏi cần tặng
    || ( Has_Merged && AD_Gun_SlotIndex == -1
      && AddAdRewardGun(gunNum: 14, cooltimeSec: 1800f) != -1 ) )
      AD_Gun_Event_Given = true                             // một-lần-cho-cả-đời-tài-khoản
  • AddAdRewardGun (0x2B0C340): đặt súng #14 vào ô trống đầu tiên, ghi AD_Gun_EndUtcTicks = UtcNow + 1800 s (30 phút đếm ngược).
  • Update() (0x2B0C64C): mỗi 0.25 s (unscaled), nếu hết giờ và không đang xem quảng cáo → AdRewardGun_Expire() — súng biến mất.
  • Chạm vào súng đó (Gun_Item.OnPointerUp, Gun_Item_List.EquipToSlot, FuseItems) → AdRewardGun_TryWatch() → ShowRewardAd(AD_Reward_Gun).
  • Xem xong → AdRewardGun_Acquire() (0x2B0C508): xoá timer + slot marker ⇒ giữ súng vĩnh viễn, bật NewGun_UI_ForceShow.

Đây là mẫu "dùng thử có hạn giờ" kinh điển: đưa món đồ vào tay người chơi, cho họ nếm sức mạnh, rồi đặt một quảng cáo giữa họ và việc giữ nó. Trigger là hành vi (vừa ghép súng lần đầu), không phải thời gian — nên nó rơi đúng lúc người chơi đang quan tâm tới súng.


5. Kinh tế Buff ×3

Đại lượng Giá trị Nguồn
Một lần xem quảng cáo cộng 300 s AddBuff 0x2AE4510
Trần tích luỹ 900 s (BUFF_MAX) — xem 3 lần liên tiếp là đầy 0x2AE4510 (fminf(x+300, 900))
Loại Money_Buff (0) và Power_Buff (1) — 2 đồng hồ độc lập (moneyBuffTime@0x28, powerBuffTime@0x2C) dump.cs:592890-592891
Hệ số ×3 (UpgradeRebirth_Multiplier cùng giá trị; hệ số buff áp trong chuỗi thu nhập) 01-economy-money.md; Cat_manager.Balance_Reload được gọi ngay sau AddBuff
Vĩnh viễn qua IAP IsPermanentBuff = Shop_manager.Is_Buff_Pack (static 0xB) ⇒ IsBuffActive(**mọi** type) = true 0x2AE4A08, 0x2AE4AF8
Hiển thị Buff_Panel.Update 0x2AE4D4C: fill = time / 900, text "{0}:{1}" phút:giây; vĩnh viễn ⇒ text rỗng, fill = 1.0, ẩn nút xem QC 0x2AE4D4C
Cache AddBuff(Money_Buff) gọi Gamemanager.InvalidateMoneyCache() — buff nằm trong chuỗi nhân thu nhập có cache LRU 0x2AE4510; xem 01-economy-money.md
Lưu SaveData.MoneyBuffTime / PowerBuffTime (offset 0x2A8/0x2AC), nạp qua LoadBuffTimes 0x2AE4180 FABLE_ANALYSIS §B-6

Giá của cùng một thứ: 300 s ×3 tiền = 1 quảng cáo. Vĩnh viễn ×3 cả hai = buff_pack 210.000đ (phaseA/01_shop.png). Đó là toàn bộ luận điểm bán hàng — và nó được đặt ở dòng thứ hai của cửa hàng, ngay dưới gói Auto Upgrade.


6. IAP — danh mục đầy đủ

6.1 34 product id → chỉ số offer → thứ được trao

Chỉ số lấy từ Shop_manager.ItemIdxFromProductId (0x2B2DE44), dùng cho log AppsFlyer m_offer_page.

Idx Product id Loại Được trao (hàm, RVA) Giá quan sát (VND, live)
1 dia_0 consumable ApplyDiaReward 0x2B32D90 → 240 dia (×2 lần đầu) 52.000đ
2 dia_1 consumable → 600 dia (×2 lần đầu) 131.000đ
3 dia_2 consumable → 1500 dia (×2 lần đầu) 263.000đ
4 dia_3 consumable → 3500 dia (×2 lần đầu) 526.000đ
5 dia_4 consumable → 8000 dia (×2 lần đầu) 1.300.000đ
6 remove_forced_ads non-consumable Is_Remove_AD_forced = true; mở DiaPig 79,000đ
7 remove_all_ads non-consumable Is_Remove_AD_All = true; mở DiaPig; tắt interstitial + banner, và rewarded ad tự trao thưởng 263,000đ
8 autoupgrade_pack non-consumable Is_AutoUpgrade_Pack = true + 500 dia 211.000đ
9 buff_pack non-consumable Is_Buff_Pack = true (×3 Power+Money vĩnh viễn) + 1000 dia + reload Cat_manager/Buff_manager 210.000đ
10 dia_pack non-consumable Is_Dia_Pack = true + 16000 dia 1.300.000đ
11 mine_pack non-consumable Is_Mine_Pack = true + 500 dia (gói duy nhất không có hiệu ứng dia bay — nó mua từ popup trong màn Mỏ, không có điểm xuất phát trên UI cửa hàng) 158,000đ
12 vip_pass và vip_pass_2 non-consumable ApplyVipPass 0x2B32C50: Mission_manager.is_Vip = true + 500 dia vip_pass 210,000đ; vip_pass_2 KHÔNG có trên store (xem ghi chú dưới bảng)
13 catgunner_core_100 consumable ApplyCoreReward 0x2B304BC → Adventure_manager.Core_Get(Core_Type.ALL, 100) ⇒ Currency_Core[3] += 100 26,000đ
14 catgunner_core_500 consumable → Currency_Core[3] += 500 105,000đ
15 catgunner_core_1050 consumable → Currency_Core[3] += 1050 210,000đ
16 catgunner_core_3300 consumable → Currency_Core[3] += 3300 631,000đ
17 catgunner_core_daily_750 consumable, nhịp ngày → Currency_Core[3] += 750 + ghi LastDailyCorePurchase_dateTime 105,000đ
18 catgunner_core_weekly_1750 consumable, nhịp tuần → Currency_Core[3] += 1750 + LastWeeklyCorePurchase_dateTime 210,000đ
22 catgunner_core_weekly_6000 consumable, nhịp tuần → Currency_Core[3] += 6000 + LastWeekly6000CorePurchase_dateTime 631,000đ
19 catgunner_luxury_pass non-consumable ApplyLuxuryPass 0x2B32AA4: is_Vip = is_Luxury = true + 1000 dia + Exp 300 (3 cấp pass) 394,000đ
20 catgunner_luxury_upgrade non-consumable ApplyLuxuryUpgrade 0x2B328F8 — thân hàm giống hệt idx 19 210,000đ
21 catgunner_rebirth_single consumable ApplyRebirthSingle 0x2B3280C → UpgradeRebirth_Start_By_IAP ⇒ thêm 1 lượt Rebirth ×3 trong ngày 52,000đ
23-27 catgunner_stepup_0..4 consumable ×5 ApplyStepUpReward 0x2B2E90C → StepUp.Gun_Rewards[i] = súng [23,28,33,39,44] + Dia_Rewards[i] = [0,200,400,600,1000] dia 26,000 / 130,000 / 261,000 / 523,000 / 1,050,000đ
28-30 catgunner_dailygun_0..2 consumable ×3 ApplyDailyGunReward 0x2B2EDB8 → DailyGun.ApplyReward(idx); nền súng GunNum_Base=[20,30,35], PetCoin [300,700,1500], giới hạn tuần MaxCounts=[2,2,1] 130,000 / 523,000 / 915,000đ
36 catgunner_pluspack_time consumable ApplyPlusPackReward 0x2B2EF0C → PlusPack.ApplyReward(Time), hạn 7 ngày 104,000đ
37 catgunner_pluspack_reward consumable → PlusPack.ApplyReward(Reward); ×2 Monster, ×2 Boss, ×1.5 Mine 234,000đ
38 catgunner_pluspack_auto consumable → PlusPack.ApplyReward(Auto); giảm giờ Adventure (PlusPack_Auto_Reduce_Hours = 16) 156,000đ

Chỉ số 31-35 bỏ trống — có thể là sản phẩm đã gỡ hoặc chừa chỗ. Đây là bằng chứng phủ định, không phải suy đoán: ItemIdxFromProductId không trả về giá trị nào trong khoảng đó.

Giá: 33/34 id đã có giá thật (đóng G162). Dump Shop_manager.Localized_Price_return (0x2B367D4) read-only trên bản Play Store chính hãng: dict_count = 33, resolved = 33 ([liveops catalog: iap_prices_store.json]). 8/8 giá trùng khớp ảnh chụp cửa hàng của do store cấp — không phải hằng số trong build (§6.3).

vip_pass_2 KHÔNG phải sản phẩm Play — nó có mặt trong TryApplyPurchase/ItemIdxFromProductId nhưng không nằm trong 33 id mà store trả giá ⇒ 34 id trong code, 33 id đang bán. Nhiều khả năng là SKU cũ giữ lại để người đã mua vẫn khôi phục được. (Bằng chứng phủ định, không suy đoán về lý do.) Tương tự, fish_free, mine_ticket, sp_iap, reward_hub, f_cash, banner_* không xuất hiện trong dump giá — củng cố kết luận §2.2 rằng chúng là chuỗi place/khoá analytics.

Phân bố giá (33 SKU, VND): thấp nhất remove_forced_ads 79.000đ · cao nhất dia_4 = dia_pack 1.300.000đ. Ba bậc giá lặp lại rõ rệt — 26.000 / 105.000 / 210.000 / 631.000 (Core) và 26.000 / 130.000 / 261.000 / 523.000 / 1.050.000 (StepUp) gần như nhân đôi mỗi bậc: đây là một thang giá hình học ×2, không phải giá đặt tay từng món.

6.1b Số đo trên thiết bị — cả 34 SKU đã CHẠY THẬT (F024 NT4, 2026-09-12)

Bảng §6.1 ở trên đọc từ binary. Mục này là phép đo: từng product id được đẩy qua đúng đường của game (Shop_manager.BeginPurchase → ServiceLocator.Purchases → quân xanh F024 → Granted → TryApplyPurchase) trên Pixel 8 Pro, trạng thái đọc bằng save trước/sau từng giao dịch. 34/34 thành công, 0 exception, mọi con số ở §6.1 trúng. Công cụ: tools/iap_catalog_oracle.py; bảng đầy đủ: [evidence codegen: SPEC-F024-T5-iap-catalog.md].

Bốn hình thái SKU — đây mới là thứ tái dùng được, bảng 34 dòng chỉ là dữ liệu của một game:

Hình thái Bán cái gì SKU trong CatGunner Dấu hiệu trong save
Tiền thuần một con số 5 × dia_*, 7 × Core ô tiền đổi, không có cờ nào bật
Quyền vĩnh viễn + quà kèm một CỜ, quà chỉ là mồi 6 non-consumable, VIP, Luxury cờ false→true, tiền cộng một lần duy nhất
Quyền CÓ HẠN một khoảng thời gian 3 × PlusPack chỉ một dấu thời gian, không tiền, không cờ
Hàng hoá + tiền phụ một vật phẩm 5 × StepUp, 3 × DailyGun ô kho vật phẩm đổi + tiền phụ cộng

Một SKU có thể lai hai hình thái: ba gói Core theo nhịp vừa cấp tiền vừa đóng dấu Last*CorePurchase_Date để tính "đã tới lượt mua lại chưa".

Cái bẫy trung tâm của hình thái 2, đo được bằng hai vế đối xứng trong cùng một lượt chạy:

  • vip_pass_2 mua khi is_Vip đã true ⇒ vẫn +500 dia. ApplyVipPass không có cổng "đã có thì thôi".
  • autoupgrade_pack / mine_pack mua khi cờ đã true ⇒ không đổi một bit. Phần Get_Dia của chúng nằm trong if (!<cờ>), còn <cờ> = true nằm ngoài.

Cùng mang tiếng "gói vĩnh viễn" nhưng hai nhóm xử quà kèm ngược nhau. Đây cũng là lý do RestorePurchases chạy lại được bao nhiêu lần cũng an toàn với nhóm sau — và không an toàn với VIP nếu ai đó nối nhầm khôi phục vào ApplyVipPass. Quy tắc framework: quyền và quà kèm phải qua HAI cổng riêng — cổng "đặt quyền" idempotent, cổng "trao quà" một lần.

Năm số đo còn lại mà đọc code không cho ra (chi tiết G1789):

  1. catgunner_rebirth_single thi hành luôn một lượt Rebirth ngay lúc mua, không phát một lượt để dành: đo được Upgrade_Lv [100,100,99] → [0,0,0] và Ruby 484,97 → 96484 trong cùng giao dịch.
  2. Bảy SKU Core đổ vào một ô duy nhất Adventure_Currency_Core[3] (Core_Type.ALL); ba ô theo vùng [0..2] không đụng ⇒ Core bán ra là tiền VẠN NĂNG.
  3. Mua chồng PlusPack là mất tiền: ô ngày chỉ DỜI mốc (03:17:19 → 04:51:44), không cộng dồn hạn.
  4. remove_forced_ads đánh thức heo đất ngay lúc mua — diaPig.LastTickTime nhảy trong cùng giao dịch, tức nhánh DiaPig.Init(null) có thật chứ không phải mã chết.
  5. StepUp_Start_Date do lần mua bậc 0 đóng dấu — đồng hồ của cả chuỗi StepUp bắt đầu từ lần mua đầu, không phải từ lúc mở panel.

6.1a "Core" là tiền tệ gì (giải quyết phần lớn G163)

enum Core_Type { Forest = 0, Desert = 1, Tundra = 2, ALL = 3 }         // dump.cs:591540
Adventure_manager.Currency_Core : int[]                                 // instance +0x28
Adventure_manager.Core_Get(Core_Type type, int value)  0x2ADA8C8
    => Currency_Core[(int)type] += value;  OnCoreChanged?.Invoke();  Alert_Reload()
Adventure_manager.Core_Lose / CoreCount_return  — cùng chỉ mục
const Adventure_Reward_Core_perHour     = 20      // kiếm được bằng cách đi Trip
const Adventure_Reward_Core_PetDegree   = 0.1     // +0.1 Core mỗi bậc pet

⇒ Core là tiền tệ của Trip (Adventure), kiếm bằng cách cử pet đi chuyến (20/giờ), tiêu vào cây Ability của Trip. Có 4 ô: 3 ô theo map (Forest / Desert / Tundra) + ô ALL (index 3). Mọi IAP catgunner_core_* đều nạp vào ô ALL — tức loại Core dùng được cho mọi map. Panel Trip đang chạy hiển thị đúng 4 chip tiền tệ: 3 viên màu (xanh lá / cam / xanh dương, khớp thứ tự enum Forest/Desert/Tundra) + 1 ngôi sao tím "PrismCore" HYPOTHESIS (mức trung bình): ngôi sao tím = ô ALL (index 3) = "PrismCore" — dựa trên (a) Core_Type có đúng 4 giá trị, (b) panel có đúng 4 chip, (c) banner promo của panel mở PrismCore_Purchase_popup (Main.unity:22690) và (d) scene có 7 object Prism_Pack, 3 trong đó đánh dấu (Limited). Chưa xác nhận: chuỗi UI ↔ index chưa được đọc từ code (Adventure_Panel dump.cs:592475, Core_sprite_return dump.cs:591675-591681) → xem §10.

6.2 Đường đi của một giao dịch

Đính chính 2026-09-12 (QD-61, G1768, F024 T5): tiêu đề cũ của mục này ghi "không có bước nào ở đây được tái hiện trong bản restore" — nay SAI. Hai đầu của chuỗi CHẠY THẬT và đo được: BeginPurchase (đã dựng, chuyển hướng qua ServiceLocator.Purchases) và TryApplyPurchase cùng toàn bộ họ Apply* (dựng đủ, đã chạy 34/34 SKU, §6.1b). Phần còn chưa dựng là khúc GIỮA — OnPurchasePending → StartValidate → ValidateAndComplete → CompletePurchaseSuccess — vì nó cần mô hình PendingOrder/receipt của store thật (R1-out, G218/G1691).

<nút UI> -> Shop_manager.Purchase*(type, place)       // ghi _pendingPlace + _pendingIdx
        -> BeginPurchase(productId)   0x2B2E7D8       // ShowPurchasingPanel(); StoreController.PurchaseProduct
        -> [Unity IAP + store]
        -> OnPurchasePending(order)   0x2B31758       // GetTxnId, AlreadyOwned, _inflightTxns
        -> StartValidate(order, product) 0x2B32144
        -> ValidateAndComplete(...)   0x2B33DAC       // POST receipt -> Cloud Function verify*
        -> CompletePurchaseSuccess(...) 0x2B34164
        -> TryApplyPurchase(id, type) 0x2B32204       // <== ĐIỂM TRAO DUY NHẤT
        -> MarkProcessed(txnId); SaveSystem.Save(); LogFacebookPurchase / LogFirebasePurchase

TryApplyPurchase (0x2B32204) là một chuỗi so khớp id: 6 non-consumable → ApplyNonConsumable; catgunner_rebirth_single; catgunner_luxury_upgrade; catgunner_luxury_pass; vip_pass/vip_pass_2; TryGetCoreTypeFromId; rồi 4 nhánh theo prefix (dia_, catgunner_stepup_, catgunner_dailygun_, catgunner_pluspack_); không khớp gì → LogError("[IAP] Unknown product id '{0}' (type={1}) → apply 실패 처리") và trả false.

Ba mẫu đáng học từ đoạn này: 1. Trao hàng là idempotent và hoãn được. Nếu manager phụ trách còn null (chưa xong Awake), hàm trả false và log "확정 보류, 다음 실행 재시도" — giao dịch không được finalize, store sẽ phát lại ở lần chạy sau. Người chơi không bao giờ mất hàng vì thứ tự khởi tạo. 2. Một điểm trao duy nhất cho cả mua mới, khôi phục (RestorePurchases 0x2B35AD0) và đơn hoãn (_deferredOrders). 3. Prefix hoá sản phẩm có chỉ số (catgunner_stepup_0..4) — thêm bậc mới không phải sửa code dispatch, chỉ sửa mảng phần thưởng.

6.3 Định giá — cái game LÀM và cái game KHÔNG làm

  • Game không hardcode giá. Localized_Price_return(...) (6 overload, 0x2B367D4…) đọc giá từ Product.metadata của store, rồi FormatPrice_Display(price, iso) (0x2B368B0) định dạng theo CurrencyFormat / ZeroDecimalCurrency (2 bảng static trong .cctor 0x2B36F9C). ⇒ Mọi con số đồng trong tài liệu này là giá cửa hàng quan sát được ở VN tại thời điểm chụp, KHÔNG phải hằng số trong build.
  • TryGetPrice(productId, out decimal, out string currency) (0x2B36D00) cho phép UI hiển thị giá chuẩn từng thị trường.
  • Bảng giá thật (VN, 33 SKU) nằm ở §6.1 — dump từ chính Localized_Price_return trên bản store chính hãng ([liveops catalog: iap_prices_store.json]). Đọc nó như một mẫu định giá, không phải như hằng số của game: cùng build ở thị trường khác sẽ hiện con số khác.
  • ZeroDecimalCurrency là lý do VND hiện 52,000đ chứ không phải 52,000.00đ. Nội dung hai bảng (đọc từ .cctor 0x2B36F9C, G399): CurrencyFormat = 23 mục tiền tệ → chuỗi định dạng (+0x68); ZeroDecimalCurrency = {KRW, JPY, VND, IDR, CLP, HUF} (+0x70) — sáu tiền tệ không có phần thập phân. Cùng .cctor cấp ProcessedTxnIds (+0x40) và PurchaseHistory (+0x48) — thiếu chúng là autosave trên save mới ném mỗi 10 s (G399).

7. Kích hoạt offer & thời điểm

7.0 BA LỚP CỔNG xếp chồng — tutorial → thang stage → segment

LỚP 1 — TUTORIAL  (Tutorial_manager, dump.cs:602669)
    Save mới: CHỈ SpeedRun_Btn + Setting_Btn active.
    Shop / Gun / Skin / Mission / DailyCheck / Auto / Eco / DiaPig / Buff đều tắt,
    và KHÔNG do Contents_Lock_Reload bật  ->  Tutorial_manager mở dần.
    TutorialStep: Upgrade 0 -> Challenge 1 -> GunOpen 2 -> GunBuy 3 -> GunEquip 4 -> Done 5
    Scene: DisableOnTutorial[] = 32 object (Main.unity:20342185+)
        v   (người chơi làm xong tutorial)
LỚP 2 — THANG STAGE  (Tree_manager.Contents_Lock_Reload 0x2B4441C)
    Stage_Value_Max >= 20/30/50/90/110/110/340  ->  Rebirth/Mine/Fish/Boss/Pet/Hunt/Trip
        v   (nội dung mở dần)
LỚP 3 — SEGMENT  (ServerConfig_manager, 15 số từ server)
    MaxStageDisplay >= offerwall_stage_min / inter_stage_min / freecash_stage_min
    + trần ngày theo place + cooldown  ->  bề mặt QUẢNG CÁO bật/tắt

Hệ quả cho phễu kiếm tiền: một người chơi mới không nhìn thấy cả cửa hàng cho tới khi tutorial mở Shop_Btn. Nghĩa là thứ tự tiếp xúc là gameplay trước, cửa hàng sau, quảng cáo sau nữa (bề mặt IAA sớm nhất là OfferWall ở Stage 10 — tức khoảng 100 màn sau khi bắt đầu).

7.1 Công tắc tổng: segment

boot -> ServerConfig_manager.Awake 0x2AC8154
     -> LoadFromSaveData 0x2AC1FA8 (dùng cache) -> Tree_manager.Contents_Lock_Reload
     -> FetchRoutine 0x2AC9864 : POST {BASE_URL}/[endpoint], TIMEOUT_SEC 2,
        FAIL_RETRY_DELAY 60, FAIL_RETRY_MAX 1, POST 1 lần/ngày (NeedPost/TodayKey),
        FIRST_POST_WAIT_CONVERSION_SEC 10  (chờ AppsFlyer trả conversion data trước khi POST lần đầu)
     -> nếu chưa có phản hồi: EmbeddedDefaults(segment) 0x2AC7B20
segment Hệ quả
1 Bật IAA: FreeCash stage 15-35, OfferWall từ stage 10, interstitial từ stage 12, cap Rebirth 6/ngày, Mine 2/ngày, các place khác 0, cap toàn cục 99999, ad-gun bật
0 / ≠1 Tắt sạch IAA (mọi ngưỡng 99999, mọi cap 0, ad-gun tắt) — đây là mặc định và là điều production đang trả về cho user thật (rule_version 41, reason ok)

⇒ Bài học vận hành lớn nhất của tài liệu này: một game có thể ship toàn bộ hạ tầng IAA và vẫn chạy 0 quảng cáo cho một phân khúc, chỉ bằng 15 con số từ server. Phân khúc quyết định bởi nguồn cài đặt / quốc gia / lịch sử chi tiêu (ConfigReq), không phải bởi build.

7.2 Offer gắn với TIẾN TRÌNH (đọc Stage_Value_Max, xem UNLOCK_GATES.md §1)

stageDisplay = Stage_Value_Max / 10 + 1

Bề mặt Điều kiện RVA
Interstitial stageDisplay >= inter_stage_min (seg 1: 12) 0x2A5CD34
OfferWall HUD stageDisplay >= offerwall_stage_min (seg 1: 10) 0x2B2B20C
FreeCash HUD freecash_stage_min <= stageDisplay <= freecash_stage_max_hud (seg 1: 15 → 35) 0x2A6CF08 + 0x2A6D1F8
FreeCash banner (shop) chỉ >= freecash_stage_min (không trần) 0x2A6D644
Popup VIP trong panel Mùa mới (VIP_Purchase_popup) NewSeason_Btn hiện khi Reward_Lv > 24; popup SetActive(!is_Vip) trong OnEnable (Reload TRƯỚC, OnInAppPurchaseChanged += Reload SAU ⇒ đã VIP thì rò một subscriber trên popup ẩn — hành vi SOT); bấm ⇒ Purchase_VIP_Banner(type, "banner") 0x2B3E6CC/0x2B3E778; đo Pixel lô 13 (docs/functions/popup-mua-vip.md)
Setting › Restore (Setting.Restore → Shop_manager.RestorePurchases(onSuccess,onFail)) không StoreController ⇒ onFail NGAY ⇒ Alert Alarm/RestoreFail; có store ⇒ _isRestoring=true, OnPurchasesFetched→duyệt receipt→onSuccess, OnStoreDisconnected→onFail, Cleanup gỡ handler; closure có cờ completed chống gọi hai lần 0x2B35AD0 (640 B) + <>c__DisplayClass136_0; đo Pixel lô 14a (docs/functions/setting-restore-cloud-update-review.md)
Setting › Đổi mã (Redeem_UI → RedeemCode_manager) KHÔNG có máy chủ: từ điển 3 hash SHA-256(code.Trim().ToLowerInvariant()) → 500 dia, expire 0; đã dùng ghi UsedCodes_Log (save); thứ tự kiểm Empty→Invalid→AlreadyUsed→Expired; thành công lưu ngay + log AppsFlyer .cctor 0x2ACC228, Redeem 0x2ACBD18; oracle Hash = shasum (docs/functions/doi-ma-lien-he-biet-danh.md)
Setting › Liên hệ (Setting.SendEmail) mailto: tới <địa chỉ hỗ trợ NPH, literal 0x5EA7E48>, cc <địa chỉ cc NPH, literal 0x5E87B98>, subject "Cat Gunner Feedback ()", body = Email_Body_str (scene) với save nén SaveCompressor.PackForMail (GZ1: + base64url(GZip)) — không SDK 0x2ACE2F0 (1276 B); Pixel: Gmail compose mở, body trống (G1705)
PlusPack banner Banner_Stage_Min = 190 (⇒ Stage 20-1) và gói đó chưa active dump.cs const; PlusPack.Banner_ShouldShow 0x2B2C778

7.3 Offer gắn với HÀNH VI (không phải stage, không phải giờ)

Bề mặt Trigger RVA
Ad-gun weapon_02 ghép súng (Gun_Item_List.FuseItems) lần đầu, khi chưa có súng bậc ≥2 0x2B0C74C
StepUp banner !Is_AllPurchased && !Is_Expired && Gun_manager.Has_Degree_Or_Higher(2) (khớp Banner_Unlock_Degree = 2) 0x2B3A670
StepUp đồng hồ 7 ngày StepUp_Start() chỉ chạy lần đầu banner/popup được reload — đồng hồ bắt đầu khi người chơi nhìn thấy, không phải khi cài 0x2B3A328 ← Hud_Reload/Popup_Reload
Interstitial sau Rebirth chỉ khi lần rebirth đó không được boost và popup review không mở Rebirth() @0x2b22d9c
Rewarded "bonus" mở panel kết quả Mine/Boss/Hunt/Challenge (nút Bonus_Btn_obj, SweepBonus_Btn_obj); nếu Is_RewardBonus_Auto thì hiện SweepNoAds_Btn_obj thay thế dump.cs Mine_manager fields

7.4 Tương tác popup Review ↔ interstitial (mẫu đáng chép)

Rebirth_manager.Rebirth()  0x2B22AE0 — đuôi hàm:
    bool reviewOpened = Review_manager.instance.Review_Panel_Open();     // @0x2b22d24
    Mission_manager.instance.MissionValue_Increase(Mission_Type.Rebirth);
    if (is_UpgradeRebirth) { if (!is_UpgradeRebirth_IAP) { UpgradeRebirth_UseCount++;
                                                           UpgradeRebirth_LastDate = today; }
                             is_UpgradeRebirth = is_UpgradeRebirth_IAP = false; }
    SaveSystem.Save();
    if ( !(boosted || reviewOpened) && Admob.instance != null )          // @0x2b22d9c
        Admob.instance.ShowPlacementInterstitial(Inter_Place.Rebirth, null);

Review_Panel_Open() (0x2AC0DE8) trả true đúng một lần trong đời tài khoản (is_reviewed lưu trong SaveData) và chỉ có một người gọi: Rebirth().

⇒ Ba luật ưu tiên popup được mã hoá trong đúng một dòng if: 1. Người vừa trả tiền / xem quảng cáo để boost ⇒ không ăn thêm interstitial. 2. Xin đánh giá app thắng interstitial (chỉ có một cơ hội, và nó rơi vào lần rebirth đầu tiên — đúng khoảnh khắc người chơi vừa cảm thấy phần thưởng lớn nhất của game). 3. Còn lại thì interstitial, và vẫn phải qua 7 cổng ở §4.2.

7.5 Offer theo LỊCH (cố định trong binary, không remote)

Hệ Nhịp Hằng RVA
StepUp 7 ngày kể từ lần hiện đầu, 5 bậc, mua tuần tự StepCount 5, Duration_Days 7 Is_Expired 0x2B3A4E0, Current_Purchasable_Step 0x2B3A630
DailyGun xoay theo tuần, WeekEpoch = 2024-01-01, 3 món/tuần, giới hạn [2,2,1] lượt ProductCount 3 WeekStart_return 0x2B25A38, WeekIndex_return 0x2B25F34, GunNum_return 0x2B26080
PlusPack 7 ngày kể từ lúc mua, 3 loại độc lập Duration_Days 7 Is_Active 0x2B2B5EC, Remain_Duration 0x2B2BEE8
PlusPack Auto — quyền bật công tắc hợp-nhất-tự-động hai cổng ĐỘC LẬP: mốc tiến trình Stage_Value_Max > 189 cho nút hiện, gói Auto còn hạn cho nút bật được; hết hạn thì cờ is_AutoMerge bị dọn lười ở lần *_UI_Reload kế tiếp, không polling đo trên máy 2026-09-07 (F008 T8) Pet_manager.AutoMerge_UI_Reload 0x2B17A90, WeeklyShop.Is_VIP_Unlocked 0x2B3EA58; docs/functions/cong-tac-hop-nhat-tu-dong.md
Core daily/weekly mốc LastDaily…/LastWeekly… – Is_Core_Special_Available 0x2B24F9C
DiaPig tick 60 s, bù tối đa 3 tick khi quay lại TICK_SECONDS 60, CatchUpMaxPending 3 LIVEOPS_SURFACE §6
Trip — vé Ticket_Max = 20, nạp 1 vé / 4 giờ (Ticket_Charge_Interval_Hours = 4) dump.cs:591608+ Ticket_Refresh
DailyCheck mốc ngày, chuỗi 7 ngày lặp SyncWaitTimeout 4; bảng dia 70/200/400/600/800/1000/1500 — đã dựng lại hoàn chỉnh, xem §7.6 (G378) LIVEOPS_SURFACE §6; G378
UpgradeRebirth 1 lượt ad + 1 lượt IAP mỗi ngày, reset bằng chuỗi ngày UpgradeRebirth_DailyLimit 1, ..._IAP_DailyLimit 1 UpgradeRebirth_DayCheck 0x2B23260, Able_return 0x2B232B0, IAP_Able_return 0x2B2339C
Interstitial cap ngày theo place + cap toàn cục, reset bằng PlayerPrefs["inter_place_date"] ServerConfig 0x2A5C7F0

7.6 Điểm danh 7 ngày (DailyCheck) — hệ retention, đọc trọn từ binary (G378)

Mục tiêu học R9: đây là vòng lặp quay lại mỗi ngày của game — thứ nuôi số ngày hoạt động (retention) mà mọi offer khác đứng trên. 13 method đã dựng lại (OnSynced 0x2A6B470, OnCheckSettled 0x2A6B588, RefreshOrShow 0x2A6B4DC, is_DateChange 0x2A6B90C, is_GetAble 0x2A6BC64, CheckStatus_return 0x2A6B9F0, UI_reload 0x2A6B58C, NewUser_Reload 0x2A6B828, Open_Btn 0x2A6B720, Test_Btn 0x2A6B678, Get_Reward 0x2A6BC7C, DailyReward_Get 0x2A6BA9C, Show_WhenReady MoveNext 0x2A6BD08).

Claim Mức bằng chứng Nguồn / provenance
Bảng thưởng DiaReward_list (scene) = 70 / 200 / 400 / 600 / 800 / 1000 / 1500 dia cho ngày 1–7 — tổng 4 570 dia một chu kỳ, tăng dần với mốc nhảy ở ngày 7 scene-YAML G378
Điều kiện sang ngày mới = kiểm đồng hồ tin cậy TRƯỚC, rồi mới so ngày: is_DateChange = CanRolloverDaily("daily", maxPending 1) && LastCheck_Date.Date < Today — không có giờ tin cậy thì không có "ngày mới", bất kể đồng hồ máy disasm-proven is_DateChange 0x2A6B90C; G378
Chuỗi 7 ngày LẶP VÔ HẠN: CheckNum == 7 && đổi ngày ⇒ CheckNum = 0 — không có "tháng", không reset về 0 khi bỏ lỡ ngày (chỉ chờ ngày đổi) disasm-proven G378
Người dùng mới được lùi ngày một hôm (NewUser_Reload) ⇒ ngày 1 nhận được ngay lần mở đầu tiên — phần thưởng đầu tiên không bắt chờ disasm-proven NewUser_Reload 0x2A6B828; G378
Nhận thưởng kiểm lại điều kiện (is_GetAble) và từ chối kèm sfx Dont nếu không còn đủ; hợp lệ ⇒ Time_manager.RecordGain("daily") ghi sổ cái TRƯỚC khi cấp dia; rồi CheckNum++ và TotalCheckNum++ (cặp), LastCheck_Date = Today, Dia_Attraction.Dia_Spawn (dia bay về ví), sfx Purchase disasm-proven Get_Reward 0x2A6BC7C, DailyReward_Get 0x2A6BA9C; G378
Mở panel ép kiểm lại giờ (Open_Btn → Time_Hacker_ForceCheck) — người chơi tự kích hoạt lần kiểm chống chỉnh giờ mỗi khi vào xem điểm danh disasm-proven Open_Btn 0x2A6B720; G378
Nút lùi ngày debug (Test_Btn) gate bằng Debug.isDebugBuild ⇒ tắt trong bản phát hành (dùng được trên bản học vì debuggable) disasm-proven Test_Btn 0x2A6B678; G378
Show_WhenReady: yield null → WaitForCheck(4 s) → Try_Show (chờ đồng bộ giờ trước khi hiện panel ngày mới) disasm-proven MoveNext 0x2A6BD08; G359, G378
TotalCheckNum (đếm suốt đời) tách khỏi CheckNum (vị trí trong chu kỳ) — số liệu retention và trạng thái UI không dùng chung một biến disasm-proven G378

Bốn quyết định đáng chép: (1) thưởng đầu tiên ngay lập tức (lùi ngày người mới) — chi phí bằng 0, gỡ rào cản đầu tiên; (2) chuỗi lặp, không phạt — bỏ lỡ một ngày không mất tiến độ, chỉ chờ; (3) đồng hồ tin cậy là điều kiện tiên quyết, không phải kiểm tra sau; (4) ghi sổ trước khi cấp — mọi thưởng theo thời gian đi qua cùng một sổ cái (RecordGain, doc 07 §2.6) nên có thể đối chiếu ngược.

8. Mô hình hành vi — phễu kiếm tiền

 TUTORIAL (lớp 1)
 ────────────────
 save mới ──> chỉ SpeedRun_Btn + Setting_Btn ──> Tutorial_manager mở dần Shop/Gun/Skin/Mission/
              DailyCheck/Auto/Eco/DiaPig/Buff ──> CHỈ TỪ ĐÂY cửa hàng mới tồn tại với người chơi

 TIẾN TRÌNH (lớp 2)            ĐIỀU KIỆN LỘ DIỆN (lớp 3)        OFFER                PHẦN THƯỞNG          TÁC ĐỘNG KINH TẾ
 ─────────                    ─────────────────                ─────                ───────────          ────────────────
 Stage_Value_Max  ──┬──> stageDisplay = SVM/10 + 1
                    │
                    ├──> >= inter_stage_min ────────────> interstitial ───────────> (không thưởng) ────> 0  (chỉ doanh thu)
                    │      && cooldown ok && cap ngày ok
                    │      && !boosted && !justWatchedRewarded
                    │
                    ├──> >= offerwall_stage_min ───────> OfferWall (Tapjoy) ──────> Dia ───────────────> mua shop / gacha
                    │      && Tapjoy connected
                    │
                    ├──> [freecash_min, freecash_max] ─> FreeCash link ───────────> RewardAdRemoved ───> MỌI rewarded
                    │      && Almedia ∈ {Eligible, Linked}                                                tự trao ⇒ buff/bonus
                    │      (seg 1: Stage 15→35, CÓ TRẦN TRÊN)                                             không còn ma sát
                    ├──> > 189 ─────────────────────────> WeeklyShop / PlusPack banner
                    └──> > 988 ─────────────────────────> catgunner_rebirth_single

 HÀNH VI                                                                                             CHUỖI THU NHẬP (doc 01)
 ───────                                                                                             ───────────────────────
 ghép súng lần đầu ─────> ad-gun #14, 30 phút ──> rewarded ──> giữ súng ────────────────────────────> Damage ↑ ⇒ stage nhanh hơn
 bấm nút Buff ──────────> rewarded ────────────> +300 s (trần 900) ─────────────────────────────────> ×3 Money hoặc ×3 Power
                                                                                                       (InvalidateMoneyCache)
 xong Mine/Boss/Hunt ───> rewarded "Bonus" ────> nhân thưởng màn ───────────────────────────────────> Money / PetCoin / Dia
 nhấn Rebirth ──────────> popup "xem QC để ×3" ─> is_UpgradeRebirth ────────────────────────────────> Ruby ×3 (doc 09 §4)
                          (1 lượt/ngày)
 IAP                                                                                                  
 ───
 buff_pack ─────────────> IsPermanentBuff ─────────────────────────────────────────────────────────> ×3 Money & Power VĨNH VIỄN
 remove_all_ads ────────> Is_Remove_AD_All ────────────────────────────────────────────────────────> tắt inter+banner,
                                                                                                       rewarded tự trao,
                                                                                                       DiaPig tick +1
 autoupgrade_pack ──────> Is_AutoUpgrade_Pack + 500 dia ───────────────────────────────────────────> tự mua upgrade
 dia_* / core_* ────────> Dia / Core ──────────────────────────────────────────────────────────────> gacha, relic, adventure
 rebirth_single ────────> +1 lượt Rebirth ×3/ngày (stage >= 100) ──────────────────────────────────> Ruby ×3 lần thứ hai

OfferWall (Stage 10 →, vĩnh viễn) · Interstitial (Stage 12 →, chỉ Rebirth 6/ngày + Mine 2/ngày) · FreeCash (Stage 15 → 35, rồi tự ẩn). Challenge/Boss/Hunt bị trần-0 chặn tuyệt đối.

Năm quan sát về hình dạng của phễu này:

  1. Interstitial là lớp duy nhất không trao gì cho người chơi — nên nó cũng là lớp bị gate nặng nhất: 7 điều kiện, cap theo từng place, cap toàn cục, cooldown, và 2 lần "miễn" (vừa xem rewarded, vừa được boost).
  2. Mọi thứ IAA trao đều là thứ IAP bán ở dạng vĩnh viễn. Không có phần thưởng quảng cáo nào không có bản IAP tương ứng. Quảng cáo là bản dùng thử của cửa hàng.
  3. FreeCash là một "IAP giá 0đ": nó cấp đúng cái mà remove_all_ads cấp cho tầng rewarded (Is_RewardBonus_Auto gộp cả hai vào một vị ngữ, 0x2B2D510). Người chơi trả bằng dữ liệu liên kết tài khoản thay vì tiền.
  4. Cửa sổ lộ diện có cả trần trên (freecash_stage_max_hud). Sau stage 35 (segment 1) HUD FreeCash tự biến mất — đo sống: 349 ⇒ hiện, 350 ⇒ ẩn. Offer chuyển đổi kém ở late-game thì bị thu hồi, không để rác trên màn hình.
  5. Ngân sách interstitial được dồn vào đúng 2 khoảnh khắc "vừa được thưởng lớn" (Rebirth, Mine), và bị tắt hẳn ở 3 chế độ còn lại bằng cách đặt trần/ngày = 0 — chứ không phải bằng một cờ bật/tắt riêng. Một con số 0 trong config làm việc của một feature flag.

9. Framework takeaway — tầng placement/offer/segment tổng quát

9.1 Bốn khái niệm, tách bạch

// 1) PLACEMENT — "ở đâu, khi nào có thể hiện"
interface IPlacement {
    string   Id { get; }                 // "inter.rebirth", "rewarded.money_buff"
    bool     CanShow(IGateContext ctx);  // chuỗi gate, fail-closed
    void     Show(Action onClosed);      // KHÔNG BAO GIỜ chặn: not-able -> onClosed() ngay
}

// 2) GATE — mỗi gate là một vị ngữ thuần, xếp theo thứ tự RẺ→ĐẮT, log lý do từ chối
interface IGate { bool Pass(IGateContext ctx, out string reason); }
// BuildFlagGate  -> hằng build (is_AD_Disable_Version)
// EntitlementGate-> người chơi đã mua quyền không thấy (remove_ads)
// ReentrancyGate -> đang hiện / đang chạy
// ProgressGate   -> progressMetric >= remote.minThreshold  (và <= maxThreshold nếu có trần)
// CooldownGate   -> now - lastShownUtc >= remote.cooldownSec
// PerPlaceCapGate-> counter[place] < remote.dailyCap[place]
// GlobalCapGate  -> counter[global] < remote.dailyCapWhole
// CourtesyGate   -> !justRewarded && !justPaid          <-- luật "không phạt người vừa trả"

// 3) OFFER — "bán cái gì, khi nào bật, hết hạn khi nào"
interface IOffer {
    bool IsActive(DateTime now, IPlayerState s);   // lịch + tiến trình + hành vi
    IReadOnlyList<Reward> Preview { get; }         // để UI vẽ trước khi mua
}

// 4) SEGMENT — công tắc tổng, do server cấp, có bản nhúng fail-safe
interface IRemoteAdConfig {                        // 15 số, KHÔNG có số kinh tế
    int  Segment { get; } string RuleVersion { get; }
    int  MinStage(string placementId); int  MaxStage(string placementId);
    int  CooldownSec { get; } int DailyCap(string placementId); int DailyCapGlobal { get; }
    bool FeatureOn(string featureId);
}

9.2 Bảy quy tắc rút ra từ SOT (mỗi quy tắc là một quyết định thật trong libil2cpp.so)

  1. Mặc định nhúng phải là "tắt", không phải "bật". EmbeddedDefaults(segment != 1) = 99999/0. Server chết ⇒ người chơi thấy ít quảng cáo hơn, không phải nhiều hơn. (0x2AC7B20)
  2. Remote config chỉ được chứa số PLACEMENT, không chứa số KINH TẾ. 15/15 field của ConfigResp là ad-placement — đã kiểm chứng bằng payload production thật. Cân bằng nằm trong build ⇒ mọi người chơi cùng một game. (PROBE_RESULT.md §4)
  3. Bộ đếm quảng cáo tách khỏi save game. PlayerPrefs cho inter_place_cnt_*, ow_pending_gem, fc_link_place; SaveData cho tiến trình. Reset save không mở lại quota quảng cáo, và ngược lại.
  4. Không bao giờ chặn luồng game vì quảng cáo. ShowPlacementInterstitial gọi onClosed() ngay khi gate fail; PlacementInterCo có timeout từng lớp (interLoadingSeconds, interLoadMaxAttempts, interLoadWaitPerAttempt, LOAD_HARD_RESET_SEC, adLoadTimeout).
  5. Entitlement chảy ngược vào tầng quảng cáo qua MỘT vị ngữ. Is_RewardBonus_Auto gộp "đã mua" và "đã liên kết FreeCash" — thêm nguồn miễn quảng cáo thứ ba chỉ cần sửa một hàm.
  6. Điểm trao thưởng duy nhất, idempotent, hoãn được. Cả rewarded (Reward_Get) và IAP (TryApplyPurchase) đều hội tụ về một hàm; nếu hệ thống đích chưa sẵn sàng thì không finalize và thử lại lần chạy sau.
  7. Lịch sự với người vừa trả. !_bonusAdWatched, !Is_RewardBonus_Auto, !boosted, !reviewOpened — bốn lần "miễn" nằm rải trong code, tất cả cùng một tinh thần.
  8. Xếp CHỒNG cổng, đừng gộp. Ba lớp độc lập — tutorial (onboarding), thang stage (nội dung), segment (kiếm tiền) — mỗi lớp một chủ sở hữu, một vòng đời, một cách đổi. Người chơi mới không
  9. Tắt một placement bằng SỐ, không bằng cờ. inter_daily_cnt_challenge = 0 tắt hẳn placement Challenge mà không cần thêm một trường boolean nào. Một trục số duy nhất (0 … 99999) vừa là công tắc, vừa là van tiết lưu — và cùng một dòng code (cnt >= limit) phục vụ cả hai.
  10. Bề mặt kém hiệu quả phải TỰ THU HỒI. freecash_stage_max_hud cho offer một hạn dùng theo tiến trình. Rất ít game làm điều này; hầu hết chỉ biết bật.
  11. Cho không trước, gắn quảng cáo sau — trên CÙNG một bộ đếm. Fish_Gacha: quota 5/ngày với 3 lượt đầu không quảng cáo (§4.1b). Hai hằng số (max, noAd) đọc một biến duy nhất, nên tỉ lệ cho-không/gắn-quảng-cáo là một tham số chỉnh được, không phải một nhánh code.
  12. Chỉ báo quảng cáo hiển thị theo DẢI, không theo ngưỡng. (uint)(used − free) < adSlots. Ngưỡng để lại một biểu tượng chết sau khi hết quota — nút hứa quảng cáo, trả về lỗi.

9.3 Ranh giới fail-closed cho bản restore (R1)

// Assets/Scripts/Services/IAdService.cs
public enum AdResult { Failed, Dismissed, Rewarded }
public interface IAdService {
    bool IsRewardedReady { get; }
    void ShowRewarded(string placementId, Action<AdResult> done);
    void ShowInterstitial(string placementId, Action done);
    void ShowBanner(); void HideBanner();
}
public sealed class NullAdService : IAdService {           // BINDING MẶC ĐỊNH
    public bool IsRewardedReady => false;
    public void ShowRewarded(string p, Action<AdResult> d) { Debug.Log($"[NullAd] {p}");
                                                             d?.Invoke(AdResult.Failed); }   // KHÔNG Rewarded
    public void ShowInterstitial(string p, Action d) => d?.Invoke();  // no-op, không chặn
    public void ShowBanner() { } public void HideBanner() { }
}
public interface IPurchaseService {
    bool IsOwned(string productId);                        // luôn false ở Null
    void Purchase(string productId, string place, Action<bool> done);   // luôn done(false)
}

Với IRemoteConfigService, bản Null phải trả đúng profile "tắt" — không phải null, không phải throw: MinStage(*) = 99999, DailyCap(*) = 0, FeatureOn(*) = false. Đó chính là (EmbeddedDefaults(1) trả đúng profile bật) — nên cả hai nhánh đều là hành vi đã kiểm chứng.

Bất biến bắt buộc: NullAdService không bao giờ trả AdResult.Rewarded; NullPurchaseService.IsOwned không bao giờ trả true; NullRemoteConfig không bao giờ trả giá trị bật quảng cáo — nó trả bản nhúng "tắt" (99999/0), đúng như SOT làm cho segment ≠ 1. Nhờ đó bản restore chạy được toàn bộ luồng UI monetization mà không cấp một entitlement nào.


10. UNKNOWN / bằng chứng phủ định — và probe phải bắt gì để đóng

Điều chưa biết Trạng thái Bắt gì để đóng (probe / device slot)
Thực thi cooldown / trần LÚC HIỆN quảng cáo UNKNOWN — lý do chính xác: ADX không bao giờ có fill trên bản sideload (_sdkReady = 1, _interLoading = 1, _interShowing = 0, IsInterstitialReady = 0 suốt phiên) ⇒ không quảng cáo nào được hiện, Inter_CountUp 0x2A5D644 chưa bao giờ chạy, ShowRewardAd 0x2A59294 / ShowPlacementInterstitial 0x2A5D508 được hook nhưng không bị gọi Cần một môi trường có fill quảng cáo thật (bản có bundle_id được mediation nhận). Sau đó: đóng panel Mine 3 lần liên tiếp và đọc PlayerPrefs["inter_place_cnt_1"] + ["inter_place_cnt_whole"] + _lastInterShownUtc sau mỗi lần; lặp qua nửa đêm để kiểm Inter_DayCheck
Lịch "Ngày mở" của map Trip nằm ở đâu trong code ✅ ĐÓNG (G1829, 2026-09-12) — không cần tìm literal: Adventure_manager.is_OpenDay_return(Map_Type, DayOfWeek) (0x2ADC890) là static 2 đối số, gọi thẳng trên .sot cho cả bảng: Forest CN·T2·T5 · Desert CN·T3·T6 · Tundra CN·T4·T7 (mask 19/37/73). Bản dựng lại khớp 21/21 ô. ~~UNKNOWN~~ — chỉ quan sát được trên UI (Forest T2/T5/CN · Desert T3/T6/CN · Glacier T4/T7/CN); không tìm thấy literal lịch trong stringliteral.json Decompile Adventure_Map_item (dump.cs:592454) / Adventure_Map_info_item (:592400) / Adventure_Panel (:592475); tìm DateTime.DayOfWeek
Tutorial_manager mở nút nào ở bước nào ✅ ĐÓNG (G375, G385): cổng vào = !_cleared && Lv(Power) < 1; BeginTutorial tắt bộ 32 DisableOnTutorial (= hàng 27 nút HUD); máy trạng thái Upgrade → Challenge → GunOpen → GunBuy → GunEquip → Done với coroutine kiểm điều kiện (MoneyCheckLoop đọc Lv(Power) mỗi 0.2 s, CheckSpeedRunClear, CheckGunSlotActive/Purchased/Equipped); CompleteTutorial bật lại cả hàng. Bước GunBuy nạp dia theo kịch bản (Dia ≤ 99 ⇒ Get_Dia(100 − Dia)) để lần mua đầu chắc chắn mua được — không phải đường kiếm tiền doc 07 §2.8
Chuỗi UI ↔ chỉ mục Currency_Core (ngôi sao tím = Core_Type.ALL index 3 = "PrismCore"?) HYPOTHESIS — mạnh thêm sau G1864: 7 nút Prism_Pack đều là IAP, và 7 SKU catgunner_core_* đều nạp vào ô ALL (index 3) ⇒ "PrismCore" nhiều khả năng đúng là ô đó. — 4 giá trị enum ↔ 4 chip trên panel, banner mở PrismCore_Purchase_popup
Prism_Pack ×7 trong scene (3 bản (Limited)) — là IAP hay mua bằng Core? ✅ ĐÓNG — LÀ IAP (G1864, 2026-09-13). Kết luận cũ ở ô này SAI, giữ lại để thấy vì sao. Đo trực tiếp trên binary GỐC: bật PrismCore_Purchase_popup bằng sot_click_oracle --force-on rồi bấm từng nút — cả 7 nút đều mở Canvas/Shop_manager/Loading_UI (Inapp Purchasing), đúng hình của mọi nút IAP khác trong game. Cái sai của lập luận cũ: nó suy từ TÊN (không có literal prism*) ra BẢN CHẤT; mà tên nút trong scene không phải product id — id sống trong code, và panel tham chiếu bằng enum, không bằng chuỗi (0 kết quả khi grep mọi SKU trong Main.unity). Ánh xạ ĐÃ ĐO (G1865) — đọc Core_Purchase_Panel.type lúc chạy: (2)→core_100 · (3)→core_500 · (4)→core_3300 · (5)→core_1050 · (Limited)→core_daily_750 · (Limited)(1)→core_weekly_1750 · (Limited)(2)→core_weekly_6000. Lưu ý: KHÔNG theo thứ tự tên nút — (4) và (5) đảo nhau, nên phép đoán theo thứ tự sẽ sai hai ô. ĐÃ ĐÓNG
Cửa sổ hiện thật của StepUp / WeeklyShop / DailyGun / PlusPack THU HẸP 2026-09-07 (F008 T8 / F014 T3, đo trên Pixel bản .learning): WeeklyShop + công tắc tiện lợi ĐÃ ĐO — Stage_Value_Max = 100 thì nút vào panel và công tắc Auto đều không hiện, = 1000 thì hiện và tab VIP hết ổ khoá; và chu kỳ 7 ngày của PlusPack đo được bằng cách đặt mốc mua now − 7 ngày + 5 phút để gói hết hạn giữa phiên. Còn UNKNOWN: StepUp và DailyGun Đừng dịch đồng hồ hệ thống — Time_manager có sàn đơn điệu _floor + phát hiện nhảy giờ nên lùi giờ hay bị kẹp; đẩy mốc thời gian vào SAVE (tools/patch_save.py) rẻ và sạch hơn
~~PlusPack.Is_Banner_Stage_OK (0x2B2C6AC) so sánh chính xác cái gì~~ ĐÃ ĐÓNG 2026-09-07 (G1689, F014 T4): thân của nó giống TỪNG LỆNH với WeeklyShop.Is_VIP_Unlocked (0x2B3EA58) — Tree_manager.instance != null && Tree_manager.instance.Stage_Value_Max > 189. Mốc banner gói và mốc mở khu VIP là MỘT (Stage 20-1); hằng Banner_Stage_Min = 190 không được binary đọc, nó so > 189 trực tiếp disasm-proven — cách gỡ: so hai bản decompile của một hàm ĐÃ BIẾT có cùng hình, rẻ hơn sửa biên hàm
StepUp.Gun_Rewards = [23,28,33,39,44] ánh xạ sang khẩu súng nào ✅ ĐÓNG (derived, doc 11 §3.1): #23 유령 저격총 (S, LASER) · #28 미사일 SS (SS, MISSILE) · #33 미사일 SSS (SSS, MISSILE) · #39 폭발레이저 X (X, BLAST_SNIPER) · #44 폭발레이저 XX (XX, BLAST_SNIPER) — 5 bậc S → XX. Giá trị blob tái xác nhận bằng SHA-256 (G353) Gun_list scene 20307980; TABLE.md hàng 19
catgunner_luxury_pass vs catgunner_luxury_upgrade khác nhau ở đâu ✅ ĐÓNG: khác ở GIÁ. Thân hàm trao giống hệt (0x2B32AA4 ≡ 0x2B328F8) nhưng store bán 394.000đ vs 210.000đ ⇒ _upgrade là bản nâng cấp rẻ hơn cho người đã có VIP. Điều kiện hiển thị của từng panel vẫn UNKNOWN iap_prices_store.json; Purchase_Luxury 0x2B2FAC4 / Purchase_Luxury_Upgrade 0x2B2FBA4
vip_pass vs vip_pass_2 ✅ ĐÓNG một nửa: vip_pass = 210.000đ trên store; vip_pass_2 không được store trả giá ⇒ không phải sản phẩm Play ở bản này. Vì sao code vẫn giữ nó (khôi phục cho người đã mua?) vẫn UNKNOWN iap_prices_store.json; TryApplyPurchase 0x2B32204
Chỉ số offer 31-35 Bằng chứng phủ định: không product id nào trả về –
Nhánh ShowRewardAd(8) trong UpgradeRebirth_Start_By_IAP Không thể đạt tới trong thực tế: hàm đặt is_UpgradeRebirth = is_UpgradeRebirth_IAP = 1 (word 257) rồi mới kiểm !is_UpgradeRebirth \|\| is_UpgradeRebirth_IAP ⇒ luôn đi nhánh Rebirth_FX.Start_Internal Hook 0x2b23668 dưới segment 1 để xác nhận 0 lần fire
Nhánh interstitial trong Monster_manager.SweepBonus_Get Không thể đạt tới: đặt cờ +194 = 1 rồi kiểm !cờ như trên
Admob.ShowRewardAd(int AdIndex) (0x2A59C04) — nút nào gọi UNKNOWN: 0 code-xref ⇒ nối từ scene bằng UnityEvent grep m_MethodName: ShowRewardAd trong Main.unity
Súng quảng cáo weapon_02 có nhánh nào trong cụm bắn của Cat không ✅ ĐÓNG — KHÔNG (G363): 12 RVA của cụm bắn đã quét, không có weapon_02 / AD_Type; súng thưởng chỉ chạm Gun_manager (§4.6) ⇒ không gì trong đường bắn route qua IAdService —
Cơ chế kích hoạt Season reset và backend Redeem code UNKNOWN (kế thừa LIVEOPS_SURFACE §7) probe openpanel + bắt network
Nội dung /[endpoint] cho segment 1 và 2 thật trên production UNKNOWN — chỉ mới thấy segment 0 POST thử với test flag, hoặc Cheat_OnSegmentChanged (0x2AC96D0) rồi dump CachedConfigJson
Shop_manager.DiaRewards / Dia_FirstBuy_Used trên save mới ✅ ĐÓNG (G350): .cctor 0x2B36F9C cấp Dia_FirstBuy_Used = new bool[5] và DiaRewards (blob SHA MATCH); Is_Dia_FirstBuy = 3 phép thử của binary (index < 0, Length <= index, !Used[index]), không null-guard TABLE.md hàng 17
Không quan sát thấy Gun_manager.Balance_Reload / Request_Sheet: 0 lần fire qua 3 phiên ⇒ bảng súng không remote-driven PROBE_RESULT.md §3 (G144 đã đóng)
Không quan sát thấy PlayFab.GetTitleData: 0 caller ⇒ TitleData không phải kênh liveops FABLE_ANALYSIS §U4

11. Raw evidence

  • [liveops catalog: PROBE_RESULT.md] — payload ServerConfig production thật (segment 0), sweep ngưỡng, đóng G144
  • [liveops catalog: 01_shop.png], 02_shop_scrolled.png — giá + phần thưởng shop thật (VND)
  • [liveops catalog: phaseA_capture.json] — 9 419 node canvas, trạng thái active của mọi nút liveops
  • [liveops catalog: sweep.json] — bảng sweep ngưỡng 0→1000
  • [liveops catalog: PROBE_RESULT.md] — thang bật/tắt IAA dưới segment 1 (§1.3), gate kép FreeCash (§1.4), biên quan sát "no fill" (§1.5), panel late-game (§2), lớp cổng tutorial (§6)
  • [liveops catalog: probe2d_capture.json] — bảng đo MaxStageDisplay → OfferWall/FreeCash/interstitial
  • [liveops catalog: iap_prices_store.json] — 33 giá VND thật dump read-only từ Localized_Price_return trên bản store chính hãng (dict_count 33, resolved 33)
  • [liveops catalog: 31_panel_Rebirth.png] — "Super Rebirth! ×3" + nút quảng cáo 1/1
  • [liveops catalog: 31_panel_Trip.png] — 4 tiền tệ, banner Mua → PrismCore, lịch map theo thứ
  • [liveops catalog: 30_all_unlocked_hud.png] — HUD sau khi mở đủ 7 feature
  • [liveops catalog: LIVEOPS_SURFACE.md] — 15 key ConfigResp, EmbeddedDefaults, Firebase RC, endpoint
  • [liveops catalog: UNLOCK_GATES.md] — thang Stage_Value_Max và công thức stageDisplay
  • [phân tích nội bộ] §U2, §B-6, §B-7 — cờ boost-kind, buff 300/900, ảnh hưởng segment
  • [evidence codegen: Rebirth.c] — Rebirth() 0x2B22AE0 (đuôi: review ↔ interstitial)
  • [evidence codegen: PRESTIGE_RULES.md] — boost ×3, daily limit, kind 1/2/3
  • dump.cs — Admob :585348, AD_Type :584959, Inter_Place :584976, FreeCash_manager :586919, Review_manager :588967, Buff_manager :592886, OfferWall_manager :599641, DailyGun :599310, PlusPack :599810, Shop_manager :600302, InApp_* enum :600059-600102, Mission_manager.is_Vip/is_Luxury :587346
  • stringliteral.json — product id, place, khoá PlayerPrefs, log ADX
  • [native: global-metadata.decrypted.dat] — blob khởi tạo mảng static (DiaRewards, StepUp.Gun_Rewards/Dia_Rewards, DailyGun.MaxCounts/PetCoin_Rewards/GunNum_Base)
  • [export AssetRipper: Main.unity:20647245-20647268] — giá trị Admob serialize trong scene
  • [restore project: Services] — ranh giới R1 fail-closed của bản restore

12. NGUỒN MỚI (2026-09-13): IAPProductCatalog — bảng khai báo SKU CÓ KIỂU

Độ tin cậy: CAO (đọc thẳng từ asset trong gói SOT, 4 bản sao trùng nhau từng byte).

Tài liệu này trước đây suy kiểu consumable/non-consumable từ hình dạng hàm Apply*. Có một nguồn thẩm quyền hơn mà bản cũ không nhắc tới:

[SOT: IAPProductCatalog.txt] — catalog Unity IAP codeless, 8.264 byte, sha256 7f76ad2e14b640eb…, trùng byte với 3 bản sao khác trong repo. Nó khai 32 product (không phải 34 như số mã trong code), mỗi mục có trường type rõ ràng.

Hai điều nó nói mà không nguồn nào khác nói được:

  1. vip_pass và vip_pass_2 khai là type: 0 = CONSUMABLE, không phải non-consumable như §6.1 đang ghi. Bằng chứng hỗ trợ phía mã: ApplyVipPass (0x2B32C50) không có cổng "đã sở hữu" — mua lại là cộng thêm 500 dia, khác hẳn khuôn của 6 gói non-consumable; và Is_Purchased(InApp_Type) chỉ phủ 6 cờ, không phủ VIP. ⇒ Mâu thuẫn CHƯA giải quyết giữa §6.1 và catalog — giữ cả hai trên bàn, đừng chép một phía vào thiết kế game mới.
  2. Hai SKU Luxury (catgunner_luxury_pass, catgunner_luxury_upgrade) KHÔNG có trong catalog 32 mục, nhưng store vẫn trả giá cho chúng (394.000₫ / 210.000₫). ⇒ chúng được đăng ký ở đường khác; đường đó chưa chứng minh được vì Shop_manager.InitializeIAP là state machine rỗng ở bản dựng lại (R1-out).

Ba điều cần đính chính khi đọc tài liệu cũ:

  • googlePrice.num = 0.0 và applePriceTier = 0 cho cả 32 mục ⇒ không giá nào nằm trong build. Mọi con số VND trong §6.1 đến từ đo store thật, không từ gói.
  • title/description để rỗng cả 32 mục ⇒ chữ hiển thị đến từ Play Console + I2.Loc.
  • Trường entries_len: 37 trong dump không phải "37 SKU" — đó là dung lượng mảng nội bộ của Dictionary. Ai đọc file thô rất dễ đếm nhầm thành 37 sản phẩm.

Cho người làm game idle mới — đây là phần đáng chép lại:

Quyết định của bản gốc Số thật Vì sao đáng để ý
Gói tiền cứng chia 5 bậc dia_0..4 = 240 / 600 / 1.500 / 3.500 / 8.000 dia tỉ lệ ~2,5× mỗi bậc, bậc cuối ~33× bậc đầu
Khuyến mãi lần đầu nhân đôi bằng dịch trái 1 bit, cờ Dia_FirstBuy_Used[5], có mùa (DiaFirstBuy_Season_Current = 1) "×2 lần đầu" tái sử dụng được theo mùa, không phải một lần đời
Gói tiện ích non-consumable 6 cờ: Remove_AD_forced, Remove_AD_All, AutoUpgrade_Pack (+500 dia), Buff_Pack (+1.000), Dia_Pack (+16.000), Mine_Pack (+500) gói tiện ích vẫn kèm tiền — trừ hai gói xoá quảng cáo, chúng cho 0 dia
Bậc thang StepUp 5 bậc, bắt buộc mua tuần tự, mỗi bậc một lần, hạn 7 ngày; súng {23,28,33,39,44}, dia {0,200,400,600,1000} bậc đầu 0 dia — mồi rẻ, giá trị dồn về cuối
Gói theo lịch DailyGun trần {2,2,1}/tuần lịch (epoch 2024-01-01); Core daily 24 h, weekly 7 ngày tính từ lần mua trước trần theo lịch và trần theo khoảng cách mua là hai cơ chế khác nhau, game này dùng cả hai
PlusPack 3 loại độc lập, 7 ngày; Mult_Monster=2, Mult_Boss=2, Mult_Mine=1.5; bản Auto rút Trip 16 giờ nhân số nhỏ (1,5–2×) nhưng cộng dồn theo chế độ
Rebirth bán lẻ catgunner_rebirth_single, trần 1 lượt/ngày, mở bán từ Stage_Value_Max > 989 bán một lần bấm nút, không bán tài nguyên
Chống lặp giao dịch ProcessedTxnIdsMax = 50 hàng đợi id đã xử lý, không phải cờ đơn
Tiền tệ hiển thị 23 định dạng, 6 loại không phần thập phân (KRW, JPY, VND, IDR, CLP, HUF) phát hành đa thị trường thì đây là bảng phải có sẵn từ ngày đầu

Nguồn từng dòng: IAPProductCatalog.txt; [reconstructed: Shop_manager],StepUp,DailyGun,PlusPack,Rebirth_manager,DiaPig,Mission_manager}.cs (mỗi hằng có chú thích RVA tại chỗ); dump.cs,stringliteral.json}; giá VND: [liveops catalog: iap_prices_store.json].