Initial commit

This commit is contained in:
Josh 2018-12-09 00:15:56 -05:00
commit aad3c9bb54
125 changed files with 18177 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
**/__pycache__
**/build
config.ini
.data
runserver.bat
.idea
common_copied
common_refractor
*.c
*.so
*.log
common
gitold/
test.py
tomejerryrx-unused.py

9
.gitmodules vendored Normal file
View File

@ -0,0 +1,9 @@
[submodule "common"]
path = common
url = git@github.com:osuAkatsuki/akatsuki-common.git
[submodule "pp/oppai-ng"]
path = pp/oppai-ng
url = https://github.com/Francesco149/oppai-ng.git
[submodule "pp/catch_the_pp"]
path = pp/catch_the_pp
url = https://github.com/osuripple/catch-the-pp.git

7
.landscape.yaml Normal file
View File

@ -0,0 +1,7 @@
python-targets:
- 3
pep8:
none: true
pylint:
disable:
- cyclic-import

661
LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.

81
README.md Normal file
View File

@ -0,0 +1,81 @@
[![Discord](https://discordapp.com/api/guilds/365406575893938177/widget.png?style=shield)](https://discord.gg/5cBtMPW)
## LETS
- Origin: https://zxq.co/ripple/lets
- Mirror: https://github.com/osuripple/lets
## Latest Essential Tatoe Server
This server handles every non real time client feature, so:
- Ingame scoreboards
- Score submission
- Screenshots
- Replays
- osu!direct, thanks to [cheesegull](https://github.com/osuripple/cheesegull)
- Tillerino-like API (partially broken)
- osu!standard and taiko pp calculation with [oppai-ng](https://github.com/francesco149/oppai-ng), made by Franc[e]sco
- osu!mania pp calculation with a slightly edited version of [osu-tools](https://github.com/ppy/osu-tools), made by the osu! team
- catch the beat pp calculation with [catch-the-pp](https://github.com/osuripple/catch-the-pp), made by Sunpy and cythonized by Nyo
## Requirements
- Python 3.6
- Cython
- C compiler
## How to set up LETS
First of all, initialize and update the submodules
```
$ git submodule init && git submodule update
```
afterwards, install the required dependencies with pip
```
$ pip install -r requirements.txt
```
compile all `*.pyx` files to `*.so` or `*.dll` files using `setup.py` (distutils file).
This compiles `catch-the-pp` as well.
```
$ python3 setup.py build_ext --inplace
```
then, run LETS once to create the default config file and edit it
```
$ python3 lets.py
$ nano config.ini
```
finally, compile oppai-ng (inside pp/oppai-ng) and osu-tools (inside pp/maniapp-osu-tools).
## tomejerry.py
`tomejerry.py` is a tool that allows you to calculate pp for specific scores. It's extremely useful to do mass PP recalculations if you mess something up. It uses lets' config and packages, so make sure lets is installed and configured correctly before using it.
```
usage: tomejerry.py [-h]
[-r | -z | -i ID | -m MODS | -g GAMEMODE | -u USERID | -b BEATMAPID | -fhd]
[-w WORKERS] [-cs CHUNKSIZE] [-v]
pp recalc tool for ripple, new version.
optional arguments:
-h, --help show this help message and exit
-r, --recalc calculates pp for all high scores
-z, --zero calculates pp for 0 pp high scores
-i ID, --id ID calculates pp for the score with this score_id
-m MODS, --mods MODS calculates pp for high scores with these mods (flags)
-g GAMEMODE, --gamemode GAMEMODE
calculates pp for scores played on this game mode
(std:0, taiko:1, ctb:2, mania:3)
-u USERID, --userid USERID
calculates pp for high scores set by a specific user
(user_id)
-b BEATMAPID, --beatmapid BEATMAPID
calculates pp for high scores played on a specific
beatmap (beatmap_id)
-fhd, --fixstdhd calculates pp for std hd high scores (14/05/2018 pp
algorithm changes)
-w WORKERS, --workers WORKERS
number of workers. 16 by default. Max 32
-cs CHUNKSIZE, --chunksize CHUNKSIZE
score chunks size
-v, --verbose verbose/debug mode
```
## License
This project is licensed under the GNU AGPL 3 License.
See the "LICENSE" file for more information.

0
constants/__init__.py Normal file
View File

11
constants/dataTypes.py Normal file
View File

@ -0,0 +1,11 @@
byte = 0
uInt16 = 1
sInt16 = 2
uInt32 = 3
sInt32 = 4
uInt64 = 5
sInt64 = 6
string = 7
ffloat = 8
bbytes = 9
rawReplay = 10

54
constants/exceptions.py Normal file
View File

@ -0,0 +1,54 @@
from common.log import logUtils as log
class invalidArgumentsException(Exception):
def __init__(self, handler):
log.warning("{} - Invalid arguments".format(handler))
class loginFailedException(Exception):
def __init__(self, handler, who):
log.warning("{} - {}'s Login failed".format(handler, who))
class userBannedException(Exception):
def __init__(self, handler, who):
log.warning("{} - {} is banned".format(handler, who))
class userLockedException(Exception):
def __init__(self, handler, who):
log.warning("{} - {} is locked".format(handler, who))
class noBanchoSessionException(Exception):
def __init__(self, handler, who, ip):
log.warning("{handler} - {username} has tried to submit a score from {ip} without an active bancho session from that ip. If this happens often, {username} is trying to use a score submitter.".format(handler=handler, ip=ip, username=who), "bunker")
class osuApiFailException(Exception):
def __init__(self, handler):
log.warning("{} - Invalid data from osu!api".format(handler))
class fileNotFoundException(Exception):
def __init__(self, handler, f):
log.warning("{} - File not found ({})".format(handler, f))
class invalidBeatmapException(Exception):
pass
class unsupportedGameModeException(Exception):
pass
class beatmapTooLongException(Exception):
def __init__(self, handler):
log.warning("{} - Requested beatmap is too long.".format(handler))
class need2FAException(Exception):
def __init__(self, handler, who, ip):
log.warning("{} - 2FA check needed for user {} ({})".format(handler, who, ip))
class noAPIDataError(Exception):
pass
class scoreNotFoundError(Exception):
pass
class ppCalcException(Exception):
def __init__(self, exception):
self.exception = exception

View File

@ -0,0 +1,8 @@
UNKNOWN = -2
NOT_SUBMITTED = -1
PENDING = 0
NEED_UPDATE = 1
RANKED = 2
APPROVED = 3
QUALIFIED = 4
LOVED = 5

4
full_build.sh Normal file
View File

@ -0,0 +1,4 @@
find . -name "*.c" -type f -delete
find . -name "*.o" -type f -delete
find . -name "*.so" -type f -delete
python3 setup.py build_ext --inplace

0
handlers/__init__.py Normal file
View File

View File

@ -0,0 +1,78 @@
import json
import sys
import traceback
import tornado.gen
import tornado.web
from raven.contrib.tornado import SentryMixin
from objects import beatmap
from common.log import logUtils as log
from common.web import requestsManager
from constants import exceptions
from helpers import osuapiHelper
from objects import glob
from common.sentry import sentry
MODULE_NAME = "api/cacheBeatmap"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /api/v1/cacheBeatmap
"""
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncPost(self):
statusCode = 400
data = {"message": "unknown error"}
try:
# Check arguments
if not requestsManager.checkArguments(self.request.arguments, ["sid", "refresh"]):
raise exceptions.invalidArgumentsException(MODULE_NAME)
# Get beatmap set data from osu api
beatmapSetID = self.get_argument("sid")
refresh = int(self.get_argument("refresh"))
if refresh == 1:
log.debug("Forced refresh")
apiResponse = osuapiHelper.osuApiRequest("get_beatmaps", "s={}".format(beatmapSetID), False)
if len(apiResponse) == 0:
raise exceptions.invalidBeatmapException
# Loop through all beatmaps in this set and save them in db
data["maps"] = []
for i in apiResponse:
log.debug("Saving beatmap {} in db".format(i["file_md5"]))
bmap = beatmap.beatmap(i["file_md5"], int(i["beatmapset_id"]), refresh=refresh)
pp = glob.db.fetch("SELECT pp_100 FROM beatmaps WHERE beatmap_id = %s LIMIT 1", [bmap.beatmapID])
if pp is None:
pp = 0
else:
pp = pp["pp_100"]
data["maps"].append({
"id": bmap.beatmapID,
"name": bmap.songName,
"status": bmap.rankedStatus,
"frozen": bmap.rankedStatusFrozen,
"pp": pp,
})
# Set status code and message
statusCode = 200
data["message"] = "ok"
except exceptions.invalidArgumentsException:
# Set error and message
statusCode = 400
data["message"] = "missing required arguments"
except exceptions.invalidBeatmapException:
statusCode = 400
data["message"] = "beatmap not found from osu!api."
finally:
# Add status code to data
data["status"] = statusCode
# Send response
self.write(json.dumps(data))
self.set_header("Content-Type", "application/json")
#self.add_header("Access-Control-Allow-Origin", "*")
self.set_status(statusCode)

176
handlers/apiPPHandler.py Normal file
View File

@ -0,0 +1,176 @@
import json
import sys
import traceback
import tornado.gen
import tornado.web
from raven.contrib.tornado import SentryMixin
from objects import beatmap
from common.constants import gameModes
from common.log import logUtils as log
from common.web import requestsManager
from constants import exceptions
from helpers import osuapiHelper
from objects import glob
from pp import rippoppai
from pp import rxoppai
from common.sentry import sentry
MODULE_NAME = "api/pp"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /api/v1/pp
"""
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncGet(self):
statusCode = 400
data = {"message": "unknown error"}
try:
# Check arguments
if not requestsManager.checkArguments(self.request.arguments, ["b"]):
raise exceptions.invalidArgumentsException(MODULE_NAME)
# Get beatmap ID and make sure it's a valid number
beatmapID = self.get_argument("b")
if not beatmapID.isdigit():
raise exceptions.invalidArgumentsException(MODULE_NAME)
# Get mods
if "m" in self.request.arguments:
modsEnum = self.get_argument("m")
if not modsEnum.isdigit():
raise exceptions.invalidArgumentsException(MODULE_NAME)
modsEnum = int(modsEnum)
else:
modsEnum = 0
# Get game mode
if "g" in self.request.arguments:
gameMode = self.get_argument("g")
if not gameMode.isdigit():
raise exceptions.invalidArgumentsException(MODULE_NAME)
gameMode = int(gameMode)
else:
gameMode = 0
# Get acc
if "a" in self.request.arguments:
accuracy = self.get_argument("a")
try:
accuracy = float(accuracy)
except ValueError:
raise exceptions.invalidArgumentsException(MODULE_NAME)
else:
accuracy = -1.0
# Print message
log.info("Requested pp for beatmap {}".format(beatmapID))
# Get beatmap md5 from osuapi
# TODO: Move this to beatmap object
osuapiData = osuapiHelper.osuApiRequest("get_beatmaps", "b={}".format(beatmapID))
if osuapiData is None or "file_md5" not in osuapiData or "beatmapset_id" not in osuapiData:
raise exceptions.invalidBeatmapException(MODULE_NAME)
beatmapMd5 = osuapiData["file_md5"]
beatmapSetID = osuapiData["beatmapset_id"]
# Create beatmap object
bmap = beatmap.beatmap(beatmapMd5, beatmapSetID)
# Check beatmap length
if bmap.hitLength > 900:
raise exceptions.beatmapTooLongException(MODULE_NAME)
returnPP = []
if gameMode == gameModes.STD and bmap.starsStd == 0:
# Mode Specific beatmap, auto detect game mode
if bmap.starsTaiko > 0:
gameMode = gameModes.TAIKO
if bmap.starsCtb > 0:
gameMode = gameModes.CTB
if bmap.starsMania > 0:
gameMode = gameModes.MANIA
# Calculate pp
if gameMode == gameModes.STD or gameMode == gameModes.TAIKO:
# Std pp
if accuracy < 0 and modsEnum == 0:
# Generic acc
# Get cached pp values
cachedPP = bmap.getCachedTillerinoPP()
if cachedPP != [0,0,0,0]:
log.debug("Got cached pp.")
returnPP = cachedPP
else:
log.debug("Cached pp not found. Calculating pp with oppai...")
# Cached pp not found, calculate them
oppai = rippoppai.oppai(bmap, mods=modsEnum, tillerino=True)
returnPP = oppai.pp
bmap.starsStd = oppai.stars
# Cache values in DB
log.debug("Saving cached pp...")
if type(returnPP) == list and len(returnPP) == 4:
bmap.saveCachedTillerinoPP(returnPP)
else:
# Specific accuracy, calculate
# Create oppai instance
log.debug("Specific request ({}%/{}). Calculating pp with oppai...".format(accuracy, modsEnum))
if modsEnum & 128:
oppai = rxoppai.oppai(bmap, mods=modsEnum, tillerino=True)
else:
oppai = rippoppai.oppai(bmap, mods=modsEnum, tillerino=True)
bmap.starsStd = oppai.stars
if accuracy > 0:
returnPP.append(calculatePPFromAcc(oppai, accuracy))
else:
returnPP = oppai.pp
else:
raise exceptions.unsupportedGameModeException()
# Data to return
data = {
"song_name": bmap.songName,
"pp": [round(x, 2) for x in returnPP] if type(returnPP) == list else returnPP,
"length": bmap.hitLength,
"stars": bmap.starsStd,
"ar": bmap.AR,
"bpm": bmap.bpm,
}
# Set status code and message
statusCode = 200
data["message"] = "ok"
except exceptions.invalidArgumentsException:
# Set error and message
statusCode = 400
data["message"] = "missing required arguments"
except exceptions.invalidBeatmapException:
statusCode = 400
data["message"] = "beatmap not found"
except exceptions.beatmapTooLongException:
statusCode = 400
data["message"] = "requested beatmap is too long"
except exceptions.unsupportedGameModeException:
statusCode = 400
data["message"] = "Unsupported gamemode"
finally:
# Add status code to data
data["status"] = statusCode
# Debug output
log.debug(str(data))
# Send response
#self.clear()
self.write(json.dumps(data))
self.set_header("Content-Type", "application/json")
self.set_status(statusCode)
def calculatePPFromAcc(ppcalc, acc):
ppcalc.acc = acc
ppcalc.calculatePP()
return ppcalc.pp

View File

@ -0,0 +1,12 @@
import json
from common.web import requestsManager
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /api/v1/status
"""
def asyncGet(self):
self.write(json.dumps({"status": 200, "server_status": 1}))
#self.finish()

View File

@ -0,0 +1,70 @@
import sys
import traceback
import tornado.gen
import tornado.web
from raven.contrib.tornado import SentryMixin
from common.log import logUtils as log
from common.ripple import userUtils
from common.web import requestsManager
from constants import exceptions
from objects import glob
from common.sentry import sentry
MODULE_NAME = "bancho_connect"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /web/bancho_connect.php
"""
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncGet(self):
try:
# Get request ip
ip = self.getRequestIP()
# Argument check
if not requestsManager.checkArguments(self.request.arguments, ["u", "h"]):
raise exceptions.invalidArgumentsException(MODULE_NAME)
# Get user ID
username = self.get_argument("u")
userID = userUtils.getID(username)
if userID is None:
raise exceptions.loginFailedException(MODULE_NAME, username)
# Check login
log.info("{} ({}) wants to connect".format(username, userID))
if not userUtils.checkLogin(userID, self.get_argument("h"), ip):
raise exceptions.loginFailedException(MODULE_NAME, username)
# Ban check
if userUtils.isBanned(userID):
raise exceptions.userBannedException(MODULE_NAME, username)
# Lock check
if userUtils.isLocked(userID):
raise exceptions.userLockedException(MODULE_NAME, username)
# 2FA check
if userUtils.check2FA(userID, ip):
raise exceptions.need2FAException(MODULE_NAME, username, ip)
# Update latest activity
userUtils.updateLatestActivity(userID)
# Get country and output it
country = glob.db.fetch("SELECT country FROM users_stats WHERE id = %s", [userID])["country"]
self.write(country)
except exceptions.invalidArgumentsException:
pass
except exceptions.loginFailedException:
self.write("error: pass\n")
except exceptions.userBannedException:
pass
except exceptions.userLockedException:
pass
except exceptions.need2FAException:
self.write("error: verify\n")

View File

@ -0,0 +1,36 @@
from urllib.parse import urlencode
import requests
import tornado.gen
import tornado.web
from common.log import logUtils as log
from common.web import requestsManager
class handler(requestsManager.asyncRequestHandler):
@tornado.web.asynchronous
@tornado.gen.engine
def asyncGet(self):
try:
args = {}
#if "stream" in self.request.arguments:
# args["stream"] = self.get_argument("stream")
#if "action" in self.request.arguments:
# args["action"] = self.get_argument("action")
#if "time" in self.request.arguments:
# args["time"] = self.get_argument("time")
# Pass all arguments otherwise it doesn't work
for key, _ in self.request.arguments.items():
args[key] = self.get_argument(key)
if args["action"].lower() == "put":
self.write("nope")
return
response = requests.get("https://osu.ppy.sh/web/check-updates.php?{}".format(urlencode(args)))
self.write(response.text)
except Exception as e:
log.error("check-updates failed: {}".format(e))
self.write("")

175
handlers/commentHandler.py Normal file
View File

@ -0,0 +1,175 @@
import tornado.gen
import tornado.web
from common.log import logUtils as log
from common.ripple import userUtils
from common.sentry import sentry
from common.web import requestsManager
from constants import exceptions
from objects import glob
MODULE_NAME = "comments"
class handler(requestsManager.asyncRequestHandler):
CLIENT_WHO = {"normal": "", "player": "player", "admin": "bat", "donor": "subscriber"}
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncPost(self):
try:
# Required arguments check
if not requestsManager.checkArguments(self.request.arguments, ("u", "p", "a")):
raise exceptions.invalidArgumentsException(MODULE_NAME)
# Get arguments
username = self.get_argument("u")
password = self.get_argument("p")
action = self.get_argument("a").strip().lower()
# IP for session check
ip = self.getRequestIP()
# Login and ban check
userID = userUtils.getID(username)
if userID == 0:
raise exceptions.loginFailedException(MODULE_NAME, userID)
if not userUtils.checkLogin(userID, password, ip):
raise exceptions.loginFailedException(MODULE_NAME, username)
if userUtils.check2FA(userID, ip):
raise exceptions.need2FAException(MODULE_NAME, userID, ip)
if userUtils.isBanned(userID):
raise exceptions.userBannedException(MODULE_NAME, username)
# Action (depends on 'action' parameter, not on HTTP method)
if action == "get":
self.write(self._getComments())
elif action == "post":
self._addComment()
except (exceptions.loginFailedException, exceptions.need2FAException, exceptions.userBannedException):
self.write("error: no")
@staticmethod
def clientWho(y):
return handler.CLIENT_WHO[y["who"]] + (
("|{}".format(y["special_format"])) if y["special_format"] is not None else ""
)
def _getComments(self):
output = ""
try:
beatmapID = int(self.get_argument("b", default=0))
beatmapSetID = int(self.get_argument("s", default=0))
scoreID = int(self.get_argument("r", default=0))
except ValueError:
raise exceptions.invalidArgumentsException(MODULE_NAME)
if beatmapID <= 0:
return
log.info("Requested comments for beatmap id {}".format(beatmapID))
# Merge beatmap, beatmapset and score comments
for x in (
{"db_type": "beatmap_id", "client_type": "map", "value": beatmapID},
{"db_type": "beatmapset_id", "client_type": "song", "value": beatmapSetID},
{"db_type": "score_id", "client_type": "replay", "value": scoreID},
):
# Add this set of comments only if the client has set the value
if x["value"] <= 0:
continue
# Fetch these comments
comments = glob.db.fetchAll(
"SELECT * FROM comments WHERE {} = %s ORDER BY `time`".format(x["db_type"]),
(x["value"],)
)
# Output comments
output += "\n".join([
"{y[time]}\t{client_name}\t{client_who}\t{y[comment]}".format(
y=y,
client_name=x["client_type"],
client_who=self.clientWho(y)
) for y in comments
]) + "\n"
return output
def _addComment(self):
username = self.get_argument("u")
target = self.get_argument("target", default=None)
specialFormat = self.get_argument("f", default=None)
userID = userUtils.getID(username)
# Technically useless
if userID < 0:
return
# Get beatmap/set/score ids
try:
beatmapID = int(self.get_argument("b", default=0))
beatmapSetID = int(self.get_argument("s", default=0))
scoreID = int(self.get_argument("r", default=0))
except ValueError:
raise exceptions.invalidArgumentsException(MODULE_NAME)
# Add a comment, removing all illegal characters and trimming after 128 characters
comment = self.get_argument("comment").replace("\r", "").replace("\t", "").replace("\n", "")[:128]
try:
time_ = int(self.get_argument("starttime"))
except ValueError:
raise exceptions.invalidArgumentsException(MODULE_NAME)
# Type of comment
who = "normal"
if target == "replay" and glob.db.fetch(
"SELECT COUNT(*) AS c FROM scores WHERE id = %s AND userid = %s AND completed = 3",
(scoreID, userID)
)["c"] > 0:
# From player, on their score
who = "player"
elif userUtils.isInAnyPrivilegeGroup(userID, ("super admin", "developer", "community manager", "bat")):
# From BAT/Admin
who = "admin"
elif userUtils.isInPrivilegeGroup(userID, "premium"):
# Akatsuki Premium Member
who = "donor"
if target == "song":
# Set comment
if beatmapSetID <= 0:
return
value = beatmapSetID
column = "beatmapset_id"
elif target == "map":
# Beatmap comment
if beatmapID <= 0:
return
value = beatmapID
column = "beatmap_id"
elif target == "replay":
# Score comment
if scoreID <= 0:
return
value = scoreID
column = "score_id"
else:
# Invalid target
return
# Make sure the user hasn't submitted another comment on the same map/set/song in a 5 seconds range
if glob.db.fetch(
"SELECT COUNT(*) AS c FROM comments WHERE user_id = %s AND {} = %s AND `time` BETWEEN %s AND %s".format(
column
), (userID, value, time_ - 5000, time_ + 5000)
)["c"] > 0:
return
# Store the comment
glob.db.execute(
"INSERT INTO comments ({}, user_id, comment, `time`, who, special_format) "
"VALUES (%s, %s, %s, %s, %s, %s)".format(column),
(value, userID, comment, time_, who, specialFormat)
)
log.info("Submitted {} ({}) comment, user {}: '{}'".format(column, value, userID, comment))

View File

@ -0,0 +1,53 @@
import tornado.gen
import tornado.web
from common.web import requestsManager
class handler(requestsManager.asyncRequestHandler):
@tornado.web.asynchronous
@tornado.gen.engine
def asyncGet(self):
print("404: {}".format(self.request.uri))
self.write("""
<html>
<head>
<style>
@import url(https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,700,600,400italic,600italic,700italic,900,900italic);
@import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
html, body {
height: 90%;
background-image: url(http://y.zxq.co/xtffuu.png);
}
.main {
height: 100%;
width: 100%;
display: table;
}
.wrapper {
display: table-cell;
height: 90%;
vertical-align: middle;
}
body {
font-family: Source Sans Pro;
text-align: center;
}
h1, h2, h3, h4, h5, h6 {
font-family: Raleway;
}
</style>
</head>
<body>
<div class = "main">
<div class = "wrapper">
<a href="https://akatsuki.pw"><img src="https://i.namir.in//Mbp.png"></a>
<h3>Howdy, you're still connected to Akatsuki!</h3>
You can't access osu!'s website if the Server Switcher is On.<br>
Please open the <b>Server Switcher</b> and click <b>On/Off</b> to switch server, then refresh this page.
<h4>If you still can't access osu! website even if the switcher is Off, <a href="http://www.refreshyourcache.com/" target="_blank">clean your browser cache</a>.</h4>
</div>
</div>
</body>
</html>
""")

View File

@ -0,0 +1,29 @@
import tornado.gen
import tornado.web
from common.web import requestsManager
from common.sentry import sentry
MODULE_NAME = "direct_download"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /d/
"""
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncGet(self, bid):
try:
noVideo = bid.endswith("n")
if noVideo:
bid = bid[:-1]
bid = int(bid)
self.set_status(302, "Moved Temporarily")
url = "https://bm6.ppy.sh/d/{}{}".format(bid, "?novideo" if noVideo else "")
self.add_header("Location", url)
self.add_header("Cache-Control", "no-cache")
self.add_header("Pragma", "no-cache")
except ValueError:
self.set_status(400)
self.write("Invalid set id")

12
handlers/emptyHandler.py Normal file
View File

@ -0,0 +1,12 @@
import tornado.gen
import tornado.web
from common.web import requestsManager
class handler(requestsManager.asyncRequestHandler):
@tornado.web.asynchronous
@tornado.gen.engine
def asyncGet(self):
#self.set_status(404)
self.write("Not yet")

View File

@ -0,0 +1,31 @@
import tornado.gen
import tornado.web
from common.web import requestsManager
from constants import exceptions
from helpers import replayHelper
from common.sentry import sentry
MODULE_NAME = "get_full_replay"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /replay/
"""
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncGet(self, replayID):
try:
fullReplay = replayHelper.buildFullReplay(scoreID=replayID)
self.write(fullReplay)
self.add_header("Content-type", "application/octet-stream")
self.set_header("Content-length", len(fullReplay))
self.set_header("Content-Description", "File Transfer")
self.set_header("Content-Disposition", "attachment; filename=\"{}.osr\"".format(replayID))
except (exceptions.fileNotFoundException, exceptions.scoreNotFoundError):
fullReplay = replayHelper.rxbuildFullReplay(scoreID=replayID)
self.write(fullReplay)
self.add_header("Content-type", "application/octet-stream")
self.set_header("Content-length", len(fullReplay))
self.set_header("Content-Description", "File Transfer")
self.set_header("Content-Disposition", "attachment; filename=\"{}.osr\"".format(replayID))

View File

@ -0,0 +1,79 @@
import os
import sys
import traceback
import tornado.gen
import tornado.web
from raven.contrib.tornado import SentryMixin
from common.log import logUtils as log
from common.ripple import userUtils
from common.web import requestsManager
from constants import exceptions
from common.constants import mods
from objects import glob
from objects import rxscore
from common.sentry import sentry
MODULE_NAME = "get_replay"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for osu-getreplay.php
"""
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncGet(self):
try:
# Get request ip
ip = self.getRequestIP()
# Check arguments
if not requestsManager.checkArguments(self.request.arguments, ["c", "u", "h"]):
raise exceptions.invalidArgumentsException(MODULE_NAME)
# Get arguments
username = self.get_argument("u")
password = self.get_argument("h")
replayID = self.get_argument("c")
s = rxscore.score()
# Login check
userID = userUtils.getID(username)
if userID == 0:
raise exceptions.loginFailedException(MODULE_NAME, userID)
if not userUtils.checkLogin(userID, password, ip):
raise exceptions.loginFailedException(MODULE_NAME, username)
if userUtils.check2FA(userID, ip):
raise exceptions.need2FAException(MODULE_NAME, username, ip)
# Get user ID
if bool(s.mods & 128): # Relax
replayData = glob.db.fetch("SELECT scores_relax.*, users.username AS uname FROM scores_relax LEFT JOIN users ON scores_relax.userid = users.id WHERE scores_relax.id = %s", [replayID])
# Increment 'replays watched by others' if needed
if replayData is not None:
if username != replayData["uname"]:
userUtils.incrementReplaysWatched(replayData["userid"], replayData["play_mode"], s.mods)
else:
replayData = glob.db.fetch("SELECT scores.*, users.username AS uname FROM scores LEFT JOIN users ON scores.userid = users.id WHERE scores.id = %s", [replayID])
# Increment 'replays watched by others' if needed
if replayData is not None:
if username != replayData["uname"]:
userUtils.incrementReplaysWatched(replayData["userid"], replayData["play_mode"], s.mods)
log.info("Serving replay_{}.osr".format(replayID))
fileName = ".data/replays/replay_{}.osr".format(replayID)
if os.path.isfile(fileName):
with open(fileName, "rb") as f:
fileContent = f.read()
self.write(fileContent)
else:
self.write("")
log.warning("Replay {} doesn't exist.".format(replayID))
except exceptions.invalidArgumentsException:
pass
except exceptions.need2FAException:
pass
except exceptions.loginFailedException:
pass

View File

@ -0,0 +1,121 @@
import json
import tornado.gen
import tornado.web
from objects import beatmap
from objects import scoreboard
from objects import relaxboard
from common.constants import privileges
from common.log import logUtils as log
from common.ripple import userUtils
from common.web import requestsManager
from constants import exceptions
from objects import glob
from common.constants import mods
from common.sentry import sentry
MODULE_NAME = "get_scores"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /web/osu-osz2-getscores.php
"""
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncGet(self):
try:
# Get request ip
ip = self.getRequestIP()
# Print arguments
if glob.debug:
requestsManager.printArguments(self)
# TODO: Maintenance check
# Check required arguments
if not requestsManager.checkArguments(self.request.arguments, ["c", "f", "i", "m", "us", "v", "vv", "mods"]):
raise exceptions.invalidArgumentsException(MODULE_NAME)
# GET parameters
md5 = self.get_argument("c")
fileName = self.get_argument("f")
beatmapSetID = self.get_argument("i")
gameMode = self.get_argument("m")
username = self.get_argument("us")
password = self.get_argument("ha")
scoreboardType = int(self.get_argument("v"))
scoreboardVersion = int(self.get_argument("vv"))
# Login and ban check
userID = userUtils.getID(username)
if userID == 0:
raise exceptions.loginFailedException(MODULE_NAME, userID)
if not userUtils.checkLogin(userID, password, ip):
raise exceptions.loginFailedException(MODULE_NAME, username)
if userUtils.check2FA(userID, ip):
raise exceptions.need2FAException(MODULE_NAME, username, ip)
# Ban check is pointless here, since there's no message on the client
#if userHelper.isBanned(userID) == True:
# raise exceptions.userBannedException(MODULE_NAME, username)
# Hax check
if "a" in self.request.arguments:
if int(self.get_argument("a")) == 1 and not userUtils.getAqn(userID):
log.warning("Found AQN folder on user {} ({})".format(username, userID), "cm")
userUtils.setAqn(userID)
# Scoreboard type
isDonor = userUtils.getPrivileges(userID) & privileges.USER_DONOR > 0
country = False
friends = False
modsFilter = -1
mods = int(self.get_argument("mods"))
if scoreboardType == 4:
# Country leaderboard
country = True
elif scoreboardType == 2:
# Mods leaderboard, replace mods (-1, every mod) with "mods" GET parameters
modsFilter = int(self.get_argument("mods"))
elif scoreboardType == 3 and isDonor:
# Friends leaderboard
friends = True
# Console output
fileNameShort = fileName[:32]+"..." if len(fileName) > 32 else fileName[:-4]
if scoreboardType == 1 and int(self.get_argument("mods")) & 128:
log.info("[RELAX] Requested beatmap {} ({})".format(fileNameShort, md5))
else:
log.info("[VANILLA] Requested beatmap {} ({})".format(fileNameShort, md5))
# Create beatmap object and set its data
bmap = beatmap.beatmap(md5, beatmapSetID, gameMode)
if int(self.get_argument("mods")) & 128:
glob.redis.publish("peppy:update_rxcached_stats", userID)
else:
glob.redis.publish("peppy:update_cached_stats", userID)
if bool(mods & 128):
sboard = relaxboard.scoreboard(username, gameMode, bmap, setScores=True, country=country, mods=modsFilter, friends=friends)
else:
sboard = scoreboard.scoreboard(username, gameMode, bmap, setScores=True, country=country, mods=modsFilter, friends=friends)
# Data to return
data = ""
data += bmap.getData(sboard.totalScores, scoreboardVersion)
data += sboard.getScoresData()
self.write(data)
# Datadog stats
glob.dog.increment(glob.DATADOG_PREFIX+".served_leaderboards")
except exceptions.need2FAException:
self.write("error: 2fa")
except exceptions.invalidArgumentsException:
self.write("error: meme")
except exceptions.userBannedException:
self.write("error: ban")
except exceptions.loginFailedException:
self.write("error: pass")

View File

@ -0,0 +1,41 @@
import os
import sys
import traceback
import tornado.gen
import tornado.web
from raven.contrib.tornado import SentryMixin
from common.log import logUtils as log
from common.web import requestsManager
from constants import exceptions
from objects import glob
from common.sentry import sentry
MODULE_NAME = "get_screenshot"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /ss/
"""
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncGet(self, screenshotID = None):
try:
# Make sure the screenshot exists
if screenshotID is None or not os.path.isfile(".data/screenshots/{}".format(screenshotID)):
raise exceptions.fileNotFoundException(MODULE_NAME, screenshotID)
# Read screenshot
with open(".data/screenshots/{}".format(screenshotID), "rb") as f:
data = f.read()
# Output
log.info("Served screenshot {}".format(screenshotID))
# Display screenshot
self.write(data)
self.set_header("Content-type", "image/jpg")
self.set_header("Content-length", len(data))
except exceptions.fileNotFoundException:
self.set_status(404)

View File

@ -0,0 +1,18 @@
import tornado.gen
import tornado.web
from common.web import requestsManager
from objects import glob
class handler(requestsManager.asyncRequestHandler):
@tornado.web.asynchronous
@tornado.gen.engine
def asyncGet(self):
if not glob.debug:
self.write("Nope")
return
glob.db.fetchAll("SELECT SQL_NO_CACHE * FROM beatmaps")
glob.db.fetchAll("SELECT SQL_NO_CACHE * FROM users")
glob.db.fetchAll("SELECT SQL_NO_CACHE * FROM scores")
self.write("ibmd")

35
handlers/mapsHandler.py Normal file
View File

@ -0,0 +1,35 @@
import tornado.gen
import tornado.web
from common.log import logUtils as log
from common.web import requestsManager
from constants import exceptions
from helpers import osuapiHelper
from common.sentry import sentry
MODULE_NAME = "maps"
class handler(requestsManager.asyncRequestHandler):
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncGet(self, fileName = None):
try:
# Check arguments
if fileName is None:
raise exceptions.invalidArgumentsException(MODULE_NAME)
if fileName == "":
raise exceptions.invalidArgumentsException(MODULE_NAME)
fileNameShort = fileName[:32]+"..." if len(fileName) > 32 else fileName[:-4]
log.info("Requested .osu file {}".format(fileNameShort))
# Get .osu file from osu! server
fileContent = osuapiHelper.getOsuFileFromName(fileName)
if fileContent is None:
# TODO: Sentry capture message here
raise exceptions.osuApiFailException(MODULE_NAME)
self.write(fileContent)
except exceptions.invalidArgumentsException:
self.set_status(500)
except exceptions.osuApiFailException:
self.set_status(500)

View File

@ -0,0 +1,11 @@
import tornado.gen
import tornado.web
from common.web import requestsManager
class handler(requestsManager.asyncRequestHandler):
@tornado.web.asynchronous
@tornado.gen.engine
def asyncGet(self):
self.write("")

View File

@ -0,0 +1,58 @@
import tornado.gen
import tornado.web
from common.sentry import sentry
from common.web import requestsManager
from common.web import cheesegull
from constants import exceptions
from common.log import logUtils as log
MODULE_NAME = "direct"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /web/osu-search.php
"""
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncGet(self):
output = ""
try:
try:
# Get arguments
gameMode = self.get_argument("m", None)
if gameMode is not None:
gameMode = int(gameMode)
if gameMode < 0 or gameMode > 3:
gameMode = None
rankedStatus = self.get_argument("r", None)
if rankedStatus is not None:
rankedStatus = int(rankedStatus)
query = self.get_argument("q", "")
page = int(self.get_argument("p", "0"))
if query.lower() in ["newest", "top rated", "most played"]:
query = ""
except ValueError:
raise exceptions.invalidArgumentsException(MODULE_NAME)
# Get data from cheesegull API
log.info("Requested osu!direct search: {}".format(query if query != "" else "index"))
searchData = cheesegull.getListing(rankedStatus=cheesegull.directToApiStatus(rankedStatus), page=page * 100, gameMode=gameMode, query=query)
if searchData is None or searchData is None:
raise exceptions.noAPIDataError()
# Write output
output += "999" if len(searchData) == 100 else str(len(searchData))
output += "\n"
for beatmapSet in searchData:
try:
output += cheesegull.toDirect(beatmapSet) + "\r\n"
except ValueError:
# Invalid cheesegull beatmap (empty beatmapset, cheesegull bug? See Sentry #LETS-00-32)
pass
except (exceptions.noAPIDataError, exceptions.invalidArgumentsException):
output = "0\n"
finally:
self.write(output)

View File

@ -0,0 +1,42 @@
import tornado.gen
import tornado.web
from common.sentry import sentry
from common.web import requestsManager
from common.web import cheesegull
from common.log import logUtils as log
from constants import exceptions
MODULE_NAME = "direct_np"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /web/osu-search-set.php
"""
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncGet(self):
output = ""
try:
# Get data by beatmap id or beatmapset id
if "b" in self.request.arguments:
_id = self.get_argument("b")
data = cheesegull.getBeatmap(_id)
elif "s" in self.request.arguments:
_id = self.get_argument("s")
data = cheesegull.getBeatmapSet(_id)
else:
raise exceptions.invalidArgumentsException(MODULE_NAME)
log.info("Requested osu!direct np: {}/{}".format("b" if "b" in self.request.arguments else "s", _id))
# Make sure cheesegull returned some valid data
if data is None or len(data) == 0:
raise exceptions.osuApiFailException(MODULE_NAME)
# Write the response
output = cheesegull.toDirectNp(data) + "\r\n"
except (exceptions.invalidArgumentsException, exceptions.osuApiFailException, KeyError):
output = ""
finally:
self.write(output)

View File

@ -0,0 +1,14 @@
import tornado.web
import tornado.gen
from common.web import requestsManager
class handler(requestsManager.asyncRequestHandler):
def initialize(self, destination):
self.destination = destination
@tornado.web.asynchronous
@tornado.gen.engine
def asyncGet(self, args=()):
self.set_status(302)
self.add_header("location", self.destination.format(args))

View File

@ -0,0 +1,513 @@
import base64
import collections
import json
import sys
import threading
import traceback
from urllib.parse import urlencode
import requests
import tornado.gen
import tornado.web
import math
import secret.achievements.utils
from common.constants import gameModes
from common.constants import mods
from common.log import logUtils as log
from common.ripple import userUtils
from common.ripple import scoreUtils
from common.web import requestsManager
from constants import exceptions
from constants import rankedStatuses
from constants.exceptions import ppCalcException
from helpers import aeshelper
from helpers import replayHelper
from helpers import leaderboardHelper
from objects import beatmap
from objects import glob
from objects import score
from objects import scoreboard
from objects import relaxboard
from objects import rxscore
from common import generalUtils
MODULE_NAME = "submit_modular"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /web/osu-submit-modular.php
"""
@tornado.web.asynchronous
@tornado.gen.engine
#@sentry.captureTornado
def asyncPost(self):
try:
# Resend the score in case of unhandled exceptions
keepSending = True
# Get request ip
ip = self.getRequestIP()
# Print arguments
if glob.debug:
requestsManager.printArguments(self)
# Check arguments
if not requestsManager.checkArguments(self.request.arguments, ["score", "iv", "pass"]):
raise exceptions.invalidArgumentsException(MODULE_NAME)
# TODO: Maintenance check
# Get parameters and IP
scoreDataEnc = self.get_argument("score")
iv = self.get_argument("iv")
password = self.get_argument("pass")
ip = self.getRequestIP()
# Get bmk and bml (notepad hack check)
if "bmk" in self.request.arguments and "bml" in self.request.arguments:
bmk = self.get_argument("bmk")
bml = self.get_argument("bml")
else:
bmk = None
bml = None
# Get right AES Key
if "osuver" in self.request.arguments:
aeskey = "osu!-scoreburgr---------{}".format(self.get_argument("osuver"))
else:
aeskey = "h89f2-890h2h89b34g-h80g134n90133"
# Get score data
log.debug("Decrypting score data...")
scoreData = aeshelper.decryptRinjdael(aeskey, iv, scoreDataEnc, True).split(":")
username = scoreData[1].strip()
# Login and ban check
userID = userUtils.getID(username)
# User exists check
if userID == 0:
raise exceptions.loginFailedException(MODULE_NAME, userID)
# Bancho session/username-pass combo check
if not userUtils.checkLogin(userID, password, ip):
raise exceptions.loginFailedException(MODULE_NAME, username)
# 2FA Check
if userUtils.check2FA(userID, ip):
raise exceptions.need2FAException(MODULE_NAME, userID, ip)
# Generic bancho session check
#if not userUtils.checkBanchoSession(userID):
# TODO: Ban (see except exceptions.noBanchoSessionException block)
# raise exceptions.noBanchoSessionException(MODULE_NAME, username, ip)
# Ban check
if userUtils.isBanned(userID):
raise exceptions.userBannedException(MODULE_NAME, username)
# Data length check
if len(scoreData) < 16:
raise exceptions.invalidArgumentsException(MODULE_NAME)
# Get restricted
restricted = userUtils.isRestricted(userID)
# Get variables for relax
used_mods = int(scoreData[13])
isRelaxing = used_mods & 128
# Create score object and set its data
log.info("[{}] {} has submitted a score on {}...".format("RELAX" if isRelaxing else "VANILLA", username, scoreData[0]))
s = rxscore.score() if isRelaxing else score.score()
s.setDataFromScoreData(scoreData)
if s.completed == -1:
# Duplicated score
log.warning("Duplicated score detected, this is normal right after restarting the server")
return
# Set score stuff missing in score data
s.playerUserID = userID
# Get beatmap info
beatmapInfo = beatmap.beatmap()
beatmapInfo.setDataFromDB(s.fileMd5)
# Make sure the beatmap is submitted and updated
if beatmapInfo.rankedStatus == rankedStatuses.NOT_SUBMITTED or beatmapInfo.rankedStatus == rankedStatuses.NEED_UPDATE or beatmapInfo.rankedStatus == rankedStatuses.UNKNOWN:
log.debug("Beatmap is not submitted/outdated/unknown. Score submission aborted.")
return
# increment user playtime
length = 0
if s.passed:
length = userUtils.getBeatmapTime(beatmapInfo.beatmapID)
else:
length = math.ceil(int(self.get_argument("ft")) / 1000)
userUtils.incrementPlaytime(userID, s.gameMode, length)
# Calculate PP
midPPCalcException = None
try:
s.calculatePP()
except Exception as e:
# Intercept ALL exceptions and bypass them.
# We want to save scores even in case PP calc fails
# due to some rippoppai bugs.
# I know this is bad, but who cares since I'll rewrite
# the scores server again.
log.error("Caught an exception in pp calculation, re-raising after saving score in db")
s.pp = 0
midPPCalcException = e
# Restrict obvious cheaters™
if restricted == False:
if isRelaxing: # Relax
rxGods = [7340, 2137, 6868, 1215, 15066, 14522, 1325, 5798, 21610, 1254] # Yea yea it's a bad way of doing it, kill yourself - cmyui osu gaming
"""
CTBLIST = []
TAIKOLIST = []
"""
if (s.pp >= 2000 and s.gameMode == gameModes.STD) and userID not in rxGods:
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain **({}pp)**".format(username, userID, s.pp), "cm")
"""
elif (s.pp >= 10000 and s.gameMode == gameModes.TAIKO) and userID not in TAIKOLIST:
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain **({}pp)**".format(username, userID, s.pp), "cm")
elif s.pp >= 10000 and (s.gameMode == gameModes.CTB) and userID not in CTBLIST:
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain **({}pp)**".format(username, userID, s.pp), "cm")
"""
else: # Vanilla
if (s.pp >= 700 and s.gameMode == gameModes.STD):
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain **({}pp)**".format(username, userID, s.pp), "cm")
"""
elif (s.pp >= 10000 and s.gameMode == gameModes.TAIKO):
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain **({}pp)**".format(username, userID, s.pp), "cm")
elif s.pp >= 10000 and (s.gameMode == gameModes.CTB):
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain **({}pp)**".format(username, userID, s.pp), "cm")
"""
# Check notepad hack
if bmk is None and bml is None:
# No bmk and bml params passed, edited or super old client
#log.warning("{} ({}) most likely submitted a score from an edited client or a super old client".format(username, userID), "cm")
pass
elif bmk != bml and not restricted:
# bmk and bml passed and they are different, restrict the user
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to notepad hack")
log.warning("**{}** ({}) has been restricted due to notepad hack".format(username, userID), "cm")
return
# Save score in db
s.saveScoreInDB()
# Client anti-cheat flags
haxFlags = scoreData[17].count(' ') # 4 is normal, 0 is irregular but inconsistent.
if haxFlags != 4 and haxFlags != 0 and s.completed > 1 and restricted == False:
flagsReadable = generalUtils.calculateFlags(haxFlags, used_mods, s.gameMode)
userUtils.appendNotes(userID, "-- has received clientside flags: {} [{}] (cheated score id: {})".format(haxFlags, flagsReadable, s.scoreID))
log.warning("**{}** ({}) has received clientside anti cheat flags.\n\nFlags: {}.\n[{}]\n\nScore ID: {scoreID}\nReplay: https://akatsuki.pw/web/replays/{scoreID}".format(username, userID, haxFlags, flagsReadable, scoreID=s.scoreID), "cm")
if s.score < 0 or s.score > (2 ** 63) - 1:
userUtils.ban(userID)
userUtils.appendNotes(userID, "Banned due to negative score.")
# Make sure the score is not memed
if s.gameMode == gameModes.MANIA and s.score > 1000000:
userUtils.ban(userID)
userUtils.appendNotes(userID, "Banned due to mania score > 1000000.")
# Ci metto la faccia, ci metto la testa e ci metto il mio cuore
if ((s.mods & mods.DOUBLETIME) > 0 and (s.mods & mods.HALFTIME) > 0) \
or ((s.mods & mods.HARDROCK) > 0 and (s.mods & mods.EASY) > 0) \
or ((s.mods & mods.SUDDENDEATH) > 0 and (s.mods & mods.NOFAIL) > 0)\
or ((s.mods & mods.RELAX) > 0 and (s.mods & mods.RELAX2) > 0):
userUtils.ban(userID)
userUtils.appendNotes(userID, "Impossible mod combination ({}).".format(s.mods))
# NOTE: Process logging was removed from the client starting from 20180322
# Save replay for all passed scores
# Make sure the score has an id as well (duplicated?, query error?)
if s.passed and s.scoreID > 0:
if "score" in self.request.files:
# Save the replay if it was provided
log.debug("Saving replay ({})...".format(s.scoreID))
replay = self.request.files["score"][0]["body"]
with open(".data/replays/replay_{}.osr".format(s.scoreID), "wb") as f:
f.write(replay)
# Send to cono ALL passed replays, even non high-scores
if glob.conf.config["cono"]["enable"]:
if isRelaxing:
threading.Thread(target=lambda: glob.redis.publish(
"cono:analyze", json.dumps({
"score_id": s.scoreID,
"beatmap_id": beatmapInfo.beatmapID,
"user_id": s.playerUserID,
"game_mode": s.gameMode,
"pp": s.pp,
"replay_data": base64.b64encode(
replayHelper.rxbuildFullReplay(
s.scoreID,
rawReplay=self.request.files["score"][0]["body"]
)
).decode(),
})
)).start()
else:
# We run this in a separate thread to avoid slowing down scores submission,
# as cono needs a full replay
threading.Thread(target=lambda: glob.redis.publish(
"cono:analyze", json.dumps({
"score_id": s.scoreID,
"beatmap_id": beatmapInfo.beatmapID,
"user_id": s.playerUserID,
"game_mode": s.gameMode,
"pp": s.pp,
"replay_data": base64.b64encode(
replayHelper.buildFullReplay(
s.scoreID,
rawReplay=self.request.files["score"][0]["body"]
)
).decode(),
})
)).start()
else:
# Restrict if no replay was provided
if not restricted:
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to missing replay while submitting a score.")
log.warning("**{}** ({}) has been restricted due to not submitting a replay on map {}.".format(
username, userID, s.fileMd5
), "cm")
# Update beatmap playcount (and passcount)
beatmap.incrementPlaycount(s.fileMd5, s.passed)
# Let the api know of this score
if s.scoreID:
glob.redis.publish("api:score_submission", s.scoreID)
# Re-raise pp calc exception after saving score, cake, replay etc
# so Sentry can track it without breaking score submission
if midPPCalcException is not None:
raise ppCalcException(midPPCalcException)
# If there was no exception, update stats and build score submitted panel
# Get "before" stats for ranking panel (only if passed)
if s.passed:
# Get stats and rank
if isRelaxing:
oldUserData = glob.userStatsCache.rxget(userID, s.gameMode)
oldRank = userUtils.rxgetGameRank(userID, s.gameMode)
else:
oldUserData = glob.userStatsCache.get(userID, s.gameMode)
oldRank = userUtils.getGameRank(userID, s.gameMode)
# Try to get oldPersonalBestRank from cache
oldPersonalBestRank = glob.personalBestCache.get(userID, s.fileMd5)
if oldPersonalBestRank == 0:
# oldPersonalBestRank not found in cache, get it from db
if isRelaxing:
oldScoreboard = relaxboard.scoreboard(username, s.gameMode, beatmapInfo, False)
else:
oldScoreboard = scoreboard.scoreboard(username, s.gameMode, beatmapInfo, False)
oldScoreboard.setPersonalBest()
oldPersonalBestRank = oldScoreboard.personalBestRank if oldScoreboard.personalBestRank > 0 else 0
# Always update users stats (total/ranked score, playcount, level, acc and pp)
# even if not passed
log.debug("[{}] Updating {}'s stats...".format("RELAX" if isRelaxing else "VANILLA", username))
if isRelaxing:
userUtils.rxupdateStats(userID, s)
else:
userUtils.updateStats(userID, s)
# Get "after" stats for ranking panel
# and to determine if we should update the leaderboard
# (only if we passed that song)
if s.passed:
# Get new stats
if isRelaxing:
newUserData = userUtils.getRelaxStats(userID, s.gameMode)
glob.userStatsCache.rxupdate(userID, s.gameMode, newUserData)
else:
newUserData = userUtils.getUserStats(userID, s.gameMode)
glob.userStatsCache.update(userID, s.gameMode, newUserData)
# Update leaderboard (global and country) if score/pp has changed
if s.completed == 3 and newUserData["pp"] != oldUserData["pp"]:
if isRelaxing:
leaderboardHelper.rxupdate(userID, newUserData["pp"], s.gameMode)
leaderboardHelper.rxupdateCountry(userID, newUserData["pp"], s.gameMode)
else:
leaderboardHelper.update(userID, newUserData["pp"], s.gameMode)
leaderboardHelper.updateCountry(userID, newUserData["pp"], s.gameMode)
# TODO: Update total hits and max combo
# Update latest activity
userUtils.updateLatestActivity(userID)
# IP log
userUtils.IPLog(userID, ip)
# Score submission and stats update done
log.debug("Score submission and user stats update done!")
# Score has been submitted, do not retry sending the score if
# there are exceptions while building the ranking panel
keepSending = False
# At the end, check achievements
if s.passed:
new_achievements = secret.achievements.utils.unlock_achievements(s, beatmapInfo, newUserData)
# Output ranking panel only if we passed the song
# and we got valid beatmap info from db
if beatmapInfo is not None and beatmapInfo != False and s.passed:
log.debug("Started building ranking panel.")
if isRelaxing: # Relax
# Trigger bancho stats cache update
glob.redis.publish("peppy:update_rxcached_stats", userID)
# Get personal best after submitting the score
newScoreboard = relaxboard.scoreboard(username, s.gameMode, beatmapInfo, True)
# Get rank info (current rank, pp/score to next rank, user who is 1 rank above us)
rankInfo = leaderboardHelper.rxgetRankInfo(userID, s.gameMode)
else: # Vanilla
# Trigger bancho stats cache update
glob.redis.publish("peppy:update_cached_stats", userID)
# Get personal best after submitting the score
newScoreboard = scoreboard.scoreboard(username, s.gameMode, beatmapInfo, True)
# Get rank info (current rank, pp/score to next rank, user who is 1 rank above us)
rankInfo = leaderboardHelper.getRankInfo(userID, s.gameMode)
# Output dictionary
output = collections.OrderedDict()
output["beatmapId"] = beatmapInfo.beatmapID
output["beatmapSetId"] = beatmapInfo.beatmapSetID
output["beatmapPlaycount"] = beatmapInfo.playcount
output["beatmapPasscount"] = beatmapInfo.passcount
#output["approvedDate"] = "2015-07-09 23:20:14\n"
output["approvedDate"] = "\n"
output["chartId"] = "overall"
output["chartName"] = "Overall Ranking"
output["chartEndDate"] = ""
output["beatmapRankingBefore"] = oldPersonalBestRank
output["beatmapRankingAfter"] = newScoreboard.personalBestRank
output["rankedScoreBefore"] = oldUserData["rankedScore"]
output["rankedScoreAfter"] = newUserData["rankedScore"]
output["totalScoreBefore"] = oldUserData["totalScore"]
output["totalScoreAfter"] = newUserData["totalScore"]
output["playCountBefore"] = newUserData["playcount"]
output["accuracyBefore"] = float(oldUserData["accuracy"])/100
output["accuracyAfter"] = float(newUserData["accuracy"])/100
output["rankBefore"] = oldRank
output["rankAfter"] = rankInfo["currentRank"]
output["toNextRank"] = rankInfo["difference"]
output["toNextRankUser"] = rankInfo["nextUsername"]
output["achievements"] = ""
output["achievements-new"] = secret.achievements.utils.achievements_response(new_achievements)
output["onlineScoreId"] = s.scoreID
# Build final string
msg = ""
for line, val in output.items():
msg += "{}:{}".format(line, val)
if val != "\n":
if (len(output) - 1) != list(output.keys()).index(line):
msg += "|"
else:
msg += "\n"
# Some debug messages
log.debug("Generated output for online ranking screen!")
log.debug(msg)
# Send message to #announce if we're rank #1
if newScoreboard.personalBestRank == 1 and s.completed == 3 and not restricted:
annmsg = "[{}] [https://akatsuki.pw/u/{} {}] achieved rank #1 on [https://osu.ppy.sh/b/{} {}] ({})".format(
"RELAX" if isRelaxing else "VANILLA",
userID,
username.encode().decode("ASCII", "ignore"),
beatmapInfo.beatmapID,
beatmapInfo.songName.encode().decode("ASCII", "ignore"),
gameModes.getGamemodeFull(s.gameMode)
)
params = urlencode({"k": glob.conf.config["server"]["apikey"], "to": "#announce", "msg": annmsg})
requests.get("{}/api/v1/fokabotMessage?{}".format(glob.conf.config["server"]["banchourl"], params))
scoreUtils.newFirst(userID, s.scoreID, s.fileMd5, s.gameMode, isRelaxing)
# Write message to client
self.write(msg)
else:
# No ranking panel, send just "ok"
self.write("ok")
# Send username change request to bancho if needed
# (key is deleted bancho-side)
newUsername = glob.redis.get("ripple:change_username_pending:{}".format(userID))
if newUsername is not None:
log.debug("Sending username change request for user {} to Bancho".format(userID))
glob.redis.publish("peppy:change_username", json.dumps({
"userID": userID,
"newUsername": newUsername.decode("utf-8")
}))
# Datadog stats
glob.dog.increment(glob.DATADOG_PREFIX+".submitted_scores")
except exceptions.invalidArgumentsException:
pass
except exceptions.loginFailedException:
self.write("error: pass")
except exceptions.need2FAException:
# Send error pass to notify the user
# resend the score at regular intervals
# for users with memy connection
self.set_status(408)
self.write("error: 2fa")
except exceptions.userBannedException:
self.write("error: ban")
except exceptions.noBanchoSessionException:
# We don't have an active bancho session.
# Don't ban the user but tell the client to send the score again.
# Once we are sure that this error doesn't get triggered when it
# shouldn't (eg: bancho restart), we'll ban users that submit
# scores without an active bancho session.
# We only log through schiavo atm (see exceptions.py).
self.set_status(408)
self.write("error: pass")
except:
# Try except block to avoid more errors
try:
log.error("Unknown error in {}!\n```{}\n{}```".format(MODULE_NAME, sys.exc_info(), traceback.format_exc()))
if glob.sentry:
yield tornado.gen.Task(self.captureException, exc_info=True)
except:
pass
# Every other exception returns a 408 error (timeout)
# This avoids lost scores due to score server crash
# because the client will send the score again after some time.
if keepSending:
self.set_status(408)

View File

@ -0,0 +1,74 @@
import os
import sys
import traceback
import tornado.gen
import tornado.web
from raven.contrib.tornado import SentryMixin
from common.log import logUtils as log
from common.ripple import userUtils
from common.web import requestsManager
from constants import exceptions
from common import generalUtils
from objects import glob
from common.sentry import sentry
MODULE_NAME = "screenshot"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /web/osu-screenshot.php
"""
@tornado.web.asynchronous
@tornado.gen.engine
@sentry.captureTornado
def asyncPost(self):
try:
if glob.debug:
requestsManager.printArguments(self)
# Make sure screenshot file was passed
if "ss" not in self.request.files:
raise exceptions.invalidArgumentsException(MODULE_NAME)
# Check user auth because of sneaky people
if not requestsManager.checkArguments(self.request.arguments, ["u", "p"]):
raise exceptions.invalidArgumentsException(MODULE_NAME)
username = self.get_argument("u")
password = self.get_argument("p")
ip = self.getRequestIP()
userID = userUtils.getID(username)
if not userUtils.checkLogin(userID, password):
raise exceptions.loginFailedException(MODULE_NAME, username)
if userUtils.check2FA(userID, ip):
raise exceptions.need2FAException(MODULE_NAME, username, ip)
# Rate limit
if glob.redis.get("lets:screenshot:{}".format(userID)) is not None:
self.write("no")
return
glob.redis.set("lets:screenshot:{}".format(userID), 1, 60)
# Get a random screenshot id
found = False
screenshotID = ""
while not found:
screenshotID = generalUtils.randomString(8)
if not os.path.isfile(".data/screenshots/{}.jpg".format(screenshotID)):
found = True
# Write screenshot file to .data folder
with open(".data/screenshots/{}.jpg".format(screenshotID), "wb") as f:
f.write(self.request.files["ss"][0]["body"])
# Output
log.info("New screenshot ({})".format(screenshotID))
# Return screenshot link
self.write("{}/ss/{}.jpg".format(glob.conf.config["server"]["servername"], screenshotID))
except exceptions.need2FAException:
pass
except exceptions.invalidArgumentsException:
pass
except exceptions.loginFailedException:
pass

0
helpers/__init__.py Normal file
View File

449
helpers/aeshelper.py Normal file
View File

@ -0,0 +1,449 @@
"""
A pure python (slow) implementation of rijndael with a decent interface
To include -
from rijndael import rijndael
To do a key setup -
r = rijndael(key, block_size = 16)
key must be a string of length 16, 24, or 32
blocksize must be 16, 24, or 32. Default is 16
To use -
ciphertext = r.encrypt(plaintext)
plaintext = r.decrypt(ciphertext)
If any strings are of the wrong length a ValueError is thrown
"""
# ported from the Java reference code by Bram Cohen, April 2001
# this code is public domain, unless someone makes
# an intellectual property claim against the reference
# code, in which case it can be made public domain by
# deleting all the comments and renaming all the variables
import copy
import base64
shifts = [[[0, 0], [1, 3], [2, 2], [3, 1]],
[[0, 0], [1, 5], [2, 4], [3, 3]],
[[0, 0], [1, 7], [3, 5], [4, 4]]]
# [keysize][block_size]
num_rounds = {16: {16: 10, 24: 12, 32: 14}, 24: {16: 12, 24: 12, 32: 14}, 32: {16: 14, 24: 14, 32: 14}}
A = [[1, 1, 1, 1, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 1, 1, 1, 1],
[1, 1, 0, 0, 0, 1, 1, 1],
[1, 1, 1, 0, 0, 0, 1, 1],
[1, 1, 1, 1, 0, 0, 0, 1]]
# produce log and alog tables, needed for multiplying in the
# field GF(2^m) (generator = 3)
alog = [1]
for i in range(255):
j = (alog[-1] << 1) ^ alog[-1]
if j & 0x100 != 0:
j ^= 0x11B
alog.append(j)
log = [0] * 256
for i in range(1, 255):
log[alog[i]] = i
# multiply two elements of GF(2^m)
def mul(a, b):
if a == 0 or b == 0:
return 0
return alog[(log[a & 0xFF] + log[b & 0xFF]) % 255]
# substitution box based on F^{-1}(x)
box = [[0] * 8 for i in range(256)]
box[1][7] = 1
for i in range(2, 256):
j = alog[255 - log[i]]
for t in range(8):
box[i][t] = (j >> (7 - t)) & 0x01
B = [0, 1, 1, 0, 0, 0, 1, 1]
# affine transform: box[i] <- B + A*box[i]
cox = [[0] * 8 for i in range(256)]
for i in range(256):
for t in range(8):
cox[i][t] = B[t]
for j in range(8):
cox[i][t] ^= A[t][j] * box[i][j]
# S-boxes and inverse S-boxes
S = [0] * 256
Si = [0] * 256
for i in range(256):
S[i] = cox[i][0] << 7
for t in range(1, 8):
S[i] ^= cox[i][t] << (7-t)
Si[S[i] & 0xFF] = i
# T-boxes
G = [[2, 1, 1, 3],
[3, 2, 1, 1],
[1, 3, 2, 1],
[1, 1, 3, 2]]
AA = [[0] * 8 for i in range(4)]
for i in range(4):
for j in range(4):
AA[i][j] = G[i][j]
AA[i][i+4] = 1
for i in range(4):
pivot = AA[i][i]
if pivot == 0:
t = i + 1
while AA[t][i] == 0 and t < 4:
t += 1
assert t != 4, 'G matrix must be invertible'
for j in range(8):
AA[i][j], AA[t][j] = AA[t][j], AA[i][j]
pivot = AA[i][i]
for j in range(8):
if AA[i][j] != 0:
AA[i][j] = alog[(255 + log[AA[i][j] & 0xFF] - log[pivot & 0xFF]) % 255]
for t in range(4):
if i != t:
for j in range(i+1, 8):
AA[t][j] ^= mul(AA[i][j], AA[t][i])
AA[t][i] = 0
iG = [[0] * 4 for i in range(4)]
for i in range(4):
for j in range(4):
iG[i][j] = AA[i][j + 4]
def mul4(a, bs):
if a == 0:
return 0
r = 0
for b in bs:
r <<= 8
if b != 0:
r |= mul(a, b)
return r
T1 = []
T2 = []
T3 = []
T4 = []
T5 = []
T6 = []
T7 = []
T8 = []
U1 = []
U2 = []
U3 = []
U4 = []
for t in range(256):
s = S[t]
T1.append(mul4(s, G[0]))
T2.append(mul4(s, G[1]))
T3.append(mul4(s, G[2]))
T4.append(mul4(s, G[3]))
s = Si[t]
T5.append(mul4(s, iG[0]))
T6.append(mul4(s, iG[1]))
T7.append(mul4(s, iG[2]))
T8.append(mul4(s, iG[3]))
U1.append(mul4(t, iG[0]))
U2.append(mul4(t, iG[1]))
U3.append(mul4(t, iG[2]))
U4.append(mul4(t, iG[3]))
# round constants
rcon = [1]
r = 1
for t in range(1, 30):
r = mul(2, r)
rcon.append(r)
del A
del AA
del pivot
del B
del G
del box
del log
del alog
del i
del j
del r
del s
del t
del mul
del mul4
del cox
del iG
class rijndael:
def __init__(self, key, block_size = 16):
if block_size != 16 and block_size != 24 and block_size != 32:
raise ValueError('Invalid block size: ' + str(block_size))
if len(key) != 16 and len(key) != 24 and len(key) != 32:
raise ValueError('Invalid key size: ' + str(len(key)))
self.block_size = block_size
ROUNDS = num_rounds[len(key)][block_size]
BC = block_size // 4
# encryption round keys
Ke = [[0] * BC for i in range(ROUNDS + 1)]
# decryption round keys
Kd = [[0] * BC for i in range(ROUNDS + 1)]
ROUND_KEY_COUNT = (ROUNDS + 1) * BC
KC = len(key) // 4
# copy user material bytes into temporary ints
tk = []
for i in range(0, KC):
tk.append((ord(key[i * 4]) << 24) | (ord(key[i * 4 + 1]) << 16) |
(ord(key[i * 4 + 2]) << 8) | ord(key[i * 4 + 3]))
# copy values into round key arrays
t = 0
j = 0
while j < KC and t < ROUND_KEY_COUNT:
Ke[t // BC][t % BC] = tk[j]
Kd[ROUNDS - (t // BC)][t % BC] = tk[j]
j += 1
t += 1
tt = 0
rconpointer = 0
while t < ROUND_KEY_COUNT:
# extrapolate using phi (the round key evolution function)
tt = tk[KC - 1]
tk[0] ^= (S[(tt >> 16) & 0xFF] & 0xFF) << 24 ^ \
(S[(tt >> 8) & 0xFF] & 0xFF) << 16 ^ \
(S[ tt & 0xFF] & 0xFF) << 8 ^ \
(S[(tt >> 24) & 0xFF] & 0xFF) ^ \
(rcon[rconpointer] & 0xFF) << 24
rconpointer += 1
if KC != 8:
for i in range(1, KC):
tk[i] ^= tk[i-1]
else:
for i in range(1, KC // 2):
tk[i] ^= tk[i-1]
tt = tk[KC // 2 - 1]
tk[KC // 2] ^= (S[ tt & 0xFF] & 0xFF) ^ \
(S[(tt >> 8) & 0xFF] & 0xFF) << 8 ^ \
(S[(tt >> 16) & 0xFF] & 0xFF) << 16 ^ \
(S[(tt >> 24) & 0xFF] & 0xFF) << 24
for i in range(KC // 2 + 1, KC):
tk[i] ^= tk[i-1]
# copy values into round key arrays
j = 0
while j < KC and t < ROUND_KEY_COUNT:
Ke[t // BC][t % BC] = tk[j]
Kd[ROUNDS - (t // BC)][t % BC] = tk[j]
j += 1
t += 1
# inverse MixColumn where needed
for r in range(1, ROUNDS):
for j in range(BC):
tt = Kd[r][j]
Kd[r][j] = U1[(tt >> 24) & 0xFF] ^ \
U2[(tt >> 16) & 0xFF] ^ \
U3[(tt >> 8) & 0xFF] ^ \
U4[ tt & 0xFF]
self.Ke = Ke
self.Kd = Kd
def encrypt(self, plaintext):
if len(plaintext) != self.block_size:
raise ValueError('wrong block length, expected ' + str(self.block_size) + ' got ' + str(len(plaintext)))
Ke = self.Ke
BC = self.block_size // 4
ROUNDS = len(Ke) - 1
if BC == 4:
SC = 0
elif BC == 6:
SC = 1
else:
SC = 2
s1 = shifts[SC][1][0]
s2 = shifts[SC][2][0]
s3 = shifts[SC][3][0]
a = [0] * BC
# temporary work array
t = []
# plaintext to ints + key
for i in range(BC):
t.append((ord(plaintext[i * 4 ]) << 24 |
ord(plaintext[i * 4 + 1]) << 16 |
ord(plaintext[i * 4 + 2]) << 8 |
ord(plaintext[i * 4 + 3]) ) ^ Ke[0][i])
# apply round transforms
for r in range(1, ROUNDS):
for i in range(BC):
a[i] = (T1[(t[ i ] >> 24) & 0xFF] ^
T2[(t[(i + s1) % BC] >> 16) & 0xFF] ^
T3[(t[(i + s2) % BC] >> 8) & 0xFF] ^
T4[ t[(i + s3) % BC] & 0xFF] ) ^ Ke[r][i]
t = copy.copy(a)
# last round is special
result = []
for i in range(BC):
tt = Ke[ROUNDS][i]
result.append((S[(t[ i ] >> 24) & 0xFF] ^ (tt >> 24)) & 0xFF)
result.append((S[(t[(i + s1) % BC] >> 16) & 0xFF] ^ (tt >> 16)) & 0xFF)
result.append((S[(t[(i + s2) % BC] >> 8) & 0xFF] ^ (tt >> 8)) & 0xFF)
result.append((S[ t[(i + s3) % BC] & 0xFF] ^ tt ) & 0xFF)
return ''.join(map(chr, result))
def decrypt(self, ciphertext):
if len(ciphertext) != self.block_size:
raise ValueError('wrong block length, expected ' + str(self.block_size) + ' got ' + str(len(ciphertext)))
Kd = self.Kd
BC = self.block_size // 4
ROUNDS = len(Kd) - 1
if BC == 4:
SC = 0
elif BC == 6:
SC = 1
else:
SC = 2
s1 = shifts[SC][1][1]
s2 = shifts[SC][2][1]
s3 = shifts[SC][3][1]
a = [0] * BC
# temporary work array
t = [0] * BC
# ciphertext to ints + key
for i in range(BC):
t[i] = (ord(ciphertext[i * 4 ]) << 24 |
ord(ciphertext[i * 4 + 1]) << 16 |
ord(ciphertext[i * 4 + 2]) << 8 |
ord(ciphertext[i * 4 + 3]) ) ^ Kd[0][i]
# apply round transforms
for r in range(1, ROUNDS):
for i in range(BC):
a[i] = (T5[(t[ i ] >> 24) & 0xFF] ^
T6[(t[(i + s1) % BC] >> 16) & 0xFF] ^
T7[(t[(i + s2) % BC] >> 8) & 0xFF] ^
T8[ t[(i + s3) % BC] & 0xFF] ) ^ Kd[r][i]
t = copy.copy(a)
# last round is special
result = []
for i in range(BC):
tt = Kd[ROUNDS][i]
result.append((Si[(t[ i ] >> 24) & 0xFF] ^ (tt >> 24)) & 0xFF)
result.append((Si[(t[(i + s1) % BC] >> 16) & 0xFF] ^ (tt >> 16)) & 0xFF)
result.append((Si[(t[(i + s2) % BC] >> 8) & 0xFF] ^ (tt >> 8)) & 0xFF)
result.append((Si[ t[(i + s3) % BC] & 0xFF] ^ tt ) & 0xFF)
return ''.join(map(chr, result))
def encrypt(key, block):
return rijndael(key, len(block)).encrypt(block)
def decrypt(key, block):
return rijndael(key, len(block)).decrypt(block)
class zeropad:
def __init__(self, block_size):
assert 0 < block_size < 256
self.block_size = block_size
def pad(self, pt):
ptlen = len(pt)
padsize = self.block_size - ((ptlen + self.block_size - 1) % self.block_size + 1)
return pt + "\0" * padsize
def unpad(self, ppt):
assert len(ppt) % self.block_size == 0
offset = len(ppt)
if offset == 0:
return ''
end = offset - self.block_size + 1
while offset > end:
offset -= 1
if ppt[offset] != "\0":
return ppt[:offset + 1]
assert False
class cbc:
def __init__(self, padding, cipher, iv):
assert padding.block_size == cipher.block_size
assert len(iv) == cipher.block_size
self.padding = padding
self.cipher = cipher
self.iv = iv
def encrypt(self, pt):
ppt = self.padding.pad(pt)
offset = 0
ct = ''
v = self.iv
while offset < len(ppt):
block = ppt[offset:offset + self.cipher.block_size]
block = self.xorblock(block, v)
block = self.cipher.encrypt(block)
ct += block
offset += self.cipher.block_size
v = block
return ct
def decrypt(self, ct):
assert len(ct) % self.cipher.block_size == 0
ppt = ''
offset = 0
v = self.iv
while offset < len(ct):
block = ct[offset:offset + self.cipher.block_size]
decrypted = self.cipher.decrypt(block)
ppt += self.xorblock(decrypted, v)
offset += self.cipher.block_size
v = block
pt = self.padding.unpad(ppt)
return pt
def xorblock(self, b1, b2):
# sorry, not very Pythonesk
i = 0
r = ''
while i < self.cipher.block_size:
r += chr(ord(b1[i]) ^ ord(b2[i]))
i += 1
return r
def decryptRinjdael(key, iv, data, areBase64 = False):
"""
Where the magic happens
key -- AES key (string)
IV -- IV thing (string)
data -- data to decrypt (string)
areBase64 -- if True, iv and data are passed in base64
"""
if areBase64:
iv = base64.b64decode(iv).decode("latin_1")
data = base64.b64decode(data).decode("latin_1")
r = rijndael(key, 32)
p = zeropad(32)
c = cbc(p, r, iv)
return str(c.decrypt(data))

65
helpers/binaryHelper.py Normal file
View File

@ -0,0 +1,65 @@
"""That's basically packetHelper.py from pep.py, with some changes to make it work with replay files."""
from constants import dataTypes
import struct
def uleb128Encode(num):
arr = bytearray()
length = 0
if num == 0:
return bytearray(b"\x00")
while num > 0:
arr.append(num & 127)
num >>= 7
if num != 0:
arr[length] |= 128
length+=1
return arr
def packData(__data, __dataType):
data = bytes()
pack = True
packType = "<B"
if __dataType == dataTypes.bbytes:
pack = False
data = __data
elif __dataType == dataTypes.string:
pack = False
if len(__data) == 0:
data += b"\x00"
else:
data += b"\x0B"
data += uleb128Encode(len(__data))
data += str.encode(__data, "latin_1")
elif __dataType == dataTypes.uInt16:
packType = "<H"
elif __dataType == dataTypes.sInt16:
packType = "<h"
elif __dataType == dataTypes.uInt32:
packType = "<L"
elif __dataType == dataTypes.sInt32:
packType = "<l"
elif __dataType == dataTypes.uInt64:
packType = "<Q"
elif __dataType == dataTypes.sInt64:
packType = "<q"
elif __dataType == dataTypes.string:
packType = "<s"
elif __dataType == dataTypes.ffloat:
packType = "<f"
elif __dataType == dataTypes.rawReplay:
pack = False
data += packData(len(__data), dataTypes.uInt32)
data += __data
if pack:
data += struct.pack(packType, __data)
return data
def binaryWrite(structure = None):
if structure is None:
structure = []
packetData = bytes()
for i in structure:
packetData += packData(i[0], i[1])
return packetData

438
helpers/chatHelper.py Normal file
View File

@ -0,0 +1,438 @@
from common.log import logUtils as log
from common.ripple import userUtils
from constants import exceptions
from constants import messageTemplates
from constants import serverPackets
from events import logoutEvent
from objects import fokabot
from objects import glob
def joinChannel(userID = 0, channel = "", token = None, toIRC = True, force=False):
"""
Join a channel
:param userID: user ID of the user that joins the channel. Optional. token can be used instead.
:param token: user token object of user that joins the channel. Optional. userID can be used instead.
:param channel: channel name
:param toIRC: if True, send this channel join event to IRC. Must be true if joining from bancho. Default: True
:param force: whether to allow game clients to join #spect_ and #multi_ channels
:return: 0 if joined or other IRC code in case of error. Needed only on IRC-side
"""
try:
# Get token if not defined
if token is None:
token = glob.tokens.getTokenFromUserID(userID)
# Make sure the token exists
if token is None:
raise exceptions.userNotFoundException
else:
token = token
# Normal channel, do check stuff
# Make sure the channel exists
if channel not in glob.channels.channels:
raise exceptions.channelUnknownException()
# Make sure a game client is not trying to join a #multi_ or #spect_ channel manually
channelObject = glob.channels.channels[channel]
if channelObject.isSpecial and not token.irc and not force:
raise exceptions.channelUnknownException()
# Add the channel to our joined channel
token.joinChannel(channelObject)
# Send channel joined (IRC)
if glob.irc and not toIRC:
glob.ircServer.banchoJoinChannel(token.username, channel)
# Console output
log.info("{} joined channel {}".format(token.username, channel))
# IRC code return
return 0
except exceptions.channelNoPermissionsException:
log.warning("{} attempted to join channel {}, but they have no read permissions".format(token.username, channel))
return 403
except exceptions.channelUnknownException:
log.warning("{} attempted to join an unknown channel ({})".format(token.username, channel))
return 403
except exceptions.userAlreadyInChannelException:
log.warning("User {} already in channel {}".format(token.username, channel))
return 403
except exceptions.userNotFoundException:
log.warning("User not connected to IRC/Bancho")
return 403 # idk
def partChannel(userID = 0, channel = "", token = None, toIRC = True, kick = False, force=False):
"""
Part a channel
:param userID: user ID of the user that parts the channel. Optional. token can be used instead.
:param token: user token object of user that parts the channel. Optional. userID can be used instead.
:param channel: channel name
:param toIRC: if True, send this channel join event to IRC. Must be true if joining from bancho. Optional. Default: True
:param kick: if True, channel tab will be closed on client. Used when leaving lobby. Optional. Default: False
:param force: whether to allow game clients to part #spect_ and #multi_ channels
:return: 0 if joined or other IRC code in case of error. Needed only on IRC-side
"""
try:
# Make sure the client is not drunk and sends partChannel when closing a PM tab
if not channel.startswith("#"):
return
# Get token if not defined
if token is None:
token = glob.tokens.getTokenFromUserID(userID)
# Make sure the token exists
if token is None:
raise exceptions.userNotFoundException()
else:
token = token
# Determine internal/client name if needed
# (toclient is used clientwise for #multiplayer and #spectator channels)
channelClient = channel
if channel == "#spectator":
if token.spectating is None:
s = userID
else:
s = token.spectatingUserID
channel = "#spect_{}".format(s)
elif channel == "#multiplayer":
channel = "#multi_{}".format(token.matchID)
elif channel.startswith("#spect_"):
channelClient = "#spectator"
elif channel.startswith("#multi_"):
channelClient = "#multiplayer"
# Make sure the channel exists
if channel not in glob.channels.channels:
raise exceptions.channelUnknownException()
# Make sure a game client is not trying to join a #multi_ or #spect_ channel manually
channelObject = glob.channels.channels[channel]
if channelObject.isSpecial and not token.irc and not force:
raise exceptions.channelUnknownException()
# Make sure the user is in the channel
if channel not in token.joinedChannels:
raise exceptions.userNotInChannelException()
# Part channel (token-side and channel-side)
token.partChannel(channelObject)
# Delete temporary channel if everyone left
if "chat/{}".format(channelObject.name) in glob.streams.streams:
if channelObject.temp and len(glob.streams.streams["chat/{}".format(channelObject.name)].clients) - 1 == 0:
glob.channels.removeChannel(channelObject.name)
# Force close tab if needed
# NOTE: Maybe always needed, will check later
if kick:
token.enqueue(serverPackets.channelKicked(channelClient))
# IRC part
if glob.irc and toIRC:
glob.ircServer.banchoPartChannel(token.username, channel)
# Console output
log.info("{} parted channel {} ({})".format(token.username, channel, channelClient))
# Return IRC code
return 0
except exceptions.channelUnknownException:
log.warning("{} attempted to part an unknown channel ({})".format(token.username, channel))
return 403
except exceptions.userNotInChannelException:
log.warning("{} attempted to part {}, but he's not in that channel".format(token.username, channel))
return 442
except exceptions.userNotFoundException:
log.warning("User not connected to IRC/Bancho")
return 442 # idk
def sendMessage(fro = "", to = "", message = "", token = None, toIRC = True):
"""
Send a message to osu!bancho and IRC server
:param fro: sender username. Optional. token can be used instead
:param to: receiver channel (if starts with #) or username
:param message: text of the message
:param token: sender token object. Optional. fro can be used instead
:param toIRC: if True, send the message to IRC. If False, send it to Bancho only. Default: True
:return: 0 if joined or other IRC code in case of error. Needed only on IRC-side
"""
try:
#tokenString = ""
# Get token object if not passed
if token is None:
token = glob.tokens.getTokenFromUsername(fro)
if token is None:
raise exceptions.userNotFoundException()
else:
# token object alredy passed, get its string and its username (fro)
fro = token.username
#tokenString = token.token
# Make sure this is not a tournament client
# if token.tournament:
# raise exceptions.userTournamentException()
# Make sure the user is not in restricted mode
if token.restricted:
raise exceptions.userRestrictedException()
# Make sure the user is not silenced
if token.isSilenced():
raise exceptions.userSilencedException()
# Redirect !report to FokaBot
if message.startswith("!report"):
to = glob.BOT_NAME
# Determine internal name if needed
# (toclient is used clientwise for #multiplayer and #spectator channels)
toClient = to
if to == "#spectator":
if token.spectating is None:
s = token.userID
else:
s = token.spectatingUserID
to = "#spect_{}".format(s)
elif to == "#multiplayer":
to = "#multi_{}".format(token.matchID)
elif to.startswith("#spect_"):
toClient = "#spectator"
elif to.startswith("#multi_"):
toClient = "#multiplayer"
# Make sure the message is valid
if not message.strip():
raise exceptions.invalidArgumentsException()
# Truncate message if > 2048 characters
message = message[:2048]+"..." if len(message) > 2048 else message
# Check for word filters
message = glob.chatFilters.filterMessage(message)
# Build packet bytes
packet = serverPackets.sendMessage(token.username, toClient, message)
# Send the message
isChannel = to.startswith("#")
if isChannel:
# CHANNEL
# Make sure the channel exists
if to not in glob.channels.channels:
raise exceptions.channelUnknownException()
# Make sure the channel is not in moderated mode
if glob.channels.channels[to].moderated and not token.admin:
raise exceptions.channelModeratedException()
# Make sure we are in the channel
if to not in token.joinedChannels:
# I'm too lazy to put and test the correct IRC error code here...
# but IRC is not strict at all so who cares
raise exceptions.channelNoPermissionsException()
# Make sure we have write permissions
if not glob.channels.channels[to].publicWrite and not token.admin:
raise exceptions.channelNoPermissionsException()
# Add message in buffer
token.addMessageInBuffer(to, message)
# Everything seems fine, build recipients list and send packet
glob.streams.broadcast("chat/{}".format(to), packet, but=[token.token])
else:
# USER
# Make sure recipient user is connected
recipientToken = glob.tokens.getTokenFromUsername(to)
if recipientToken is None:
raise exceptions.userNotFoundException()
# Make sure the recipient is not a tournament client
#if recipientToken.tournament:
# raise exceptions.userTournamentException()
# Make sure the recipient is not restricted or we are FokaBot
if recipientToken.restricted and fro.lower() != glob.BOT_NAME:
raise exceptions.userRestrictedException()
# TODO: Make sure the recipient has not disabled PMs for non-friends or he's our friend
# Away check
if recipientToken.awayCheck(token.userID):
sendMessage(to, fro, "\x01ACTION is away: {}\x01".format(recipientToken.awayMessage))
# Check message templates (mods/admins only)
if message in messageTemplates.templates and token.admin:
sendMessage(fro, to, messageTemplates.templates[message])
# Everything seems fine, send packet
recipientToken.enqueue(packet)
# Send the message to IRC
if glob.irc and toIRC:
messageSplitInLines = message.encode("latin-1").decode("utf-8").split("\n")
for line in messageSplitInLines:
if line == messageSplitInLines[:1] and line == "":
continue
glob.ircServer.banchoMessage(fro, to, line)
# Spam protection (ignore FokaBot)
if token.userID > 999:
token.spamProtection()
# Fokabot message
if isChannel or to == glob.BOT_NAME:
fokaMessage = fokabot.fokabotResponse(token.username, to, message)
if fokaMessage:
sendMessage(glob.BOT_NAME, to if isChannel else fro, fokaMessage)
# File and discord logs (public chat only) (to make public only, if to.startswith("#") and not)
if not (message.startswith("\x01ACTION is playing") and to.startswith("#spect_")):
if isChannel:
log.chat("[PUBLIC] {fro} @ {to}: {message}".format(fro=token.username, to=to, message=message.encode("latin-1").decode("utf-8")))
glob.schiavo.sendChatlog("**{fro} @ {to}:** {message}".format(fro=token.username, to=to, message=message.encode("latin-1").decode("utf-8")))
else:
log.pm("[PRIVATE] {fro} @ {to}: {message}".format(fro=token.username, to=to, message=message.encode("latin-1").decode("utf-8")))
glob.schiavo.sendChatlog("**{fro} @ {to}:** {message}".format(fro=token.username, to=to, message=message.encode("latin-1").decode("utf-8")))
return 0
except exceptions.userSilencedException:
token.enqueue(serverPackets.silenceEndTime(token.getSilenceSecondsLeft()))
log.warning("{} tried to send a message during silence".format(token.username))
return 404
except exceptions.channelModeratedException:
log.warning("{} tried to send a message to a channel that is in moderated mode ({})".format(token.username, to))
return 404
except exceptions.channelUnknownException:
log.warning("{} tried to send a message to an unknown channel ({})".format(token.username, to))
return 403
except exceptions.channelNoPermissionsException:
log.warning("{} tried to send a message to channel {}, but they have no write permissions".format(token.username, to))
return 404
except exceptions.userRestrictedException:
log.warning("{} tried to send a message {}, but the recipient is in restricted mode".format(token.username, to))
return 404
except exceptions.userTournamentException:
log.warning("{} tried to send a message {}, but the recipient is a tournament client".format(token.username, to))
return 404
except exceptions.userNotFoundException:
log.warning("User not connected to IRC/Bancho")
return 401
except exceptions.invalidArgumentsException:
log.warning("{} tried to send an invalid message to {}".format(token.username, to))
return 404
""" IRC-Bancho Connect/Disconnect/Join/Part interfaces"""
def fixUsernameForBancho(username):
"""
Convert username from IRC format (without spaces) to Bancho format (with spaces)
:param username: username to convert
:return: converted username
"""
# If there are no spaces or underscores in the name
# return it
if " " not in username and "_" not in username:
return username
# Exact match first
result = glob.db.fetch("SELECT id FROM users WHERE username = %s LIMIT 1", [username])
if result is not None:
return username
# Username not found, replace _ with space
return username.replace("_", " ")
def fixUsernameForIRC(username):
"""
Convert an username from Bancho format to IRC format (underscores instead of spaces)
:param username: username to convert
:return: converted username
"""
return username.replace(" ", "_")
def IRCConnect(username):
"""
Handle IRC login bancho-side.
Add token and broadcast login packet.
:param username: username
:return:
"""
userID = userUtils.getID(username)
if not userID:
log.warning("{} doesn't exist".format(username))
return
glob.tokens.deleteOldTokens(userID)
glob.tokens.addToken(userID, irc=True)
glob.streams.broadcast("main", serverPackets.userPanel(userID))
log.info("{} logged in from IRC".format(username))
def IRCDisconnect(username):
"""
Handle IRC logout bancho-side.
Remove token and broadcast logout packet.
:param username: username
:return:
"""
token = glob.tokens.getTokenFromUsername(username)
if token is None:
log.warning("{} doesn't exist".format(username))
return
logoutEvent.handle(token)
log.info("{} disconnected from IRC".format(username))
def IRCJoinChannel(username, channel):
"""
Handle IRC channel join bancho-side.
:param username: username
:param channel: channel name
:return: IRC return code
"""
userID = userUtils.getID(username)
if not userID:
log.warning("{} doesn't exist".format(username))
return
# NOTE: This should have also `toIRC` = False` tho,
# since we send JOIN message later on ircserver.py.
# Will test this later
return joinChannel(userID, channel)
def IRCPartChannel(username, channel):
"""
Handle IRC channel part bancho-side.
:param username: username
:param channel: channel name
:return: IRC return code
"""
userID = userUtils.getID(username)
if not userID:
log.warning("{} doesn't exist".format(username))
return
return partChannel(userID, channel)
def IRCAway(username, message):
"""
Handle IRC away command bancho-side.
:param username:
:param message: away message
:return: IRC return code
"""
userID = userUtils.getID(username)
if not userID:
log.warning("{} doesn't exist".format(username))
return
glob.tokens.getTokenFromUserID(userID).awayMessage = message
return 305 if message == "" else 306

148
helpers/config.py Normal file
View File

@ -0,0 +1,148 @@
import os
import configparser
class config:
"""
config.ini object
config -- list with ini data
default -- if true, we have generated a default config.ini
"""
config = configparser.ConfigParser()
fileName = "" # config filename
default = True
# Check if config.ini exists and load/generate it
def __init__(self, __file):
"""
Initialize a config object
__file -- filename
"""
self.fileName = __file
if os.path.isfile(self.fileName):
# config.ini found, load it
self.config.read(self.fileName)
self.default = False
else:
# config.ini not found, generate a default one
self.generateDefaultConfig()
self.default = True
# Check if config.ini has all needed the keys
def checkConfig(self):
"""
Check if this config has the required keys
return -- True if valid, False if not
"""
try:
# Try to get all the required keys
self.config.get("db","host")
self.config.get("db","username")
self.config.get("db","password")
self.config.get("db","database")
self.config.get("db","workers")
self.config.get("redis","host")
self.config.get("redis","port")
self.config.get("redis","database")
self.config.get("redis","password")
self.config.get("server","host")
self.config.get("server","port")
self.config.get("server", "debug")
self.config.get("server", "beatmapcacheexpire")
self.config.get("server", "serverurl")
self.config.get("server", "banchourl")
self.config.get("server", "threads")
self.config.get("server", "apikey")
self.config.get("sentry","enable")
self.config.get("sentry","dsn")
self.config.get("datadog", "enable")
self.config.get("datadog", "apikey")
self.config.get("datadog", "appkey")
self.config.get("osuapi","enable")
self.config.get("osuapi","apiurl")
self.config.get("osuapi","apikey")
self.config.get("cheesegull", "apiurl")
self.config.get("discord","enable")
self.config.get("discord","boturl")
self.config.get("discord", "devgroup")
self.config.get("discord", "secretwebhook")
self.config.get("cono", "enable")
return True
except:
return False
# Generate a default config.ini
def generateDefaultConfig(self):
"""Open and set default keys for that config file"""
# Open config.ini in write mode
f = open(self.fileName, "w")
# Set keys to config object
self.config.add_section("db")
self.config.set("db", "host", "localhost")
self.config.set("db", "username", "root")
self.config.set("db", "password", "")
self.config.set("db", "database", "ripple")
self.config.set("db", "workers", "16")
self.config.add_section("redis")
self.config.set("redis", "host", "localhost")
self.config.set("redis", "port", "6379")
self.config.set("redis", "database", "0")
self.config.set("redis", "password", "")
self.config.add_section("server")
self.config.set("server", "host", "0.0.0.0")
self.config.set("server", "port", "5002")
self.config.set("server", "debug", "False")
self.config.set("server", "beatmapcacheexpire", "86400")
self.config.set("server", "serverurl", "http://127.0.0.1:5002")
self.config.set("server", "banchourl", "http://127.0.0.1:5001")
self.config.set("server", "threads", "16")
self.config.set("server", "apikey", "changeme")
self.config.add_section("sentry")
self.config.set("sentry", "enable", "False")
self.config.set("sentry", "dsn", "")
self.config.add_section("datadog")
self.config.set("datadog", "enable", "False")
self.config.set("datadog", "apikey", "")
self.config.set("datadog", "appkey", "")
self.config.add_section("osuapi")
self.config.set("osuapi", "enable", "True")
self.config.set("osuapi", "apiurl", "https://osu.ppy.sh")
self.config.set("osuapi", "apikey", "YOUR_OSU_API_KEY_HERE")
self.config.add_section("cheesegull")
self.config.set("cheesegull", "apiurl", "http://cheesegu.ll/api")
self.config.add_section("discord")
self.config.set("discord", "enable", "False")
self.config.set("discord", "boturl", "")
self.config.set("discord", "devgroup", "")
self.config.set("discord", "secretwebhook", "")
self.config.add_section("cono")
self.config.set("cono", "enable", "False")
# Write ini to file and close
self.config.write(f)
f.close()

99
helpers/consoleHelper.py Normal file
View File

@ -0,0 +1,99 @@
"""Some console related functions"""
from common.constants import bcolors
from objects import glob
def printServerStartHeader(asciiArt):
"""
Print server start header with optional ascii art
asciiArt -- if True, will print ascii art too
"""
if asciiArt:
printColored(" ( ( ", bcolors.YELLOW)
printColored(" )\\ ) * ) )\\ ) ", bcolors.YELLOW)
printColored("(()/( ( ` ) /((()/( ", bcolors.YELLOW)
printColored(" /(_)) )\\ ( )(_))/(_)) ", bcolors.YELLOW)
printColored("(_)) ((_) (_(_())(_)) ", bcolors.YELLOW)
printColored("| | | __||_ _|/ __| ", bcolors.GREEN)
printColored("| |__ | _| | | \\__ \\ ", bcolors.GREEN)
printColored("|____||___| |_| |___/ \n", bcolors.GREEN)
printColored("> Welcome to the Latest Essential Tatoe Server v{}".format(glob.VERSION), bcolors.GREEN)
printColored("> Made by the Ripple and Akatsuki teams", bcolors.GREEN)
printColored("> {}https://github.com/cmyui/lets".format(bcolors.UNDERLINE), bcolors.GREEN)
printColored("> Press CTRL+C to exit\n", bcolors.GREEN)
def printNoNl(string):
"""
Print string without new line at the end
string -- string to print
"""
print(string, end="")
def printColored(string, color):
"""
Print colored string
string -- string to print
color -- see bcolors.py
"""
print("{}{}{}".format(color, string, bcolors.ENDC))
def printError():
"""Print error text FOR LOADING"""
printColored("Error", bcolors.RED)
def printDone():
"""Print error text FOR LOADING"""
printColored("Done", bcolors.GREEN)
def printWarning():
"""Print error text FOR LOADING"""
printColored("Warning", bcolors.YELLOW)
def printGetScoresMessage(message):
printColored("[get_scores] {}".format(message), bcolors.PINK)
def printSubmitModularMessage(message):
printColored("[submit_modular] {}".format(message), bcolors.YELLOW)
def printBanchoConnectMessage(message):
printColored("[bancho_connect] {}".format(message), bcolors.YELLOW)
def printGetReplayMessage(message):
printColored("[get_replay] {}".format(message), bcolors.PINK)
def printMapsMessage(message):
printColored("[maps] {}".format(message), bcolors.PINK)
def printRippMessage(message):
printColored("[ripp] {}".format(message), bcolors.GREEN)
# def printRippoppaiMessage(message):
# printColored("[rippoppai] {}".format(message), bcolors.GREEN)
def printWifiPianoMessage(message):
printColored("[wifipiano] {}".format(message), bcolors.GREEN)
def printDebugMessage(message):
printColored("[debug] {}".format(message), bcolors.BLUE)
def printScreenshotsMessage(message):
printColored("[screenshots] {}".format(message), bcolors.YELLOW)
def printApiMessage(module, message):
printColored("[{}] {}".format(module, message), bcolors.GREEN)

View File

@ -0,0 +1,17 @@
import sys
import traceback
from functools import wraps
from common.log import logUtils as log
def trackExceptions(moduleName=""):
def _trackExceptions(func):
def _decorator(request, *args, **kwargs):
try:
response = func(request, *args, **kwargs)
return response
except:
log.error("Unknown error{}!\n```\n{}\n{}```".format(" in "+moduleName if moduleName != "" else "", sys.exc_info(), traceback.format_exc()), True)
return wraps(func)(_decorator)
return _trackExceptions

View File

@ -0,0 +1,131 @@
from common.log import logUtils as log
from common.ripple import scoreUtils
from objects import glob
from common.ripple import userUtils
def rxgetRankInfo(userID, gameMode):
"""
Get userID's current rank, user above us and pp/score difference
:param userID: user
:param gameMode: gameMode number
:return: {"nextUsername": "", "difference": 0, "currentRank": 0}
"""
data = {"nextUsername": "", "difference": 0, "currentRank": 0}
k = "ripple:relaxboard:{}".format(scoreUtils.readableGameMode(gameMode))
position = userUtils.rxgetGameRank(userID, gameMode) - 1
log.debug("Our position is {}".format(position))
if position is not None and position > 0:
aboveUs = glob.redis.zrevrange(k, position - 1, position)
log.debug("{} is above us".format(aboveUs))
if aboveUs is not None and len(aboveUs) > 0 and aboveUs[0].isdigit():
# Get our rank, next rank username and pp/score difference
myScore = glob.redis.zscore(k, userID)
otherScore = glob.redis.zscore(k, aboveUs[0])
nextUsername = userUtils.getUsername(aboveUs[0])
if nextUsername is not None and myScore is not None and otherScore is not None:
data["nextUsername"] = nextUsername
data["difference"] = int(myScore) - int(otherScore)
else:
position = 0
data["currentRank"] = position + 1
return data
def getRankInfo(userID, gameMode):
"""
Get userID's current rank, user above us and pp/score difference
:param userID: user
:param gameMode: gameMode number
:return: {"nextUsername": "", "difference": 0, "currentRank": 0}
"""
data = {"nextUsername": "", "difference": 0, "currentRank": 0}
k = "ripple:leaderboard:{}".format(scoreUtils.readableGameMode(gameMode))
position = userUtils.getGameRank(userID, gameMode) - 1
log.debug("Our position is {}".format(position))
if position is not None and position > 0:
aboveUs = glob.redis.zrevrange(k, position - 1, position)
log.debug("{} is above us".format(aboveUs))
if aboveUs is not None and len(aboveUs) > 0 and aboveUs[0].isdigit():
# Get our rank, next rank username and pp/score difference
myScore = glob.redis.zscore(k, userID)
otherScore = glob.redis.zscore(k, aboveUs[0])
nextUsername = userUtils.getUsername(aboveUs[0])
if nextUsername is not None and myScore is not None and otherScore is not None:
data["nextUsername"] = nextUsername
data["difference"] = int(myScore) - int(otherScore)
else:
position = 0
data["currentRank"] = position + 1
return data
def rxupdate(userID, newScore, gameMode):
"""
Update gamemode's leaderboard.
Doesn't do anything if userID is banned/restricted.
:param userID: user
:param newScore: new score or pp
:param gameMode: gameMode number
"""
if userUtils.isAllowed(userID):
log.debug("Updating relaxboard...")
glob.redis.zadd("ripple:relaxboard:{}".format(scoreUtils.readableGameMode(gameMode)), str(userID), str(newScore))
else:
log.debug("Relaxboard update for user {} skipped (not allowed)".format(userID))
def update(userID, newScore, gameMode):
"""
Update gamemode's leaderboard.
Doesn't do anything if userID is banned/restricted.
:param userID: user
:param newScore: new score or pp
:param gameMode: gameMode number
"""
if userUtils.isAllowed(userID):
log.debug("Updating leaderboard...")
glob.redis.zadd("ripple:leaderboard:{}".format(scoreUtils.readableGameMode(gameMode)), str(userID), str(newScore))
else:
log.debug("Leaderboard update for user {} skipped (not allowed)".format(userID))
def rxupdateCountry(userID, newScore, gameMode):
"""
Update gamemode's country leaderboard.
Doesn't do anything if userID is banned/restricted.
:param userID: user, country is determined by the user
:param newScore: new score or pp
:param gameMode: gameMode number
:return:
"""
if userUtils.isAllowed(userID):
country = userUtils.getCountry(userID)
if country is not None and len(country) > 0 and country.lower() != "xx":
log.debug("Updating {} country relaxboard...".format(country))
k = "ripple:relaxboard:{}:{}".format(scoreUtils.readableGameMode(gameMode), country.lower())
glob.redis.zadd(k, str(userID), str(newScore))
else:
log.debug("Country relaxboard update for user {} skipped (not allowed)".format(userID))
def updateCountry(userID, newScore, gameMode):
"""
Update gamemode's country leaderboard.
Doesn't do anything if userID is banned/restricted.
:param userID: user, country is determined by the user
:param newScore: new score or pp
:param gameMode: gameMode number
:return:
"""
if userUtils.isAllowed(userID):
country = userUtils.getCountry(userID)
if country is not None and len(country) > 0 and country.lower() != "xx":
log.debug("Updating {} country leaderboard...".format(country))
k = "ripple:leaderboard:{}:{}".format(scoreUtils.readableGameMode(gameMode), country.lower())
glob.redis.zadd(k, str(userID), str(newScore))
else:
log.debug("Country leaderboard update for user {} skipped (not allowed)".format(userID))

51
helpers/levbodHelper.py Normal file
View File

@ -0,0 +1,51 @@
import requests
import json
from constants import exceptions
from objects import glob
def levbodRequest(handler, params=None):
if params is None:
params = {}
result = requests.get("{}/{}".format(glob.conf.config["levbod"]["url"], handler), params=params)
try:
data = json.loads(result.text)
except (json.JSONDecodeError, ValueError, requests.RequestException, KeyError, exceptions.noAPIDataError):
return None
if result.status_code != 200 or "data" not in data:
return None
return data["data"]
def getListing(rankedStatus=4, page=0, gameMode=-1, query=""):
return levbodRequest("listing", {
"mode": gameMode,
"status": rankedStatus,
"query": query,
"page": page,
})
def getBeatmapSet(id):
return levbodRequest("beatmapset", {
"id": id
})
def getBeatmap(id):
return levbodRequest("beatmap", {
"id": id
})
def levbodToDirect(data):
s = "{beatmapset_id}.osz|{artist}|{title}|{creator}|{ranked_status}|10.00|0|{beatmapset_id}|".format(**data)
if len(data["beatmaps"]) > 0:
s += "{}|0|0|0||".format(data["beatmaps"][0]["beatmap_id"])
for i in data["beatmaps"]:
s += "{difficulty_name}@{game_mode},".format(**i)
s = s.strip(",")
s += "|"
return s
def levbodToDirectNp(data):
return "{beatmapset_id}.osz|{artist}|{title}|{creator}|{ranked_status}|10.00|0|{beatmapset_id}|{beatmapset_id}|0|0|0|".format(**data)

56
helpers/mapsHelper.py Normal file
View File

@ -0,0 +1,56 @@
import os
from common import generalUtils
from common.log import logUtils as log
from constants import exceptions
from helpers import osuapiHelper
def isBeatmap(fileName=None, content=None):
if fileName is not None:
with open(fileName, "rb") as f:
firstLine = f.readline().decode("utf-8-sig").strip()
elif content is not None:
try:
firstLine = content.decode("utf-8-sig").split("\n")[0].strip()
except IndexError:
return False
else:
raise ValueError("Either `fileName` or `content` must be provided.")
return firstLine.lower().startswith("osu file format v")
def cacheMap(mapFile, _beatmap):
# Check if we have to download the .osu file
download = False
if not os.path.isfile(mapFile):
# .osu file doesn't exist. We must download it
download = True
else:
# File exists, check md5
if generalUtils.fileMd5(mapFile) != _beatmap.fileMD5 or not isBeatmap(mapFile):
# MD5 don't match, redownload .osu file
download = True
# Download .osu file if needed
if download:
log.debug("maps ~> Downloading {} osu file".format(_beatmap.beatmapID))
# Get .osu file from osu servers
fileContent = osuapiHelper.getOsuFileFromID(_beatmap.beatmapID)
# Make sure osu servers returned something
if fileContent is None or not isBeatmap(content=fileContent):
raise exceptions.osuApiFailException("maps")
# Delete old .osu file if it exists
if os.path.isfile(mapFile):
os.remove(mapFile)
# Save .osu file
with open(mapFile, "wb+") as f:
f.write(fileContent)
else:
# Map file is already in folder
log.debug("maps ~> Beatmap found in cache!")
def cachedMapPath(beatmap_id):
return ".data/beatmaps/{}.osu".format(beatmap_id)

83
helpers/osuapiHelper.py Normal file
View File

@ -0,0 +1,83 @@
import json
from urllib.parse import quote
import requests
from common.log import logUtils as log
from common import generalUtils
from objects import glob
from constants import exceptions
def osuApiRequest(request, params, getFirst=True):
"""
Send a request to osu!api.
request -- request type, string (es: get_beatmaps)
params -- GET parameters, without api key or trailing ?/& (es: h=a5b99395a42bd55bc5eb1d2411cbdf8b&limit=10)
return -- dictionary with json response if success, None if failed or empty response.
"""
# Make sure osuapi is enabled
if not generalUtils.stringToBool(glob.conf.config["osuapi"]["enable"]):
log.warning("osu!api is disabled")
return None
# Api request
resp = None
try:
finalURL = "{}/api/{}?k={}&{}".format(glob.conf.config["osuapi"]["apiurl"], request, glob.conf.config["osuapi"]["apikey"], params)
log.debug(finalURL)
resp = requests.get(finalURL, timeout=5).text
data = json.loads(resp)
if getFirst:
if len(data) >= 1:
resp = data[0]
else:
resp = None
else:
resp = data
finally:
glob.dog.increment(glob.DATADOG_PREFIX+".osu_api.requests")
log.debug(str(resp).encode("utf-8"))
return resp
def getOsuFileFromName(fileName):
"""
Send a request to osu! servers to download a .osu file from file name
Used to update beatmaps
fileName -- .osu file name to download
return -- .osu file content if success, None if failed
"""
# Make sure osuapi is enabled
if not generalUtils.stringToBool(glob.conf.config["osuapi"]["enable"]):
log.warning("osuapi is disabled")
return None
response = None
try:
URL = "{}/web/maps/{}".format(glob.conf.config["osuapi"]["apiurl"], quote(fileName))
req = requests.get(URL, timeout=20)
req.encoding = "utf-8"
response = req.content
finally:
glob.dog.increment(glob.DATADOG_PREFIX+".osu_api.osu_file_requests")
return response
def getOsuFileFromID(beatmapID):
"""
Send a request to osu! servers to download a .osu file from beatmap ID
Used to get .osu files for oppai
beatmapID -- ID of beatmap (not beatmapset) to download
return -- .osu file content if success, None if failed
"""
# Make sure osuapi is enabled
if not generalUtils.stringToBool(glob.conf.config["osuapi"]["enable"]):
log.warning("osuapi is disabled")
return None
response = None
try:
URL = "{}/osu/{}".format(glob.conf.config["osuapi"]["apiurl"], beatmapID)
response = requests.get(URL, timeout=20).content
finally:
glob.dog.increment(glob.DATADOG_PREFIX+".osu_api.osu_file_requests")
return response

134
helpers/replayHelper.py Normal file
View File

@ -0,0 +1,134 @@
import os
from common import generalUtils
from constants import exceptions, dataTypes
from helpers import binaryHelper
from objects import glob
def rxbuildFullReplay(scoreID=None, scoreData=None, rawReplay=None):
if all(v is None for v in (scoreID, scoreData)) or all(v is not None for v in (scoreID, scoreData)):
raise AttributeError("Either scoreID or scoreData must be provided, not neither or both")
if scoreData is None:
scoreData = glob.db.fetch(
"SELECT scores_relax.*, users.username FROM scores_relax LEFT JOIN users ON scores_relax.userid = users.id "
"WHERE scores_relax.id = %s",
[scoreID]
)
else:
scoreID = scoreData["id"]
if scoreData is None or scoreID is None:
raise exceptions.scoreNotFoundError()
if rawReplay is None:
# Make sure raw replay exists
fileName = ".data/replays/replay_{}.osr".format(scoreID)
if not os.path.isfile(fileName):
raise FileNotFoundError()
# Read raw replay
with open(fileName, "rb") as f:
rawReplay = f.read()
# Calculate missing replay data
rank = generalUtils.getRank(int(scoreData["play_mode"]), int(scoreData["mods"]), int(scoreData["accuracy"]),
int(scoreData["300_count"]), int(scoreData["100_count"]), int(scoreData["50_count"]),
int(scoreData["misses_count"]))
magicHash = generalUtils.stringMd5(
"{}p{}o{}o{}t{}a{}r{}e{}y{}o{}u{}{}{}".format(int(scoreData["100_count"]) + int(scoreData["300_count"]),
scoreData["50_count"], scoreData["gekis_count"],
scoreData["katus_count"], scoreData["misses_count"],
scoreData["beatmap_md5"], scoreData["max_combo"],
"True" if int(scoreData["full_combo"]) == 1 else "False",
scoreData["username"], scoreData["score"], rank,
scoreData["mods"], "True"))
# Add headers (convert to full replay)
fullReplay = binaryHelper.binaryWrite([
[scoreData["play_mode"], dataTypes.byte],
[20150414, dataTypes.uInt32],
[scoreData["beatmap_md5"], dataTypes.string],
[scoreData["username"], dataTypes.string],
[magicHash, dataTypes.string],
[scoreData["300_count"], dataTypes.uInt16],
[scoreData["100_count"], dataTypes.uInt16],
[scoreData["50_count"], dataTypes.uInt16],
[scoreData["gekis_count"], dataTypes.uInt16],
[scoreData["katus_count"], dataTypes.uInt16],
[scoreData["misses_count"], dataTypes.uInt16],
[scoreData["score"], dataTypes.uInt32],
[scoreData["max_combo"], dataTypes.uInt16],
[scoreData["full_combo"], dataTypes.byte],
[scoreData["mods"], dataTypes.uInt32],
[0, dataTypes.byte],
[0, dataTypes.uInt64],
[rawReplay, dataTypes.rawReplay],
[0, dataTypes.uInt32],
[0, dataTypes.uInt32],
])
# Return full replay
return fullReplay
def buildFullReplay(scoreID=None, scoreData=None, rawReplay=None):
if all(v is None for v in (scoreID, scoreData)) or all(v is not None for v in (scoreID, scoreData)):
raise AttributeError("Either scoreID or scoreData must be provided, not neither or both")
if scoreData is None:
scoreData = glob.db.fetch(
"SELECT scores.*, users.username FROM scores LEFT JOIN users ON scores.userid = users.id "
"WHERE scores.id = %s",
[scoreID]
)
else:
scoreID = scoreData["id"]
if scoreData is None or scoreID is None:
raise exceptions.scoreNotFoundError()
if rawReplay is None:
# Make sure raw replay exists
fileName = ".data/replays/replay_{}.osr".format(scoreID)
if not os.path.isfile(fileName):
raise FileNotFoundError()
# Read raw replay
with open(fileName, "rb") as f:
rawReplay = f.read()
# Calculate missing replay data
rank = generalUtils.getRank(int(scoreData["play_mode"]), int(scoreData["mods"]), int(scoreData["accuracy"]),
int(scoreData["300_count"]), int(scoreData["100_count"]), int(scoreData["50_count"]),
int(scoreData["misses_count"]))
magicHash = generalUtils.stringMd5(
"{}p{}o{}o{}t{}a{}r{}e{}y{}o{}u{}{}{}".format(int(scoreData["100_count"]) + int(scoreData["300_count"]),
scoreData["50_count"], scoreData["gekis_count"],
scoreData["katus_count"], scoreData["misses_count"],
scoreData["beatmap_md5"], scoreData["max_combo"],
"True" if int(scoreData["full_combo"]) == 1 else "False",
scoreData["username"], scoreData["score"], rank,
scoreData["mods"], "True"))
# Add headers (convert to full replay)
fullReplay = binaryHelper.binaryWrite([
[scoreData["play_mode"], dataTypes.byte],
[20150414, dataTypes.uInt32],
[scoreData["beatmap_md5"], dataTypes.string],
[scoreData["username"], dataTypes.string],
[magicHash, dataTypes.string],
[scoreData["300_count"], dataTypes.uInt16],
[scoreData["100_count"], dataTypes.uInt16],
[scoreData["50_count"], dataTypes.uInt16],
[scoreData["gekis_count"], dataTypes.uInt16],
[scoreData["katus_count"], dataTypes.uInt16],
[scoreData["misses_count"], dataTypes.uInt16],
[scoreData["score"], dataTypes.uInt32],
[scoreData["max_combo"], dataTypes.uInt16],
[scoreData["full_combo"], dataTypes.byte],
[scoreData["mods"], dataTypes.uInt32],
[0, dataTypes.byte],
[0, dataTypes.uInt64],
[rawReplay, dataTypes.rawReplay],
[0, dataTypes.uInt32],
[0, dataTypes.uInt32],
])
# Return full replay
return fullReplay

243
lets.py Normal file
View File

@ -0,0 +1,243 @@
# General imports
import os
import sys
from multiprocessing.pool import ThreadPool
import tornado.gen
import tornado.httpserver
import tornado.ioloop
import tornado.web
from raven.contrib.tornado import AsyncSentryClient
import redis
from common.constants import bcolors
from common.db import dbConnector
from common.ddog import datadogClient
from common.log import logUtils as log
from common.redis import pubSub
from common.web import schiavo
from handlers import apiCacheBeatmapHandler
from handlers import apiPPHandler
from handlers import apiStatusHandler
from handlers import banchoConnectHandler
from handlers import checkUpdatesHandler
from handlers import defaultHandler
from handlers import downloadMapHandler
from handlers import emptyHandler
from handlers import getFullReplayHandler
from handlers import getReplayHandler
from handlers import getScoresHandler
from handlers import getScreenshotHandler
from handlers import loadTestHandler
from handlers import mapsHandler
from handlers import osuErrorHandler
from handlers import osuSearchHandler
from handlers import osuSearchSetHandler
from handlers import redirectHandler
from handlers import submitModularHandler
from handlers import uploadScreenshotHandler
from handlers import commentHandler
from helpers import config
from helpers import consoleHelper
from common import generalUtils
from common import agpl
from objects import glob
from pubSubHandlers import beatmapUpdateHandler
def make_app():
return tornado.web.Application([
(r"/web/bancho_connect.php", banchoConnectHandler.handler),
(r"/web/osu-osz2-getscores.php", getScoresHandler.handler),
(r"/web/osu-submit-modular.php", submitModularHandler.handler),
(r"/web/osu-getreplay.php", getReplayHandler.handler),
(r"/web/osu-screenshot.php", uploadScreenshotHandler.handler),
(r"/web/osu-search.php", osuSearchHandler.handler),
(r"/web/osu-search-set.php", osuSearchSetHandler.handler),
(r"/web/check-updates.php", checkUpdatesHandler.handler),
(r"/web/osu-error.php", osuErrorHandler.handler),
(r"/web/osu-comment.php", commentHandler.handler),
(r"/ss/(.*)", getScreenshotHandler.handler),
(r"/web/maps/(.*)", mapsHandler.handler),
(r"/d/(.*)", downloadMapHandler.handler),
(r"/s/(.*)", downloadMapHandler.handler),
(r"/web/replays/(.*)", getFullReplayHandler.handler),
(r"/p/verify", redirectHandler.handler, dict(destination="https://akatsuki.pw/index.php?p=2")),
(r"/u/(.*)", redirectHandler.handler, dict(destination="https://akatsuki.pw/u/{}")),
(r"/api/v1/status", apiStatusHandler.handler),
(r"/api/v1/pp", apiPPHandler.handler),
(r"/api/v1/cacheBeatmap", apiCacheBeatmapHandler.handler),
(r"/letsapi/v1/status", apiStatusHandler.handler),
(r"/letsapi/v1/pp", apiPPHandler.handler),
(r"/letsapi/v1/cacheBeatmap", apiCacheBeatmapHandler.handler),
# Not done yet
(r"/web/lastfm.php", emptyHandler.handler),
(r"/web/osu-rate.php", emptyHandler.handler),
(r"/web/osu-comment.php", emptyHandler.handler),
(r"/web/osu-checktweets.php", emptyHandler.handler),
(r"/loadTest", loadTestHandler.handler),
], default_handler_class=defaultHandler.handler)
if __name__ == "__main__":
# AGPL license agreement
try:
agpl.check_license("ripple", "LETS")
except agpl.LicenseError as e:
print(str(e))
sys.exit(1)
try:
consoleHelper.printServerStartHeader(True)
# Read config
consoleHelper.printNoNl("> Reading config file... ")
glob.conf = config.config("config.ini")
if glob.conf.default:
# We have generated a default config.ini, quit server
consoleHelper.printWarning()
consoleHelper.printColored("[!] config.ini not found. A default one has been generated.", bcolors.YELLOW)
consoleHelper.printColored("[!] Please edit your config.ini and run the server again.", bcolors.YELLOW)
sys.exit()
# If we haven't generated a default config.ini, check if it's valid
if not glob.conf.checkConfig():
consoleHelper.printError()
consoleHelper.printColored("[!] Invalid config.ini. Please configure it properly", bcolors.RED)
consoleHelper.printColored("[!] Delete your config.ini to generate a default one", bcolors.RED)
sys.exit()
else:
consoleHelper.printDone()
# Create data/oppai maps folder if needed
consoleHelper.printNoNl("> Checking folders... ")
paths = [
".data",
".data/replays",
".data/screenshots",
".data/oppai",
".data/catch_the_pp",
".data/beatmaps"
]
for i in paths:
if not os.path.exists(i):
os.makedirs(i, 0o770)
consoleHelper.printDone()
# Connect to db
try:
consoleHelper.printNoNl("> Connecting to MySQL database... ")
glob.db = dbConnector.db(glob.conf.config["db"]["host"], glob.conf.config["db"]["username"], glob.conf.config["db"]["password"], glob.conf.config["db"]["database"], int(
glob.conf.config["db"]["workers"]))
consoleHelper.printNoNl(" ")
consoleHelper.printDone()
except:
# Exception while connecting to db
consoleHelper.printError()
consoleHelper.printColored("[!] Error while connection to database. Please check your config.ini and run the server again", bcolors.RED)
raise
# Connect to redis
try:
consoleHelper.printNoNl("> Connecting to redis... ")
glob.redis = redis.Redis(glob.conf.config["redis"]["host"], glob.conf.config["redis"]["port"], glob.conf.config["redis"]["database"], glob.conf.config["redis"]["password"])
glob.redis.ping()
consoleHelper.printNoNl(" ")
consoleHelper.printDone()
except:
# Exception while connecting to db
consoleHelper.printError()
consoleHelper.printColored("[!] Error while connection to redis. Please check your config.ini and run the server again", bcolors.RED)
raise
# Empty redis cache
try:
glob.redis.eval("return redis.call('del', unpack(redis.call('keys', ARGV[1])))", 0, "lets:*")
except redis.exceptions.ResponseError:
# Script returns error if there are no keys starting with peppy:*
pass
# Save lets version in redis
glob.redis.set("lets:version", glob.VERSION)
# Create threads pool
try:
consoleHelper.printNoNl("> Creating threads pool... ")
glob.pool = ThreadPool(int(glob.conf.config["server"]["threads"]))
consoleHelper.printDone()
except:
consoleHelper.printError()
consoleHelper.printColored("[!] Error while creating threads pool. Please check your config.ini and run the server again", bcolors.RED)
# Check osuapi
if not generalUtils.stringToBool(glob.conf.config["osuapi"]["enable"]):
consoleHelper.printColored("[!] osu!api features are disabled. If you don't have a valid beatmaps table, all beatmaps will show as unranked", bcolors.YELLOW)
if int(glob.conf.config["server"]["beatmapcacheexpire"]) > 0:
consoleHelper.printColored("[!] IMPORTANT! Your beatmapcacheexpire in config.ini is > 0 and osu!api features are disabled.\nWe do not reccoment this, because too old beatmaps will be shown as unranked.\nSet beatmapcacheexpire to 0 to disable beatmap latest update check and fix that issue.", bcolors.YELLOW)
# Set achievements version
glob.redis.set("lets:achievements_version", glob.ACHIEVEMENTS_VERSION)
consoleHelper.printColored("Achievements version is {}".format(glob.ACHIEVEMENTS_VERSION), bcolors.YELLOW)
# Discord
if generalUtils.stringToBool(glob.conf.config["discord"]["enable"]):
glob.schiavo = schiavo.schiavo(glob.conf.config["discord"]["boturl"], "**lets**")
else:
consoleHelper.printColored("[!] Warning! Discord logging is disabled!", bcolors.YELLOW)
# Check debug mods
glob.debug = generalUtils.stringToBool(glob.conf.config["server"]["debug"])
if glob.debug:
consoleHelper.printColored("[!] Warning! Server running in debug mode!", bcolors.YELLOW)
# Server port
try:
serverPort = int(glob.conf.config["server"]["port"])
except:
consoleHelper.printColored("[!] Invalid server port! Please check your config.ini and run the server again", bcolors.RED)
# Make app
glob.application = make_app()
# Set up sentry
try:
glob.sentry = generalUtils.stringToBool(glob.conf.config["sentry"]["enable"])
if glob.sentry:
glob.application.sentry_client = AsyncSentryClient(glob.conf.config["sentry"]["dsn"], release=glob.VERSION)
else:
consoleHelper.printColored("[!] Warning! Sentry logging is disabled!", bcolors.YELLOW)
except:
consoleHelper.printColored("[!] Error while starting Sentry client! Please check your config.ini and run the server again", bcolors.RED)
# Set up Datadog
try:
if generalUtils.stringToBool(glob.conf.config["datadog"]["enable"]):
glob.dog = datadogClient.datadogClient(glob.conf.config["datadog"]["apikey"], glob.conf.config["datadog"]["appkey"])
else:
consoleHelper.printColored("[!] Warning! Datadog stats tracking is disabled!", bcolors.YELLOW)
except:
consoleHelper.printColored("[!] Error while starting Datadog client! Please check your config.ini and run the server again", bcolors.RED)
# Connect to pubsub channels
pubSub.listener(glob.redis, {
"lets:beatmap_updates": beatmapUpdateHandler.handler(),
}).start()
# Server start message and console output
consoleHelper.printColored("> L.E.T.S. is listening for clients on {}:{}...".format(glob.conf.config["server"]["host"], serverPort), bcolors.GREEN)
log.logMessage("Server started!", discord="bunker", of="info.txt", stdout=False)
# Start Tornado
glob.application.listen(serverPort, address=glob.conf.config["server"]["host"])
tornado.ioloop.IOLoop.instance().start()
finally:
# Perform some clean up
print("> Disposing server... ")
glob.fileBuffers.flushAll()
consoleHelper.printColored("Goodbye!", bcolors.GREEN)

0
objects/__init__.py Normal file
View File

340
objects/beatmap.pyx Normal file
View File

@ -0,0 +1,340 @@
import time
from common.log import logUtils as log
from constants import rankedStatuses
from helpers import osuapiHelper
from objects import glob
class beatmap:
__slots__ = ["songName", "fileMD5", "rankedStatus", "rankedStatusFrozen", "beatmapID", "beatmapSetID", "offset",
"rating", "starsStd", "starsTaiko", "starsCtb", "starsMania", "AR", "OD", "maxCombo", "hitLength",
"bpm", "playcount" ,"passcount", "refresh"]
def __init__(self, md5 = None, beatmapSetID = None, gameMode = 0, refresh=False):
"""
Initialize a beatmap object.
md5 -- beatmap md5. Optional.
beatmapSetID -- beatmapSetID. Optional.
"""
self.songName = ""
self.fileMD5 = ""
self.rankedStatus = rankedStatuses.NOT_SUBMITTED
self.rankedStatusFrozen = 0
self.beatmapID = 0
self.beatmapSetID = 0
self.offset = 0 # Won't implement
self.rating = 10.0 # Won't implement
self.starsStd = 0.0 # stars for converted
self.starsTaiko = 0.0 # stars for converted
self.starsCtb = 0.0 # stars for converted
self.starsMania = 0.0 # stars for converted
self.AR = 0.0
self.OD = 0.0
self.maxCombo = 0
self.hitLength = 0
self.bpm = 0
# Statistics for ranking panel
self.playcount = 0
# Force refresh from osu api
self.refresh = refresh
if md5 is not None and beatmapSetID is not None:
self.setData(md5, beatmapSetID)
def addBeatmapToDB(self):
"""
Add current beatmap data in db if not in yet
"""
# Make sure the beatmap is not already in db
bdata = glob.db.fetch("SELECT id, ranked_status_freezed, ranked FROM beatmaps WHERE beatmap_md5 = %s OR beatmap_id = %s LIMIT 1", [self.fileMD5, self.beatmapID])
if bdata is not None:
# This beatmap is already in db, remove old record
# Get current frozen status
frozen = bdata["ranked_status_freezed"]
if frozen == 1:
self.rankedStatus = bdata["ranked"]
log.debug("Deleting old beatmap data ({})".format(bdata["id"]))
glob.db.execute("DELETE FROM beatmaps WHERE id = %s LIMIT 1", [bdata["id"]])
else:
# Unfreeze beatmap status
frozen = 0
# Add new beatmap data
log.debug("Saving beatmap data in db...")
glob.db.execute("INSERT INTO `beatmaps` (`id`, `beatmap_id`, `beatmapset_id`, `beatmap_md5`, `song_name`, `ar`, `od`, `difficulty_std`, `difficulty_taiko`, `difficulty_ctb`, `difficulty_mania`, `max_combo`, `hit_length`, `bpm`, `ranked`, `latest_update`, `ranked_status_freezed`) VALUES (NULL, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);", [
self.beatmapID,
self.beatmapSetID,
self.fileMD5,
self.songName.encode("utf-8", "ignore").decode("utf-8"),
self.AR,
self.OD,
self.starsStd,
self.starsTaiko,
self.starsCtb,
self.starsMania,
self.maxCombo,
self.hitLength,
self.bpm,
self.rankedStatus if frozen == 0 else 2,
int(time.time()),
frozen
])
def setDataFromDB(self, md5):
"""
Set this object's beatmap data from db.
md5 -- beatmap md5
return -- True if set, False if not set
"""
# Get data from DB
data = glob.db.fetch("SELECT * FROM beatmaps WHERE beatmap_md5 = %s LIMIT 1", [md5])
# Make sure the query returned something
if data is None:
return False
# Make sure the beatmap is not an old one
if data["difficulty_taiko"] == 0 and data["difficulty_ctb"] == 0 and data["difficulty_mania"] == 0:
log.debug("Difficulty for non-std gamemodes not found in DB, refreshing data from osu!api...")
return False
# Set cached data period
expire = int(glob.conf.config["server"]["beatmapcacheexpire"])
# If the beatmap is ranked, we don't need to refresh data from osu!api that often
if data["ranked"] >= rankedStatuses.RANKED and data["ranked_status_freezed"] == 0:
expire *= 3
# Make sure the beatmap data in db is not too old
if int(expire) > 0 and time.time() > data["latest_update"]+int(expire):
if data["ranked_status_freezed"] == 1:
self.setDataFromDict(data)
return False
# Data in DB, set beatmap data
log.debug("Got beatmap data from db")
self.setDataFromDict(data)
return True
def setDataFromDict(self, data):
"""
Set this object's beatmap data from data dictionary.
data -- data dictionary
return -- True if set, False if not set
"""
self.songName = data["song_name"]
self.fileMD5 = data["beatmap_md5"]
self.rankedStatus = int(data["ranked"])
self.rankedStatusFrozen = int(data["ranked_status_freezed"])
self.beatmapID = int(data["beatmap_id"])
self.beatmapSetID = int(data["beatmapset_id"])
self.AR = float(data["ar"])
self.OD = float(data["od"])
self.starsStd = float(data["difficulty_std"])
self.starsTaiko = float(data["difficulty_taiko"])
self.starsCtb = float(data["difficulty_ctb"])
self.starsMania = float(data["difficulty_mania"])
self.maxCombo = int(data["max_combo"])
self.hitLength = int(data["hit_length"])
self.bpm = int(data["bpm"])
# Ranking panel statistics
self.playcount = int(data["playcount"]) if "playcount" in data else 0
self.passcount = int(data["passcount"]) if "passcount" in data else 0
def setDataFromOsuApi(self, md5, beatmapSetID):
"""
Set this object's beatmap data from osu!api.
md5 -- beatmap md5
beatmapSetID -- beatmap set ID, used to check if a map is outdated
return -- True if set, False if not set
"""
# Check if osuapi is enabled
mainData = None
dataStd = osuapiHelper.osuApiRequest("get_beatmaps", "h={}&a=1&m=0".format(md5))
dataTaiko = osuapiHelper.osuApiRequest("get_beatmaps", "h={}&a=1&m=1".format(md5))
dataCtb = osuapiHelper.osuApiRequest("get_beatmaps", "h={}&a=1&m=2".format(md5))
dataMania = osuapiHelper.osuApiRequest("get_beatmaps", "h={}&a=1&m=3".format(md5))
if dataStd is not None:
mainData = dataStd
elif dataTaiko is not None:
mainData = dataTaiko
elif dataCtb is not None:
mainData = dataCtb
elif dataMania is not None:
mainData = dataMania
# If the beatmap is frozen and still valid from osu!api, return True so we don't overwrite anything
if mainData is not None and self.rankedStatusFrozen == 1:
return True
# Can't fint beatmap by MD5. The beatmap has been updated. Check with beatmap set ID
if mainData is None:
log.debug("osu!api data is None")
dataStd = osuapiHelper.osuApiRequest("get_beatmaps", "s={}&a=1&m=0".format(beatmapSetID))
dataTaiko = osuapiHelper.osuApiRequest("get_beatmaps", "s={}&a=1&m=1".format(beatmapSetID))
dataCtb = osuapiHelper.osuApiRequest("get_beatmaps", "s={}&a=1&m=2".format(beatmapSetID))
dataMania = osuapiHelper.osuApiRequest("get_beatmaps", "s={}&a=1&m=3".format(beatmapSetID))
if dataStd is not None:
mainData = dataStd
elif dataTaiko is not None:
mainData = dataTaiko
elif dataCtb is not None:
mainData = dataCtb
elif dataMania is not None:
mainData = dataMania
if mainData is None:
# Still no data, beatmap is not submitted
return False
else:
# We have some data, but md5 doesn't match. Beatmap is outdated
self.rankedStatus = rankedStatuses.NEED_UPDATE
return True
# We have data from osu!api, set beatmap data
log.debug("Got beatmap data from osu!api")
self.songName = "{} - {} [{}]".format(mainData["artist"], mainData["title"], mainData["version"])
self.fileMD5 = md5
self.rankedStatus = convertRankedStatus(int(mainData["approved"]))
self.beatmapID = int(mainData["beatmap_id"])
self.beatmapSetID = int(mainData["beatmapset_id"])
self.AR = float(mainData["diff_approach"])
self.OD = float(mainData["diff_overall"])
# Determine stars for every mode
self.starsStd = 0.0
self.starsTaiko = 0.0
self.starsCtb = 0.0
self.starsMania = 0.0
if dataStd is not None:
self.starsStd = float(dataStd["difficultyrating"])
if dataTaiko is not None:
self.starsTaiko = float(dataTaiko["difficultyrating"])
if dataCtb is not None:
self.starsCtb = float(dataCtb["difficultyrating"])
if dataMania is not None:
self.starsMania = float(dataMania["difficultyrating"])
self.maxCombo = int(mainData["max_combo"]) if mainData["max_combo"] is not None else 0
self.hitLength = int(mainData["hit_length"])
if mainData["bpm"] is not None:
self.bpm = int(float(mainData["bpm"]))
else:
self.bpm = -1
return True
def setData(self, md5, beatmapSetID):
"""
Set this object's beatmap data from highest level possible.
md5 -- beatmap MD5
beatmapSetID -- beatmap set ID
"""
# Get beatmap from db
dbResult = self.setDataFromDB(md5)
# Force refresh from osu api.
# We get data before to keep frozen maps ranked
# if they haven't been updated
if dbResult and self.refresh:
dbResult = False
if not dbResult:
log.debug("Beatmap not found in db")
# If this beatmap is not in db, get it from osu!api
apiResult = self.setDataFromOsuApi(md5, beatmapSetID)
if not apiResult:
# If it's not even in osu!api, this beatmap is not submitted
self.rankedStatus = rankedStatuses.NOT_SUBMITTED
elif self.rankedStatus != rankedStatuses.NOT_SUBMITTED and self.rankedStatus != rankedStatuses.NEED_UPDATE:
# We get beatmap data from osu!api, save it in db
self.addBeatmapToDB()
else:
log.debug("Beatmap found in db")
log.debug("{}\n{}\n{}\n{}".format(self.starsStd, self.starsTaiko, self.starsCtb, self.starsMania))
def getData(self, totalScores=0, version=4):
"""
Return this beatmap's data (header) for getscores
return -- beatmap header for getscores
"""
# Fix loved maps for old clients
if version < 4 and self.rankedStatus == rankedStatuses.LOVED:
rankedStatusOutput = rankedStatuses.QUALIFIED
else:
rankedStatusOutput = self.rankedStatus
data = "{}|false".format(rankedStatusOutput)
if self.rankedStatus != rankedStatuses.NOT_SUBMITTED and self.rankedStatus != rankedStatuses.NEED_UPDATE and self.rankedStatus != rankedStatuses.UNKNOWN:
# If the beatmap is updated and exists, the client needs more data
data += "|{}|{}|{}\n{}\n{}\n{}\n".format(self.beatmapID, self.beatmapSetID, totalScores, self.offset, self.songName, self.rating)
# Return the header
return data
def getCachedTillerinoPP(self):
"""
Returned cached pp values for 100, 99, 98 and 95 acc nomod
(used ONLY with Tillerino, pp is always calculated with oppai when submitting scores)
return -- list with pp values. [0,0,0,0] if not cached.
"""
data = glob.db.fetch("SELECT pp_100, pp_99, pp_98, pp_95 FROM beatmaps WHERE beatmap_md5 = %s LIMIT 1", [self.fileMD5])
if data is None:
return [0,0,0,0]
return [data["pp_100"], data["pp_99"], data["pp_98"], data["pp_95"]]
def saveCachedTillerinoPP(self, l):
"""
Save cached pp for tillerino
l -- list with 4 default pp values ([100,99,98,95])
"""
glob.db.execute("UPDATE beatmaps SET pp_100 = %s, pp_99 = %s, pp_98 = %s, pp_95 = %s WHERE beatmap_md5 = %s", [l[0], l[1], l[2], l[3], self.fileMD5])
@property
def is_rankable(self):
return self.rankedStatus >= rankedStatuses.RANKED and self.rankedStatus != rankedStatuses.UNKNOWN
def convertRankedStatus(approvedStatus):
"""
Convert approved_status (from osu!api) to ranked status (for getscores)
approvedStatus -- approved status, from osu!api
return -- rankedStatus for getscores
"""
approvedStatus = int(approvedStatus)
if approvedStatus <= 0:
return rankedStatuses.PENDING
elif approvedStatus == 1:
return rankedStatuses.RANKED
elif approvedStatus == 2:
return rankedStatuses.APPROVED
elif approvedStatus == 3:
return rankedStatuses.QUALIFIED
elif approvedStatus == 4:
return rankedStatuses.LOVED
else:
return rankedStatuses.UNKNOWN
def incrementPlaycount(md5, passed):
"""
Increment playcount (and passcount) for a beatmap
md5 -- beatmap md5
passed -- if True, increment passcount too
"""
glob.db.execute("UPDATE beatmaps SET playcount = playcount+1 WHERE beatmap_md5 = %s LIMIT 1", [md5])
if passed:
glob.db.execute("UPDATE beatmaps SET passcount = passcount+1 WHERE beatmap_md5 = %s LIMIT 1", [md5])

34
objects/glob.py Normal file
View File

@ -0,0 +1,34 @@
import personalBestCache
import userStatsCache
from common.ddog import datadogClient
from common.files import fileBuffer, fileLocks
from common.web import schiavo
try:
with open("version") as f:
VERSION = f.read().strip()
except:
VERSION = "Unknown"
ACHIEVEMENTS_VERSION = 1
DATADOG_PREFIX = "lets"
BOT_NAME = "Charlotte"
db = None
redis = None
conf = None
application = None
pool = None
pascoa = {}
busyThreads = 0
debug = False
sentry = False
# Cache and objects
fLocks = fileLocks.fileLocks()
userStatsCache = userStatsCache.userStatsCache()
personalBestCache = personalBestCache.personalBestCache()
fileBuffers = fileBuffer.buffersList()
dog = datadogClient.datadogClient()
schiavo = schiavo.schiavo()
achievementClasses = {}

241
objects/relaxboard.pyx Normal file
View File

@ -0,0 +1,241 @@
from objects import rxscore
from common.ripple import userUtils
from constants import rankedStatuses
from common.constants import mods as modsEnum
from common.constants import privileges
from objects import glob
class scoreboard:
def __init__(self, username, gameMode, beatmap, setScores = True, country = False, friends = False, mods = -1):
"""
Initialize a leaderboard object
username -- username of who's requesting the scoreboard. None if not known
gameMode -- requested gameMode
beatmap -- beatmap objecy relative to this leaderboard
setScores -- if True, will get personal/top 50 scores automatically. Optional. Default: True
"""
self.scores = [] # list containing all top 50 scores objects. First object is personal best
self.totalScores = 0
self.personalBestRank = -1 # our personal best rank, -1 if not found yet
self.username = username # username of who's requesting the scoreboard. None if not known
self.userID = userUtils.getID(self.username) # username's userID
self.gameMode = gameMode # requested gameMode
self.beatmap = beatmap # beatmap objecy relative to this leaderboard
self.country = country
self.friends = friends
self.mods = mods
if setScores:
self.setScores()
def setScores(self):
"""
Set scores list
"""
isPremium = userUtils.getPrivileges(self.userID) & privileges.USER_PREMIUM
def buildQuery(params):
return "{select} {joins} {country} {mods} {friends} {order} {limit}".format(**params)
# Reset score list
self.scores = []
self.scores.append(-1)
# Make sure the beatmap is ranked
if self.beatmap.rankedStatus < rankedStatuses.RANKED:
return
# Query parts
cdef str select = ""
cdef str joins = ""
cdef str country = ""
cdef str mods = ""
cdef str friends = ""
cdef str order = ""
cdef str limit = ""
# Find personal best score
if self.userID != 0:
# Query parts
select = "SELECT id FROM scores_relax WHERE userid = %(userid)s AND beatmap_md5 = %(md5)s AND play_mode = %(mode)s AND completed = 3"
# Mods
if self.mods > -1:
mods = "AND mods = %(mods)s"
# Friends ranking
if self.friends:
friends = "AND (scores_relax.userid IN (SELECT user2 FROM users_relationships WHERE user1 = %(userid)s) OR scores_relax.userid = %(userid)s)"
else:
friends = ""
# Sort and limit at the end
order = "ORDER BY pp DESC"
limit = "LIMIT 1"
# Build query, get params and run query
query = buildQuery(locals())
params = {"userid": self.userID, "md5": self.beatmap.fileMD5, "mode": self.gameMode, "mods": self.mods}
personalBestScore = glob.db.fetch(query, params)
else:
personalBestScore = None
# Output our personal best if found
if personalBestScore is not None:
s = rxscore.score(personalBestScore["id"])
self.scores[0] = s
else:
# No personal best
self.scores[0] = -1
# Get top 50 scores
select = "SELECT *"
joins = "FROM scores_relax STRAIGHT_JOIN users ON scores_relax.userid = users.id STRAIGHT_JOIN users_stats ON users.id = users_stats.id WHERE scores_relax.beatmap_md5 = %(beatmap_md5)s AND scores_relax.play_mode = %(play_mode)s AND scores_relax.completed = 3 AND (users.privileges & 1 > 0 OR users.id = %(userid)s)"
# Country ranking
if self.country:
""" Honestly this is more of a preference thing than something that should be premium only?
if isPremium:
country = "AND user_clans.clan = (SELECT clan FROM user_clans WHERE user = %(userid)s LIMIT 1)"
else:
"""
country = "AND users_stats.country = (SELECT country FROM users_stats WHERE id = %(userid)s LIMIT 1)"
else:
country = ""
# Mods ranking (ignore auto, since we use it for pp sorting)
if self.mods > -1:
mods = "AND scores_relax.mods = %(mods)s"
else:
mods = ""
# Friends ranking
if self.friends:
friends = "AND (scores_relax.userid IN (SELECT user2 FROM users_relationships WHERE user1 = %(userid)s) OR scores_relax.userid = %(userid)s)"
else:
friends = ""
order = "ORDER BY pp DESC"
if isPremium: # Premium members can see up to 100 scores on leaderboards
limit = "LIMIT 100"
else:
limit = "LIMIT 50"
# Build query, get params and run query
query = buildQuery(locals())
params = {"beatmap_md5": self.beatmap.fileMD5, "play_mode": self.gameMode, "userid": self.userID, "mods": self.mods}
topScores = glob.db.fetchAll(query, params)
# Set data for all scores
cdef int c = 1
cdef dict topScore
if topScores is not None:
for topScore in topScores:
# Create score object
s = rxscore.score(topScore["id"], setData=False)
# Set data and rank from topScores's row
s.setDataFromDict(topScore)
s.setRank(c)
# Check if this top 50 score is our personal best
if s.playerName == self.username:
self.personalBestRank = c
# Add this score to scores list and increment rank
self.scores.append(s)
c+=1
'''# If we have more than 50 scores, run query to get scores count
if c >= 50:
# Count all scores on this map
select = "SELECT COUNT(*) AS count"
limit = "LIMIT 1"
# Build query, get params and run query
query = buildQuery(locals())
count = glob.db.fetch(query, params)
if count == None:
self.totalScores = 0
else:
self.totalScores = count["count"]
else:
self.totalScores = c-1'''
# If personal best score was not in top 50, try to get it from cache
if personalBestScore is not None and self.personalBestRank < 1:
self.personalBestRank = glob.personalBestCache.get(self.userID, self.beatmap.fileMD5, self.country, self.friends, self.mods)
# It's not even in cache, get it from db
if personalBestScore is not None and self.personalBestRank < 1:
self.setPersonalBest()
# Cache our personal best rank so we can eventually use it later as
# before personal best rank" in submit modular when building ranking panel
if self.personalBestRank >= 1:
glob.personalBestCache.set(self.userID, self.personalBestRank, self.beatmap.fileMD5)
def setPersonalBest(self):
"""
Set personal best rank ONLY
Ikr, that query is HUGE but xd
"""
# Before running the HUGE query, make sure we have a score on that map
cdef str query = "SELECT id FROM scores_relax WHERE beatmap_md5 = %(md5)s AND userid = %(userid)s AND play_mode = %(mode)s AND completed = 3"
# Mods
if self.mods > -1:
query += " AND scores_relax.mods = %(mods)s"
# Friends ranking
if self.friends:
query += " AND (scores_relax.userid IN (SELECT user2 FROM users_relationships WHERE user1 = %(userid)s) OR scores_relax.userid = %(userid)s)"
# Sort and limit at the end
query += " LIMIT 1"
hasScore = glob.db.fetch(query, {"md5": self.beatmap.fileMD5, "userid": self.userID, "mode": self.gameMode, "mods": self.mods})
if hasScore is None:
return
# We have a score, run the huge query
# Base query
query = """SELECT COUNT(*) AS rank FROM scores_relax STRAIGHT_JOIN users ON scores_relax.userid = users.id STRAIGHT_JOIN users_stats ON users.id = users_stats.id WHERE scores_relax.pp >= (
SELECT pp FROM scores_relax WHERE beatmap_md5 = %(md5)s AND play_mode = %(mode)s AND completed = 3 AND userid = %(userid)s LIMIT 1
) AND scores_relax.beatmap_md5 = %(md5)s AND scores_relax.play_mode = %(mode)s AND scores_relax.completed = 3 AND users.privileges & 1 > 0"""
# Country
if self.country:
query += " AND users_stats.country = (SELECT country FROM users_stats WHERE id = %(userid)s LIMIT 1)"
# Mods
if self.mods > -1:
query += " AND scores_relax.mods = %(mods)s"
# Friends
if self.friends:
query += " AND (scores_relax.userid IN (SELECT user2 FROM users_relationships WHERE user1 = %(userid)s) OR scores_relax.userid = %(userid)s)"
# Sort and limit at the end
query += " ORDER BY pp DESC LIMIT 1"
result = glob.db.fetch(query, {"md5": self.beatmap.fileMD5, "userid": self.userID, "mode": self.gameMode, "mods": self.mods})
if result is not None:
self.personalBestRank = result["rank"]
def getScoresData(self):
"""
Return scores data for getscores
return -- score data in getscores format
"""
data = ""
# Output personal best
if self.scores[0] == -1:
# We don't have a personal best score
data += "\n"
else:
# Set personal best score rank
self.setPersonalBest() # sets self.personalBestRank with the huge query
self.scores[0].setRank(self.personalBestRank)
data += self.scores[0].getData()
# Output top 50 scores
for i in self.scores[1:]:
data += i.getData(pp=1)
return data

274
objects/rxscore.pyx Normal file
View File

@ -0,0 +1,274 @@
import time
from objects import beatmap
from common.constants import gameModes
from common.log import logUtils as log
from common.ripple import userUtils
from constants import rankedStatuses
from common.ripple import scoreUtils
from objects import glob
from pp import rippoppai
from pp import rxoppai
from pp import wifipiano2
from pp import cicciobello
class score:
PP_CALCULATORS = {
gameModes.STD: rxoppai.oppai,
gameModes.TAIKO: rippoppai.oppai,
gameModes.CTB: cicciobello.Cicciobello,
gameModes.MANIA: wifipiano2.piano
}
__slots__ = ["scoreID", "playerName", "score", "maxCombo", "c50", "c100", "c300", "cMiss", "cKatu", "cGeki",
"fullCombo", "mods", "playerUserID","rank","date", "hasReplay", "fileMd5", "passed", "playDateTime",
"gameMode", "completed", "accuracy", "pp", "oldPersonalBest", "rankedScoreIncrease"]
def __init__(self, scoreID = None, rank = None, setData = True):
"""
Initialize a (empty) score object.
scoreID -- score ID, used to get score data from db. Optional.
rank -- score rank. Optional
setData -- if True, set score data from db using scoreID. Optional.
"""
self.scoreID = 0
self.playerName = "nospe"
self.score = 0
self.maxCombo = 0
self.c50 = 0
self.c100 = 0
self.c300 = 0
self.cMiss = 0
self.cKatu = 0
self.cGeki = 0
self.fullCombo = False
self.mods = 0
self.playerUserID = 0
self.rank = rank # can be empty string too
self.date = 0
self.hasReplay = 0
self.fileMd5 = None
self.passed = False
self.playDateTime = 0
self.gameMode = 0
self.completed = 0
self.accuracy = 0.00
self.pp = 0.00
self.oldPersonalBest = 0
self.rankedScoreIncrease = 0
if scoreID is not None and setData == True:
self.setDataFromDB(scoreID, rank)
def calculateAccuracy(self):
"""
Calculate and set accuracy for that score
"""
if self.gameMode == 0:
# std
totalPoints = self.c50*50+self.c100*100+self.c300*300
totalHits = self.c300+self.c100+self.c50+self.cMiss
if totalHits == 0:
self.accuracy = 1
else:
self.accuracy = totalPoints/(totalHits*300)
elif self.gameMode == 1:
# taiko
totalPoints = (self.c100*50)+(self.c300*100)
totalHits = self.cMiss+self.c100+self.c300
if totalHits == 0:
self.accuracy = 1
else:
self.accuracy = totalPoints / (totalHits * 100)
elif self.gameMode == 2:
# ctb
fruits = self.c300+self.c100+self.c50
totalFruits = fruits+self.cMiss+self.cKatu
if totalFruits == 0:
self.accuracy = 1
else:
self.accuracy = fruits / totalFruits
elif self.gameMode == 3:
# mania
totalPoints = self.c50*50+self.c100*100+self.cKatu*200+self.c300*300+self.cGeki*300
totalHits = self.cMiss+self.c50+self.c100+self.c300+self.cGeki+self.cKatu
self.accuracy = totalPoints / (totalHits * 300)
else:
# unknown gamemode
self.accuracy = 0
def setRank(self, rank):
"""
Force a score rank
rank -- new score rank
"""
self.rank = rank
def setDataFromDB(self, scoreID, rank = None):
"""
Set this object's score data from db
Sets playerUserID too
scoreID -- score ID
rank -- rank in scoreboard. Optional.
"""
data = glob.db.fetch("SELECT scores_relax.*, users.username FROM scores_relax LEFT JOIN users ON users.id = scores_relax.userid WHERE scores_relax.id = %s LIMIT 1", [scoreID])
if data is not None:
self.setDataFromDict(data, rank)
def setDataFromDict(self, data, rank = None):
"""
Set this object's score data from dictionary
Doesn't set playerUserID
data -- score dictionarty
rank -- rank in scoreboard. Optional.
"""
#print(str(data))
self.scoreID = data["id"]
if "username" in data:
self.playerName = userUtils.getClan(data["userid"])
else:
self.playerName = userUtils.getUsername(data["userid"])
self.playerUserID = data["userid"]
self.score = data["score"]
self.maxCombo = data["max_combo"]
self.gameMode = data["play_mode"]
self.c50 = data["50_count"]
self.c100 = data["100_count"]
self.c300 = data["300_count"]
self.cMiss = data["misses_count"]
self.cKatu = data["katus_count"]
self.cGeki = data["gekis_count"]
self.fullCombo = True if data["full_combo"] == 1 else False
self.mods = data["mods"]
self.rank = rank if rank is not None else ""
self.date = data["time"]
self.fileMd5 = data["beatmap_md5"]
self.completed = data["completed"]
#if "pp" in data:
self.pp = data["pp"]
self.calculateAccuracy()
def setDataFromScoreData(self, scoreData):
"""
Set this object's score data from scoreData list (submit modular)
scoreData -- scoreData list
"""
if len(scoreData) >= 16:
self.fileMd5 = scoreData[0]
self.playerName = scoreData[1].strip()
# %s%s%s = scoreData[2]
self.c300 = int(scoreData[3])
self.c100 = int(scoreData[4])
self.c50 = int(scoreData[5])
self.cGeki = int(scoreData[6])
self.cKatu = int(scoreData[7])
self.cMiss = int(scoreData[8])
self.score = int(scoreData[9])
self.maxCombo = int(scoreData[10])
self.fullCombo = True if scoreData[11] == 'True' else False
#self.rank = scoreData[12]
self.mods = int(scoreData[13])
self.passed = True if scoreData[14] == 'True' else False
self.gameMode = int(scoreData[15])
#self.playDateTime = int(scoreData[16])
self.playDateTime = int(time.time())
self.calculateAccuracy()
#osuVersion = scoreData[17]
self.calculatePP()
# Set completed status
self.setCompletedStatus()
def getData(self, pp=True):
"""Return score row relative to this score for getscores"""
return "{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|1\n".format(
self.scoreID,
self.playerName,
int(self.pp) if pp else self.score,
self.maxCombo,
self.c50,
self.c100,
self.c300,
self.cMiss,
self.cKatu,
self.cGeki,
self.fullCombo,
self.mods,
self.playerUserID,
self.rank,
self.date)
def setCompletedStatus(self):
"""
Set this score completed status and rankedScoreIncrease
"""
self.completed = 0
if self.passed == True and scoreUtils.isRankable(self.mods):
# Get userID
userID = userUtils.getID(self.playerName)
# Make sure we don't have another score identical to this one
duplicate = glob.db.fetch("SELECT id FROM scores_relax WHERE userid = %s AND beatmap_md5 = %s AND play_mode = %s AND time = %s AND score = %s LIMIT 1", [userID, self.fileMd5, self.gameMode, self.date, self.score])
if duplicate is not None:
# Found same score in db. Don't save this score.
self.completed = -1
return
# No duplicates found.
# Get right "completed" value
personalBest = glob.db.fetch("SELECT id, pp, score FROM scores_relax WHERE userid = %s AND beatmap_md5 = %s AND play_mode = %s AND completed = 3 LIMIT 1", [userID, self.fileMd5, self.gameMode])
if personalBest is None:
# This is our first score on this map, so it's our best score
self.completed = 3
self.rankedScoreIncrease = self.score
self.oldPersonalBest = 0
else:
# Compare personal best's score with current score
if self.pp > personalBest["pp"]:
# New best score
self.completed = 3
self.rankedScoreIncrease = self.score-personalBest["score"]
self.oldPersonalBest = personalBest["id"]
else:
self.completed = 2
self.rankedScoreIncrease = 0
self.oldPersonalBest = 0
log.info("Completed status: {}".format(self.completed))
def saveScoreInDB(self):
"""
Save this score in DB (if passed and mods are valid)
"""
# Add this score
if self.completed >= 2:
query = "INSERT INTO scores_relax (id, beatmap_md5, userid, score, max_combo, full_combo, mods, 300_count, 100_count, 50_count, katus_count, gekis_count, misses_count, time, play_mode, completed, accuracy, pp) VALUES (NULL, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);"
self.scoreID = int(glob.db.execute(query, [self.fileMd5, userUtils.getID(self.playerName), self.score, self.maxCombo, 1 if self.fullCombo == True else 0, self.mods, self.c300, self.c100, self.c50, self.cKatu, self.cGeki, self.cMiss, self.playDateTime, self.gameMode, self.completed, self.accuracy * 100, self.pp]))
# Set old personal best to completed = 2
if self.oldPersonalBest != 0:
glob.db.execute("UPDATE scores_relax SET completed = 2 WHERE id = %s", [self.oldPersonalBest])
def calculatePP(self, b = None):
"""
Calculate this score's pp value if completed == 3
"""
# Create beatmap object
if b is None:
b = beatmap.beatmap(self.fileMd5, 0)
# Calculate pp
if b.rankedStatus >= rankedStatuses.RANKED and b.rankedStatus != rankedStatuses.LOVED and b.rankedStatus != rankedStatuses.UNKNOWN \
and scoreUtils.isRankable(self.mods) and self.passed and self.gameMode in score.PP_CALCULATORS:
calculator = score.PP_CALCULATORS[self.gameMode](b, self)
self.pp = calculator.pp
else:
self.pp = 0

273
objects/score.pyx Normal file
View File

@ -0,0 +1,273 @@
import time
from objects import beatmap
from common.constants import gameModes
from common.log import logUtils as log
from common.ripple import userUtils
from constants import rankedStatuses
from common.ripple import scoreUtils
from objects import glob
from pp import rippoppai
from pp import wifipiano2
from pp import cicciobello
class score:
PP_CALCULATORS = {
gameModes.STD: rippoppai.oppai,
gameModes.TAIKO: rippoppai.oppai,
gameModes.CTB: cicciobello.Cicciobello,
gameModes.MANIA: wifipiano2.piano
}
__slots__ = ["scoreID", "playerName", "score", "maxCombo", "c50", "c100", "c300", "cMiss", "cKatu", "cGeki",
"fullCombo", "mods", "playerUserID","rank","date", "hasReplay", "fileMd5", "passed", "playDateTime",
"gameMode", "completed", "accuracy", "pp", "oldPersonalBest", "rankedScoreIncrease"]
def __init__(self, scoreID = None, rank = None, setData = True):
"""
Initialize a (empty) score object.
scoreID -- score ID, used to get score data from db. Optional.
rank -- score rank. Optional
setData -- if True, set score data from db using scoreID. Optional.
"""
self.scoreID = 0
self.playerName = "nospe"
self.score = 0
self.maxCombo = 0
self.c50 = 0
self.c100 = 0
self.c300 = 0
self.cMiss = 0
self.cKatu = 0
self.cGeki = 0
self.fullCombo = False
self.mods = 0
self.playerUserID = 0
self.rank = rank # can be empty string too
self.date = 0
self.hasReplay = 0
self.fileMd5 = None
self.passed = False
self.playDateTime = 0
self.gameMode = 0
self.completed = 0
self.accuracy = 0.00
self.pp = 0.00
self.oldPersonalBest = 0
self.rankedScoreIncrease = 0
if scoreID is not None and setData == True:
self.setDataFromDB(scoreID, rank)
def calculateAccuracy(self):
"""
Calculate and set accuracy for that score
"""
if self.gameMode == 0:
# std
totalPoints = self.c50*50+self.c100*100+self.c300*300
totalHits = self.c300+self.c100+self.c50+self.cMiss
if totalHits == 0:
self.accuracy = 1
else:
self.accuracy = totalPoints/(totalHits*300)
elif self.gameMode == 1:
# taiko
totalPoints = (self.c100*50)+(self.c300*100)
totalHits = self.cMiss+self.c100+self.c300
if totalHits == 0:
self.accuracy = 1
else:
self.accuracy = totalPoints / (totalHits * 100)
elif self.gameMode == 2:
# ctb
fruits = self.c300+self.c100+self.c50
totalFruits = fruits+self.cMiss+self.cKatu
if totalFruits == 0:
self.accuracy = 1
else:
self.accuracy = fruits / totalFruits
elif self.gameMode == 3:
# mania
totalPoints = self.c50*50+self.c100*100+self.cKatu*200+self.c300*300+self.cGeki*300
totalHits = self.cMiss+self.c50+self.c100+self.c300+self.cGeki+self.cKatu
self.accuracy = totalPoints / (totalHits * 300)
else:
# unknown gamemode
self.accuracy = 0
def setRank(self, rank):
"""
Force a score rank
rank -- new score rank
"""
self.rank = rank
def setDataFromDB(self, scoreID, rank = None):
"""
Set this object's score data from db
Sets playerUserID too
scoreID -- score ID
rank -- rank in scoreboard. Optional.
"""
data = glob.db.fetch("SELECT scores.*, users.username FROM scores LEFT JOIN users ON users.id = scores.userid WHERE scores.id = %s LIMIT 1", [scoreID])
if data is not None:
self.setDataFromDict(data, rank)
def setDataFromDict(self, data, rank = None):
"""
Set this object's score data from dictionary
Doesn't set playerUserID
data -- score dictionarty
rank -- rank in scoreboard. Optional.
"""
#print(str(data))
self.scoreID = data["id"]
if "username" in data:
self.playerName = userUtils.getClan(data["userid"])
else:
self.playerName = userUtils.getUsername(data["userid"])
self.playerUserID = data["userid"]
self.score = data["score"]
self.maxCombo = data["max_combo"]
self.gameMode = data["play_mode"]
self.c50 = data["50_count"]
self.c100 = data["100_count"]
self.c300 = data["300_count"]
self.cMiss = data["misses_count"]
self.cKatu = data["katus_count"]
self.cGeki = data["gekis_count"]
self.fullCombo = True if data["full_combo"] == 1 else False
self.mods = data["mods"]
self.rank = rank if rank is not None else ""
self.date = data["time"]
self.fileMd5 = data["beatmap_md5"]
self.completed = data["completed"]
#if "pp" in data:
self.pp = data["pp"]
self.calculateAccuracy()
def setDataFromScoreData(self, scoreData):
"""
Set this object's score data from scoreData list (submit modular)
scoreData -- scoreData list
"""
if len(scoreData) >= 16:
self.fileMd5 = scoreData[0]
self.playerName = scoreData[1].strip()
# %s%s%s = scoreData[2]
self.c300 = int(scoreData[3])
self.c100 = int(scoreData[4])
self.c50 = int(scoreData[5])
self.cGeki = int(scoreData[6])
self.cKatu = int(scoreData[7])
self.cMiss = int(scoreData[8])
self.score = int(scoreData[9])
self.maxCombo = int(scoreData[10])
self.fullCombo = True if scoreData[11] == 'True' else False
#self.rank = scoreData[12]
self.mods = int(scoreData[13])
self.passed = True if scoreData[14] == 'True' else False
self.gameMode = int(scoreData[15])
#self.playDateTime = int(scoreData[16])
self.playDateTime = int(time.time())
self.calculateAccuracy()
#osuVersion = scoreData[17]
# Set completed status
self.setCompletedStatus()
def getData(self, pp=False):
"""Return score row relative to this score for getscores"""
return "{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|1\n".format(
self.scoreID,
self.playerName,
int(self.pp) if pp else self.score,
self.maxCombo,
self.c50,
self.c100,
self.c300,
self.cMiss,
self.cKatu,
self.cGeki,
self.fullCombo,
self.mods,
self.playerUserID,
self.rank,
self.date)
def setCompletedStatus(self):
"""
Set this score completed status and rankedScoreIncrease
"""
self.completed = 0
if self.passed == True and scoreUtils.isRankable(self.mods):
# Get userID
userID = userUtils.getID(self.playerName)
# Make sure we don't have another score identical to this one
duplicate = glob.db.fetch("SELECT id FROM scores WHERE userid = %s AND beatmap_md5 = %s AND play_mode = %s AND score = %s LIMIT 1", [userID, self.fileMd5, self.gameMode, self.score])
if duplicate is not None:
# Found same score in db. Don't save this score.
self.completed = -1
return
# No duplicates found.
# Get right "completed" value
personalBest = glob.db.fetch("SELECT id, score FROM scores WHERE userid = %s AND beatmap_md5 = %s AND play_mode = %s AND completed = 3 LIMIT 1", [userID, self.fileMd5, self.gameMode])
if personalBest is None:
# This is our first score on this map, so it's our best score
self.completed = 3
self.rankedScoreIncrease = self.score
self.oldPersonalBest = 0
else:
# Compare personal best's score with current score
if self.score > personalBest["score"]:
# New best score
self.completed = 3
self.rankedScoreIncrease = self.score-personalBest["score"]
self.oldPersonalBest = personalBest["id"]
else:
self.completed = 2
self.rankedScoreIncrease = 0
self.oldPersonalBest = 0
log.debug("Completed status: {}".format(self.completed))
def saveScoreInDB(self):
"""
Save this score in DB (if passed and mods are valid)
"""
# Add this score
if self.completed >= 2:
query = "INSERT INTO scores (id, beatmap_md5, userid, score, max_combo, full_combo, mods, 300_count, 100_count, 50_count, katus_count, gekis_count, misses_count, time, play_mode, completed, accuracy, pp) VALUES (NULL, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);"
self.scoreID = int(glob.db.execute(query, [self.fileMd5, userUtils.getID(self.playerName), self.score, self.maxCombo, 1 if self.fullCombo == True else 0, self.mods, self.c300, self.c100, self.c50, self.cKatu, self.cGeki, self.cMiss, self.playDateTime, self.gameMode, self.completed, self.accuracy * 100, self.pp]))
# Set old personal best to completed = 2
if self.oldPersonalBest != 0:
glob.db.execute("UPDATE scores SET completed = 2 WHERE id = %s", [self.oldPersonalBest])
def calculatePP(self, b = None):
"""
Calculate this score's pp value if completed == 3
"""
# Create beatmap object
if b is None:
b = beatmap.beatmap(self.fileMd5, 0)
# Calculate pp
if b.rankedStatus >= rankedStatuses.RANKED and b.rankedStatus != rankedStatuses.LOVED and b.rankedStatus != rankedStatuses.UNKNOWN \
and scoreUtils.isRankable(self.mods) and self.passed and self.gameMode in score.PP_CALCULATORS:
calculator = score.PP_CALCULATORS[self.gameMode](b, self)
self.pp = calculator.pp
else:
self.pp = 0

240
objects/scoreboard.pyx Normal file
View File

@ -0,0 +1,240 @@
from objects import score
from common.ripple import userUtils
from constants import rankedStatuses
from common.constants import mods as modsEnum
from common.constants import privileges
from objects import glob
class scoreboard:
def __init__(self, username, gameMode, beatmap, setScores = True, country = False, friends = False, mods = -1):
"""
Initialize a leaderboard object
username -- username of who's requesting the scoreboard. None if not known
gameMode -- requested gameMode
beatmap -- beatmap objecy relative to this leaderboard
setScores -- if True, will get personal/top 50 scores automatically. Optional. Default: True
"""
self.scores = [] # list containing all top 50 scores objects. First object is personal best
self.totalScores = 0
self.personalBestRank = -1 # our personal best rank, -1 if not found yet
self.username = username # username of who's requesting the scoreboard. None if not known
self.userID = userUtils.getID(self.username) # username's userID
self.gameMode = gameMode # requested gameMode
self.beatmap = beatmap # beatmap objecy relative to this leaderboard
self.country = country
self.friends = friends
self.mods = mods
if setScores:
self.setScores()
def setScores(self):
"""
Set scores list
"""
isPremium = userUtils.getPrivileges(self.userID) & privileges.USER_PREMIUM
def buildQuery(params):
return "{select} {joins} {country} {mods} {friends} {order} {limit}".format(**params)
# Reset score list
self.scores = []
self.scores.append(-1)
# Make sure the beatmap is ranked
if self.beatmap.rankedStatus < rankedStatuses.RANKED:
return
# Query parts
cdef str select = ""
cdef str joins = ""
cdef str country = ""
cdef str mods = ""
cdef str friends = ""
cdef str order = ""
cdef str limit = ""
# Find personal best score
if self.userID != 0:
# Query parts
select = "SELECT id FROM scores WHERE userid = %(userid)s AND beatmap_md5 = %(md5)s AND play_mode = %(mode)s AND completed = 3"
# Mods
if self.mods > -1:
mods = "AND mods = %(mods)s"
# Friends ranking
if self.friends:
friends = "AND (scores.userid IN (SELECT user2 FROM users_relationships WHERE user1 = %(userid)s) OR scores.userid = %(userid)s)"
# Sort and limit at the end
order = "ORDER BY score DESC"
limit = "LIMIT 1"
# Build query, get params and run query
query = buildQuery(locals())
params = {"userid": self.userID, "md5": self.beatmap.fileMD5, "mode": self.gameMode, "mods": self.mods}
personalBestScore = glob.db.fetch(query, params)
else:
personalBestScore = None
# Output our personal best if found
if personalBestScore is not None:
s = score.score(personalBestScore["id"])
self.scores[0] = s
else:
# No personal best
self.scores[0] = -1
# Get top 50 scores
select = "SELECT *"
joins = "FROM scores STRAIGHT_JOIN users ON scores.userid = users.id STRAIGHT_JOIN users_stats ON users.id = users_stats.id WHERE scores.beatmap_md5 = %(beatmap_md5)s AND scores.play_mode = %(play_mode)s AND scores.completed = 3 AND (users.privileges & 1 > 0 OR users.id = %(userid)s)"
# Country ranking
if self.country:
""" Honestly this is more of a preference thing than something that should be premium only?
if isPremium:
country = "AND user_clans.clan = (SELECT clan FROM user_clans WHERE user = %(userid)s LIMIT 1)"
else:
"""
country = "AND users_stats.country = (SELECT country FROM users_stats WHERE id = %(userid)s LIMIT 1)"
else:
country = ""
# Mods ranking (ignore auto, since we use it for pp sorting)
if self.mods > -1:
mods = "AND scores.mods = %(mods)s"
else:
mods = ""
# Friends ranking
if self.friends:
friends = "AND (scores.userid IN (SELECT user2 FROM users_relationships WHERE user1 = %(userid)s) OR scores.userid = %(userid)s)"
else:
friends = ""
order = "ORDER BY score DESC"
if isPremium: # Premium members can see up to 100 scores on leaderboards
limit = "LIMIT 100"
else:
limit = "LIMIT 50"
# Build query, get params and run query
query = buildQuery(locals())
params = {"beatmap_md5": self.beatmap.fileMD5, "play_mode": self.gameMode, "userid": self.userID, "mods": self.mods}
topScores = glob.db.fetchAll(query, params)
# Set data for all scores
cdef int c = 1
cdef dict topScore
if topScores is not None:
for topScore in topScores:
# Create score object
s = score.score(topScore["id"], setData=False)
# Set data and rank from topScores's row
s.setDataFromDict(topScore)
s.setRank(c)
# Check if this top 50 score is our personal best
if s.playerName == self.username:
self.personalBestRank = c
# Add this score to scores list and increment rank
self.scores.append(s)
c+=1
'''# If we have more than 50 scores, run query to get scores count
if c >= 50:
# Count all scores on this map
select = "SELECT COUNT(*) AS count"
limit = "LIMIT 1"
# Build query, get params and run query
query = buildQuery(locals())
count = glob.db.fetch(query, params)
if count == None:
self.totalScores = 0
else:
self.totalScores = count["count"]
else:
self.totalScores = c-1'''
# If personal best score was not in top 50, try to get it from cache
if personalBestScore is not None and self.personalBestRank < 1:
self.personalBestRank = glob.personalBestCache.get(self.userID, self.beatmap.fileMD5, self.country, self.friends, self.mods)
# It's not even in cache, get it from db
if personalBestScore is not None and self.personalBestRank < 1:
self.setPersonalBest()
# Cache our personal best rank so we can eventually use it later as
# before personal best rank" in submit modular when building ranking panel
if self.personalBestRank >= 1:
glob.personalBestCache.set(self.userID, self.personalBestRank, self.beatmap.fileMD5)
def setPersonalBest(self):
"""
Set personal best rank ONLY
Ikr, that query is HUGE but xd
"""
# Before running the HUGE query, make sure we have a score on that map
cdef str query = "SELECT id FROM scores WHERE beatmap_md5 = %(md5)s AND userid = %(userid)s AND play_mode = %(mode)s AND completed = 3"
# Mods
if self.mods > -1:
query += " AND scores.mods = %(mods)s"
# Friends ranking
if self.friends:
query += " AND (scores.userid IN (SELECT user2 FROM users_relationships WHERE user1 = %(userid)s) OR scores.userid = %(userid)s)"
# Sort and limit at the end
query += " LIMIT 1"
hasScore = glob.db.fetch(query, {"md5": self.beatmap.fileMD5, "userid": self.userID, "mode": self.gameMode, "mods": self.mods})
if hasScore is None:
return
# We have a score, run the huge query
# Base query
query = """SELECT COUNT(*) AS rank FROM scores STRAIGHT_JOIN users ON scores.userid = users.id STRAIGHT_JOIN users_stats ON users.id = users_stats.id WHERE scores.score >= (
SELECT score FROM scores WHERE beatmap_md5 = %(md5)s AND play_mode = %(mode)s AND completed = 3 AND userid = %(userid)s LIMIT 1
) AND scores.beatmap_md5 = %(md5)s AND scores.play_mode = %(mode)s AND scores.completed = 3 AND users.privileges & 1 > 0"""
# Country
if self.country:
query += " AND users_stats.country = (SELECT country FROM users_stats WHERE id = %(userid)s LIMIT 1)"
# Mods
if self.mods > -1:
query += " AND scores.mods = %(mods)s"
# Friends
if self.friends:
query += " AND (scores.userid IN (SELECT user2 FROM users_relationships WHERE user1 = %(userid)s) OR scores.userid = %(userid)s)"
# Sort and limit at the end
query += " ORDER BY score DESC LIMIT 1"
result = glob.db.fetch(query, {"md5": self.beatmap.fileMD5, "userid": self.userID, "mode": self.gameMode, "mods": self.mods})
if result is not None:
self.personalBestRank = result["rank"]
def getScoresData(self):
"""
Return scores data for getscores
return -- score data in getscores format
"""
data = ""
# Output personal best
if self.scores[0] == -1:
# We don't have a personal best score
data += "\n"
else:
# Set personal best score rank
self.setPersonalBest() # sets self.personalBestRank with the huge query
self.scores[0].setRank(self.personalBestRank)
data += self.scores[0].getData()
# Output top 50 scores
for i in self.scores[1:]:
data += i.getData(pp=self.mods > -1 and self.mods & modsEnum.AUTOPLAY > 0)
return data

58
personalBestCache.py Normal file
View File

@ -0,0 +1,58 @@
from common.log import logUtils as log
from common import generalUtils
from objects import glob
class cacheMiss(Exception):
pass
class personalBestCache:
def get(self, userID, fileMd5, country=False, friends=False, mods=-1):
"""
Get cached personal best rank
:param userID: userID
:param fileMd5: beatmap md5
:param country: True if country leaderboard, otherwise False
:param friends: True if friends leaderboard, otherwise False
:param mods: leaderboard mods
:return: 0 if cache miss, otherwise rank number
"""
try:
# Make sure the value is in cache
data = glob.redis.get("lets:personal_best_cache:{}".format(userID))
if data is None:
raise cacheMiss()
# Unpack cached data
data = data.decode("utf-8").split("|")
cachedpersonalBestRank = int(data[0])
cachedfileMd5 = str(data[1])
cachedCountry = generalUtils.stringToBool(data[2])
cachedFriends = generalUtils.stringToBool(data[3])
cachedMods = int(data[4])
# Check if everything matches
if fileMd5 != cachedfileMd5 or country != cachedCountry or friends != cachedFriends or mods != cachedMods:
raise cacheMiss()
# Cache hit
log.debug("personalBestCache hit")
return cachedpersonalBestRank
except cacheMiss:
log.debug("personalBestCache miss")
return 0
def set(self, userID, rank, fileMd5, country=False, friends=False, mods=-1):
"""
Set userID's redis personal best cache
:param userID: userID
:param rank: leaderboard rank
:param fileMd5: beatmap md5
:param country: True if country leaderboard, otherwise False
:param friends: True if friends leaderboard, otherwise False
:param mods: leaderboard mods
:return:
"""
glob.redis.set("lets:personal_best_cache:{}".format(userID), "{}|{}|{}|{}|{}".format(rank, fileMd5, country, friends, mods), 1800)
log.debug("personalBestCache set")

0
pp/__init__.py Normal file
View File

104
pp/catch_the_pp/.gitignore vendored Normal file
View File

@ -0,0 +1,104 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# dotenv
.env
# virtualenv
.venv
venv/
ENV/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.idea/
*.c

674
pp/catch_the_pp/LICENSE Normal file
View File

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.

39
pp/catch_the_pp/README.md Normal file
View File

@ -0,0 +1,39 @@
# Cythonized catch-the-pp
An osu ctb gamemode star/pp calculator made in Cython.
Original repo: [catch-the-pp](https://github.com/osufx/catch-the-pp) by [Sunpy](https://github.com/EmilySunpy).
*Note: This repo is meant to be used as a Python package, not as a standalone program!*
## Changes
- Cythonized all files, functions, classes and methods (with static typing as well)
- Replaced `math.pow` with `**`, this gives _a bit_ of extra speed
- Replaced imports
- Minor code cleaning
## Performance
These are the execution times after running pp calculation (with beatmap parsing and difficulty calculation as well) on `reanimate.osu` 100 times
Pure Python version: `Min: 0.7986021041870117 s, Max: 0.932903528213501 s, Avg: 0.8350819730758667 s`
Cythonized version: `Min: 0.22933077812194824 s, Max: 0.25774192810058594 s, Avg: 0.23836223363876344 s`
## Compiling & Usage
```
$ git clone ... catch_the_pp
$ cd catch_the_pp
$ python3.6 setup.py build_ext --inplace
...
$ cd ..
$ python3.6 -m catch_the_pp.sample
Calculation:
Stars: 1.9046727418899536, PP: 42.187660217285156, MaxCombo: 1286
$ python3.6
>>> from catch_the_pp.osu_parser.beatmap import Beatmap
>>> from catch_the_pp.osu.ctb.difficulty import Difficulty
>>> from catch_the_pp.ppCalc import calculate_pp
>>> beatmap = Beatmap("catch_the_pp/test.osu")
>>> difficulty = Difficulty(beatmap=beatmap, mods=0)
>>> difficulty.star_rating
1.9046727418899536
>>> calculate_pp(diff=difficulty, accuracy=1, combo=beatmap.max_combo, miss=0)
42.187660217285156
```
> Note: You must clone the repo in a folder that has no dashes in its name, because Python modules cannot have dashes in their name! In this example, `catch_the_pp` was used.

View File

View File

@ -0,0 +1,9 @@
STAR_SCALING_FACTOR = 0.145
STRAIN_STEP = 750
DECAY_WEIGHT = 0.94
DECAY_BASE = 0.2
ABSOLUTE_PLAYER_POSITIONING_ERROR = 16
NORMALIZED_HITOBJECT_RADIUS = 41
DIRECTION_CHANGE_BONUS = 12.5
SLIDER_QUALITY = 50

View File

View File

View File

@ -0,0 +1,268 @@
from ... import constants
from ...osu_parser.mathhelper import clamp, sign
cdef class DifficultyObject:
"""
Object that holds strain value etc.
Handled in Difficulty.calculate_strainValues & Difficulty.update_hyperdash_distance.
Used in Difficulty.calculate_difficulty
"""
cdef public float strain, last_movement
cdef public float offset, player_width, scaled_position, hyperdash_distance
cdef public object hitobject
cdef public int error_margin, hyperdash
def __init__(self, hitobject, player_width):
"""
Hitobject wrapper to do calculation with.
hitobject -- Hitobject to wrap around (basic)
player_width -- Catcher width (after determined by active mods)
"""
self.strain = 1
self.offset = 0
self.last_movement = 0
self.hitobject = hitobject
self.error_margin = constants.ABSOLUTE_PLAYER_POSITIONING_ERROR
self.player_width = player_width
self.scaled_position = self.hitobject.x * (constants.NORMALIZED_HITOBJECT_RADIUS / self.player_width)
self.hyperdash_distance = 0
self.hyperdash = False
cpdef calculate_strain(self, object last, float time_rate):
"""
Calculate strain value by refering last object.
(and sets offset & last_movement info)
last -- Previous hitobject
time_rate -- Timescale from enabled mods
"""
cdef float time = (self.hitobject.time - last.hitobject.time) / time_rate
cdef float decay = constants.DECAY_BASE ** (time / 1000)
self.offset = clamp(last.scaled_position + last.offset,
self.scaled_position - (constants.NORMALIZED_HITOBJECT_RADIUS - self.error_margin),
self.scaled_position + (constants.NORMALIZED_HITOBJECT_RADIUS - self.error_margin)
) - self.scaled_position
self.last_movement = abs(self.scaled_position - last.scaled_position + self.offset - last.offset)
cdef float addition = (self.last_movement ** 1.3) / 500
if self.scaled_position < last.scaled_position:
self.last_movement *= -1
cdef float addition_bonus = 0
cdef float sqrt_time = max(time, 25) ** 0.5
if abs(self.last_movement) > 0.1:
if abs(last.last_movement) > 0.1 and sign(self.last_movement) != sign(last.last_movement):
bonus = constants.DIRECTION_CHANGE_BONUS / sqrt_time
bonus_factor = min(self.error_margin, abs(self.last_movement)) / self.error_margin
addition += bonus * bonus_factor
if last.hyperdash_distance <= 10:
addition_bonus += 0.3 * bonus_factor
addition += 7.5 * min(abs(self.last_movement), constants.NORMALIZED_HITOBJECT_RADIUS * 2) / (constants.NORMALIZED_HITOBJECT_RADIUS * 6) / sqrt_time
if last.hyperdash_distance <= 10:
if not last.hyperdash:
addition_bonus += 1
else:
self.offset = 0
addition *= 1 + addition_bonus * ((10 - last.hyperdash_distance) / 10)
addition *= 850 / max(time, 25)
self.strain = last.strain * decay + addition
cdef class Difficulty:
"""
Difficulty object for calculating star rating.
Stars: self.star_rating
"""
cdef public object beatmap
cdef public int mods
cdef public list hitobjects_with_ticks, difficulty_objects
cdef public float time_rate, player_width, star_rating
def __init__(self, beatmap, mods):
"""
CTB difficulty calculator params.
Calculates the star rating for the given beatmap.
beatmap -- Beatmap object of parsed beatmap
mods -- Int representation of mods selected / bitmask
"""
self.beatmap = beatmap
self.mods = mods
#Difficulty modifier by mod
cdef str diff
for diff in self.beatmap.difficulty.keys():
if diff == "CircleSize":
scala = 1.3
else:
scala = 1.4
self.beatmap.difficulty[diff] = self.adjust_difficulty(self.beatmap.difficulty[diff], self.mods, scala)
cdef object hitobject
self.hitobjects_with_ticks = []
for hitobject in self.beatmap.hitobjects:
self.hitobjects_with_ticks.append(hitobject)
if 2 & hitobject.type:
for tick in hitobject.ticks:
self.hitobjects_with_ticks.append(tick)
for end_tick in hitobject.end_ticks:
self.hitobjects_with_ticks.append(end_tick)
self.difficulty_objects = []
#Do the calculation
self.time_rate = self.get_time_rate()
self.player_width = 305 / 1.6 * ((102.4 * (1 - 0.7 * (self.beatmap.difficulty["CircleSize"] - 5) / 5)) / 128) * 0.7
for hitobject in self.hitobjects_with_ticks:
self.difficulty_objects.append(DifficultyObject(hitobject, self.player_width * 0.4))
self.update_hyperdash_distance()
#Sort the list so its sorted by time (Incase it somehow isnt)
self.difficulty_objects.sort(key=lambda o: o.hitobject.time)
self.calculate_strain_values()
self.star_rating = (self.calculate_difficulty() ** 0.5) * constants.STAR_SCALING_FACTOR
def adjust_difficulty(self, diff, mods, scala):
"""
Scale difficulty from selected mods.
diff -- CircleSize
mods -- Int representation of mods selected / bitmask
return -- Scaled difficulty
"""
if mods & 1 << 1 > 0: #EZ
diff = max(0, diff / 2)
if mods & 1 << 4 > 0: #HR
diff = min(10, diff * scala)
return diff
def get_time_rate(self):
"""
Get scaled time_rate from mods. (DT / HT)
return -- time_rate
"""
rate = 1
if self.mods & 1 << 6 > 0: #DT
rate += 0.5
elif self.mods & 1 << 8 > 0: #HT
rate -= 0.25
return rate
cpdef update_hyperdash_distance(self):
"""
Update hyperdash_distance value for every hitobject in the beatmap.
"""
cdef int last_direction = 0, direction, i
cdef float player_width_half = self.player_width / 2
cdef float last = player_width_half
cdef object current_object, next_object
for i in range(len(self.difficulty_objects) - 1):
current_object = self.difficulty_objects[i]
next_object = self.difficulty_objects[i + 1]
if next_object.hitobject.x > current_object.hitobject.x:
direction = 1
else:
direction = -1
time_to_next = next_object.hitobject.time - current_object.hitobject.time - 4.166667 #ms for 60fps divided by 4
distance_to_next = abs(next_object.hitobject.x - current_object.hitobject.x)
if last_direction == direction:
distance_to_next -= last
else:
distance_to_next -= player_width_half
if time_to_next < distance_to_next:
current_object.hyperdash = True
last = player_width_half
else:
current_object.hyperdash_distance = time_to_next - distance_to_next
last = clamp(current_object.hyperdash_distance, 0, player_width_half)
last_direction = direction
cpdef calculate_strain_values(self):
"""
Calculate strain values for every hitobject.
It does this by using distance, decay & previous hitobject strain value.
Time_rate also effects this.
"""
cdef object current_object = self.difficulty_objects[0], next_object
cdef index = 1
while index < len(self.difficulty_objects):
next_object = self.difficulty_objects[index]
next_object.calculate_strain(current_object, self.time_rate)
current_object = next_object
index += 1
cpdef float calculate_difficulty(self):
"""
Calculates the difficulty for this beatmap.
This is used in the final function to calculate star rating.
DISCLAIMER: This is not the final star rating value.
return -- difficulty
"""
cdef float strain_step = constants.STRAIN_STEP * self.time_rate
cdef list highest_strains = []
cdef float interval = strain_step
cdef float max_strain = 0
cdef object last = None, difficulty_object
for difficulty_object in self.difficulty_objects:
while difficulty_object.hitobject.time > interval:
highest_strains.append(max_strain)
if last is None:
max_strain = 0
else:
decay = (constants.DECAY_BASE ** ((interval - last.hitobject.time) / 1000))
max_strain = last.strain * decay
interval += strain_step
if difficulty_object.strain > max_strain:
max_strain = difficulty_object.strain
last = difficulty_object
cdef float difficulty = 0
cdef float weight = 1
#Sort from high to low strain
highest_strains.sort(key=int, reverse=True)
cdef float strain
for strain in highest_strains:
difficulty += weight * strain
weight *= constants.DECAY_WEIGHT
return difficulty

View File

View File

@ -0,0 +1,222 @@
from . import mathhelper
from .hitobject import HitObject
cdef class Beatmap(object):
"""
Beatmap object for beatmap parsing and handling
"""
cdef public str file_name
cdef public int version
cdef public int header
cdef public dict difficulty
cdef public dict timing_points
cdef public float slider_point_distance
cdef public list hitobjects
cdef public int max_combo
def __init__(self, file_name):
"""
file_name -- Directory for beatmap file (.osu)
"""
self.file_name = file_name
self.version = -1 #Unknown by default
self.header = -1
self.difficulty = {}
self.timing_points = {
"raw_bpm": {}, #Raw bpm modifier code
"raw_spm": {}, #Raw speed modifier code
"bpm": {}, #Beats pr minute
"spm": {} #Speed modifier
}
self.slider_point_distance = 1 #Changes after [Difficulty] is fully parsed
self.hitobjects = []
self.max_combo = 0
self.parse_beatmap()
if "ApproachRate" not in self.difficulty.keys(): #Fix old osu version
self.difficulty["ApproachRate"] = self.difficulty["OverallDifficulty"]
cpdef parse_beatmap(self):
"""
Parses beatmap file line by line by passing each line into parse_line.
"""
cdef str line
with open(self.file_name, encoding="utf8") as file_stream:
ver_line = ""
while len(ver_line) < 2: #Find the line where beatmap version is spesified (normaly first line)
ver_line = file_stream.readline()
self.version = int(''.join(list(filter(str.isdigit, ver_line)))) #Set version
for line in file_stream:
self.parse_line(line.replace("\n", ""))
cpdef parse_line(self, str line):
"""
Parse a beatmapfile line.
Handles lines that are required for our use case (Difficulty, TimingPoints & hitobjects),
everything else is skipped.
"""
if len(line) < 1:
return
if line.startswith("["):
if line == "[Difficulty]":
self.header = 0
elif line == "[TimingPoints]":
self.header = 1
elif line == "[HitObjects]":
self.header = 2
self.slider_point_distance = (100 * self.difficulty["SliderMultiplier"]) / self.difficulty["SliderTickRate"]
else:
self.header = -1
return
if self.header == -1: #We return if we are reading under a header we dont care about
return
if self.header == 0:
self.handle_difficulty_propperty(line)
elif self.header == 1:
self.handle_timing_point(line)
elif self.header == 2:
self.handle_hitobject(line)
cpdef handle_difficulty_propperty(self, str propperty):
"""
Puts the [Difficulty] propperty into the difficulty dict.
"""
prop = propperty.split(":")
self.difficulty[prop[0]] = float(prop[1])
cpdef handle_timing_point(self, str timing_point):
"""
Formats timing points used for slider velocity changes,
and store them into self.timing_points dict.
"""
timing_point_split = timing_point.split(",")
timing_point_time = int(float(timing_point_split[0])) #Fixes some special mappers special needs
timing_point_focus = timing_point_split[1]
timing_point_type = 1
if len(timing_point_split) >= 7: #Fix for old beatmaps that only stores bpm change and timestamp (only BPM change) [v3?]
timing_point_type = int(timing_point_split[6])
if timing_point_type == 0 and not timing_point_focus.startswith("-"):
timing_point_focus = "-100"
if timing_point_focus.startswith("-"): #If not then its not a slider velocity modifier
self.timing_points["spm"][timing_point_time] = -100 / float(timing_point_focus) #Convert to normalized value and store
self.timing_points["raw_spm"][timing_point_time] = float(timing_point_focus)
else:
if len(self.timing_points["bpm"]) == 0: #Fixes if hitobjects shows up before bpm is set
timing_point_time = 0
self.timing_points["bpm"][timing_point_time] = 60000 / float(timing_point_focus)#^
self.timing_points["raw_bpm"][timing_point_time] = float(timing_point_focus)
#This trash of a game resets the spm when bpm change >.>
self.timing_points["spm"][timing_point_time] = 1
self.timing_points["raw_spm"][timing_point_time] = -100
cpdef handle_hitobject(self, str line):
"""
Puts every hitobject into the hitobjects array.
Creates hitobjects, hitobject_sliders or skip depending on the given data.
We skip everything that is not important for us for our use case (Spinners)
"""
split_object = line.split(",")
time = int(split_object[2])
object_type = int(split_object[3])
if not (1 & object_type > 0 or 2 & object_type > 0): #We only want sliders and circles as spinners are random bannanas etc.
return
if 2 & object_type: #Slider
repeat = int(split_object[6])
pixel_length = float(split_object[7])
time_point = self.get_timing_point_all(time)
tick_distance = (100 * self.difficulty["SliderMultiplier"]) / self.difficulty["SliderTickRate"]
if self.version >= 8:
tick_distance /= (mathhelper.clamp(-time_point["raw_spm"], 10, 1000) / 100)
curve_split = split_object[5].split("|")
curve_points = []
for i in range(1, len(curve_split)):
vector_split = curve_split[i].split(":")
vector = mathhelper.Vec2(int(vector_split[0]), int(vector_split[1]))
curve_points.append(vector)
slider_type = curve_split[0]
if self.version <= 6 and len(curve_points) >= 2:
if slider_type == "L":
slider_type = "B"
if len(curve_points) == 2:
if (int(split_object[0]) == curve_points[0].x and int(split_object[1]) == curve_points[0].y) or (curve_points[0].x == curve_points[1].x and curve_points[0].y == curve_points[1].y):
del curve_points[0]
slider_type = "L"
if len(curve_points) == 0: #Incase of ExGon meme (Sliders that acts like hitcircles)
hitobject = HitObject(int(split_object[0]), int(split_object[1]), time, 1)
else:
hitobject = HitObject(int(split_object[0]), int(split_object[1]), time, object_type, slider_type, curve_points, repeat, pixel_length, time_point, self.difficulty, tick_distance)
else:
hitobject = HitObject(int(split_object[0]), int(split_object[1]), time, object_type)
self.hitobjects.append(hitobject)
self.max_combo += hitobject.get_combo()
def get_timing_point_all(self, time):
"""
Returns a object of all current timing types
time -- timestamp
return -- {"raw_bpm": Float, "raw_spm": Float, "bpm": Float, "spm": Float}
"""
types = {
"raw_bpm": 600,
"raw_spm": -100,
"bpm": 100,
"spm": 1
} #Will return the default value if timing point were not found
for t in types.keys():
r = self.get_timing_point(time, t)
if r is not None:
types[t] = r
#else:
#print("{} were not found for timestamp {}, using {} instead.".format(t, time, types[t]))
return types
def get_timing_point(self, time, timing_type):
"""
Returns latest timing point by timestamp (Current)
time -- timestamp
timing_type -- mpb, bmp or spm
return -- self.timing_points object
"""
r = None
try:
for key in sorted(self.timing_points[timing_type].keys(), key=lambda k: k):
if key <= time:
r = self.timing_points[timing_type][key]
else:
break
except Exception as e:
print(e)
return r
def get_object_count(self):
"""
Get the total hitobject count for the parsed beatmap (Normal hitobjects, sliders & sliderticks)
return -- total hitobjects for parsed beatmap
"""
cdef int count = 0
for hitobject in self.hitobjects:
count += hitobject.get_points()
return count

View File

@ -0,0 +1,166 @@
import math
from .. import constants
from . import mathhelper
class Linear(object): #Because it made sense at the time...
def __init__(self, points):
self.pos = points
cdef class Bezier(object):
cdef public list points, pos
cdef public int order
def __init__(self, points):
self.points = points
self.order = len(self.points)
self.pos = []
self.calc_points()
cpdef calc_points(self):
if len(self.pos) != 0: #This should never happen but since im working on this I want to warn myself if I fuck up
raise Exception("Bezier was calculated twice!")
cdef list sub_points = []
for i in range(len(self.points)):
if i == len(self.points) - 1:
sub_points.append(self.points[i])
self.bezier(sub_points)
sub_points.clear()
elif len(sub_points) > 1 and self.points[i] == sub_points[-1]:
self.bezier(sub_points)
sub_points.clear()
sub_points.append(self.points[i])
cpdef bezier(self, list points):
cdef int order = len(points)
cdef float step = 0.25 / constants.SLIDER_QUALITY / order #Normaly 0.0025
cdef float i = 0
cdef int n = order - 1
cdef float x, y
cdef int p
while i < 1 + step:
x = 0
y = 0
for p in range(n + 1):
a = mathhelper.cpn(p, n) * ((1 - i) ** (n - p)) * (i ** p)
x += a * points[p].x
y += a * points[p].y
point = mathhelper.Vec2(x, y)
self.pos.append(point)
i += step
def point_at_distance(self, length):
return {
0: False,
1: self.points[0],
}.get(self.order, self.rec(length))
def rec(self, length):
return mathhelper.point_at_distance(self.pos, length)
cdef class Catmull(object): #Yes... I cry deep down on the inside aswell
cdef public list points, pos
cdef public int order
cdef public float step
def __init__(self, points):
self.points = points
self.order = len(points)
self.step = 2.5 / constants.SLIDER_QUALITY #Normaly 0.025
self.pos = []
self.calc_points()
cpdef calc_points(self):
if len(self.pos) != 0: #This should never happen but since im working on this I want to warn myself if I fuck up
raise Exception("Catmull was calculated twice!")
cdef int x
cdef float t
cdef object v1, v2, v3
for x in range(self.order - 1):
t = 0
while t < self.step + 1:
if x >= 1:
v1 = self.points[x - 1]
else:
v1 = self.points[x]
v2 = self.points[x]
if x + 1 < self.order:
v3 = self.points[x + 1]
else:
v3 = v2.calc(1, v2.calc(-1, v1))
if x + 2 < self.order:
v4 = self.points[x + 2]
else:
v4 = v3.calc(1, v3.calc(-1, v2))
point = get_point([v1, v2, v3, v4], t)
self.pos.append(point)
t += self.step
def point_at_distance(self, length):
return {
0: False,
1: self.points[0],
}.get(self.order, self.rec(length))
def rec(self, length):
return mathhelper.point_at_distance(self.pos, length)
cdef class Perfect(object):
cdef public list points
cdef float cx, cy
cdef float radius
def __init__(self, points):
self.points = points
self.cx = 0
self.cy = 0
self.radius = 0
self.setup_path()
def setup_path(self):
self.cx, self.cy, self.radius = get_circum_circle(self.points)
if is_left(self.points):
self.radius *= -1
cpdef point_at_distance(self, float length):
cdef float radians = length / self.radius
return rotate(self.cx, self.cy, self.points[0], radians)
cpdef object get_point(object p, float length):
cdef float x = mathhelper.catmull([o.x for o in p], length)
cdef float y = mathhelper.catmull([o.y for o in p], length)
return mathhelper.Vec2(x, y)
cpdef tuple get_circum_circle(list p):
cdef float d = 2 * (p[0].x * (p[1].y - p[2].y) + p[1].x * (p[2].y - p[0].y) + p[2].x * (p[0].y - p[1].y))
if d == 0:
raise Exception("Invalid circle! Unable to chose angle.")
cdef float ux = ((pow(p[0].x, 2) + pow(p[0].y, 2)) * (p[1].y - p[2].y) + (pow(p[1].x, 2) + pow(p[1].y, 2)) * (p[2].y - p[0].y) + (pow(p[2].x, 2) + pow(p[2].y, 2)) * (p[0].y - p[1].y)) / d
cdef float uy = ((pow(p[0].x, 2) + pow(p[0].y, 2)) * (p[2].x - p[1].x) + (pow(p[1].x, 2) + pow(p[1].y, 2)) * (p[0].x - p[2].x) + (pow(p[2].x, 2) + pow(p[2].y, 2)) * (p[1].x - p[0].x)) / d
cdef float px = ux - p[0].x
cdef float py = uy - p[0].y
cdef float r = pow(pow(px, 2) + pow(py, 2), 0.5)
return ux, uy, r
cpdef float is_left(object p):
return ((p[1].x - p[0].x) * (p[2].y - p[0].y) - (p[1].y - p[0].y) * (p[2].x - p[0].x)) < 0
cpdef object rotate(float cx, float cy, object p, float radians):
cdef float cos = math.cos(radians)
cdef float sin = math.sin(radians)
return mathhelper.Vec2((cos * (p.x - cx)) - (sin * (p.y - cy)) + cx, (sin * (p.x - cx)) + (cos * (p.y - cy)) + cy)

View File

@ -0,0 +1,169 @@
import copy
from . import mathhelper
from . import curves
cdef class SliderTick:
cdef public float x, y, time
def __init__(self, x, y, time):
self.x = x
self.y = y
self.time = time
cdef class HitObject(object):
cdef public float x, y, time, end_time, pixel_length, tick_distance, duration
cdef public int type, repeat
cdef public str slider_type
cdef public list curve_points, ticks, end_ticks, path
cdef public dict timing_point
cdef public object difficulty, end
def __init__(self, x, y, time, object_type, slider_type = None, curve_points = None, repeat = 1, pixel_length = 0, timing_point = None, difficulty = None, tick_distance = 1):
"""
HitObject params for normal hitobject and sliders
x -- x position
y -- y position
time -- timestamp
object_type -- type of object (bitmask)
[+] IF SLIDER
slider_type -- type of slider (L, P, B, C)
curve_points -- points in the curve path
repeat -- amount of repeats for the slider (+1)
pixel_length -- length of the slider
timing_point -- ref of current timing point for the timestamp
difficulty -- ref of beatmap difficulty
tick_distance -- distance betwin each slidertick
"""
self.x = x
self.y = y
self.time = time
self.end_time = 0
self.type = object_type
#isSlider?
if 2 & self.type:
self.slider_type = slider_type
self.curve_points = [mathhelper.Vec2(self.x, self.y)] + curve_points
self.repeat = repeat
self.pixel_length = pixel_length
#For slider tick calculations
self.timing_point = timing_point
self.difficulty = difficulty
self.tick_distance = tick_distance
self.duration = (int(self.timing_point["raw_bpm"]) * (pixel_length / (self.difficulty["SliderMultiplier"] * self.timing_point["spm"])) / 100) * self.repeat
self.ticks = []
self.end_ticks = []
self.path = []
self.end = None
self.calc_slider()
def calc_slider(self, calc_path = False):
#Fix broken objects
if self.slider_type == "P" and len(self.curve_points) > 3:
self.slider_type = "B"
elif len(self.curve_points) == 2:
self.slider_type = "L"
#Make curve
if self.slider_type == "P": #Perfect
try:
curve = curves.Perfect(self.curve_points)
except:
curve = curves.Bezier(self.curve_points)
self.slider_type = "B"
elif self.slider_type == "B": #Bezier
curve = curves.Bezier(self.curve_points)
elif self.slider_type == "C": #Catmull
curve = curves.Catmull(self.curve_points)
#Quickest to skip this
if calc_path: #Make path if requested (For drawing visual for testing)
if self.slider_type == "L": #Linear
self.path = curves.Linear(self.curve_points).pos
elif self.slider_type == "P": #Perfect
self.path = []
l = 0
step = 5
while l <= self.pixel_length:
self.path.append(curve.point_at_distance(l))
l += step
elif self.slider_type == "B": #Bezier
self.path = curve.pos
elif self.slider_type == "C": #Catmull
self.path = curve.pos
else:
raise Exception("Slidertype not supported! ({})".format(self.slider_type))
#Set slider ticks
current_distance = self.tick_distance
time_add = self.duration * (self.tick_distance / (self.pixel_length * self.repeat))
while current_distance < self.pixel_length - self.tick_distance / 8:
if self.slider_type == "L": #Linear
point = mathhelper.point_on_line(self.curve_points[0], self.curve_points[1], current_distance)
else: #Perfect, Bezier & Catmull uses the same function
point = curve.point_at_distance(current_distance)
self.ticks.append(SliderTick(point.x, point.y, self.time + time_add * (len(self.ticks) + 1)))
current_distance += self.tick_distance
#Adds slider_ends / repeat_points
repeat_id = 1
repeat_bonus_ticks = []
while repeat_id < self.repeat:
dist = (1 & repeat_id) * self.pixel_length
time_offset = (self.duration / self.repeat) * repeat_id
if self.slider_type == "L": #Linear
point = mathhelper.point_on_line(self.curve_points[0], self.curve_points[1], dist)
else: #Perfect, Bezier & Catmull uses the same function
point = curve.point_at_distance(dist)
self.end_ticks.append(SliderTick(point.x, point.y, self.time + time_offset))
#Adds the ticks that already exists on the slider back (but reversed)
repeat_ticks = copy.deepcopy(self.ticks)
if 1 & repeat_id: #We have to reverse the timing normalizer
repeat_ticks = list(reversed(repeat_ticks))
normalize_time_value = self.time + (self.duration / self.repeat)
else:
normalize_time_value = self.time
#Correct timing
for tick in repeat_ticks:
tick.time = self.time + time_offset + abs(tick.time - normalize_time_value)
repeat_bonus_ticks += repeat_ticks
repeat_id += 1
self.ticks += repeat_bonus_ticks
#Add endpoint for slider
dist_end = (1 & self.repeat) * self.pixel_length
if self.slider_type == "L": #Linear
point = mathhelper.point_on_line(self.curve_points[0], self.curve_points[1], dist_end)
else: #Perfect, Bezier & Catmull uses the same function
point = curve.point_at_distance(dist_end)
self.end_ticks.append(SliderTick(point.x, point.y, self.time + self.duration))
def get_combo(self):
"""
Returns the combo given by this object
1 if normal hitobject, 2+ if slider (adds sliderticks)
"""
if 2 & self.type: #Slider
val = 1 #Start of the slider
val += len(self.ticks) #The amount of sliderticks
val += self.repeat #Reverse slider
else: #Normal
val = 1 #Itself...
return val

View File

@ -0,0 +1,124 @@
import math
cpdef float clamp(float value, float mn, float mx):
return min(max(mn, value), mx)
cpdef sign(float value):
if value == 0:
return 0
elif value > 0:
return 1
else:
return -1
cpdef cpn(int p, int n):
if p < 0 or p > n:
return 0
p = min(p, n - p)
out = 1
for i in range(1, p + 1):
out = out * (n - p + i) / i
return out
cpdef float catmull(p, t): # WARNING: Worst math formula incomming
return 0.5 * (
(2 * p[1]) +
(-p[0] + p[2]) * t +
(2 * p[0] - 5 * p[1] + 4 * p[2] - p[3]) * (t ** 2) +
(-p[0] + 3 * p[1] - 3 * p[2] + p[3]) * (t ** 3))
cpdef Vec2 point_on_line(Vec2 p0, Vec2 p1, float length):
cdef float full_length = (((p1.x - p0.x) ** 2) + ((p1.y - p0.y) ** 2)) ** 0.5
cdef float n = full_length - length
if full_length == 0: #Fix for something that seems unknown... (We warn if this happens)
full_length = 1
cdef float x = (n * p0.x + length * p1.x) / full_length
cdef float y = (n * p0.y + length * p1.y) / full_length
return Vec2(x, y)
cpdef float angle_from_points(Vec2 p0, Vec2 p1):
return math.atan2(p1.y - p0.y, p1.x - p0.x)
cpdef float distance_from_points(array):
cdef float distance = 0
cdef int i
for i in range(1, len(array)):
distance += array[i].distance(array[i - 1])
return distance
cpdef Vec2 cart_from_pol(r, t):
cdef float x = (r * math.cos(t))
cdef float y = (r * math.sin(t))
return Vec2(x, y)
cpdef point_at_distance(array, float distance): #TODO: Optimize...
cdef int i = 0
cdef float x, y, current_distance = 0, new_distance = 0, angle
cdef Vec2 coord, cart
if len(array) < 2:
return Vec2(0, 0)
if distance == 0:
return array[0]
if distance_from_points(array) <= distance:
return array[len(array) - 1]
for i in range(len(array) - 2):
x = (array[i].x - array[i + 1].x)
y = (array[i].y - array[i + 1].y)
new_distance = math.sqrt(x * x + y * y)
current_distance += new_distance
if distance <= current_distance:
break
current_distance -= new_distance
if distance == current_distance:
return array[i]
else:
angle = angle_from_points(array[i], array[i + 1])
cart = cart_from_pol((distance - current_distance), angle)
if array[i].x > array[i + 1].x:
coord = Vec2((array[i].x - cart.x), (array[i].y - cart.y))
else:
coord = Vec2((array[i].x + cart.y), (array[i].y + cart.y))
return coord
cdef class Vec2(object):
cdef public float x
cdef public float y
def __init__(self, x, y):
self.x = x
self.y = y
def __richcmp__(x, y, op):
if op == 2:#Py_EQ
return x.__is_equal(y)
else:#Py_NE
return not x.__is_equal(y)
def __is_equal(self, other):
return self.x == other.x and self.y == other.y
cpdef float distance(Vec2 self, Vec2 other):
cdef float x = self.x - other.x
cdef float y = self.y - other.y
return (x*x + y*y) ** 0.5 #sqrt, lol
cpdef Vec2 calc(Vec2 self, float value, Vec2 other): #I dont know what to call this function yet
cdef float x = self.x + value * other.x
cdef float y = self.y + value * other.y
return Vec2(x, y)

View File

@ -0,0 +1,41 @@
import math
cpdef calculate_pp(diff, accuracy, combo, miss):
"""
Calculate pp for gameplay
diff -- Difficulty object
accuracy -- Accuracy of the play (Float 0-1)
combo -- MaxCombo achived during the play (Int)
miss -- Amount of misses during the play (Int)
return -- Total pp for gameplay
"""
cdef float pp = (((5 * diff.star_rating / 0.0049) - 4) ** 2) / 100000
cdef float length_bonus = 0.95 + 0.4 * min(1, combo / 3000)
if combo > 3000:
length_bonus += math.log10(combo / 3000) * 0.5
pp *= length_bonus
pp *= (0.97 ** miss)
pp *= min((combo ** 0.8) / (diff.beatmap.max_combo ** 0.8), 1)
if diff.beatmap.difficulty["ApproachRate"] > 9:
pp *= 1 + 0.1 * (diff.beatmap.difficulty["ApproachRate"] - 9)
if diff.beatmap.difficulty["ApproachRate"] < 8:
pp *= 1 + 0.025 * (8 - diff.beatmap.difficulty["ApproachRate"])
if diff.mods & 1 << 3 > 0: #HD
pp *= 1.05 + 0.075 * (10 - min(10, diff.beatmap.difficulty["ApproachRate"]))
if diff.mods & 1 << 10 > 0: #FL
pp *= 1.35 * length_bonus
pp *= (accuracy ** 5.5)
if diff.mods & 1 << 0 > 0: #NF
pp *= 0.9
if diff.mods & 1 << 12 > 0: #SO
pp *= 0.95
return pp

View File

@ -0,0 +1,899 @@
osu file format v14
[General]
AudioFilename: REANIMATE.mp3
AudioLeadIn: 0
PreviewTime: 57263
Countdown: 0
SampleSet: Soft
StackLeniency: 0.5
Mode: 2
LetterboxInBreaks: 0
WidescreenStoryboard: 0
[Editor]
Bookmarks: 13470,24504,35539,41056,46573,50711,57608,68642,79677,90711,101746,112780,118298,119677,129246,140367,145884
DistanceSpacing: 0.6
BeatDivisor: 8
GridSize: 8
TimelineZoom: 2.372001
[Metadata]
Title:REANIMATE
TitleUnicode:REANIMATE
Artist:Warak
ArtistUnicode:Warak
Creator:- Magic Bomb -
Version:Imagination
Source:节奏大师
Tags:MBomb rhythm master symphonic drumstep JBHyperion Zirox JBH
BeatmapID:1042702
BeatmapSetID:489190
[Difficulty]
HPDrainRate:6
CircleSize:4
OverallDifficulty:9.4
ApproachRate:9.4
SliderMultiplier:2.1
SliderTickRate:2
[Events]
//Background and Video events
0,0,"REVIVE.png",0,0
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Sound Samples
[TimingPoints]
2436,344.827586206897,4,2,77,100,1,0
13470,-100,4,2,80,100,0,0
13556,-100,4,2,78,85,0,0
24160,-100,4,2,77,80,0,0
24504,-100,4,2,78,80,0,0
24677,-100,4,2,77,80,0,0
27263,-100,4,2,78,80,0,0
27436,-100,4,2,77,80,0,0
30022,-100,4,2,78,80,0,0
30194,-100,4,2,77,80,0,0
32780,-100,4,2,78,80,0,0
32953,-100,4,2,77,80,0,0
35539,-100,4,2,78,90,0,0
35711,-100,4,2,77,90,0,0
38298,-100,4,2,78,90,0,0
38470,-100,4,2,77,90,0,0
41056,-100,4,2,78,90,0,0
41229,-100,4,2,77,90,0,0
43815,-100,4,2,78,90,0,0
45194,-100,4,2,77,90,0,0
46573,-100,4,2,78,90,0,1
46746,-100,4,2,78,90,0,1
48642,-66.6666666666667,4,2,78,90,0,1
48987,-100,4,2,78,90,0,1
49677,-66.6666666666667,4,2,78,90,0,1
49849,-100,4,2,78,90,0,1
56229,-66.6666666666667,4,2,78,90,0,1
57263,-66.6666666666667,4,2,78,80,0,0
57608,-100,4,2,78,90,0,1
57780,-100,4,2,78,90,0,1
63125,-100,4,2,78,90,0,1
63470,-66.6666666666667,4,2,78,90,0,1
63815,-100,4,2,78,90,0,1
64332,-66.6666666666667,4,2,78,90,0,1
64504,-100,4,2,78,90,0,1
65539,-66.6666666666667,4,2,78,90,0,1
65711,-100,4,2,78,90,0,1
65884,-66.6666666666667,4,2,78,90,0,1
66573,-100,4,2,78,90,0,1
66918,-83.3333333333333,4,2,78,90,0,1
67263,-100,4,2,77,80,0,1
68642,-100,4,2,78,80,0,0
78298,-100,4,2,77,80,0,0
79677,-100,4,2,79,70,0,0
90711,-100,4,2,78,70,0,0
99073,-100,4,2,77,90,0,0
100367,-100,4,2,77,80,0,0
101056,-100,4,2,77,70,0,0
101746,-100,4,2,77,80,0,0
111573,-100,4,2,77,50,0,0
113125,-100,4,2,77,80,0,0
118298,-66.6666666666667,4,2,78,90,0,1
118642,-100,4,2,78,90,0,1
120711,-83.3333333333333,4,2,78,90,0,1
121056,-100,4,2,78,90,0,1
124849,-66.6666666666667,4,2,78,90,0,1
125022,-100,4,2,78,90,0,1
125539,-66.6666666666667,4,2,78,90,0,1
125711,-100,4,2,78,90,0,1
125884,-66.6666666666667,4,2,78,90,0,1
126056,-100,4,2,78,90,0,1
126229,-66.6666666666667,4,2,78,90,0,1
126401,-100,4,2,78,90,0,1
128987,-100,4,2,78,90,0,0
129332,-100,4,2,78,90,0,1
130194,-66.6666666666667,4,2,78,90,0,1
130367,-100,4,2,78,90,0,1
130884,-66.6666666666667,4,2,78,90,0,1
131056,-100,4,2,78,90,0,1
133470,-66.6666666666667,4,2,78,90,0,1
133815,-100,4,2,78,90,0,1
134158,-66.6666666666667,4,2,78,90,0,1
134418,-100,4,2,78,90,0,1
138987,-66.6666666666667,4,2,77,100,0,1
139677,-83.3333333333333,4,2,77,100,0,1
140022,-100,4,2,77,100,0,1
140367,-100,4,2,79,80,0,0
144504,-100,4,2,77,80,0,0
145194,-100,4,2,77,100,0,1
145280,-100,4,2,77,40,0,0
[Colours]
Combo1 : 183,47,49
Combo2 : 223,170,70
Combo3 : 84,114,98
Combo4 : 92,107,131
[HitObjects]
192,192,2436,5,4,0:0:0:0:
141,200,2522,1,0,0:0:0:0:
102,168,2608,1,0,0:0:0:0:
101,117,2694,1,0,0:0:0:0:
256,192,2780,1,0,0:0:0:0:
144,192,2953,1,0,0:0:0:0:
392,192,3125,2,0,P|504:112|488:64,1,210,2|0,0:0|0:0,0:0:0:0:
432,48,3556,1,0,0:0:0:0:
376,56,3642,1,0,0:0:0:0:
344,95,3728,1,0,0:0:0:0:
496,192,3815,6,0,L|400:152,2,105,2|0|0,0:0|0:0|0:0,0:0:0:0:
16,192,4504,2,0,L|112:152,2,105,2|0|0,0:0|0:0|0:0,0:0:0:0:
448,192,5194,5,4,0:0:0:0:
422,146,5280,1,0,0:0:0:0:
379,117,5366,1,0,0:0:0:0:
327,110,5452,1,0,0:0:0:0:
224,192,5539,1,0,0:0:0:0:
496,192,5711,1,0,0:0:0:0:
160,192,5884,2,0,P|88:80|120:56,1,210,2|0,0:0|0:0,0:0:0:0:
392,192,6401,1,0,0:0:0:0:
96,192,6573,5,2,0:0:0:0:
96,192,6832,1,0,0:0:0:0:
256,192,6918,2,0,B|184:120|184:120|128:120,1,157.5,2|0,0:0|0:0,0:0:0:0:
416,192,7263,1,2,0:0:0:0:
416,192,7522,1,0,0:0:0:0:
256,192,7608,2,0,B|328:120|328:120|384:120,1,157.5,2|0,0:0|0:0,0:0:0:0:
176,192,7953,5,4,0:0:0:0:
112,192,8039,1,0,0:0:0:0:
112,192,8125,1,0,0:0:0:0:
176,192,8211,1,0,0:0:0:0:
272,192,8298,1,0,0:0:0:0:
16,192,8470,1,0,0:0:0:0:
272,192,8642,2,0,L|496:192,1,210,2|0,0:0|0:0,0:0:0:0:
320,192,9160,1,0,0:0:0:0:
482,192,9332,5,2,0:0:0:0:
308,192,9504,2,0,L|244:192,1,52.5
164,192,9677,2,0,L|220:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
372,192,9849,2,0,L|308:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
116,192,10022,2,0,P|52:88|108:56,1,210,2|0,0:0|0:0,0:0:0:0:
448,192,10539,1,2,0:0:0:0:
256,192,10625,1,0,0:0:0:0:
64,192,10711,5,4,0:0:0:0:
256,192,10970,1,0,0:0:0:0:
448,192,11056,1,2,0:0:0:0:
256,192,11315,1,0,0:0:0:0:
96,192,11401,1,2,0:0:0:0:
256,192,11660,1,0,0:0:0:0:
400,192,11746,1,2,0:0:0:0:
224,192,12004,1,2,0:0:0:0:
224,192,12091,5,0,0:0:0:0:
464,192,12263,2,0,L|411:192,1,52.5
176,192,12436,2,0,P|96:120|160:88,1,210,2|2,0:0|0:0,0:0:0:0:
208,192,12867,2,0,L|272:192,2,52.5
144,192,13125,2,0,P|40:128|40:112,1,157.5,2|0,0:0|0:0,0:0:0:0:
288,192,13470,5,4,0:0:0:0:
235,192,13556,1,0,0:0:0:0:
182,192,13642,1,0,0:0:0:0:
130,192,13728,1,0,0:0:0:0:
288,192,13815,2,0,L|392:192,1,105,2|0,0:0|0:0,0:0:0:0:
120,192,14160,2,0,P|47:144|96:80,1,157.5,8|0,0:0|0:0,0:0:0:0:
256,192,14504,2,0,L|320:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
256,192,14677,2,0,L|192:192,1,52.5
32,192,14849,5,2,0:0:0:0:
176,192,15022,1,2,0:0:0:0:
32,192,15194,2,0,L|192:192,1,157.5,2|0,0:0|0:0,0:0:0:0:
480,192,15539,1,8,0:0:0:0:
368,192,15712,1,0,0:0:0:0:
480,192,15884,2,0,L|320:192,1,157.5,2|0,0:0|0:0,0:0:0:0:
136,192,16229,5,2,0:0:0:0:
186,185,16315,1,0,0:0:0:0:
215,143,16401,1,0,0:0:0:0:
204,93,16487,1,0,0:0:0:0:
128,192,16573,1,2,0:0:0:0:
384,192,16746,1,0,0:0:0:0:
64,192,16918,2,0,P|120:96|216:80,1,210,8|2,0:0|0:0,0:0:0:0:
488,192,17436,1,0,0:0:0:0:
199,192,17608,6,0,L|272:112,1,105,2|2,0:0|0:0,0:0:0:0:
184,192,17867,1,0,0:0:0:0:
16,192,17953,2,0,L|184:192,1,157.5,2|0,0:0|0:0,0:0:0:0:
320,192,18298,1,8,0:0:0:0:
224,192,18557,1,0,0:0:0:0:
392,192,18642,2,0,L|224:192,1,157.5,2|0,0:0|0:0,0:0:0:0:
88,192,18987,5,2,0:0:0:0:
16,192,19073,1,0,0:0:0:0:
16,192,19160,1,0,0:0:0:0:
64,192,19246,1,0,0:0:0:0:
136,192,19332,1,2,0:0:0:0:
408,192,19504,1,0,0:0:0:0:
136,192,19677,2,0,P|224:136|272:184,1,157.5,8|0,0:0|0:0,0:0:0:0:
64,192,20022,2,0,P|32:128|56:120,1,105,2|0,0:0|0:0,0:0:0:0:
320,192,20367,5,2,0:0:0:0:
448,192,20539,2,0,L|392:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
240,192,20711,2,0,L|184:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
344,192,20884,2,0,L|280:192,1,52.5
128,192,21056,2,0,P|48:112|104:64,1,210,8|2,0:0|0:0,0:0:0:0:
416,192,21573,2,0,L|456:152,1,52.5
256,192,21746,6,0,L|144:192,1,105,2|0,0:0|0:0,0:0:0:0:
216,192,22004,1,0,0:0:0:0:
368,192,22091,1,0,0:0:0:0:
417,179,22177,1,0,0:0:0:0:
441,134,22263,1,0,0:0:0:0:
425,86,22349,1,0,0:0:0:0:
160,192,22436,2,0,P|72:112|208:48,1,315,8|0,0:0|0:0,0:0:0:0:
352,192,23039,1,0,0:0:0:0:
496,192,23125,5,2,0:0:0:0:
400,192,23298,2,0,L|512:192,1,105,2|2,0:0|0:0,0:0:0:0:
48,192,23814,1,8,0:0:0:0:
176,192,24160,2,0,L|88:136,1,105,2|0,0:0|0:0,0:0:0:0:
352,192,24504,5,2,0:0:0:0:
192,192,24849,2,0,L|416:192,1,210,0|2,0:0|0:0,0:0:0:0:
64,192,25539,2,0,L|288:192,1,210,0|2,0:0|0:0,0:0:0:0:
496,192,26229,2,0,L|272:192,2,210,2|0|2,0:0|0:0|0:0,0:0:0:0:
32,192,27263,6,0,L|120:136,1,105,2|0,0:0|0:0,0:0:0:0:
304,192,27608,2,0,P|384:120|360:88,1,157.5,2|0,0:0|0:0,0:0:0:0:
176,192,27953,2,0,B|384:168|384:168|272:168,1,315,2|0,0:0|0:0,0:0:0:0:
16,192,28642,1,2,0:0:0:0:
176,192,28987,2,0,L|392:192,2,210,2|0|0,0:0|0:0|0:0,0:0:0:0:
496,192,30022,6,0,L|384:192,1,105,2|0,0:0|0:0,0:0:0:0:
112,192,30367,2,0,P|40:104|80:64,1,157.5,2|0,0:0|0:0,0:0:0:0:
168,192,30711,2,0,L|328:192,1,157.5
168,192,31056,2,0,L|8:192,1,157.5,2|0,0:0|0:0,0:0:0:0:
168,192,31401,2,0,B|464:168|424:72|352:72,1,315,2|0,0:0|0:0,0:0:0:0:
72,72,32091,2,0,B|16:70|-23:166|272:190,1,315,2|0,0:0|0:0,0:0:0:0:
16,192,32780,5,2,0:0:0:0:
256,192,33125,2,0,L|40:192,1,210,2|0,0:0|0:0,0:0:0:0:
352,192,33815,2,0,P|400:112|352:72,1,157.5,2|0,0:0|0:0,0:0:0:0:
176,192,34160,6,0,L|392:192,1,210,2|0,0:0|0:0,0:0:0:0:
256,192,34677,1,0,0:0:0:0:
496,192,34849,2,0,L|280:192,1,210,2|0,0:0|0:0,0:0:0:0:
16,192,35539,6,0,L|128:192,1,105,2|0,0:0|0:0,0:0:0:0:
288,192,35884,2,0,L|184:192,1,105,2|0,0:0|0:0,0:0:0:0:
496,192,36229,2,0,L|384:192,1,105
224,192,36573,2,0,L|112:192,1,105,2|0,0:0|0:0,0:0:0:0:
384,192,36918,6,0,L|272:192,1,105,2|0,0:0|0:0,0:0:0:0:
24,192,37263,2,0,L|240:192,2,210,0|2|2,0:0|0:0|0:0,0:0:0:0:
176,192,38125,1,0,0:0:0:0:
432,192,38298,6,0,L|328:192,1,105,2|0,0:0|0:0,0:0:0:0:
496,192,38642,2,0,L|384:192,1,105,2|0,0:0|0:0,0:0:0:0:
128,192,38987,2,0,L|8:192,1,105
192,192,39332,2,0,L|304:192,1,105,2|0,0:0|0:0,0:0:0:0:
23,192,39677,6,0,L|135:192,1,105,2|0,0:0|0:0,0:0:0:0:
383,192,40022,2,0,L|167:192,2,210,0|2|2,0:0|0:0|0:0,0:0:0:0:
480,192,40884,1,0,0:0:0:0:
176,192,41056,5,2,0:0:0:0:
304,192,41229,1,0,0:0:0:0:
24,192,41401,2,0,L|128:192,1,105,2|0,0:0|0:0,0:0:0:0:
400,192,41746,2,0,L|505:192,1,105
129,192,42091,2,0,L|24:192,1,105,2|0,0:0|0:0,0:0:0:0:
383,192,42436,6,0,L|488:192,2,105,2|0|0,0:0|0:0|0:0,0:0:0:0:
88,192,42953,1,0,0:0:0:0:
344,192,43125,2,0,L|239:192,1,105,2|0,0:0|0:0,0:0:0:0:
496,192,43470,2,0,L|336:192,1,157.5,2|0,0:0|0:0,0:0:0:0:
160,192,43815,6,0,L|48:192,3,105,2|0|2|0,0:0|0:0|0:0|0:0,0:0:0:0:
352,192,44504,2,0,L|464:192,3,105,2|0|2|0,0:0|0:0|0:0|0:0,0:0:0:0:
192,192,45194,5,2,0:0:0:0:
456,192,45539,1,0,0:0:0:0:
32,192,45884,2,0,P|104:96|328:120,1,367.5,2|0,0:0|0:0,0:0:0:0:
184,192,46573,6,0,L|24:192,1,157.5,6|0,0:0|0:0,0:0:0:0:
184,192,46918,1,8,0:0:0:0:
64,192,47091,1,0,0:0:0:0:
344,192,47263,2,0,L|400:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
192,192,47436,2,0,L|136:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
400,192,47608,1,8,0:0:0:0:
128,192,47780,2,0,L|176:168,1,52.5,2|0,0:0|0:0,0:0:0:0:
352,192,47953,6,0,P|404:184|452:160,1,105,2|0,0:0|0:0,0:0:0:0:
180,192,48298,1,8,0:0:0:0:
452,192,48470,2,0,L|396:192,1,52.5
96,192,48642,1,0,0:0:0:0:
110,155,48685,1,0,0:0:0:0:
137,127,48728,1,0,0:0:0:0:
174,113,48771,1,0,0:0:0:0:
213,114,48814,1,0,0:0:0:0:
248,130,48857,1,0,0:0:0:0:
274,159,48900,1,0,0:0:0:0:
287,196,48943,1,0,0:0:0:0:
128,192,48987,2,0,L|24:192,1,105,8|0,0:0|0:0,0:0:0:0:
320,192,49332,6,0,L|496:192,1,157.5,2|0,0:0|0:0,0:0:0:0:
320,192,49677,2,0,L|224:192,1,78.7500030040742,8|0,0:0|0:0,0:0:0:0:
80,192,49849,2,0,L|136:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
288,192,50022,2,0,L|232:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
64,192,50194,2,0,L|120:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
312,192,50367,2,0,L|272:152,1,52.5,8|0,0:0|0:0,0:0:0:0:
120,192,50539,2,0,L|176:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
336,192,50711,6,0,L|224:192,1,105,2|0,0:0|0:0,0:0:0:0:
496,192,51056,2,0,L|384:192,1,105,8|0,0:0|0:0,0:0:0:0:
224,192,51401,2,0,L|352:192,1,105,2|2,0:0|0:0,0:0:0:0:
272,192,51660,1,2,0:0:0:0:
120,192,51746,2,0,L|8:192,1,105,8|2,0:0|0:0,0:0:0:0:
360,192,52091,6,0,L|464:192,1,105,2|0,0:0|0:0,0:0:0:0:
192,192,52436,1,8,0:0:0:0:
464,192,52608,1,0,0:0:0:0:
484,176,52651,1,0,0:0:0:0:
497,153,52694,1,0,0:0:0:0:
497,127,52737,1,0,0:0:0:0:
360,192,52780,1,2,0:0:0:0:
200,192,52867,1,2,0:0:0:0:
40,192,52953,1,2,0:0:0:0:
360,192,53125,1,8,0:0:0:0:
40,192,53298,2,0,L|88:168,1,52.5
240,192,53470,5,2,0:0:0:0:
490,192,53642,2,0,L|418:192,1,52.5
256,192,53815,1,8,0:0:0:0:
112,192,53987,2,0,L|168:192,1,52.5
328,192,54160,5,0,0:0:0:0:
353,196,54203,1,0,0:0:0:0:
378,188,54246,1,0,0:0:0:0:
395,169,54289,1,0,0:0:0:0:
400,144,54332,1,0,0:0:0:0:
393,119,54375,1,0,0:0:0:0:
374,101,54418,1,0,0:0:0:0:
348,95,54461,1,0,0:0:0:0:
216,192,54504,5,8,0:0:0:0:
190,196,54547,1,0,0:0:0:0:
165,188,54590,1,0,0:0:0:0:
148,169,54633,1,0,0:0:0:0:
143,144,54676,1,0,0:0:0:0:
150,119,54719,1,0,0:0:0:0:
169,101,54762,1,0,0:0:0:0:
195,95,54805,1,0,0:0:0:0:
72,192,54849,6,0,P|24:128|80:64,1,157.5,2|0,0:0|0:0,0:0:0:0:
256,192,55194,1,8,0:0:0:0:
448,192,55280,1,2,0:0:0:0:
256,192,55367,1,2,0:0:0:0:
64,192,55453,1,2,0:0:0:0:
256,192,55539,2,0,L|320:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
116,192,55711,2,0,L|60:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
256,192,55884,2,0,L|320:192,1,52.5,8|0,0:0|0:0,0:0:0:0:
116,192,56056,2,0,L|60:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
336,192,56229,6,0,L|496:192,1,157.500006008148,8|0,0:0|0:0,0:0:0:0:
176,192,56573,2,0,L|16:192,1,157.500006008148,8|0,0:0|0:0,0:0:0:0:
336,192,56918,5,8,0:0:0:0:
410,190,57004,1,2,0:0:0:0:
440,122,57090,1,2,0:0:0:0:
390,66,57176,1,2,0:0:0:0:
176,192,57263,2,0,P|96:112|168:48,1,236.250009012223,8|0,0:0|0:0,0:0:0:0:
352,192,57608,6,0,L|456:192,1,105,6|0,0:0|0:0,0:0:0:0:
192,192,57953,1,8,0:0:0:0:
456,192,58125,2,0,L|400:192,1,52.5
192,192,58298,2,0,L|192:136,1,52.5,2|0,0:0|0:0,0:0:0:0:
352,192,58470,2,0,L|328:144,1,52.5,2|0,0:0|0:0,0:0:0:0:
128,192,58642,1,8,0:0:0:0:
72,192,58729,2,0,L|176:192,1,105
328,192,58987,5,2,0:0:0:0:
64,192,59160,2,0,L|8:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
177,192,59332,2,0,L|241:192,1,52.5,8|0,0:0|0:0,0:0:0:0:
64,192,59504,2,0,L|8:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
176,192,59677,2,0,L|232:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
64,192,59849,2,0,L|8:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
192,192,60022,1,8,0:0:0:0:
480,192,60194,2,0,L|432:168,1,52.5
256,192,60367,6,0,L|368:192,1,105,2|0,0:0|0:0,0:0:0:0:
71,192,60711,2,0,L|175:192,1,105,8|0,0:0|0:0,0:0:0:0:
256,192,60970,1,0,0:0:0:0:
441,192,61056,2,0,L|337:192,1,105,2|2,0:0|0:0,0:0:0:0:
48,192,61401,1,8,0:0:0:0:
30,172,61444,1,0,0:0:0:0:
24,147,61487,1,0,0:0:0:0:
30,122,61530,1,0,0:0:0:0:
48,103,61573,1,0,0:0:0:0:
320,192,61746,5,2,0:0:0:0:
456,192,61918,1,2,0:0:0:0:
192,192,62091,1,8,0:0:0:0:
56,192,62263,1,2,0:0:0:0:
320,192,62436,2,0,L|216:192,1,105,2|2,0:0|0:0,0:0:0:0:
288,192,62694,1,2,0:0:0:0:
448,192,62780,1,8,0:0:0:0:
96,192,62953,1,2,0:0:0:0:
384,192,63125,6,0,L|496:192,1,105,2|0,0:0|0:0,0:0:0:0:
424,192,63384,1,0,0:0:0:0:
176,192,63470,2,0,B|32:192|32:192|51:144|96:120,1,236.250009012223,8|0,0:0|0:0,0:0:0:0:
240,192,63815,1,2,0:0:0:0:
384,192,63901,1,2,0:0:0:0:
240,192,63987,1,2,0:0:0:0:
96,192,64073,1,2,0:0:0:0:
288,192,64160,2,0,L|368:192,1,52.5,8|0,0:0|0:0,0:0:0:0:
192,192,64332,2,0,L|96:192,1,78.7500030040742,2|0,0:0|0:0,0:0:0:0:
288,192,64504,6,0,P|384:136|384:88,1,157.5,2|0,0:0|0:0,0:0:0:0:
224,192,64849,2,0,L|160:192,1,52.5,8|0,0:0|0:0,0:0:0:0:
328,192,65022,2,0,L|392:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
224,192,65194,2,0,L|160:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
344,192,65367,2,0,L|408:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
240,192,65539,2,0,L|160:192,1,78.7500030040742,8|0,0:0|0:0,0:0:0:0:
16,192,65711,2,0,L|80:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
256,192,65884,6,0,B|416:192|416:192|384:144|320:120,1,236.250009012223,2|0,0:0|0:0,0:0:0:0:
128,192,66229,1,8,0:0:0:0:
48,192,66315,2,0,L|208:192,1,157.500006008148
376,192,66573,1,8,0:0:0:0:
205,192,66660,1,2,0:0:0:0:
120,192,66746,2,0,L|176:192,1,52.5,2|2,0:0|0:0,0:0:0:0:
336,192,66918,2,0,L|208:192,1,125.999996154785,8|2,0:0|0:0,0:0:0:0:
480,192,67263,6,0,B|488:112|480:48|432:24|336:16|232:48,1,367.5,2|0,0:0|0:0,0:0:0:0:
32,192,67953,2,0,B|24:112|32:48|80:24|176:16|280:48,1,367.5,2|0,0:0|0:0,0:0:0:0:
120,192,68642,6,0,P|48:168|48:88,1,157.5,2|0,0:0|0:0,0:0:0:0:
120,192,68987,2,0,P|248:120|248:96,1,157.5,2|0,0:0|0:0,0:0:0:0:
96,192,69332,1,8,0:0:0:0:
352,192,69504,2,0,L|408:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
248,192,69677,2,0,L|192:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
352,192,69849,2,0,L|408:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
232,192,70022,6,0,L|176:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
128,192,70194,1,2,0:0:0:0:
264,192,70367,1,2,0:0:0:0:
392,192,70539,2,0,L|328:192,1,52.5
168,192,70711,5,8,0:0:0:0:
142,199,70754,1,0,0:0:0:0:
116,199,70797,1,0,0:0:0:0:
91,193,70840,1,0,0:0:0:0:
68,180,70883,1,0,0:0:0:0:
50,162,70926,1,0,0:0:0:0:
36,139,70969,1,0,0:0:0:0:
30,114,71012,1,0,0:0:0:0:
30,87,71055,1,0,0:0:0:0:
37,62,71098,1,0,0:0:0:0:
51,40,71142,1,0,0:0:0:0:
70,22,71185,1,0,0:0:0:0:
93,9,71228,1,0,0:0:0:0:
118,4,71271,1,0,0:0:0:0:
144,5,71314,1,0,0:0:0:0:
169,12,71357,1,0,0:0:0:0:
280,192,71401,6,0,P|392:120|368:72,1,210,2|2,0:0|0:0,0:0:0:0:
160,192,72004,1,0,0:0:0:0:
336,192,72091,1,8,0:0:0:0:
283,192,72177,1,2,0:0:0:0:
230,192,72263,1,2,0:0:0:0:
177,192,72349,1,2,0:0:0:0:
124,192,72435,1,2,0:0:0:0:
88,192,72522,1,2,0:0:0:0:
144,192,72608,1,2,0:0:0:0:
224,192,72694,1,2,0:0:0:0:
400,192,72780,6,0,L|504:192,1,105,2|2,0:0|0:0,0:0:0:0:
416,192,73125,1,0,0:0:0:0:
8,192,73470,2,0,L|120:192,1,105,8|0,0:0|0:0,0:0:0:0:
32,192,73815,2,0,L|192:192,1,157.5,2|0,0:0|0:0,0:0:0:0:
344,192,74160,6,0,L|456:192,1,105,2|0,0:0|0:0,0:0:0:0:
288,192,74504,2,0,L|176:192,1,105,2|0,0:0|0:0,0:0:0:0:
464,192,74849,2,0,L|408:192,1,52.5,8|0,0:0|0:0,0:0:0:0:
256,192,75022,2,0,L|192:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
352,192,75194,2,0,L|296:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
144,192,75367,2,0,L|80:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
240,192,75539,6,0,L|176:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
40,192,75711,1,2,0:0:0:0:
299,192,75884,2,0,L|352:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
496,192,76056,2,0,L|440:192,1,52.5
280,192,76229,1,8,0:0:0:0:
254,186,76272,1,0,0:0:0:0:
235,168,76315,1,0,0:0:0:0:
227,144,76358,1,0,0:0:0:0:
232,118,76401,1,0,0:0:0:0:
249,99,76444,1,0,0:0:0:0:
274,90,76487,1,0,0:0:0:0:
299,95,76530,1,0,0:0:0:0:
408,192,76573,2,0,P|488:112|464:80,1,157.5
288,192,76918,6,0,P|176:112|200:64,1,210,2|2,0:0|0:0,0:0:0:0:
352,192,77522,1,0,0:0:0:0:
208,192,77608,1,8,0:0:0:0:
157,199,77694,1,2,0:0:0:0:
112,173,77780,1,2,0:0:0:0:
95,124,77866,1,2,0:0:0:0:
111,76,77952,1,2,0:0:0:0:
155,48,78039,1,2,0:0:0:0:
240,192,78125,1,2,0:0:0:0:
292,192,78211,1,2,0:0:0:0:
144,192,78298,6,0,L|32:192,3,105,2|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0:
368,192,78987,2,0,L|480:192,3,105,2|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0:
136,192,79677,6,0,P|24:96|152:64,1,341.25,4|0,0:0|0:0,0:0:0:0:
376,192,80367,2,0,P|488:96|360:64,1,341.25,8|0,0:0|0:0,0:0:0:0:
96,192,81056,6,0,L|0:144,2,105,2|2|2,0:0|0:0|0:0,0:0:0:0:
504,192,81746,1,8,0:0:0:0:
336,192,82091,2,0,L|448:192,1,105
160,192,82436,6,0,B|8:192|8:192|56:96|152:80,1,341.25,4|0,0:0|0:0,0:0:0:0:
352,192,83125,2,0,B|504:192|504:192|456:96|360:80,1,341.25,8|0,0:0|0:0,0:0:0:0:
224,192,83815,6,0,L|112:192,2,105,2|2|2,0:0|0:0|0:0,0:0:0:0:
464,192,84504,1,8,0:0:0:0:
336,192,84677,1,0,0:0:0:0:
496,192,84849,1,0,0:0:0:0:
336,192,85022,1,0,0:0:0:0:
48,192,85194,6,0,L|272:192,2,210,4|0|8,0:0|0:0|0:0,0:0:0:0:
464,192,86229,2,0,L|240:192,2,210,0|2|2,0:0|0:0|0:0,0:0:0:0:
248,192,87263,2,0,L|136:192,1,105,8|0,0:0|0:0,0:0:0:0:
264,192,87608,2,0,L|376:192,1,105
104,192,87953,5,4,0:0:0:0:
8,192,88125,2,0,L|64:192,1,52.5
120,192,88298,2,0,L|32:136,1,105
320,192,88642,1,8,0:0:0:0:
424,192,88987,1,0,0:0:0:0:
394,192,89101,1,0,0:0:0:0:
324,192,89216,1,0,0:0:0:0:
112,192,89332,6,0,L|224:192,1,105,2|2,0:0|0:0,0:0:0:0:
384,192,89677,2,0,L|288:192,1,70,2|0,0:0|0:0,0:0:0:0:
224,192,89907,1,0,0:0:0:0:
48,192,90022,2,0,L|128:192,1,70,8|0,0:0|0:0,0:0:0:0:
208,192,90252,1,0,0:0:0:0:
280,192,90367,2,0,L|176:192,1,105,2|0,0:0|0:0,0:0:0:0:
256,192,90625,1,0,0:0:0:0:
416,192,90711,6,0,L|488:192,2,70,6|0|0,0:0|0:0|0:0,0:0:0:0:
208,192,91056,1,0,0:0:0:0:
181,192,91099,1,0,0:0:0:0:
157,184,91142,1,0,0:0:0:0:
136,168,91185,1,0,0:0:0:0:
123,146,91228,1,0,0:0:0:0:
118,120,91271,1,0,0:0:0:0:
122,94,91314,1,0,0:0:0:0:
135,72,91357,1,0,0:0:0:0:
256,192,91401,6,0,L|336:192,2,70,8|0|0,0:0|0:0|0:0,0:0:0:0:
168,192,91746,2,0,L|88:192,2,70
384,192,92091,5,2,0:0:0:0:
410,191,92134,1,0,0:0:0:0:
434,181,92177,1,0,0:0:0:0:
452,163,92220,1,0,0:0:0:0:
424,192,92263,1,2,0:0:0:0:
397,192,92306,1,0,0:0:0:0:
373,182,92349,1,0,0:0:0:0:
355,164,92392,1,0,0:0:0:0:
232,192,92436,2,0,L|152:192,2,70,2|0|0,0:0|0:0|0:0,0:0:0:0:
432,192,92780,6,0,L|504:192,2,70,8|0|0,0:0|0:0|0:0,0:0:0:0:
337,192,93125,2,0,L|225:192,1,105,2|0,0:0|0:0,0:0:0:0:
496,192,93470,6,0,L|408:192,2,70,2|0|0,0:0|0:0|0:0,0:0:0:0:
320,192,93815,1,0,0:0:0:0:
294,196,93858,1,0,0:0:0:0:
268,191,93901,1,0,0:0:0:0:
246,177,93944,1,0,0:0:0:0:
231,156,93987,1,0,0:0:0:0:
224,131,94030,1,0,0:0:0:0:
226,105,94073,1,0,0:0:0:0:
238,82,94116,1,0,0:0:0:0:
352,192,94160,6,0,L|464:192,2,70,8|0|0,0:0|0:0|0:0,0:0:0:0:
160,192,94504,2,0,L|48:192,2,70
352,192,94849,5,2,0:0:0:0:
325,192,94892,1,0,0:0:0:0:
299,192,94935,1,0,0:0:0:0:
288,192,94978,1,0,0:0:0:0:
299,192,95022,1,2,0:0:0:0:
325,192,95065,1,0,0:0:0:0:
351,192,95108,1,0,0:0:0:0:
377,192,95151,1,0,0:0:0:0:
272,192,95194,2,0,L|217:144,2,70,2|0|0,0:0|0:0|0:0,0:0:0:0:
72,192,95539,6,0,L|16:144,2,70,8|0|0,0:0|0:0|0:0,0:0:0:0:
280,192,95884,2,0,L|392:192,1,105,2|0,0:0|0:0,0:0:0:0:
96,192,96229,5,2,0:0:0:0:
288,192,96344,1,0,0:0:0:0:
488,192,96458,1,0,0:0:0:0:
256,192,96573,1,0,0:0:0:0:
229,193,96616,1,0,0:0:0:0:
204,185,96659,1,0,0:0:0:0:
183,170,96702,1,0,0:0:0:0:
168,149,96745,1,0,0:0:0:0:
160,124,96788,1,0,0:0:0:0:
160,98,96831,1,0,0:0:0:0:
169,74,96874,1,0,0:0:0:0:
288,192,96918,5,8,0:0:0:0:
480,192,97033,1,0,0:0:0:0:
288,192,97147,1,0,0:0:0:0:
80,192,97263,1,0,0:0:0:0:
272,192,97378,1,0,0:0:0:0:
464,192,97492,1,0,0:0:0:0:
256,192,97608,5,2,0:0:0:0:
232,183,97651,1,0,0:0:0:0:
217,162,97694,1,0,0:0:0:0:
224,136,97737,1,0,0:0:0:0:
256,192,97780,1,2,0:0:0:0:
281,183,97823,1,0,0:0:0:0:
296,162,97866,1,0,0:0:0:0:
297,136,97909,1,0,0:0:0:0:
160,192,97953,2,0,L|80:192,2,70,2|0|0,0:0|0:0|0:0,0:0:0:0:
352,192,98298,6,0,L|432:192,2,70,8|0|0,0:0|0:0|0:0,0:0:0:0:
160,192,98642,2,0,L|40:192,1,105,2|0,0:0|0:0,0:0:0:0:
296,192,98987,5,6,0:0:0:0:
488,192,99102,1,0,0:0:0:0:
296,192,99217,1,0,0:0:0:0:
72,192,99332,1,0,0:0:0:0:
46,189,99375,1,0,0:0:0:0:
24,175,99418,1,0,0:0:0:0:
10,153,99461,1,0,0:0:0:0:
7,127,99504,1,0,0:0:0:0:
17,103,99547,1,0,0:0:0:0:
32,80,99590,1,0,0:0:0:0:
48,72,99633,1,0,0:0:0:0:
152,192,99677,5,2,0:0:0:0:
328,192,99792,1,0,0:0:0:0:
152,192,99907,1,0,0:0:0:0:
440,192,100022,1,0,0:0:0:0:
264,192,100137,1,0,0:0:0:0:
88,192,100252,1,0,0:0:0:0:
264,192,100367,5,4,0:0:0:0:
296,192,100410,1,0,0:0:0:0:
296,192,100453,1,0,0:0:0:0:
264,192,100496,1,0,0:0:0:0:
232,192,100539,1,0,0:0:0:0:
208,192,100582,1,0,0:0:0:0:
208,192,100625,1,0,0:0:0:0:
232,192,100668,1,0,0:0:0:0:
344,192,100711,1,2,0:0:0:0:
152,192,101056,1,2,0:0:0:0:
408,192,101401,1,2,0:0:0:0:
64,192,101746,5,4,0:0:0:0:
20,168,101832,1,0,0:0:0:0:
24,119,101918,1,0,0:0:0:0:
72,104,102004,1,0,0:0:0:0:
160,192,102091,2,0,L|296:192,1,105
416,192,102436,2,0,L|256:192,1,157.5,2|0,0:0|0:0,0:0:0:0:
336,192,102780,2,0,L|400:192,1,52.5
288,192,102953,2,0,L|224:192,1,52.5
80,192,103125,6,0,L|184:192,1,105,2|0,0:0|0:0,0:0:0:0:
464,192,103470,1,2,0:0:0:0:
436,147,103556,1,0,0:0:0:0:
396,113,103642,1,0,0:0:0:0:
350,89,103728,1,0,0:0:0:0:
300,73,103815,1,2,0:0:0:0:
248,66,103901,1,0,0:0:0:0:
195,67,103987,1,0,0:0:0:0:
145,81,104073,1,0,0:0:0:0:
110,118,104159,1,2,0:0:0:0:
124,167,104245,1,0,0:0:0:0:
158,207,104332,1,0,0:0:0:0:
198,241,104418,1,0,0:0:0:0:
360,192,104504,5,4,0:0:0:0:
407,174,104590,1,0,0:0:0:0:
426,127,104676,1,0,0:0:0:0:
403,82,104762,1,0,0:0:0:0:
208,192,104849,2,0,L|104:192,1,105
384,192,105194,2,0,P|476:146|432:56,1,210,2|0,0:0|0:0,0:0:0:0:
160,192,105711,1,0,0:0:0:0:
456,192,105884,5,2,0:0:0:0:
456,192,106142,1,0,0:0:0:0:
272,192,106229,1,2,0:0:0:0:
220,180,106315,1,0,0:0:0:0:
170,164,106401,1,0,0:0:0:0:
123,141,106487,1,0,0:0:0:0:
91,101,106573,1,2,0:0:0:0:
125,65,106660,1,0,0:0:0:0:
175,51,106746,1,0,0:0:0:0:
227,45,106832,1,0,0:0:0:0:
280,42,106918,1,2,0:0:0:0:
332,42,107004,1,0,0:0:0:0:
385,44,107091,1,0,0:0:0:0:
437,47,107177,1,0,0:0:0:0:
240,192,107263,5,4,0:0:0:0:
168,192,107349,1,0,0:0:0:0:
216,192,107436,1,0,0:0:0:0:
280,192,107522,1,0,0:0:0:0:
440,192,107608,2,0,L|336:192,1,105
32,192,107953,2,0,P|64:112|176:64,1,210,2|0,0:0|0:0,0:0:0:0:
112,192,108470,1,0,0:0:0:0:
384,192,108642,5,2,0:0:0:0:
480,192,108815,2,0,L|424:192,1,52.5
256,192,108987,1,2,0:0:0:0:
200,192,109073,1,0,0:0:0:0:
144,192,109159,1,0,0:0:0:0:
98,192,109245,1,0,0:0:0:0:
256,192,109332,1,2,0:0:0:0:
308,192,109418,1,0,0:0:0:0:
361,192,109504,1,0,0:0:0:0:
413,192,109590,1,0,0:0:0:0:
256,192,109677,1,2,0:0:0:0:
16,192,109849,2,0,L|80:192,1,52.5
256,192,110022,6,0,L|144:192,1,105,4|0,0:0|0:0,0:0:0:0:
100,185,110280,1,0,0:0:0:0:
67,146,110366,1,2,0:0:0:0:
70,95,110452,1,0,0:0:0:0:
107,59,110538,1,0,0:0:0:0:
159,59,110625,1,0,0:0:0:0:
328,192,110711,1,2,0:0:0:0:
373,169,110797,1,0,0:0:0:0:
382,119,110883,1,0,0:0:0:0:
347,82,110969,1,0,0:0:0:0:
256,192,111056,1,2,0:0:0:0:
203,192,111142,1,0,0:0:0:0:
151,192,111228,1,0,0:0:0:0:
304,192,111315,1,0,0:0:0:0:
464,192,111401,5,4,0:0:0:0:
256,192,111573,12,0,112780,0:0:0:0:
224,192,113125,6,0,L|448:192,1,210,2|0,0:0|0:0,0:0:0:0:
16,192,113815,1,2,0:0:0:0:
480,192,114160,6,0,L|152:192,1,315,2|0,0:0|0:0,0:0:0:0:
32,192,114849,2,0,L|256:192,1,210,2|0,0:0|0:0,0:0:0:0:
480,192,115539,2,0,B|360:96|136:104|152:200|112:288|112:288,2,420,2|2|2,0:0|0:0|0:0,0:0:0:0:
400,192,117091,5,0,0:0:0:0:
347,192,117177,1,0,0:0:0:0:
295,192,117263,1,2,0:0:0:0:
224,192,117349,1,0,0:0:0:0:
62,192,117436,2,0,L|126:192,1,52.5
277,192,117608,1,4,0:0:0:0:
96,192,117953,2,0,P|16:120|32:96,1,157.5
256,192,118298,6,0,P|360:80|328:48,1,236.250009012223,4|0,0:0|0:0,0:0:0:0:
176,192,118642,1,8,0:0:0:0:
440,192,118815,2,0,L|496:192,1,52.5
336,192,118987,2,0,L|280:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
440,192,119160,2,0,L|496:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
256,192,119332,1,8,0:0:0:0:
192,192,119418,2,0,L|280:128,1,105
128,192,119677,5,2,0:0:0:0:
416,192,119849,2,0,L|468:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
264,192,120022,2,0,L|320:192,1,52.5,8|0,0:0|0:0,0:0:0:0:
464,192,120194,2,0,L|408:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
264,192,120367,2,0,L|320:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
488,192,120539,2,0,L|432:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
288,192,120711,2,0,L|216:192,1,62.9999980773926,8|0,0:0|0:0,0:0:0:0:
65,192,120884,2,0,L|16:152,1,62.9999980773926,2|0,0:0|0:0,0:0:0:0:
264,192,121056,6,0,P|344:120|336:80,1,157.5,2|0,0:0|0:0,0:0:0:0:
176,192,121401,1,8,0:0:0:0:
56,192,121573,1,0,0:0:0:0:
328,192,121746,2,0,L|448:192,1,105,2|2,0:0|0:0,0:0:0:0:
160,192,122091,1,8,0:0:0:0:
186,192,122134,1,0,0:0:0:0:
212,192,122177,1,0,0:0:0:0:
238,192,122220,1,0,0:0:0:0:
265,192,122263,1,0,0:0:0:0:
16,192,122436,6,0,L|128:192,1,105,2|0,0:0|0:0,0:0:0:0:
384,192,122780,2,0,L|272:192,1,105,8|0,0:0|0:0,0:0:0:0:
16,192,123125,1,2,0:0:0:0:
160,192,123298,1,2,0:0:0:0:
160,192,123384,1,2,0:0:0:0:
16,192,123470,1,8,0:0:0:0:
288,192,123642,1,2,0:0:0:0:
16,192,123815,6,0,L|128:192,1,105,2|0,0:0|0:0,0:0:0:0:
384,192,124160,1,8,0:0:0:0:
240,192,124332,1,0,0:0:0:0:
496,192,124504,1,2,0:0:0:0:
344,192,124591,1,2,0:0:0:0:
192,192,124677,1,2,0:0:0:0:
40,192,124763,1,2,0:0:0:0:
344,192,124849,2,0,L|424:192,1,78.7500030040742,8|0,0:0|0:0,0:0:0:0:
192,192,125022,2,0,L|136:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
336,192,125194,6,0,P|432:152|432:96,1,157.5,2|0,0:0|0:0,0:0:0:0:
280,192,125539,2,0,L|200:192,1,78.7500030040742,8|0,0:0|0:0,0:0:0:0:
56,192,125711,2,0,L|0:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
176,192,125884,2,0,L|257:192,1,78.7500030040742,2|0,0:0|0:0,0:0:0:0:
400,192,126056,2,0,L|456:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
288,192,126229,2,0,L|208:192,1,78.7500030040742,8|0,0:0|0:0,0:0:0:0:
56,192,126401,2,0,L|56:136,1,52.5,2|0,0:0|0:0,0:0:0:0:
256,192,126573,6,0,P|208:136|136:176,1,157.5,2|0,0:0|0:0,0:0:0:0:
288,192,126918,1,8,0:0:0:0:
32,192,127091,2,0,L|32:128,1,52.5,2|0,0:0|0:0,0:0:0:0:
176,192,127263,1,2,0:0:0:0:
320,192,127349,1,2,0:0:0:0:
176,192,127436,1,2,0:0:0:0:
424,192,127608,2,0,L|424:88,1,105,8|0,0:0|0:0,0:0:0:0:
152,192,127953,6,0,L|40:192,1,105,8|2,0:0|0:0,0:0:0:0:
360,192,128298,2,0,L|472:192,1,105,8|2,0:0|0:0,0:0:0:0:
128,192,128642,1,2,0:0:0:0:
280,192,128729,1,2,0:0:0:0:
424,192,128815,1,2,0:0:0:0:
368,192,128901,1,2,0:0:0:0:
192,192,128987,2,0,P|104:112|120:72,1,157.5,8|0,0:0|0:0,0:0:0:0:
320,192,129332,6,0,P|408:112|392:72,1,157.5,6|0,0:0|0:0,0:0:0:0:
216,192,129677,2,0,L|80:192,1,105,8|0,0:0|0:0,0:0:0:0:
400,192,130022,2,0,L|456:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
304,192,130194,2,0,L|224:192,1,78.7500030040742,2|0,0:0|0:0,0:0:0:0:
64,192,130367,2,0,L|112:160,1,52.5,8|0,0:0|0:0,0:0:0:0:
272,192,130539,2,0,L|208:192,1,52.5
64,192,130711,6,0,L|120:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
272,192,130884,2,0,L|352:192,1,78.7500030040742,2|0,0:0|0:0,0:0:0:0:
496,192,131056,2,0,P|453:151|352:144,1,157.5,8|0,0:0|0:0,0:0:0:0:
128,192,131401,1,0,0:0:0:0:
102,192,131444,1,0,0:0:0:0:
79,180,131487,1,0,0:0:0:0:
65,158,131530,1,0,0:0:0:0:
64,132,131573,1,0,0:0:0:0:
77,109,131616,1,0,0:0:0:0:
99,96,131659,1,0,0:0:0:0:
125,95,131702,1,0,0:0:0:0:
232,192,131746,1,8,0:0:0:0:
328,192,131918,1,2,0:0:0:0:
48,192,132091,6,0,P|112:112|176:104,1,157.5,2|0,0:0|0:0,0:0:0:0:
328,192,132436,1,8,0:0:0:0:
64,192,132608,1,2,0:0:0:0:
480,192,132780,1,2,0:0:0:0:
328,192,132867,1,0,0:0:0:0:
176,192,132953,1,2,0:0:0:0:
24,192,133039,1,0,0:0:0:0:
176,192,133125,1,8,0:0:0:0:
328,192,133212,1,0,0:0:0:0:
176,192,133298,1,2,0:0:0:0:
24,192,133384,1,0,0:0:0:0:
256,192,133470,5,0,0:0:0:0:
292,206,133513,1,0,0:0:0:0:
330,200,133556,1,0,0:0:0:0:
361,176,133599,1,0,0:0:0:0:
375,140,133642,1,0,0:0:0:0:
370,101,133685,1,0,0:0:0:0:
346,70,133728,1,0,0:0:0:0:
192,192,133815,2,0,L|88:192,1,105,8|0,0:0|0:0,0:0:0:0:
368,192,134160,1,0,0:0:0:0:
405,183,134203,1,0,0:0:0:0:
434,157,134246,1,0,0:0:0:0:
448,120,134289,1,0,0:0:0:0:
442,82,134332,1,0,0:0:0:0:
288,192,134418,1,0,0:0:0:0:
136,192,134504,2,0,P|144:136|192:112,1,105,8|0,0:0|0:0,0:0:0:0:
448,192,134849,6,0,P|480:120|416:80,1,157.5,2|0,0:0|0:0,0:0:0:0:
208,192,135194,1,8,0:0:0:0:
80,192,135367,1,2,0:0:0:0:
352,192,135539,2,0,L|296:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
155,192,135711,2,0,L|211:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
384,192,135884,2,0,L|328:192,1,52.5,8|0,0:0|0:0,0:0:0:0:
187,192,136056,2,0,L|243:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
384,192,136229,6,0,P|440:136|424:112,1,105,2|0,0:0|0:0,0:0:0:0:
144,192,136573,1,8,0:0:0:0:
48,192,136746,2,0,L|104:192,1,52.5
256,192,136918,2,0,L|296:192,7,26.25,2|0|0|0|0|0|0|0,0:0|0:0|0:0|0:0|0:0|0:0|0:0|0:0,0:0:0:0:
160,192,137263,2,0,L|48:192,1,105,8|0,0:0|0:0,0:0:0:0:
368,192,137608,6,0,P|448:112|432:88,1,157.5,2|0,0:0|0:0,0:0:0:0:
256,192,137953,2,0,L|144:192,1,105,8|0,0:0|0:0,0:0:0:0:
480,192,138298,1,2,0:0:0:0:
328,192,138384,1,2,0:0:0:0:
176,192,138470,1,2,0:0:0:0:
96,192,138556,1,2,0:0:0:0:
256,192,138642,1,8,0:0:0:0:
400,192,138729,1,0,0:0:0:0:
216,192,138815,2,0,L|152:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
352,192,138987,6,0,L|496:120,3,157.500006008148,2|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0:
192,192,139677,2,0,L|56:192,1,125.999996154785,2|0,0:0|0:0,0:0:0:0:
368,192,140022,2,0,L|256:192,1,105,2|0,0:0|0:0,0:0:0:0:
16,192,140367,5,2,0:0:0:0:
48,151,140453,1,0,0:0:0:0:
94,125,140539,1,0,0:0:0:0:
145,118,140625,1,0,0:0:0:0:
304,192,140711,2,0,L|416:192,1,105
120,192,141056,2,0,P|48:112|104:64,1,157.5,4|0,0:0|0:0,0:0:0:0:
160,192,141401,1,0,0:0:0:0:
212,192,141487,1,0,0:0:0:0:
265,192,141573,1,0,0:0:0:0:
317,192,141659,1,0,0:0:0:0:
160,192,141746,5,2,0:0:0:0:
212,192,141832,1,0,0:0:0:0:
265,192,141918,1,0,0:0:0:0:
317,192,142004,1,0,0:0:0:0:
160,192,142091,2,0,L|56:192,1,105
317,192,142436,2,0,P|432:128|432:80,1,157.5,4|0,0:0|0:0,0:0:0:0:
256,192,142780,2,0,L|179:192,1,52.5
152,192,142953,2,0,L|216:192,1,52.5
376,192,143125,5,2,0:0:0:0:
425,182,143211,1,0,0:0:0:0:
448,137,143297,1,0,0:0:0:0:
426,91,143383,1,0,0:0:0:0:
256,192,143470,2,0,L|360:192,1,105,2|0,0:0|0:0,0:0:0:0:
88,192,143815,2,0,P|32:112|72:72,1,157.5,4|0,0:0|0:0,0:0:0:0:
216,192,144160,2,0,L|280:192,1,52.5,2|0,0:0|0:0,0:0:0:0:
112,192,144332,2,0,L|48:192,1,52.5
288,192,144504,5,2,0:0:0:0:
448,192,144677,1,0,0:0:0:0:
471,146,144763,1,0,0:0:0:0:
464,94,144849,1,2,0:0:0:0:
428,57,144935,1,2,0:0:0:0:
377,47,145022,1,2,0:0:0:0:
330,69,145108,1,2,0:0:0:0:
64,192,145194,1,4,0:0:0:0:
296,192,145884,5,0,0:0:0:0:
128,192,146229,1,2,0:0:0:0:
280,192,146573,1,0,0:0:0:0:
56,192,146918,1,2,0:0:0:0:
480,192,147263,5,2,0:0:0:0:
288,192,147780,1,0,0:0:0:0:
432,192,147953,1,2,0:0:0:0:
160,192,148298,1,0,0:0:0:0:
472,192,148642,5,2,0:0:0:0:
128,192,149332,1,2,0:0:0:0:
488,192,150022,1,2,0:0:0:0:
16,192,150367,5,2,0:0:0:0:
488,192,150711,1,4,0:0:0:0:

31
pp/catch_the_pp/sample.py Normal file
View File

@ -0,0 +1,31 @@
import os
import sys
from .osu_parser.beatmap import Beatmap
from .osu.ctb.difficulty import Difficulty
from .ppCalc import calculate_pp
if len(sys.argv) <= 1:
beatmap = Beatmap(os.path.dirname(os.path.realpath(__file__)) + "/test.osu") # Yes... this be my test file (Will remove when project is done)
else:
beatmap = Beatmap(sys.argv[1])
if len(sys.argv) >= 3:
mods = int(sys.argv[2])
else:
mods = 0
difficulty = Difficulty(beatmap, mods)
print("Calculation:")
print("Stars: {}, PP: {}, MaxCombo: {}\n".format(
difficulty.star_rating, calculate_pp(difficulty, 1, beatmap.max_combo, 0), beatmap.max_combo
))
"""
m = {"NOMOD": 0, "EASY": 2, "HIDDEN": 8, "HARDROCK": 16, "DOUBLETIME": 64, "HALFTIME": 256, "FLASHLIGHT": 1024}
for key in m.keys():
difficulty = Difficulty(beatmap, m[key])
print("Mods: {}".format(key))
print("Stars: {}".format(difficulty.star_rating))
print("PP: {}\n".format(calculate_pp(difficulty, 1, beatmap.max_combo, 0)))
"""

16
pp/catch_the_pp/setup.py Normal file
View File

@ -0,0 +1,16 @@
from distutils.core import setup
from distutils.extension import Extension
from Cython.Build import cythonize
import os
extensions = []
for root, dirs, files in os.walk(os.getcwd()):
for file in files:
if file.endswith(".pyx"):
file_path = os.path.relpath(os.path.join(root, file))
extensions.append(Extension(file_path.replace("/", ".")[:-4], [file_path]))
setup(
name="catch-the-pp",
ext_modules=cythonize(extensions, nthreads=4),
)

1219
pp/catch_the_pp/test.osu Normal file

File diff suppressed because it is too large Load Diff

82
pp/cicciobello.py Normal file
View File

@ -0,0 +1,82 @@
from common.log import logUtils as log
from common.constants import gameModes
from constants import exceptions
from helpers import mapsHelper
from pp.catch_the_pp.osu_parser.beatmap import Beatmap as CalcBeatmap
from pp.catch_the_pp.osu.ctb.difficulty import Difficulty
from pp.catch_the_pp import ppCalc
class Cicciobello:
def __init__(self, _beatmap, _score=None, accuracy=0, mods=0, combo=-1, misses=0, tillerino=False):
# Beatmap is always present
self.beatmap = _beatmap
# If passed, set everything from score object
if _score is not None:
self.score = _score
self.accuracy = self.score.accuracy
self.mods = self.score.mods
self.combo = self.score.maxCombo
self.misses = self.score.cMiss
else:
# Otherwise, set acc and mods from params (tillerino)
self.accuracy = accuracy
self.mods = mods
self.combo = combo
if self.combo < 0:
self.combo = self.beatmap.maxCombo
self.misses = misses
# Multiple acc values computation
self.tillerino = tillerino
# Result
self.pp = 0
self.calculate_pp()
def calculate_pp(self):
try:
# Cache beatmap
mapFile = mapsHelper.cachedMapPath(self.beatmap.beatmapID)
mapsHelper.cacheMap(mapFile, self.beatmap)
# TODO: Sanizite mods
# Gamemode check
if self.score and self.score.gameMode != gameModes.CTB:
raise exceptions.unsupportedGameModeException()
# Accuracy check
if self.accuracy > 1:
raise ValueError("Accuracy must be between 0 and 1")
# Calculate difficulty
calcBeatmap = CalcBeatmap(mapFile)
difficulty = Difficulty(beatmap=calcBeatmap, mods=self.mods)
# Calculate pp
if self.tillerino:
results = []
for acc in [1, 0.99, 0.98, 0.95]:
results.append(ppCalc.calculate_pp(
diff=difficulty, accuracy=acc, combo=self.combo, miss=self.misses
))
self.pp = results
else:
self.pp = ppCalc.calculate_pp(
diff=difficulty, accuracy=self.accuracy, combo=self.combo, miss=self.misses
)
except exceptions.osuApiFailException:
log.error("cicciobello ~> osu!api error!")
self.pp = 0
except exceptions.unsupportedGameModeException:
log.error("cicciobello ~> Unsupported gamemode")
self.pp = 0
except Exception as e:
log.error("cicciobello ~> Unhandled exception: {}".format(str(e)))
self.pp = 0
raise
finally:
log.debug("cicciobello ~> Shutting down, pp = {}".format(self.pp))

BIN
pp/cmyui-testing/CAy.rar Normal file

Binary file not shown.

BIN
pp/cmyui-testing/TiS.zip Normal file

Binary file not shown.

11
pp/oppai-ng/.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
tags
*.log
*.tar.xz
*.zip
/oppai
*.json
*.obj
*.exe
*.swp
/test/test_suite
/test/oppai_test

22
pp/oppai-ng/.travis.yml Normal file
View File

@ -0,0 +1,22 @@
language: c
matrix:
include:
- os: linux
dist: precise
- os: linux
dist: trusty
- os: osx
install: true
cache:
directories:
- test/test_suite
script:
- ./build
- cd test
- ./download_suite
- ./build
- ./oppai_test

349
pp/oppai-ng/README.md Normal file
View File

@ -0,0 +1,349 @@
[![Build Status](https://travis-ci.org/Francesco149/oppai-ng.svg?branch=master)](https://travis-ci.org/Francesco149/oppai-ng)
difficulty and pp calculator for osu!
this is a pure C89 rewrite of
[oppai](https://github.com/Francesco149/oppai) with much lower
memory usage, smaller and easier to read codebase
executable size and better performance.
experimental taiko support is now available and appears to give
correct values for actual taiko maps. converted maps are still
unreliable due to incorrect slider conversion and might be
completely off (use ```-m1``` or ```-taiko``` to convert a std map
to taiko).
- [installing (linux)](#installing-linux)
- [installing (windows)](#installing-windows)
- [installing (osx)](#installing-osx)
- [usage](#usage)
- [implementations for other programming languages](#implementations-for-other-programming-languages)
- [oppai-ng vs old oppai](#oppai-ng-vs-old-oppai)
- [compile from source (windows)](#compile-from-source-windows)
- [using oppai as a library or making bindings](#using-oppai-as-a-library-or-making-bindings)
- [other build parameters](#other-build-parameters)
# installing (linux)
```sh
wget https://github.com/Francesco149/oppai-ng/archive/HEAD.tar.gz
tar xf HEAD.tar.gz
cd oppai-*
./build
sudo install -Dm 755 oppai /usr/bin/oppai
oppai
```
you can also grab pre-compiled standalone binaries (statically
linked against musl libc) from
[here](https://github.com/Francesco149/oppai-ng/releases) if you
are somehow too scared to run those 5 commands.
# installing (windows)
download and unzip binaries from
[here](https://github.com/Francesco149/oppai-ng/releases) and
optionally add oppai's folder to your ```PATH``` environment
variable for easy access. you can find a guide
[here](https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/)
if you don't know how.
# installing (osx)
## via homebrew
```sh
brew install --HEAD pmrowla/homebrew-tap/oppai-ng
```
Note that installing with ```--HEAD``` is recommended but not required.
Installing from homebrew will place the ```oppai``` executable in your homebrew path.
## manually
Follow the same steps as for linux but substitute ```curl -O``` for ```wget``` since wget is not distributed by default in osx.
The same caveat applies if you want to run the test suite - you will need to edit the ```download_suite``` script to use curl.
# usage
you can run oppai with no arguments to check the documentation.
here's some example usages:
```sh
oppai path/to/map.osu +HDHR 98% 500x 1xmiss
oppai path/to/map.osu 3x100
oppai path/to/map.osu 3x100 OD10
oppai path/to/map.osu -ojson
```
you can also pipe maps from standard input by setting the filename
to ```-```.
for example on linux you can do:
```sh
curl https://osu.ppy.sh/osu/774965 | oppai - +HDDT
curl https://osu.ppy.sh/osu/774965 | oppai - +HDDT 1200x 1m
```
while on windows it's a bit more verbose (powershell):
```powershell
(New-Object System.Net.WebClient).DownloadString("https://osu.ppy.sh/osu/37658") | ./oppai -
(New-Object System.Net.WebClient).DownloadString("https://osu.ppy.sh/osu/37658") | ./oppai - +HDHR
(New-Object System.Net.WebClient).DownloadString("https://osu.ppy.sh/osu/37658") | ./oppai - +HDHR 99% 600x 1m
```
I got the .osu file url from "Grab latest .osu file" on the
beatmap's page.
# implementations for other programming languages
oppai has been implemented for many other programming languages.
If you feel like making your own implementation and want it listed
here, open an issue or pull request. the requirement is that it
should pass the same test suite that oppai-ng passes.
note: these aren't just native bindings unless stated otherwise.
* [ojsama (javascript)](https://github.com/Francesco149/ojsama)
* [koohii (java)](https://github.com/Francesco149/koohii) . this
is currently being used in tillerino.
* [pyttanko (python)](https://github.com/Francesco149/pyttanko)
* [oppai5 (golang)](https://github.com/flesnuk/oppai5) (by flesnuk)
* [OppaiSharp (C#)](https://github.com/HoLLy-HaCKeR/OppaiSharp)
(by HoLLy)
# oppai-ng vs old oppai
executable size is around 7 times smaller:
```sh
$ cd ~/src/oppai
$ ./build.sh -static
$ wc -c oppai
574648 oppai
$ cd ~/src/oppai-ng
$ ./build -static
$ wc -c oppai
75512 oppai
```
oppai-ng has proper error output in whatever format you select,
while legacy oppai either gives empty output or just dies with
a plaintext error.
oppai-ng has well-defined errno style error codes that you can
check for when using it as a library or reading its output.
the same test suite runs about 45% faster on oppai-ng compared
to old oppai, also the peak resident memory size is 4 to 6 times
smaller according to various ```time -v``` runs.
```sh
$ cd ~/src/oppai
$ ./build_test.sh
$ time -v ./oppai_test
...
Command being timed: "./oppai_test"
User time (seconds): 13.89
System time (seconds): 0.10
Percent of CPU this job got: 99%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0m 13.99s
Average shared text size (kbytes): 0
Average unshared data size (kbytes): 0
Average stack size (kbytes): 0
Average total size (kbytes): 0
Maximum resident set size (kbytes): 45184
Average resident set size (kbytes): 0
Major (requiring I/O) page faults: 0
Minor (reclaiming a frame) page faults: 2143
Voluntary context switches: 1
Involuntary context switches: 41
Swaps: 0
File system inputs: 0
File system outputs: 0
Socket messages sent: 0
Socket messages received: 0
Signals delivered: 0
Page size (bytes): 4096
Exit status: 0
$ cd ~/src/oppai-ng/test/
$ ./build
$ time -v ./oppai_test
...
Command being timed: "./oppai_test"
User time (seconds): 9.09
System time (seconds): 0.06
Percent of CPU this job got: 99%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0m 9.15s
Average shared text size (kbytes): 0
Average unshared data size (kbytes): 0
Average stack size (kbytes): 0
Average total size (kbytes): 0
Maximum resident set size (kbytes): 11840
Average resident set size (kbytes): 0
Major (requiring I/O) page faults: 0
Minor (reclaiming a frame) page faults: 304
Voluntary context switches: 1
Involuntary context switches: 39
Swaps: 0
File system inputs: 0
File system outputs: 0
Socket messages sent: 0
Socket messages received: 0
Signals delivered: 0
Page size (bytes): 4096
Exit status: 0
```
note that when the test suite is compiled without libcurl, the
resident memory usage drops by a flat 4mb, so almost half of that
is curl.
you can expect oppai memory usage to be under 4 mb most of the time
with the raw parsed beatmap data not taking more than ~800k even
for a 15 minute marathon.
the codebase has ~3-4x less lines than legacy oppai, making it easy
to read and use as a single header library. not only it is smaller,
but it now also implements both taiko and osu, so more features
than legacy oppai.
the osu! pp and diff calc alone would be around ~3k LOC including
the cli, which would be 5x less lines than legacy oppai for the
same functionality.
```sh
$ cd ~/src/oppai
$ sloc *.cc
---------- Result ------------
Physical : 15310
Source : 14406
Comment : 301
Single-line comment : 289
Block comment : 12
Mixed : 23
Empty : 626
To Do : 11
Number of files read : 10
------------------------------
$ cd ~/src/oppai-ng
$ sloc *.c
---------- Result ------------
Physical : 4123
Source : 2906
Comment : 492
Single-line comment : 1
Block comment : 491
Mixed : 64
Empty : 811
To Do : 9
Number of files read : 2
------------------------------
```
not to mention it's C89, which will be compatible with many more
platforms and old compilers than c++98
```oppai.c``` alone is only ~2200 LOC (~1500 without comments), and
you can compile piece of it out when you don't need them.
of course, it's not as heavily tested as legacy oppai (which runs
24/7 on Tillerino's back-end), however the test suite is a very
good test that runs through ~12000 unique scores and I'm confident
this rewrite is already very stable.
# compile from source (windows)
oppai should compile even on old versions of msvc dating back to
2005, although it was only tested on msvc 2010 and higher.
have at least [microsoft c++ build tools](http://landinghub.visualstudio.com/visual-cpp-build-tools)
installed. visual studio with c/c++ support also works.
open a visual studio prompt:
```bat
cd path\to\oppai\source
build.bat
oppai
```
you can also probably set up mingw and cygwin and follow the linux
instructions instead, I'm not sure. I don't use windows.
# using oppai as a library or making bindings
the new codebase is much easier to isolate and include in your
projects.
just copy oppai.c into your project, it acts as a single-header
library.
```c
#define OPPAI_IMPLEMENTATION
#include "../oppai.c"
int main()
{
struct parser pstate;
struct beatmap map;
uint32_t mods;
struct diff_calc stars;
struct pp_calc pp;
p_init(&pstate);
p_map(&pstate, &map, stdin);
mods = MODS_HD | MODS_DT;
d_init(&stars);
d_calc(&stars, &map, mods);
printf("%g stars\n", stars.total);
b_ppv2(&map, &pp, stars.aim, stars.speed, mods);
printf("%gpp\n", pp.total);
return 0;
}
```
```sh
gcc test.c
cat /path/to/file.osu | ./a.out
```
read oppai.c, there's documentation for each function at the top.
see examples directory for detailed examples. you can also read
main.c to see how the CLI uses it.
oppai is also modular, you can define out parts of the code
that you don't use by defining any of:
```
OPPAI_NOPARSER
OPPAI_NOPP
OPPAI_NODIFFCALC
```
if you don't feel comfortable writing bindings or using oppai
from c code, you can use the -o parameter to output in json or
other parsable formats. ```examples/binary.c``` shows how to parse
the binary output.
# other build parameters
when you build the oppai cli, you can pass any of these parameters
to the build script to disable features:
* ```-DOPPAI_NOTEXT``` disables text output module
* ```-DOPPAI_NOJSON``` disables json output module
* ```-DOPPAI_NOCSV``` disables CSV output module
* ```-DOPPAI_NOBINARY``` disables binary output module
* ```-DOPPAI_DEBUG``` enables debug output module and memory usage
statistics
* ```-DOPPAI_NOSTDINT``` doesn't use ```stdint.h```, as some
machines or old compilers don't have it

24
pp/oppai-ng/UNLICENSE Normal file
View File

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

11
pp/oppai-ng/build.bat Normal file
View File

@ -0,0 +1,11 @@
@echo off
del oppai.exe >nul 2>&1
del oppai.obj >nul 2>&1
cl -D_CRT_SECURE_NO_WARNINGS=1 ^
-DNOMINMAX=1 ^
-O2 ^
-nologo -MT -Gm- -GR- -EHsc -W4 ^
main.c ^
-Feoppai.exe ^
|| EXIT /B 1

View File

@ -0,0 +1,10 @@
#!/bin/sh
dir=$(dirname $0)
for d in $dir/docker/*
do
bn=$(basename $d)
cp $d/Dockerfile .
docker build -t "oppai-ng:$bn" .
rm Dockerfile
done

View File

@ -0,0 +1,7 @@
$dir = Split-Path -Parent $MyInvocation.MyCommand.Definition
Push-Location "$dir"
$d = "docker/windows"
Copy-Item $d/Dockerfile .
docker build -t "oppai-ng:windows" .
Remove-Item Dockerfile
Pop-Location

34
pp/oppai-ng/cflags Normal file
View File

@ -0,0 +1,34 @@
#!/bin/sh
cflags="-std=c89 -pedantic"
cflags="$cflags -O3"
cflags="$cflags -fno-strict-aliasing"
cflags="$cflags -Wno-variadic-macros -Wno-long-long -Wall"
cflags="$cflags -ffunction-sections -fdata-sections"
cflags="$cflags -g0 -fno-unwind-tables -s"
cflags="$cflags -fno-asynchronous-unwind-tables"
ldflags="-lm"
cflags="$cflags $CFLAGS"
ldflags="$ldflags $LDFLAGS"
cc="$CC"
if [ $(uname) = "Darwin" ]; then
cc=${cc:-clang}
else
cc=${cc:-gcc}
fi
uname -a > flags.log
echo $cc >> flags.log
echo $cflags >> flags.log
echo $ldflags >> flags.log
$cc --version >> flags.log
$cc -dumpmachine >> flags.log
export cflags="$cflags"
export ldflags="$ldflags"
export cc="$cc"

View File

@ -0,0 +1,5 @@
FROM multiarch/alpine:x86-latest-stable
RUN apk add --no-cache musl-dev gcc git xz
WORKDIR /tmp
CMD [ "sh", "./release" ]

View File

@ -0,0 +1,5 @@
FROM multiarch/alpine:x86_64-latest-stable
RUN apk add --no-cache musl-dev gcc git xz
WORKDIR /tmp
CMD [ "sh", "./release" ]

View File

@ -0,0 +1,26 @@
# escape=`
# I wanted to use nanoserver but I couldn't get visual c++ build
# tools to install. the nuget visual c++ build tools package isn't
# enough to get a working compiler and manually copying msvc
# defeats the purpose of using the container to automatically
# install deps.
# unfortunately windowsservercore is a 4GB image
FROM microsoft/windowsservercore
RUN @"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -InputFormat None -ExecutionPolicy Bypass -Command "iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))" && SET "PATH=%PATH%;%ALLUSERSPROFILE%\chocolatey\bin"
# wait for vs_installer.exe, vs_installerservice.exe
# or vs_installershell.exe because choco doesn't
RUN powershell -NoProfile -InputFormat None -Command `
choco install git 7zip -y; `
choco install visualcpp-build-tools `
--version 15.0.26228.20170424 -y; `
Write-Host 'Waiting for Visual C++ Build Tools to finish'; `
Wait-Process -Name vs_installer
WORKDIR C:\oppai-ng
CMD powershell -ExecutionPolicy Bypass -Command `
Get-Location; Get-ChildItem; `
. .\vcvarsall17.ps1 x64; .\release.ps1; `
. .\vcvarsall17.ps1 x86; .\release.ps1

10
pp/oppai-ng/package Normal file
View File

@ -0,0 +1,10 @@
#!/bin/sh
dir=$(dirname $0)
dir=$(readlink -f $dir)
prevdir=$(pwd)
cd $dir
for d in $dir/docker/*; do
docker run --rm -v $dir:/tmp oppai-ng:$(basename $d)
done
cd $prevdir

4
pp/oppai-ng/package.ps1 Normal file
View File

@ -0,0 +1,4 @@
$dir = Split-Path -Parent $MyInvocation.MyCommand.Definition
Push-Location $dir
docker run --rm -v ${dir}:c:\oppai-ng oppai-ng:windows
Pop-Location

36
pp/oppai-ng/release Normal file
View File

@ -0,0 +1,36 @@
#!/bin/sh
dir=$(dirname $0)
olddir=$(pwd)
cd $dir
git pull origin master
echo -e "\nCompiling and Stripping"
./build -static -no-pie || exit 1
echo -e "\nPackaging"
folder="oppai-$(./oppai -version)-"
folder="${folder}$(gcc -dumpmachine)"
mkdir -p "$folder"
mv ./oppai $folder/oppai
git archive HEAD --prefix=src/ -o "$folder"/src.tar
cd "$folder"
tar xf src.tar
cd ..
rm "$folder".tar.xz
tar -cvJf "$folder".tar.xz \
"$folder"/oppai \
"$folder"/src
echo -e "\nResult:"
tar tf "$folder".tar.xz
readelf --dynamic "$folder"/oppai
ldd "$folder"/oppai
rm -rf "$folder"
cd $olddir

42
pp/oppai-ng/release.ps1 Normal file
View File

@ -0,0 +1,42 @@
# you must allow script execution by running
# 'Set-ExecutionPolicy RemoteSigned' in an admin powershell
# this requires vcvars to be already set (see vcvarsall17.ps1)
# 7zip is also required (choco install 7zip and add it to path)
$dir = Split-Path -Parent $MyInvocation.MyCommand.Definition
Push-Location "$dir"
git pull origin master -q
Write-Host ""
Write-Host "########################" -Foreground Yellow -Background Black
Write-Host "> Compiling and stripping" -Foreground Yellow -Background Black
cmd /c "build.bat"
Write-Host ""
Write-Host "########################" -Foreground Yellow -Background Black
Write-Host "> Packaging" -Foreground Yellow -Background Black
$folder = "oppai-" + $(.\oppai.exe -version) + "-windows-"
$clout = & cl 2>&1 | %{ "$_" }
"$clout" -match "(Microsoft.*for )([a-z0-9\-_]+)" | Out-Null
$folder = $folder + $Matches[2]
mkdir $folder
Copy-Item oppai.exe $folder
git archive HEAD --prefix=src\ -o $folder\src.zip
Set-Location $folder
&7z x src.zip
Set-Location ..
if (Test-Path "$folder.zip") {
Remove-Item "$folder.zip"
}
&7z a "$folder.zip" $folder\oppai.exe $folder\src
Write-Host ""
Write-Host "########################" -Foreground Yellow -Background Black
Write-Host "> Result:" -Foreground Yellow -Background Black
&7z l "$folder.zip"
Remove-Item $folder -Force -Recurse
Pop-Location

View File

@ -0,0 +1,10 @@
#!/bin/sh
if [ $(find test_suite 2>/dev/null | tail -n +2 | wc -l) = "0" ]
then
wget http://www.hnng.moe/stuff/test_suite_20180515.tar.xz \
|| exit 1
tar xf test_suite_20180515.tar.xz || exit 1
else
echo "using existing test_suite"
fi

254
pp/oppai-ng/test/gentest.py Normal file
View File

@ -0,0 +1,254 @@
#!/usr/bin/env python
import sys
import os
import time
import json
import traceback
import argparse
import hashlib
if sys.version_info[0] < 3:
# hack to force utf-8
reload(sys)
sys.setdefaultencoding('utf-8')
try:
import httplib
except ImportError:
import http.client as httplib
try:
import urllib
except ImportError:
import urllib.parse as urllib
# -------------------------------------------------------------------------
parser = argparse.ArgumentParser(
description = (
'generates the oppai test suite. outputs c++ code to ' +
'stdout and the json dump to a file.'
)
)
parser.add_argument(
'-key',
default = None,
help = (
'osu! api key. required if -input-file is not present. ' +
'can also be specified through the OSU_API_KEY ' +
'environment variable'
)
)
parser.add_argument(
'-output-file',
default = 'test_suite.json',
help = 'dumps json to this file'
)
parser.add_argument(
'-input-file',
default = None,
help = (
'loads test suite from this json file instead of '
'fetching it from osu api. if set to "-", json will be '
'read from standard input'
)
)
args = parser.parse_args()
if args.key == None and 'OSU_API_KEY' in os.environ:
args.key = os.environ['OSU_API_KEY']
# -------------------------------------------------------------------------
osu_treset = time.time() + 60
osu_ncalls = 0
def osu_get(conn, endpoint, paramsdict=None):
# GETs /api/endpoint?paramsdict&k=args.key from conn
# return json object, exits process on api errors
global osu_treset, osu_ncalls, args
sys.stderr.write('%s %s\n' % (endpoint, str(paramsdict)))
paramsdict['k'] = args.key
path = '/api/%s?%s' % (endpoint, urllib.urlencode(paramsdict))
while True:
while True:
if time.time() >= osu_treset:
osu_ncalls = 0
osu_treset = time.time() + 60
sys.stderr.write('\napi ready\n')
if osu_ncalls < 60:
break
else:
sys.stderr.write('waiting for api cooldown...\r')
time.sleep(1)
try:
conn.request('GET', path)
osu_ncalls += 1
r = conn.getresponse()
raw = ''
while True:
try:
raw += r.read()
break
except httplib.IncompleteRead as e:
raw += e.partial
j = json.loads(raw)
if 'error' in j:
sys.stderr.write('%s\n' % j['error'])
sys.exit(1)
return j
except (httplib.HTTPException, ValueError) as e:
sys.stderr.write('%s\n' % (traceback.format_exc()))
try:
# prevents exceptions on next request if the
# response wasn't previously read due to errors
conn.getresponse().read()
except httplib.HTTPException:
pass
time.sleep(5)
def gen_modstr(bitmask):
# generates c++ code for a mod combination's bitmask
mods = []
allmods = {
(1<< 0, 'nf'), (1<< 1, 'ez'), (1<< 2, 'td'), (1<< 3, 'hd'),
(1<< 4, 'hr'), (1<< 6, 'dt'), (1<< 8, 'ht'),
(1<< 9, 'nc'), (1<<10, 'fl'), (1<<12, 'so')
}
for bit, string in allmods:
if bitmask & bit != 0:
mods.append(string)
if len(mods) == 0:
return 'nomod'
return ' | '.join(mods)
# -------------------------------------------------------------------------
if args.key == None:
sys.stderr.write(
'please set OSU_API_KEY or pass it as a parameter\n'
)
sys.exit(1)
scores = []
if args.input_file == None:
# fetch a fresh test suite from osu api
top_players = [
124493, 4787150, 2558286, 1777162, 2831793, 50265
]
osu = httplib.HTTPSConnection('osu.ppy.sh')
for u in top_players:
params = { 'u': u, 'limit': 100, 'type': 'id' }
scores += osu_get(osu, 'get_user_best', params)
params = { 'm': 0, 'since': '2015-11-26' }
maps = osu_get(osu, 'get_beatmaps', params)
for m in maps:
params = { 'b': m['beatmap_id'] }
map_scores = osu_get(osu, 'get_scores', params)
if len(map_scores) == 0:
sys.stderr.write('W: map has no scores???\n')
continue
# note: api also returns qualified and loved, so ignore
# maps that don't have pp in rankings
if not 'pp' in map_scores[0]:
sys.stderr.write('W: ignoring loved/qualified map\n')
continue
for s in map_scores:
s['beatmap_id'] = m['beatmap_id']
scores += map_scores
with open(args.output_file, 'w+') as f:
f.write(json.dumps(scores))
else:
# load existing test suite from json file
with open(args.input_file, 'r') as f:
scores = json.loads(f.read())
print('/* this code was automatically generated by gentest.py */')
print('')
# make code a little nicer by shortening mods
allmods = {
'nf', 'ez', 'td', 'hd', 'hr', 'dt', 'ht', 'nc', 'fl', 'so', 'nomod'
}
for mod in allmods:
print('#define %s MODS_%s' % (mod, mod.upper()))
print('''
struct score
{
uint32_t id;
uint16_t max_combo;
uint16_t n300, n100, n50, nmiss;
uint32_t mods;
double pp;
};
struct score const suite[] =
{''')
seen_hashes = []
for s in scores:
# why is every value returned by osu api a string?
line = (
' { %s, %s, %s, %s, %s, %s, %s, %s },' %
(
s['beatmap_id'], s['maxcombo'], s['count300'],
s['count100'], s['count50'], s['countmiss'],
gen_modstr(int(s['enabled_mods'])), s['pp']
)
)
# don't include identical scores by different people
s = hashlib.sha1(line).digest()
if s in seen_hashes:
continue
print(line)
seen_hashes.append(s)
print('};\n')
for mod in allmods:
print('#undef %s' % (mod))

View File

@ -0,0 +1,15 @@
# https://stackoverflow.com/questions/2124753/how-can-i-use-powershell-with-the-visual-studio-command-prompt
param (
[string]$arch = "amd64"
)
Push-Location "C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\Common7\Tools"
cmd /c "VsDevCmd.bat -arch=$arch&set" |
ForEach-Object {
if ($_ -match "=") {
$v = $_.split("="); set-item -force -path "ENV:\$($v[0])" -value "$($v[1])"
}
}
Pop-Location
Write-Host "`nVisual Studio 2017 Command Prompt variables set." -ForegroundColor Yellow

184
pp/rippoppai.py Normal file
View File

@ -0,0 +1,184 @@
"""
oppai interface for ripple 2 / LETS
"""
import json
import os
import subprocess
from common.constants import gameModes
from common.log import logUtils as log
from common.ripple import scoreUtils
from constants import exceptions
from helpers import mapsHelper
# constants
MODULE_NAME = "rippoppai"
UNIX = True if os.name == "posix" else False
def fixPath(command):
"""
Replace / with \ if running under WIN32
commnd -- command to fix
return -- command with fixed paths
"""
if UNIX:
return command
return command.replace("/", "\\")
class OppaiError(Exception):
def __init__(self, error):
self.error = error
class oppai:
"""
Oppai cacalculator
"""
# __slots__ = ["pp", "score", "acc", "mods", "combo", "misses", "stars", "beatmap", "map"]
def __init__(self, __beatmap, __score = None, acc = 0, mods = 0, tillerino = False):
"""
Set oppai params.
__beatmap -- beatmap object
__score -- score object
acc -- manual acc. Used in tillerino-like bot. You don't need this if you pass __score object
mods -- manual mods. Used in tillerino-like bot. You don't need this if you pass __score object
tillerino -- If True, self.pp will be a list with pp values for 100%, 99%, 98% and 95% acc. Optional.
"""
# Default values
self.pp = None
self.score = None
self.acc = 0
self.mods = 0
self.combo = 0
self.misses = 0
self.stars = 0
self.tillerino = tillerino
# Beatmap object
self.beatmap = __beatmap
# If passed, set everything from score object
if __score is not None:
self.score = __score
self.acc = self.score.accuracy * 100
self.mods = self.score.mods
self.combo = self.score.maxCombo
self.misses = self.score.cMiss
self.gameMode = self.score.gameMode
else:
# Otherwise, set acc and mods from params (tillerino)
self.acc = acc
self.mods = mods
if self.beatmap.starsStd > 0:
self.gameMode = gameModes.STD
elif self.beatmap.starsTaiko > 0:
self.gameMode = gameModes.TAIKO
else:
self.gameMode = None
# Calculate pp
log.debug("oppai ~> Initialized oppai diffcalc")
self.calculatePP()
@staticmethod
def _runOppaiProcess(command):
log.debug("oppai ~> running {}".format(command))
process = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
output = json.loads(process.stdout.decode("utf-8", errors="ignore"))
if "code" not in output or "errstr" not in output:
raise OppaiError("No code in json output")
if output["code"] != 200:
raise OppaiError("oppai error {}: {}".format(output["code"], output["errstr"]))
if "pp" not in output or "stars" not in output:
raise OppaiError("No pp/stars entry in oppai json output")
pp = output["pp"]
stars = output["stars"]
log.debug("oppai ~> full output: {}".format(output))
log.debug("oppai ~> pp: {}, stars: {}".format(pp, stars))
except (json.JSONDecodeError, IndexError, OppaiError) as e:
raise OppaiError(e)
return pp, stars
def calculatePP(self):
"""
Calculate total pp value with oppai and return it
return -- total pp
"""
# Set variables
self.pp = None
try:
# Build .osu map file path
mapFile = mapsHelper.cachedMapPath(self.beatmap.beatmapID)
log.debug("oppai ~> Map file: {}".format(mapFile))
mapsHelper.cacheMap(mapFile, self.beatmap)
# Use only mods supported by oppai
modsFixed = self.mods & 5983
# Check gamemode
if self.gameMode != gameModes.STD and self.gameMode != gameModes.TAIKO:
raise exceptions.unsupportedGameModeException()
command = "./pp/oppai-ng/oppai {}".format(mapFile)
if not self.tillerino:
# force acc only for non-tillerino calculation
# acc is set for each subprocess if calculating tillerino-like pp sets
if self.acc > 0:
command += " {acc:.2f}%".format(acc=self.acc)
if self.mods > 0:
command += " +{mods}".format(mods=scoreUtils.readableMods(modsFixed))
if self.combo > 0:
command += " {combo}x".format(combo=self.combo)
if self.misses > 0:
command += " {misses}xm".format(misses=self.misses)
if self.gameMode == gameModes.TAIKO:
command += " -taiko"
command += " -ojson"
# Calculate pp
if not self.tillerino:
# self.pp, self.stars = self._runOppaiProcess(command)
temp_pp, self.stars = self._runOppaiProcess(command)
if (self.gameMode == gameModes.TAIKO and self.beatmap.starsStd > 0 and temp_pp > 800) or \
self.stars > 50:
# Invalidate pp for bugged taiko converteds and bugged inf pp std maps
self.pp = 0
else:
self.pp = temp_pp
else:
pp_list = []
for acc in [100, 99, 98, 95]:
temp_command = command
temp_command += " {acc:.2f}%".format(acc=acc)
pp, self.stars = self._runOppaiProcess(temp_command)
# If this is a broken converted, set all pp to 0 and break the loop
if self.gameMode == gameModes.TAIKO and self.beatmap.starsStd > 0 and pp > 800:
pp_list = [0, 0, 0, 0]
break
pp_list.append(pp)
self.pp = pp_list
log.debug("oppai ~> Calculated PP: {}, stars: {}".format(self.pp, self.stars))
except OppaiError:
log.error("oppai ~> oppai-ng error!")
self.pp = 0
except exceptions.osuApiFailException:
log.error("oppai ~> osu!api error!")
self.pp = 0
except exceptions.unsupportedGameModeException:
log.error("oppai ~> Unsupported gamemode")
self.pp = 0
except Exception as e:
log.error("oppai ~> Unhandled exception: {}".format(str(e)))
self.pp = 0
raise
finally:
log.debug("oppai ~> Shutting down, pp = {}".format(self.pp))

184
pp/rxoppai.py Normal file
View File

@ -0,0 +1,184 @@
"""
oppai interface for ripple 2 / LETS
"""
import json
import os
import subprocess
from common.constants import gameModes
from common.log import logUtils as log
from common.ripple import scoreUtils
from constants import exceptions
from helpers import mapsHelper
# constants
MODULE_NAME = "rxoppai"
UNIX = True if os.name == "posix" else False
def fixPath(command):
"""
Replace / with \ if running under WIN32
commnd -- command to fix
return -- command with fixed paths
"""
if UNIX:
return command
return command.replace("/", "\\")
class OppaiError(Exception):
def __init__(self, error):
self.error = error
class oppai:
"""
Oppai cacalculator
"""
# __slots__ = ["pp", "score", "acc", "mods", "combo", "misses", "stars", "beatmap", "map"]
def __init__(self, __beatmap, __score = None, acc = 0, mods = 0, tillerino = False):
"""
Set oppai params.
__beatmap -- beatmap object
__score -- score object
acc -- manual acc. Used in tillerino-like bot. You don't need this if you pass __score object
mods -- manual mods. Used in tillerino-like bot. You don't need this if you pass __score object
tillerino -- If True, self.pp will be a list with pp values for 100%, 99%, 98% and 95% acc. Optional.
"""
# Default values
self.pp = None
self.score = None
self.acc = 0
self.mods = 0
self.combo = 0
self.misses = 0
self.stars = 0
self.tillerino = tillerino
# Beatmap object
self.beatmap = __beatmap
# If passed, set everything from score object
if __score is not None:
self.score = __score
self.acc = self.score.accuracy * 100
self.mods = self.score.mods
self.combo = self.score.maxCombo
self.misses = self.score.cMiss
self.gameMode = self.score.gameMode
else:
# Otherwise, set acc and mods from params (tillerino)
self.acc = acc
self.mods = mods
if self.beatmap.starsStd > 0:
self.gameMode = gameModes.STD
elif self.beatmap.starsTaiko > 0:
self.gameMode = gameModes.TAIKO
else:
self.gameMode = None
# Calculate pp
log.debug("rxoppai ~> Initialized oppai diffcalc")
self.calculatePP()
@staticmethod
def _runOppaiProcess(command):
log.debug("rxoppai ~> running {}".format(command))
process = subprocess.run((command + " -ojson"), shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
output = json.loads(process.stdout.decode("utf-8", errors="ignore"))
if "code" not in output or "errstr" not in output:
raise OppaiError("No code in json output")
if output["code"] != 200:
raise OppaiError("rxoppai error {}: {}".format(output["code"], output["errstr"]))
if "pp" not in output or "stars" not in output:
raise OppaiError("No pp/stars entry in rxoppai json output")
pp = output["pp"]
stars = output["stars"]
log.debug("rxoppai ~> full output: {}".format(output))
log.debug("rxoppai ~> pp: {}, stars: {}".format(pp, stars))
except (json.JSONDecodeError, IndexError, OppaiError) as e:
raise OppaiError(e)
return pp, stars
def calculatePP(self):
"""
Calculate total pp value with oppai and return it
return -- total pp
"""
# Set variables
self.pp = None
try:
# Build .osu map file path
mapFile = mapsHelper.cachedMapPath(self.beatmap.beatmapID)
log.debug("rxoppai ~> Map file: {}".format(mapFile))
mapsHelper.cacheMap(mapFile, self.beatmap)
# Use only mods supported by oppai
modsFixed = self.mods & 5983
# Check gamemode
if self.gameMode != gameModes.STD and self.gameMode != gameModes.TAIKO:
raise exceptions.unsupportedGameModeException()
command = "./pp/rxoppai/oppai {}".format(mapFile)
if not self.tillerino:
# force acc only for non-tillerino calculation
# acc is set for each subprocess if calculating tillerino-like pp sets
if self.acc > 0:
command += " {acc:.2f}%".format(acc=self.acc)
if self.mods > 0:
command += " +{mods}".format(mods=scoreUtils.readableMods(modsFixed))
if self.combo > 0:
command += " {combo}x".format(combo=self.combo)
if self.misses > 0:
command += " {misses}xm".format(misses=self.misses)
if self.gameMode == gameModes.TAIKO:
command += " -taiko"
command += " -ojson"
# Calculate pp
if not self.tillerino:
# self.pp, self.stars = self._runOppaiProcess(command)
temp_pp, self.stars = self._runOppaiProcess(command)
if (self.gameMode == gameModes.TAIKO and self.beatmap.starsStd > 0 and temp_pp > 800) or \
self.stars > 50:
# Invalidate pp for bugged taiko converteds and bugged inf pp std maps
self.pp = 0
else:
self.pp = temp_pp
else:
pp_list = []
for acc in [100, 99, 98, 95]:
temp_command = command
temp_command += " {acc:.2f}%".format(acc=acc)
pp, self.stars = self._runOppaiProcess(temp_command)
# If this is a broken converted, set all pp to 0 and break the loop
if self.gameMode == gameModes.TAIKO and self.beatmap.starsStd > 0 and pp > 800:
pp_list = [0, 0, 0, 0]
break
pp_list.append(pp)
self.pp = pp_list
log.debug("rxoppai ~> Calculated PP: {}, stars: {}".format(self.pp, self.stars))
except OppaiError as e:
log.error("rxoppai ~> rxoppai error! {}".format(str(e)))
self.pp = 0
except exceptions.osuApiFailException:
log.error("rxoppai ~> osu!api error!")
self.pp = 0
except exceptions.unsupportedGameModeException:
log.error("rxoppai ~> Unsupported gamemode")
self.pp = 0
except Exception as e:
log.error("rxoppai ~> Unhandled exception: {}".format(str(e)))
self.pp = 0
raise
finally:
log.debug("rxoppai ~> Shutting down, pp = {}".format(self.pp))

31
pp/rxoppai/b3.py Normal file
View File

@ -0,0 +1,31 @@
import json
import os
import subprocess
def runOppaiProcess(command):
process = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output = json.loads(process.stdout.decode("utf-8", errors="ignore"))
if "code" not in output or "errstr" not in output:
print("oof")
if output["code"] != 200:
print("oof")
if "pp" not in output or "stars" not in output:
print("oof")
pp = output["pp"]
stars = output["stars"]
return pp, stars
command = "./oppai {}".format("../../.data/beatmaps/55.osu")
command += " {acc:.2f}%".format(acc=99.6)
command += " +{mods}".format(mods="HDHR")
command += " {combo}x".format(combo=500)
command += " {misses}xm".format(misses=1)
command += " -ojson"
pp = 0.00
stars = 0.00
pp, stars = runOppaiProcess(command)
print(pp)

BIN
pp/rxoppai/oppai Normal file

Binary file not shown.

BIN
pp/rxoppai/oppai-old Normal file

Binary file not shown.

1179
pp/rxoppai/yes.osu Normal file

File diff suppressed because it is too large Load Diff

1179
pp/rxoppai/yes2.osu Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More