diff --git a/bemani/backend/iidx/bistrover.py b/bemani/backend/iidx/bistrover.py new file mode 100644 index 0000000..2c7a1bd --- /dev/null +++ b/bemani/backend/iidx/bistrover.py @@ -0,0 +1,15 @@ +# vim: set fileencoding=utf-8 +from typing import Optional + +from bemani.backend.iidx.base import IIDXBase +from bemani.backend.iidx.heroicverse import IIDXHeroicVerse +from bemani.common import VersionConstants + + +class IIDXBistrover(IIDXBase): + + name = 'Beatmania IIDX BISTROVER' + version = VersionConstants.IIDX_BISTROVER + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXHeroicVerse(self.data, self.config, self.model) diff --git a/bemani/backend/iidx/cannonballers.py b/bemani/backend/iidx/cannonballers.py index f7c0151..72d67bf 100644 --- a/bemani/backend/iidx/cannonballers.py +++ b/bemani/backend/iidx/cannonballers.py @@ -1,15 +1,2235 @@ # vim: set fileencoding=utf-8 -from typing import Optional +import copy +import random +import struct +from typing import Optional, Dict, Any, List, Tuple +from datetime import datetime +from discord_webhook import DiscordWebhook, DiscordEmbed from bemani.backend.iidx.base import IIDXBase +from bemani.backend.iidx.course import IIDXCourse from bemani.backend.iidx.sinobuz import IIDXSinobuz -from bemani.common import VersionConstants + +from bemani.common import ValidatedDict, VersionConstants, Time, ID, intish +from bemani.data import Data, UserID +from bemani.protocol import Node -class IIDXCannonBallers(IIDXBase): +class IIDXCannonBallers(IIDXCourse, IIDXBase): name = 'Beatmania IIDX CANNON BALLERS' version = VersionConstants.IIDX_CANNON_BALLERS + GAME_CLTYPE_SINGLE = 0 + GAME_CLTYPE_DOUBLE = 1 + + DAN_STAGES = 4 + + GAME_CLEAR_STATUS_NO_PLAY = 0 + GAME_CLEAR_STATUS_FAILED = 1 + GAME_CLEAR_STATUS_ASSIST_CLEAR = 2 + GAME_CLEAR_STATUS_EASY_CLEAR = 3 + GAME_CLEAR_STATUS_CLEAR = 4 + GAME_CLEAR_STATUS_HARD_CLEAR = 5 + GAME_CLEAR_STATUS_EX_HARD_CLEAR = 6 + GAME_CLEAR_STATUS_FULL_COMBO = 7 + + GAME_GHOST_TYPE_RIVAL = 1 + GAME_GHOST_TYPE_GLOBAL_TOP = 2 + GAME_GHOST_TYPE_GLOBAL_AVERAGE = 3 + GAME_GHOST_TYPE_LOCAL_TOP = 4 + GAME_GHOST_TYPE_LOCAL_AVERAGE = 5 + GAME_GHOST_TYPE_DAN_TOP = 6 + GAME_GHOST_TYPE_DAN_AVERAGE = 7 + GAME_GHOST_TYPE_RIVAL_TOP = 8 + GAME_GHOST_TYPE_RIVAL_AVERAGE = 9 + + GAME_GHOST_LENGTH = 64 + + GAME_SP_DAN_RANK_7_KYU = 0 + GAME_SP_DAN_RANK_6_KYU = 1 + GAME_SP_DAN_RANK_5_KYU = 2 + GAME_SP_DAN_RANK_4_KYU = 3 + GAME_SP_DAN_RANK_3_KYU = 4 + GAME_SP_DAN_RANK_2_KYU = 5 + GAME_SP_DAN_RANK_1_KYU = 6 + GAME_SP_DAN_RANK_1_DAN = 7 + GAME_SP_DAN_RANK_2_DAN = 8 + GAME_SP_DAN_RANK_3_DAN = 9 + GAME_SP_DAN_RANK_4_DAN = 10 + GAME_SP_DAN_RANK_5_DAN = 11 + GAME_SP_DAN_RANK_6_DAN = 12 + GAME_SP_DAN_RANK_7_DAN = 13 + GAME_SP_DAN_RANK_8_DAN = 14 + GAME_SP_DAN_RANK_9_DAN = 15 + GAME_SP_DAN_RANK_10_DAN = 16 + GAME_SP_DAN_RANK_CHUDEN = 17 + GAME_SP_DAN_RANK_KAIDEN = 18 + + GAME_DP_DAN_RANK_7_KYU = 0 + GAME_DP_DAN_RANK_6_KYU = 1 + GAME_DP_DAN_RANK_5_KYU = 2 + GAME_DP_DAN_RANK_4_KYU = 3 + GAME_DP_DAN_RANK_3_KYU = 4 + GAME_DP_DAN_RANK_2_KYU = 5 + GAME_DP_DAN_RANK_1_KYU = 6 + GAME_DP_DAN_RANK_1_DAN = 7 + GAME_DP_DAN_RANK_2_DAN = 8 + GAME_DP_DAN_RANK_3_DAN = 9 + GAME_DP_DAN_RANK_4_DAN = 10 + GAME_DP_DAN_RANK_5_DAN = 11 + GAME_DP_DAN_RANK_6_DAN = 12 + GAME_DP_DAN_RANK_7_DAN = 13 + GAME_DP_DAN_RANK_8_DAN = 14 + GAME_DP_DAN_RANK_9_DAN = 15 + GAME_DP_DAN_RANK_10_DAN = 16 + GAME_DP_DAN_RANK_CHUDEN = 17 + GAME_DP_DAN_RANK_KAIDEN = 18 + + FAVORITE_LIST_LENGTH = 20 + def previous_version(self) -> Optional[IIDXBase]: return IIDXSinobuz(self.data, self.config, self.model) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Insert dailies into the DB. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'daily_charts', 'daily'): + # Generate a new list of three dailies. + start_time, end_time = data.local.network.get_schedule_duration('daily') + all_songs = list(set([song.id for song in data.local.music.get_all_songs(cls.game, cls.version)])) + daily_songs = random.sample(all_songs, 3) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'dailies', + { + 'start_time': start_time, + 'end_time': end_time, + 'music': daily_songs, + }, + ) + events.append(( + 'iidx_daily_charts', + { + 'version': cls.version, + 'music': daily_songs, + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'daily_charts', 'daily') + return events + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Global Shop Ranking', + 'tip': 'Return network-wide ranking instead of shop ranking on results screen.', + 'category': 'game_config', + 'setting': 'global_shop_ranking', + }, + { + 'name': 'Events In Omnimix', + 'tip': 'Allow events to be enabled at all for Omnimix.', + 'category': 'game_config', + 'setting': 'omnimix_events_enabled', + }, + ], + 'ints': [ + { + 'name': 'Event Phase', + 'tip': 'Event phase for all players.', + 'category': 'game_config', + 'setting': 'event_phase', + 'values': { + 0: 'No Event', + 1: 'Gekisou! Cannon racer Continent 1', + 2: 'Gekisou! Cannon racer Continent 2', + 3: 'Gekisou! Cannon racer Continent 3', + 4: 'Gekisou! Cannon racer Continent 4', + } + }, + ], + } + + def db_to_game_status(self, db_status: int) -> int: + return { + self.CLEAR_STATUS_NO_PLAY: self.GAME_CLEAR_STATUS_NO_PLAY, + self.CLEAR_STATUS_FAILED: self.GAME_CLEAR_STATUS_FAILED, + self.CLEAR_STATUS_ASSIST_CLEAR: self.GAME_CLEAR_STATUS_ASSIST_CLEAR, + self.CLEAR_STATUS_EASY_CLEAR: self.GAME_CLEAR_STATUS_EASY_CLEAR, + self.CLEAR_STATUS_CLEAR: self.GAME_CLEAR_STATUS_CLEAR, + self.CLEAR_STATUS_HARD_CLEAR: self.GAME_CLEAR_STATUS_HARD_CLEAR, + self.CLEAR_STATUS_EX_HARD_CLEAR: self.GAME_CLEAR_STATUS_EX_HARD_CLEAR, + self.CLEAR_STATUS_FULL_COMBO: self.GAME_CLEAR_STATUS_FULL_COMBO, + }[db_status] + + def game_to_db_status(self, game_status: int) -> int: + return { + self.GAME_CLEAR_STATUS_NO_PLAY: self.CLEAR_STATUS_NO_PLAY, + self.GAME_CLEAR_STATUS_FAILED: self.CLEAR_STATUS_FAILED, + self.GAME_CLEAR_STATUS_ASSIST_CLEAR: self.CLEAR_STATUS_ASSIST_CLEAR, + self.GAME_CLEAR_STATUS_EASY_CLEAR: self.CLEAR_STATUS_EASY_CLEAR, + self.GAME_CLEAR_STATUS_CLEAR: self.CLEAR_STATUS_CLEAR, + self.GAME_CLEAR_STATUS_HARD_CLEAR: self.CLEAR_STATUS_HARD_CLEAR, + self.GAME_CLEAR_STATUS_EX_HARD_CLEAR: self.CLEAR_STATUS_EX_HARD_CLEAR, + self.GAME_CLEAR_STATUS_FULL_COMBO: self.CLEAR_STATUS_FULL_COMBO, + }[game_status] + + def db_to_game_rank(self, db_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if db_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.DAN_RANK_7_KYU: self.GAME_SP_DAN_RANK_7_KYU, + self.DAN_RANK_6_KYU: self.GAME_SP_DAN_RANK_6_KYU, + self.DAN_RANK_5_KYU: self.GAME_SP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_SP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_SP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_SP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_SP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_SP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_SP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_SP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_SP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_SP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_SP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_SP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_SP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_SP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_SP_DAN_RANK_10_DAN, + self.DAN_RANK_CHUDEN: self.GAME_SP_DAN_RANK_CHUDEN, + self.DAN_RANK_KAIDEN: self.GAME_SP_DAN_RANK_KAIDEN, + }[db_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.DAN_RANK_7_KYU: self.GAME_DP_DAN_RANK_7_KYU, + self.DAN_RANK_6_KYU: self.GAME_DP_DAN_RANK_6_KYU, + self.DAN_RANK_5_KYU: self.GAME_DP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_DP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_DP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_DP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_DP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_DP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_DP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_DP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_DP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_DP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_DP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_DP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_DP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_DP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_DP_DAN_RANK_10_DAN, + self.DAN_RANK_CHUDEN: self.GAME_DP_DAN_RANK_CHUDEN, + self.DAN_RANK_KAIDEN: self.GAME_DP_DAN_RANK_KAIDEN, + }[db_dan] + else: + raise Exception('Invalid cltype!') + + def game_to_db_rank(self, game_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if game_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.GAME_SP_DAN_RANK_7_KYU: self.DAN_RANK_7_KYU, + self.GAME_SP_DAN_RANK_6_KYU: self.DAN_RANK_6_KYU, + self.GAME_SP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_SP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_SP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_SP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_SP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_SP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_SP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_SP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_SP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_SP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_SP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_SP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_SP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_SP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_SP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_SP_DAN_RANK_CHUDEN: self.DAN_RANK_CHUDEN, + self.GAME_SP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.GAME_DP_DAN_RANK_7_KYU: self.DAN_RANK_7_KYU, + self.GAME_DP_DAN_RANK_6_KYU: self.DAN_RANK_6_KYU, + self.GAME_DP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_DP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_DP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_DP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_DP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_DP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_DP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_DP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_DP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_DP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_DP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_DP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_DP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_DP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_DP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_DP_DAN_RANK_CHUDEN: self.DAN_RANK_CHUDEN, + self.GAME_DP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + else: + raise Exception('Invalid cltype!') + + def handle_IIDX25shop_getname_request(self, request: Node) -> Node: + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine is not None: + machine_name = machine.name + close = machine.data.get_bool('close') + hour = machine.data.get_int('hour') + minute = machine.data.get_int('minute') + else: + machine_name = '' + close = False + hour = 0 + minute = 0 + + root = Node.void('IIDX25shop') + root.set_attribute('opname', machine_name) + root.set_attribute('pid', '51') + root.set_attribute('cls_opt', '1' if close else '0') + root.set_attribute('hr', str(hour)) + root.set_attribute('mi', str(minute)) + return root + + def handle_IIDX25shop_savename_request(self, request: Node) -> Node: + self.update_machine_name(request.attribute('opname')) + + shop_close = intish(request.attribute('cls_opt')) or 0 + minutes = intish(request.attribute('mnt')) or 0 + hours = intish(request.attribute('hr')) or 0 + + self.update_machine_data({ + 'close': shop_close != 0, + 'minutes': minutes, + 'hours': hours, + }) + + return Node.void('IIDX25shop') + + def handle_IIDX25shop_sentinfo_request(self, request: Node) -> Node: + return Node.void('IIDX25shop') + + def handle_IIDX25shop_sendescapepackageinfo_request(self, request: Node) -> Node: + root = Node.void('IIDX25shop') + root.set_attribute('expire', str((Time.now() + 86400 * 365) * 1000)) + return root + + def handle_IIDX25shop_getconvention_request(self, request: Node) -> Node: + root = Node.void('IIDX25shop') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + root.set_attribute('music_0', str(course.get_int('music_0', 20032))) + root.set_attribute('music_1', str(course.get_int('music_1', 20009))) + root.set_attribute('music_2', str(course.get_int('music_2', 20015))) + root.set_attribute('music_3', str(course.get_int('music_3', 20064))) + root.add_child(Node.bool('valid', course.get_bool('valid'))) + return root + + def handle_IIDX25shop_setconvention_request(self, request: Node) -> Node: + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = ValidatedDict() + course.replace_int('music_0', request.child_value('music_0')) + course.replace_int('music_1', request.child_value('music_1')) + course.replace_int('music_2', request.child_value('music_2')) + course.replace_int('music_3', request.child_value('music_3')) + course.replace_bool('valid', request.child_value('valid')) + self.data.local.machine.put_settings(machine.arcade, self.game, self.music_version, 'shop_course', course) + + return Node.void('IIDX25shop') + + def handle_IIDX25ranking_getranker_request(self, request: Node) -> Node: + root = Node.void('IIDX25ranking') + chart = int(request.attribute('clid')) + if chart not in [ + self.CHART_TYPE_N7, + self.CHART_TYPE_H7, + self.CHART_TYPE_A7, + self.CHART_TYPE_N14, + self.CHART_TYPE_H14, + self.CHART_TYPE_A14, + ]: + # Chart type 6 is presumably beginner mode, but it crashes the game + return root + + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + if not course.get_bool('valid'): + # Shop course not enabled or not present + return root + + convention = Node.void('convention') + root.add_child(convention) + convention.set_attribute('clid', str(chart)) + convention.set_attribute('update_date', str(Time.now() * 1000)) + + # Grab all scores for each of the four songs, filter out people who haven't + # set us as their arcade and then return the top 20 scores (adding all 4 songs). + songids = [ + course.get_int('music_0'), + course.get_int('music_1'), + course.get_int('music_2'), + course.get_int('music_3'), + ] + + totalscores: Dict[UserID, int] = {} + profiles: Dict[UserID, ValidatedDict] = {} + for songid in songids: + scores = self.data.local.music.get_all_scores( + self.game, + self.music_version, + songid=songid, + songchart=chart, + ) + + for score in scores: + if score[0] not in totalscores: + totalscores[score[0]] = 0 + profile = self.get_any_profile(score[0]) + if profile is None: + profile = ValidatedDict() + profiles[score[0]] = profile + + totalscores[score[0]] += score[1].points + + topscores = sorted( + [ + (totalscores[userid], profiles[userid]) + for userid in totalscores + if self.user_joined_arcade(machine, profiles[userid]) + ], + key=lambda tup: tup[0], + reverse=True, + )[:20] + + rank = 0 + for topscore in topscores: + rank = rank + 1 + + detail = Node.void('detail') + convention.add_child(detail) + detail.set_attribute('name', topscore[1].get_str('name')) + detail.set_attribute('rank', str(rank)) + detail.set_attribute('score', str(topscore[0])) + detail.set_attribute('pid', str(topscore[1].get_int('pid'))) + + qpro = topscore[1].get_dict('qpro') + detail.set_attribute('head', str(qpro.get_int('head'))) + detail.set_attribute('hair', str(qpro.get_int('hair'))) + detail.set_attribute('face', str(qpro.get_int('face'))) + detail.set_attribute('body', str(qpro.get_int('body'))) + detail.set_attribute('hand', str(qpro.get_int('hand'))) + + return root + + def handle_IIDX25ranking_entry_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + courseid = int(request.attribute('coid')) + chart = int(request.attribute('clid')) + course_type = int(request.attribute('regist_type')) + clear_status = self.game_to_db_status(int(request.attribute('clr'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + if course_type == 0: + index = self.COURSE_TYPE_INTERNET_RANKING + elif course_type == 1: + index = self.COURSE_TYPE_SECRET + else: + raise Exception('Unknown registration type for course entry!') + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + # Update achievement to track course statistics + self.update_course( + userid, + index, + courseid, + chart, + clear_status, + pgreats, + greats, + ) + + # We should return the user's position, but its not displayed anywhere + # so fuck it. + root = Node.void('IIDX25ranking') + root.set_attribute('anum', '1') + root.set_attribute('jun', '1') + return root + + def handle_IIDX25ranking_classicentry_request(self, request: Node) -> Node: + extid = int(request.attribute('iidx_id')) + courseid = int(request.attribute('course_id')) + coursestyle = int(request.attribute('play_style')) + clear_status = self.game_to_db_status(int(request.attribute('clear_flg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + # Update achievement to track course statistics + self.update_course( + userid, + self.COURSE_TYPE_CLASSIC, + courseid, + coursestyle, + clear_status, + pgreats, + greats, + ) + + return Node.void('IIDX25ranking') + + def handle_IIDX25music_crate_request(self, request: Node) -> Node: + root = Node.void('IIDX25music') + attempts = self.get_clear_rates() + + all_songs = list(set([song.id for song in self.data.local.music.get_all_songs(self.game, self.music_version)])) + for song in all_songs: + clears = [] + fcs = [] + + for chart in [0, 1, 2, 3, 4, 5]: + placed = False + if song in attempts and chart in attempts[song]: + values = attempts[song][chart] + if values['total'] > 0: + clears.append(int((1000 * values['clears']) / values['total'])) + fcs.append(int((1000 * values['fcs']) / values['total'])) + placed = True + if not placed: + clears.append(1001) + fcs.append(1001) + + clearnode = Node.s32_array('c', clears + fcs) + clearnode.set_attribute('mid', str(song)) + root.add_child(clearnode) + + return root + + def handle_IIDX25music_getrank_request(self, request: Node) -> Node: + cltype = int(request.attribute('cltype')) + + root = Node.void('IIDX25music') + style = Node.void('style') + root.add_child(style) + style.set_attribute('type', str(cltype)) + + for rivalid in [-1, 0, 1, 2, 3, 4]: + if rivalid == -1: + attr = 'iidxid' + else: + attr = f'iidxid{rivalid}' + + try: + extid = int(request.attribute(attr)) + except Exception: + # Invalid extid + continue + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.music_version, userid) + + # Grab score data for user/rival + scoredata = self.make_score_struct( + scores, + self.CLEAR_TYPE_SINGLE if cltype == self.GAME_CLTYPE_SINGLE else self.CLEAR_TYPE_DOUBLE, + rivalid, + ) + for s in scoredata: + root.add_child(Node.s16_array('m', s)) + + # Grab most played for user/rival + most_played = [ + play[0] for play in + self.data.local.music.get_most_played(self.game, self.music_version, userid, 20) + ] + if len(most_played) < 20: + most_played.extend([0] * (20 - len(most_played))) + best = Node.u16_array('best', most_played) + best.set_attribute('rno', str(rivalid)) + root.add_child(best) + + if rivalid == -1: + # Grab beginner statuses for user only + beginnerdata = self.make_beginner_struct(scores) + for b in beginnerdata: + root.add_child(Node.u16_array('b', b)) + + return root + + def handle_IIDX25music_appoint_request(self, request: Node) -> Node: + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + ghost_type = int(request.attribute('ctype')) + extid = int(request.attribute('iidxid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + root = Node.void('IIDX25music') + + if userid is not None: + # Try to look up previous ghost for user + my_score = self.data.remote.music.get_score(self.game, self.music_version, userid, musicid, chart) + if my_score is not None: + mydata = Node.binary('mydata', my_score.data.get_bytes('ghost')) + mydata.set_attribute('score', str(my_score.points)) + root.add_child(mydata) + + ghost_score = self.get_ghost( + { + self.GAME_GHOST_TYPE_RIVAL: self.GHOST_TYPE_RIVAL, + self.GAME_GHOST_TYPE_GLOBAL_TOP: self.GHOST_TYPE_GLOBAL_TOP, + self.GAME_GHOST_TYPE_GLOBAL_AVERAGE: self.GHOST_TYPE_GLOBAL_AVERAGE, + self.GAME_GHOST_TYPE_LOCAL_TOP: self.GHOST_TYPE_LOCAL_TOP, + self.GAME_GHOST_TYPE_LOCAL_AVERAGE: self.GHOST_TYPE_LOCAL_AVERAGE, + self.GAME_GHOST_TYPE_DAN_TOP: self.GHOST_TYPE_DAN_TOP, + self.GAME_GHOST_TYPE_DAN_AVERAGE: self.GHOST_TYPE_DAN_AVERAGE, + self.GAME_GHOST_TYPE_RIVAL_TOP: self.GHOST_TYPE_RIVAL_TOP, + self.GAME_GHOST_TYPE_RIVAL_AVERAGE: self.GHOST_TYPE_RIVAL_AVERAGE, + }.get(ghost_type, self.GHOST_TYPE_NONE), + request.attribute('subtype'), + self.GAME_GHOST_LENGTH, + musicid, + chart, + userid, + ) + + # Add ghost score if we support it + if ghost_score is not None: + sdata = Node.binary('sdata', ghost_score['ghost']) + sdata.set_attribute('score', str(ghost_score['score'])) + if 'name' in ghost_score: + sdata.set_attribute('name', ghost_score['name']) + if 'pid' in ghost_score: + sdata.set_attribute('pid', str(ghost_score['pid'])) + if 'extid' in ghost_score: + sdata.set_attribute('riidxid', str(ghost_score['extid'])) + root.add_child(sdata) + + return root + + def handle_IIDX25music_breg_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + self.update_score( + userid, + musicid, + self.CHART_TYPE_B7, + clear_status, + pgreats, + greats, + -1, + b'', + None, + ) + + # Return nothing. + return Node.void('IIDX25music') + + def handle_IIDX25music_reg_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + # See if we need to report global or shop scores + if self.machine_joined_arcade(): + game_config = self.get_game_config() + global_scores = game_config.get_bool('global_shop_ranking') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + else: + # If we aren't in an arcade, we can only show global scores + global_scores = True + machine = None + + # First, determine our current ranking before saving the new score + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + all_players = { + uid: prof for (uid, prof) in + self.get_any_profiles([s[0] for s in all_scores]) + } + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + oldindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + oldindex = i + break + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + miss_count = int(request.attribute('mnum')) + ghost = request.child_value('ghost') + shopid = ID.parse_machine_id(request.attribute('convid')) + + self.update_score( + userid, + musicid, + chart, + clear_status, + pgreats, + greats, + miss_count, + ghost, + shopid, + ) + + # Calculate and return statistics about this song + root = Node.void('IIDX25music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((1000 * clear) / count))) + root.set_attribute('frate', str(int((1000 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + root.set_attribute('rankside', '0') + + if userid is not None: + # Shop ranking + shopdata = Node.void('shopdata') + root.add_child(shopdata) + shopdata.set_attribute('rank', '-1' if oldindex is None else str(oldindex + 1)) + + # Grab the rank of some other players on this song + ranklist = Node.void('ranklist') + root.add_child(ranklist) + + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + missing_players = [ + uid for (uid, _) in all_scores + if uid not in all_players + ] + for (uid, prof) in self.get_any_profiles(missing_players): + all_players[uid] = prof + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + ourindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + ourindex = i + break + if ourindex is None: + raise Exception('Cannot find our own score after saving to DB!') + start = ourindex - 4 + end = ourindex + 4 + if start < 0: + start = 0 + if end >= len(all_scores): + end = len(all_scores) - 1 + relevant_scores = all_scores[start:(end + 1)] + + record_num = start + 1 + for score in relevant_scores: + profile = all_players[score[0]] + + data = Node.void('data') + ranklist.add_child(data) + data.set_attribute('iidx_id', str(profile.get_int('extid'))) + data.set_attribute('name', profile.get_str('name')) + + machine_name = '' + if 'shop_location' in profile: + shop_id = profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + machine_name = machine.name + data.set_attribute('opname', machine_name) + data.set_attribute('rnum', str(record_num)) + data.set_attribute('score', str(score[1].points)) + data.set_attribute('clflg', str(self.db_to_game_status(score[1].data.get_int('clear_status')))) + data.set_attribute('pid', str(profile.get_int('pid'))) + data.set_attribute('myFlg', '1' if score[0] == userid else '0') + data.set_attribute('update', '0') + + data.set_attribute('sgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE), + )) + data.set_attribute('dgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE), + )) + + qpro = profile.get_dict('qpro') + data.set_attribute('head', str(qpro.get_int('head'))) + data.set_attribute('hair', str(qpro.get_int('hair'))) + data.set_attribute('face', str(qpro.get_int('face'))) + data.set_attribute('body', str(qpro.get_int('body'))) + data.set_attribute('hand', str(qpro.get_int('hand'))) + + record_num = record_num + 1 + + return root + + def handle_IIDX25music_play_request(self, request: Node) -> Node: + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + + self.update_score( + None, # No userid since its anonymous + musicid, + chart, + clear_status, + 0, # No ex score + 0, # No ex score + 0, # No miss count + None, # No ghost + None, # No shop for this user + ) + + # Calculate and return statistics about this song + root = Node.void('IIDX25music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((1000 * clear) / count))) + root.set_attribute('frate', str(int((1000 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + + return root + + # Bare minimum response to say we handled the request + def handle_IIDX25music_beginnerplay_request(self, request: Node) -> Node: + return Node.void('IIDX25music') + + def handle_IIDX25music_arenaCPU_request(self, request: Node) -> Node: + root = Node.void('IIDX25music') + music_list = [0, 0, 0, 0] + cpu_list = [0, 0, 0, 0] + grade_to_percentage_map = { + 6: 50, + 7: 55, + 8: 60, + 9: 60, + 10: 65, + 11: 65, + 12: 70, + 13: 70, + 14: 75, + 15: 75, + 16: 80, + 17: 80, + 18: 80, + } + for item in request.children: + if item.name == 'music_list': + music_list[int(item.child_value('index'))] = int(item.child_value('total_notes')) + if item.name == 'cpu_list': + cpu_list[int(item.child_value('index'))] = int(item.child_value('grade_id')) + for index in range(4): + cpu_score_list = Node.void('cpu_score_list') + cpu_score_list.add_child(Node.s32('index', index)) + root.add_child(cpu_score_list) + for i in range(4): + score_list = Node.void('score_list') + cpu_score_list.add_child(score_list) + percentage = grade_to_percentage_map.get(cpu_list[index], 0) + notes = music_list[i] + score = (notes * 2 * percentage) // 100 + ghost_section = score // 64 + ghost = [ghost_section] * 64 + ghost_total = ghost_section * 64 + ghost_lost = score - ghost_total + ghost[0] = ghost[0] + ghost_lost + score_list.add_child(Node.s32('score', score)) + score_list.add_child(Node.bool('enable_score', True)) + score_list.add_child(Node.u8_array('ghost', ghost)) + score_list.add_child(Node.bool('enable_ghost', True)) + return root + + def handle_IIDX25grade_raised_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + cltype = int(request.attribute('gtype')) + rank = self.game_to_db_rank(int(request.attribute('gid')), cltype) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + percent = int(request.attribute('achi')) + stages_cleared = int(request.attribute('cstage')) + cleared = stages_cleared == self.DAN_STAGES + + if cltype == self.GAME_CLTYPE_SINGLE: + index = self.DAN_RANKING_SINGLE + else: + index = self.DAN_RANKING_DOUBLE + + self.update_rank( + userid, + index, + rank, + percent, + cleared, + stages_cleared, + ) + + # Figure out number of players that played this ranking + all_achievements = self.data.local.user.get_all_achievements(self.game, self.version) + num_players = 0 + for [_, ach] in all_achievements: + if ach.type != index: + continue + if ach.id != rank: + continue + num_players = num_players + 1 + + root = Node.void('IIDX25grade') + root.set_attribute('pnum', str(num_players)) + return root + + def handle_IIDX25pc_common_request(self, request: Node) -> Node: + root = Node.void('IIDX25pc') + root.set_attribute('expire', '600') + + ir = Node.void('ir') + root.add_child(ir) + ir.set_attribute('beat', '2') + + vip_black_pass = Node.void('vip_pass_black') + root.add_child(vip_black_pass) + + newsong_another = Node.void('newsong_another') + root.add_child(newsong_another) + newsong_another.set_attribute('open', '1') + + deller_bonus = Node.void('deller_bonus') + root.add_child(deller_bonus) + deller_bonus.set_attribute('open', '1') + + common_evnet = Node.void('common_evnet') # Yes, this is misspelled in the game + root.add_child(common_evnet) + common_evnet.set_attribute('flg', '0') + + # See if we configured event overrides + if self.machine_joined_arcade(): + game_config = self.get_game_config() + event_phase = game_config.get_int('event_phase') + omni_events = game_config.get_bool('omnimix_events_enabled') + else: + # If we aren't in an arcade, we turn off events + event_phase = 0 + omni_events = False + + if event_phase == 0 or (self.omnimix and (not omni_events)): + event1 = 0 + boss_phase = 0 + elif event_phase in [1, 2, 3, 4]: + boss_phase = 1 + event1 = event_phase - 1 + + boss = Node.void('boss') + root.add_child(boss) + boss.set_attribute('phase', str(boss_phase)) + + event1_phase = Node.void('event1_phase') + root.add_child(event1_phase) + event1_phase.set_attribute('phase', str(event1)) + + extra_boss_event = Node.void('extra_boss_event') # Always enable IIDX AIR RACE 5 + root.add_child(extra_boss_event) + extra_boss_event.set_attribute('phase', '4') + + # Course definitions + courses: List[Dict[str, Any]] = [ + { + 'name': 'EUROBEAT', + 'id': 1, + 'songs': [ + 17040, + 8006, + 10033, + 25001, + ], + }, + { + 'name': 'WEATHER', + 'id': 2, + 'open_music_id': 15104, + 'songs': [ + 17023, + 9030, + 15004, + 10024, + ], + }, + { + 'name': 'FEAT. KANAE ASABA', + 'id': 3, + 'open_music_id': 24101, + 'songs': [ + 25056, + 23033, + 24011, + 21034, + ], + }, + { + 'name': 'BLOOM', + 'id': 4, + 'songs': [ + 15054, + 20053, + 20004, + 23083, + ], + }, + { + 'name': 'VACUUM', + 'id': 5, + 'songs': [ + 19070, + 23021, + 17002, + 18071, + ], + }, + { + 'name': 'PRINCESS', + 'id': 6, + 'open_music_id': 12100, + 'songs': [ + 25024, + 20021, + 19046, + 12002, + ], + }, + { + 'name': 'TECHNO', + 'id': 7, + 'open_music_id': 19100, + 'songs': [ + 25026, + 19063, + 17061, + 23085, + ], + }, + { + 'name': 'POP', + 'id': 8, + 'open_music_id': 15103, + 'songs': [ + 15061, + 20099, + 20101, + 22093, + ], + }, + { + 'name': 'CARDINAL', + 'id': 9, + 'open_music_id': 13101, + 'songs': [ + 13038, + 13026, + 13000, + 13010, + ], + }, + { + 'name': 'O2K', + 'id': 10, + 'songs': [ + 21059, + 19002, + 22054, + 16020, + ], + }, + ] + + # Secret course definitions + secret_courses: List[Dict[str, Any]] = [ + { + 'name': 'EMOTIONAL', + 'id': 1, + 'songs': [ + 21009, + 25006, + 17065, + 25066, + ], + }, + { + 'name': 'RYU', + 'id': 2, + 'songs': [ + 23082, + 21007, + 23037, + 25067, + ], + }, + ] + + # For some reason, omnimix crashes on course mode, so don't enable it + if not self.omnimix: + internet_ranking = Node.void('internet_ranking') + root.add_child(internet_ranking) + + used_ids: List[int] = [] + for c in courses: + if c['id'] in used_ids: + raise Exception('Cannot have multiple courses with the same ID!') + elif c['id'] < 0 or c['id'] >= 20: + raise Exception('Course ID is out of bounds!') + else: + used_ids.append(c['id']) + + course = Node.void('course') + internet_ranking.add_child(course) + course.set_attribute('course_id', str(c['id'])) + course.set_attribute('name', c['name']) + course.set_attribute('mid0', str(c['songs'][0])) + course.set_attribute('mid1', str(c['songs'][1])) + course.set_attribute('mid2', str(c['songs'][2])) + course.set_attribute('mid3', str(c['songs'][3])) + course.set_attribute('open_music_id', str(c.get('open_music_id', 0))) + course.set_attribute('opflg', '1') + + secret_ex_course = Node.void('secret_ex_course') + root.add_child(secret_ex_course) + + used_secret_ids: List[int] = [] + for c in secret_courses: + if c['id'] in used_secret_ids: + raise Exception('Cannot have multiple secret courses with the same ID!') + elif c['id'] < 0 or c['id'] >= 20: + raise Exception('Secret course ID is out of bounds!') + else: + used_secret_ids.append(c['id']) + + course = Node.void('course') + secret_ex_course.add_child(course) + course.set_attribute('course_id', str(c['id'])) + course.set_attribute('name', c['name']) + course.set_attribute('mid0', str(c['songs'][0])) + course.set_attribute('mid1', str(c['songs'][1])) + course.set_attribute('mid2', str(c['songs'][2])) + course.set_attribute('mid3', str(c['songs'][3])) + + expert = Node.void('expert') + root.add_child(expert) + expert.set_attribute('phase', '1') + + expert_random_select = Node.void('expert_random_select') + root.add_child(expert_random_select) + expert_random_select.set_attribute('phase', '1') + + expert_full = Node.void('expert_secret_full_open') + root.add_child(expert_full) + + return root + + def handle_IIDX25pc_delete_request(self, request: Node) -> Node: + return Node.void('IIDX25pc') + + def handle_IIDX25pc_playstart_request(self, request: Node) -> Node: + return Node.void('IIDX25pc') + + def handle_IIDX25pc_playend_request(self, request: Node) -> Node: + return Node.void('IIDX25pc') + + def handle_IIDX25pc_visit_request(self, request: Node) -> Node: + root = Node.void('IIDX25pc') + root.set_attribute('anum', '0') + root.set_attribute('snum', '0') + root.set_attribute('pnum', '0') + root.set_attribute('aflg', '0') + root.set_attribute('sflg', '0') + root.set_attribute('pflg', '0') + return root + + def handle_IIDX25pc_shopregister_request(self, request: Node) -> Node: + root = Node.void('IIDX25pc') + return root + + def handle_IIDX25pc_oldget_request(self, request: Node) -> Node: + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + + root = Node.void('IIDX25pc') + root.set_attribute('status', '1' if profile is None else '0') + return root + + def handle_IIDX25pc_getname_request(self, request: Node) -> Node: + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + if profile is None: + raise Exception( + 'Should not get here if we have no profile, we should ' + + 'have returned \'1\' in the \'oldget\' method above ' + + 'which should tell the game not to present a migration.' + ) + + root = Node.void('IIDX25pc') + root.set_attribute('name', profile.get_str('name')) + root.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + root.set_attribute('pid', str(profile.get_int('pid'))) + return root + + def handle_IIDX25pc_takeover_request(self, request: Node) -> Node: + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + newprofile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('IIDX25pc') + if newprofile is not None: + root.set_attribute('id', str(newprofile.get_int('extid'))) + return root + + def handle_IIDX25pc_reg_request(self, request: Node) -> Node: + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + profile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('IIDX25pc') + if profile is not None: + root.set_attribute('id', str(profile.get_int('extid'))) + root.set_attribute('id_str', ID.format_extid(profile.get_int('extid'))) + return root + + def handle_IIDX25pc_get_request(self, request: Node) -> Node: + refid = request.attribute('rid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('IIDX25pc') + # Shop register seems to be gone so let's just register a shop if one isn't already + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + profile = self.get_profile(userid) + if 'shop_location' not in profile: + location = ID.parse_machine_id(request.attribute('lid')) + profile.replace_int('shop_location', location) + self.put_profile(userid, profile) + return root + + def handle_IIDX25pc_save_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + self.put_profile_by_extid(extid, request) + + return Node.void('IIDX25pc') + + def handle_IIDX25pc_logout_request(self, request: Node) -> Node: + return Node.void('IIDX25pc') + + def handle_IIDX25gameSystem_systemInfo_request(self, request: Node) -> Node: + root = Node.void('IIDX25gameSystem') + arena_schedule = Node.void('arena_schedule') + root.add_child(arena_schedule) + arena_schedule.add_child(Node.u8('phase', 1)) + arena_schedule.add_child(Node.u32('start', 0)) + arena_schedule.add_child(Node.u32('end', 0)) + cpu_grades = [6, 7, 8, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 18] + cpu_low_diff = [4, 5, 6, 6, 7, 7, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 11, 12, 12] + cpu_high_diff = [5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 12, 12] + for playstyle in range(2): + for arena_class in range(20): + arena_music_difficult = Node.void('arena_music_difficult') + root.add_child(arena_music_difficult) + arena_music_difficult.add_child(Node.s32('play_style', playstyle)) + arena_music_difficult.add_child(Node.s32('arena_class', arena_class)) + arena_music_difficult.add_child(Node.s32('low_difficult', 1)) + arena_music_difficult.add_child(Node.s32('high_difficult', 12)) + arena_music_difficult.add_child(Node.bool('is_leggendaria', True)) + arena_music_difficult.add_child(Node.s32('force_music_list_id', 0)) + arena_cpu_define = Node.void('arena_cpu_define') + root.add_child(arena_cpu_define) + arena_cpu_define.add_child(Node.s32('play_style', playstyle)) + arena_cpu_define.add_child(Node.s32('arena_class', arena_class)) + arena_cpu_define.add_child(Node.s32('grade_id', cpu_grades[arena_class])) + arena_cpu_define.add_child(Node.s32('low_music_difficult', cpu_low_diff[arena_class])) + arena_cpu_define.add_child(Node.s32('high_music_difficult', cpu_high_diff[arena_class])) + arena_cpu_define.add_child(Node.bool('is_leggendaria', arena_class >= 13)) + for matching_class in range(21): + matching_class_range = Node.void('matching_class_range') + root.add_child(matching_class_range) + matching_class_range.add_child(Node.s32('play_style', playstyle)) + matching_class_range.add_child(Node.s32('matching_class', matching_class)) + matching_class_range.add_child(Node.s32('low_arena_class', 1)) + matching_class_range.add_child(Node.s32('high_arena_class', 20)) + return root + + def handle_IIDX25pc_eaappliresult_request(self, request: Node) -> Node: + # first, we register the iidx webhook. this is done by sending all of our money to konami + if self.config['webhooks'][self.game] is None: + return Node.void('IIDX25pc') + webhook = DiscordWebhook(url=self.config['webhooks'][self.game]) + + clear_map = { + self.GAME_CLEAR_STATUS_NO_PLAY: 'NO PLAY', + self.GAME_CLEAR_STATUS_FAILED: 'FAILED', + self.GAME_CLEAR_STATUS_ASSIST_CLEAR: 'ASSIST CLEAR', + self.GAME_CLEAR_STATUS_EASY_CLEAR: 'EASY CLEAR', + self.GAME_CLEAR_STATUS_CLEAR: 'CLEAR', + self.GAME_CLEAR_STATUS_HARD_CLEAR: 'HARD CLEAR', + self.GAME_CLEAR_STATUS_EX_HARD_CLEAR: 'EX HARD CLEAR', + self.GAME_CLEAR_STATUS_FULL_COMBO: 'FULL COMBO', + } + # first we'll grab the data from the packet + # did = request.child_value('did') + # rid = request.child_value('rid') + name = request.child_value('name') + # qpro_hair = request.child_value('qpro_hair') + # qpro_head = request.child_value('qpro_head') + # qpro_body = request.child_value('qpro_body') + # qpro_hand = request.child_value('qpro_hand') + music_id = request.child_value('music_id') + class_id = request.child_value('class_id') + # no_save = request.child_value('no_save') + # is_couple = request.child_value('is_couple') + # target_graph = request.child_value('target_graph') + target_exscore = request.child_value('target_exscore') + # pacemaker = request.child_value('pacemaker') + best_clear = request.child_value('best_clear') + # best_djlevel = request.child_value('best_djlevel') + # best_exscore = request.child_value('best_exscore') + # best_misscount = request.child_value('best_misscount') + now_clear = request.child_value('now_clear') + # now_djlevel = request.child_value('now_djlevel') + now_exscore = request.child_value('now_exscore') + # now_misscount = request.child_value('now_misscount') + now_pgreat = request.child_value('now_pgreat') + now_great = request.child_value('now_great') + now_good = request.child_value('now_good') + now_bad = request.child_value('now_bad') + now_poor = request.child_value('now_poor') + now_combo = request.child_value('now_combo') + now_fast = request.child_value('now_fast') + now_slow = request.child_value('now_slow') + best_clear_string = clear_map.get(best_clear, 'NO PLAY') + now_clear_string = clear_map.get(now_clear, 'NO PLAY') + # let's get the song info first + song = self.data.local.music.get_song(self.game, self.music_version, music_id, self.game_to_db_chart(class_id)) + + # now we will build up the embed + now = datetime.now() + + # now, we generate the embed + scoreembed = DiscordEmbed(title='New IIDX Score!', description="Login to see whole score.", color='fbba08') + scoreembed.set_footer(text=(now.strftime('Score was recorded on %m/%d/%y at %H:%M:%S'))) + + # lets give it an author + scoreembed.set_author(name=self.config['name'], url=self.config['server']['uri']) + # Set `inline=False` for the embed field to occupy the whole line + scoreembed.add_embed_field(name='DJ Name', value=(name), inline=False) + scoreembed.add_embed_field(name='Song', value=(song.name), inline=False) + scoreembed.add_embed_field(name='Artist', value=(song.artist), inline=False) + scoreembed.add_embed_field(name='Difficulty', value=(song.data.get('difficulty', 0))) + scoreembed.add_embed_field(name='Target EXScore', value=(target_exscore)) + scoreembed.add_embed_field(name='Your EXScore', value=(now_exscore)) + scoreembed.add_embed_field(name='Best Clear', value=best_clear_string) + scoreembed.add_embed_field(name='Clear Status', value=now_clear_string) + scoreembed.add_embed_field(name='Note Counts', value='How did you do?', inline=False) + scoreembed.add_embed_field(name=('Perfect Greats'), value=((now_pgreat))) + scoreembed.add_embed_field(name=('Greats'), value=((now_great))) + scoreembed.add_embed_field(name=('Goods'), value=((now_good))) + scoreembed.add_embed_field(name=('Bads'), value=((now_bad))) + scoreembed.add_embed_field(name=('Poors'), value=((now_poor))) + + scoreembed.add_embed_field(name='Combo Breaks', value=(now_combo)) + + # scoreembed.add_embed_field(name='Timing Stats', value='Were you on time?', inline = False) + scoreembed.add_embed_field(name=('Slow'), value=((now_slow))) + scoreembed.add_embed_field(name=('Fast'), value=((now_fast))) + + # add embed object to webhook + webhook.add_embed(scoreembed) + + # now we send the webhook! + response = webhook.execute() + + end = Node.void('IIDX25pc') + + return end + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('IIDX25pc') + + # Look up play stats we bridge to every mix + play_stats = self.get_play_statistics(userid) + + # Look up judge window adjustments + judge_dict = profile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + + # Profile data + pcdata = Node.void('pcdata') + root.add_child(pcdata) + pcdata.set_attribute('id', str(profile.get_int('extid'))) + pcdata.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + pcdata.set_attribute('name', profile.get_str('name')) + pcdata.set_attribute('pid', str(profile.get_int('pid'))) + pcdata.set_attribute('spnum', str(play_stats.get_int('single_plays'))) + pcdata.set_attribute('dpnum', str(play_stats.get_int('double_plays'))) + pcdata.set_attribute('sach', str(play_stats.get_int('single_dj_points'))) + pcdata.set_attribute('dach', str(play_stats.get_int('double_dj_points'))) + pcdata.set_attribute('mode', str(profile.get_int('mode'))) + pcdata.set_attribute('pmode', str(profile.get_int('pmode'))) + pcdata.set_attribute('rtype', str(profile.get_int('rtype'))) + pcdata.set_attribute('sp_opt', str(profile.get_int('sp_opt'))) + pcdata.set_attribute('dp_opt', str(profile.get_int('dp_opt'))) + pcdata.set_attribute('dp_opt2', str(profile.get_int('dp_opt2'))) + pcdata.set_attribute('gpos', str(profile.get_int('gpos'))) + pcdata.set_attribute('s_sorttype', str(profile.get_int('s_sorttype'))) + pcdata.set_attribute('d_sorttype', str(profile.get_int('d_sorttype'))) + pcdata.set_attribute('s_pace', str(profile.get_int('s_pace'))) + pcdata.set_attribute('d_pace', str(profile.get_int('d_pace'))) + pcdata.set_attribute('s_gno', str(profile.get_int('s_gno'))) + pcdata.set_attribute('d_gno', str(profile.get_int('d_gno'))) + pcdata.set_attribute('s_gtype', str(profile.get_int('s_gtype'))) + pcdata.set_attribute('d_gtype', str(profile.get_int('d_gtype'))) + pcdata.set_attribute('s_sdlen', str(profile.get_int('s_sdlen'))) + pcdata.set_attribute('d_sdlen', str(profile.get_int('d_sdlen'))) + pcdata.set_attribute('s_sdtype', str(profile.get_int('s_sdtype'))) + pcdata.set_attribute('d_sdtype', str(profile.get_int('d_sdtype'))) + pcdata.set_attribute('s_timing', str(profile.get_int('s_timing'))) + pcdata.set_attribute('d_timing', str(profile.get_int('d_timing'))) + pcdata.set_attribute('s_notes', str(profile.get_float('s_notes'))) + pcdata.set_attribute('d_notes', str(profile.get_float('d_notes'))) + pcdata.set_attribute('s_judge', str(profile.get_int('s_judge'))) + pcdata.set_attribute('d_judge', str(profile.get_int('d_judge'))) + pcdata.set_attribute('s_judgeAdj', str(machine_judge.get_int('single'))) + pcdata.set_attribute('d_judgeAdj', str(machine_judge.get_int('double'))) + pcdata.set_attribute('s_hispeed', str(profile.get_float('s_hispeed'))) + pcdata.set_attribute('d_hispeed', str(profile.get_float('d_hispeed'))) + pcdata.set_attribute('s_liflen', str(profile.get_int('s_lift'))) + pcdata.set_attribute('d_liflen', str(profile.get_int('d_lift'))) + pcdata.set_attribute('s_disp_judge', str(profile.get_int('s_disp_judge'))) + pcdata.set_attribute('d_disp_judge', str(profile.get_int('d_disp_judge'))) + pcdata.set_attribute('s_opstyle', str(profile.get_int('s_opstyle'))) + pcdata.set_attribute('d_opstyle', str(profile.get_int('d_opstyle'))) + pcdata.set_attribute('s_exscore', str(profile.get_int('s_exscore'))) + pcdata.set_attribute('d_exscore', str(profile.get_int('d_exscore'))) + pcdata.set_attribute('s_graph_score', str(profile.get_int('s_graph_score'))) + pcdata.set_attribute('d_graph_score', str(profile.get_int('d_graph_score'))) + pcdata.set_attribute('s_auto_scrach', str(profile.get_int('s_auto_scrach'))) + pcdata.set_attribute('d_auto_scrach', str(profile.get_int('d_auto_scrach'))) + pcdata.set_attribute('s_gauge_disp', str(profile.get_int('s_gauge_disp'))) + pcdata.set_attribute('d_gauge_disp', str(profile.get_int('d_gauge_disp'))) + pcdata.set_attribute('s_lane_brignt', str(profile.get_int('s_lane_brignt'))) + pcdata.set_attribute('d_lane_brignt', str(profile.get_int('d_lane_brignt'))) + pcdata.set_attribute('s_camera_layout', str(profile.get_int('s_camera_layout'))) + pcdata.set_attribute('d_camera_layout', str(profile.get_int('d_camera_layout'))) + + spdp_rival = Node.void('spdp_rival') + root.add_child(spdp_rival) + spdp_rival.set_attribute('flg', str(profile.get_int('spdp_rival_flag'))) + + premium_unlocks = Node.void('ea_premium_course') + root.add_child(premium_unlocks) + + legendarias = Node.void('leggendaria_open') + root.add_child(legendarias) + + # Song unlock flags + secret_dict = profile.get_dict('secret') + secret = Node.void('secret') + root.add_child(secret) + secret.add_child(Node.s64_array('flg1', secret_dict.get_int_array('flg1', 3))) + secret.add_child(Node.s64_array('flg2', secret_dict.get_int_array('flg2', 3))) + secret.add_child(Node.s64_array('flg3', secret_dict.get_int_array('flg3', 3))) + + # Favorites + for folder in ['favorite1', 'favorite2', 'favorite3']: + favorite_dict = profile.get_dict(folder) + sp_mlist = b'' + sp_clist = b'' + singles_list = favorite_dict['single'] if 'single' in favorite_dict else [] + for single in singles_list: + sp_mlist = sp_mlist + struct.pack(' ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + play_stats = self.get_play_statistics(userid) + + # Track play counts + cltype = int(request.attribute('cltype')) + if cltype == self.GAME_CLTYPE_SINGLE: + play_stats.increment_int('single_plays') + if cltype == self.GAME_CLTYPE_DOUBLE: + play_stats.increment_int('double_plays') + + # Track DJ points + play_stats.replace_int('single_dj_points', int(request.attribute('s_achi'))) + play_stats.replace_int('double_dj_points', int(request.attribute('d_achi'))) + + # Profile settings + newprofile.replace_int('sp_opt', int(request.attribute('sp_opt'))) + newprofile.replace_int('dp_opt', int(request.attribute('dp_opt'))) + newprofile.replace_int('dp_opt2', int(request.attribute('dp_opt2'))) + newprofile.replace_int('gpos', int(request.attribute('gpos'))) + newprofile.replace_int('s_sorttype', int(request.attribute('s_sorttype'))) + newprofile.replace_int('d_sorttype', int(request.attribute('d_sorttype'))) + newprofile.replace_int('s_disp_judge', int(request.attribute('s_disp_judge'))) + newprofile.replace_int('d_disp_judge', int(request.attribute('d_disp_judge'))) + newprofile.replace_int('s_pace', int(request.attribute('s_pace'))) + newprofile.replace_int('d_pace', int(request.attribute('d_pace'))) + newprofile.replace_int('s_gno', int(request.attribute('s_gno'))) + newprofile.replace_int('d_gno', int(request.attribute('d_gno'))) + newprofile.replace_int('s_gtype', int(request.attribute('s_gtype'))) + newprofile.replace_int('d_gtype', int(request.attribute('d_gtype'))) + newprofile.replace_int('s_sdlen', int(request.attribute('s_sdlen'))) + newprofile.replace_int('d_sdlen', int(request.attribute('d_sdlen'))) + newprofile.replace_int('s_sdtype', int(request.attribute('s_sdtype'))) + newprofile.replace_int('d_sdtype', int(request.attribute('d_sdtype'))) + newprofile.replace_int('s_timing', int(request.attribute('s_timing'))) + newprofile.replace_int('d_timing', int(request.attribute('d_timing'))) + newprofile.replace_float('s_notes', float(request.attribute('s_notes'))) + newprofile.replace_float('d_notes', float(request.attribute('d_notes'))) + newprofile.replace_int('s_judge', int(request.attribute('s_judge'))) + newprofile.replace_int('d_judge', int(request.attribute('d_judge'))) + newprofile.replace_float('s_hispeed', float(request.attribute('s_hispeed'))) + newprofile.replace_float('d_hispeed', float(request.attribute('d_hispeed'))) + newprofile.replace_int('s_opstyle', int(request.attribute('s_opstyle'))) + newprofile.replace_int('d_opstyle', int(request.attribute('d_opstyle'))) + newprofile.replace_int('s_graph_score', int(request.attribute('s_graph_score'))) + newprofile.replace_int('d_graph_score', int(request.attribute('d_graph_score'))) + newprofile.replace_int('s_auto_scrach', int(request.attribute('s_auto_scrach'))) + newprofile.replace_int('d_auto_scrach', int(request.attribute('d_auto_scrach'))) + newprofile.replace_int('s_gauge_disp', int(request.attribute('s_gauge_disp'))) + newprofile.replace_int('d_gauge_disp', int(request.attribute('d_gauge_disp'))) + newprofile.replace_int('s_lane_brignt', int(request.attribute('s_lane_brignt'))) + newprofile.replace_int('d_lane_brignt', int(request.attribute('d_lane_brignt'))) + newprofile.replace_int('s_camera_layout', int(request.attribute('s_camera_layout'))) + newprofile.replace_int('d_camera_layout', int(request.attribute('d_camera_layout'))) + newprofile.replace_int('s_lift', int(request.attribute('s_lift'))) + newprofile.replace_int('d_lift', int(request.attribute('d_lift'))) + newprofile.replace_int('mode', int(request.attribute('mode'))) + newprofile.replace_int('pmode', int(request.attribute('pmode'))) + newprofile.replace_int('rtype', int(request.attribute('rtype'))) + # Update judge window adjustments per-machine + judge_dict = newprofile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + machine_judge.replace_int('single', int(request.attribute('s_judgeAdj'))) + machine_judge.replace_int('double', int(request.attribute('d_judgeAdj'))) + judge_dict.replace_dict(self.config['machine']['pcbid'], machine_judge) + newprofile.replace_dict('machine_judge_adjust', judge_dict) + + # Secret flags saving + secret = request.child('secret') + if secret is not None: + secret_dict = newprofile.get_dict('secret') + secret_dict.replace_int_array('flg1', 3, secret.child_value('flg1')) + secret_dict.replace_int_array('flg2', 3, secret.child_value('flg2')) + secret_dict.replace_int_array('flg3', 3, secret.child_value('flg3')) + newprofile.replace_dict('secret', secret_dict) + + # Basic achievements + achievements = request.child('achievements') + if achievements is not None: + newprofile.replace_int('visit_flg', int(achievements.attribute('visit_flg'))) + newprofile.replace_int('last_weekly', int(achievements.attribute('last_weekly'))) + newprofile.replace_int('weekly_num', int(achievements.attribute('weekly_num'))) + + pack_id = int(achievements.attribute('pack_id')) + if pack_id > 0: + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + pack_id, + 'daily', + { + 'pack_flg': int(achievements.attribute('pack_flg')), + 'pack_comp': int(achievements.attribute('pack_comp')), + }, + ) + + trophies = achievements.child('trophy') + if trophies is not None: + # We only load the first 20 in profile load. + newprofile.replace_int_array('trophy', 20, trophies.value[:20]) + + # Deller saving + deller = request.child('deller') + if deller is not None: + newprofile.replace_int('deller', newprofile.get_int('deller') + int(deller.attribute('deller'))) + + # Secret course expert point saving + expert_point = request.child('expert_point') + if expert_point is not None: + courseid = int(expert_point.attribute('course_id')) + + # Update achievement to track expert points + expert_point_achievement = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + courseid, + 'expert_point', + ) + if expert_point_achievement is None: + expert_point_achievement = ValidatedDict() + expert_point_achievement.replace_int( + 'normal_points', + int(expert_point.attribute('n_point')), + ) + expert_point_achievement.replace_int( + 'hyper_points', + int(expert_point.attribute('h_point')), + ) + expert_point_achievement.replace_int( + 'another_points', + int(expert_point.attribute('a_point')), + ) + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + courseid, + 'expert_point', + expert_point_achievement, + ) + + # Favorites saving + for favorite in request.children: + singles = [] + doubles = [] + name = None + if favorite.name in ['favorite', 'extra_favorite']: + if favorite.name == 'favorite': + name = 'favorite1' + elif favorite.name == 'extra_favorite': + folder = favorite.attribute('folder_id') + if folder == '0': + name = 'favorite2' + if folder == '1': + name = 'favorite3' + if name is None: + continue + + single_music_bin = favorite.child_value('sp_mlist') + single_chart_bin = favorite.child_value('sp_clist') + double_music_bin = favorite.child_value('dp_mlist') + double_chart_bin = favorite.child_value('dp_clist') + + for i in range(self.FAVORITE_LIST_LENGTH): + singles.append({ + 'id': struct.unpack('= 2016102600 and date < 2017122100: return VersionConstants.IIDX_SINOBUZ - if date >= 2017122100: + if date >= 2017122100 and date < 2018110700: return VersionConstants.IIDX_CANNON_BALLERS + if date >= 2018110700 and date < 2019101600: + return VersionConstants.IIDX_ROOTAGE + if date >= 2019101600 and date < 2020102800: + return VersionConstants.IIDX_HEROIC_VERSE + if date >= 2020102800: + return VersionConstants.IIDX_BISTROVER return None if model.game == 'JDJ': @@ -111,6 +123,12 @@ class IIDXFactory(Factory): return IIDXCopula(data, config, model) if parentversion == VersionConstants.IIDX_CANNON_BALLERS: return IIDXSinobuz(data, config, model) + if parentversion == VersionConstants.IIDX_ROOTAGE: + return IIDXCannonBallers(data, config, model) + if parentversion == VersionConstants.IIDX_HEROIC_VERSE: + return IIDXRootage(data, config, model) + if parentversion == VersionConstants.IIDX_BISTROVER: + return IIDXHeroicVerse(data, config, model) # Unknown older version return None @@ -128,6 +146,12 @@ class IIDXFactory(Factory): return IIDXSinobuz(data, config, model) if version == VersionConstants.IIDX_CANNON_BALLERS: return IIDXCannonBallers(data, config, model) + if version == VersionConstants.IIDX_ROOTAGE: + return IIDXRootage(data, config, model) + if version == VersionConstants.IIDX_HEROIC_VERSE: + return IIDXHeroicVerse(data, config, model) + if version == VersionConstants.IIDX_BISTROVER: + return IIDXBistrover(data, config, model) # Unknown game version return None diff --git a/bemani/backend/iidx/heroicverse.py b/bemani/backend/iidx/heroicverse.py new file mode 100644 index 0000000..ba3bb8b --- /dev/null +++ b/bemani/backend/iidx/heroicverse.py @@ -0,0 +1,15 @@ +# vim: set fileencoding=utf-8 +from typing import Optional + +from bemani.backend.iidx.base import IIDXBase +from bemani.backend.iidx.rootage import IIDXRootage +from bemani.common import VersionConstants + + +class IIDXHeroicVerse(IIDXBase): + + name = 'Beatmania IIDX HEROIC VERSE' + version = VersionConstants.IIDX_HEROIC_VERSE + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXRootage(self.data, self.config, self.model) diff --git a/bemani/backend/iidx/rootage.py b/bemani/backend/iidx/rootage.py new file mode 100644 index 0000000..c7125ea --- /dev/null +++ b/bemani/backend/iidx/rootage.py @@ -0,0 +1,2164 @@ +# vim: set fileencoding=utf-8 +import copy +import random +import struct +from typing import Optional, Dict, Any, List, Tuple +from datetime import datetime +from discord_webhook import DiscordWebhook, DiscordEmbed + +from bemani.backend.iidx.base import IIDXBase +from bemani.backend.iidx.course import IIDXCourse +from bemani.backend.iidx.cannonballers import IIDXCannonBallers + +from bemani.common import ValidatedDict, VersionConstants, Time, ID, intish +from bemani.data import Data, UserID +from bemani.protocol import Node + + +class IIDXRootage(IIDXCourse, IIDXBase): + + name = 'Beatmania IIDX ROOTAGE' + version = VersionConstants.IIDX_ROOTAGE + + GAME_CLTYPE_SINGLE = 0 + GAME_CLTYPE_DOUBLE = 1 + + DAN_STAGES = 4 + + GAME_CLEAR_STATUS_NO_PLAY = 0 + GAME_CLEAR_STATUS_FAILED = 1 + GAME_CLEAR_STATUS_ASSIST_CLEAR = 2 + GAME_CLEAR_STATUS_EASY_CLEAR = 3 + GAME_CLEAR_STATUS_CLEAR = 4 + GAME_CLEAR_STATUS_HARD_CLEAR = 5 + GAME_CLEAR_STATUS_EX_HARD_CLEAR = 6 + GAME_CLEAR_STATUS_FULL_COMBO = 7 + + GAME_GHOST_TYPE_RIVAL = 1 + GAME_GHOST_TYPE_GLOBAL_TOP = 2 + GAME_GHOST_TYPE_GLOBAL_AVERAGE = 3 + GAME_GHOST_TYPE_LOCAL_TOP = 4 + GAME_GHOST_TYPE_LOCAL_AVERAGE = 5 + GAME_GHOST_TYPE_DAN_TOP = 6 + GAME_GHOST_TYPE_DAN_AVERAGE = 7 + GAME_GHOST_TYPE_RIVAL_TOP = 8 + GAME_GHOST_TYPE_RIVAL_AVERAGE = 9 + + GAME_GHOST_LENGTH = 64 + + GAME_SP_DAN_RANK_7_KYU = 0 + GAME_SP_DAN_RANK_6_KYU = 1 + GAME_SP_DAN_RANK_5_KYU = 2 + GAME_SP_DAN_RANK_4_KYU = 3 + GAME_SP_DAN_RANK_3_KYU = 4 + GAME_SP_DAN_RANK_2_KYU = 5 + GAME_SP_DAN_RANK_1_KYU = 6 + GAME_SP_DAN_RANK_1_DAN = 7 + GAME_SP_DAN_RANK_2_DAN = 8 + GAME_SP_DAN_RANK_3_DAN = 9 + GAME_SP_DAN_RANK_4_DAN = 10 + GAME_SP_DAN_RANK_5_DAN = 11 + GAME_SP_DAN_RANK_6_DAN = 12 + GAME_SP_DAN_RANK_7_DAN = 13 + GAME_SP_DAN_RANK_8_DAN = 14 + GAME_SP_DAN_RANK_9_DAN = 15 + GAME_SP_DAN_RANK_10_DAN = 16 + GAME_SP_DAN_RANK_CHUDEN = 17 + GAME_SP_DAN_RANK_KAIDEN = 18 + + GAME_DP_DAN_RANK_7_KYU = 0 + GAME_DP_DAN_RANK_6_KYU = 1 + GAME_DP_DAN_RANK_5_KYU = 2 + GAME_DP_DAN_RANK_4_KYU = 3 + GAME_DP_DAN_RANK_3_KYU = 4 + GAME_DP_DAN_RANK_2_KYU = 5 + GAME_DP_DAN_RANK_1_KYU = 6 + GAME_DP_DAN_RANK_1_DAN = 7 + GAME_DP_DAN_RANK_2_DAN = 8 + GAME_DP_DAN_RANK_3_DAN = 9 + GAME_DP_DAN_RANK_4_DAN = 10 + GAME_DP_DAN_RANK_5_DAN = 11 + GAME_DP_DAN_RANK_6_DAN = 12 + GAME_DP_DAN_RANK_7_DAN = 13 + GAME_DP_DAN_RANK_8_DAN = 14 + GAME_DP_DAN_RANK_9_DAN = 15 + GAME_DP_DAN_RANK_10_DAN = 16 + GAME_DP_DAN_RANK_CHUDEN = 17 + GAME_DP_DAN_RANK_KAIDEN = 18 + + FAVORITE_LIST_LENGTH = 20 + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXCannonBallers(self.data, self.config, self.model) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Insert dailies into the DB. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'daily_charts', 'daily'): + # Generate a new list of three dailies. + start_time, end_time = data.local.network.get_schedule_duration('daily') + all_songs = list(set([song.id for song in data.local.music.get_all_songs(cls.game, cls.version)])) + daily_songs = random.sample(all_songs, 3) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'dailies', + { + 'start_time': start_time, + 'end_time': end_time, + 'music': daily_songs, + }, + ) + events.append(( + 'iidx_daily_charts', + { + 'version': cls.version, + 'music': daily_songs, + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'daily_charts', 'daily') + return events + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Global Shop Ranking', + 'tip': 'Return network-wide ranking instead of shop ranking on results screen.', + 'category': 'game_config', + 'setting': 'global_shop_ranking', + }, + { + 'name': 'Events In Omnimix', + 'tip': 'Allow events to be enabled at all for Omnimix.', + 'category': 'game_config', + 'setting': 'omnimix_events_enabled', + }, + ], + 'ints': [ + { + 'name': 'Event Phase', + 'tip': 'Event phase for all players.', + 'category': 'game_config', + 'setting': 'event_phase', + 'values': { + 0: 'No Event', + 1: '蜃気楼の図書館(The Bibliotheca of Mirage) Phase 1', + 2: '蜃気楼の図書館(The Bibliotheca of Mirage) Phase 2', + 3: '蜃気楼の図書館(The Bibliotheca of Mirage) Phase 3', + 4: '蜃気楼の図書館(The Bibliotheca of Mirage) Phase 4', + 5: 'DELABITY LABORATORY', + } + }, + ], + } + + def db_to_game_status(self, db_status: int) -> int: + return { + self.CLEAR_STATUS_NO_PLAY: self.GAME_CLEAR_STATUS_NO_PLAY, + self.CLEAR_STATUS_FAILED: self.GAME_CLEAR_STATUS_FAILED, + self.CLEAR_STATUS_ASSIST_CLEAR: self.GAME_CLEAR_STATUS_ASSIST_CLEAR, + self.CLEAR_STATUS_EASY_CLEAR: self.GAME_CLEAR_STATUS_EASY_CLEAR, + self.CLEAR_STATUS_CLEAR: self.GAME_CLEAR_STATUS_CLEAR, + self.CLEAR_STATUS_HARD_CLEAR: self.GAME_CLEAR_STATUS_HARD_CLEAR, + self.CLEAR_STATUS_EX_HARD_CLEAR: self.GAME_CLEAR_STATUS_EX_HARD_CLEAR, + self.CLEAR_STATUS_FULL_COMBO: self.GAME_CLEAR_STATUS_FULL_COMBO, + }[db_status] + + def game_to_db_status(self, game_status: int) -> int: + return { + self.GAME_CLEAR_STATUS_NO_PLAY: self.CLEAR_STATUS_NO_PLAY, + self.GAME_CLEAR_STATUS_FAILED: self.CLEAR_STATUS_FAILED, + self.GAME_CLEAR_STATUS_ASSIST_CLEAR: self.CLEAR_STATUS_ASSIST_CLEAR, + self.GAME_CLEAR_STATUS_EASY_CLEAR: self.CLEAR_STATUS_EASY_CLEAR, + self.GAME_CLEAR_STATUS_CLEAR: self.CLEAR_STATUS_CLEAR, + self.GAME_CLEAR_STATUS_HARD_CLEAR: self.CLEAR_STATUS_HARD_CLEAR, + self.GAME_CLEAR_STATUS_EX_HARD_CLEAR: self.CLEAR_STATUS_EX_HARD_CLEAR, + self.GAME_CLEAR_STATUS_FULL_COMBO: self.CLEAR_STATUS_FULL_COMBO, + }[game_status] + + def db_to_game_rank(self, db_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if db_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.DAN_RANK_7_KYU: self.GAME_SP_DAN_RANK_7_KYU, + self.DAN_RANK_6_KYU: self.GAME_SP_DAN_RANK_6_KYU, + self.DAN_RANK_5_KYU: self.GAME_SP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_SP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_SP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_SP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_SP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_SP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_SP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_SP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_SP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_SP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_SP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_SP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_SP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_SP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_SP_DAN_RANK_10_DAN, + self.DAN_RANK_CHUDEN: self.GAME_SP_DAN_RANK_CHUDEN, + self.DAN_RANK_KAIDEN: self.GAME_SP_DAN_RANK_KAIDEN, + }[db_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.DAN_RANK_7_KYU: self.GAME_DP_DAN_RANK_7_KYU, + self.DAN_RANK_6_KYU: self.GAME_DP_DAN_RANK_6_KYU, + self.DAN_RANK_5_KYU: self.GAME_DP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_DP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_DP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_DP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_DP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_DP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_DP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_DP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_DP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_DP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_DP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_DP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_DP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_DP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_DP_DAN_RANK_10_DAN, + self.DAN_RANK_CHUDEN: self.GAME_DP_DAN_RANK_CHUDEN, + self.DAN_RANK_KAIDEN: self.GAME_DP_DAN_RANK_KAIDEN, + }[db_dan] + else: + raise Exception('Invalid cltype!') + + def game_to_db_rank(self, game_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if game_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.GAME_SP_DAN_RANK_7_KYU: self.DAN_RANK_7_KYU, + self.GAME_SP_DAN_RANK_6_KYU: self.DAN_RANK_6_KYU, + self.GAME_SP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_SP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_SP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_SP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_SP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_SP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_SP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_SP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_SP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_SP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_SP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_SP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_SP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_SP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_SP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_SP_DAN_RANK_CHUDEN: self.DAN_RANK_CHUDEN, + self.GAME_SP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.GAME_DP_DAN_RANK_7_KYU: self.DAN_RANK_7_KYU, + self.GAME_DP_DAN_RANK_6_KYU: self.DAN_RANK_6_KYU, + self.GAME_DP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_DP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_DP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_DP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_DP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_DP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_DP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_DP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_DP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_DP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_DP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_DP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_DP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_DP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_DP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_DP_DAN_RANK_CHUDEN: self.DAN_RANK_CHUDEN, + self.GAME_DP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + else: + raise Exception('Invalid cltype!') + + def handle_IIDX26shop_getname_request(self, request: Node) -> Node: + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine is not None: + machine_name = machine.name + close = machine.data.get_bool('close') + hour = machine.data.get_int('hour') + minute = machine.data.get_int('minute') + else: + machine_name = '' + close = False + hour = 0 + minute = 0 + + root = Node.void('IIDX26shop') + root.set_attribute('opname', machine_name) + root.set_attribute('pid', '51') + root.set_attribute('cls_opt', '1' if close else '0') + root.set_attribute('hr', str(hour)) + root.set_attribute('mi', str(minute)) + return root + + def handle_IIDX26shop_savename_request(self, request: Node) -> Node: + self.update_machine_name(request.attribute('opname')) + + shop_close = intish(request.attribute('cls_opt')) or 0 + minutes = intish(request.attribute('mnt')) or 0 + hours = intish(request.attribute('hr')) or 0 + + self.update_machine_data({ + 'close': shop_close != 0, + 'minutes': minutes, + 'hours': hours, + }) + + return Node.void('IIDX26shop') + + def handle_IIDX26shop_sentinfo_request(self, request: Node) -> Node: + return Node.void('IIDX26shop') + + def handle_IIDX26shop_sendescapepackageinfo_request(self, request: Node) -> Node: + root = Node.void('IIDX26shop') + root.set_attribute('expire', str((Time.now() + 86400 * 365) * 1000)) + return root + + def handle_IIDX26shop_getconvention_request(self, request: Node) -> Node: + root = Node.void('IIDX26shop') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + root.set_attribute('music_0', str(course.get_int('music_0', 20032))) + root.set_attribute('music_1', str(course.get_int('music_1', 20009))) + root.set_attribute('music_2', str(course.get_int('music_2', 20015))) + root.set_attribute('music_3', str(course.get_int('music_3', 20064))) + root.add_child(Node.bool('valid', course.get_bool('valid'))) + return root + + def handle_IIDX26shop_setconvention_request(self, request: Node) -> Node: + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = ValidatedDict() + course.replace_int('music_0', request.child_value('music_0')) + course.replace_int('music_1', request.child_value('music_1')) + course.replace_int('music_2', request.child_value('music_2')) + course.replace_int('music_3', request.child_value('music_3')) + course.replace_bool('valid', request.child_value('valid')) + self.data.local.machine.put_settings(machine.arcade, self.game, self.music_version, 'shop_course', course) + + return Node.void('IIDX26shop') + + def handle_IIDX26ranking_getranker_request(self, request: Node) -> Node: + root = Node.void('IIDX26ranking') + chart = int(request.attribute('clid')) + if chart not in [ + self.CHART_TYPE_N7, + self.CHART_TYPE_H7, + self.CHART_TYPE_A7, + self.CHART_TYPE_N14, + self.CHART_TYPE_H14, + self.CHART_TYPE_A14, + ]: + # Chart type 6 is presumably beginner mode, but it crashes the game + return root + + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + if not course.get_bool('valid'): + # Shop course not enabled or not present + return root + + convention = Node.void('convention') + root.add_child(convention) + convention.set_attribute('clid', str(chart)) + convention.set_attribute('update_date', str(Time.now() * 1000)) + + # Grab all scores for each of the four songs, filter out people who haven't + # set us as their arcade and then return the top 20 scores (adding all 4 songs). + songids = [ + course.get_int('music_0'), + course.get_int('music_1'), + course.get_int('music_2'), + course.get_int('music_3'), + ] + + totalscores: Dict[UserID, int] = {} + profiles: Dict[UserID, ValidatedDict] = {} + for songid in songids: + scores = self.data.local.music.get_all_scores( + self.game, + self.music_version, + songid=songid, + songchart=chart, + ) + + for score in scores: + if score[0] not in totalscores: + totalscores[score[0]] = 0 + profile = self.get_any_profile(score[0]) + if profile is None: + profile = ValidatedDict() + profiles[score[0]] = profile + + totalscores[score[0]] += score[1].points + + topscores = sorted( + [ + (totalscores[userid], profiles[userid]) + for userid in totalscores + if self.user_joined_arcade(machine, profiles[userid]) + ], + key=lambda tup: tup[0], + reverse=True, + )[:20] + + rank = 0 + for topscore in topscores: + rank = rank + 1 + + detail = Node.void('detail') + convention.add_child(detail) + detail.set_attribute('name', topscore[1].get_str('name')) + detail.set_attribute('rank', str(rank)) + detail.set_attribute('score', str(topscore[0])) + detail.set_attribute('pid', str(topscore[1].get_int('pid'))) + + qpro = topscore[1].get_dict('qpro') + detail.set_attribute('head', str(qpro.get_int('head'))) + detail.set_attribute('hair', str(qpro.get_int('hair'))) + detail.set_attribute('face', str(qpro.get_int('face'))) + detail.set_attribute('body', str(qpro.get_int('body'))) + detail.set_attribute('hand', str(qpro.get_int('hand'))) + + return root + + def handle_IIDX26ranking_entry_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + courseid = int(request.attribute('coid')) + chart = int(request.attribute('clid')) + course_type = int(request.attribute('regist_type')) + clear_status = self.game_to_db_status(int(request.attribute('clr'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + if course_type == 0: + index = self.COURSE_TYPE_INTERNET_RANKING + elif course_type == 1: + index = self.COURSE_TYPE_SECRET + else: + raise Exception('Unknown registration type for course entry!') + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + # Update achievement to track course statistics + self.update_course( + userid, + index, + courseid, + chart, + clear_status, + pgreats, + greats, + ) + + # We should return the user's position, but its not displayed anywhere + # so fuck it. + root = Node.void('IIDX26ranking') + root.set_attribute('anum', '1') + root.set_attribute('jun', '1') + return root + + def handle_IIDX26ranking_classicentry_request(self, request: Node) -> Node: + extid = int(request.attribute('iidx_id')) + courseid = int(request.attribute('course_id')) + coursestyle = int(request.attribute('play_style')) + clear_status = self.game_to_db_status(int(request.attribute('clear_flg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + # Update achievement to track course statistics + self.update_course( + userid, + self.COURSE_TYPE_CLASSIC, + courseid, + coursestyle, + clear_status, + pgreats, + greats, + ) + + return Node.void('IIDX26ranking') + + def handle_IIDX26music_crate_request(self, request: Node) -> Node: + root = Node.void('IIDX26music') + attempts = self.get_clear_rates() + + all_songs = list(set([song.id for song in self.data.local.music.get_all_songs(self.game, self.music_version)])) + for song in all_songs: + clears = [] + fcs = [] + + for chart in [0, 1, 2, 3, 4, 5]: + placed = False + if song in attempts and chart in attempts[song]: + values = attempts[song][chart] + if values['total'] > 0: + clears.append(int((1000 * values['clears']) / values['total'])) + fcs.append(int((1000 * values['fcs']) / values['total'])) + placed = True + if not placed: + clears.append(1001) + fcs.append(1001) + + clearnode = Node.s32_array('c', clears + fcs) + clearnode.set_attribute('mid', str(song)) + root.add_child(clearnode) + + return root + + def handle_IIDX26music_getrank_request(self, request: Node) -> Node: + cltype = int(request.attribute('cltype')) + + root = Node.void('IIDX26music') + style = Node.void('style') + root.add_child(style) + style.set_attribute('type', str(cltype)) + + for rivalid in [-1, 0, 1, 2, 3, 4]: + if rivalid == -1: + attr = 'iidxid' + else: + attr = f'iidxid{rivalid}' + + try: + extid = int(request.attribute(attr)) + except Exception: + # Invalid extid + continue + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.music_version, userid) + + # Grab score data for user/rival + scoredata = self.make_score_struct( + scores, + self.CLEAR_TYPE_SINGLE if cltype == self.GAME_CLTYPE_SINGLE else self.CLEAR_TYPE_DOUBLE, + rivalid, + ) + for s in scoredata: + root.add_child(Node.s16_array('m', s)) + + # Grab most played for user/rival + most_played = [ + play[0] for play in + self.data.local.music.get_most_played(self.game, self.music_version, userid, 20) + ] + if len(most_played) < 20: + most_played.extend([0] * (20 - len(most_played))) + best = Node.u16_array('best', most_played) + best.set_attribute('rno', str(rivalid)) + root.add_child(best) + + if rivalid == -1: + # Grab beginner statuses for user only + beginnerdata = self.make_beginner_struct(scores) + for b in beginnerdata: + root.add_child(Node.u16_array('b', b)) + + return root + + def handle_IIDX26music_appoint_request(self, request: Node) -> Node: + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + ghost_type = int(request.attribute('ctype')) + extid = int(request.attribute('iidxid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + root = Node.void('IIDX26music') + + if userid is not None: + # Try to look up previous ghost for user + my_score = self.data.remote.music.get_score(self.game, self.music_version, userid, musicid, chart) + if my_score is not None: + mydata = Node.binary('mydata', my_score.data.get_bytes('ghost')) + mydata.set_attribute('score', str(my_score.points)) + root.add_child(mydata) + + ghost_score = self.get_ghost( + { + self.GAME_GHOST_TYPE_RIVAL: self.GHOST_TYPE_RIVAL, + self.GAME_GHOST_TYPE_GLOBAL_TOP: self.GHOST_TYPE_GLOBAL_TOP, + self.GAME_GHOST_TYPE_GLOBAL_AVERAGE: self.GHOST_TYPE_GLOBAL_AVERAGE, + self.GAME_GHOST_TYPE_LOCAL_TOP: self.GHOST_TYPE_LOCAL_TOP, + self.GAME_GHOST_TYPE_LOCAL_AVERAGE: self.GHOST_TYPE_LOCAL_AVERAGE, + self.GAME_GHOST_TYPE_DAN_TOP: self.GHOST_TYPE_DAN_TOP, + self.GAME_GHOST_TYPE_DAN_AVERAGE: self.GHOST_TYPE_DAN_AVERAGE, + self.GAME_GHOST_TYPE_RIVAL_TOP: self.GHOST_TYPE_RIVAL_TOP, + self.GAME_GHOST_TYPE_RIVAL_AVERAGE: self.GHOST_TYPE_RIVAL_AVERAGE, + }.get(ghost_type, self.GHOST_TYPE_NONE), + request.attribute('subtype'), + self.GAME_GHOST_LENGTH, + musicid, + chart, + userid, + ) + + # Add ghost score if we support it + if ghost_score is not None: + sdata = Node.binary('sdata', ghost_score['ghost']) + sdata.set_attribute('score', str(ghost_score['score'])) + if 'name' in ghost_score: + sdata.set_attribute('name', ghost_score['name']) + if 'pid' in ghost_score: + sdata.set_attribute('pid', str(ghost_score['pid'])) + if 'extid' in ghost_score: + sdata.set_attribute('riidxid', str(ghost_score['extid'])) + root.add_child(sdata) + + return root + + def handle_IIDX26music_breg_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + self.update_score( + userid, + musicid, + self.CHART_TYPE_B7, + clear_status, + pgreats, + greats, + -1, + b'', + None, + ) + + # Return nothing. + return Node.void('IIDX26music') + + def handle_IIDX26music_reg_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + # See if we need to report global or shop scores + if self.machine_joined_arcade(): + game_config = self.get_game_config() + global_scores = game_config.get_bool('global_shop_ranking') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + else: + # If we aren't in an arcade, we can only show global scores + global_scores = True + machine = None + + # First, determine our current ranking before saving the new score + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + all_players = { + uid: prof for (uid, prof) in + self.get_any_profiles([s[0] for s in all_scores]) + } + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + oldindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + oldindex = i + break + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + miss_count = int(request.attribute('mnum')) + ghost = request.child_value('ghost') + shopid = ID.parse_machine_id(request.attribute('convid')) + + self.update_score( + userid, + musicid, + chart, + clear_status, + pgreats, + greats, + miss_count, + ghost, + shopid, + ) + + # Calculate and return statistics about this song + root = Node.void('IIDX26music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((1000 * clear) / count))) + root.set_attribute('frate', str(int((1000 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + root.set_attribute('rankside', '0') + + if userid is not None: + # Shop ranking + shopdata = Node.void('shopdata') + root.add_child(shopdata) + shopdata.set_attribute('rank', '-1' if oldindex is None else str(oldindex + 1)) + + # Grab the rank of some other players on this song + ranklist = Node.void('ranklist') + root.add_child(ranklist) + + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + missing_players = [ + uid for (uid, _) in all_scores + if uid not in all_players + ] + for (uid, prof) in self.get_any_profiles(missing_players): + all_players[uid] = prof + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + ourindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + ourindex = i + break + if ourindex is None: + raise Exception('Cannot find our own score after saving to DB!') + start = ourindex - 4 + end = ourindex + 4 + if start < 0: + start = 0 + if end >= len(all_scores): + end = len(all_scores) - 1 + relevant_scores = all_scores[start:(end + 1)] + + record_num = start + 1 + for score in relevant_scores: + profile = all_players[score[0]] + + data = Node.void('data') + ranklist.add_child(data) + data.set_attribute('iidx_id', str(profile.get_int('extid'))) + data.set_attribute('name', profile.get_str('name')) + + machine_name = '' + if 'shop_location' in profile: + shop_id = profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + machine_name = machine.name + data.set_attribute('opname', machine_name) + data.set_attribute('rnum', str(record_num)) + data.set_attribute('score', str(score[1].points)) + data.set_attribute('clflg', str(self.db_to_game_status(score[1].data.get_int('clear_status')))) + data.set_attribute('pid', str(profile.get_int('pid'))) + data.set_attribute('myFlg', '1' if score[0] == userid else '0') + data.set_attribute('update', '0') + + data.set_attribute('sgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE), + )) + data.set_attribute('dgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE), + )) + + qpro = profile.get_dict('qpro') + data.set_attribute('head', str(qpro.get_int('head'))) + data.set_attribute('hair', str(qpro.get_int('hair'))) + data.set_attribute('face', str(qpro.get_int('face'))) + data.set_attribute('body', str(qpro.get_int('body'))) + data.set_attribute('hand', str(qpro.get_int('hand'))) + + record_num = record_num + 1 + + return root + + def handle_IIDX26music_play_request(self, request: Node) -> Node: + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + + self.update_score( + None, # No userid since its anonymous + musicid, + chart, + clear_status, + 0, # No ex score + 0, # No ex score + 0, # No miss count + None, # No ghost + None, # No shop for this user + ) + + # Calculate and return statistics about this song + root = Node.void('IIDX26music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((1000 * clear) / count))) + root.set_attribute('frate', str(int((1000 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + + return root + + # Bare minimum response to say we handled the request + def handle_IIDX26music_beginnerplay_request(self, request: Node) -> Node: + return Node.void('IIDX26music') + + def handle_IIDX26music_arenaCPU_request(self, request: Node) -> Node: + root = Node.void('IIDX26music') + music_list = [0, 0, 0, 0] + cpu_list = [0, 0, 0, 0] + grade_to_percentage_map = { + 6: 50, + 7: 55, + 8: 60, + 9: 60, + 10: 65, + 11: 65, + 12: 70, + 13: 70, + 14: 75, + 15: 75, + 16: 80, + 17: 80, + 18: 80, + } + for item in request.children: + if item.name == 'music_list': + music_list[int(item.child_value('index'))] = int(item.child_value('total_notes')) + if item.name == 'cpu_list': + cpu_list[int(item.child_value('index'))] = int(item.child_value('grade_id')) + for index in range(4): + cpu_score_list = Node.void('cpu_score_list') + cpu_score_list.add_child(Node.s32('index', index)) + root.add_child(cpu_score_list) + for i in range(4): + score_list = Node.void('score_list') + cpu_score_list.add_child(score_list) + percentage = grade_to_percentage_map.get(cpu_list[index], 0) + notes = music_list[i] + score = (notes * 2 * percentage) // 100 + ghost_section = score // 64 + ghost = [ghost_section] * 64 + ghost_total = ghost_section * 64 + ghost_lost = score - ghost_total + ghost[0] = ghost[0] + ghost_lost + score_list.add_child(Node.s32('score', score)) + score_list.add_child(Node.bool('enable_score', True)) + score_list.add_child(Node.u8_array('ghost', ghost)) + score_list.add_child(Node.bool('enable_ghost', True)) + return root + + def handle_IIDX26grade_raised_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + cltype = int(request.attribute('gtype')) + rank = self.game_to_db_rank(int(request.attribute('gid')), cltype) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + percent = int(request.attribute('achi')) + stages_cleared = int(request.attribute('cstage')) + cleared = stages_cleared == self.DAN_STAGES + + if cltype == self.GAME_CLTYPE_SINGLE: + index = self.DAN_RANKING_SINGLE + else: + index = self.DAN_RANKING_DOUBLE + + self.update_rank( + userid, + index, + rank, + percent, + cleared, + stages_cleared, + ) + + # Figure out number of players that played this ranking + all_achievements = self.data.local.user.get_all_achievements(self.game, self.version) + num_players = 0 + for [_, ach] in all_achievements: + if ach.type != index: + continue + if ach.id != rank: + continue + num_players = num_players + 1 + + root = Node.void('IIDX26grade') + root.set_attribute('pnum', str(num_players)) + return root + + def handle_IIDX26pc_common_request(self, request: Node) -> Node: + root = Node.void('IIDX26pc') + root.set_attribute('expire', '600') + + ir = Node.void('ir') + root.add_child(ir) + ir.set_attribute('beat', '2') + + escape_package_info = Node.void('escape_package_info') + root.add_child(escape_package_info) + + vip_black_pass = Node.void('vip_pass_black') + root.add_child(vip_black_pass) + + newsong_another = Node.void('newsong_another') + root.add_child(newsong_another) + newsong_another.set_attribute('open', '1') + + deller_bonus = Node.void('deller_bonus') + root.add_child(deller_bonus) + deller_bonus.set_attribute('open', '1') + + common_evnet = Node.void('common_evnet') # Yes, this is misspelled in the game + root.add_child(common_evnet) + common_evnet.set_attribute('flg', '0') + + # See if we configured event overrides + if self.machine_joined_arcade(): + game_config = self.get_game_config() + event_phase = game_config.get_int('event_phase') + omni_events = game_config.get_bool('omnimix_events_enabled') + else: + # If we aren't in an arcade, we turn off events + event_phase = 0 + omni_events = False + + if event_phase == 0 or (self.omnimix and (not omni_events)): + boss_phase = 0 + event1 = 0 + event2 = 0 + elif event_phase in [1, 2, 3, 4]: + boss_phase = 1 + event1 = event_phase - 1 + event2 = 0 + elif event_phase == 5: + boss_phase = 2 + event1 = 0 + event2 = 2 + + boss = Node.void('boss') + root.add_child(boss) + boss.set_attribute('phase', str(boss_phase)) + + event1_phase = Node.void('event1_phase') + root.add_child(event1_phase) + event1_phase.set_attribute('phase', str(event1)) + + event2_phase = Node.void('event2_phase') + root.add_child(event2_phase) + event2_phase.set_attribute('phase', str(event2)) + + extra_boss_event = Node.void('extra_boss_event') + root.add_child(extra_boss_event) + extra_boss_event.set_attribute('phase', '2') + + expert = Node.void('expert') + root.add_child(expert) + expert.set_attribute('phase', '1') + + expert_random_select = Node.void('expert_random_secret') + root.add_child(expert_random_select) + expert_random_select.set_attribute('phase', '1') + + expert_full = Node.void('expert_secret_full_open') + root.add_child(expert_full) + + # some new nodes for rootage + + system_voice = Node.void('system_voice_phase') + root.add_child(system_voice) + system_voice.set_attribute('phase', '1') + + anniv20 = Node.void('anniv20_phase') + root.add_child(anniv20) + anniv20.set_attribute('phase', '1') + + return root + + def handle_IIDX26pc_delete_request(self, request: Node) -> Node: + return Node.void('IIDX26pc') + + def handle_IIDX26pc_playstart_request(self, request: Node) -> Node: + return Node.void('IIDX26pc') + + def handle_IIDX26pc_playend_request(self, request: Node) -> Node: + return Node.void('IIDX26pc') + + def handle_IIDX26pc_visit_request(self, request: Node) -> Node: + root = Node.void('IIDX26pc') + root.set_attribute('anum', '0') + root.set_attribute('snum', '0') + root.set_attribute('pnum', '0') + root.set_attribute('aflg', '0') + root.set_attribute('sflg', '0') + root.set_attribute('pflg', '0') + return root + + def handle_IIDX26pc_shopregister_request(self, request: Node) -> Node: # Not used anymore???? + root = Node.void('IIDX26pc') + return root + + def handle_IIDX26pc_oldget_request(self, request: Node) -> Node: + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + + root = Node.void('IIDX26pc') + root.set_attribute('status', '1' if profile is None else '0') + return root + + def handle_IIDX26pc_getname_request(self, request: Node) -> Node: + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + if profile is None: + raise Exception( + 'Should not get here if we have no profile, we should ' + + 'have returned \'1\' in the \'oldget\' method above ' + + 'which should tell the game not to present a migration.' + ) + + root = Node.void('IIDX26pc') + root.set_attribute('name', profile.get_str('name')) + root.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + root.set_attribute('pid', str(profile.get_int('pid'))) + return root + + def handle_IIDX26pc_takeover_request(self, request: Node) -> Node: + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + newprofile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('IIDX26pc') + if newprofile is not None: + root.set_attribute('id', str(newprofile.get_int('extid'))) + return root + + def handle_IIDX26pc_reg_request(self, request: Node) -> Node: + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + profile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('IIDX26pc') + if profile is not None: + root.set_attribute('id', str(profile.get_int('extid'))) + root.set_attribute('id_str', ID.format_extid(profile.get_int('extid'))) + return root + + def handle_IIDX26pc_get_request(self, request: Node) -> Node: + refid = request.attribute('rid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('IIDX26pc') + # Shop register seems to be gone so let's just register a shop if one isn't already + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + profile = self.get_profile(userid) + if 'shop_location' not in profile: + location = ID.parse_machine_id(request.attribute('lid')) + profile.replace_int('shop_location', location) + self.put_profile(userid, profile) + return root + + def handle_IIDX26pc_save_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + self.put_profile_by_extid(extid, request) + + return Node.void('IIDX26pc') + + def handle_IIDX26pc_logout_request(self, request: Node) -> Node: + return Node.void('IIDX26pc') + + def handle_IIDX26pc_eaappliresult_request(self, request: Node) -> Node: + # first, we register the iidx webhook. this is done by sending all of our money to konami + if self.config['webhooks'][self.game] is None: + return Node.void('IIDX26pc') + webhook = DiscordWebhook(url=self.config['webhooks'][self.game]) + + clear_map = { + self.GAME_CLEAR_STATUS_NO_PLAY: 'NO PLAY', + self.GAME_CLEAR_STATUS_FAILED: 'FAILED', + self.GAME_CLEAR_STATUS_ASSIST_CLEAR: 'ASSIST CLEAR', + self.GAME_CLEAR_STATUS_EASY_CLEAR: 'EASY CLEAR', + self.GAME_CLEAR_STATUS_CLEAR: 'CLEAR', + self.GAME_CLEAR_STATUS_HARD_CLEAR: 'HARD CLEAR', + self.GAME_CLEAR_STATUS_EX_HARD_CLEAR: 'EX HARD CLEAR', + self.GAME_CLEAR_STATUS_FULL_COMBO: 'FULL COMBO', + } + # first we'll grab the data from the packet + # did = request.child_value('did') + # rid = request.child_value('rid') + name = request.child_value('name') + # qpro_hair = request.child_value('qpro_hair') + # qpro_head = request.child_value('qpro_head') + # qpro_body = request.child_value('qpro_body') + # qpro_hand = request.child_value('qpro_hand') + music_id = request.child_value('music_id') + class_id = request.child_value('class_id') + # no_save = request.child_value('no_save') + # is_couple = request.child_value('is_couple') + # target_graph = request.child_value('target_graph') + target_exscore = request.child_value('target_exscore') + # pacemaker = request.child_value('pacemaker') + best_clear = request.child_value('best_clear') + # best_djlevel = request.child_value('best_djlevel') + # best_exscore = request.child_value('best_exscore') + # best_misscount = request.child_value('best_misscount') + now_clear = request.child_value('now_clear') + # now_djlevel = request.child_value('now_djlevel') + now_exscore = request.child_value('now_exscore') + # now_misscount = request.child_value('now_misscount') + now_pgreat = request.child_value('now_pgreat') + now_great = request.child_value('now_great') + now_good = request.child_value('now_good') + now_bad = request.child_value('now_bad') + now_poor = request.child_value('now_poor') + now_combo = request.child_value('now_combo') + now_fast = request.child_value('now_fast') + now_slow = request.child_value('now_slow') + best_clear_string = clear_map.get(best_clear, 'NO PLAY') + now_clear_string = clear_map.get(now_clear, 'NO PLAY') + # let's get the song info first + song = self.data.local.music.get_song(self.game, self.music_version, music_id, self.game_to_db_chart(class_id)) + + # now we will build up the embed + now = datetime.now() + + # now, we generate the embed + scoreembed = DiscordEmbed(title='New IIDX Score!', description="Login to see whole score.", color='fbba08') + scoreembed.set_footer(text=(now.strftime('Score was recorded on %m/%d/%y at %H:%M:%S'))) + + # lets give it an author + scoreembed.set_author(name=self.config['name'], url=self.config['server']['uri']) + # Set `inline=False` for the embed field to occupy the whole line + scoreembed.add_embed_field(name='DJ Name', value=(name), inline=False) + scoreembed.add_embed_field(name='Song', value=(song.name), inline=False) + scoreembed.add_embed_field(name='Artist', value=(song.artist), inline=False) + scoreembed.add_embed_field(name='Difficulty', value=(song.data.get('difficulty', 0))) + scoreembed.add_embed_field(name='Target EXScore', value=(target_exscore)) + scoreembed.add_embed_field(name='Your EXScore', value=(now_exscore)) + scoreembed.add_embed_field(name='Best Clear', value=best_clear_string) + scoreembed.add_embed_field(name='Clear Status', value=now_clear_string) + scoreembed.add_embed_field(name='Note Counts', value='How did you do?', inline=False) + scoreembed.add_embed_field(name=('Perfect Greats'), value=((now_pgreat))) + scoreembed.add_embed_field(name=('Greats'), value=((now_great))) + scoreembed.add_embed_field(name=('Goods'), value=((now_good))) + scoreembed.add_embed_field(name=('Bads'), value=((now_bad))) + scoreembed.add_embed_field(name=('Poors'), value=((now_poor))) + + scoreembed.add_embed_field(name='Combo Breaks', value=(now_combo)) + + # scoreembed.add_embed_field(name='Timing Stats', value='Were you on time?', inline = False) + scoreembed.add_embed_field(name=('Slow'), value=((now_slow))) + scoreembed.add_embed_field(name=('Fast'), value=((now_fast))) + + # add embed object to webhook + webhook.add_embed(scoreembed) + + # now we send the webhook! + response = webhook.execute() + + end = Node.void('IIDX26pc') + + return end + + def handle_IIDX26gameSystem_systemInfo_request(self, request: Node) -> Node: + root = Node.void('IIDX26gameSystem') + arena_schedule = Node.void('arena_schedule') + root.add_child(arena_schedule) + arena_schedule.add_child(Node.u8('phase', 1)) + arena_schedule.add_child(Node.u32('start', 0)) + arena_schedule.add_child(Node.u32('end', 0)) + cpu_grades = [6, 7, 8, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 18] + cpu_low_diff = [4, 5, 6, 6, 7, 7, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 11, 12, 12] + cpu_high_diff = [5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 12, 12] + for playstyle in range(2): + for arena_class in range(20): + arena_music_difficult = Node.void('arena_music_difficult') + root.add_child(arena_music_difficult) + arena_music_difficult.add_child(Node.s32('play_style', playstyle)) + arena_music_difficult.add_child(Node.s32('arena_class', arena_class)) + arena_music_difficult.add_child(Node.s32('low_difficult', 1)) + arena_music_difficult.add_child(Node.s32('high_difficult', 12)) + arena_music_difficult.add_child(Node.bool('is_leggendaria', True)) + arena_music_difficult.add_child(Node.s32('force_music_list_id', 0)) + arena_cpu_define = Node.void('arena_cpu_define') + root.add_child(arena_cpu_define) + arena_cpu_define.add_child(Node.s32('play_style', playstyle)) + arena_cpu_define.add_child(Node.s32('arena_class', arena_class)) + arena_cpu_define.add_child(Node.s32('grade_id', cpu_grades[arena_class])) + arena_cpu_define.add_child(Node.s32('low_music_difficult', cpu_low_diff[arena_class])) + arena_cpu_define.add_child(Node.s32('high_music_difficult', cpu_high_diff[arena_class])) + arena_cpu_define.add_child(Node.bool('is_leggendaria', arena_class >= 13)) + for matching_class in range(21): + matching_class_range = Node.void('matching_class_range') + root.add_child(matching_class_range) + matching_class_range.add_child(Node.s32('play_style', playstyle)) + matching_class_range.add_child(Node.s32('matching_class', matching_class)) + matching_class_range.add_child(Node.s32('low_arena_class', 1)) + matching_class_range.add_child(Node.s32('high_arena_class', 20)) + return root + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('IIDX26pc') + + # Look up play stats we bridge to every mix + play_stats = self.get_play_statistics(userid) + + # Look up judge window adjustments + judge_dict = profile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + + # Profile data + pcdata = Node.void('pcdata') + root.add_child(pcdata) + pcdata.set_attribute('id', str(profile.get_int('extid'))) + pcdata.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + pcdata.set_attribute('name', profile.get_str('name')) + pcdata.set_attribute('pid', str(profile.get_int('pid'))) + pcdata.set_attribute('spnum', str(play_stats.get_int('single_plays'))) + pcdata.set_attribute('dpnum', str(play_stats.get_int('double_plays'))) + pcdata.set_attribute('sach', str(play_stats.get_int('single_dj_points'))) + pcdata.set_attribute('dach', str(play_stats.get_int('double_dj_points'))) + pcdata.set_attribute('mode', str(profile.get_int('mode'))) + pcdata.set_attribute('pmode', str(profile.get_int('pmode'))) + pcdata.set_attribute('rtype', str(profile.get_int('rtype'))) + pcdata.set_attribute('sp_opt', str(profile.get_int('sp_opt'))) + pcdata.set_attribute('dp_opt', str(profile.get_int('dp_opt'))) + pcdata.set_attribute('dp_opt2', str(profile.get_int('dp_opt2'))) + pcdata.set_attribute('gpos', str(profile.get_int('gpos'))) + pcdata.set_attribute('s_sorttype', str(profile.get_int('s_sorttype'))) + pcdata.set_attribute('d_sorttype', str(profile.get_int('d_sorttype'))) + pcdata.set_attribute('s_pace', str(profile.get_int('s_pace'))) + pcdata.set_attribute('d_pace', str(profile.get_int('d_pace'))) + pcdata.set_attribute('s_gno', str(profile.get_int('s_gno'))) + pcdata.set_attribute('d_gno', str(profile.get_int('d_gno'))) + pcdata.set_attribute('s_gtype', str(profile.get_int('s_gtype'))) + pcdata.set_attribute('d_gtype', str(profile.get_int('d_gtype'))) + pcdata.set_attribute('s_sdlen', str(profile.get_int('s_sdlen'))) + pcdata.set_attribute('d_sdlen', str(profile.get_int('d_sdlen'))) + pcdata.set_attribute('s_sdtype', str(profile.get_int('s_sdtype'))) + pcdata.set_attribute('d_sdtype', str(profile.get_int('d_sdtype'))) + pcdata.set_attribute('s_timing', str(profile.get_int('s_timing'))) + pcdata.set_attribute('d_timing', str(profile.get_int('d_timing'))) + pcdata.set_attribute('s_notes', str(profile.get_float('s_notes'))) + pcdata.set_attribute('d_notes', str(profile.get_float('d_notes'))) + pcdata.set_attribute('s_judge', str(profile.get_int('s_judge'))) + pcdata.set_attribute('d_judge', str(profile.get_int('d_judge'))) + pcdata.set_attribute('s_judgeAdj', str(machine_judge.get_int('single'))) + pcdata.set_attribute('d_judgeAdj', str(machine_judge.get_int('double'))) + pcdata.set_attribute('s_hispeed', str(profile.get_float('s_hispeed'))) + pcdata.set_attribute('d_hispeed', str(profile.get_float('d_hispeed'))) + pcdata.set_attribute('s_liflen', str(profile.get_int('s_lift'))) + pcdata.set_attribute('d_liflen', str(profile.get_int('d_lift'))) + pcdata.set_attribute('s_disp_judge', str(profile.get_int('s_disp_judge'))) + pcdata.set_attribute('d_disp_judge', str(profile.get_int('d_disp_judge'))) + pcdata.set_attribute('s_opstyle', str(profile.get_int('s_opstyle'))) + pcdata.set_attribute('d_opstyle', str(profile.get_int('d_opstyle'))) + pcdata.set_attribute('s_graph_score', str(profile.get_int('s_graph_score'))) + pcdata.set_attribute('d_graph_score', str(profile.get_int('d_graph_score'))) + pcdata.set_attribute('s_auto_scrach', str(profile.get_int('s_auto_scrach'))) + pcdata.set_attribute('d_auto_scrach', str(profile.get_int('d_auto_scrach'))) + pcdata.set_attribute('s_gauge_disp', str(profile.get_int('s_gauge_disp'))) + pcdata.set_attribute('d_gauge_disp', str(profile.get_int('d_gauge_disp'))) + pcdata.set_attribute('s_lane_brignt', str(profile.get_int('s_lane_brignt'))) + pcdata.set_attribute('d_lane_brignt', str(profile.get_int('d_lane_brignt'))) + pcdata.set_attribute('s_camera_layout', str(profile.get_int('s_camera_layout'))) + pcdata.set_attribute('d_camera_layout', str(profile.get_int('d_camera_layout'))) + pcdata.set_attribute('s_ghost_score', str(profile.get_int('s_ghost_score'))) + pcdata.set_attribute('d_ghost_score', str(profile.get_int('d_ghost_score'))) + pcdata.set_attribute('s_tsujigiri_disp', str(profile.get_int('s_tsujigiri_disp'))) + pcdata.set_attribute('d_tsujigiri_disp', str(profile.get_int('d_tsujigiri_disp'))) + + # KAC stuff + # kac_entry_info = Node.void('kac_entry_info') + # root.add_child(kac_entry_info) + # kac_entry_info.add_child(Node.void('enable_kac_deller')) + # kac_entry_info.add_child(Node.void('disp_kac_mark')) + # kac_entry_info.add_child(Node.void('is_kac_evnet_entry')) + # kac_secret_music = Node.void('kac_secret_music') + # kac_entry_info.add_child(kac_secret_music) + + legendarias = Node.void('leggendaria_open') + root.add_child(legendarias) + + premium_unlocks = Node.void('ea_premium_course') + root.add_child(premium_unlocks) + + # Song unlock flags + secret_dict = profile.get_dict('secret') + secret = Node.void('secret') + root.add_child(secret) + secret.add_child(Node.s64_array('flg1', secret_dict.get_int_array('flg1', 3))) + secret.add_child(Node.s64_array('flg2', secret_dict.get_int_array('flg2', 3))) + secret.add_child(Node.s64_array('flg3', secret_dict.get_int_array('flg3', 3))) + + # Favorites + for folder in ['favorite1', 'favorite2', 'favorite3']: + favorite_dict = profile.get_dict(folder) + sp_mlist = b'' + sp_clist = b'' + singles_list = favorite_dict['single'] if 'single' in favorite_dict else [] + for single in singles_list: + sp_mlist = sp_mlist + struct.pack(' ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + play_stats = self.get_play_statistics(userid) + + # Track play counts + cltype = int(request.attribute('cltype')) + if cltype == self.GAME_CLTYPE_SINGLE: + play_stats.increment_int('single_plays') + if cltype == self.GAME_CLTYPE_DOUBLE: + play_stats.increment_int('double_plays') + + # Track DJ points + play_stats.replace_int('single_dj_points', int(request.attribute('s_achi'))) + play_stats.replace_int('double_dj_points', int(request.attribute('d_achi'))) + + # Profile settings + newprofile.replace_int('sp_opt', int(request.attribute('sp_opt'))) + newprofile.replace_int('dp_opt', int(request.attribute('dp_opt'))) + newprofile.replace_int('dp_opt2', int(request.attribute('dp_opt2'))) + newprofile.replace_int('gpos', int(request.attribute('gpos'))) + newprofile.replace_int('s_sorttype', int(request.attribute('s_sorttype'))) + newprofile.replace_int('d_sorttype', int(request.attribute('d_sorttype'))) + newprofile.replace_int('s_disp_judge', int(request.attribute('s_disp_judge'))) + newprofile.replace_int('d_disp_judge', int(request.attribute('d_disp_judge'))) + newprofile.replace_int('s_pace', int(request.attribute('s_pace'))) + newprofile.replace_int('d_pace', int(request.attribute('d_pace'))) + newprofile.replace_int('s_gno', int(request.attribute('s_gno'))) + newprofile.replace_int('d_gno', int(request.attribute('d_gno'))) + newprofile.replace_int('s_gtype', int(request.attribute('s_gtype'))) + newprofile.replace_int('d_gtype', int(request.attribute('d_gtype'))) + newprofile.replace_int('s_sdlen', int(request.attribute('s_sdlen'))) + newprofile.replace_int('d_sdlen', int(request.attribute('d_sdlen'))) + newprofile.replace_int('s_sdtype', int(request.attribute('s_sdtype'))) + newprofile.replace_int('d_sdtype', int(request.attribute('d_sdtype'))) + newprofile.replace_int('s_timing', int(request.attribute('s_timing'))) + newprofile.replace_int('d_timing', int(request.attribute('d_timing'))) + newprofile.replace_float('s_notes', float(request.attribute('s_notes'))) + newprofile.replace_float('d_notes', float(request.attribute('d_notes'))) + newprofile.replace_int('s_judge', int(request.attribute('s_judge'))) + newprofile.replace_int('d_judge', int(request.attribute('d_judge'))) + newprofile.replace_float('s_hispeed', float(request.attribute('s_hispeed'))) + newprofile.replace_float('d_hispeed', float(request.attribute('d_hispeed'))) + newprofile.replace_int('s_opstyle', int(request.attribute('s_opstyle'))) + newprofile.replace_int('d_opstyle', int(request.attribute('d_opstyle'))) + newprofile.replace_int('s_graph_score', int(request.attribute('s_graph_score'))) + newprofile.replace_int('d_graph_score', int(request.attribute('d_graph_score'))) + newprofile.replace_int('s_auto_scrach', int(request.attribute('s_auto_scrach'))) + newprofile.replace_int('d_auto_scrach', int(request.attribute('d_auto_scrach'))) + newprofile.replace_int('s_gauge_disp', int(request.attribute('s_gauge_disp'))) + newprofile.replace_int('d_gauge_disp', int(request.attribute('d_gauge_disp'))) + newprofile.replace_int('s_lane_brignt', int(request.attribute('s_lane_brignt'))) + newprofile.replace_int('d_lane_brignt', int(request.attribute('d_lane_brignt'))) + newprofile.replace_int('s_camera_layout', int(request.attribute('s_camera_layout'))) + newprofile.replace_int('d_camera_layout', int(request.attribute('d_camera_layout'))) + newprofile.replace_int('s_ghost_score', int(request.attribute('s_ghost_score'))) + newprofile.replace_int('d_ghost_score', int(request.attribute('d_ghost_score'))) + newprofile.replace_int('s_tsujigiri_disp', int(request.attribute('s_tsujigiri_disp'))) + newprofile.replace_int('d_tsujigiri_disp', int(request.attribute('d_tsujigiri_disp'))) + newprofile.replace_int('s_lift', int(request.attribute('s_lift'))) + newprofile.replace_int('d_lift', int(request.attribute('d_lift'))) + newprofile.replace_int('mode', int(request.attribute('mode'))) + newprofile.replace_int('pmode', int(request.attribute('pmode'))) + newprofile.replace_int('rtype', int(request.attribute('rtype'))) + + # Update judge window adjustments per-machine + judge_dict = newprofile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + machine_judge.replace_int('single', int(request.attribute('s_judgeAdj'))) + machine_judge.replace_int('double', int(request.attribute('d_judgeAdj'))) + judge_dict.replace_dict(self.config['machine']['pcbid'], machine_judge) + newprofile.replace_dict('machine_judge_adjust', judge_dict) + + # Secret flags saving + secret = request.child('secret') + if secret is not None: + secret_dict = newprofile.get_dict('secret') + secret_dict.replace_int_array('flg1', 3, secret.child_value('flg1')) + secret_dict.replace_int_array('flg2', 3, secret.child_value('flg2')) + secret_dict.replace_int_array('flg3', 3, secret.child_value('flg3')) + newprofile.replace_dict('secret', secret_dict) + + # Basic achievements + achievements = request.child('achievements') + if achievements is not None: + newprofile.replace_int('visit_flg', int(achievements.attribute('visit_flg'))) + newprofile.replace_int('last_weekly', int(achievements.attribute('last_weekly'))) + newprofile.replace_int('weekly_num', int(achievements.attribute('weekly_num'))) + + pack_id = int(achievements.attribute('pack_id')) + if pack_id > 0: + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + pack_id, + 'daily', + { + 'pack_flg': int(achievements.attribute('pack_flg')), + 'pack_comp': int(achievements.attribute('pack_comp')), + }, + ) + + trophies = achievements.child('trophy') + if trophies is not None: + # We only load the first 20 in profile load. + newprofile.replace_int_array('trophy', 20, trophies.value[:20]) + + # Deller saving + deller = request.child('deller') + if deller is not None: + newprofile.replace_int('deller', newprofile.get_int('deller') + int(deller.attribute('deller'))) + + # Secret course expert point saving + expert_point = request.child('expert_point') + if expert_point is not None: + courseid = int(expert_point.attribute('course_id')) + + # Update achievement to track expert points + expert_point_achievement = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + courseid, + 'expert_point', + ) + if expert_point_achievement is None: + expert_point_achievement = ValidatedDict() + expert_point_achievement.replace_int( + 'normal_points', + int(expert_point.attribute('n_point')), + ) + expert_point_achievement.replace_int( + 'hyper_points', + int(expert_point.attribute('h_point')), + ) + expert_point_achievement.replace_int( + 'another_points', + int(expert_point.attribute('a_point')), + ) + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + courseid, + 'expert_point', + expert_point_achievement, + ) + + # Favorites saving + for favorite in request.children: + singles = [] + doubles = [] + name = None + if favorite.name in ['favorite', 'extra_favorite']: + if favorite.name == 'favorite': + name = 'favorite1' + elif favorite.name == 'extra_favorite': + folder = favorite.attribute('folder_id') + if folder == '0': + name = 'favorite2' + if folder == '1': + name = 'favorite3' + if name is None: + continue + + single_music_bin = favorite.child_value('sp_mlist') + single_chart_bin = favorite.child_value('sp_clist') + double_music_bin = favorite.child_value('dp_mlist') + double_chart_bin = favorite.child_value('dp_clist') + + for i in range(self.FAVORITE_LIST_LENGTH): + singles.append({ + 'id': struct.unpack(' None: - if version in ['20', '21', '22', '23', '24']: + if version in ['20', '21', '22', '23', '24', '25', '26']: actual_version = { '20': VersionConstants.IIDX_TRICORO, '21': VersionConstants.IIDX_SPADA, '22': VersionConstants.IIDX_PENDUAL, '23': VersionConstants.IIDX_COPULA, '24': VersionConstants.IIDX_SINOBUZ, + '25': VersionConstants.IIDX_CANNON_BALLERS, + '26': VersionConstants.IIDX_ROOTAGE, }[version] self.charts = [0, 1, 2, 3, 4, 5, 6] - elif version in ['omni-20', 'omni-21', 'omni-22', 'omni-23', 'omni-24']: + elif version in ['omni-20', 'omni-21', 'omni-22', 'omni-23', 'omni-24', 'omni-25', 'omni-26']: actual_version = { 'omni-20': VersionConstants.IIDX_TRICORO, 'omni-21': VersionConstants.IIDX_SPADA, 'omni-22': VersionConstants.IIDX_PENDUAL, 'omni-23': VersionConstants.IIDX_COPULA, 'omni-24': VersionConstants.IIDX_SINOBUZ, + 'omni-25': VersionConstants.IIDX_CANNON_BALLERS, + 'omni-26': VersionConstants.IIDX_ROOTAGE, }[version] + DBConstants.OMNIMIX_VERSION_BUMP self.charts = [0, 1, 2, 3, 4, 5, 6] elif version == 'all': actual_version = None self.charts = [0, 1, 2, 3, 4, 5, 6] else: - raise Exception("Unsupported IIDX version, expected one of the following: 20, 21, 22, 23, 24, omni-20, omni-21, omni-22, omni-23, omni-24!") + raise Exception("Unsupported IIDX version, expected one of the following: 20, 21, 22, 23, 24, 25, 26, omni-20, omni-21, omni-22, omni-23, omni-24, omni-25, omni-26!") super().__init__(config, GameConstants.IIDX, actual_version, no_combine, update) @@ -1545,6 +1549,18 @@ class ImportIIDX(ImportBase): 16212: 21066, 22096: 23030, 22097: 23051, + 21214: 11101, + 21221: 14101, + 21225: 15104, + 21226: 15102, + 21231: 15101, + 21237: 15103, + 21240: 16105, + 21242: 16104, + 21253: 16103, + 21258: 16102, + 21262: 16101, + 21220: 14100, } # Some charts were changed, and others kept the same on these if chart in [0, 1, 2]: @@ -1581,6 +1597,18 @@ class ImportIIDX(ImportBase): 21066: 16212, 23030: 22096, 23051: 22097, + 11101: 21214, + 14101: 21221, + 15104: 21225, + 15102: 21226, + 15101: 21231, + 15103: 21237, + 16105: 21240, + 16104: 21242, + 16103: 21253, + 16102: 21258, + 16101: 21262, + 14100: 21220, } # Some charts were changed, and others kept the same on tehse if chart in [0, 1, 2]: @@ -1605,7 +1633,7 @@ class ImportIIDX(ImportBase): return 1 return chart - def scrape(self, binfile: str, assets_dir: Optional[str]) -> List[Dict[str, Any]]: + def scrape(self, binfile: str, assets_dir: Optional[str]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: if self.version is None: raise Exception('Can\'t import IIDX database for \'all\' version!') @@ -1620,73 +1648,250 @@ class ImportIIDX(ImportBase): finally: bh.close() - musicdb = IIDXMusicDB(binarydata) + import_qpros = True # by default, try to import qpros + try: + pe = pefile.PE(data=binarydata, fast_load=True) + except BaseException: + import_qpros = False # if it failed then we're reading a music db file, not the executable + + def virtual_to_physical(offset: int) -> int: + for section in pe.sections: + start = section.VirtualAddress + pe.OPTIONAL_HEADER.ImageBase + end = start + section.SizeOfRawData + + if offset >= start and offset < end: + return (offset - start) + section.PointerToRawData + raise Exception(f'Couldn\'t find raw offset for virtual offset 0x{offset:08x}') + songs: List[Dict[str, Any]] = [] - for song in musicdb.songs: - bpm = (0, 0) - notecounts = [0, 0, 0, 0, 0, 0] + if not import_qpros: + musicdb = IIDXMusicDB(binarydata) + for song in musicdb.songs: + bpm = (0, 0) + notecounts = [0, 0, 0, 0, 0, 0] - if song.id in self.BANNED_CHARTS: - continue + if song.id in self.BANNED_CHARTS: + continue - if sound_files is not None: - if song.id in sound_files: - # Look up chart info! - filename = sound_files[song.id] - _, extension = os.path.splitext(filename) - data = None + if sound_files is not None: + if song.id in sound_files: + # Look up chart info! + filename = sound_files[song.id] + _, extension = os.path.splitext(filename) + data = None - if extension == '.1': - fp = open(filename, 'rb') - data = fp.read() - fp.close() + if extension == '.1': + fp = open(filename, 'rb') + data = fp.read() + fp.close() + else: + fp = open(filename, 'rb') + ifsdata = fp.read() + fp.close() + ifs = IFS(ifsdata) + for fn in ifs.filenames: + _, extension = os.path.splitext(fn) + if extension == '.1': + data = ifs.read_file(fn) + + if data is not None: + iidxchart = IIDXChart(data) + bpm_min, bpm_max = iidxchart.bpm + bpm = (bpm_min, bpm_max) + notecounts = iidxchart.notecounts + else: + print(f"Could not find chart information for song {song.id}!") else: - fp = open(filename, 'rb') - ifsdata = fp.read() - fp.close() - ifs = IFS(ifsdata) - for fn in ifs.filenames: - _, extension = os.path.splitext(fn) - if extension == '.1': - data = ifs.read_file(fn) + print(f"No chart information because chart for song {song.id} is missing!") + songs.append({ + 'id': song.id, + 'title': song.title, + 'artist': song.artist, + 'genre': song.genre, + 'bpm_min': bpm[0], + 'bpm_max': bpm[1], + 'difficulty': { + 'spn': song.difficulties[0], + 'sph': song.difficulties[1], + 'spa': song.difficulties[2], + 'dpn': song.difficulties[3], + 'dph': song.difficulties[4], + 'dpa': song.difficulties[5], + }, + 'notecount': { + 'spn': notecounts[0], + 'sph': notecounts[1], + 'spa': notecounts[2], + 'dpn': notecounts[3], + 'dph': notecounts[4], + 'dpa': notecounts[5], + }, + }) - if data is not None: - iidxchart = IIDXChart(data) - bpm_min, bpm_max = iidxchart.bpm - bpm = (bpm_min, bpm_max) - notecounts = iidxchart.notecounts - else: - print(f"Could not find chart information for song {song.id}!") + qpros: List[Dict[str, Any]] = [] + if self.version == VersionConstants.IIDX_TRICORO: + stride = 4 + qp_head_offset = 0x1CCB18 # qpro body parts are stored in 5 separate arrays in the game data, since there can be collision in + qp_head_length = 79 # the qpro id numbers, it's best to store them as separate types in the catalog as well. + qp_hair_offset = 0x1CCC58 + qp_hair_length = 103 + qp_face_offset = 0x1CCDF8 + qp_face_length = 50 + qp_hand_offset = 0x1CCEC0 + qp_hand_length = 103 + qp_body_offset = 0x1CD060 + qp_body_length = 106 + filename_offset = 0 + packedfmt = ( + 'I' # filename + ) + if self.version == VersionConstants.IIDX_SPADA: + stride = 4 + qp_head_offset = 0x213B50 # qpro body parts are stored in 5 separate arrays in the game data, since there can be collision in + qp_head_length = 125 # the qpro id numbers, it's best to store them as separate types in the catalog as well. + qp_hair_offset = 0x213D48 + qp_hair_length = 126 + qp_face_offset = 0x213F40 + qp_face_length = 72 + qp_hand_offset = 0x214060 + qp_hand_length = 135 + qp_body_offset = 0x214280 + qp_body_length = 135 + filename_offset = 0 + packedfmt = ( + 'I' # filename + ) + if self.version == VersionConstants.IIDX_PENDUAL: + stride = 4 + qp_head_offset = 0x1D5228 # qpro body parts are stored in 5 separate arrays in the game data, since there can be collision in + qp_head_length = 163 # the qpro id numbers, it's best to store them as separate types in the catalog as well. + qp_hair_offset = 0x1D54B8 + qp_hair_length = 182 + qp_face_offset = 0x1D5790 + qp_face_length = 106 + qp_hand_offset = 0x1D5938 + qp_hand_length = 184 + qp_body_offset = 0x1D5C18 + qp_body_length = 191 + filename_offset = 0 + packedfmt = ( + 'I' # filename + ) + if self.version == VersionConstants.IIDX_COPULA: + stride = 8 + qp_head_offset = 0x12F9D8 # qpro body parts are stored in 5 separate arrays in the game data, since there can be collision in + qp_head_length = 186 # the qpro id numbers, it's best to store them as separate types in the catalog as well. + qp_hair_offset = 0x12FFA8 + qp_hair_length = 202 + qp_face_offset = 0x1305F8 + qp_face_length = 126 + qp_hand_offset = 0x1309E8 + qp_hand_length = 206 + qp_body_offset = 0x131058 + qp_body_length = 211 + filename_offset = 0 + qpro_id_offset = 1 + packedfmt = ( + 'I' # filename + 'I' # string containing id and name of the part + ) + if self.version == VersionConstants.IIDX_SINOBUZ: + stride = 8 + qp_head_offset = 0x149F88 # qpro body parts are stored in 5 separate arrays in the game data, since there can be collision in + qp_head_length = 211 # the qpro id numbers, it's best to store them as separate types in the catalog as well. + qp_hair_offset = 0x14A620 + qp_hair_length = 245 + qp_face_offset = 0x14ADC8 + qp_face_length = 152 + qp_hand_offset = 0x14B288 + qp_hand_length = 236 + qp_body_offset = 0x14B9E8 + qp_body_length = 256 + filename_offset = 0 + qpro_id_offset = 1 + packedfmt = ( + 'I' # filename + 'I' # string containing id and name of the part + ) + if self.version == VersionConstants.IIDX_CANNON_BALLERS: + stride = 16 + qp_head_offset = 0x2339E0 # qpro body parts are stored in 5 separate arrays in the game data, since there can be collision in + qp_head_length = 231 # the qpro id numbers, it's best to store them as separate types in the catalog as well. + qp_hair_offset = 0x234850 + qp_hair_length = 267 + qp_face_offset = 0x235900 + qp_face_length = 173 + qp_hand_offset = 0x2363D0 + qp_hand_length = 261 + qp_body_offset = 0x237420 + qp_body_length = 282 + filename_offset = 0 + qpro_id_offset = 1 + packedfmt = ( + 'L' # filename + 'L' # string containing id and name of the part + ) + if self.version == VersionConstants.IIDX_ROOTAGE: + stride = 16 + qp_head_offset = 0x5065F0 # qpro body parts are stored in 5 separate arrays in the game data, since there can be collision in + qp_head_length = 259 # the qpro id numbers, it's best to store them as separate types in the catalog as well. + qp_hair_offset = 0x507620 + qp_hair_length = 288 + qp_face_offset = 0x508820 + qp_face_length = 193 + qp_hand_offset = 0x509430 + qp_hand_length = 287 + qp_body_offset = 0x50A620 + qp_body_length = 304 + filename_offset = 0 + qpro_id_offset = 1 + packedfmt = ( + 'L' # filename + 'L' # string containing id and name of the part + ) + + def read_string(offset: int) -> str: + # First, translate load offset in memory to disk offset + offset = virtual_to_physical(offset) + + # Now, grab bytes until we're null-terminated + bytestring = [] + while binarydata[offset] != 0: + bytestring.append(binarydata[offset]) + offset = offset + 1 + + # Its shift-jis encoded, so decode it now + return bytes(bytestring).decode('shift_jisx0213') + + def read_qpro_db(offset: int, length: int, qp_type: str) -> None: + for qpro_id in range(length): + chunkoffset = offset + (stride * qpro_id) + chunkdata = binarydata[chunkoffset:(chunkoffset + stride)] + unpacked = struct.unpack(packedfmt, chunkdata) + filename = read_string(unpacked[filename_offset]).replace('qp_', '') + remove = f'_{qp_type}.ifs' + filename = filename.replace(remove, '').replace('_head1.ifs', '').replace('_head2.ifs', '') + if self.version in [VersionConstants.IIDX_TRICORO, VersionConstants.IIDX_SPADA, VersionConstants.IIDX_PENDUAL]: + name = filename # qpro names are not stored in these 3 games so use the identifier instead else: - print(f"No chart information because chart for song {song.id} is missing!") + name = read_string(unpacked[qpro_id_offset])[4:] # qpro name is stored in second string of form "000:name" + qproinfo = { + 'identifier': filename, + 'id': qpro_id, + 'name': name, + 'type': qp_type, + } + qpros.append(qproinfo) + if import_qpros: + read_qpro_db(qp_head_offset, qp_head_length, 'head') + read_qpro_db(qp_hair_offset, qp_hair_length, 'hair') + read_qpro_db(qp_face_offset, qp_face_length, 'face') + read_qpro_db(qp_hand_offset, qp_hand_length, 'hand') + read_qpro_db(qp_body_offset, qp_body_length, 'body') - songs.append({ - 'id': song.id, - 'title': song.title, - 'artist': song.artist, - 'genre': song.genre, - 'bpm_min': bpm[0], - 'bpm_max': bpm[1], - 'difficulty': { - 'spn': song.difficulties[0], - 'sph': song.difficulties[1], - 'spa': song.difficulties[2], - 'dpn': song.difficulties[3], - 'dph': song.difficulties[4], - 'dpa': song.difficulties[5], - }, - 'notecount': { - 'spn': notecounts[0], - 'sph': notecounts[1], - 'spa': notecounts[2], - 'dpn': notecounts[3], - 'dph': notecounts[4], - 'dpa': notecounts[5], - }, - }) - return songs + return songs, qpros - def lookup(self, server: str, token: str) -> List[Dict[str, Any]]: + def lookup(self, server: str, token: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: if self.version is None: raise Exception('Can\'t look up IIDX database for \'all\' version!') @@ -1741,7 +1946,18 @@ class ImportIIDX(ImportBase): lut[song.id]['notecount'][chart_map[song.chart]] = song.data.get_int('notecount') # Return the reassembled data - return [val for _, val in lut.items()] + qpros: List[Dict[str, Any]] = [] + game = self.remote_game(server, token) + for item in game.get_items(self.game, self.version): + if 'qp_' in item.type: + qpros.append({ + 'identifier': item.data.get_str('identifier'), + 'id': item.id, + 'name': item.data.get_str('name'), + 'type': item.data.get_str('type'), + }) + + return [val for _, val in lut.items()], qpros def import_music_db(self, songs: List[Dict[str, Any]]) -> None: if self.version is None: @@ -1782,6 +1998,29 @@ class ImportIIDX(ImportBase): self.insert_music_id_for_song(next_id, song['id'], chart, song['title'], song['artist'], song['genre'], songdata) self.finish_batch() + def import_qpros(self, qpros: List[Dict[str, Any]]) -> None: + if self.version is None: + raise Exception('Can\'t import IIDX database for \'all\' version!') + + self.start_batch() + for i, qpro in enumerate(qpros): + # Make importing faster but still do it in chunks + if (i % 16) == 15: + self.finish_batch() + self.start_batch() + + print(f"New catalog entry for {qpro['id']}") + self.insert_catalog_entry( + f"qp_{qpro['type']}", + qpro['id'], + { + 'name': qpro['name'], + 'identifier': qpro['identifier'], + }, + ) + + self.finish_batch() + def import_metadata(self, tsvfile: str) -> None: if self.version is not None: raise Exception("Unsupported IIDX version, expected one of the following: all") @@ -3482,15 +3721,16 @@ if __name__ == "__main__": else: # Normal case, doing a music DB import. if args.bin is not None: - songs = iidx.scrape(args.bin, args.assets) + songs, qpros = iidx.scrape(args.bin, args.assets) elif args.server and args.token: - songs = iidx.lookup(args.server, args.token) + songs, qpros = iidx.lookup(args.server, args.token) else: raise Exception( 'No music_data.bin or TSV provided and no remote server specified! Please ' + 'provide either a --bin, --tsv or a --server and --token option!' ) iidx.import_music_db(songs) + iidx.import_qpros(qpros) iidx.close() elif args.series == GameConstants.DDR: