Merge branch 'trunk' into unilab-fix

This commit is contained in:
tyam
2025-10-14 10:36:33 -05:00
committed by GitHub
46 changed files with 3586 additions and 102 deletions

View File

@@ -1,15 +1,16 @@
# Introduction
A collection of programs for working with various games in the BEMANI series. This
could be untangled quite a bit into various modules that provide simpler pieces.
However, this is how it ended up evolving over time. This repository includes
utilities for unpacking (and sometimes repacking) various file formats, emulating
network services for various games, utilities for sniffing, redirecting and
reconstructing network packets, utilities for gathering information about various
game music databases and associated tooling that makes developing the previous
utilities easier. It is meant to be a complete ecosystem for somebody looking to
provide hobby network services to themselves in order to preserve a particular era
of gaming that is no longer officially supported.
A collection of programs for working with various games in and out of the BEMANI
series. This could be untangled quite a bit into various modules that provide
simpler pieces. However, this is how it ended up evolving over time. This
repository includes utilities for unpacking (and sometimes repacking) various
file formats, emulating network services for various games, utilities for
sniffing, redirecting and reconstructing network packets, utilities for
gathering information about various game music databases and associated tooling
that makes developing the previous utilities easier. It is meant to be a
complete ecosystem for somebody looking to provide hobby network services to
themselves in order to preserve a particular era of gaming that is no longer
officially supported.
Thanks to Tau for the great writeup on the binary network format. Thanks to some
rando on stack overflow for RC4 code for Python. Thanks to some other rando on
@@ -46,12 +47,12 @@ how to use it.
## api
Development version of this repository's BEMAPI implementation. This serves as the
REST-like API layer for inter-network federation of scores, profiles and rivals.
Run it like `./api --help` to see help output and determine how to use this. Much
like "services" and "frontend", this should be pointed at the development version
of your services config file which holds information about the MySQL database that
this should connect to as well as what game series are supported. See
`config/server.yaml` for an example file that you can modify.
REST-like API layer for inter-network federation of game song catalogs, scores,
profiles and rivals. Run it like `./api --help` to see help output and determine
how to use this. Much like "services" and "frontend", this should be pointed at
the development version of your services config file which holds information about
the MySQL database that this should connect to as well as what game series are
supported. See `config/server.yaml` for an example file that you can modify.
Do not use this utility to serve production traffic. Instead, see
`bemani/wsgi/api.wsgi` for a ready-to-go WSGI file that can be used with a Python
@@ -293,12 +294,13 @@ BEMANI games boot and supports full scores, profile and events for Beatmania IID
Pop'n Music 19-27, Jubeat Saucer, Saucer Fulfill, Prop, Qubell, Clan and Festo, Sound
Voltex 1, 2, 3 Season 1/2 and 4, Dance Dance Revolution X2, X3, 2013, 2014 and Ace,
MÚSECA 1, MÚSECA 1+1/2, MÚSECA Plus, Reflec Beat, Limelight, Colette, groovin'!! Upper,
Volzza 1 and Volzza 2, Metal Gear Arcade, and finally The\*BishiBashi. Note that it also
has matching support for all Reflec Beat versions as well as MGA. By default, this serves
traffic based solely on the database it is configured against. If you federate with
other networks using the "Data API" admin page, it will upgrade to serving traffic
based on the profiles, scores and statistics of all connected networks as well as the
local database. Run like `./services --help` to see how to use this.
Volzza 1 and Volzza 2, Metal Gear Arcade, The\*BishiBashi, and finally, Dance Evolution
Arcade. Note that it also has matching support for all Reflec Beat versions as well
as MGA. By default, this serves traffic based solely on the database it is configured
against. If you federate with other networks using the "Data API" admin page, it will
upgrade to serving traffic based on the profiles, scores and statistics of all
connected networks as well as the local database. Run like `./services --help` to
see how to use this.
Do not use this utility to serve production traffic. Instead, see
`bemani/wsgi/api.wsgi` for a ready-to-go WSGI file that can be used with a Python
@@ -341,9 +343,11 @@ simply verify that certain things exist so as not to crash a real client. This
currently generates traffic emulating Beatmania IIDX 20-26, Pop'n Music 19-27, Jubeat
Saucer, Fulfill, Prop, Qubell, Clan and Festo, Sound Voltex 1, 2, 3 Season 1/2 and 4,
Dance Dance Revolution X2, X3, 2013, 2014 and Ace, The\*BishiBashi, MÚSECA 1 and MÚSECA
1+1/2, Reflec Beat, Reflec Beat Limelight, Reflec Beat Colette, groovin'!! Upper,
Volzza 1 and Volzza 2 ad Metal Gear Arcade and can verify card events and score events
as well as PASELI transactions. Run it like `./trafficgen --help` to see how to use this.
1+1/2, Reflec Beat, Limelight, Colette, groovin'!! Upper, Volzza 1 and Volzza 2,
Dance Evolution Arcade and Metal Gear Arcade and can verify card events and score
events as well as PASELI transactions. Run it like `./trafficgen --help` to see how
to use this.
Note tha this takes a config file which sets up how the clients behave. See
`config/trafficgen.yaml` for a sample file that can be used.
@@ -690,6 +694,17 @@ corresponding to version in the following table:
./read --config config/server.yaml --series reflec --version 1 --bin reflecbeat.dll
```
### Dance Evolution
For Dance Evolution, you will need the `resource_lists.arc` file out of `data/arc/`
for the game that you wish to import. Then, run the following command. Note that
Dance Evolution only ever had one release so you only need to do this to the newest
copy of the data you wish to run.
```
./read --config config/server.yaml --series danevo --version 1 --bin resource_lists.arc
```
## Running Locally
Once you've set all of this up, you can start the network in debug mode using a command

View File

@@ -172,6 +172,8 @@ Valid game series and their versions are as follows. Clients and servers should
* ``6`` - Jubeat Prop
* ``7`` - Jubeat Qubell
* ``8`` - Jubeat Clan
* ``9`` - Jubeat Festo
* ``10`` - Jubeat Ave
* ``museca``
* ``1`` - MUSECA
* ``1p`` - MUSECA 1+1/2
@@ -245,6 +247,22 @@ Given that a high score, once set, is immutable, records are easily cached on th
* ``since`` A UTC unix timestamp to constrain the range of records looked up and returned. If provided, records with an update time greater than or equal to this time should be returned, and records less than this time should be excluded.
* ``until`` A UTC unix timestamp to constrain the range of records looked up and returned. If provided, records with an update time less than this time should be returned, and records greater than or equal to this time should be excluded.
### Dance Evolution Additional Attributes and Documentation
Valid charts for Dance Evolution according to the game are as follows:
* ``0`` - Light
* ``1`` - Standard
* ``2`` - Extreme
* ``3`` - Stealth
* ``4`` - Master
The following attributes should be returned (if available) for records belonging to the Dance Evolution series.
* ``grade`` - The letter grade ranking the user has earned, as a string enum. Should be one of the following values: "AAA", "AA", "A", "B", "C", "D", "E", "F".
* ``combo`` - The highest combo achieved during play, as an integer.
* ``full_combo`` - Whether the record represents a full combo or not, as a boolean.
### DDR Additional Attributes and Documentation
Valid charts for DDR according to the game are as follows:
@@ -460,6 +478,12 @@ The profile request isn't currently meant to allow instantiation of full game pr
* ``exact`` - This profile is for the requested game/version. Additional attributes specified below are for this game/version.
* ``partial`` - This profile is for the requested game, but a different version. If the server is capable of doing so, additional attributes should be converted for correct consumption for the game/version requested by the client. If it is not possible, they should be set to -1 to indicate they are not available for the requested game/version.
### Dance Evolution Additional Attributes and Documentation
The following attributes should be returned (if available) by all profiles belonging to the Dance Evolution series.
* ``area`` - The string area as set on the cabinet when creating a profile on DDR. If unavailable, this should be set to "".
### DDR Additional Attributes and Documentation
The following attributes should be returned (if available) by all profiles belonging to the DDR series.
@@ -514,6 +538,15 @@ Each song object found in the "songs" list should at minimum contain the followi
* ``artist`` - A string specifying the song's artist. If this is unavailable for this song, a blank string should be returned.
* ``genre`` - A string specifying the song's genre. If this is unavailable for this song, a blank string should be returned.
### Dance Evolution Additional Attributes and Documentation
The following attributes should be returned (if available) for all songs belonging to the Dance Evolution series.
* ``level`` - An integer representing the in-game level of the song.
* ``bpm_min`` - An integer representing the minimum BPM of the song.
* ``bpm_max`` - An integer representing the maximum BPM of the song.
* ``kcal`` - A float representing the kcal rating of the song.
### DDR Additional Attributes and Documentation
The following attributes should be returned (if available) for all songs belonging to the DDR series.

View File

@@ -211,6 +211,7 @@ def lookup(protoversion: str, requestgame: str, requestversion: str) -> Dict[str
("popnmusic", GameConstants.POPN_MUSIC),
("reflecbeat", GameConstants.REFLEC_BEAT),
("soundvoltex", GameConstants.SDVX),
("danceevolution", GameConstants.DANCE_EVOLUTION),
]:
if constant in g.config.support:
gamemapping[gameid] = constant
@@ -293,6 +294,9 @@ def lookup(protoversion: str, requestgame: str, requestversion: str) -> Dict[str
"3": VersionConstants.SDVX_GRAVITY_WARS,
"4": VersionConstants.SDVX_HEAVENLY_HAVEN,
},
GameConstants.DANCE_EVOLUTION: {
"1": VersionConstants.DANCE_EVOLUTION,
},
}
.get(game, {})
.get(requestversion)

View File

@@ -7,6 +7,14 @@ from bemani.data import Song
class CatalogObject(BaseObject):
def __format_danevo_song(self, song: Song) -> Dict[str, Any]:
return {
"level": song.data.get_int("level"),
"bpm_min": song.data.get_int("bpm_min"),
"bpm_max": song.data.get_int("bpm_max"),
"kcal": song.data.get_float("kcal"),
}
def __format_ddr_song(self, song: Song) -> Dict[str, Any]:
groove = song.data.get_dict("groove")
return {
@@ -122,6 +130,8 @@ class CatalogObject(BaseObject):
base.update(self.__format_reflec_song(song))
if self.game == GameConstants.SDVX:
base.update(self.__format_sdvx_song(song))
if self.game == GameConstants.DANCE_EVOLUTION:
base.update(self.__format_danevo_song(song))
return base
@@ -258,6 +268,12 @@ class CatalogObject(BaseObject):
)
)
songs.extend(additions)
# Always a special case, of course. DanEvo has a virtual chart for play statistics
# tracking, so we need to filter that out here.
if self.game == GameConstants.DANCE_EVOLUTION:
songs = [song for song in songs if song.chart in [0, 1, 2, 3, 4]]
retval = {
"songs": [self.__format_song(song) for song in songs],
}

View File

@@ -7,6 +7,11 @@ from bemani.data import UserID
class ProfileObject(BaseObject):
def __format_danevo_profile(self, profile: Profile, exact: bool) -> Dict[str, Any]:
return {
"area": profile.get_str("area", "") if exact else "",
}
def __format_ddr_profile(self, profile: Profile, exact: bool) -> Dict[str, Any]:
return {
"area": profile.get_int("area", -1) if exact else -1,
@@ -71,6 +76,8 @@ class ProfileObject(BaseObject):
base.update(self.__format_reflec_profile(profile, exact))
if self.game == GameConstants.SDVX:
base.update(self.__format_sdvx_profile(profile, exact))
if self.game == GameConstants.DANCE_EVOLUTION:
base.update(self.__format_danevo_profile(profile, exact))
return base

View File

@@ -7,6 +7,24 @@ from bemani.data import Score, UserID
class RecordsObject(BaseObject):
def __format_danevo_record(self, record: Score) -> Dict[str, Any]:
grade = {
DBConstants.DANEVO_GRADE_AAA: "AAA",
DBConstants.DANEVO_GRADE_AA: "AA",
DBConstants.DANEVO_GRADE_A: "A",
DBConstants.DANEVO_GRADE_B: "B",
DBConstants.DANEVO_GRADE_C: "C",
DBConstants.DANEVO_GRADE_D: "D",
DBConstants.DANEVO_GRADE_E: "E",
DBConstants.DANEVO_GRADE_FAILED: "F",
}.get(record.data.get_int("grade"), "F")
return {
"grade": grade,
"combo": record.data.get_int("combo"),
"full_combo": record.data.get_bool("full_combo"),
}
def __format_ddr_record(self, record: Score) -> Dict[str, Any]:
halo = {
DBConstants.DDR_HALO_NONE: "none",
@@ -217,6 +235,8 @@ class RecordsObject(BaseObject):
base.update(self.__format_reflec_record(record))
if self.game == GameConstants.SDVX:
base.update(self.__format_sdvx_record(record))
if self.game == GameConstants.DANCE_EVOLUTION:
base.update(self.__format_danevo_record(record))
return base
@@ -310,6 +330,13 @@ class RecordsObject(BaseObject):
if record.update >= until:
continue
# Dance Evolution is a special case where it stores data in a virtual chart
# to keep track of play counts, due to the game not sending chart back with
# attempts.
if self.game == GameConstants.DANCE_EVOLUTION:
if record.chart not in [0, 1, 2, 3, 4]:
continue
if userid not in id_to_cards:
cards = self.data.local.user.get_cards(userid)
if len(cards) == 0:

View File

@@ -188,6 +188,13 @@ class StatisticsObject(BaseObject):
def fetch_v1(self, idtype: APIConstants, ids: List[str], params: Dict[str, Any]) -> List[Dict[str, Any]]:
retval: List[Dict[str, Any]] = []
# Special case, Dance Evolution can't track attempts in any meaningful capacity
# because the game does not actually send down information about the chart for
# given attempts. So, we only know that players plays a song, not what chart or
# whether they cleared it or full-combo'd it.
if self.game == GameConstants.DANCE_EVOLUTION:
return []
# Fetch the attempts
if idtype == APIConstants.ID_TYPE_SERVER:
retval = self.__aggregate_global(

View File

@@ -0,0 +1,8 @@
from bemani.backend.danevo.factory import DanceEvolutionFactory
from bemani.backend.danevo.base import DanceEvolutionBase
__all__ = [
"DanceEvolutionFactory",
"DanceEvolutionBase",
]

View File

@@ -0,0 +1,115 @@
# vim: set fileencoding=utf-8
from typing import Optional
from typing_extensions import Final
from bemani.backend.base import Base
from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler
from bemani.common import DBConstants, GameConstants, ValidatedDict
from bemani.data import UserID
class DanceEvolutionBase(CoreHandler, CardManagerHandler, PASELIHandler, Base):
"""
Base game class for all Dance Evolution version that we support.
"""
game: GameConstants = GameConstants.DANCE_EVOLUTION
CHART_TYPE_LIGHT: Final[int] = 0
CHART_TYPE_STANDARD: Final[int] = 1
CHART_TYPE_EXTREME: Final[int] = 2
CHART_TYPE_STEALTH: Final[int] = 3
CHART_TYPE_MASTER: Final[int] = 4
CHART_TYPE_PLAYTRACKING: Final[int] = 5
GRADE_FAILED: Final[int] = DBConstants.DANEVO_GRADE_FAILED
GRADE_E: Final[int] = DBConstants.DANEVO_GRADE_E
GRADE_D: Final[int] = DBConstants.DANEVO_GRADE_D
GRADE_C: Final[int] = DBConstants.DANEVO_GRADE_C
GRADE_B: Final[int] = DBConstants.DANEVO_GRADE_B
GRADE_A: Final[int] = DBConstants.DANEVO_GRADE_A
GRADE_AA: Final[int] = DBConstants.DANEVO_GRADE_AA
GRADE_AAA: Final[int] = DBConstants.DANEVO_GRADE_AAA
def previous_version(self) -> Optional["DanceEvolutionBase"]:
"""
Returns the previous version of the game, based on this game. Should
be overridden.
"""
return None
def update_score(
self,
userid: UserID,
songid: int,
chart: int,
points: int,
grade: int,
combo: int,
full_combo: bool,
) -> None:
"""
Given various pieces of a score, update the user's high score.
"""
if chart not in {
self.CHART_TYPE_LIGHT,
self.CHART_TYPE_STANDARD,
self.CHART_TYPE_EXTREME,
self.CHART_TYPE_STEALTH,
self.CHART_TYPE_MASTER,
}:
raise Exception(f"Invalid chart {chart}")
if grade not in {
self.GRADE_FAILED,
self.GRADE_E,
self.GRADE_D,
self.GRADE_C,
self.GRADE_B,
self.GRADE_A,
self.GRADE_AA,
self.GRADE_AAA,
}:
raise Exception(f"Invalid grade {grade}")
oldscore = self.data.local.music.get_score(
self.game,
self.version,
userid,
songid,
chart,
)
if oldscore is None:
# If it is a new score, create a new dictionary to add to
scoredata = ValidatedDict({})
highscore = True
else:
# Set the score to any new record achieved
highscore = points >= oldscore.points
points = max(oldscore.points, points)
scoredata = oldscore.data
# Save combo
scoredata.replace_int("combo", max(scoredata.get_int("combo"), combo))
# Save grade
scoredata.replace_int("grade", max(scoredata.get_int("grade"), grade))
# Save full combo indicator.
scoredata.replace_bool("full_combo", scoredata.get_bool("full_combo") or full_combo)
# Look up where this score was earned
lid = self.get_machine_id()
# Write the new score back
self.data.local.music.put_score(
self.game,
self.version,
userid,
songid,
chart,
lid,
points,
scoredata,
highscore,
)

View File

@@ -0,0 +1,520 @@
import base64
import struct
from typing import Any, Dict
from typing_extensions import Final
from bemani.backend.ess import EventLogHandler
from bemani.backend.danevo.base import DanceEvolutionBase
from bemani.common import VersionConstants, Profile, CardCipher, Time
from bemani.data import ScoreSaveException
from bemani.protocol import Node
class DanceEvolution(
EventLogHandler,
DanceEvolutionBase,
):
name: str = "Dance Evolution"
version: int = VersionConstants.DANCE_EVOLUTION
# Dance Evolution is a mess that only uses profile blobs for everything from the server.
# This includes scores/records, and your profile and class advancement. DATA01 includes
# standard profile info in the CSV portion much like every ESS game. DATA03 includes, for
# some reason, the ID of the songs you just played as well as the score, but not the chart.
# DATA04 includes your cumulative score earned across all plays. DATA01-DATA05 as well as
# DATA11-DATA15 hold the records for songs in the binary portion, where 01-05 indicate the
# chart difficulty for the song and the offset into the binary data can be calculated by
# the song's offset in the music DB. RDATA01 appears to be for attempts, and seems to
# include information in the binary of previous attempts at songs, ordered by attempt.
# This, however, does not include the chart played, so it can't be used to inform the backend
# of attempts.
# RDATA running score list includes history chunks that are 32 bytes in length. The game
# never sends trailing zeros for a data structure, even if it internally recognizes them,
# so this may have length not divisible by 32 if there are no non-zero bytes after a certain
# location. The correct thing to do is to fill with assumed zero bytes to a 32-byte boundary.
# The first 4 bytes of any history chunk are the score in little endian. The next byte is the
# song ID. Note that the chart does not appear anywhere in this structure to my knowledge.
# I have no idea what the rest of the bytes are, but most stay zeros and many are the same
# no matter what.
# DATA01-05 store the records for songs, including the high score, the letter grade earned,
# and whether the song has been played (a record exists), cleared and whether a full combo
# has been earned. The first 63 songs are stored in DATA01-05 (songs with ID 0-62). Song
# ID 63 and above are stored in DATA11-15 instead, but in an identical format. Each record
# is 8 bytes long and can be found by multiplying the music ID by 8. To get to songs in the
# second chunk, first subtract 63 from the song ID and then multiply that value by 8. The
# first 4 bytes are the high score, stored in little-endian. The next byte is the combo
# achieved. The next byte is always observed to be 0x04, and the one after that 0x00. Finally,
# the last byte is the letter grade and full combo marker. Bit 4 set indicates the player
# earned a full combo. Bits 3-1 are a 3 bit integer indicating the letter grade. Bit 0 is
# unused. The game considers any letter grade other than 0 to be a pass.
# The game stores records for each chart in a different DATAXX location. See the below chart
# for exact storage locations. Note that yes, stealth and master are reversed from how they
# are presented in game.
#
# DATA01/11 - Light
# DATA02/12 - Standard
# DATA03/13 - Extreme
# DATA04/14 - Stealth
# DATA05/15 - Master
# The letter grades are believed to be as follows.
#
# 0 - Failed. Game displays a white gem to indicate the song was played.
# 1 - E. Game displays a colored gem matching the chart to indicate the song was cleared.
# 2 - D. Game displays a colored gem matching the chart to indicate the song was cleared.
# 3 - C. Game displays a colored gem matching the chart to indicate the song was cleared.
# 4 - B. Game displays a colored gem matching the chart to indicate the song was cleared.
# 5 - A. Game displays a colored gem matching the chart to indicate the song was cleared.
# 6 - AA. Game displays a colored gem matching the chart to indicate the song was cleared.
# 7 - AAA. Game displays a colored gem matching the chart to indicate the song was cleared.
DATA01_CLASS_OFFSET: Final[int] = 2
DATA01_GOLD_OFFSET: Final[int] = 3
DATA01_NAME_OFFSET: Final[int] = 25
DATA01_AREA_OFFSET: Final[int] = 26
DATA01_SHOP_OFFSET: Final[int] = 27
DATA02_DANCE_MATE_NAME_OFFSET: Final[int] = 25
DATA03_DANCE_MATE_OFFSET: Final[int] = 8
DATA03_FIRST_SONG_OFFSET: Final[int] = 13
DATA03_FIRST_HIGH_SCORE_OFFSET: Final[int] = 14
DATA03_SECOND_SONG_OFFSET: Final[int] = 15
DATA03_SECOND_HIGH_SCORE_OFFSET: Final[int] = 16
DATA03_THIRD_SONG_OFFSET: Final[int] = 17
DATA03_THIRD_HIGH_SCORE_OFFSET: Final[int] = 18
DATA04_TOTAL_SCORE_EARNED_OFFSET: Final[int] = 9
GAME_GRADE_FAILED: Final[int] = 0
GAME_GRADE_E: Final[int] = 1
GAME_GRADE_D: Final[int] = 2
GAME_GRADE_C: Final[int] = 3
GAME_GRADE_B: Final[int] = 4
GAME_GRADE_A: Final[int] = 5
GAME_GRADE_AA: Final[int] = 6
GAME_GRADE_AAA: Final[int] = 7
@classmethod
def get_settings(cls) -> Dict[str, Any]:
"""
Return all of our front-end modifiably settings.
"""
return {}
def __update_shop_name(self, profiledata: bytes) -> None:
# Figure out the profile type
csvs = profiledata.split(b",")
if len(csvs) < 2:
# Not long enough to care about
return
datatype = csvs[1].decode("ascii")
if datatype != "DATA01":
# Not the right profile type requested
return
# Grab the shop name, which is offset based on storage, not based on the
# game's sending this to us now.
try:
shopname = csvs[self.DATA01_SHOP_OFFSET + 2].decode("shift-jis")
except Exception:
return
self.update_machine_name(shopname)
def handle_tax_get_phase_request(self, request: Node) -> Node:
tax = Node.void("tax")
tax.add_child(Node.s32("phase", 0))
return tax
def handle_system_convcardnumber_request(self, request: Node) -> Node:
cardid = request.child_value("data/card_id")
cardnumber = CardCipher.encode(cardid)
system = Node.void("system")
data = Node.void("data")
system.add_child(data)
system.add_child(Node.s32("result", 0))
data.add_child(Node.string("card_number", cardnumber))
return system
def handle_system_getmaster_request(self, request: Node) -> Node:
# See if we can grab the request
data = request.child("data")
if not data:
root = Node.void("system")
root.add_child(Node.s32("result", 0))
return root
# Figure out what type of messsage this is
reqtype = data.child_value("datatype")
reqkey = data.child_value("datakey") # noqa
# System message
root = Node.void("system")
if reqtype == "S_SRVMSG":
# Known keys include: INFO, ARK_ARR0, ARK_HAS0, SONGOPEN, IRDATA, EVTMSG3, WEEKLYSO
strdata = ""
root.add_child(Node.string("strdata1", base64.b64encode(strdata.encode("ascii")).decode("ascii")))
root.add_child(Node.string("strdata2", ""))
root.add_child(Node.u64("updatedate", Time.now() * 1000))
root.add_child(Node.s32("result", 1))
else:
# Unknown message.
root.add_child(Node.s32("result", 0))
return root
def handle_playerdata_usergamedata_recvscores_request(self, request: Node) -> Node:
# NOTE: This is an entirely made up endpoint. The game does not call it. This exists
# entirely to allow for client integration tests (trafficgen) to verify score saving
# because otherwise there's no way to know that the backend actually parsed a score.
playerdata = Node.void("playerdata")
player = Node.void("player")
playerdata.add_child(player)
refid = request.child_value("data/refid")
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
if userid is not None:
scorecount = 0
scores = Node.void("scores")
player.add_child(scores)
player.add_child(Node.string("refid", refid))
for score in self.data.local.music.get_scores(self.game, self.version, userid):
if score.chart not in {
self.CHART_TYPE_LIGHT,
self.CHART_TYPE_STANDARD,
self.CHART_TYPE_EXTREME,
self.CHART_TYPE_STEALTH,
self.CHART_TYPE_MASTER,
}:
# Skip virtual scores for tracking play counts and popularity.
continue
grade = {
self.GRADE_FAILED: self.GAME_GRADE_FAILED,
self.GRADE_E: self.GAME_GRADE_E,
self.GRADE_D: self.GAME_GRADE_D,
self.GRADE_C: self.GAME_GRADE_C,
self.GRADE_B: self.GAME_GRADE_B,
self.GRADE_A: self.GAME_GRADE_A,
self.GRADE_AA: self.GAME_GRADE_AA,
self.GRADE_AAA: self.GAME_GRADE_AAA,
}[score.data.get_int("grade")]
scorenode = Node.void("score")
scorenode.add_child(Node.u16("id", score.id))
scorenode.add_child(Node.u8("chart", score.chart))
scorenode.add_child(Node.u32("points", score.points))
scorenode.add_child(Node.u8("grade", grade))
scorenode.add_child(Node.u8("combo", score.data.get_int("combo")))
scorenode.add_child(Node.bool("full_combo", score.data.get_bool("full_combo")))
scores.add_child(scorenode)
scorecount += 1
player.add_child(Node.u32("scores_num", scorecount))
playerdata.add_child(Node.s32("result", 0))
return playerdata
def _to_hex(self, number: int) -> str:
return hex(number)[2:]
def handle_playerdata_usergamedata_recv_request(self, request: Node) -> Node:
playerdata = Node.void("playerdata")
player = Node.void("player")
playerdata.add_child(player)
refid = request.child_value("data/refid")
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
if userid is not None:
profile = self.get_profile(userid)
links = self.data.local.user.get_links(self.game, self.version, userid)
dancemates = len([l for l in links if l.type == "dancemate"])
# Dancemates are extremely weird, the game doesn't seem to send the extid or refid of
# the person you played with, only a space-padded representation of their name. So, we
# store the name at lookup to correlate names back to profiles on save.
if profile:
name_padded = profile.get_str("name")
while len(name_padded) < 10:
name_padded += "_"
self.cache.set(name_padded.replace(" ", "_"), userid, timeout=30 * Time.SECONDS_IN_MINUTE)
records = 0
record = Node.void("record")
player.add_child(record)
def danevohex(val: int) -> str:
return hex(val)[2:]
if profile is None:
# Figure out what profiles are being requested
profiletypes = request.child_value("data/recv_csv").split(",")[::2]
for ptype in profiletypes:
# Just return a default empty node
record.add_child(Node.string("d", "<NODATA>"))
records += 1
else:
# Figure out what profiles are being requested
profiletypes = request.child_value("data/recv_csv").split(",")[::2]
usergamedata = profile.get_dict("usergamedata")
for ptype in profiletypes:
if ptype in usergamedata:
splits = usergamedata[ptype]["strdata"].split(b",")
if ptype == "DATA01":
# Common profile stuff.
splits[self.DATA01_NAME_OFFSET] = profile.get_str("name").encode("shift-jis")
splits[self.DATA01_AREA_OFFSET] = profile.get_str("area").encode("shift-jis")
splits[self.DATA01_CLASS_OFFSET] = self._to_hex(profile.get_int("class", 1)).encode(
"shift-jis"
)
splits[self.DATA01_GOLD_OFFSET] = self._to_hex(profile.get_int("gold", 0)).encode(
"shift-jis"
)
elif ptype == "DATA03":
# Dance mate stuff, and where scores come back.
splits[self.DATA03_DANCE_MATE_OFFSET] = self._to_hex(dancemates).encode("shift-jis")
elif ptype == "DATA04":
# Cumulative score.
splits[self.DATA04_TOTAL_SCORE_EARNED_OFFSET] = self._to_hex(
profile.get_int("cumulative_score", 0)
).encode("shift-jis")
dnode = Node.string(
"d",
base64.b64encode(b",".join(splits)).decode("ascii"),
)
dnode.add_child(
Node.string(
"bin1",
base64.b64encode(usergamedata[ptype]["bindata"]).decode("ascii"),
)
)
record.add_child(dnode)
else:
# Just return a default empty node
record.add_child(Node.string("d", "<NODATA>"))
records += 1
player.add_child(Node.u32("record_num", records))
playerdata.add_child(Node.s32("result", 0))
return playerdata
def handle_playerdata_usergamedata_send_request(self, request: Node) -> Node:
playerdata = Node.void("playerdata")
refid = request.child_value("data/refid")
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
if userid is not None:
profile = self.get_profile(userid)
is_new = False
if profile is None:
profile = Profile(self.game, self.version, refid, 0)
is_new = True
usergamedata = profile.get_dict("usergamedata")
for record in request.child("data/record").children:
if record.name != "d":
continue
strdata = base64.b64decode(record.value)
bindata = base64.b64decode(record.child_value("bin1"))
# Update the shop name if this is a new profile, since we know on new profiles that
# this value came from the game itself.
if is_new:
self.__update_shop_name(strdata)
# Grab and format the profile objects
strdatalist = strdata.split(b",")
profiletype = strdatalist[1].decode("utf-8")
strdatalist = strdatalist[2:]
if profiletype == "DATA01":
# Extract relevant info so that it's in the profile normally.
profile.replace_str("name", strdatalist[self.DATA01_NAME_OFFSET].decode("shift-jis"))
profile.replace_int("class", int(strdatalist[self.DATA01_CLASS_OFFSET].decode("shift-jis"), 16))
profile.replace_int("gold", int(strdatalist[self.DATA01_GOLD_OFFSET].decode("shift-jis"), 16))
profile.replace_str("area", strdatalist[self.DATA01_AREA_OFFSET].decode("shift-jis"))
elif profiletype == "DATA02":
# Extract possible dance mate and link it to the player.
potential_dancemate = strdatalist[self.DATA02_DANCE_MATE_NAME_OFFSET].decode("shift-jis")
potential_dancemate = potential_dancemate[:10]
if potential_dancemate.strip():
# First, try to find it in our cache.
other_userid = self.cache.get(potential_dancemate.replace(" ", "_"))
if other_userid:
self.data.local.user.put_link(
self.game, self.version, userid, "dancemate", other_userid, {"last_played": Time.now()}
)
elif profiletype == "DATA04":
# Keep track of this for fun, because hey, why not?
profile.replace_int(
"cumulative_score",
int(strdatalist[self.DATA04_TOTAL_SCORE_EARNED_OFFSET].decode("shift-jis"), 16),
)
usergamedata[profiletype] = {
"strdata": b",".join(strdatalist),
"bindata": bindata,
}
profile.replace_dict("usergamedata", usergamedata)
profile.replace_int("write_time", Time.now())
self.put_profile(userid, profile)
# Keep track of play statistics across all versions, but don't do it for the initial profile save.
if not is_new:
self.update_play_statistics(userid)
# Now that we've got a fully updated profile to look at, let's see if we can't extract the last played songs
# and link those to records if they were a record. Unfortunately the game does not specify what chart was
# played in RDATA so we can't use that. We can, however, look at the song played positions in DATA03 and
# compare the score achieved to the various locations in DATAXX to see if it was a record thie time or not.
valid_ids = {song.id for song in self.data.local.music.get_all_songs(self.game, self.version)}
if "DATA03" in usergamedata:
strdatalist = usergamedata["DATA03"]["strdata"].split(b",")
# First two are represented in the hex section.
first_song_played = int(strdatalist[self.DATA03_FIRST_SONG_OFFSET].decode("shift-jis"), 16)
second_song_played = int(strdatalist[self.DATA03_SECOND_SONG_OFFSET].decode("shift-jis"), 16)
first_song_scored = int(strdatalist[self.DATA03_FIRST_HIGH_SCORE_OFFSET].decode("shift-jis"), 16)
second_song_scored = int(strdatalist[self.DATA03_SECOND_HIGH_SCORE_OFFSET].decode("shift-jis"), 16)
# They really just stuck this in as floats. I couldn't make this up.
third_song_played = int(strdatalist[self.DATA03_THIRD_SONG_OFFSET].decode("shift-jis").split(".")[0])
third_song_scored = int(
strdatalist[self.DATA03_THIRD_HIGH_SCORE_OFFSET].decode("shift-jis").split(".")[0]
)
for played, scored in [
(first_song_played, first_song_scored),
(second_song_played, second_song_scored),
(third_song_played, third_song_scored),
]:
if played not in valid_ids:
# Game might be set to 1 song.
continue
# For the purpose of popularity tracking, save an attempt for this song into the virtual
# attempt chart, since we can't know for certain what chart an attempt was associated with.
now = Time.now()
lid = self.get_machine_id()
for bump in range(10):
timestamp = now + bump
self.data.local.music.put_score(
self.game,
self.version,
userid,
played,
self.CHART_TYPE_PLAYTRACKING,
lid,
scored,
{},
False,
timestamp=timestamp,
)
try:
self.data.local.music.put_attempt(
self.game,
self.version,
userid,
played,
self.CHART_TYPE_PLAYTRACKING,
lid,
scored,
{},
False,
timestamp=timestamp,
)
except ScoreSaveException:
# Try again one second in the future
continue
# We saved successfully
break
# First, calculate whether we're going to look at DATA01-05 or DATA11-15.
if played < 63:
mapping = {
"DATA01": self.CHART_TYPE_LIGHT,
"DATA02": self.CHART_TYPE_STANDARD,
"DATA03": self.CHART_TYPE_EXTREME,
"DATA04": self.CHART_TYPE_STEALTH,
"DATA05": self.CHART_TYPE_MASTER,
}
offset = played * 8
else:
mapping = {
"DATA11": self.CHART_TYPE_LIGHT,
"DATA12": self.CHART_TYPE_STANDARD,
"DATA13": self.CHART_TYPE_EXTREME,
"DATA14": self.CHART_TYPE_STEALTH,
"DATA15": self.CHART_TYPE_MASTER,
}
offset = (played - 63) * 8
# Now, grab that data and offset into it to decode the record if it is there.
for key in mapping:
if key not in usergamedata:
# Haven't played any charts that filled this in, so the game never sent it, skip this
# because it couldn't possibly be it.
continue
# They could have played some other songs and the game truncated this because it
# only ever sends back until the last nonzero value, so we need to fill in the blanks
# so to speak with all zero bytes.
chunk = usergamedata[key]["bindata"][offset : (offset + 8)]
if len(chunk) < 8:
chunk = chunk + (b"\x00" * (8 - len(chunk)))
record, combo, playmarker, _, stats = struct.unpack("<Ibbbb", chunk)
if record != scored:
# This wasn't the record we just earned.
continue
if not playmarker:
# This wasn't actually played.
continue
# Found it!
full_combo = bool(stats & 0x10)
letter_grade = (stats >> 1) & 0x7
grade = {
self.GAME_GRADE_FAILED: self.GRADE_FAILED,
self.GAME_GRADE_E: self.GRADE_E,
self.GAME_GRADE_D: self.GRADE_D,
self.GAME_GRADE_C: self.GRADE_C,
self.GAME_GRADE_B: self.GRADE_B,
self.GAME_GRADE_A: self.GRADE_A,
self.GAME_GRADE_AA: self.GRADE_AA,
self.GAME_GRADE_AAA: self.GRADE_AAA,
}[letter_grade]
self.update_score(userid, played, mapping[key], scored, grade, combo, full_combo)
# Don't need to update anything else now.
break
playerdata.add_child(Node.s32("result", 0))
return playerdata

View File

@@ -0,0 +1,28 @@
from typing import List, Optional, Type
from bemani.backend.base import Base, Factory
from bemani.backend.danevo.danevo import DanceEvolution
from bemani.common import Model
from bemani.data import Config, Data
class DanceEvolutionFactory(Factory):
MANAGED_CLASSES: List[Type[Base]] = [
DanceEvolution,
]
@classmethod
def register_all(cls) -> None:
for gamecode in ["KDM"]:
Base.register(gamecode, DanceEvolutionFactory)
@classmethod
def create(
cls,
data: Data,
config: Config,
model: Model,
parentmodel: Optional[Model] = None,
) -> Optional[Base]:
# There is only one Dance Evolution.
return DanceEvolution(data, config, model)

View File

@@ -809,17 +809,18 @@ class DDRAce(
return hex(val)[2:]
if profile is None:
# Just return a default empty node
record.add_child(Node.string("d", "<NODATA>"))
records = 1
# Figure out what profiles are being requested
profiletypes = request.child_value("data/recv_csv").split(",")[::2]
for ptype in profiletypes:
# Just return a default empty node
record.add_child(Node.string("d", "<NODATA>"))
records += 1
else:
# Figure out what profiles are being requested
profiletypes = request.child_value("data/recv_csv").split(",")[::2]
usergamedata = profile.get_dict("usergamedata")
for ptype in profiletypes:
if ptype in usergamedata:
records = records + 1
if ptype == "COMMON":
# Return basic profile options
name = profile.get_str("name")
@@ -935,6 +936,12 @@ class DDRAce(
)
record.add_child(dnode)
else:
# Just return a default empty node
record.add_child(Node.string("d", "<NODATA>"))
records += 1
player.add_child(Node.u32("record_num", records))
playerdata.add_child(Node.s32("result", 0))

View File

@@ -0,0 +1,6 @@
from bemani.client.danevo.danevo import DanceEvolutionClient
__all__ = [
"DanceEvolutionClient",
]

View File

@@ -0,0 +1,855 @@
import base64
import random
import struct
import time
from typing import Optional, Dict, List
from bemani.client.base import BaseClient
from bemani.protocol import Node
class DanceEvolutionClient(BaseClient):
NAME1 = ""
NAME2 = ""
def verify_eventlog_write(self, location: str) -> None:
call = self.call_node()
# Construct node
eventlog = Node.void("eventlog")
call.add_child(eventlog)
eventlog.set_attribute("method", "write")
eventlog.add_child(Node.u32("retrycnt", 0))
data = Node.void("data")
eventlog.add_child(data)
data.add_child(Node.string("eventid", "S_PWRON"))
data.add_child(Node.s32("eventorder", 0))
data.add_child(Node.u64("pcbtime", int(time.time() * 1000)))
data.add_child(Node.s64("gamesession", -1))
data.add_child(Node.string("strdata1", "2.3.4"))
data.add_child(Node.string("strdata2", ""))
data.add_child(Node.s64("numdata1", 1))
data.add_child(Node.s64("numdata2", 0))
data.add_child(Node.string("locationid", location))
# Swap with server
resp = self.exchange("", call)
# Verify that response is correct
self.assert_path(resp, "response/eventlog/gamesession")
self.assert_path(resp, "response/eventlog/logsendflg")
self.assert_path(resp, "response/eventlog/logerrlevel")
self.assert_path(resp, "response/eventlog/evtidnosendflg")
def verify_tax_get_phase(self) -> None:
call = self.call_node()
tax = Node.void("tax")
call.add_child(tax)
tax.set_attribute("method", "get_phase")
# Swap with server
resp = self.exchange("", call)
# Verify that response is correct
self.assert_path(resp, "response/tax/phase")
def verify_system_getmaster(self) -> None:
for datakey in ["ARK_ARR0", "ARK_HAS0", "SONGOPEN", "INFO", "IRDATA", "EVTMSG3", "WEEKLYSO"]:
call = self.call_node()
system = Node.void("system")
call.add_child(system)
system.set_attribute("method", "getmaster")
data = Node.void("data")
system.add_child(data)
data.add_child(Node.string("gamekind", "KDM"))
data.add_child(Node.string("datatype", "S_SRVMSG"))
data.add_child(Node.string("datakey", datakey))
# Swap with server
resp = self.exchange("", call)
# Verify that response is correct
self.assert_path(resp, "response/system/strdata1")
self.assert_path(resp, "response/system/strdata2")
self.assert_path(resp, "response/system/updatedate")
self.assert_path(resp, "response/system/result")
def verify_system_convcardnumber(self, cardno: str) -> None:
call = self.call_node()
# Construct node
system = Node.void("system")
call.add_child(system)
system.set_attribute("method", "convcardnumber")
info = Node.void("info")
system.add_child(info)
info.add_child(Node.s32("version", 1))
data = Node.void("data")
system.add_child(data)
data.add_child(Node.string("card_id", cardno))
data.add_child(Node.s32("card_type", 1))
# Swap with server
resp = self.exchange("", call)
# Verify that response is correct
self.assert_path(resp, "response/system/data/card_number")
self.assert_path(resp, "response/system/result")
def _get_base_profile_data(self, include_secondary: bool) -> Dict[str, List[bytes]]:
profiledata = {
"DATA01": [
b"1",
b"0",
b"1", # Class offset.
b"0", # Earned gold offset.
b"0",
b"1",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"", # Name spot, will be filled in later.
b"\x96\xa2\x90\xdd\x92\xe8", # Area spot, hardcoded to "unset".
b"", # Arcade name spot, we don't send this in tests.
b"",
b"",
b"",
b"",
b"",
],
"DATA02": [
b"1",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"", # Dance Mate name spot, will be filled in later.
b"",
b"",
b"",
b"",
b"",
b"",
b"",
],
"DATA03": [
b"1",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0", # Number of dancemates, ignored on send.
b"0",
b"0",
b"0",
b"0",
b"ffffffffffffffff", # First song ID.
b"ffffffffffffffff", # First score ID.
b"ffffffffffffffff", # Second song ID.
b"ffffffffffffffff", # Second score ID.
b"-1.000000",
b"-1.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"",
b"",
b"",
b"",
b"",
b"",
b"",
b"",
],
"DATA04": [
b"1",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0", # Total points earned cumulative.
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"",
b"",
b"",
b"",
b"",
b"",
b"",
b"",
],
"RDAT01": [
b"1",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"",
b"",
b"",
b"",
b"",
b"",
b"",
b"",
],
}
if include_secondary:
for secondary in ["DATA05", "DATA11", "DATA12", "DATA13", "DATA14", "DATA15"]:
profiledata[secondary] = [
b"1",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"0.000000",
b"",
b"",
b"",
b"",
b"",
b"",
b"",
b"",
]
return profiledata
def verify_usergamedata_send(self, ref_id: str, name: str, othername: str, msg_type: str) -> None:
call = self.call_node()
# Set up profile write
profiledata = self._get_base_profile_data(False)
if msg_type == "new":
# New profile gets blank name, because we save over it at the end of the round.
profiledata["DATA01"][2] = b"1"
profiledata["DATA01"][3] = b"0"
profiledata["DATA01"][25] = b""
# New profile gets blank dance mate no matter what.
profiledata["DATA02"][25] = b""
elif msg_type == "existing":
# Existing profile gets our hardcoded name saved.
profiledata["DATA01"][2] = b"3"
profiledata["DATA01"][3] = b"145"
profiledata["DATA01"][25] = name.encode("shift-jis")
# Existing profile also gets hardcoded other name if present.
if othername:
while len(othername) < 10:
othername = othername + " "
profiledata["DATA02"][25] = othername.encode("shift-jis")
else:
profiledata["DATA02"][25] = b""
else:
raise Exception(f"Unknown message type {msg_type}!")
# Construct node
playerdata = Node.void("playerdata")
call.add_child(playerdata)
playerdata.set_attribute("method", "usergamedata_send")
playerdata.add_child(Node.u32("retrycnt", 0))
info = Node.void("info")
playerdata.add_child(info)
info.add_child(Node.s32("version", 1))
data = Node.void("data")
playerdata.add_child(data)
data.add_child(Node.string("refid", ref_id))
data.add_child(Node.string("dataid", ref_id))
data.add_child(Node.string("gamekind", "KDM"))
data.add_child(Node.u32("datanum", len(profiledata.keys())))
record = Node.void("record")
data.add_child(record)
for ptype in profiledata:
profile = [b"ffffffff", ptype.encode("shift-jis")] + profiledata[ptype]
d = Node.string("d", base64.b64encode(b",".join(profile)).decode("ascii"))
d.add_child(Node.string("bin1", ""))
record.add_child(d)
# Swap with server
resp = self.exchange("", call)
self.assert_path(resp, "response/playerdata/result")
def verify_usergamedata_recv(self, ref_id: str) -> Dict[str, object]:
call = self.call_node()
# Construct node
playerdata = Node.void("playerdata")
call.add_child(playerdata)
playerdata.set_attribute("method", "usergamedata_recv")
info = Node.void("info")
playerdata.add_child(info)
info.add_child(Node.s32("version", 1))
data = Node.void("data")
playerdata.add_child(data)
data.add_child(Node.string("refid", ref_id))
data.add_child(Node.string("dataid", ref_id))
data.add_child(Node.string("gamekind", "KDM"))
data.add_child(Node.u32("recv_num", 11))
data.add_child(
Node.string(
"recv_csv",
"DATA01,3fffffffff,DATA02,3fffffffff,DATA03,3fffffffff,DATA04,3fffffffff,DATA05,3fffffffff,RDAT01,3fffffffff,DATA11,3fffffffff,DATA12,3fffffffff,DATA13,3fffffffff,DATA14,3fffffffff,DATA15,3fffffffff",
)
)
# Swap with server
resp = self.exchange("", call)
self.assert_path(resp, "response/playerdata/result")
self.assert_path(resp, "response/playerdata/player/record/d/bin1")
self.assert_path(resp, "response/playerdata/player/record_num")
profiles = 0
name = ""
gold = 0
cls = 0
dancemates = 0
total_score = 0
for child in resp.child("playerdata/player/record").children:
if child.name != "d":
continue
bindata = child.value
if bindata != "<NODATA>":
profiledata = base64.b64decode(bindata).split(b",")
if profiles == 0:
cls = int(profiledata[2].decode("shift-jis"), 16)
gold = int(profiledata[3].decode("shift-jis"), 16)
name = profiledata[25].decode("shift-jis")
if profiles == 2:
dancemates = int(profiledata[8].decode("shift-jis"), 16)
if profiles == 3:
total_score = int(profiledata[9].decode("shift-jis"), 16)
profiles = profiles + 1
if profiles != 11:
raise Exception("Didn't receive all 11 profiles in the right order!")
return {
"name": name,
"gold": gold,
"class": cls,
"dancemates": dancemates,
"total_score": total_score,
}
def verify_scores_send(self, ref_id: str, name: str, scores: List[Dict[str, int]]) -> None:
if len(scores) > 3:
raise Exception("DanEvo can only save two scores at once!")
# This is identical to usergamedata_send, but the function was getting out of hand.
call = self.call_node()
# Set up profile write
profiledata = self._get_base_profile_data(True)
# Existing profile gets our hardcoded name saved.
profiledata["DATA01"][2] = b"3"
profiledata["DATA01"][3] = b"145"
profiledata["DATA01"][25] = name.encode("shift-jis")
profiledata["DATA02"][25] = b""
spots: Dict[int, Dict[int, bytes]] = {}
highest_id: int = 0
def _to_hex(number: int) -> bytes:
return hex(number)[2:].encode("shift-jis")
for offset, score in enumerate(scores):
sid = score["id"]
chart = score["chart"]
if chart not in spots:
spots[chart] = {}
spots[chart][sid] = struct.pack(
"<Ibbbb",
score["points"],
score["combo"],
0x04,
0x00,
(score["grade"] << 1) + (0x10 if score["full_combo"] else 0x00),
)
highest_id = max(highest_id, sid)
# Game won't save unless we put things in the right spot.
if offset == 0:
profiledata["DATA03"][13] = _to_hex(sid)
profiledata["DATA03"][14] = _to_hex(score["points"])
elif offset == 1:
profiledata["DATA03"][15] = _to_hex(sid)
profiledata["DATA03"][16] = _to_hex(score["points"])
elif offset == 2:
profiledata["DATA03"][17] = str(float(sid)).encode("shift-jis")
profiledata["DATA03"][18] = str(float(score["points"])).encode("shift-jis")
else:
raise Exception("Logic error, can't save more than three scores!")
# Make binary data blobs.
blobs: Dict[int, bytes] = {}
for chart in [0, 1, 2, 3, 4]:
bdata: List[bytes] = []
for sid in range(highest_id + 1):
bdata.append(spots.get(chart, {}).get(sid, b"\x00" * 8))
blobs[chart] = b"".join(bdata)
def trimnulls(data: bytes) -> bytes:
# The game only sends as many bytes as it needs, truncating nulls after
# the last non-null.
while data and (data[-1] == 0):
data = data[:-1]
return data
# Split it by profile type.
profilebindata = {
"DATA01": trimnulls(blobs[0][:504]),
"DATA02": trimnulls(blobs[1][:504]),
"DATA03": trimnulls(blobs[2][:504]),
"DATA04": trimnulls(blobs[3][:504]),
"DATA05": trimnulls(blobs[4][:504]),
"DATA11": trimnulls(blobs[0][504:]),
"DATA12": trimnulls(blobs[1][504:]),
"DATA13": trimnulls(blobs[2][504:]),
"DATA14": trimnulls(blobs[3][504:]),
"DATA15": trimnulls(blobs[4][504:]),
"RDAT01": b"",
}
# Construct node
playerdata = Node.void("playerdata")
call.add_child(playerdata)
playerdata.set_attribute("method", "usergamedata_send")
playerdata.add_child(Node.u32("retrycnt", 0))
info = Node.void("info")
playerdata.add_child(info)
info.add_child(Node.s32("version", 1))
data = Node.void("data")
playerdata.add_child(data)
data.add_child(Node.string("refid", ref_id))
data.add_child(Node.string("dataid", ref_id))
data.add_child(Node.string("gamekind", "KDM"))
data.add_child(Node.u32("datanum", len(profiledata.keys())))
record = Node.void("record")
data.add_child(record)
for ptype in profiledata:
profile = [b"ffffffff", ptype.encode("shift-jis")] + profiledata[ptype]
d = Node.string("d", base64.b64encode(b",".join(profile)).decode("ascii"))
d.add_child(Node.string("bin1", base64.b64encode(profilebindata[ptype]).decode("ascii")))
record.add_child(d)
# Swap with server
resp = self.exchange("", call)
self.assert_path(resp, "response/playerdata/result")
def verify_scores_recv(self, ref_id: str) -> List[Dict[str, int]]:
# Note that this uses a completely made up endpoint because otherwise the game
# completely client-side manages high scores, and we just extract them for display
# on the front-end. Maybe we should have gone the whole way and round-tripped scores
# by regenerating the binary record nodes? But it's unclear what some of the values
# could be and finding the code that generates the score blobs is next to impossible.
call = self.call_node()
# Construct node
playerdata = Node.void("playerdata")
call.add_child(playerdata)
playerdata.set_attribute("method", "usergamedata_recvscores")
playerdata.add_child(Node.u32("retrycnt", 0))
info = Node.void("info")
playerdata.add_child(info)
info.add_child(Node.s32("version", 1))
data = Node.void("data")
playerdata.add_child(data)
data.add_child(Node.string("refid", ref_id))
data.add_child(Node.string("dataid", ref_id))
data.add_child(Node.string("gamekind", "KDM"))
# Swap with server
resp = self.exchange("", call)
self.assert_path(resp, "response/playerdata/result")
scores: List[Dict[str, int]] = []
for child in resp.child("playerdata/player/scores").children:
if child.name != "score":
continue
score: Dict[str, int] = {}
score["id"] = child.child_value("id")
score["chart"] = child.child_value("chart")
score["points"] = child.child_value("points")
score["grade"] = child.child_value("grade")
score["combo"] = child.child_value("combo")
score["full_combo"] = 1 if child.child_value("full_combo") else 0
scores.append(score)
return scores
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_eventlog_write(location)
self.verify_tax_get_phase()
self.verify_system_getmaster()
# Verify card registration and profile lookup
if cardid is not None:
card = cardid
card2 = None
else:
card = self.random_card()
card2 = self.random_card()
print(f"Generated random card IDs {card} and {card} for use.")
if cardid is None:
self.verify_cardmng_inquire(card, msg_type="unregistered", paseli_enabled=paseli_enabled)
self.verify_system_convcardnumber(card)
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_usergamedata_send(ref_id, self.NAME1, "", "new")
deets = self.verify_usergamedata_recv(ref_id)
if deets["name"] != "":
raise Exception("Name stored on profile we just created!")
if deets["dancemates"] != 0:
raise Exception("Dance mates on profile we just created!")
if deets["total_score"] != 0:
raise Exception("Total score on profile we just created!")
if deets["gold"] != 0:
raise Exception("Gold on profile we just created!")
if deets["class"] != 1:
raise Exception("Class on profile we just created!")
self.verify_usergamedata_send(ref_id, self.NAME1, "", "existing")
deets = self.verify_usergamedata_recv(ref_id)
if deets["name"] != self.NAME1:
raise Exception("Name stored on profile we just created!")
if deets["dancemates"] != 0:
raise Exception("Dance mates on profile we just created!")
if deets["total_score"] != 0:
raise Exception("Total score on profile we just created!")
if deets["gold"] != 325:
raise Exception("Gold on profile we just created!")
if deets["class"] != 3:
raise Exception("Class on profile we just created!")
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")
if card2 is not None:
# Create a second profile so we can be dance mates with it.
other_ref_id = self.verify_cardmng_getrefid(card2)
self.verify_usergamedata_send(other_ref_id, self.NAME2, "", "new")
self.verify_usergamedata_send(other_ref_id, self.NAME2, "", "existing")
self.verify_usergamedata_recv(other_ref_id)
# Now, have both be dance mates of each other, one at a time.
self.verify_usergamedata_send(ref_id, self.NAME1, self.NAME2, "existing")
deets = self.verify_usergamedata_recv(ref_id)
if deets["name"] != self.NAME1:
raise Exception("Unexpected name in bagging area!")
if deets["dancemates"] != 1:
raise Exception("Didn't make best friends with other profile!")
deets = self.verify_usergamedata_recv(other_ref_id)
if deets["name"] != self.NAME2:
raise Exception("Unexpected name in bagging area!")
if deets["dancemates"] != 0:
raise Exception("Shouldn't have a dance mate yet, we didn't save this profile!")
# And the second one.
self.verify_usergamedata_send(other_ref_id, self.NAME2, self.NAME1, "existing")
deets = self.verify_usergamedata_recv(ref_id)
if deets["name"] != self.NAME1:
raise Exception("Unexpected name in bagging area!")
if deets["dancemates"] != 1:
raise Exception("Didn't make best friends with other profile!")
deets = self.verify_usergamedata_recv(other_ref_id)
if deets["name"] != self.NAME2:
raise Exception("Unexpected name in bagging area!")
if deets["dancemates"] != 1:
raise Exception("Didn't make best friends with other profile!")
if cardid is None:
scores = self.verify_scores_recv(ref_id)
if len(scores) > 0:
raise Exception("Created profile should have no scores associated!")
# Verify score saving and updating
for phase in [1, 2]:
if phase == 1:
dummyscores = [
# An okay score on a chart
{
"id": 10,
"chart": 4,
"grade": 4,
"combo": 25,
"points": 765432,
"full_combo": 0,
},
# A good score on an easier chart of the same song
{
"id": 10,
"chart": 2,
"grade": 6,
"combo": 45,
"points": 876543,
"full_combo": 0,
},
# A bad score on a hard chart
{
"id": 87,
"chart": 2,
"grade": 3,
"combo": 5,
"points": 654321,
"full_combo": 0,
},
# A terrible score on an easy chart
{
"id": 89,
"chart": 1,
"grade": 0,
"combo": 1,
"points": 123456,
"full_combo": 0,
},
]
if phase == 2:
dummyscores = [
# A better score on the same chart
{
"id": 10,
"chart": 4,
"grade": 5,
"combo": 99,
"points": 888888,
"full_combo": 1,
},
# A worse score on another same chart
{
"id": 87,
"chart": 2,
"grade": 1,
"combo": 3,
"points": 543210,
"full_combo": 0,
"expected_points": 654321,
"expected_grade": 3,
"expected_combo": 5,
},
]
scorechunks = [dummyscores[x : (x + 3)] for x in range(0, len(dummyscores), 3)]
for chunk in scorechunks:
self.verify_scores_send(ref_id, self.NAME1, chunk)
scores = self.verify_scores_recv(ref_id)
if len(scores) == 0:
raise Exception("Expected some scores after saving!")
for expected in dummyscores:
actual = None
for received in scores:
if received["id"] == expected["id"] and received["chart"] == expected["chart"]:
actual = received
break
if actual is None:
raise Exception(f"Didn't find song {expected['id']} chart {expected['chart']} in response!")
if "expected_points" in expected:
expected_score = expected["expected_points"]
else:
expected_score = expected["points"]
if "expected_grade" in expected:
expected_grade = expected["expected_grade"]
else:
expected_grade = expected["grade"]
if "expected_combo" in expected:
expected_combo = expected["expected_combo"]
else:
expected_combo = expected["combo"]
if "expected_full_combo" in expected:
expected_full_combo = expected["expected_full_combo"]
else:
expected_full_combo = expected["full_combo"]
if actual["points"] != expected_score:
raise Exception(
f'Expected a score of \'{expected_score}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got score \'{actual["points"]}\''
)
if actual["grade"] != expected_grade:
raise Exception(
f'Expected a grade of \'{expected_grade}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got grade \'{actual["grade"]}\''
)
if actual["combo"] != expected_combo:
raise Exception(
f'Expected a combo of \'{expected_combo}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got combo \'{actual["combo"]}\''
)
if actual["full_combo"] != expected_full_combo:
raise Exception(
f'Expected a full_combo of \'{expected_full_combo}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got full_combo \'{actual["full_combo"]}\''
)
# 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)

View File

@@ -32,6 +32,8 @@ class VersionConstants:
BISHI_BASHI_TSBB: Final[int] = 1
DANCE_EVOLUTION: Final[int] = 1
DDR_1STMIX: Final[int] = 1
DDR_2NDMIX: Final[int] = 2
DDR_3RDMIX: Final[int] = 3
@@ -177,6 +179,15 @@ class DBConstants:
OMNIMIX_VERSION_BUMP: Final[int] = 10000
DANEVO_GRADE_FAILED: Final[int] = 100
DANEVO_GRADE_E: Final[int] = 200
DANEVO_GRADE_D: Final[int] = 300
DANEVO_GRADE_C: Final[int] = 400
DANEVO_GRADE_B: Final[int] = 500
DANEVO_GRADE_A: Final[int] = 600
DANEVO_GRADE_AA: Final[int] = 700
DANEVO_GRADE_AAA: Final[int] = 800
DDR_HALO_NONE: Final[int] = 100
DDR_HALO_GOOD_FULL_COMBO: Final[int] = 200
DDR_HALO_GREAT_FULL_COMBO: Final[int] = 300

View File

@@ -147,6 +147,7 @@ class APIClient:
GameConstants.POPN_MUSIC: "popnmusic",
GameConstants.REFLEC_BEAT: "reflecbeat",
GameConstants.SDVX: "soundvoltex",
GameConstants.DANCE_EVOLUTION: "danceevolution",
}.get(game)
if servergame is None:
raise UnsupportedRequestAPIException("The client does not support this game/version!")
@@ -217,6 +218,9 @@ class APIClient:
VersionConstants.SDVX_GRAVITY_WARS: "3",
VersionConstants.SDVX_HEAVENLY_HAVEN: "4",
},
GameConstants.DANCE_EVOLUTION: {
VersionConstants.DANCE_EVOLUTION: "1",
},
}
.get(game, {})
.get(version)

View File

@@ -38,6 +38,34 @@ class GlobalMusicData(BaseGlobalData):
def __max(self, int1: int, int2: int) -> int:
return max(int1, int2)
def __format_danevo_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score:
grade = {
"AAA": DBConstants.DANEVO_GRADE_AAA,
"AA": DBConstants.DANEVO_GRADE_AA,
"A": DBConstants.DANEVO_GRADE_A,
"B": DBConstants.DANEVO_GRADE_B,
"C": DBConstants.DANEVO_GRADE_C,
"D": DBConstants.DANEVO_GRADE_D,
"E": DBConstants.DANEVO_GRADE_E,
"F": DBConstants.DANEVO_GRADE_FAILED,
}.get(data.get("grade"), DBConstants.DANEVO_GRADE_FAILED)
return Score(
-1,
songid,
songchart,
int(data.get("points", 0)),
int(data.get("timestamp", -1)),
self.__max(int(data.get("timestamp", -1)), int(data.get("updated", -1))),
-1, # No location for remote play
1, # No play info for remote play
{
"combo": int(data.get("combo", -1)),
"grade": grade,
"full_combo": bool(data.get("full_combo", False)),
},
)
def __format_ddr_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score:
halo = {
"none": DBConstants.DDR_HALO_NONE,
@@ -317,8 +345,30 @@ class GlobalMusicData(BaseGlobalData):
return self.__format_reflec_score(version, songid, songchart, data)
if game == GameConstants.SDVX:
return self.__format_sdvx_score(version, songid, songchart, data)
if game == GameConstants.DANCE_EVOLUTION:
return self.__format_danevo_score(version, songid, songchart, data)
return None
def __merge_danevo_score(self, version: int, oldscore: Score, newscore: Score) -> Score:
return Score(
-1,
oldscore.id,
oldscore.chart,
self.__max(oldscore.points, newscore.points),
self.__max(oldscore.timestamp, newscore.timestamp),
self.__max(
self.__max(oldscore.update, newscore.update),
self.__max(oldscore.timestamp, newscore.timestamp),
),
oldscore.location, # Always propagate location from local setup if possible
oldscore.plays + newscore.plays,
{
"grade": self.__max(oldscore.data["grade"], newscore.data["grade"]),
"combo": self.__max(oldscore.data["combo"], newscore.data["combo"]),
"full_combo": oldscore.data["full_combo"] or newscore.data["full_combo"],
},
)
def __merge_ddr_score(self, version: int, oldscore: Score, newscore: Score) -> Score:
return Score(
-1,
@@ -513,6 +563,8 @@ class GlobalMusicData(BaseGlobalData):
return self.__merge_reflec_score(version, oldscore, newscore)
if game == GameConstants.SDVX:
return self.__merge_sdvx_score(version, oldscore, newscore)
if game == GameConstants.DANCE_EVOLUTION:
return self.__merge_danevo_score(version, oldscore, newscore)
return oldscore
@@ -929,6 +981,32 @@ class GlobalMusicData(BaseGlobalData):
return retval
def __format_danevo_song(
self,
version: int,
songid: int,
songchart: int,
name: Optional[str],
artist: Optional[str],
genre: Optional[str],
data: Dict[str, Any],
) -> Song:
return Song(
game=GameConstants.DANCE_EVOLUTION,
version=version,
songid=songid,
songchart=songchart,
name=name,
artist=artist,
genre=genre,
data={
"bpm_min": int(data["bpm_min"]),
"bpm_max": int(data["bpm_max"]),
"level": int(data["level"]),
"kcal": float(data["kcal"]),
},
)
def __format_ddr_song(
self,
version: int,
@@ -1168,6 +1246,8 @@ class GlobalMusicData(BaseGlobalData):
return self.__format_reflec_song(version, songid, songchart, name, artist, genre, data)
if game == GameConstants.SDVX:
return self.__format_sdvx_song(version, songid, songchart, name, artist, genre, data)
if game == GameConstants.DANCE_EVOLUTION:
return self.__format_danevo_song(version, songid, songchart, name, artist, genre, data)
return None
def get_all_songs(

View File

@@ -13,6 +13,11 @@ class GlobalUserData(BaseGlobalData):
super().__init__(api)
self.user = user
def __format_danevo_profile(self, updates: Profile, profile: Profile) -> None:
area = profile.get_str("area", "")
if area:
updates["area"] = area
def __format_ddr_profile(self, updates: Profile, profile: Profile) -> None:
area = profile.get_int("area", -1)
if area != -1:
@@ -86,6 +91,8 @@ class GlobalUserData(BaseGlobalData):
self.__format_reflec_profile(new, profile)
if profile.game == GameConstants.SDVX:
self.__format_sdvx_profile(new, profile)
if profile.game == GameConstants.DANCE_EVOLUTION:
self.__format_danevo_profile(new, profile)
return new

View File

@@ -1387,6 +1387,10 @@ class UserData(BaseData):
Returns:
A User ID if creation was successful, or None otherwise.
"""
existing = self.from_cardid(cardid)
if existing:
return None
# First, create a user account
sql = "INSERT INTO user (pin, admin) VALUES (:pin, 0)"
cursor = self.execute(sql, {"pin": pin})

View File

@@ -412,6 +412,51 @@ def navigation() -> Dict[str, Any]:
},
)
if GameConstants.DANCE_EVOLUTION in g.config.support:
# Dance Evolution pages
danevo_entries = []
if len([p for p in profiles if p[0] == GameConstants.DANCE_EVOLUTION]) > 0:
danevo_entries.extend(
[
{
"label": "Game Options",
"uri": url_for("danevo_pages.viewsettings"),
},
{
"label": "Personal Profile",
"uri": url_for("danevo_pages.viewplayer", userid=g.userID),
},
{
"label": "Dance Mates",
"uri": url_for("danevo_pages.viewdancemates", userid=g.userID),
},
{
"label": "Personal Records",
"uri": url_for("danevo_pages.viewrecords", userid=g.userID),
},
]
)
danevo_entries.extend(
[
{
"label": "Global Records",
"uri": url_for("danevo_pages.viewnetworkrecords"),
},
{
"label": "All Players",
"uri": url_for("danevo_pages.viewplayers"),
},
]
)
pages.append(
{
"label": "Dance Evolution",
"entries": danevo_entries,
"base_uri": app.blueprints["danevo_pages"].url_prefix,
"gamecode": GameConstants.DANCE_EVOLUTION.value,
},
)
if GameConstants.DDR in g.config.support:
# DDR pages
ddr_entries = []

View File

@@ -0,0 +1,8 @@
from bemani.frontend.danevo.endpoints import danevo_pages
from bemani.frontend.danevo.cache import DanceEvolutionCache
__all__ = [
"DanceEvolutionCache",
"danevo_pages",
]

View File

@@ -0,0 +1,10 @@
from bemani.common import cache
from bemani.data import Config, Data
from bemani.frontend.danevo.danevo import DanceEvolutionFrontend
class DanceEvolutionCache:
@classmethod
def preload(cls, data: Data, config: Config) -> None:
frontend = DanceEvolutionFrontend(data, config, cache)
frontend.get_all_songs(force_db_load=True)

View File

@@ -0,0 +1,95 @@
# vim: set fileencoding=utf-8
from typing import Any, Dict, Iterator, List, Tuple
from bemani.backend.danevo import DanceEvolutionFactory, DanceEvolutionBase
from bemani.common import Profile, ValidatedDict, GameConstants
from bemani.data import Attempt, Link, Score, Song, UserID
from bemani.frontend.base import FrontendBase
class DanceEvolutionFrontend(FrontendBase):
game: GameConstants = GameConstants.DANCE_EVOLUTION
valid_charts: List[int] = [
DanceEvolutionBase.CHART_TYPE_LIGHT,
DanceEvolutionBase.CHART_TYPE_STANDARD,
DanceEvolutionBase.CHART_TYPE_EXTREME,
DanceEvolutionBase.CHART_TYPE_STEALTH,
DanceEvolutionBase.CHART_TYPE_MASTER,
# Only included so that we can grab the play count for this song.
DanceEvolutionBase.CHART_TYPE_PLAYTRACKING,
]
valid_rival_types: List[str] = ["dancemate"]
def all_games(self) -> Iterator[Tuple[GameConstants, int, str]]:
yield from DanceEvolutionFactory.all_games()
def get_all_songs(self, force_db_load: bool = False) -> Dict[int, Dict[str, Any]]:
def is_valid(data: Dict[str, Any]) -> bool:
if "levels" not in data:
return False
levels = data["levels"]
if not isinstance(levels, list):
return False
for x in levels:
if x == 0:
return False
return True
songs = super().get_all_songs(force_db_load)
filtered_songs = {sid: contents for sid, contents in songs.items() if is_valid(contents)}
return filtered_songs
def format_score(self, userid: UserID, score: Score) -> Dict[str, Any]:
formatted_score = super().format_score(userid, score)
formatted_score["combo"] = score.data.get_int("combo")
formatted_score["full_combo"] = score.data.get_bool("full_combo")
formatted_score["medal"] = score.data.get_int("grade")
formatted_score["grade"] = {
DanceEvolutionBase.GRADE_FAILED: "FAILED",
DanceEvolutionBase.GRADE_E: "E",
DanceEvolutionBase.GRADE_D: "D",
DanceEvolutionBase.GRADE_C: "C",
DanceEvolutionBase.GRADE_B: "B",
DanceEvolutionBase.GRADE_A: "A",
DanceEvolutionBase.GRADE_AA: "AA",
DanceEvolutionBase.GRADE_AAA: "AAA",
}.get(score.data.get_int("grade"), "NO PLAY")
return formatted_score
def format_attempt(self, userid: UserID, attempt: Attempt) -> Dict[str, Any]:
raise NotImplementedError("Dance Evolution does not have attempts!")
def format_profile(self, profile: Profile, playstats: ValidatedDict) -> Dict[str, Any]:
formatted_profile = super().format_profile(profile, playstats)
formatted_profile["plays"] = playstats.get_int("total_plays")
formatted_profile["player_class"] = profile.get_int("class")
return formatted_profile
def format_song(self, song: Song) -> Dict[str, Any]:
levels = [0, 0, 0, 0, 0]
levels[song.chart] = song.data.get_int("level")
formatted_song = super().format_song(song)
formatted_song["levels"] = levels
formatted_song["bpm_min"] = song.data.get_int("bpm_min")
formatted_song["bpm_max"] = song.data.get_int("bpm_max")
formatted_song["kcal"] = song.data.get_float("kcal")
return formatted_song
def merge_song(self, existing: Dict[str, Any], new: Song) -> Dict[str, Any]:
if new.chart == DanceEvolutionBase.CHART_TYPE_PLAYTRACKING:
return existing
new_song = super().merge_song(existing, new)
if existing["levels"][new.chart] == 0:
new_song["levels"][new.chart] = new.data.get_int("level")
return new_song
def format_rival(self, link: Link, profile: Profile) -> Dict[str, Any]:
return {
"userid": str(link.other_userid),
"last_played": link.data.get_int("last_played"),
}

View File

@@ -0,0 +1,342 @@
# vim: set fileencoding=utf-8
import re
from typing import Any, Dict
from flask import Blueprint, request, Response, url_for, abort
from bemani.common import GameConstants
from bemani.data import UserID
from bemani.frontend.app import loginrequired, jsonify, render_react
from bemani.frontend.danevo.danevo import DanceEvolutionFrontend
from bemani.frontend.templates import templates_location
from bemani.frontend.static import static_location
from bemani.frontend.types import g
danevo_pages = Blueprint(
"danevo_pages",
__name__,
url_prefix=f"/{GameConstants.DANCE_EVOLUTION.value}",
template_folder=templates_location,
static_folder=static_location,
)
@danevo_pages.route("/records")
@loginrequired
def viewnetworkrecords() -> Response:
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
network_records = frontend.get_network_records()
versions = {version: name for (game, version, name) in frontend.all_games()}
return render_react(
"Global Dance Evolution Records",
"danevo/records.react.js",
{
"records": network_records["records"],
"songs": frontend.get_all_songs(),
"players": network_records["players"],
"versions": versions,
"shownames": True,
"showpersonalsort": False,
"filterempty": False,
},
{
"refresh": url_for("danevo_pages.listnetworkrecords"),
"player": url_for("danevo_pages.viewplayer", userid=-1),
"individual_score": url_for("danevo_pages.viewtopscores", musicid=-1),
},
)
@danevo_pages.route("/records/list")
@jsonify
@loginrequired
def listnetworkrecords() -> Dict[str, Any]:
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
return frontend.get_network_records()
@danevo_pages.route("/records/<int:userid>")
@loginrequired
def viewrecords(userid: UserID) -> Response:
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
info = frontend.get_latest_player_info([userid]).get(userid)
if info is None:
abort(404)
versions = {version: name for (game, version, name) in frontend.all_games()}
return render_react(
f'{info["name"]}\'s Dance Evolution Records',
"danevo/records.react.js",
{
"records": frontend.get_records(userid),
"songs": frontend.get_all_songs(),
"players": {},
"versions": versions,
"shownames": False,
"showpersonalsort": True,
"filterempty": True,
},
{
"refresh": url_for("danevo_pages.listrecords", userid=userid),
"player": url_for("danevo_pages.viewplayer", userid=-1),
"individual_score": url_for("danevo_pages.viewtopscores", musicid=-1),
},
)
@danevo_pages.route("/records/<int:userid>/list")
@jsonify
@loginrequired
def listrecords(userid: UserID) -> Dict[str, Any]:
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
return {
"records": frontend.get_records(userid),
"players": {},
}
@danevo_pages.route("/topscores/<int:musicid>")
@loginrequired
def viewtopscores(musicid: int) -> Response:
# We just want to find the latest mix that this song exists in
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
versions = sorted(
[version for (game, version, name) in frontend.all_games()],
reverse=True,
)
name = None
artist = None
genre = None
kcal = None
levels = [0, 0, 0, 0, 0]
for version in versions:
for chart in [0, 1, 2, 3, 4]:
details = g.data.local.music.get_song(GameConstants.DANCE_EVOLUTION, version, musicid, chart)
if details is not None:
if name is None:
name = details.name
if artist is None:
artist = details.artist
if genre is None:
genre = details.genre
if kcal is None:
kcal = details.data.get_float("kcal")
if levels[chart] == 0:
levels[chart] = details.data.get_int("level")
if name is None or not [x for x in levels if x > 0]:
# Not a real song!
abort(404)
top_scores = frontend.get_top_scores(musicid)
return render_react(
f"Top Dance Evolution Scores for {artist} - {name}",
"danevo/topscores.react.js",
{
"name": name,
"artist": artist,
"genre": genre,
"levels": levels,
"kcal": kcal,
"players": top_scores["players"],
"topscores": top_scores["topscores"],
},
{
"refresh": url_for("danevo_pages.listtopscores", musicid=musicid),
"player": url_for("danevo_pages.viewplayer", userid=-1),
},
)
@danevo_pages.route("/topscores/<int:musicid>/list")
@jsonify
@loginrequired
def listtopscores(musicid: int) -> Dict[str, Any]:
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
return frontend.get_top_scores(musicid)
@danevo_pages.route("/players")
@loginrequired
def viewplayers() -> Response:
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
return render_react(
"All Dance Evolution Players",
"danevo/allplayers.react.js",
{"players": frontend.get_all_players()},
{
"refresh": url_for("danevo_pages.listplayers"),
"player": url_for("danevo_pages.viewplayer", userid=-1),
},
)
@danevo_pages.route("/players/list")
@jsonify
@loginrequired
def listplayers() -> Dict[str, Any]:
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
return {
"players": frontend.get_all_players(),
}
@danevo_pages.route("/players/<int:userid>")
@loginrequired
def viewplayer(userid: UserID) -> Response:
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
info = frontend.get_all_player_info([userid])[userid]
if not info:
abort(404)
latest_version = sorted(info.keys(), reverse=True)[0]
return render_react(
f'{info[latest_version]["name"]}\'s Dance Evolution Profile',
"danevo/player.react.js",
{
"playerid": userid,
"own_profile": userid == g.userID,
"player": info,
"versions": {version: name for (game, version, name) in frontend.all_games()},
},
{
"refresh": url_for("danevo_pages.listplayer", userid=userid),
"records": url_for("danevo_pages.viewrecords", userid=userid),
},
)
@danevo_pages.route("/players/<int:userid>/list")
@jsonify
@loginrequired
def listplayer(userid: UserID) -> Dict[str, Any]:
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
info = frontend.get_all_player_info([userid])[userid]
return {
"player": info,
}
@danevo_pages.route("/options")
@loginrequired
def viewsettings() -> Response:
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
userid = g.userID
info = frontend.get_all_player_info([userid])[userid]
if not info:
abort(404)
return render_react(
"Dance Evolution Game Settings",
"danevo/settings.react.js",
{
"player": info,
"versions": {version: name for (game, version, name) in frontend.all_games()},
},
{
"updatename": url_for("danevo_pages.updatename"),
},
)
@danevo_pages.route("/options/name/update", methods=["POST"])
@jsonify
@loginrequired
def updatename() -> Dict[str, Any]:
version = int(request.get_json()["version"])
name = request.get_json()["name"]
user = g.data.local.user.get_user(g.userID)
if user is None:
raise Exception("Unable to find user to update!")
# Grab profile and update name
profile = g.data.local.user.get_profile(GameConstants.DANCE_EVOLUTION, version, user.id)
if profile is None:
raise Exception("Unable to find profile to update!")
if len(name) == 0 or len(name) > 10:
raise Exception("Invalid profile name!")
if (
re.match(
"^["
+ "\uff20-\uff3a" # widetext A-Z and @
+ "\uff40-\uff5a" # widetext a-z and `
+ "\uff10-\uff19" # widetext 0-9
+ "\uff0c\uff0e\uff3f\u0437\u2200\u2207" # ,._ and face symbols
+ "\u3041-\u308d\u308f\u3092\u3093" # hiragana
+ "\u30a1-\u30ed\u30ef\u30f2\u30f3\u30fc" # katakana
+ "\u2605\u266a\uff01\uff1f\uff0b" # allowed symbols
+ "\u2212\u00d7\u00f7\uff03\u3002" # allowed symbols
+ "\u2267\u2266\u0434\u0398\u25a1" # allowed symbols
+ "\u76bf\uff1b\uff1a\u301c\uff0a" # allowed symbols
+ "\u00b4\uff3e\u309c\uff1c\uff1e" # allowed symbols
+ "\uff08\uff09\u8278\u30fb\uff0f" # allowed symbols
+ "\u309d\u03c9\u03b5" # allowed symbols
+ "]*$",
name,
)
is None
):
raise Exception("Invalid profile name!")
profile.replace_str("name", name)
g.data.local.user.put_profile(GameConstants.DANCE_EVOLUTION, version, user.id, profile)
# Return that we updated
return {
"version": version,
"name": name,
}
@danevo_pages.route("/dancemates/<int:userid>")
@loginrequired
def viewdancemates(userid: UserID) -> Response:
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
info = frontend.get_latest_player_info([userid]).get(userid)
if info is None:
abort(404)
# Since we have one version of DanEvo this is an ugly hack.
dancemates_by_version, profiles = frontend.get_rivals(userid)
dancemates = []
for version, actual_dancemates in dancemates_by_version.items():
dancemates.extend(actual_dancemates)
return render_react(
f'{info["name"]}\'s Dance Evolution Dance Mates',
"danevo/dancemates.react.js",
{
"name": info["name"],
"version": version,
"dancemates": dancemates,
"profiles": profiles,
},
{
"refresh": url_for("danevo_pages.listdancemates", userid=userid),
},
)
@danevo_pages.route("/dancemates/<int:userid>/list")
@jsonify
@loginrequired
def listdancemates(userid: UserID) -> Dict[str, Any]:
frontend = DanceEvolutionFrontend(g.data, g.config, g.cache)
info = frontend.get_latest_player_info([userid]).get(userid)
if info is None:
abort(404)
# Since we have one version of DanEvo this is an ugly hack.
dancemates_by_version, profiles = frontend.get_rivals(userid)
dancemates = []
for version, actual_dancemates in dancemates_by_version.items():
dancemates.extend(actual_dancemates)
return {
"name": info["name"],
"version": version,
"dancemates": dancemates,
"profiles": profiles,
}

View File

@@ -9,7 +9,7 @@ var Timestamp = createReactClass({
var t = new Date(this.props.timestamp * 1000);
var formatted = t.format('Y/m/d @ g:i:s a');
return (
<div className="timestamp">{ formatted }</div>
<div className={this.props.className || "timestamp"}>{ formatted }</div>
);
},
});

View File

@@ -0,0 +1,97 @@
/*** @jsx React.DOM */
var all_players = createReactClass({
getInitialState: function(props) {
return {
players: window.players,
};
},
componentDidMount: function() {
this.refreshPlayers();
},
refreshPlayers: function() {
AJAX.get(
Link.get('refresh'),
function(response) {
this.setState({
players: response.players,
});
// Refresh every 30 seconds
setTimeout(this.refreshPlayers, 30000);
}.bind(this)
);
},
render: function() {
return (
<div>
<div className="section">
<Table
className="list players"
columns={[
{
name: 'Name',
render: function(userid) {
var player = this.state.players[userid];
return <a href={Link.get('player', userid)}>{ player.name }</a>;
}.bind(this),
sort: function(aid, bid) {
var a = this.state.players[aid];
var b = this.state.players[bid];
return a.name.localeCompare(b.name);
}.bind(this),
},
{
name: 'Dance Evolution ID',
render: function(userid) {
var player = this.state.players[userid];
return player.extid;
}.bind(this),
sort: function(aid, bid) {
var a = this.state.players[aid];
var b = this.state.players[bid];
return a.extid.localeCompare(b.extid);
}.bind(this),
},
{
name: 'Total Rounds',
render: function(userid) {
var player = this.state.players[userid];
return player.plays;
}.bind(this),
sort: function(aid, bid) {
var a = this.state.players[aid];
var b = this.state.players[bid];
return a.plays - b.plays;
}.bind(this),
reverse: true,
},
{
name: 'Class',
render: function(userid) {
var player = this.state.players[userid];
return player.player_class;
}.bind(this),
sort: function(aid, bid) {
var a = this.state.players[aid];
var b = this.state.players[bid];
return a.player_class - b.player_class;
}.bind(this),
},
]}
rows={Object.keys(this.state.players)}
paginate={10}
/>
</div>
</div>
);
},
});
ReactDOM.render(
React.createElement(all_players, null),
document.getElementById('content')
);

View File

@@ -0,0 +1,83 @@
/*** @jsx React.DOM */
var dancemates_view = createReactClass({
getInitialState: function(props) {
return {
name: window.name,
version: window.version,
dancemates: window.dancemates,
profiles: window.profiles,
};
},
componentDidMount: function() {
this.refreshDanceMates();
},
refreshDanceMates: function() {
AJAX.get(
Link.get('refresh'),
function(response) {
this.setState({
name: response.name,
version: response.version,
dancemates: response.dancemates,
profiles: response.profiles,
});
setTimeout(this.refreshDanceMates, 5000);
}.bind(this)
);
},
renderDanceMates: function(player) {
return(
<Table
className='list dancemates'
columns={[
{
name: 'Name',
render: function(entry) {
return this.state.profiles[entry.userid][this.state.version].name;
}.bind(this),
sort: function(aid, bid) {
var a = this.state.profiles[aid.userid][this.state.version].name;
var b = this.state.profiles[bid.userid][this.state.version].name;
return a.localeCompare(b);
}.bind(this),
},
{
name: 'Last Played With',
render: function(entry) {
return <Timestamp className={"dancemate"} timestamp={entry.last_played}/>;
},
sort: function(aid, bid) {
return aid.last_played - bid.last_played;
}.bind(this),
},
]}
defaultsort='Name'
rows={this.state.dancemates}
paginate={10}
/>
);
},
render: function() {
return (
<div>
<section>
<h3>{this.state.name}'s Dance Mates</h3>
<p>
{this.renderDanceMates()}
</p>
</section>
</div>
);
},
});
ReactDOM.render(
React.createElement(dancemates_view, null),
document.getElementById('content')
);

View File

@@ -0,0 +1,100 @@
/*** @jsx React.DOM */
var valid_versions = Object.keys(window.versions);
var pagenav = new History(valid_versions);
var profile_view = createReactClass({
getInitialState: function(props) {
var profiles = Object.keys(window.player);
return {
player: window.player,
profiles: profiles,
version: pagenav.getInitialState(profiles[profiles.length - 1]),
};
},
componentDidMount: function() {
pagenav.onChange(function(version) {
this.setState({version: version});
}.bind(this));
this.refreshProfile();
},
refreshProfile: function() {
AJAX.get(
Link.get('refresh'),
function(response) {
var profiles = Object.keys(response.player);
this.setState({
player: response.player,
profiles: profiles,
});
setTimeout(this.refreshProfile, 5000);
}.bind(this)
);
},
render: function() {
if (this.state.player[this.state.version]) {
var player = this.state.player[this.state.version];
return (
<div>
<div className="section danevo-nav">
<h3>{player.name}'s profile</h3>
</div>
<div className="section">
<LabelledSection label="User ID">{player.extid}</LabelledSection>
<LabelledSection label="Profile Created">
<Timestamp timestamp={player.first_play_time}/>
</LabelledSection>
<LabelledSection label="Last Played">
<Timestamp timestamp={player.last_play_time}/>
</LabelledSection>
<LabelledSection label="Total Rounds">
{player.plays}回
</LabelledSection>
<LabelledSection label="Class">
{player.player_class}
</LabelledSection>
</div>
<div className="section">
<a href={Link.get('records')}>{ window.own_profile ?
<span>view your records</span> :
<span>view {player.name}'s records</span>
}</a>
</div>
</div>
);
} else {
return (
<div>
<div className="section">
{this.state.profiles.map(function(version) {
return (
<Nav
title={window.versions[version]}
active={this.state.version == version}
onClick={function(event) {
if (this.state.version == version) { return; }
this.setState({version: version});
pagenav.navigate(version);
}.bind(this)}
/>
);
}.bind(this))}
</div>
<div className="section">
This player has no profile for {window.versions[this.state.version]}!
</div>
</div>
);
}
},
});
ReactDOM.render(
React.createElement(profile_view, null),
document.getElementById('content')
);

View File

@@ -0,0 +1,537 @@
/*** @jsx React.DOM */
var valid_sorts = ['series', 'name', 'popularity'];
var valid_charts = ['Light', 'Standard', 'Extreme', 'Master', 'Stealth'];
var valid_mixes = Object.keys(window.versions);
var valid_subsorts = [valid_mixes, false, false, valid_charts, valid_charts];
if (window.showpersonalsort) {
valid_sorts.push('score');
valid_sorts.push('grade');
}
var pagenav = new History(valid_sorts, valid_subsorts);
var sort_names = {
'series': 'Series',
'name': 'Song Name',
'popularity': 'Popularity',
'score': 'Score',
'grade': 'Grade',
};
var HighScore = createReactClass({
render: function() {
if (!this.props.score) {
return null;
}
return (
<div className="score">
<div>
<span className="label">Score</span>
<span className="score">{this.props.score.points}</span>
<span className="label">Grade</span>
<span className="score">{this.props.score.grade}</span>
</div>
<div>
<span className="label">Combo</span>
<span className="score">{this.props.score.combo <= 0 ? '-' : this.props.score.combo}</span>
{ this.props.score.full_combo ? <span className="label">full combo</span> : null }
</div>
{ this.props.score.userid && window.shownames ?
<div><a href={Link.get('player', this.props.score.userid)}>{
this.props.players[this.props.score.userid].name
}</a></div> : null
}
</div>
);
},
});
var network_records = createReactClass({
sortRecords: function(records) {
var sorted_records = {};
records.forEach(function(record) {
if (!(record.songid in sorted_records)) {
sorted_records[record.songid] = {}
}
sorted_records[record.songid][record.chart] = record;
});
return sorted_records;
},
getInitialState: function(props) {
return {
songs: window.songs,
records: this.sortRecords(window.records),
players: window.players,
versions: window.versions,
sort: pagenav.getInitialState('series', '1'),
subtab: this.getSubIndex('series', pagenav.getInitialSubState('series', '1')),
offset: 0,
limit: 10,
};
},
getSubIndex: function(sort, subsort) {
var subtab = 0;
window.valid_sorts.forEach(function(potential, index) {
if (window.valid_subsorts[index]) {
window.valid_subsorts[index].forEach(function(subpotential, subindex) {
if (subpotential == subsort) {
subtab = subindex;
}
}.bind(this));
}
}.bind(this));
return subtab;
},
componentDidMount: function() {
pagenav.onChange(function(sort, subsort) {
var subtab = this.getSubIndex(sort, subsort);
this.setState({sort: sort, offset: 0, subtab: subtab});
}.bind(this));
this.refreshRecords();
},
refreshRecords: function() {
AJAX.get(
Link.get('refresh'),
function(response) {
this.setState({
records: this.sortRecords(response.records),
players: response.players,
});
// Refresh every 15 seconds
setTimeout(this.refreshRecords, 15000);
}.bind(this)
);
},
renderLevel: function(songid) {
if (this.state.songs[songid].levels[0] == 0) {
return <span className="level">--</span>;
} else {
return <span className="level">{this.state.songs[songid].levels[0]}</span>;
}
},
renderKcal: function(songid) {
if (this.state.songs[songid].kcal <= 0.0) {
return <span className="kcal">--</span>;
} else {
return <span className="kcal">{this.state.songs[songid].kcal}</span>;
}
},
getPlays: function(record) {
if (!record) { return 0; }
var plays = 0;
// Play counts are only storted in the play statistics chart.
if (record[5]) { plays += record[5].plays; }
return plays;
},
renderBySeries: function() {
var songids = Object.keys(this.state.songs);
if (window.filterempty) {
songids = songids.filter(function(songid) {
return this.getPlays(this.state.records[songid]) > 0;
}.bind(this));
}
if (songids.length == 0) {
return (
<div>
No records to display!
</div>
);
}
var curpage = -1;
var curbutton = -1;
return (
<>
<div className="section" key="contents">
<table className="list records">
<thead>
<tr>
<th className="subheader">Song / Artist / Level</th>
<th className="subheader">Light</th>
<th className="subheader">Standard</th>
<th className="subheader">Extreme</th>
<th className="subheader">Master</th>
<th className="subheader">Stealth</th>
</tr>
</thead>
<tbody>
{songids.map(function(songid) {
var records = this.state.records[songid];
if (!records) {
records = {};
}
var levels = this.state.songs[songid].levels;
return (
<tr key={songid.toString()}>
<td className="center">
<div>
<a href={Link.get('individual_score', songid)}>
<div className="songname">{ this.state.songs[songid].name }</div>
<div className="songartist">{ this.state.songs[songid].artist }</div>
<div className="songgenre">{ this.state.songs[songid].genre }</div>
</a>
</div>
<div className="songlevels">
level {this.renderLevel(songid)} / {this.renderKcal(songid)} kcal
</div>
</td>
<td className={levels[0] > 0 ? "" : "nochart"}>
<HighScore
players={this.state.players}
songid={songid}
chart={0}
score={records[0]}
/>
</td>
<td className={levels[1] > 0 ? "" : "nochart"}>
<HighScore
players={this.state.players}
songid={songid}
chart={1}
score={records[1]}
/>
</td>
<td className={levels[2] > 0 ? "" : "nochart"}>
<HighScore
players={this.state.players}
songid={songid}
chart={2}
score={records[2]}
/>
</td>
<td className={levels[4] > 0 ? "" : "nochart"}>
<HighScore
players={this.state.players}
songid={songid}
chart={4}
score={records[4]}
/>
</td>
<td className={levels[3] > 0 ? "" : "nochart"}>
<HighScore
players={this.state.players}
songid={songid}
chart={3}
score={records[3]}
/>
</td>
</tr>
);
}.bind(this))}
</tbody>
</table>
</div>
</>
);
},
renderByName: function() {
var songids = Object.keys(this.state.songs).sort(function(a, b) {
var an = this.state.songs[a].name;
var bn = this.state.songs[b].name;
var c = an.localeCompare(bn);
if (c == 0) {
return parseInt(a) - parseInt(b)
} else {
return c;
}
}.bind(this));
if (window.filterempty) {
songids = songids.filter(function(songid) {
return this.getPlays(this.state.records[songid]) > 0;
}.bind(this));
}
return this.renderBySongIDList(songids, false);
},
renderByPopularity: function() {
var songids = Object.keys(this.state.songs).sort(function(a, b) {
var ap = this.getPlays(this.state.records[a]);
var bp = this.getPlays(this.state.records[b]);
if (bp == ap) {
return parseInt(a) - parseInt(b)
} else {
return bp - ap;
}
}.bind(this));
if (window.filterempty) {
songids = songids.filter(function(songid) {
return this.getPlays(this.state.records[songid]) > 0;
}.bind(this));
}
return this.renderBySongIDList(songids, true);
},
renderByScore: function() {
var songids = Object.keys(this.state.songs).sort(function(a, b) {
// Grab records for this song
var ar = this.state.records[a];
var br = this.state.records[b];
var ac = null;
var bc = null;
var as = 0;
var bs = 0;
// Fill in record for current chart only if it exists
if (ar) { ac = ar[this.state.subtab]; }
if (br) { bc = br[this.state.subtab]; }
if (ac) { as = ac.points; }
if (bc) { bs = bc.points; }
if (bs == as) {
return parseInt(a) - parseInt(b);
} else {
return bs - as;
}
}.bind(this));
if (window.filterempty) {
songids = songids.filter(function(songid) {
return this.getPlays(this.state.records[songid]) > 0;
}.bind(this));
}
return (
<>
<div className="section">
{window.valid_charts.map(function(chartname, index) {
return (
<Nav
title={ chartname }
active={ this.state.subtab == index }
onClick={function(event) {
if (this.state.subtab == index) { return; }
this.setState({subtab: index, offset: 0});
pagenav.navigate(this.state.sort, window.valid_charts[index]);
}.bind(this)}
/>
);
}.bind(this))}
</div>
{ this.renderBySongIDList(songids, false) }
</>
);
},
renderByClearGrade: function() {
var songids = Object.keys(this.state.songs).sort(function(a, b) {
// Grab records for this song
var ar = this.state.records[a];
var br = this.state.records[b];
var ac = null;
var bc = null;
var al = 0;
var bl = 0;
// Fill in record for current chart only if it exists
if (ar) { ac = ar[this.state.subtab]; }
if (br) { bc = br[this.state.subtab]; }
if (ac) { al = ac.medal; }
if (bc) { bl = bc.medal; }
if (al == bl) {
return parseInt(a) - parseInt(b)
} else {
return bl - al;
}
}.bind(this));
if (window.filterempty) {
songids = songids.filter(function(songid) {
return this.getPlays(this.state.records[songid]) > 0;
}.bind(this));
}
return (
<>
<div className="section">
{window.valid_charts.map(function(chartname, index) {
return (
<Nav
title={ chartname }
active={ this.state.subtab == index }
onClick={function(event) {
if (this.state.subtab == index) { return; }
this.setState({subtab: index, offset: 0});
pagenav.navigate(this.state.sort, window.valid_charts[index]);
}.bind(this)}
/>
);
}.bind(this))}
</div>
{ this.renderBySongIDList(songids, false) }
</>
);
},
renderBySongIDList: function(songids, showplays) {
return (
<div className="section">
<table className="list records">
<thead>
<tr>
<th className="subheader">Song / Artist / Level</th>
<th className="subheader">Light</th>
<th className="subheader">Standard</th>
<th className="subheader">Extreme</th>
<th className="subheader">Master</th>
<th className="subheader">Stealth</th>
</tr>
</thead>
<tbody>
{songids.map(function(songid, index) {
if (index < this.state.offset || index >= this.state.offset + this.state.limit) {
return null;
}
var records = this.state.records[songid];
if (!records) {
records = {};
}
var plays = this.getPlays(records);
var levels = this.state.songs[songid].levels;
return (
<tr key={songid.toString()}>
<td className="center">
<div>
<a href={Link.get('individual_score', songid)}>
<div className="songname">{ this.state.songs[songid].name }</div>
<div className="songartist">{ this.state.songs[songid].artist }</div>
<div className="songgenre">{ this.state.songs[songid].genre }</div>
</a>
</div>
<div className="songlevels">
level {this.renderLevel(songid)} / {this.renderKcal(songid)} kcal
</div>
{ showplays ? <div className="songplays">#{index + 1} - {plays}{plays == 1 ? ' play' : ' plays'}</div> : null }
</td>
<td className={levels[0] > 0 ? "" : "nochart"}>
<HighScore
players={this.state.players}
songid={songid}
chart={0}
score={records[0]}
/>
</td>
<td className={levels[1] > 0 ? "" : "nochart"}>
<HighScore
players={this.state.players}
songid={songid}
chart={1}
score={records[1]}
/>
</td>
<td className={levels[2] > 0 ? "" : "nochart"}>
<HighScore
players={this.state.players}
songid={songid}
chart={2}
score={records[2]}
/>
</td>
<td className={levels[4] > 0 ? "" : "nochart"}>
<HighScore
players={this.state.players}
songid={songid}
chart={4}
score={records[4]}
/>
</td>
<td className={levels[3] > 0 ? "" : "nochart"}>
<HighScore
players={this.state.players}
songid={songid}
chart={3}
score={records[3]}
/>
</td>
</tr>
);
}.bind(this))}
</tbody>
<tfoot>
<tr>
<td colSpan={5}>
{ this.state.offset > 0 ?
<Prev onClick={function(event) {
var page = this.state.offset - this.state.limit;
if (page < 0) { page = 0; }
this.setState({offset: page});
}.bind(this)}/> : null
}
{ (this.state.offset + this.state.limit) < songids.length ?
<Next style={ {float: 'right'} } onClick={function(event) {
var page = this.state.offset + this.state.limit;
if (page >= songids.length) { return }
this.setState({offset: page});
}.bind(this)}/> :
null
}
</td>
</tr>
</tfoot>
</table>
</div>
);
},
render: function() {
var data = null;
if (this.state.sort == 'series') {
data = this.renderBySeries();
} else if (this.state.sort == 'popularity') {
data = this.renderByPopularity();
} else if (this.state.sort == 'name') {
data = this.renderByName();
} else if (this.state.sort == 'score') {
data = this.renderByScore();
} else if (this.state.sort == 'grade') {
data = this.renderByClearGrade();
}
return (
<div>
<div className="section">
{ window.valid_sorts.map(function(sort, index) {
return (
<Nav
title={"Records Sorted by " + window.sort_names[sort]}
active={this.state.sort == sort}
onClick={function(event) {
if (this.state.sort == sort) { return; }
this.setState({sort: sort, offset: 0, subtab: 0});
pagenav.navigate(sort, window.valid_subsorts[index][0]);
}.bind(this)}
/>
);
}.bind(this)) }
</div>
<div className="section">
{data}
</div>
</div>
);
},
});
ReactDOM.render(
React.createElement(network_records, null),
document.getElementById('content')
);

View File

@@ -0,0 +1,210 @@
/*** @jsx React.DOM */
var valid_versions = Object.keys(window.versions);
var pagenav = new History(valid_versions);
var settings_view = createReactClass({
getInitialState: function(props) {
var profiles = Object.keys(window.player);
var version = pagenav.getInitialState(profiles[profiles.length - 1]);
return {
player: window.player,
profiles: profiles,
version: version,
new_name: window.player[version].name,
editing_name: false,
};
},
componentDidMount: function() {
pagenav.onChange(function(version) {
this.setState({version: version});
}.bind(this));
},
componentDidUpdate: function() {
if (this.focus_element && this.focus_element != this.already_focused) {
this.focus_element.focus();
this.already_focused = this.focus_element;
}
},
saveName: function(event) {
AJAX.post(
Link.get('updatename'),
{
version: this.state.version,
name: this.state.new_name,
},
function(response) {
var player = this.state.player;
player[response.version].name = response.name;
this.setState({
player: player,
new_name: this.state.player[response.version].name,
editing_name: false,
});
}.bind(this)
);
event.preventDefault();
},
renderName: function(player) {
return (
<LabelledSection vertical={true} label="Name">{
!this.state.editing_name ?
<>
<span>{player.name}</span>
<Edit
onClick={function(event) {
this.setState({editing_name: true});
}.bind(this)}
/>
</> :
<form className="inline" onSubmit={this.saveName}>
<input
type="text"
className="inline"
maxlength="10"
size="12"
autofocus="true"
ref={c => (this.focus_element = c)}
value={this.state.new_name}
onChange={function(event) {
var rawvalue = event.target.value;
var value = "";
// Nasty conversion to change typing into wide text
for (var i = 0; i < rawvalue.length; i++) {
var c = rawvalue.charCodeAt(i);
if (c >= '0'.charCodeAt(0) && c <= '9'.charCodeAt(0)) {
c = 0xFF10 + (c - '0'.charCodeAt(0));
} else if(c >= 'A'.charCodeAt(0) && c <= 'Z'.charCodeAt(0)) {
c = 0xFF21 + (c - 'A'.charCodeAt(0));
} else if(c >= 'a'.charCodeAt(0) && c <= 'z'.charCodeAt(0)) {
c = 0xFF41 + (c - 'a'.charCodeAt(0));
} else if(c == '@'.charCodeAt(0)) {
c = 0xFF20;
} else if(c == ','.charCodeAt(0)) {
c = 0xFF0C;
} else if(c == '.'.charCodeAt(0)) {
c = 0xFF0E;
} else if(c == ':'.charCodeAt(0)) {
c = 0xFF1A;
} else if(c == ';'.charCodeAt(0)) {
c = 0xFF1B;
} else if(c == '!'.charCodeAt(0)) {
c = 0xFF01;
} else if(c == '?'.charCodeAt(0)) {
c = 0xFF1F;
} else if(c == '+'.charCodeAt(0)) {
c = 0xFF0B;
} else if(c == '-'.charCodeAt(0)) {
c = 0x2212;
} else if(c == '#'.charCodeAt(0)) {
c = 0xFF03;
} else if(c == '_'.charCodeAt(0)) {
c = 0xFF3F;
} else if(c == '~'.charCodeAt(0)) {
c = 0x301C;
} else if(c == '*'.charCodeAt(0)) {
c = 0xFF0A;
} else if(c == '`'.charCodeAt(0)) {
c = 0xFF40;
} else if(c == '^'.charCodeAt(0)) {
c = 0xFF3E;
} else if(c == '<'.charCodeAt(0)) {
c = 0xFF1C;
} else if(c == '>'.charCodeAt(0)) {
c = 0xFF1E;
} else if(c == '('.charCodeAt(0)) {
c = 0xFF08;
} else if(c == ')'.charCodeAt(0)) {
c = 0xFF09;
} else if(c == '/'.charCodeAt(0)) {
c = 0xFF0F;
}
value = value + String.fromCharCode(c);
}
var nameRegex = new RegExp(
"^[" +
"\uFF20-\uFF3A" + // widetext A-Z and @
"\uFF40-\uFF5A" + // widetext a-z and `
"\uFF10-\uFF19" + // widetext 0-9
"\uFF0C\uFF0E\uFF3F\u0437\u2200\u2207" + // widetext ,._ and face symbols
"\u3041-\u308D\u308F\u3092\u3093" + // hiragana
"\u30A1-\u30ED\u30EF\u30F2\u30F3\u30FC" + // katakana
"\u2605\u266A\uff01\uff1f\uff0b\u2212\u00d7\u00f7\uff03\u3002\u2267" + // allowed symbols
"\u2266\u0434\u0398\u25a1\u76bf\uff1b\uff1a\u301c\uff0a\u00b4\uff3e" + // allowed symbols
"\u309c\uff1c\uff1e\uff08\uff09\u8278\u30fb\uff0f\u309d\u03c9\u03b5" + // allowed symbols
"]*$"
);
if (value.length <= 10 && nameRegex.test(value)) {
this.setState({new_name: value});
}
}.bind(this)}
name="name"
/>
<input
type="submit"
value="save"
/>
<input
type="button"
value="cancel"
onClick={function(event) {
this.setState({
new_name: this.state.player[this.state.version].name,
editing_name: false,
});
}.bind(this)}
/>
</form>
}</LabelledSection>
);
},
render: function() {
if (this.state.player[this.state.version]) {
var player = this.state.player[this.state.version];
return (
<div>
<div className="section">
<h3>User Profile</h3>
{this.renderName(player)}
</div>
</div>
);
} else {
return (
<div>
<div className="section">
You have no profile for {window.versions[this.state.version]}!
</div>
<div className="section">
{this.state.profiles.map(function(version) {
return (
<Nav
title={window.versions[version]}
active={this.state.version == version}
onClick={function(event) {
if (this.state.version == version) { return; }
this.setState({
version: version,
});
pagenav.navigate(version);
}.bind(this)}
/>
);
}.bind(this))}
</div>
</div>
);
}
},
});
ReactDOM.render(
React.createElement(settings_view, null),
document.getElementById('content')
);

View File

@@ -0,0 +1,151 @@
/*** @jsx React.DOM */
var valid_charts = ['Light', 'Standard', 'Extreme', 'Master', 'Stealth'];
var pagenav = new History(valid_charts);
var top_scores = createReactClass({
sortTopScores: function(topscores) {
var newscores = [[], [], [], [], []];
topscores.map(function(score) {
// Skip over the play stats dummy chart.
if (score.chart != 5) {
newscores[score.chart].push(score);
}
}.bind(this));
return newscores;
},
getInitialState: function(props) {
return {
topscores: this.sortTopScores(window.topscores),
players: window.players,
chart: pagenav.getInitialState(valid_charts[0]),
};
},
componentDidMount: function() {
pagenav.onChange(function(chart) {
this.setState({chart: chart});
}.bind(this));
this.refreshScores();
},
refreshScores: function() {
AJAX.get(
Link.get('refresh'),
function(response) {
this.setState({
topscores: this.sortTopScores(response.topscores),
players: response.players,
});
// Refresh every 15 seconds
setTimeout(this.refreshScores, 15000);
}.bind(this)
);
},
convertChart: function(chart) {
switch(chart) {
case 'Light':
return 0;
case 'Standard':
return 1;
case 'Extreme':
return 2;
case 'Stealth':
return 3;
case 'Master':
return 4;
default:
return null;
}
},
render: function() {
var chart = this.convertChart(this.state.chart);
return (
<div>
<div className="section">
<div className="songname">{window.name}</div>
<div className="songartist">{window.artist}</div>
<div className="songgenre">{window.genre}</div>
<div className="songlevels">level {window.levels[chart]} / {window.kcal} kcal</div>
</div>
<div className="section">
{valid_charts.map(function(chart) {
return (
<Nav
title={chart}
active={this.state.chart == chart}
onClick={function(event) {
if (this.state.chart == chart) { return; }
this.setState({chart: chart});
pagenav.navigate(chart);
}.bind(this)}
/>
);
}.bind(this))}
</div>
<div className="section">
<Table
className="list topscores"
columns={[
{
name: 'Name',
render: function(topscore) {
return (
<a href={Link.get('player', topscore.userid)}>{
this.state.players[topscore.userid].name
}</a>
);
}.bind(this),
sort: function(a, b) {
var an = this.state.players[a.userid].name;
var bn = this.state.players[b.userid].name;
return an.localeCompare(bn);
}.bind(this),
},
{
name: 'Score',
render: function(topscore) { return topscore.points; },
sort: function(a, b) {
return a.points - b.points;
},
reverse: true,
},
{
name: 'Grade',
render: function(topscore) { return topscore.grade; },
},
{
name: 'Combo',
render: function(topscore) {
return (
(topscore.combo >= 0 ? topscore.combo : '-') +
(topscore.full_combo ? ' (full combo)' : '')
);
},
sort: function(a, b) {
return a.combo - b.combo;
},
reverse: true,
},
]}
defaultsort='Score'
rows={this.state.topscores[chart]}
key={chart}
paginate={10}
emptymessage="There are no scores for this chart."
/>
</div>
</div>
);
},
});
ReactDOM.render(
React.createElement(top_scores, null),
document.getElementById('content')
);

View File

@@ -43,19 +43,6 @@ var profile_view = createReactClass({
<div>
<div className="section">
<h3>{player.name}'s profile</h3>
{this.state.profiles.map(function(version) {
return (
<Nav
title={window.versions[version]}
active={this.state.version == version}
onClick={function(event) {
if (this.state.version == version) { return; }
this.setState({version: version});
pagenav.navigate(version);
}.bind(this)}
/>
);
}.bind(this))}
</div>
<div className="section">
<LabelledSection label="User ID">{player.extid}</LabelledSection>

View File

@@ -111,25 +111,6 @@ var settings_view = createReactClass({
var player = this.state.player[this.state.version];
return (
<div>
<div className="section">
{this.state.profiles.map(function(version) {
return (
<Nav
title={window.versions[version]}
active={this.state.version == version}
onClick={function(event) {
if (this.state.editing_name) { return; }
if (this.state.version == version) { return; }
this.setState({
version: version,
new_name: this.state.player[version].name,
});
pagenav.navigate(version);
}.bind(this)}
/>
);
}.bind(this))}
</div>
<div className="section">
<h3>User Profile</h3>
{this.renderName(player)}

View File

@@ -33,3 +33,7 @@
.sdvx.border {
border-color: #710162;
}
.danevo.border {
border-color: #2e5eee;
}

View File

@@ -34,8 +34,8 @@ div.loading {
div.navigation {
background-color: #1f1f1f;
padding-left: 10px;
padding-right: 10px;
padding-left: 0px;
padding-right: 0px;
}
.tinynav {
@@ -193,7 +193,10 @@ ul.navigation li a {
border: 0px solid #676767;
display: block;
text-decoration: none;
padding: 10px;
padding-top: 10px;
padding-bottom: 10px;
padding-left: 7px;
padding-right: 7px;
color: #b5b5b5;
}
@@ -214,7 +217,7 @@ ul.navigation > li > a.current {
ul.navigation > li > ul.navigation_sub > li > a.current {
border-left-width: 2px;
border-left-style: solid;
padding-left: 8px;
padding-left: 5px;
}
h1, h2, h3, h4, h5 {

View File

@@ -33,3 +33,7 @@
.sdvx.border {
border-color: #710162;
}
.danevo.border {
border-color: #2e5eee;
}

View File

@@ -28,8 +28,8 @@ div.loading {
div.navigation {
background-color: whitesmoke;
padding-left: 10px;
padding-right: 10px;
padding-left: 0px;
padding-right: 0px;
}
.tinynav {
@@ -179,7 +179,10 @@ ul.navigation li a {
border: 0px solid #676767;
display: block;
text-decoration: none;
padding: 10px;
padding-top: 10px;
padding-bottom: 10px;
padding-left: 7px;
padding-right: 7px;
color: #676767;
}
@@ -200,7 +203,7 @@ ul.navigation > li > a.current {
ul.navigation > li > ul.navigation_sub > li > a.current {
border-left-width: 2px;
border-left-style: solid;
padding-left: 8px;
padding-left: 5px;
}
h1, h2, h3, h4, h5 {

View File

@@ -6,6 +6,7 @@ from bemani.backend.iidx import IIDXFactory
from bemani.backend.popn import PopnMusicFactory
from bemani.backend.jubeat import JubeatFactory
from bemani.backend.bishi import BishiBashiFactory
from bemani.backend.danevo import DanceEvolutionFactory
from bemani.backend.ddr import DDRFactory
from bemani.backend.sdvx import SoundVoltexFactory
from bemani.backend.reflec import ReflecBeatFactory
@@ -78,3 +79,5 @@ def register_games(config: Config) -> None:
MusecaFactory.register_all()
if GameConstants.MGA in config.support:
MetalGearArcadeFactory.register_all()
if GameConstants.DANCE_EVOLUTION in config.support:
DanceEvolutionFactory.register_all()

View File

@@ -16,6 +16,7 @@ from bemani.frontend.ddr import ddr_pages
from bemani.frontend.sdvx import sdvx_pages
from bemani.frontend.reflec import reflec_pages
from bemani.frontend.museca import museca_pages
from bemani.frontend.danevo import danevo_pages
from bemani.utils.config import (
load_config as base_load_config,
instantiate_cache as base_instantiate_cache,
@@ -47,6 +48,8 @@ def register_blueprints() -> None:
app.register_blueprint(reflec_pages)
if GameConstants.MUSECA in config.support:
app.register_blueprint(museca_pages)
if GameConstants.DANCE_EVOLUTION in config.support:
app.register_blueprint(danevo_pages)
def register_games() -> None:

View File

@@ -6106,28 +6106,44 @@ class ImportDanceEvolution(ImportBase):
for i in range(numsongs):
offset = (i * 128) + 16
songcode = get_string(offset + 0) # noqa: F841
songcode = get_string(offset + 0)
songres1 = get_string(offset + 4) # noqa: F841
songres2 = get_string(offset + 8) # noqa: F841
bpm_min = get_int(offset + 12)
bpm_max = get_int(offset + 16)
# Unknown 4 byte value at offset 20.
copyright = get_string(offset + 24, "")
# Unknown 4 byte value at offset 28, 36, 40, 44, 48.
title = get_string(offset + 52, "Unknown song")
artist = get_string(offset + 56, "Unknown artist")
# Unknown 4 byte value at offset 60.
level = get_int(offset + 64)
kcal = float(get_int(offset + 68)) / 100.0
charares1 = get_string(offset + 72) # noqa: F841
charares2 = get_string(offset + 76) # noqa: F841
# Unknown 4 byte values at offset 80, 84, 88, 92, 96, 100, 104.
kana_sort = get_string(offset + 108)
flag1 = data[offset + 33] != 0x00 # noqa: F841
flag2 = data[offset + 34] == 0x01 # noqa: F841
flag3 = data[offset + 34] == 0x02 # noqa: F841
flag4 = data[offset + 116] != 0x00 # noqa: F841
# Unknown 4 byte value at offset 112.
# TODO: Get the real music ID from the data, once we have in-game traffic.
flag1 = data[offset + 33] != 0x00
flag2 = data[offset + 34] == 0x01
flag3 = data[offset + 34] == 0x02
flag4 = data[offset + 35] != 0x00
# The music ID is actually, genuinely just the offset into this, from what I can tell.
retval.append(
{
"id": i,
"code": songcode,
"title": title,
"artist": artist,
"copyright": copyright or None,
@@ -6135,38 +6151,59 @@ class ImportDanceEvolution(ImportBase):
"bpm_min": bpm_min,
"bpm_max": bpm_max,
"level": level,
"kcal": kcal,
"flag1": flag1,
"flag2": flag2,
"flag3": flag3,
"flag4": flag4,
}
)
return retval
def lookup(self, server: str, token: str) -> List[Dict[str, Any]]:
# TODO: We never got far enough to support DanEvo in the server, or
# specify it in BEMAPI. So this is a dead function for now, but maybe
# some year in the future I'll be able to support this.
return []
# Grab music info from remote server
music = self.remote_music(server, token)
songs = music.get_all_songs(self.game, self.version)
lut: Dict[int, Dict[str, Any]] = {}
for song in songs:
if song.id not in lut:
lut[song.id] = {
"id": song.id,
"title": song.name,
"artist": song.artist,
"level": song.data.get_int("level"),
"bpm_min": song.data.get_int("bpm_min"),
"bpm_max": song.data.get_int("bpm_max"),
"kcal": song.data.get_float("kcal"),
}
# Return the reassembled data
return [val for _, val in lut.items()]
def import_music_db(self, songs: List[Dict[str, Any]]) -> None:
for song in songs:
# Import it
self.start_batch()
# First, try to find in the DB from another version
old_id = self.get_music_id_for_song(song["id"], 0)
if self.no_combine or old_id is None:
# Insert original
print(f"New entry for {song['id']} chart {0}")
next_id = self.get_next_music_id()
else:
# Insert pointing at same ID so scores transfer
print(f"Reused entry for {song['id']} chart {0}")
next_id = old_id
data = {
"level": song["level"],
"bpm_min": song["bpm_min"],
"bpm_max": song["bpm_max"],
}
self.insert_music_id_for_song(next_id, song["id"], 0, song["title"], song["artist"], None, data)
for chart_id in [0, 1, 2, 3, 4, 5]:
# First, try to find in the DB from another version
old_id = self.get_music_id_for_song(song["id"], chart_id)
if self.no_combine or old_id is None:
# Insert original
print(f"New entry for {song['id']} chart {chart_id}")
next_id = self.get_next_music_id()
else:
# Insert pointing at same ID so scores transfer
print(f"Reused entry for {song['id']} chart {chart_id}")
next_id = old_id
data = {
"level": song["level"],
"kcal": song["kcal"],
"bpm_min": song["bpm_min"],
"bpm_max": song["bpm_max"],
}
self.insert_music_id_for_song(next_id, song["id"], chart_id, song["title"], song["artist"], None, data)
self.finish_batch()

View File

@@ -10,6 +10,7 @@ from bemani.backend.ddr import DDRFactory
from bemani.backend.sdvx import SoundVoltexFactory
from bemani.backend.reflec import ReflecBeatFactory
from bemani.backend.museca import MusecaFactory
from bemani.backend.danevo import DanceEvolutionFactory
from bemani.frontend.popn import PopnMusicCache
from bemani.frontend.iidx import IIDXCache
from bemani.frontend.jubeat import JubeatCache
@@ -19,6 +20,7 @@ from bemani.frontend.ddr import DDRCache
from bemani.frontend.sdvx import SoundVoltexCache
from bemani.frontend.reflec import ReflecBeatCache
from bemani.frontend.museca import MusecaCache
from bemani.frontend.danevo import DanceEvolutionCache
from bemani.common import GameConstants, Time
from bemani.data import Config, Data
from bemani.utils.config import load_config, instantiate_cache
@@ -57,6 +59,9 @@ def run_scheduled_work(config: Config) -> None:
if GameConstants.MUSECA in config.support:
enabled_factories.append(MusecaFactory)
enabled_caches.append(MusecaCache)
if GameConstants.DANCE_EVOLUTION in config.support:
enabled_factories.append(DanceEvolutionFactory)
enabled_caches.append(DanceEvolutionCache)
# First, run any backend scheduled work
for factory in enabled_factories:

View File

@@ -59,7 +59,8 @@ from bemani.client.reflec import (
ReflecBeatVolzza2,
)
from bemani.client.bishi import TheStarBishiBashiClient
from bemani.client.mga.mga import MetalGearArcadeClient
from bemani.client.mga import MetalGearArcadeClient
from bemani.client.danevo import DanceEvolutionClient
def get_client(proto: ClientProtocol, pcbid: str, game: str, config: Dict[str, Any]) -> BaseClient:
@@ -315,6 +316,12 @@ def get_client(proto: ClientProtocol, pcbid: str, game: str, config: Dict[str, A
pcbid,
config,
)
if game == "dance-evolution":
return DanceEvolutionClient(
proto,
pcbid,
config,
)
raise Exception(f"Unknown game {game}")
@@ -548,6 +555,11 @@ def mainloop(
"model": "I36:J:A:A:2011092900",
"avs": None,
},
"dance-evolution": {
"name": "Dance Evolution Arcade",
"model": "KDM:J:B:A:2016080100",
"avs": "2.15.5 r6251",
},
}
if action == "list":
for game in sorted([game for game in games]):
@@ -670,6 +682,7 @@ def main() -> None:
"reflec-5": "reflec-volzza",
"reflec-6": "reflec-volzza2",
"mga": "metal-gear-arcade",
"danevo": "dance-evolution",
}.get(game, game)
mainloop(args.address, args.port, args.config, action, game, args.cardid, args.verbose)

View File

@@ -57,3 +57,6 @@ set -e
./read --series reflec --version 4 "$@"
./read --series reflec --version 5 "$@"
./read --series reflec --version 6 "$@"
# Init Dance Evolution
./read --series danevo --version 1 "$@"

View File

@@ -71,14 +71,16 @@ paseli:
support:
# Bishi Bashi frontend/backend enabled.
bishi: True
# Metal Gear Arcade frontend/backend enabled
mga: True
# Dance Evolution frontend/backend enable.
danevo: True
# DDR frontend/backend enabled
ddr: True
# IIDX frontend/backend enabled.
iidx: True
# Jubeat frontend/backend enabled.
jubeat: True
# Metal Gear Arcade frontend/backend enabled
mga: True
# Museca frontend/backend enabled.
museca: True
# Pop'n Music frontend/backend enabled.

View File

@@ -227,6 +227,7 @@ setup(
'bemani.frontend.sdvx',
'bemani.frontend.reflec',
'bemani.frontend.museca',
'bemani.frontend.danevo',
# Backend packages
'bemani.backend',
@@ -241,6 +242,7 @@ setup(
'bemani.backend.sdvx',
'bemani.backend.reflec',
'bemani.backend.museca',
'bemani.backend.danevo',
# API packages
'bemani.api',
@@ -256,6 +258,7 @@ setup(
'bemani.client.sdvx',
'bemani.client.reflec',
'bemani.client.museca',
'bemani.client.danevo',
],
install_requires=[
req for req in open('requirements.txt').read().split('\n') if len(req) > 0

View File

@@ -3,6 +3,9 @@
set -e
declare -a arr=(
"bishi"
"mga"
"danevo"
"pnm-19"
"pnm-20"
"pnm-21"
@@ -30,7 +33,6 @@ declare -a arr=(
"ddr-2013"
"ddr-2014"
"ddr-ace"
"bishi"
"sdvx-1"
"sdvx-2"
"sdvx-3s1"
@@ -44,7 +46,6 @@ declare -a arr=(
"reflec-4"
"reflec-5"
"reflec-6"
"mga"
)
for project in "${arr[@]}"