From ca2f46dce67cadea6aca3fc1b1468c75fce47619 Mon Sep 17 00:00:00 2001 From: 573dev <> Date: Mon, 2 Nov 2020 08:10:29 -0600 Subject: [PATCH] Have a bunch of stuff working. Next is gametop --- .gitignore | 3 + v8_server/eamuse/services/cardmng.py | 13 +- v8_server/eamuse/services/lobby.py | 1 + v8_server/eamuse/services/local.py | 184 +++++++++++++++++++++++--- v8_server/eamuse/services/services.py | 18 +-- v8_server/eamuse/utils/crc.py | 14 ++ v8_server/model/song.py | 103 ++++++++++++++ v8_server/model/user.py | 28 +++- v8_server/view/index.py | 2 + 9 files changed, 337 insertions(+), 29 deletions(-) create mode 100644 v8_server/eamuse/services/lobby.py create mode 100644 v8_server/eamuse/utils/crc.py create mode 100644 v8_server/model/song.py diff --git a/.gitignore b/.gitignore index 330ab9b..b1864c4 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,6 @@ ENV/ /decompiled/ /database/ + +# Ignore this for now, not sure I want to just throw it up on github yet +/v8_server/model/data/mdb.json diff --git a/v8_server/eamuse/services/cardmng.py b/v8_server/eamuse/services/cardmng.py index 3fd0d48..7aa8460 100644 --- a/v8_server/eamuse/services/cardmng.py +++ b/v8_server/eamuse/services/cardmng.py @@ -63,7 +63,15 @@ class CardMng(object): if user is None: # The user doesn't exist, force system to create a new account - response = E.response(E.cardmng({"status": str(CardMng.NOT_REGISTERED)})) + response = E.response( + E.cardmng( + { + "newflag": "1", + "binded": "0", + "status": str(CardMng.NOT_REGISTERED), + } + ) + ) else: refid = RefID.from_userid(user.userid) bound = Profile.from_userid(user.userid) is not None @@ -76,12 +84,13 @@ class CardMng(object): { "refid": refid.refid, "dataid": refid.refid, - "newflag": "1", + "newflag": "0", "binded": "1" if bound else "0", "expired": "0", "exflag": "0", "useridflag": "1", "extidflag": "1", + "status": str(CardMng.SUCCESS), } ) ) diff --git a/v8_server/eamuse/services/lobby.py b/v8_server/eamuse/services/lobby.py new file mode 100644 index 0000000..a6b886b --- /dev/null +++ b/v8_server/eamuse/services/lobby.py @@ -0,0 +1 @@ +# >>libshare-pj.c: xrpc_module_add(aLobby, "lobby", aLobby, &off_100404C4); diff --git a/v8_server/eamuse/services/local.py b/v8_server/eamuse/services/local.py index 9a5fe5a..8ba1bd3 100644 --- a/v8_server/eamuse/services/local.py +++ b/v8_server/eamuse/services/local.py @@ -1,8 +1,18 @@ +import logging +from datetime import datetime + from lxml import etree from lxml.builder import E +from v8_server import db from v8_server.eamuse.services.services import ServiceRequest +from v8_server.eamuse.utils.crc import calculate_crc8 from v8_server.eamuse.utils.xml import XMLBinTypes as T, e_type +from v8_server.model.song import HitChart +from v8_server.model.user import User, UserAccount + + +logger = logging.getLogger(__name__) class Local(object): @@ -21,10 +31,30 @@ class Local(object): CARDUTIL = "cardutil" CARDUTIL_CHECK = "check" + CARDUTIL_REGIST = "regist" GAMEINFO = "gameinfo" GAMEINFO_GET = "get" + GAMEEND = "gameend" + GAMEEND_REGIST = "regist" + + GAMEINFO = "gameinfo" + GAMEINFO_GET = "get" + + GAMETOP = "gametop" + GAMETOP_GET = "get" + GAMETOP_GET_RIVAL = "get_rival" + + CUSTOMIZE = "customize" + CUSTOMIZE_REGIST = "regist" + + ASSERT_REPORT = "assert_report" + ASSERT_REPORT_REGIST = "regist" + + # Not sure about this one yet + INCREMENT = "increment" + @classmethod def shopinfo(cls, req: ServiceRequest) -> etree: """ @@ -110,6 +140,9 @@ class Local(object): """ Handle the demodata request. + Potentially this is just some initial demo data for initial boot/factory reset + After this data, the game might keep track of all this stuff itself. + # Example Request: @@ -125,27 +158,40 @@ class Local(object): """ - # TODO: Figure out what this thing actually needs to send back + dtfmt = "%Y-%m-%d %H:%M:%S%z" if req.method == cls.DEMODATA_GET: - # response = E.response(E.demodata({"expire": "600"})) + hitchart_number = int(req.xml[0].find("hitchart_nr").text) + rank_data = HitChart.get_ranking(hitchart_number) - # try some dummy response that might have some info in it response = E.response( E.demodata( - E.hitchart({"nr": "3"}), - E.data( - E.musicid("133", e_type(T.s32)), - E.last1("1", e_type(T.s32)), + E.mode("0", e_type(T.u8)), # Unknown what mode we need + E.hitchart( + E.start(datetime.now().strftime(dtfmt)), + E.end(datetime.now().strftime(dtfmt)), + *[ + E.data( + E.musicid(str(x), e_type(T.s32)), + E.last1("0", e_type(T.s32)), + ) + for x in rank_data + ], + {"nr": str(hitchart_number)}, ), - E.data( - E.musicid("208", e_type(T.s32)), - E.last1("2", e_type(T.s32)), - ), - E.data( - E.musicid("209", e_type(T.s32)), - E.last1("3", e_type(T.s32)), + E.bossdata( # No idea what this stuff means + E.division("14", e_type(T.u8)), # Shows up as "Extra Lv X" + E.border("0 0 0 0 0 0 0 0 0", e_type(T.u8, count=9)), + E.extra_border("90", e_type(T.u8)), + E.bsc_encore_border("92", e_type(T.u8)), + E.adv_encore_border("93", e_type(T.u8)), + E.ext_encore_border("94", e_type(T.u8)), + E.bsc_premium_border("95", e_type(T.u8)), + E.adv_premium_border("95", e_type(T.u8)), + E.ext_premium_border("95", e_type(T.u8)), ), + E.info(E.message("SenPi's Kickass Machine")), + E.assert_report_state("0", e_type(T.u8)), ) ) else: @@ -186,6 +232,30 @@ class Local(object): response = E.response( E.cardutil(E.card(E.kind("0", e_type(T.s8)), {"no": "1", "state": "0"})) ) + elif req.method == cls.CARDUTIL_REGIST: + root = req.xml[0].find("data") + refid = root.find("refid").text + name = root.find("name").text + chara = root.find("chara").text + cardid = root.find("uid").text + is_succession = root.find("is_succession").text + + user = User.from_refid(refid) + if user is None: + raise Exception("This user should theoretically exist here") + if user.card.cardid != cardid: + raise Exception(f"Card ID is incorrect: {user.card.cardid} != {cardid}") + + user_account = UserAccount( + userid=user.userid, + name=name, + chara=int(chara), + is_succession=True if is_succession == "1" else False, + ) + db.session.add(user_account) + db.session.commit() + + response = E.response(E.cardutil()) else: raise Exception( "Not sure how to handle this cardutil request. " @@ -217,7 +287,48 @@ class Local(object): """ if req.method == cls.GAMEINFO_GET: - response = E.response(E.gameinfo()) + response = E.response( + E.gameinfo( + E.mode("0", e_type(T.u8)), + E.free_music("262143", e_type(T.u32)), + E.key(E.musicid("-1", e_type(T.s32))), + E.limit_gdp("40000", e_type(T.u32)), + E.free_chara("1824", e_type(T.u32)), + E.tag(str(calculate_crc8(str(262143 + 1824))), e_type(T.u8)), + E.bossdata( + E.division("14", e_type(T.u8)), # Shows up as "Extra Lv X" + E.border("0 0 0 0 0 0 0 0 0", e_type(T.u8, count=9)), + E.extra_border("90", e_type(T.u8)), + E.bsc_encore_border("92", e_type(T.u8)), + E.adv_encore_border("93", e_type(T.u8)), + E.ext_encore_border("94", e_type(T.u8)), + E.bsc_premium_border("95", e_type(T.u8)), + E.adv_premium_border("95", e_type(T.u8)), + E.ext_premium_border("95", e_type(T.u8)), + ), + E.battledata( + E.battle_music_level( + " ".join("0" * 13), e_type(T.u8, count=13) + ), + E.standard_skill(" ".join("0" * 13), e_type(T.s32, count=13)), + E.border_skill(" ".join("0" * 13), e_type(T.s32, count=13)), + ), + E.quest( + E.division("0", e_type(T.u8)), + E.border("0", e_type(T.u8)), + E.qdata(" ".join("0" * 26), e_type(T.u32, count=26)), + *[ + E(f"play_{x}", " ".join("0" * 32), e_type(T.u32, count=32)) + for x in range(0, 13) + ], + *[ + E(f"clear_{x}", " ".join("0" * 32), e_type(T.u32, count=32)) + for x in range(0, 13) + ], + ), + E.campaign(E.campaign("0", e_type(T.u8))), + ) + ) else: raise Exception( "Not sure how to handle this gameinfo request. " @@ -225,3 +336,46 @@ class Local(object): ) return response + + @classmethod + def gametop(cls, req: ServiceRequest) -> etree: + + if req.method == cls.GAMETOP_GET: + pass + elif req.method == cls.GAMETOP_GET_RIVAL: + pass + else: + raise Exception( + "Not sure how to handle this gametop request. " + f'method "{req.method}" is unknown for request: {req}' + ) + + return request + + @classmethod + def gameend(cls, req: ServiceRequest) -> etree: + """ + Handle a GameEnd request. + + For now just save the hitchart data + """ + if req.method == cls.GAMEEND_REGIST: + # Grab the hitchart data + hc_root = req.xml[0].find("hitchart") + + for mid in hc_root.findall("musicid"): + musicid = int(mid.text) + hc = HitChart(musicid=musicid, playdate=datetime.now()) + logger.debug(f"Saving HitChart: {hc}") + db.session.add(hc) + db.session.commit() + + # Just send back a dummy object for now + response = E.response(E.gameend()) + else: + raise Exception( + "Not sure how to handle this gameend request. " + f'method "{req.method}" is unknown for request: {req}' + ) + + return response diff --git a/v8_server/eamuse/services/services.py b/v8_server/eamuse/services/services.py index 126f58b..5387ee6 100644 --- a/v8_server/eamuse/services/services.py +++ b/v8_server/eamuse/services/services.py @@ -38,20 +38,21 @@ class ServiceType(IntEnum): # Extra for testing # BINARY = 8 - # DLSTATUS = 9 - # EACOIN = 10 + DLSTATUS = 9 + EACOIN = 10 # EEMALL = 11 # INFO = 12 - # LOBBY = 13 - # NETLOG = 14 + LOBBY = 13 + NETLOG = 14 # NUMBERING = 15 # PKGLIST = 16 # POSEVENT = 17 # REFERENCE = 18 # SHOPINF = 19 - # SIDMGR = 20 - # USERDATA = 21 - # USERID = 22 + SIDMGR = 20 + USERDATA = 21 + USERID = 22 + TRACEROUTE = 23 class Services(object): @@ -66,7 +67,8 @@ class Services(object): # Default service url that GFDM uses. You will need to set up your network so that # this URL points to this server. - SERVICE_URL = "https://eamuse.konami.fun" + # SERVICE_URL = "https://eamuse.konami.fun" + SERVICE_URL = "https://e.k.f" # The base route that GFDM uses to query the eAmuse server to get the list of # offered services diff --git a/v8_server/eamuse/utils/crc.py b/v8_server/eamuse/utils/crc.py new file mode 100644 index 0000000..7218a5c --- /dev/null +++ b/v8_server/eamuse/utils/crc.py @@ -0,0 +1,14 @@ +def calculate_crc8(value: str) -> int: + """ + Calculate the CRC8 of a string representation of an int + """ + crc = 0 + + for c in bytearray(value.encode("ASCII")): + for i in range(8, 0, -1): + t = c ^ crc + crc >>= 1 + if (t & 0x01) != 0: + crc ^= 0x8C + c >>= 1 + return crc diff --git a/v8_server/model/song.py b/v8_server/model/song.py new file mode 100644 index 0000000..b683cc4 --- /dev/null +++ b/v8_server/model/song.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import List, Tuple + +from flask_sqlalchemy.model import DefaultMeta +from sqlalchemy import Column, ForeignKey, PrimaryKeyConstraint, event, func, text +from sqlalchemy.orm import relationship +from sqlalchemy.types import DateTime, Integer, String + +from v8_server import db + + +BaseModel: DefaultMeta = db.Model +logger = logging.getLogger(__name__) + + +class Song(BaseModel): + """ + Table representing the song data + """ + + __tablename__ = "songs" + musicid = Column(Integer, nullable=False, primary_key=True) + bpm = Column(Integer, nullable=False) + title_ascii = Column(String(16), nullable=False) + hitcharts = relationship("HitChart", back_populates="song") + + def __repr__(self) -> str: + return ( + f"Song' + ) + + +class HitChart(BaseModel): + """ + Table representing the song hit chart + """ + + __tablename__ = "hitchart" + __table_args__ = (PrimaryKeyConstraint("musicid", "playdate"),) + musicid = Column(Integer, ForeignKey("songs.musicid"), nullable=False) + playdate = Column(DateTime, nullable=False) + song = relationship("Song", back_populates="hitcharts") + + def __repr__(self) -> str: + return f"HitChart" + + @classmethod + def get_ranking(cls, count) -> List[int]: + items = ( + db.session.query( + HitChart.musicid, func.count(HitChart.musicid).label("count") + ) + .group_by(HitChart.musicid) + .order_by(text("count DESC")) + .order_by(HitChart.musicid.desc()) + .limit(count) + .all() + ) + + results = [] + for item in items: + results.append(item[0]) + + return results + + +def insert_initial_song_data(target, connection, **kwargs) -> None: + # Make sure both Song and HitChart have been created + try: + db.session.execute("SELECT * FROM songs") + db.session.execute("SELECT * FROM hitchart") + except Exception: + return + + # Load up the mdb.json file + data_path = Path(__file__).parent / "data" / "mdb.json" + with data_path.open() as f: + json_data = json.loads(f.read()) + + for key, song in json_data["musicdb"]["songs"].items(): + song_obj = Song(musicid=key, bpm=song["bpm"], title_ascii=song["title_ascii"]) + db.session.add(song_obj) + + db.session.commit() + + # Insert initial hitchart data, just add one entry for every song with the current + # timestamp + now = datetime.now() + for key, song in json_data["musicdb"]["songs"].items(): + hc = HitChart(musicid=key, playdate=now) + db.session.add(hc) + + db.session.commit() + + +event.listen(Song.__table__, "after_create", insert_initial_song_data) +event.listen(HitChart.__table__, "after_create", insert_initial_song_data) diff --git a/v8_server/model/user.py b/v8_server/model/user.py index 8ad71ea..1aecb30 100644 --- a/v8_server/model/user.py +++ b/v8_server/model/user.py @@ -6,7 +6,7 @@ from typing import Optional from flask_sqlalchemy.model import DefaultMeta from sqlalchemy import JSON, Column, ForeignKey, UniqueConstraint from sqlalchemy.orm import relationship -from sqlalchemy.types import Integer, String +from sqlalchemy.types import Boolean, Integer, String from v8_server import db @@ -27,9 +27,10 @@ class User(BaseModel): userid = Column(Integer, nullable=False, primary_key=True) pin = Column(String(4), nullable=False) - cards = relationship("Card", back_populates="user") + card = relationship("Card", uselist=False, back_populates="user") extids = relationship("ExtID", back_populates="user") refids = relationship("RefID", back_populates="user") + user_account = relationship("UserAccount", uselist=False, back_populates="user") def __repr__(self) -> str: return f'User' @@ -39,6 +40,25 @@ class User(BaseModel): card = db.session.query(Card).filter(Card.cardid == cardid).one_or_none() return card.user if card is not None else None + @classmethod + def from_refid(cls, refid: str) -> Optional[User]: + ref = db.session.query(RefID).filter(RefID.refid == refid).one_or_none() + return ref.user if ref is not None else None + + +class UserAccount(BaseModel): + """ + Table representing a user account. + """ + + __tablename__ = "user_accounts" + + userid = Column(Integer, ForeignKey("users.userid"), primary_key=True) + name = Column(String(8), nullable=False) + chara = Column(Integer, nullable=False) + is_succession = Column(Boolean, nullable=False) + user = relationship("User", back_populates="user_account") + class Card(BaseModel): """ @@ -51,7 +71,7 @@ class Card(BaseModel): cardid = Column(String(16), nullable=False, primary_key=True) userid = Column(Integer, ForeignKey("users.userid"), nullable=False) - user = relationship("User", back_populates="cards") + user = relationship("User", back_populates="card") def __repr__(self) -> str: return f'Card' @@ -64,7 +84,7 @@ class ExtID(BaseModel): """ __tablename__ = "extids" - __table_args_ = (UniqueConstraint("game", "userid", name="game_userid"),) + __table_args__ = (UniqueConstraint("game", "userid", name="game_userid"),) extid = Column(Integer, nullable=False, primary_key=True) game = Column(String(32), nullable=False) userid = Column(Integer, ForeignKey("users.userid"), nullable=False) diff --git a/v8_server/view/index.py b/v8_server/view/index.py index 3780eb8..532e76d 100644 --- a/v8_server/view/index.py +++ b/v8_server/view/index.py @@ -145,6 +145,8 @@ def local_service() -> FlaskResponse: response = Local.cardutil(req) elif req.module == Local.GAMEINFO: response = Local.gameinfo(req) + elif req.module == Local.GAMEEND: + response = Local.gameend(req) else: raise Exception(f"Not sure how to handle this Local Request: {req}") return req.response(response)