diff --git a/bemani/backend/popn/factory.py b/bemani/backend/popn/factory.py index 0fda189..2fa2b6d 100644 --- a/bemani/backend/popn/factory.py +++ b/bemani/backend/popn/factory.py @@ -28,6 +28,7 @@ from bemani.backend.popn.lapistoria import PopnMusicLapistoria from bemani.backend.popn.eclale import PopnMusicEclale from bemani.backend.popn.usaneko import PopnMusicUsaNeko from bemani.backend.popn.peace import PopnMusicPeace +from bemani.backend.popn.kaimei import PopnMusicKaimei from bemani.common import Model, VersionConstants from bemani.data import Config, Data @@ -60,6 +61,7 @@ class PopnMusicFactory(Factory): PopnMusicEclale, PopnMusicUsaNeko, PopnMusicPeace, + PopnMusicKaimei, ] @classmethod @@ -79,8 +81,10 @@ class PopnMusicFactory(Factory): return VersionConstants.POPN_MUSIC_ECLALE if date >= 2016121400 and date < 2018101700: return VersionConstants.POPN_MUSIC_USANEKO - if date >= 2018101700: + if date >= 2018101700 and date < 2021042600: return VersionConstants.POPN_MUSIC_PEACE + if date >= 2021042600: + return VersionConstants.POPN_MUSIC_KAIMEI_RIDDLES return None if model.gamecode == 'G15': @@ -113,6 +117,8 @@ class PopnMusicFactory(Factory): return PopnMusicEclale(data, config, model) if parentversion == VersionConstants.POPN_MUSIC_PEACE: return PopnMusicUsaNeko(data, config, model) + if parentversion == VersionConstants.POPN_MUSIC_KAIMEI_RIDDLES: + return PopnMusicPeace(data, config, model) # Unknown older version return None @@ -128,6 +134,8 @@ class PopnMusicFactory(Factory): return PopnMusicUsaNeko(data, config, model) if version == VersionConstants.POPN_MUSIC_PEACE: return PopnMusicPeace(data, config, model) + if version == VersionConstants.POPN_MUSIC_KAIMEI_RIDDLES: + return PopnMusicKaimei(data, config, model) # Unknown game version return None diff --git a/bemani/backend/popn/kaimei.py b/bemani/backend/popn/kaimei.py new file mode 100644 index 0000000..28f0ef6 --- /dev/null +++ b/bemani/backend/popn/kaimei.py @@ -0,0 +1,491 @@ +# vim: set fileencoding=utf-8 +import math +import random +from typing import Any, Dict, List, Tuple + +from bemani.backend.popn.base import PopnMusicBase +from bemani.backend.popn.common import PopnMusicModernBase +from bemani.backend.popn.peace import PopnMusicPeace +from bemani.common import VersionConstants +from bemani.common.validateddict import Profile +from bemani.data.types import UserID +from bemani.protocol.node import Node + + +class PopnMusicKaimei(PopnMusicModernBase): + + name: str = "Pop'n Music 解明リドルズ" + version: int = VersionConstants.POPN_MUSIC_KAIMEI_RIDDLES + + # Biggest ID in the music DB + GAME_MAX_MUSIC_ID: int = 2019 + + # Biggest deco part ID in the game + GAME_MAX_DECO_ID: int = 133 + + # Item limits are as follows: + # 0: 1877 - ID is the music ID that the player purchased/unlocked. + # 1: 2344 + # 2: 3 + # 3: 133 - ID points at a character part that can be purchased on the character screen. + # 4: 1 + # 5: 1 + # 6: 60 + + def previous_version(self) -> PopnMusicBase: + return PopnMusicPeace(self.data, self.config, self.model) + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'ints': [ + { + 'name': 'Music Open Phase', + 'tip': 'Default music phase for all players.', + 'category': 'game_config', + 'setting': 'music_phase', + 'values': { + # The value goes to 30 now, but it starts where usaneko left off at 23 + # Unlocks a total of 10 songs + 23: 'No music unlocks', + 24: 'Phase 1', + 25: 'Phase 2', + 26: 'Phase 3', + 27: 'Phase 4', + 28: 'Phase 5', + 29: 'Phase 6', + 30: 'Phase MAX', + } + }, + { + 'name': 'Kaimei! MN tanteisha event Phase', + 'tip': 'Kaimei! MN tanteisha event phase for all players.', + 'category': 'game_config', + 'setting': 'mn_tanteisha_phase', + 'values': { + 0: 'Disabled', + 1: 'Roki', + 2: 'shiroro', + 3: 'PIERRE&JILL', + 4: 'ROSA', + 5: 'taoxiang', + 6: 'TangTang', + 7: 'OTOBEAR', + 8: 'kaorin', + 9: 'CHARLY', + 10: 'ALOE', + 11: 'RIE♥chan', + 12: 'hina', + 13: 'PAPYRUS', + 14: '雷蔵, miho, RIE♥chan, Ryusei Honey', + 15: 'Murasaki', + 16: 'Lucifelle', + 17: '六', + 18: 'stella', + 19: 'ちせ', + 20: 'LISA', + 21: 'SUMIRE', + 22: 'SHISHITUGU', + 23: 'WALKER', + 24: 'Candy', + 25: 'Jade', + 26: 'AYA', + 27: 'kaorin', + 28: 'Lord Meh', + 29: 'HAMANOV', + 30: 'Agent', + 31: 'Yima', + 32: 'ikkei', + 33: 'echidna', + 34: 'lithos', + 35: 'SMOKE', + 36: 'the KING', + 37: 'Kicoro', + 38: 'DEBORAH', + 39: 'Teruo', + 40: 'the TOWER', + 41: 'Mamoru-kun', + 42: 'Canopus', + 43: 'Mimi Nyami', + 44: 'iO-LOWER', + 45: 'BOY', + 46: 'Sergei', + 47: 'SAPPHIRE', + 48: 'Chocky', + 49: 'HAPPPY', + 50: 'SHOLLKEE', + 51: 'CHARA-O', + 52: 'Hugh, GRIM, SUMIKO', + 53: 'Peetan', + 54: 'SHARK', + 55: 'Nakajima-san', + 56: 'KIKYO', + 57: 'SUMIRE', + 58: 'NAKAJI', + 59: 'moi moi', + 60: 'TITICACA', + 61: 'MASAMUNE', + 62: 'YUMMY' + }, + }, + { + # For festive times, it's possible to change the welcome greeting. I'm not sure why you would want to change this, but now you can. + 'name': 'Holiday Greeting', + 'tip': 'Changes the payment selection confirmation sound.', + 'category': 'game_config', + 'setting': 'holiday_greeting', + 'values': { + 0: 'Okay!', + 1: 'Merry Christmas!', + 2: 'Happy New Year!', + } + }, + { + # peace soundtrack hatsubai kinen SP event, 0 = off, 1 = active, 2 = off (0-2) + 'name': 'peace soundtrack hatsubai kinen SP', + 'tip': 'peace soundtrack hatsubai kinen SP for all players.', + 'category': 'game_config', + 'setting': 'peace_soundtrack', + 'values': { + 0: 'Not stated', + 1: 'Active', + 2: 'Ended', + } + }, + { + 'name': 'MZD no kimagure tanteisha joshu', + 'tip': 'Boost increasing the Clarification Level, if four or more Requests still unresolved.', + 'category': 'game_config', + 'setting': 'tanteisha_joshu', + 'values': { + 0: 'Not stated', + 1: 'Active', + 2: 'Ended', + } + }, + { + # Shutchou! pop'n quest Lively II event + 'name': 'Shutchou! pop\'n quest Lively phase', + 'tip': 'Shutchou! pop\'n quest Lively phase for all players.', + 'category': 'game_config', + 'setting': 'popn_quest_lively', + 'values': { + 0: 'Not started', + 1: 'fes 1', + 2: 'fes 2', + 3: 'fes FINAL', + 4: 'fes EXTRA', + 5: 'Ended', + } + }, + { + # Shutchou! pop'n quest Lively II event + 'name': 'Shutchou! pop\'n quest Lively II phase', + 'tip': 'Shutchou! pop\'n quest Lively II phase for all players.', + 'category': 'game_config', + 'setting': 'popn_quest_lively_2', + 'values': { + 0: 'Not started', + 1: 'fes 1', + 2: 'fes 2', + 3: 'fes FINAL', + 4: 'fes EXTRA', + 5: 'fes THE END', + 6: 'Ended', + } + }, + ], + 'bools': [ + # We don't currently support lobbies or anything, so this is commented out until + # somebody gets around to implementing it. + # { + # 'name': 'Net Taisen', + # 'tip': 'Enable Net Taisen, including win/loss display on song select', + # 'category': 'game_config', + # 'setting': 'enable_net_taisen', + # }, + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + ], + } + + def get_common_config(self) -> Tuple[Dict[int, int], bool]: + game_config = self.get_game_config() + music_phase = game_config.get_int('music_phase') + holiday_greeting = game_config.get_int('holiday_greeting') + enable_net_taisen = False # game_config.get_bool('enable_net_taisen') + mn_tanteisha_phase = game_config.get_int('mn_tanteisha_phase') + peace_soundtrack = game_config.get_int('peace_soundtrack') + tanteisha_joshu = game_config.get_int('tanteisha_joshu') + popn_quest_lively = game_config.get_int('popn_quest_lively') + popn_quest_lively_2 = game_config.get_int('popn_quest_lively_2') + + # Event phases + return ( + { + # Default song phase availability (0-30) + # The following songs are unlocked when the phase is at or above the number specified: + # For 23 and before, see usaneko/peace + # 24 - 1929, 1930 + # 25 - 1964 + # 26 - 1966, 1967 + # 27 - 1975 + # 28 - 1994 + # 29 - 1995, 1996 + # 30 - 1997 + 0: music_phase, + # Unknown event (0-4) + 1: 4, + # Holiday Greeting (0-2) + 2: holiday_greeting, + # Unknown event (0-4) + 3: 4, + # Unknown event (0-1) + 4: 1, + # Enable Net Taisen, including win/loss display on song select (0-1) + 5: 1 if enable_net_taisen else 0, + # Enable NAVI-kun shunkyoku toujou, allows song 1608 to be unlocked (0-1) + 6: 1, + # Unknown event (0-1) + 7: 1, + # Unknown event (0-2) + 8: 2, + # Daily Mission (0-2) + 9: 2, + # NAVI-kun Song phase availability (0-30) + 10: 30, + # Unknown event (0-1) + 11: 1, + # Unknown event (0-2) + 12: 2, + # Enable Pop'n Peace preview song (0-1) + 13: 1, + # Stamp Card Rally (0-39) + 14: 39, + # Unknown event (0-2) + 15: 2, + # Unknown event (0-3) + 16: 3, + # Unknown event (0-8) + 17: 8, + # FLOOR INFECTION event (0-1) + 18: 1, + # pop'n music × NOSTALGIA kyouenkai (0-1) + 19: 1, + # Event archive event (0-13) + 20: 13, + # Pop'n event archive song phase availability (0-20) + 21: 20, + # バンめし♪ ふるさとグランプリunlocks (split into two rounds) (0-2) + 22: 2, + # いちかのBEMANI投票選抜戦2019 unlocks (0-1) + 23: 1, + # ダンキラ!!! × pop'n music unlocks (0-1) + 24: 1, + # Kaimei riddles events starts here + # Kaimei! MN tanteisha event Phase (0-62) + # When active, the following songs are available for unlock + # 1: 1914 + # 2: 195 + # 3: 1915 + # 4: 1916 + # 5: 1896 + # 6: 1908 + # 7: 1931 + # 8: 1924 + # 9: 1925 + # 10: 1894 + # 11: 1926 + # 12: 1927 + # 13: 1928 + # 14: 1932, 1933, 1934, 1935 + # 15: 521 + # 16: 1936 + # 17: 1943 + # 18: 1937 + # 19: 1938 + # 20: 1939 + # 21: 1943 + # 22: 1941 + # 23: 1942 + # 24: 323 + # 25: 1946 + # 26: 575 + # 27: 1947 + # 28: 1955 + # 29: 1957 + # 30: 1958 + # 31: 1959 + # 32: 1960 + # 33: 1961 + # 34: 1963 + # 35: 1962 + # 36: 1968 + # 37: 1969 + # 38: 1965 + # 39: 1970 + # 40: 1976 + # 41: 1977 + # 42: 1978 + # 43: 1945 + # 44: 1944 + # 45: 1999 + # 46: 2000 + # 47: 2001 + # 48: 2002 + # 49: 2003 + # 50: 2004 + # 51: 2005 + # 52: 267, 1998, 2006 + # 53: 2011 + # 54: 2007 + # 55: 2008 + # 56: 2009 + # 57: 2010 + # 58: 2016 + # 59: 2012 + # 60: 2018 + # 61: 2013 + # 62: 2015 + 25: mn_tanteisha_phase, + # Unknown event (0-3) + 26: 3, + # peace soundtrack hatsubai kinen SP (0-2) + # When active, the following songs are available for unlock: 1971, 1972, 1973 + 27: peace_soundtrack, + # MZD no kimagure tanteisha joshu (0-2) + 28: tanteisha_joshu, + # Shutchou! pop'n quest Lively (0-5) + # When active, the following songs are available for unlock + # 1: 1917, 1918 + # 2: 1919, 1921 + # 3: 1920, 1922, 1923 + # 4: 1974 + 29: popn_quest_lively, + # Shutchou! pop'n quest Lively II (0-6) + # When active, the following songs are available for unlock + # 1: 1989, 1990, 1991 + # 2: 1984, 1985, 1992 + # 3: 1982, 1983, 1988 + # 4: 1986, 1987, 1993 + # 5: 2017 + 30: popn_quest_lively_2, + }, + False, + ) + + def format_profile(self, userid: UserID, profile: Profile) -> Node: + root = PopnMusicModernBase.format_profile(self, userid, profile) + + account = root.child('account') + account.add_child(Node.s16('card_again_count', profile.get_int('point'))) + account.add_child(Node.s16('sp_riddles_id', profile.get_int('step'))) + + # Kaimei riddles events + event2021 = Node.void('event2021') + root.add_child(event2021) + event2021.add_child(Node.u32('point', profile.get_int('point'))) + event2021.add_child(Node.u8('step', profile.get_int('step'))) + event2021.add_child(Node.u32_array('quest_point', profile.get_int_array('quest_point', 8, [0] * 8))) + event2021.add_child(Node.u8('step_nos', profile.get_int('step_nos'))) + event2021.add_child(Node.u32_array('quest_point_nos', profile.get_int_array('quest_point_nos', 13, [0] * 13))) + + riddles_data = Node.void('riddles_data') + root.add_child(riddles_data) + + # Generate Short Riddles for MN tanteisha + randomRiddles: List[int] = [] + for x in range(3): + riddle = 0 + while True: + riddle = math.floor(random.randrange(1, 21, 1)) + try: + randomRiddles.index(riddle) + except ValueError: + break + + randomRiddles.append(riddle) + + sh_riddles = Node.void('sh_riddles') + riddles_data.add_child(sh_riddles) + sh_riddles.add_child(Node.u32('sh_riddles_id', riddle)) + + # Set up kaimei riddles achievements + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + for achievement in achievements: + if achievement.type == 'riddle': + kaimei_gauge = achievement.data.get_int('kaimei_gauge') + is_cleared = achievement.data.get_bool('is_cleared') + riddles_cleared = achievement.data.get_bool('riddles_cleared') + select_count = achievement.data.get_int('select_count') + other_count = achievement.data.get_int('other_count') + + sp_riddles = Node.void('sp_riddles') + riddles_data.add_child(sp_riddles) + sp_riddles.add_child(Node.u16('kaimei_gauge', kaimei_gauge)) + sp_riddles.add_child(Node.bool('is_cleared', is_cleared)) + sp_riddles.add_child(Node.bool('riddles_cleared', riddles_cleared)) + sp_riddles.add_child(Node.u8('select_count', select_count)) + sp_riddles.add_child(Node.u32('other_count', other_count)) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: Profile) -> Profile: + newprofile = PopnMusicModernBase.unformat_profile(self, userid, request, oldprofile) + + account = request.child('account') + if account is not None: + newprofile.replace_int('card_again_count', account.child_value('card_again_count')) + newprofile.replace_int('sp_riddles_id', account.child_value('sp_riddles_id')) + + # Kaimei riddles events + event2021 = request.child('event2021') + if event2021 is not None: + newprofile.replace_int('point', event2021.child_value('point')) + newprofile.replace_int('step', event2021.child_value('step')) + newprofile.replace_int_array('quest_point', 8, event2021.child_value('quest_point')) + newprofile.replace_int('step_nos', event2021.child_value('step_nos')) + newprofile.replace_int_array('quest_point_nos', 13, event2021.child_value('quest_point_nos')) + + # Extract kaimei riddles achievements + for node in request.children: + if node.name == 'riddles_data': + riddle_id = 0 + playedRiddle = request.child('account').child_value('sp_riddles_id') + for riddle in node.children: + kaimei_gauge = riddle.child_value('kaimei_gauge') + is_cleared = riddle.child_value('is_cleared') + riddles_cleared = riddle.child_value('riddles_cleared') + select_count = riddle.child_value('select_count') + other_count = riddle.child_value('other_count') + + if (riddles_cleared or select_count >= 3): + select_count = 3 + elif (playedRiddle == riddle_id): + select_count += 1 + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + riddle_id, + 'riddle', + { + 'kaimei_gauge': kaimei_gauge, + 'is_cleared': is_cleared, + 'riddles_cleared': riddles_cleared, + 'select_count': select_count, + 'other_count': other_count, + }, + ) + + riddle_id += 1 + + return newprofile diff --git a/bemani/client/popn/__init__.py b/bemani/client/popn/__init__.py index 2e90bb9..80a113e 100644 --- a/bemani/client/popn/__init__.py +++ b/bemani/client/popn/__init__.py @@ -5,6 +5,7 @@ from bemani.client.popn.lapistoria import PopnMusicLapistoriaClient from bemani.client.popn.eclale import PopnMusicEclaleClient from bemani.client.popn.usaneko import PopnMusicUsaNekoClient from bemani.client.popn.peace import PopnMusicPeaceClient +from bemani.client.popn.kaimei import PopnMusicKaimeiClient __all__ = [ @@ -15,4 +16,5 @@ __all__ = [ "PopnMusicEclaleClient", "PopnMusicUsaNekoClient", "PopnMusicPeaceClient", + "PopnMusicKaimeiClient", ] diff --git a/bemani/client/popn/kaimei.py b/bemani/client/popn/kaimei.py new file mode 100644 index 0000000..630ced7 --- /dev/null +++ b/bemani/client/popn/kaimei.py @@ -0,0 +1,672 @@ +import random +import time +from typing import Any, Dict, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class PopnMusicKaimeiClient(BaseClient): + NAME = 'TEST' + + def verify_pcb24_boot(self, loc: str) -> None: + call = self.call_node() + + # Construct node + pcb24 = Node.void('pcb24') + call.add_child(pcb24) + pcb24.set_attribute('method', 'boot') + pcb24.add_child(Node.string('loc_id', loc)) + pcb24.add_child(Node.u8('loc_type', 0)) + pcb24.add_child(Node.string('loc_name', '')) + pcb24.add_child(Node.string('country', 'US')) + pcb24.add_child(Node.string('region', '.')) + pcb24.add_child(Node.s16('pref', 51)) + pcb24.add_child(Node.string('customer', '')) + pcb24.add_child(Node.string('company', '')) + pcb24.add_child(Node.ipv4('gip', '127.0.0.1')) + pcb24.add_child(Node.u16('gp', 10011)) + pcb24.add_child(Node.string('rom_number', 'M39-JB-G01')) + pcb24.add_child(Node.u64('c_drive', 10028228608)) + pcb24.add_child(Node.u64('d_drive', 47945170944)) + pcb24.add_child(Node.u64('e_drive', 10394677248)) + pcb24.add_child(Node.string('etc', '')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcb24/@status") + + def __verify_common(self, root: str, resp: Node) -> None: + self.assert_path(resp, f"response/{root}/phase/event_id") + self.assert_path(resp, f"response/{root}/phase/phase") + + # Area stuff is not needed unless enabling events. + # self.assert_path(resp, f"response/{root}/area/area_id") + # self.assert_path(resp, f"response/{root}/area/end_date") + # self.assert_path(resp, f"response/{root}/area/medal_id") + # self.assert_path(resp, f"response/{root}/area/is_limit") + + self.assert_path(resp, f"response/{root}/choco/choco_id") + self.assert_path(resp, f"response/{root}/choco/param") + self.assert_path(resp, f"response/{root}/goods/item_id") + self.assert_path(resp, f"response/{root}/goods/item_type") + self.assert_path(resp, f"response/{root}/goods/price") + self.assert_path(resp, f"response/{root}/goods/goods_type") + + def verify_info24_common(self, loc: str) -> None: + call = self.call_node() + + # Construct node + info24 = Node.void('info24') + call.add_child(info24) + info24.set_attribute('loc_id', loc) + info24.set_attribute('method', 'common') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.__verify_common('info24', resp) + + def verify_lobby24_getlist(self, loc: str) -> None: + call = self.call_node() + + # Construct node + lobby24 = Node.void('lobby24') + call.add_child(lobby24) + lobby24.set_attribute('method', 'getList') + lobby24.add_child(Node.string('location_id', loc)) + lobby24.add_child(Node.u8('net_version', 63)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby24/@status") + + def __verify_profile(self, resp: Node) -> None: + self.assert_path(resp, "response/player24/account/name") + self.assert_path(resp, "response/player24/account/g_pm_id") + self.assert_path(resp, "response/player24/account/tutorial") + self.assert_path(resp, "response/player24/account/area_id") + self.assert_path(resp, "response/player24/account/use_navi") + self.assert_path(resp, "response/player24/account/read_news") + self.assert_path(resp, "response/player24/account/nice") + self.assert_path(resp, "response/player24/account/favorite_chara") + self.assert_path(resp, "response/player24/account/special_area") + self.assert_path(resp, "response/player24/account/chocolate_charalist") + self.assert_path(resp, "response/player24/account/chocolate_sp_chara") + self.assert_path(resp, "response/player24/account/chocolate_pass_cnt") + self.assert_path(resp, "response/player24/account/chocolate_hon_cnt") + self.assert_path(resp, "response/player24/account/teacher_setting") + self.assert_path(resp, "response/player24/account/welcom_pack") + self.assert_path(resp, "response/player24/account/ranking_node") + self.assert_path(resp, "response/player24/account/chara_ranking_kind_id") + self.assert_path(resp, "response/player24/account/navi_evolution_flg") + self.assert_path(resp, "response/player24/account/ranking_news_last_no") + self.assert_path(resp, "response/player24/account/power_point") + self.assert_path(resp, "response/player24/account/player_point") + self.assert_path(resp, "response/player24/account/power_point_list") + self.assert_path(resp, "response/player24/account/staff") + self.assert_path(resp, "response/player24/account/item_type") + self.assert_path(resp, "response/player24/account/item_id") + self.assert_path(resp, "response/player24/account/is_conv") + self.assert_path(resp, "response/player24/account/license_data") + self.assert_path(resp, "response/player24/account/my_best") + self.assert_path(resp, "response/player24/account/latest_music") + self.assert_path(resp, "response/player24/account/total_play_cnt") + self.assert_path(resp, "response/player24/account/today_play_cnt") + self.assert_path(resp, "response/player24/account/consecutive_days") + self.assert_path(resp, "response/player24/account/total_days") + self.assert_path(resp, "response/player24/account/interval_day") + self.assert_path(resp, "response/player24/account/active_fr_num") + self.assert_path(resp, "response/player24/eaappli/relation") + self.assert_path(resp, "response/player24/info/ep") + self.assert_path(resp, "response/player24/config") + self.assert_path(resp, "response/player24/option") + self.assert_path(resp, "response/player24/custom_cate") + self.assert_path(resp, "response/player24/navi_data") + self.assert_path(resp, "response/player24/mission/mission_id") + self.assert_path(resp, "response/player24/mission/gauge_point") + self.assert_path(resp, "response/player24/mission/mission_comp") + self.assert_path(resp, "response/player24/netvs") + self.assert_path(resp, "response/player24/customize") + self.assert_path(resp, "response/player24/stamp/stamp_id") + self.assert_path(resp, "response/player24/stamp/cnt") + + def verify_player24_read(self, ref_id: str, msg_type: str) -> Dict[str, Dict[int, Dict[str, int]]]: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'read') + + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + if msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/player24/result") + status = resp.child_value('player24/result') + if status != 2: + raise Exception(f'Reference ID \'{ref_id}\' returned invalid status \'{status}\'') + + return { + 'items': {}, + 'characters': {}, + 'points': {}, + } + elif msg_type == 'query': + # Verify that the response is correct + self.__verify_profile(resp) + + self.assert_path(resp, "response/player24/result") + status = resp.child_value('player24/result') + if status != 0: + raise Exception(f'Reference ID \'{ref_id}\' returned invalid status \'{status}\'') + name = resp.child_value('player24/account/name') + if name != self.NAME: + raise Exception(f'Invalid name \'{name}\' returned for Ref ID \'{ref_id}\'') + + # Medals and items + items: Dict[int, Dict[str, int]] = {} + charas: Dict[int, Dict[str, int]] = {} + courses: Dict[int, Dict[str, int]] = {} + for obj in resp.child('player24').children: + if obj.name == 'item': + items[obj.child_value('id')] = { + 'type': obj.child_value('type'), + 'param': obj.child_value('param'), + } + elif obj.name == 'chara_param': + charas[obj.child_value('chara_id')] = { + 'friendship': obj.child_value('friendship'), + } + elif obj.name == 'course_data': + courses[obj.child_value('course_id')] = { + 'clear_type': obj.child_value('clear_type'), + 'clear_rank': obj.child_value('clear_rank'), + 'total_score': obj.child_value('total_score'), + 'count': obj.child_value('update_count'), + 'sheet_num': obj.child_value('sheet_num'), + } + + return { + 'items': items, + 'characters': charas, + 'courses': courses, + 'points': {0: {'points': resp.child_value('player24/account/player_point')}}, + } + else: + raise Exception(f'Unrecognized message type \'{msg_type}\'') + + def verify_player24_read_score(self, ref_id: str) -> Dict[str, Dict[int, Dict[int, int]]]: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'read_score') + + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + # Verify defaults + self.assert_path(resp, "response/player24/@status") + + # Grab scores + scores: Dict[int, Dict[int, int]] = {} + medals: Dict[int, Dict[int, int]] = {} + ranks: Dict[int, Dict[int, int]] = {} + for child in resp.child('player24').children: + if child.name != 'music': + continue + + musicid = child.child_value('music_num') + chart = child.child_value('sheet_num') + score = child.child_value('score') + medal = child.child_value('clear_type') + rank = child.child_value('clear_rank') + + if musicid not in scores: + scores[musicid] = {} + if musicid not in medals: + medals[musicid] = {} + if musicid not in ranks: + ranks[musicid] = {} + + scores[musicid][chart] = score + medals[musicid][chart] = medal + ranks[musicid][chart] = rank + + return { + 'scores': scores, + 'medals': medals, + 'ranks': ranks, + } + + def verify_player24_start(self, ref_id: str, loc: str) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('loc_id', loc) + player24.set_attribute('ref_id', ref_id) + player24.set_attribute('method', 'start') + player24.set_attribute('start_type', '0') + pcb_card = Node.void('pcb_card') + player24.add_child(pcb_card) + pcb_card.add_child(Node.s8('card_enable', 1)) + pcb_card.add_child(Node.s8('card_soldout', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.__verify_common('player24', resp) + + def verify_player24_update_ranking(self, ref_id: str, loc: str) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'update_ranking') + player24.add_child(Node.s16('pref', 51)) + player24.add_child(Node.string('location_id', loc)) + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.string('name', self.NAME)) + player24.add_child(Node.s16('chara_num', 1)) + player24.add_child(Node.s16('course_id', 12345)) + player24.add_child(Node.s32('total_score', 86000)) + player24.add_child(Node.s16('music_num', 1375)) + player24.add_child(Node.u8('sheet_num', 2)) + player24.add_child(Node.u8('clear_type', 7)) + player24.add_child(Node.u8('clear_rank', 5)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player24/all_ranking/name") + self.assert_path(resp, "response/player24/all_ranking/chara_num") + self.assert_path(resp, "response/player24/all_ranking/total_score") + self.assert_path(resp, "response/player24/all_ranking/clear_type") + self.assert_path(resp, "response/player24/all_ranking/clear_rank") + self.assert_path(resp, "response/player24/all_ranking/player_count") + self.assert_path(resp, "response/player24/all_ranking/player_rank") + + def verify_player24_logout(self, ref_id: str) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('ref_id', ref_id) + player24.set_attribute('method', 'logout') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player24/@status") + + def verify_player24_write( + self, + ref_id: str, + item: Optional[Dict[str, int]]=None, + character: Optional[Dict[str, int]]=None, + ) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'write') + player24.add_child(Node.string('ref_id', ref_id)) + + # Add required children + config = Node.void('config') + player24.add_child(config) + config.add_child(Node.s16('chara', 1543)) + + if item is not None: + itemnode = Node.void('item') + player24.add_child(itemnode) + itemnode.add_child(Node.u8('type', item['type'])) + itemnode.add_child(Node.u16('id', item['id'])) + itemnode.add_child(Node.u16('param', item['param'])) + itemnode.add_child(Node.bool('is_new', False)) + itemnode.add_child(Node.u64('get_time', 0)) + + if character is not None: + chara_param = Node.void('chara_param') + player24.add_child(chara_param) + chara_param.add_child(Node.u16('chara_id', character['id'])) + chara_param.add_child(Node.u16('friendship', character['friendship'])) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player24/@status") + + def verify_player24_buy(self, ref_id: str, item: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'buy') + player24.add_child(Node.s32('play_id', 0)) + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.u16('id', item['id'])) + player24.add_child(Node.u8('type', item['type'])) + player24.add_child(Node.u16('param', item['param'])) + player24.add_child(Node.s32('lumina', item['points'])) + player24.add_child(Node.u16('price', item['price'])) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player24/@status") + + def verify_player24_write_music(self, ref_id: str, score: Dict[str, Any]) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'write_music') + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.string('data_id', ref_id)) + player24.add_child(Node.string('name', self.NAME)) + player24.add_child(Node.u8('stage', 0)) + player24.add_child(Node.s16('music_num', score['id'])) + player24.add_child(Node.u8('sheet_num', score['chart'])) + player24.add_child(Node.u8('clear_type', score['medal'])) + player24.add_child(Node.s32('score', score['score'])) + player24.add_child(Node.s16('combo', 0)) + player24.add_child(Node.s16('cool', 0)) + player24.add_child(Node.s16('great', 0)) + player24.add_child(Node.s16('good', 0)) + player24.add_child(Node.s16('bad', 0)) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player24/@status") + + def verify_player24_new(self, ref_id: str) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'new') + + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.string('name', self.NAME)) + player24.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes + self.__verify_profile(resp) + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_pcb24_boot(location) + self.verify_info24_common(location) + self.verify_lobby24_getlist(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print(f"Generated random card ID {card} for use.") + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception(f'Invalid refid \'{ref_id}\' returned when registering card') + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception(f'Invalid refid \'{ref_id}\' returned when querying card') + self.verify_player24_read(ref_id, msg_type='new') + self.verify_player24_new(ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception(f'Invalid refid \'{ref_id}\' returned when querying card') + + # Verify proper handling of basic stuff + self.verify_player24_read(ref_id, msg_type='query') + self.verify_player24_start(ref_id, location) + self.verify_player24_write(ref_id) + self.verify_player24_logout(ref_id) + + if cardid is None: + # Verify unlocks/story mode work + unlocks = self.verify_player24_read(ref_id, msg_type='query') + for item in unlocks['items']: + if item in [1592, 1608]: + # Song unlocks after one play + continue + raise Exception('Got nonzero items count on a new card!') + for _ in unlocks['characters']: + raise Exception('Got nonzero characters count on a new card!') + for _ in unlocks['courses']: + raise Exception('Got nonzero course count on a new card!') + if unlocks['points'][0]['points'] != 300: + raise Exception('Got wrong default value for points on a new card!') + + self.verify_player24_write(ref_id, item={'id': 4, 'type': 2, 'param': 69}) + unlocks = self.verify_player24_read(ref_id, msg_type='query') + if 4 not in unlocks['items']: + raise Exception('Expecting to see item ID 4 in items!') + if unlocks['items'][4]['type'] != 2: + raise Exception('Expecting to see item ID 4 to have type 2 in items!') + if unlocks['items'][4]['param'] != 69: + raise Exception('Expecting to see item ID 4 to have param 69 in items!') + + self.verify_player24_write(ref_id, character={'id': 5, 'friendship': 420}) + unlocks = self.verify_player24_read(ref_id, msg_type='query') + if 5 not in unlocks['characters']: + raise Exception('Expecting to see chara ID 5 in characters!') + if unlocks['characters'][5]['friendship'] != 420: + raise Exception('Expecting to see chara ID 5 to have type 2 in characters!') + + # Verify purchases work + self.verify_player24_buy(ref_id, item={'id': 6, 'type': 3, 'param': 8, 'points': 400, 'price': 250}) + unlocks = self.verify_player24_read(ref_id, msg_type='query') + if 6 not in unlocks['items']: + raise Exception('Expecting to see item ID 6 in items!') + if unlocks['items'][6]['type'] != 3: + raise Exception('Expecting to see item ID 6 to have type 3 in items!') + if unlocks['items'][6]['param'] != 8: + raise Exception('Expecting to see item ID 6 to have param 8 in items!') + if unlocks['points'][0]['points'] != 150: + raise Exception(f'Got wrong value for points {unlocks["points"][0]["points"]} after purchase!') + + # Verify course handling + self.verify_player24_update_ranking(ref_id, location) + unlocks = self.verify_player24_read(ref_id, msg_type='query') + if 12345 not in unlocks['courses']: + raise Exception('Expecting to see course ID 12345 in courses!') + if unlocks['courses'][12345]['clear_type'] != 7: + raise Exception('Expecting to see item ID 12345 to have clear_type 7 in courses!') + if unlocks['courses'][12345]['clear_rank'] != 5: + raise Exception('Expecting to see item ID 12345 to have clear_rank 5 in courses!') + if unlocks['courses'][12345]['total_score'] != 86000: + raise Exception('Expecting to see item ID 12345 to have total_score 86000 in courses!') + if unlocks['courses'][12345]['count'] != 1: + raise Exception('Expecting to see item ID 12345 to have count 1 in courses!') + if unlocks['courses'][12345]['sheet_num'] != 2: + raise Exception('Expecting to see item ID 12345 to have sheet_num 2 in courses!') + + # Verify score handling + scores = self.verify_player24_read_score(ref_id) + for _ in scores['medals']: + raise Exception('Got nonzero medals count on a new card!') + for _ in scores['scores']: + raise Exception('Got nonzero scores count on a new card!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 987, + 'chart': 2, + 'medal': 5, + 'score': 76543, + }, + # A good score on an easier chart of the same song + { + 'id': 987, + 'chart': 0, + 'medal': 6, + 'score': 99999, + }, + # A bad score on a hard chart + { + 'id': 741, + 'chart': 3, + 'medal': 2, + 'score': 45000, + }, + # A terrible score on an easy chart + { + 'id': 742, + 'chart': 1, + 'medal': 2, + 'score': 1, + }, + ] + # Random score to add in + songid = random.randint(907, 950) + chartid = random.randint(0, 3) + score = random.randint(0, 100000) + medal = random.randint(1, 11) + dummyscores.append({ + 'id': songid, + 'chart': chartid, + 'medal': medal, + 'score': score, + }) + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 987, + 'chart': 2, + 'medal': 6, + 'score': 98765, + }, + # A worse score on another same chart + { + 'id': 987, + 'chart': 0, + 'medal': 3, + 'score': 12345, + 'expected_score': 99999, + 'expected_medal': 6, + }, + ] + + for dummyscore in dummyscores: + self.verify_player24_write_music(ref_id, dummyscore) + scores = self.verify_player24_read_score(ref_id) + for expected in dummyscores: + newscore = scores['scores'][expected['id']][expected['chart']] + newmedal = scores['medals'][expected['id']][expected['chart']] + newrank = scores['ranks'][expected['id']][expected['chart']] + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_medal' in expected: + expected_medal = expected['expected_medal'] + else: + expected_medal = expected['medal'] + + if newscore < 50000: + expected_rank = 1 + elif newscore < 62000: + expected_rank = 2 + elif newscore < 72000: + expected_rank = 3 + elif newscore < 82000: + expected_rank = 4 + elif newscore < 90000: + expected_rank = 5 + elif newscore < 95000: + expected_rank = 6 + elif newscore < 98000: + expected_rank = 7 + else: + expected_rank = 8 + + if newscore != expected_score: + raise Exception(f'Expected a score of \'{expected_score}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got score \'{newscore}\'') + if newmedal != expected_medal: + raise Exception(f'Expected a medal of \'{expected_medal}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got medal \'{newmedal}\'') + if newrank != expected_rank: + raise Exception(f'Expected a rank of \'{expected_rank}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got rank \'{newrank}\'') + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/common/constants.py b/bemani/common/constants.py index e083244..f63b4ac 100644 --- a/bemani/common/constants.py +++ b/bemani/common/constants.py @@ -122,6 +122,7 @@ class VersionConstants: POPN_MUSIC_ECLALE: Final[int] = 23 POPN_MUSIC_USANEKO: Final[int] = 24 POPN_MUSIC_PEACE: Final[int] = 25 + POPN_MUSIC_KAIMEI_RIDDLES: Final[int] = 26 REFLEC_BEAT: Final[int] = 1 REFLEC_BEAT_LIMELIGHT: Final[int] = 2 diff --git a/bemani/utils/read.py b/bemani/utils/read.py index e5f6fd7..ad1edfa 100644 --- a/bemani/utils/read.py +++ b/bemani/utils/read.py @@ -372,6 +372,7 @@ class ImportPopn(ImportBase): '23': VersionConstants.POPN_MUSIC_ECLALE, '24': VersionConstants.POPN_MUSIC_USANEKO, '25': VersionConstants.POPN_MUSIC_PEACE, + '26': VersionConstants.POPN_MUSIC_KAIMEI_RIDDLES, }.get(version, -1) if actual_version == VersionConstants.POPN_MUSIC_TUNE_STREET: @@ -383,7 +384,7 @@ class ImportPopn(ImportBase): # Newer pop'n has charts for easy, normal, hyper, another self.charts = [0, 1, 2, 3] else: - raise Exception("Unsupported Pop'n Music version, expected one of the following: 19, 20, 21, 22, 23, 24, 25!") + raise Exception("Unsupported Pop'n Music version, expected one of the following: 19, 20, 21, 22, 23, 24, 25, 26!") super().__init__(config, GameConstants.POPN_MUSIC, actual_version, no_combine, update) @@ -1056,6 +1057,104 @@ class ImportPopn(ImportBase): 'I' ) + # Decoding function for chart masks + def available_charts(mask: int) -> Tuple[bool, bool, bool, bool, bool, bool]: + return ( + mask & 0x0080000 > 0, # Easy chart bit + True, # Always a normal chart + mask & 0x1000000 > 0, # Hyper chart bit + mask & 0x2000000 > 0, # Ex chart bit + True, # Always a battle normal chart + mask & 0x4000000 > 0, # Battle hyper chart bit + ) + + elif self.version == VersionConstants.POPN_MUSIC_KAIMEI_RIDDLES: + # Based on M39:J:A:A:2022061300 + + # Normal offset for music DB, size + offset = 0x2DEA68 + step = 172 + length = 2019 + + # Offset and step of file DB + file_offset = 0x2CDB00 + file_step = 32 + + # Standard lookups + genre_offset = 0 + title_offset = 1 + artist_offset = 2 + comment_offset = 3 + english_title_offset = 4 + english_artist_offset = 5 + extended_genre_offset = -1 + charts_offset = 8 + folder_offset = 9 + + # Offsets for normal chart difficulties + easy_offset = 12 + normal_offset = 13 + hyper_offset = 14 + ex_offset = 15 + + # Offsets for battle chart difficulties + battle_normal_offset = 16 + battle_hyper_offset = 17 + + # Offsets into which offset to seek to for file lookups + easy_file_offset = 18 + normal_file_offset = 19 + hyper_file_offset = 20 + ex_file_offset = 21 + battle_normal_file_offset = 22 + battle_hyper_file_offset = 23 + + packedfmt = ( + '<' + 'I' # Genre + 'I' # Title + 'I' # Artist + 'I' # Comment + 'I' # English Title + 'I' # English Artist + 'H' # ?? + 'H' # ?? + 'I' # Available charts mask + 'I' # Folder + 'I' # Event unlocks? + 'I' # Event unlocks? + 'B' # Easy difficulty + 'B' # Normal difficulty + 'B' # Hyper difficulty + 'B' # EX difficulty + 'B' # Battle normal difficulty + 'B' # Battle hyper difficulty + 'xx' # Unknown pointer + 'H' # Easy chart pointer + 'H' # Normal chart pointer + 'H' # Hyper chart pointer + 'H' # EX chart pointer + 'H' # Battle normal pointer + 'H' # Battle hyper pointer + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' + ) + + # Offsets into file DB for finding file and folder. + file_folder_offset = 0 + file_name_offset = 1 + + filefmt = ( + '<' + 'I' # Folder + 'I' # Filename + 'I' + 'I' + 'I' + 'I' + 'I' + 'I' + ) + # Decoding function for chart masks def available_charts(mask: int) -> Tuple[bool, bool, bool, bool, bool, bool]: return ( diff --git a/bemani/utils/trafficgen.py b/bemani/utils/trafficgen.py index f652e61..8154a56 100644 --- a/bemani/utils/trafficgen.py +++ b/bemani/utils/trafficgen.py @@ -28,6 +28,7 @@ from bemani.client.popn import ( PopnMusicEclaleClient, PopnMusicUsaNekoClient, PopnMusicPeaceClient, + PopnMusicKaimeiClient, ) from bemani.client.ddr import ( DDRX2Client, @@ -102,6 +103,12 @@ def get_client(proto: ClientProtocol, pcbid: str, game: str, config: Dict[str, A pcbid, config, ) + if game == 'pnm-kaimei': + return PopnMusicKaimeiClient( + proto, + pcbid, + config, + ) if game == 'jubeat-saucer': return JubeatSaucerClient( proto, @@ -342,6 +349,12 @@ def mainloop(address: str, port: int, configfile: str, action: str, game: str, c 'old_profile_model': "M39:J:B:A", 'avs': "2.15.8 r6631", }, + 'pnm-kaimei': { + 'name': "Pop'n Music Kaimei riddles", + 'model': "M39:J:B:A:2022061300", + 'old_profile_model': "M39:J:B:A", + 'avs': "2.15.8 r6631", + }, 'jubeat-saucer': { 'name': "Jubeat Saucer", 'model': "L44:J:A:A:2014012802", @@ -560,6 +573,7 @@ def main() -> None: 'pnm-23': 'pnm-eclale', 'pnm-24': 'pnm-usaneko', 'pnm-25': 'pnm-peace', + 'pnm-26': 'pnm-kaimei', 'iidx-20': 'iidx-tricoro', 'iidx-21': 'iidx-spada', 'iidx-22': 'iidx-pendual', diff --git a/bootstrap b/bootstrap index 8c0d5ac..fdc9fbe 100755 --- a/bootstrap +++ b/bootstrap @@ -13,6 +13,7 @@ set -e ./read --series pnm --version 23 "$@" ./read --series pnm --version 24 "$@" ./read --series pnm --version 25 "$@" +./read --series pnm --version 26 "$@" # Init Jubeat ./read --series jubeat --version saucer "$@" diff --git a/verifytraffic b/verifytraffic index 9e32d6e..65e315a 100755 --- a/verifytraffic +++ b/verifytraffic @@ -10,6 +10,7 @@ declare -a arr=( "pnm-23" "pnm-24" "pnm-25" + "pnm-26" "iidx-20" "iidx-21" "iidx-22"