405 lines
16 KiB
Python
405 lines
16 KiB
Python
# vim: set fileencoding=utf-8
|
|
import copy
|
|
from typing import Optional
|
|
|
|
from bemani.backend.popn.base import PopnMusicBase
|
|
from bemani.backend.popn.stubs import PopnMusicSengokuRetsuden
|
|
|
|
from bemani.backend.base import Status
|
|
from bemani.common import ValidatedDict, VersionConstants
|
|
from bemani.data import Score, UserID
|
|
from bemani.protocol import Node
|
|
|
|
|
|
class PopnMusicTuneStreet(PopnMusicBase):
|
|
|
|
name = "Pop'n Music TUNE STREET"
|
|
version = VersionConstants.POPN_MUSIC_TUNE_STREET
|
|
|
|
# Play modes, as reported by profile save from the game
|
|
GAME_PLAY_MODE_CHALLENGE = 3
|
|
GAME_PLAY_MODE_CHO_CHALLENGE = 4
|
|
|
|
# Play flags, as saved into/loaded from the DB
|
|
GAME_PLAY_FLAG_FAILED = 0
|
|
GAME_PLAY_FLAG_CLEARED = 1
|
|
GAME_PLAY_FLAG_FULL_COMBO = 2
|
|
GAME_PLAY_FLAG_PERFECT_COMBO = 3
|
|
|
|
# Chart type, as reported by profile save from the game
|
|
GAME_CHART_TYPE_NORMAL = 0
|
|
GAME_CHART_TYPE_HYPER = 1
|
|
GAME_CHART_TYPE_5_BUTTON = 2
|
|
GAME_CHART_TYPE_EX = 3
|
|
GAME_CHART_TYPE_BATTLE_NORMAL = 4
|
|
GAME_CHART_TYPE_BATTLE_HYPER = 5
|
|
GAME_CHART_TYPE_ENJOY_5_BUTTON = 6
|
|
GAME_CHART_TYPE_ENJOY_9_BUTTON = 7
|
|
|
|
# Extra chart types supported by Pop'n 19
|
|
CHART_TYPE_OLD_NORMAL = 4
|
|
CHART_TYPE_OLD_HYPER = 5
|
|
CHART_TYPE_OLD_EX = 6
|
|
CHART_TYPE_ENJOY_5_BUTTON = 7
|
|
CHART_TYPE_ENJOY_9_BUTTON = 8
|
|
CHART_TYPE_5_BUTTON = 9
|
|
|
|
# Chart type, as packed into a hiscore binary
|
|
GAME_CHART_TYPE_5_BUTTON_POSITION = 0
|
|
GAME_CHART_TYPE_NORMAL_POSITION = 1
|
|
GAME_CHART_TYPE_HYPER_POSITION = 2
|
|
GAME_CHART_TYPE_EX_POSITION = 3
|
|
GAME_CHART_TYPE_CHO_NORMAL_POSITION = 4
|
|
GAME_CHART_TYPE_CHO_HYPER_POSITION = 5
|
|
GAME_CHART_TYPE_CHO_EX_POSITION = 6
|
|
|
|
# Highest song ID we can represent
|
|
GAME_MAX_MUSIC_ID = 1045
|
|
|
|
def previous_version(self) -> Optional[PopnMusicBase]:
|
|
return PopnMusicSengokuRetsuden(self.data, self.config, self.model)
|
|
|
|
def __format_flags_for_score(self, score: Score) -> int:
|
|
# Format song flags (cleared/not, combo flags)
|
|
playedflag = {
|
|
self.CHART_TYPE_5_BUTTON: 0x2000,
|
|
self.CHART_TYPE_OLD_NORMAL: 0x0800,
|
|
self.CHART_TYPE_OLD_HYPER: 0x1000,
|
|
self.CHART_TYPE_OLD_EX: 0x4000,
|
|
self.CHART_TYPE_NORMAL: 0x0800,
|
|
self.CHART_TYPE_HYPER: 0x1000,
|
|
self.CHART_TYPE_EX: 0x4000,
|
|
# We don't have a played flag for these, only cleared/no play
|
|
self.CHART_TYPE_ENJOY_5_BUTTON: 0,
|
|
self.CHART_TYPE_ENJOY_9_BUTTON: 0,
|
|
}[score.chart]
|
|
# Shift value for cleared/failed/combo indicators
|
|
shift = {
|
|
self.CHART_TYPE_5_BUTTON: 4,
|
|
self.CHART_TYPE_OLD_NORMAL: 0,
|
|
self.CHART_TYPE_OLD_HYPER: 2,
|
|
self.CHART_TYPE_OLD_EX: 6,
|
|
self.CHART_TYPE_NORMAL: 0,
|
|
self.CHART_TYPE_HYPER: 2,
|
|
self.CHART_TYPE_EX: 6,
|
|
self.CHART_TYPE_ENJOY_5_BUTTON: 9,
|
|
self.CHART_TYPE_ENJOY_9_BUTTON: 8,
|
|
}[score.chart]
|
|
flags = {
|
|
self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_FLAG_FAILED,
|
|
self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_FLAG_FAILED,
|
|
self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_FLAG_FAILED,
|
|
self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_FLAG_CLEARED,
|
|
self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_FLAG_CLEARED,
|
|
self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_FLAG_CLEARED,
|
|
self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_FLAG_CLEARED,
|
|
self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_FLAG_FULL_COMBO,
|
|
self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_FLAG_FULL_COMBO,
|
|
self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_FLAG_FULL_COMBO,
|
|
self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_FLAG_PERFECT_COMBO,
|
|
}[score.data.get_int('medal')]
|
|
return (flags << shift) | playedflag
|
|
|
|
def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node:
|
|
root = Node.void('playerdata')
|
|
|
|
# Format profile
|
|
binary_profile = [0] * 2200
|
|
|
|
# Copy name
|
|
name_binary = profile.get_str('name', 'なし').encode('shift-jis')[0:12]
|
|
name_pos = 0
|
|
for byte in name_binary:
|
|
binary_profile[name_pos] = byte
|
|
name_pos = name_pos + 1
|
|
|
|
# Copy game mode
|
|
binary_profile[13] = {
|
|
0: 0,
|
|
1: 0,
|
|
2: 1,
|
|
3: 1,
|
|
4: 4,
|
|
5: 2,
|
|
}[profile.get_int('play_mode')]
|
|
|
|
# Copy miscelaneous values
|
|
binary_profile[16] = profile.get_int('last_play_flag') & 0xFF
|
|
binary_profile[44] = profile.get_int('option') & 0xFF
|
|
binary_profile[45] = (profile.get_int('option') >> 8) & 0xFF
|
|
binary_profile[46] = (profile.get_int('option') >> 16) & 0xFF
|
|
binary_profile[47] = (profile.get_int('option') >> 24) & 0xFF
|
|
binary_profile[60] = profile.get_int('chara') & 0xFF
|
|
binary_profile[61] = (profile.get_int('chara') >> 8) & 0xFF
|
|
binary_profile[62] = profile.get_int('music') & 0xFF
|
|
binary_profile[63] = (profile.get_int('music') >> 8) & 0xFF
|
|
binary_profile[64] = profile.get_int('sheet') & 0xFF
|
|
binary_profile[65] = profile.get_int('category') & 0xFF
|
|
# This might be the count of friends, since Tune Street *does* support
|
|
# rivals. However, I can no longer get it running on my cabinet or locally
|
|
# so there's no way for me to test.
|
|
binary_profile[67] = profile.get_int('medal_and_friend') & 0xFF
|
|
|
|
# Format Scores
|
|
hiscore_array = [0] * int((((self.GAME_MAX_MUSIC_ID * 7) * 17) + 7) / 8)
|
|
scores = self.data.remote.music.get_scores(self.game, self.version, userid)
|
|
for score in scores:
|
|
if score.id > self.GAME_MAX_MUSIC_ID:
|
|
continue
|
|
|
|
# Skip any scores for chart types we don't support
|
|
if score.chart in [
|
|
self.CHART_TYPE_EASY,
|
|
]:
|
|
continue
|
|
|
|
flags = self.__format_flags_for_score(score)
|
|
|
|
flags_index = score.id * 2
|
|
binary_profile[108 + flags_index] = binary_profile[108 + flags_index] | (flags & 0xFF)
|
|
binary_profile[109 + flags_index] = binary_profile[109 + flags_index] | ((flags >> 8) & 0xFF)
|
|
|
|
if score.chart in [
|
|
self.CHART_TYPE_ENJOY_5_BUTTON,
|
|
self.CHART_TYPE_ENJOY_9_BUTTON,
|
|
]:
|
|
# We don't return enjoy scores, just the flags that we played them
|
|
continue
|
|
|
|
# Format actual score, according to DB chart position
|
|
points = score.points
|
|
|
|
hiscore_index = (score.id * 7) + {
|
|
self.CHART_TYPE_5_BUTTON: self.GAME_CHART_TYPE_5_BUTTON_POSITION,
|
|
self.CHART_TYPE_OLD_NORMAL: self.GAME_CHART_TYPE_NORMAL_POSITION,
|
|
self.CHART_TYPE_OLD_HYPER: self.GAME_CHART_TYPE_HYPER_POSITION,
|
|
self.CHART_TYPE_OLD_EX: self.GAME_CHART_TYPE_EX_POSITION,
|
|
self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_CHO_NORMAL_POSITION,
|
|
self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_CHO_HYPER_POSITION,
|
|
self.CHART_TYPE_EX: self.GAME_CHART_TYPE_CHO_EX_POSITION,
|
|
}[score.chart]
|
|
hiscore_byte_pos = int((hiscore_index * 17) / 8)
|
|
hiscore_bit_pos = int((hiscore_index * 17) % 8)
|
|
hiscore_value = points << hiscore_bit_pos
|
|
hiscore_array[hiscore_byte_pos] = hiscore_array[hiscore_byte_pos] | (hiscore_value & 0xFF)
|
|
hiscore_array[hiscore_byte_pos + 1] = hiscore_array[hiscore_byte_pos + 1] | ((hiscore_value >> 8) & 0xFF)
|
|
hiscore_array[hiscore_byte_pos + 2] = hiscore_array[hiscore_byte_pos + 2] | ((hiscore_value >> 16) & 0xFF)
|
|
|
|
# Format most played
|
|
most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 20)]
|
|
while len(most_played) < 20:
|
|
most_played.append(-1)
|
|
profile_pos = 68
|
|
for musicid in most_played:
|
|
binary_profile[profile_pos] = musicid & 0xFF
|
|
binary_profile[profile_pos + 1] = (musicid >> 8) & 0xFF
|
|
profile_pos = profile_pos + 2
|
|
|
|
# Construct final profile
|
|
root.add_child(Node.binary('b', bytes(binary_profile)))
|
|
root.add_child(Node.binary('hiscore', bytes(hiscore_array)))
|
|
root.add_child(Node.binary('town', b''))
|
|
|
|
return root
|
|
|
|
def format_conversion(self, userid: UserID, profile: ValidatedDict) -> Node:
|
|
root = Node.void('playerdata')
|
|
|
|
root.add_child(Node.string('name', profile.get_str('name', 'なし')))
|
|
root.add_child(Node.s16('chara', profile.get_int('chara', -1)))
|
|
root.add_child(Node.s32('option', profile.get_int('option', 0)))
|
|
root.add_child(Node.u8('version', 0))
|
|
root.add_child(Node.u8('kind', 0))
|
|
root.add_child(Node.u8('season', 0))
|
|
|
|
medals = [0] * (self.GAME_MAX_MUSIC_ID)
|
|
scores = self.data.remote.music.get_scores(self.game, self.version, userid)
|
|
for score in scores:
|
|
if score.id > self.GAME_MAX_MUSIC_ID:
|
|
continue
|
|
|
|
# Skip any scores for chart types we don't support
|
|
if score.chart in [
|
|
self.CHART_TYPE_EASY,
|
|
]:
|
|
continue
|
|
|
|
flags = self.__format_flags_for_score(score)
|
|
medals[score.id] = medals[score.id] | flags
|
|
root.add_child(Node.u16_array('clear_medal', medals))
|
|
|
|
return root
|
|
|
|
def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict:
|
|
newprofile = copy.deepcopy(oldprofile)
|
|
|
|
# Extract the playmode, important for scores later
|
|
playmode = int(request.attribute('play_mode'))
|
|
newprofile.replace_int('play_mode', playmode)
|
|
|
|
# Extract profile options
|
|
newprofile.replace_int('chara', int(request.attribute('chara_num')))
|
|
if 'option' in request.attributes:
|
|
newprofile.replace_int('option', int(request.attribute('option')))
|
|
if 'last_play_flag' in request.attributes:
|
|
newprofile.replace_int('last_play_flag', int(request.attribute('last_play_flag')))
|
|
if 'medal_and_friend' in request.attributes:
|
|
newprofile.replace_int('medal_and_friend', int(request.attribute('medal_and_friend')))
|
|
if 'music_num' in request.attributes:
|
|
newprofile.replace_int('music', int(request.attribute('music_num')))
|
|
if 'sheet_num' in request.attributes:
|
|
newprofile.replace_int('sheet', int(request.attribute('sheet_num')))
|
|
if 'category_num' in request.attributes:
|
|
newprofile.replace_int('category', int(request.attribute('category_num')))
|
|
|
|
# Keep track of play statistics
|
|
self.update_play_statistics(userid)
|
|
|
|
# Extract scores
|
|
for node in request.children:
|
|
if node.name == 'music':
|
|
songid = int(node.attribute('music_num'))
|
|
chart = int(node.attribute('sheet_num'))
|
|
points = int(node.attribute('score'))
|
|
data = int(node.attribute('data'))
|
|
|
|
# We never save battle scores
|
|
if chart in [
|
|
self.GAME_CHART_TYPE_BATTLE_NORMAL,
|
|
self.GAME_CHART_TYPE_BATTLE_HYPER,
|
|
]:
|
|
continue
|
|
|
|
# Arrange order to be compatible with future mixes
|
|
if playmode == self.GAME_PLAY_MODE_CHO_CHALLENGE:
|
|
if chart in [
|
|
self.GAME_CHART_TYPE_5_BUTTON,
|
|
self.GAME_CHART_TYPE_ENJOY_5_BUTTON,
|
|
self.GAME_CHART_TYPE_ENJOY_9_BUTTON,
|
|
]:
|
|
# We don't save 5 button for cho scores, or enjoy modes
|
|
continue
|
|
chart = {
|
|
self.GAME_CHART_TYPE_NORMAL: self.CHART_TYPE_NORMAL,
|
|
self.GAME_CHART_TYPE_HYPER: self.CHART_TYPE_HYPER,
|
|
self.GAME_CHART_TYPE_EX: self.CHART_TYPE_EX,
|
|
}[chart]
|
|
else:
|
|
chart = {
|
|
self.GAME_CHART_TYPE_NORMAL: self.CHART_TYPE_OLD_NORMAL,
|
|
self.GAME_CHART_TYPE_HYPER: self.CHART_TYPE_OLD_HYPER,
|
|
self.GAME_CHART_TYPE_5_BUTTON: self.CHART_TYPE_5_BUTTON,
|
|
self.GAME_CHART_TYPE_EX: self.CHART_TYPE_OLD_EX,
|
|
self.GAME_CHART_TYPE_ENJOY_5_BUTTON: self.CHART_TYPE_ENJOY_5_BUTTON,
|
|
self.GAME_CHART_TYPE_ENJOY_9_BUTTON: self.CHART_TYPE_ENJOY_9_BUTTON,
|
|
}[chart]
|
|
|
|
# Extract play flags
|
|
shift = {
|
|
self.CHART_TYPE_5_BUTTON: 4,
|
|
self.CHART_TYPE_OLD_NORMAL: 0,
|
|
self.CHART_TYPE_OLD_HYPER: 2,
|
|
self.CHART_TYPE_OLD_EX: 6,
|
|
self.CHART_TYPE_NORMAL: 0,
|
|
self.CHART_TYPE_HYPER: 2,
|
|
self.CHART_TYPE_EX: 6,
|
|
self.CHART_TYPE_ENJOY_5_BUTTON: 9,
|
|
self.CHART_TYPE_ENJOY_9_BUTTON: 8,
|
|
}[chart]
|
|
|
|
if chart in [
|
|
self.CHART_TYPE_ENJOY_5_BUTTON,
|
|
self.CHART_TYPE_ENJOY_9_BUTTON,
|
|
]:
|
|
# We only store cleared or not played for enjoy mode
|
|
mask = 0x1
|
|
else:
|
|
# We store all data for regular charts
|
|
mask = 0x3
|
|
|
|
# Grab flags, map to medals in DB. Choose lowest one for each so
|
|
# a newer pop'n can still improve scores and medals.
|
|
flags = (data >> shift) & mask
|
|
medal = {
|
|
self.GAME_PLAY_FLAG_FAILED: self.PLAY_MEDAL_CIRCLE_FAILED,
|
|
self.GAME_PLAY_FLAG_CLEARED: self.PLAY_MEDAL_CIRCLE_CLEARED,
|
|
self.GAME_PLAY_FLAG_FULL_COMBO: self.PLAY_MEDAL_CIRCLE_FULL_COMBO,
|
|
self.GAME_PLAY_FLAG_PERFECT_COMBO: self.PLAY_MEDAL_PERFECT,
|
|
}[flags]
|
|
self.update_score(userid, songid, chart, points, medal)
|
|
|
|
return newprofile
|
|
|
|
def handle_game_request(self, request: Node) -> Optional[Node]:
|
|
method = request.attribute('method')
|
|
|
|
if method == 'get':
|
|
# TODO: Hook these up to config so we can change this
|
|
root = Node.void('game')
|
|
root.set_attribute('game_phase', '2')
|
|
root.set_attribute('psp_phase', '2')
|
|
return root
|
|
|
|
if method == 'active':
|
|
# Update the name of this cab for admin purposes
|
|
self.update_machine_name(request.attribute('shop_name'))
|
|
return Node.void('game')
|
|
|
|
if method == 'taxphase':
|
|
return Node.void('game')
|
|
|
|
# Invalid method
|
|
return None
|
|
|
|
def handle_playerdata_request(self, request: Node) -> Optional[Node]:
|
|
method = request.attribute('method')
|
|
|
|
if method == 'expire':
|
|
return Node.void('playerdata')
|
|
|
|
elif method == 'logout':
|
|
return Node.void('playerdata')
|
|
|
|
elif method == 'get':
|
|
modelstring = request.attribute('model')
|
|
refid = request.attribute('ref_id')
|
|
root = self.get_profile_by_refid(
|
|
refid,
|
|
self.NEW_PROFILE_ONLY if modelstring is None else self.OLD_PROFILE_ONLY,
|
|
)
|
|
if root is None:
|
|
root = Node.void('playerdata')
|
|
root.set_attribute('status', str(Status.NO_PROFILE))
|
|
return root
|
|
|
|
elif method == 'new':
|
|
refid = request.attribute('ref_id')
|
|
name = request.attribute('name')
|
|
root = self.new_profile_by_refid(refid, name)
|
|
if root is None:
|
|
root = Node.void('playerdata')
|
|
root.set_attribute('status', str(Status.NO_PROFILE))
|
|
return root
|
|
|
|
elif method == 'set':
|
|
refid = request.attribute('ref_id')
|
|
|
|
root = Node.void('playerdata')
|
|
if refid is None:
|
|
return root
|
|
|
|
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
|
|
if userid is None:
|
|
return root
|
|
|
|
oldprofile = self.get_profile(userid) or ValidatedDict()
|
|
newprofile = self.unformat_profile(userid, request, oldprofile)
|
|
|
|
if newprofile is not None:
|
|
self.put_profile(userid, newprofile)
|
|
|
|
return root
|
|
|
|
# Invalid method
|
|
return None
|