import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:pointycastle/export.dart'; import '../config/title_server_config.dart'; import '../models/user_preview.dart'; class TitleApiException implements Exception { final String message; const TitleApiException(this.message); @override String toString() => 'TitleApiException: $message'; } class UserLoginResult { final String token; final int loginId; final String lastLoginDate; const UserLoginResult({ required this.token, required this.loginId, required this.lastLoginDate, }); } class TitleApiService { static const String _obfuscateConstant = 'MaimaiChn'; static String? lastRawResponse; final TitleServerConfig _config; TitleApiService(this._config); Uint8List _aesEncrypt(Uint8List plaintext) { final cipher = PaddedBlockCipherImpl( PKCS7Padding(), CBCBlockCipher(AESEngine()), )..init( true, PaddedBlockCipherParameters( ParametersWithIV( KeyParameter(Uint8List.fromList(_config.aesKeyBytes)), Uint8List.fromList(_config.aesIvBytes), ), null, ), ); return cipher.process(plaintext); } Uint8List _aesDecrypt(Uint8List ciphertext) { final cipher = PaddedBlockCipherImpl( PKCS7Padding(), CBCBlockCipher(AESEngine()), )..init( false, PaddedBlockCipherParameters( ParametersWithIV( KeyParameter(Uint8List.fromList(_config.aesKeyBytes)), Uint8List.fromList(_config.aesIvBytes), ), null, ), ); return cipher.process(ciphertext); } Uint8List _compress(List data) { return Uint8List.fromList(ZLibEncoder().encode(data)); } Uint8List _decompress(List data) { return Uint8List.fromList(ZLibDecoder().decodeBytes(data)); } Uint8List _buildRequestBody(Map packet) { final jsonStr = jsonEncode(packet); final jsonBytes = utf8.encode(jsonStr); final compressed = _compress(jsonBytes); return _aesEncrypt(compressed); } Map _processResponseBody(Uint8List bodyBytes) { final decrypted = _aesDecrypt(bodyBytes); final decompressed = _decompress(decrypted); final jsonStr = utf8.decode(decompressed); return jsonDecode(jsonStr) as Map; } String _buildHash(String apiName) { final raw = apiName + _obfuscateConstant + _config.obfuscateParam; return md5.convert(utf8.encode(raw)).toString(); } Future> _callApi( String apiName, Map packet, int userId, ) async { final hash = _buildHash(apiName); final body = _buildRequestBody(packet); final baseUrl = _normalizeUrl(_config.titleServerUrl); final url = Uri.parse('$baseUrl/$hash'); final response = await http .post( url, headers: { 'User-Agent': '$hash#$userId', 'Content-Type': 'application/json', 'Mai-Encoding': _config.apiVersion, 'Accept-Encoding': '', 'Charset': 'UTF-8', 'Content-Encoding': 'deflate', 'Host': 'maimai-gm.wahlap.com:42081', }, body: body, ) .timeout(const Duration(seconds: 15)); if (response.statusCode != 200) { throw TitleApiException('$apiName returned ${response.statusCode}'); } final json = _processResponseBody(response.bodyBytes); final raw = const JsonEncoder.withIndent(' ').convert(json); // ignore: avoid_print print('[$apiName] === RESPONSE ==='); // ignore: avoid_print print(raw); return json; } static int calcRandom() { final rand = _RandomHelper(); final max = 1037933; final num2 = (rand.nextInt(max - 1) + 1) * 2069 + 1024; var num3 = 0; var n = num2; for (var i = 0; i < 32; i++) { num3 <<= 1; num3 += n % 2; n >>= 1; } return num3; } String _normalizeUrl(String url) { var normalized = url.trim(); if (normalized.endsWith('/')) { normalized = normalized.substring(0, normalized.length - 1); } return normalized; } Future userLogin({ required int userId, required String token, }) async { const apiName = 'UserLoginApi'; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final hash = _buildHash(apiName); final packet = { 'userId': userId, 'accessCode': '', 'regionId': _config.regionId, 'placeId': _config.placeId, 'clientId': _config.clientId, 'dateTime': now - 600, 'loginDateTime': now, 'isContinue': false, 'genericFlag': 0, 'token': token, }; // ignore: avoid_print print('[userLogin] packet=${jsonEncode(packet)}'); final body = _buildRequestBody(packet); final baseUrl = _normalizeUrl(_config.titleServerUrl); final url = Uri.parse('$baseUrl/$hash'); // ignore: avoid_print print('[userLogin] POST $url'); final response = await http .post( url, headers: { 'User-Agent': '$hash#$userId', 'Content-Type': 'application/json', 'Mai-Encoding': _config.apiVersion, 'Accept-Encoding': '', 'Charset': 'UTF-8', 'Content-Encoding': 'deflate', 'Host': 'maimai-gm.wahlap.com:42081', }, body: body, ) .timeout(const Duration(seconds: 15)); // ignore: avoid_print print('[userLogin] HTTP status=${response.statusCode}'); if (response.statusCode != 200) { throw TitleApiException( 'UserLoginApi returned ${response.statusCode}', ); } final json = _processResponseBody(response.bodyBytes); final raw = const JsonEncoder.withIndent(' ').convert(json); // ignore: avoid_print print('[userLogin] === RESPONSE ==='); // ignore: avoid_print print(raw); final errorId = json['errorId'] as int? ?? -1; if (errorId != 0) { throw TitleApiException('UserLoginApi errorId=$errorId'); } final newToken = json['token'] as String?; if (newToken == null || newToken.isEmpty) { throw TitleApiException('UserLoginApi returned no token'); } return newToken; } Future getUserPreview({ required int userId, required String token, }) async { // ignore: avoid_print print('[getUserPreview] ===== START ====='); // ignore: avoid_print print('[getUserPreview] userId=$userId'); // ignore: avoid_print print('[getUserPreview] token=$token'); // ignore: avoid_print print('[getUserPreview] clientId=${_config.clientId}'); // ignore: avoid_print print('[getUserPreview] titleServerUrl=${_config.titleServerUrl}'); // ignore: avoid_print print('[getUserPreview] aesKey.len=${_config.aesKeyBytes.length} aesIv.len=${_config.aesIvBytes.length}'); const apiName = 'GetUserPreviewApi'; final hash = _buildHash(apiName); // ignore: avoid_print print('[getUserPreview] hash=$hash'); final packet = { 'userId': userId, 'segaIdAuthKey': '', 'token': token, 'clientId': _config.clientId, }; // ignore: avoid_print print('[getUserPreview] packet=${jsonEncode(packet)}'); // ignore: avoid_print print('[getUserPreview] building request body (compress + encrypt)...'); final body = _buildRequestBody(packet); // ignore: avoid_print print('[getUserPreview] body size=${body.length} bytes'); final baseUrl = _normalizeUrl(_config.titleServerUrl); final url = Uri.parse('$baseUrl/$hash'); // ignore: avoid_print print('[getUserPreview] POST $url'); try { final response = await http .post( url, headers: { 'User-Agent': '$hash#$userId', 'Content-Type': 'application/json', 'Mai-Encoding': _config.apiVersion, 'Accept-Encoding': '', 'Charset': 'UTF-8', 'Content-Encoding': 'deflate', "Host": "maimai-gm.wahlap.com:42081" }, body: body, ) .timeout(const Duration(seconds: 15)); // ignore: avoid_print print('[getUserPreview] HTTP status=${response.statusCode}'); // ignore: avoid_print print('[getUserPreview] response body size=${response.bodyBytes.length} bytes'); if (response.statusCode != 200) { // ignore: avoid_print print('[getUserPreview] non-200 response body: ${utf8.decode(response.bodyBytes.take(500).toList())}'); throw TitleApiException( 'Server returned ${response.statusCode}', ); } // ignore: avoid_print print('[getUserPreview] decrypting + decompressing...'); final json = _processResponseBody(response.bodyBytes); final raw = const JsonEncoder.withIndent(' ').convert(json); lastRawResponse = raw; // ignore: avoid_print print('[getUserPreview] === RESPONSE ==='); // ignore: avoid_print print(raw); // ignore: avoid_print print('[getUserPreview] ===== END ====='); return UserPreviewDataBean.fromJson(json); } catch (e) { // ignore: avoid_print print('[getUserPreview] ERROR: $e'); rethrow; } } // ---- Generic data APIs ---- Future> getUserData(int userId) async { return _callApi('GetUserDataApi', {'userId': userId}, userId); } Future> getUserExtend(int userId) async { return _callApi('GetUserExtendApi', {'userId': userId}, userId); } Future> getUserOption(int userId) async { return _callApi('GetUserOptionApi', {'userId': userId}, userId); } Future> getUserRating(int userId) async { return _callApi('GetUserRatingApi', {'userId': userId}, userId); } Future> getUserCharge(int userId) async { return _callApi('GetUserChargeApi', {'userId': userId}, userId); } Future> getUserActivity(int userId) async { return _callApi('GetUserActivityApi', {'userId': userId}, userId); } Future> getUserMissionData(int userId) async { return _callApi('GetUserMissionDataApi', {'userId': userId}, userId); } // ---- UserLogin (returns full result) ---- Future userLoginFull({ required int userId, required String token, }) async { const apiName = 'UserLoginApi'; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final packet = { 'userId': userId, 'accessCode': '', 'regionId': _config.regionId, 'placeId': _config.placeId, 'clientId': _config.clientId, 'dateTime': now - 600, 'loginDateTime': now, 'isContinue': false, 'genericFlag': 0, 'token': token, }; final json = await _callApi(apiName, packet, userId); final errorId = json['errorId'] as int? ?? -1; if (errorId != 0) { throw TitleApiException('UserLoginApi errorId=$errorId'); } final loginId = (json['loginId'] as num?)?.toInt() ?? 0; final lastLoginDate = json['lastLoginDate'] as String? ?? ''; final newToken = json['token'] as String? ?? ''; return UserLoginResult( token: newToken, loginId: loginId, lastLoginDate: lastLoginDate, ); } // ---- UserLogout ---- Future userLogout({ required int userId, required int loginDateTime, }) async { const apiName = 'UserLogoutApi'; final packet = { 'userId': userId, 'accessCode': '', 'regionId': _config.regionId, 'placeId': _config.placeId, 'clientId': _config.clientId, 'loginDateTime': loginDateTime, 'type': 1, }; final json = await _callApi(apiName, packet, userId); final errorId = json['errorId'] as int? ?? -1; if (errorId != 0) { throw TitleApiException('UserLogoutApi errorId=$errorId'); } } // ---- UploadUserPlaylog ---- Future uploadUserPlaylog({ required int userId, required int loginId, required Map musicData, required Map userData, }) async { const apiName = 'UploadUserPlaylogListApi'; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final packet = { 'userId': userId, 'userPlaylogList': [ _buildPlaylogEntry(userId, loginId, now, musicData, userData), ], }; final json = await _callApi(apiName, packet, userId); final errorId = json['errorId'] as int? ?? -1; if (errorId != 0) { throw TitleApiException('UploadUserPlaylogListApi errorId=$errorId'); } } Map _buildPlaylogEntry( int userId, int loginId, int timestamp, Map musicData, Map userData, ) { final user = userData['userData'] as Map? ?? {}; final charaSlot = (user['charaSlot'] as List?) ?.map((e) => (e as num).toInt()) .toList() ?? [0, 0, 0, 0, 0]; return { 'userId': userId, 'orderId': 0, 'playlogId': loginId, 'version': 1053000, 'placeId': _config.placeId, 'placeName': '', 'loginDate': timestamp, 'playDate': _formatDate(DateTime.now()), 'userPlayDate': _formatDateTime(DateTime.now()), 'type': 0, 'musicId': musicData['musicId'], 'level': musicData['level'], 'trackNo': 1, 'vsMode': 0, 'vsUserName': '', 'vsStatus': 0, 'vsUserRating': 0, 'vsUserAchievement': 0, 'vsUserGradeRank': 0, 'vsRank': 0, 'playerNum': 1, 'playedUserId1': 0, 'playedUserName1': '', 'playedMusicLevel1': 0, 'playedUserId2': 0, 'playedUserName2': '', 'playedMusicLevel2': 0, 'playedUserId3': 0, 'playedUserName3': '', 'playedMusicLevel3': 0, 'characterId1': charaSlot.isNotEmpty ? charaSlot[0] : 0, 'characterLevel1': 1, 'characterAwakening1': 0, 'characterId2': charaSlot.length > 1 ? charaSlot[1] : 0, 'characterLevel2': 1, 'characterAwakening2': 0, 'characterId3': charaSlot.length > 2 ? charaSlot[2] : 0, 'characterLevel3': 1, 'characterAwakening3': 0, 'characterId4': charaSlot.length > 3 ? charaSlot[3] : 0, 'characterLevel4': 1, 'characterAwakening4': 0, 'characterId5': charaSlot.length > 4 ? charaSlot[4] : 0, 'characterLevel5': 1, 'characterAwakening5': 0, 'achievement': musicData['achievement'], 'deluxscore': musicData['deluxscoreMax'], 'scoreRank': musicData['scoreRank'], 'maxCombo': 0, 'totalCombo': 128, 'maxSync': 0, 'totalSync': 0, 'tapCriticalPerfect': 101, 'tapPerfect': 0, 'tapGreat': 0, 'tapGood': 0, 'tapMiss': 0, 'holdCriticalPerfect': 9, 'holdPerfect': 0, 'holdGreat': 0, 'holdGood': 0, 'holdMiss': 0, 'slideCriticalPerfect': 4, 'slidePerfect': 0, 'slideGreat': 0, 'slideGood': 0, 'slideMiss': 0, 'touchCriticalPerfect': 0, 'touchPerfect': 0, 'touchGreat': 0, 'touchGood': 0, 'touchMiss': 0, 'breakCriticalPerfect': 1, 'breakPerfect': 0, 'breakGreat': 0, 'breakGood': 0, 'breakMiss': 0, 'isTap': true, 'isHold': true, 'isSlide': true, 'isTouch': false, 'isBreak': true, 'isCriticalDisp': true, 'isFastLateDisp': true, 'fastCount': 0, 'lateCount': 0, 'isAchieveNewRecord': false, 'isDeluxscoreNewRecord': false, 'comboStatus': musicData['comboStatus'], 'syncStatus': musicData['syncStatus'], 'isClear': true, 'beforeRating': user['playerRating'] ?? 0, 'afterRating': user['playerRating'] ?? 0, 'beforeGrade': 0, 'afterGrade': 0, 'afterGradeRank': 0, 'beforeDeluxRating': user['playerRating'] ?? 0, 'afterDeluxRating': user['playerRating'] ?? 0, 'isPlayTutorial': false, 'isEventMode': false, 'isFreedomMode': false, 'playMode': 0, 'isNewFree': false, 'trialPlayAchievement': -1, 'extNum1': musicData['extNum1'] ?? 0, 'extNum2': 0, 'extNum4': 101, 'extBool1': false, 'extBool2': false, }; } // ---- UpsertUserAll ---- Future upsertUserAll({ required int userId, required int loginId, required String loginDate, required Map musicData, required List> generalUserInfo, }) async { const apiName = 'UpsertUserAllApi'; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final userData = generalUserInfo[0]; final userExtend = generalUserInfo[1]; final userOption = generalUserInfo[2]; final userRating = generalUserInfo[3]; final userChargeList = generalUserInfo[4]; final userActivity = generalUserInfo[5]; final userMissionData = generalUserInfo[6]; final user = userData['userData'] as Map? ?? {}; final packet = { 'userId': userId, 'playlogId': loginId, 'isEventMode': false, 'isFreePlay': false, 'loginDateTime': now, 'userPlaylogList': [ _buildPlaylogEntry(userId, loginId, now, musicData, userData), ], 'upsertUserAll': { 'userData': [ { 'accessCode': '', 'userName': user['userName'] ?? '', 'isNetMember': 1, 'point': user['point'] ?? 0, 'totalPoint': user['totalPoint'] ?? 0, 'iconId': user['iconId'] ?? 0, 'plateId': user['plateId'] ?? 0, 'titleId': user['titleId'] ?? 0, 'partnerId': user['partnerId'] ?? 0, 'frameId': user['frameId'] ?? 0, 'selectMapId': user['selectMapId'] ?? 0, 'totalAwake': user['totalAwake'] ?? 0, 'gradeRating': user['gradeRating'] ?? 0, 'musicRating': user['musicRating'] ?? 0, 'playerRating': user['playerRating'] ?? 0, 'highestRating': user['highestRating'] ?? 0, 'gradeRank': user['gradeRank'] ?? 0, 'classRank': user['classRank'] ?? 0, 'courseRank': user['courseRank'] ?? 0, 'charaSlot': user['charaSlot'] ?? [0, 0, 0, 0, 0], 'charaLockSlot': user['charaLockSlot'] ?? [0, 0, 0, 0, 0], 'contentBit': user['contentBit'] ?? 0, 'playCount': ((user['playCount'] as num?)?.toInt() ?? 0) + 1, 'currentPlayCount': ((user['currentPlayCount'] as num?)?.toInt() ?? 0) + 1, 'renameCredit': user['renameCredit'] ?? 0, 'mapStock': user['mapStock'] ?? 0, 'eventWatchedDate': user['eventWatchedDate'] ?? '', 'lastGameId': 'SDGB', 'lastRomVersion': user['lastRomVersion'] ?? '', 'lastDataVersion': user['lastDataVersion'] ?? '', 'lastLoginDate': loginDate, 'lastPlayDate': _formatDateTime(DateTime.now()), 'lastPlayCredit': 1, 'lastPlayMode': 0, 'lastPlaceId': _config.placeId, 'lastPlaceName': '', 'lastAllNetId': 0, 'lastRegionId': _config.regionId, 'lastRegionName': '', 'lastClientId': _config.clientId, 'lastCountryCode': 'CHN', 'lastSelectEMoney': user['lastSelectEMoney'] ?? 0, 'lastSelectTicket': user['lastSelectTicket'] ?? 0, 'lastSelectCourse': user['lastSelectCourse'] ?? 0, 'lastCountCourse': user['lastCountCourse'] ?? 0, 'firstGameId': user['firstGameId'] ?? '', 'firstRomVersion': user['firstRomVersion'] ?? '', 'firstDataVersion': user['firstDataVersion'] ?? '', 'firstPlayDate': user['firstPlayDate'] ?? '', 'compatibleCmVersion': user['compatibleCmVersion'] ?? '', 'dailyBonusDate': user['dailyBonusDate'] ?? '', 'dailyCourseBonusDate': user['dailyCourseBonusDate'] ?? '', 'lastPairLoginDate': user['lastPairLoginDate'] ?? '', 'lastTrialPlayDate': user['lastTrialPlayDate'] ?? '', 'playVsCount': user['playVsCount'] ?? 0, 'playSyncCount': user['playSyncCount'] ?? 0, 'winCount': user['winCount'] ?? 0, 'helpCount': user['helpCount'] ?? 0, 'comboCount': user['comboCount'] ?? 0, 'totalDeluxscore': user['totalDeluxscore'] ?? 0, 'totalBasicDeluxscore': user['totalBasicDeluxscore'] ?? 0, 'totalAdvancedDeluxscore': user['totalAdvancedDeluxscore'] ?? 0, 'totalExpertDeluxscore': user['totalExpertDeluxscore'] ?? 0, 'totalMasterDeluxscore': user['totalMasterDeluxscore'] ?? 0, 'totalReMasterDeluxscore': user['totalReMasterDeluxscore'] ?? 0, 'totalSync': user['totalSync'] ?? 0, 'totalBasicSync': user['totalBasicSync'] ?? 0, 'totalAdvancedSync': user['totalAdvancedSync'] ?? 0, 'totalExpertSync': user['totalExpertSync'] ?? 0, 'totalMasterSync': user['totalMasterSync'] ?? 0, 'totalReMasterSync': user['totalReMasterSync'] ?? 0, 'totalAchievement': user['totalAchievement'] ?? 0, 'totalBasicAchievement': user['totalBasicAchievement'] ?? 0, 'totalAdvancedAchievement': user['totalAdvancedAchievement'] ?? 0, 'totalExpertAchievement': user['totalExpertAchievement'] ?? 0, 'totalMasterAchievement': user['totalMasterAchievement'] ?? 0, 'totalReMasterAchievement': user['totalReMasterAchievement'] ?? 0, 'playerOldRating': user['playerOldRating'] ?? 0, 'playerNewRating': user['playerNewRating'] ?? 0, 'banState': userData['banState'] ?? 0, 'friendRegistSkip': user['friendRegistSkip'] ?? 0, 'dateTime': now, } ], 'userExtend': [userExtend['userExtend']], 'userOption': [userOption['userOption']], 'userCharacterList': [], 'userGhost': [], 'userMapList': [], 'userLoginBonusList': [], 'userRatingList': [userRating['userRating']], 'userItemList': [], 'userMusicDetailList': [musicData], 'userCourseList': [], 'userFriendSeasonRankingList': [], 'userChargeList': userChargeList['userChargeList'] ?? [], 'userFavoriteList': [ {'itemKind': 3, 'itemIdList': []}, {'itemKind': 1, 'itemIdList': []}, {'itemKind': 2, 'itemIdList': []}, {'itemKind': 10, 'itemIdList': []}, {'itemKind': 11, 'itemIdList': []}, ], 'userActivityList': [userActivity['userActivity']], 'userMissionDataList': _buildMissionDataList(userMissionData), 'userWeeklyData': { 'lastLoginWeek': userMissionData['userWeeklyData']?['lastLoginWeek'] ?? 0, 'beforeLoginWeek': userMissionData['userWeeklyData']?['beforeLoginWeek'] ?? 0, 'friendBonusFlag': userMissionData['userWeeklyData']?['friendBonusFlag'] ?? 0, }, 'userGamePlaylogList': [ { 'playlogId': loginId, 'version': user['lastRomVersion'] ?? 0, 'playDate': _formatDateTime(DateTime.now()), 'playMode': 0, 'useTicketId': -1, 'playCredit': 1, 'playTrack': 1, 'clientId': _config.clientId, 'isPlayTutorial': false, 'isEventMode': false, 'isNewFree': false, 'playCount': 0, 'playSpecial': calcRandom(), 'playOtherUserId': 0, } ], 'user2pPlaylog': { 'userId1': 0, 'userId2': 0, 'userName1': '', 'userName2': '', 'regionId': 0, 'placeId': 0, 'user2pPlaylogDetailList': [], }, 'userIntimateList': [], 'userShopItemStockList': [], 'userGetPointList': [], 'userTradeItemList': [], 'userFavoritemusicList': [], 'userKaleidxScopeList': [], 'isNewCharacterList': '', 'isNewMapList': '', 'isNewLoginBonusList': '', 'isNewItemList': '', 'isNewMusicDetailList': '0', 'isNewCourseList': '', 'isNewFavoriteList': '11111', 'isNewFriendSeasonRankingList': '', 'isNewUserIntimateList': '', 'isNewFavoritemusicList': '', 'isNewKaleidxScopeList': '', }, }; final json = await _callApi(apiName, packet, userId); final errorId = json['errorId'] as int? ?? -1; if (errorId != 0) { throw TitleApiException('UpsertUserAllApi errorId=$errorId'); } } List> _buildMissionDataList( Map userMissionData) { final list = userMissionData['userMissionDataList'] as List? ?? []; return list.take(6).map((item) { final m = item as Map; return { 'type': m['type'] ?? 0, 'difficulty': m['difficulty'] ?? 0, 'targetGenreId': m['targetGenreId'] ?? 0, 'targetGenreTableId': m['targetGenreTableId'] ?? 0, 'conditionGenreId': m['conditionGenreId'] ?? 0, 'conditionGenreTableId': m['conditionGenreTableId'] ?? 0, 'clearFlag': m['clearFlag'] ?? 0, }; }).toList(); } String _formatDate(DateTime dt) { return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; } String _formatDateTime(DateTime dt) { return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}.0'; } } class _RandomHelper { final _random = Random(); int nextInt(int max) => _random.nextInt(max); }