1
0
mirror of synced 2024-12-01 09:07:18 +01:00
bemaniutils/bemani/backend/popn/tunestreet.py

402 lines
16 KiB
Python
Raw Normal View History

# 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
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