12-time-trust-daily-rollover.md · Cập nhật lần cuối: 2026-09-12 12:14 +07 · sha256 nguồn 0f4193842adf12 — Tin cậy thời gian & vòng quay ngày (cổng chung của mọi phần thưởng theo ngày)
Nguồn: chưng cất từ restore SOT
com.Chodun.CatGunner1.1.59 (BardCat). Mọi C# / hằng số ở đây là L1 ANALYSIS — đọc từ ARM64 / Hex-Rays củalibil2cpp.sotại RVA ghi kèm, và đã đối chiếu bằng quan sát runtime trên thiết bị (xem §6). Không phải source gốc. Liên quan: 07-progression-managers.md (bốn manager nghe sự kiện), 10-monetization-iaa-iap.md (phần thưởng theo ngày là mồi giữ chân). Tài liệu bản-restore tương ứng:docs/functions/lane-thoi-gian.md.
1. Vấn đề: idle bán thời gian, nên thời gian là thứ bị gian lận đầu tiên
Game idle đổi thời gian trôi lấy tài nguyên. Hệ quả là đồng hồ thiết bị trở thành mặt hàng: chỉnh đồng hồ tới tương lai là in tiền. Mọi game idle ship thật đều phải trả lời ba câu:
- Giờ nào đáng tin? (nguồn giờ)
- Khi chưa biết giờ đáng tin thì có được cấp thưởng không? (cổng)
- Nếu đã lỡ cấp dựa trên giờ sai thì làm gì? (đối soát sau)
CatGunner trả lời cả ba bằng một lớp Time_manager tĩnh, và — đây là điểm đáng chép lại — nó
đặt cả ba sau một cổng chung duy nhất.
2. Cổng chung: ba tầng, mỗi tầng một câu hỏi
// RVA 0x2AD110C
CanRolloverDaily(kind, maxPending = 3)
= SaveLoaded // đã nạp save chưa? (chưa nạp thì mọi so sánh ngày vô nghĩa)
&& CanGrantTimedReward // giờ có đáng tin chưa?
&& PendingCount(kind) < maxPending; // đã nợ người chơi bao nhiêu lượt chưa trả?
// RVA 0x2AD11E4
CanGrantTimedReward
= TrustLevel != Trust.None
&& (!AwaitingCheck || MonoSec - _awaitSinceMono >= AwaitTimeoutSec /* 20 s */);
Ba tầng này trả lời ba câu khác nhau và không thay thế được cho nhau:
SaveLoaded— chống so sánh ngày với dữ liệu chưa có.CanGrantTimedReward— chống cấp thưởng khi chưa biết giờ.PendingCount(kind) < maxPending— van chống dồn: nếu người chơi offline 10 ngày, game không trả một lúc 10 lượt; nó trả tối đamaxPendingrồi dừng.
Mẹo thiết kế ở tầng hai: khi đang chờ máy chủ (AwaitingCheck), cổng không đóng vĩnh viễn mà mở
ra sau 20 giây đo bằng đồng hồ đơn điệu. Máy chủ im lặng thì game vẫn chơi được, chỉ là chấp
nhận rủi ro có kiểm soát. Cổng đóng cứng sẽ biến một sự cố mạng thành một bug "không nhận được
thưởng" mà người chơi báo còn lập trình viên không tái hiện nổi.
3. Ba mức tin cậy, không phải hai
enum Trust { None = 0, Derived = 1, Synced = 2 }
Derived là mức ở giữa hay bị bỏ quên: "tôi chưa hỏi được máy chủ lần này, nhưng tôi có một độ
lệch đã lưu từ lần trước cộng với một đồng hồ đơn điệu, nên tôi suy ra được giờ". Có mức giữa thì
mới phân biệt được chưa biết gì với biết tương đối — và mới cho phép cấp một số thứ mà không
cấp những thứ khác.
4. Hai đồng hồ, và vì sao phải có cả hai
DateTime.UtcNow (hệ thống) |
Stopwatch đơn điệu (MonoSec) |
|
|---|---|---|
| Người chơi chỉnh được | Có | Không |
| Sống qua khi app bị giết | Có | Không (reset về 0) |
| Dùng để | biết hôm nay là ngày nào | đo đã trôi bao lâu |
Lớp này không bao giờ đo một khoảng thời gian bằng DateTime. Mọi timeout, mọi giãn cách, mọi
mốc chờ đều đóng bằng MonoSec. Lý do hiển nhiên khi nói ra: đo timeout bằng chính thứ mình đang
nghi ngờ là tự bịt mắt.
Và mẹo hay nhất của cả lane — OnAppResume (RVA 0x2AD28F4) suy ra giờ máy chủ bằng hai đường rồi
so chúng với nhau:
var theoOffset = DateTime.UtcNow.AddSeconds(SavedOffsetSec); // bám đồng hồ THIẾT BỊ
var theoAnchor = _anchorServerUtc.AddSeconds(MonoSec - _anchorMonoSec); // bám đồng hồ ĐƠN ĐIỆU
if (Math.Abs((theoOffset - theoAnchor).TotalSeconds) > 300.0)
BeginAwait(); // hai đường tách nhau ⇒ đồng hồ bị chỉnh lúc app ở nền ⇒ phải kiểm lại
Hai đường chỉ tách nhau khi có người chỉnh đồng hồ trong lúc app nằm nền — đúng khe hở mà một lớp chống gian lận chỉ kiểm-lúc-khởi-động sẽ bỏ lọt.
5. Sổ cái + đối soát sau: cho phép sai, rồi truy thu
Chống gian lận thời gian có hai trường phái. Trường phái chặn trước (không cấp gì cho tới khi chắc chắn) làm hỏng trải nghiệm offline — mà offline chính là sản phẩm của game idle. CatGunner đi trường phái cấp trước, đối soát sau:
RecordGain(kind)(RVA 0x2AD2CF4) — mỗi lần cấp, ghi một dòng vàoLedger:{kind, gameUtc, trust LÚC CẤP, sessionId, mono}. Ghi cả mức tin cậy tại thời điểm cấp là chi tiết quan trọng: nó cho phép phán xử sau này biết dòng đó đáng ngờ tới đâu. Sổ chặn ở 32 dòng, cũ nhất rơi trước.Reconcile(serverUtc, monoNow)(RVA 0x2AD1DB4) — chạy trong mỗi lần đồng bộ thành công, xử toàn bộ sổ và vét sạch nó. Mỗi dòng được so với "giờ máy chủ lẽ ra là bao nhiêu lúc cấp":
| Dòng thuộc | Mốc so | Khoan dung |
|---|---|---|
| phiên KHÁC (hoặc không ghi phiên) | serverUtc nguyên |
1800 s |
CÙNG phiên, lúc cấp Trust.Synced |
serverUtc − (monoNow − entry.mono) |
300 s |
CÙNG phiên, lúc cấp chưa Synced |
như trên | 1800 s |
Dòng vượt khoan dung thành Violation và bay ra sự kiện OnFutureGain(list, serverUtc).
Vì sao mục cùng phiên được siết chặt hơn: ta biết chính xác nó được cấp cách đây bao nhiêu giây (đồng hồ đơn điệu), nên dựng lại được mốc đúng. Mục phiên khác thì không biết, đành nới rộng.
Đây là chỗ dễ dựng sai nhất và cũng dễ thiết kế sai nhất: nếu lấy serverUtc nguyên làm mốc cho
cả mục cùng phiên, thì một người chơi thật thà chơi liên tục 40 phút sẽ bị đánh dấu vi phạm chỉ vì
phần thưởng anh ta nhận lúc đầu phiên "ở tương lai" so với mốc hiện tại.
6. Số đo thật (Pixel 8 Pro, bản học tập, 2026-09-12)
Lane này từng có 8 chỗ đăng ký nghe và 0 chỗ phát — bốn hệ thống (điểm danh, vé, nhiệm vụ ngày,
quay thưởng) cùng chết lặng, không báo lỗi, vì TrustLevel không bao giờ rời None. Sau khi dựng
đủ bên phát:
| Đo gì | Kết quả |
|---|---|
| Boot → có kết luận về giờ | ~2,5 s (nhờ ép kiểm ngay thay vì chờ hết chu kỳ 30 s) |
| Dòng log đồng bộ | synced offset=-2s violations=0 ledgerLeft=0 |
ledgerLeft = 0 |
sổ được vét sạch trong đúng một vòng đối soát |
| Vào nền rồi trở lại | sinh thêm một lần đồng bộ, dù chưa tới giãn cách 10 phút |
| Bốn hệ thống phía sau | cả bốn cấp được thưởng thật ngay sau đó |
Bài học vận hành, đắt hơn bài học kỹ thuật: bốn triệu chứng ở bốn màn hình khác nhau, theo dõi suốt nhiều phiên như bốn lỗi riêng biệt, hoá ra là một nguyên nhân — một cổng chung không bao giờ mở. Khi nhiều hệ thống "im lặng mà không lỗi", hãy đi tìm cổng chung của chúng trước khi sửa từng cái một. Công cụ đếm nợ kỹ thuật xếp việc theo class nào thiếu nhiều hàm nhất; nó không biết cái gì đang khoá cái gì.
7. Rút gọn cho framework: tối thiểu cần gì
Muốn bê mẫu này sang một game idle khác mà không chép cả lớp chống gian lận:
- Một nguồn giờ ngoài thiết bị. Không cần máy chủ riêng — header
Datecủa một phản hồi HTTPSHEADtới host công cộng bất kỳ là đủ, và xoay vòng vài host cho bền. (CatGunner dùng đúng cách này, danh sách 4 host.) - Một đồng hồ đơn điệu để đo mọi khoảng thời gian.
- Ba mức
Trustvà một cổng chung mà mọi phần thưởng theo ngày phải đi qua. - Van chống dồn (
PendingCount < max) và van chống treo (timeout mở cổng sau N giây). - Sổ cái nhỏ có chặn + một hàm đối soát chạy mỗi lần đồng bộ. Không cần phạt ngay: chỉ cần bắn ra một sự kiện, quyết định xử lý để tầng trên lo.
8. Hình phạt: bốn nét khiến nó không phạt oan (RVA 0x2A4D7CC)
Reconcile chỉ TÌM RA vi phạm; quyết định nằm ở handler của OnFutureGain. Bốn nét thiết kế ở đó
đáng chép lại nguyên vẹn, vì mỗi nét chặn một kiểu phạt oan cụ thể:
- Không phạt ở lần đầu. Bộ đếm
TimeStriketăng một; chỉ tới strike thứ hai mới gắn cờ. Một lần lệch giờ có thể là múi giờ, là đồng hồ máy chạy sai thật, là người chơi vừa bay qua châu lục. Gắn cờ ngay là phạt oan một nhóm người dùng thật. - Strike PHAI theo thời gian. Nếu lần vi phạm gần nhất cách hiện tại hơn 60 ngày, bộ đếm được đặt lại về 0 trước khi cộng. Không có nét này thì hai lần lệch cách nhau hai năm vẫn dồn thành "hacker" — một lỗi mà chỉ người chơi lâu năm nhất mới gặp, tức đúng nhóm không nên mất.
- Chỉ lấy vi phạm NẶNG NHẤT của lô, không lặp phạt từng dòng. Một lần chỉnh đồng hồ đẻ ra nhiều dòng sổ; phạt theo số dòng là nhân hình phạt lên theo số phần thưởng người chơi tình cờ đang treo — tức phạt nặng hơn đúng vào người chơi chăm.
- Nhật ký cộng dồn, không ghi đè.
TimeStrike_Lognối thêm<giờ máy chủ>|<kind>|+<số giờ>h|n=<số vi phạm>;mỗi lần, nên về sau còn đọc lại được chuỗi hành vi thay vì chỉ thấy lần cuối.
Cộng lại, đây là một hệ tích luỹ bằng chứng có hạn dùng, không phải một cái bẫy bật-tắt. Đó là điểm đáng bê sang game khác hơn cả bản thân thuật toán đối soát.
8b. Và hình phạt cuối cùng nhẹ đến bất ngờ (RVA 0x2A4CEA0)
FlagAsHacker là nơi cây cờ được cắm, và điều đáng chú ý nhất là những gì nó KHÔNG làm:
is_TimeHacker = true;
TimeHack_FlagDate = <giờ THIẾT BỊ lúc gắn cờ>;
TimeHack_ServerDate = <giờ MÁY CHỦ, rỗng nếu bên gọi không có>;
TimeHack_Reason = reason;
Rank_btn_Refresh(); // ← hình phạt DUY NHẤT
SaveSystem.Save();
Không thu tiền, không khoá game, không xoá save, không chặn đăng nhập. Người bị gắn cờ chỉ mất quyền vào bảng xếp hạng. Đó là một lựa chọn sản phẩm đáng bắt chước: bảng xếp hạng là thứ DUY NHẤT mà gian lận làm hỏng trải nghiệm của người khác; mọi thứ còn lại chỉ là người chơi tự phá game của chính mình, và phạt nặng ở đó chỉ đổi lấy tỉ lệ bỏ game cộng với những lần phạt oan.
Hai nét kỹ thuật đi kèm: - Cổng một-lần: đã gắn cờ thì hàm thoát ngay, nên lý do và mốc thời gian giữ nguyên của lần ĐẦU TIÊN. Ghi đè bằng lần sau là xoá mất bằng chứng đáng giá nhất. - Ghi CẢ HAI mốc (giờ thiết bị và giờ máy chủ). Chính khoảng cách giữa hai mốc mới là bằng chứng; giữ một mốc thì về sau không đọc lại được gì.
Đối lại, OnCheatDetected (bộ dò sửa giá trị trong bộ nhớ) gắn cờ ngay, không có strike. Phân
biệt này có lý: lệch giờ thì có cách giải thích lương thiện (múi giờ, đồng hồ máy sai, bay qua châu
lục), còn sửa bộ nhớ tiến trình thì không.
9. Người anh em của đồng hồ: bất biến VÍ (RVA 0x2A4FE0C)
Chống gian lận thời gian mới là nửa bài toán. Nửa kia là ví: kẻ sửa được đồng hồ thường cũng sửa được con số tiền trong bộ nhớ. CatGunner chống việc đó bằng một mẹo rẻ đến mức đáng chép — hai sổ phụ nhân ba:
Get_Dia(v) { Dia += v; _getDia += v * 3; ... } // 0x2A4FBBC
Lose_Dia(v) { Dia -= v; _useDia += v * 3; ... } // 0x2A4FA8C
// 0x2A4FE0C — cổng kiểm, gọi từ Rank_manager.IsRankingBlocked và Tree_manager.Game_Win
static bool is_ProgramDestroyed()
{
if (is_TimeHacker) return true; // đã bắt vặn đồng hồ thì khỏi xét ví
return Dia != _getDia / 3 - _useDia / 3; // bất biến ví
}
Vì sao nhân 3 chứ không giữ nguyên. Nếu hai sổ phụ ghi đúng giá trị thật thì kẻ dò bộ nhớ tìm
số 1302 sẽ thấy ba ô cùng biến thiên và sửa cả ba. Nhân 3 làm sổ phụ mang con số khác ô
chính (4806, 900), nên phép dò "tìm giá trị đang hiển thị" không bắt được chúng; phải hiểu
quan hệ mới sửa đúng. Chia nguyên ở chiều ngược lại không làm mất mát vì mọi phép ghi đều là
bội của 3.
Cấu trúc đáng học, tách khỏi game này: một bất biến kiểm được nối số dư hiển thị với lịch
sử cộng/trừ, cộng một hàm kiểm không gây hậu quả (is_ProgramDestroyed chỉ TRẢ LỜI), cộng
hình phạt đặt ở bên gọi. Ba mảnh tách rời nên thêm chỗ kiểm mới không phải sửa logic phạt, và
đổi mức phạt không phải đụng phép kiểm. Ở đây hình phạt cũng chính là hình phạt của cụm thời gian
(§8b): mất quyền vào bảng xếp hạng, không thu tiền, không khoá game.
Số đo (4/4 save thật, không dùng để suy ra công thức):
| Hồ sơ | Dia |
_getDia |
_useDia |
_getDia/3 − _useDia/3 |
|---|---|---|---|---|
store_pixel (vừa cài) |
290 | 1170 | 300 | 290 ✓ |
lane-thoi-gian-song |
1302 | 4806 | 900 | 1302 ✓ |
truoc-danh-muc-iap |
1302 | 4806 | 900 | 1302 ✓ |
sau-danh-muc-iap (sau 34 giao dịch IAP) |
52422 | 158166 | 900 | 52422 ✓ |
Nghiệm thu CẢ HAI NHÁNH trên máy (APK g1795, Pixel, is_TimeHacker = false cả ba lượt nên
kết quả đến thuần từ ví): save thật 1407 / 5121 / 900 ⇒ false; vá MỖI Dia = 99999 ⇒
true; bù _getDia = (99999 + 900/3) × 3 = 300897 ⇒ false trở lại. Một phép thử ba điểm
như vậy đáng làm cho mọi bất biến chống gian lận: chỉ đo nhánh "sạch" thì không phân biệt được
hàm ĐÚNG với hàm luôn trả false. (Bản dựng cũ ở đây đúng là return false — và nếu chỉ đo
nhánh sạch thì nó cũng "đạt".)
Cái bẫy mà chính dự án này đã sập (G1791): mọi công cụ vá save phải coi
Dia/_getDia/_useDialà một bộ ba. Hồ sơtestcủatools/save_profile.pytừng ép mỗiDia=99999⇒ bất biến vỡ ⇒ game âm thầm coi phiên đo là "bị can thiệp" và chặn bảng xếp hạng, không có một dấu hiệu nào trên màn hình. Bài học chung cho framework: hễ thêm một bất biến chống gian lận thì phải thêm ngay một đường ghi hợp lệ cho công cụ nội bộ, nếu không chính mình sẽ đo sai.