commit aad3c9bb54315b77c83ed7ad53bb1e80e92f22c5 Author: Josh Date: Sun Dec 9 00:15:56 2018 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19c4df1 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3d2e5af --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/.landscape.yaml b/.landscape.yaml new file mode 100644 index 0000000..982ae34 --- /dev/null +++ b/.landscape.yaml @@ -0,0 +1,7 @@ +python-targets: + - 3 +pep8: + none: true +pylint: + disable: + - cyclic-import \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..607c518 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +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. + + +Copyright (C) + +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 . + +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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1a2773 --- /dev/null +++ b/README.md @@ -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. diff --git a/constants/__init__.py b/constants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/constants/dataTypes.py b/constants/dataTypes.py new file mode 100644 index 0000000..8cc816b --- /dev/null +++ b/constants/dataTypes.py @@ -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 diff --git a/constants/exceptions.py b/constants/exceptions.py new file mode 100644 index 0000000..cd3dfeb --- /dev/null +++ b/constants/exceptions.py @@ -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 \ No newline at end of file diff --git a/constants/rankedStatuses.py b/constants/rankedStatuses.py new file mode 100644 index 0000000..1b97681 --- /dev/null +++ b/constants/rankedStatuses.py @@ -0,0 +1,8 @@ +UNKNOWN = -2 +NOT_SUBMITTED = -1 +PENDING = 0 +NEED_UPDATE = 1 +RANKED = 2 +APPROVED = 3 +QUALIFIED = 4 +LOVED = 5 diff --git a/full_build.sh b/full_build.sh new file mode 100644 index 0000000..7b2ee9e --- /dev/null +++ b/full_build.sh @@ -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 \ No newline at end of file diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/handlers/apiCacheBeatmapHandler.py b/handlers/apiCacheBeatmapHandler.py new file mode 100644 index 0000000..2a75ab7 --- /dev/null +++ b/handlers/apiCacheBeatmapHandler.py @@ -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) \ No newline at end of file diff --git a/handlers/apiPPHandler.py b/handlers/apiPPHandler.py new file mode 100644 index 0000000..9d0d36a --- /dev/null +++ b/handlers/apiPPHandler.py @@ -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 diff --git a/handlers/apiStatusHandler.py b/handlers/apiStatusHandler.py new file mode 100644 index 0000000..71b450f --- /dev/null +++ b/handlers/apiStatusHandler.py @@ -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() diff --git a/handlers/banchoConnectHandler.py b/handlers/banchoConnectHandler.py new file mode 100644 index 0000000..006a46d --- /dev/null +++ b/handlers/banchoConnectHandler.py @@ -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") diff --git a/handlers/checkUpdatesHandler.py b/handlers/checkUpdatesHandler.py new file mode 100644 index 0000000..5aba291 --- /dev/null +++ b/handlers/checkUpdatesHandler.py @@ -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("") diff --git a/handlers/commentHandler.py b/handlers/commentHandler.py new file mode 100644 index 0000000..fd79fe9 --- /dev/null +++ b/handlers/commentHandler.py @@ -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)) diff --git a/handlers/defaultHandler.py b/handlers/defaultHandler.py new file mode 100644 index 0000000..2bc248f --- /dev/null +++ b/handlers/defaultHandler.py @@ -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(""" + + + + + +
+
+ +

Howdy, you're still connected to Akatsuki!

+ You can't access osu!'s website if the Server Switcher is On.
+ Please open the Server Switcher and click On/Off to switch server, then refresh this page. +

If you still can't access osu! website even if the switcher is Off, clean your browser cache.

+
+
+ + + """) diff --git a/handlers/downloadMapHandler.py b/handlers/downloadMapHandler.py new file mode 100644 index 0000000..0d18765 --- /dev/null +++ b/handlers/downloadMapHandler.py @@ -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") \ No newline at end of file diff --git a/handlers/emptyHandler.py b/handlers/emptyHandler.py new file mode 100644 index 0000000..ed682bc --- /dev/null +++ b/handlers/emptyHandler.py @@ -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") diff --git a/handlers/getFullReplayHandler.py b/handlers/getFullReplayHandler.py new file mode 100644 index 0000000..836481e --- /dev/null +++ b/handlers/getFullReplayHandler.py @@ -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)) \ No newline at end of file diff --git a/handlers/getReplayHandler.py b/handlers/getReplayHandler.py new file mode 100644 index 0000000..6545ca2 --- /dev/null +++ b/handlers/getReplayHandler.py @@ -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 \ No newline at end of file diff --git a/handlers/getScoresHandler.pyx b/handlers/getScoresHandler.pyx new file mode 100644 index 0000000..f9ba323 --- /dev/null +++ b/handlers/getScoresHandler.pyx @@ -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") diff --git a/handlers/getScreenshotHandler.py b/handlers/getScreenshotHandler.py new file mode 100644 index 0000000..e196cca --- /dev/null +++ b/handlers/getScreenshotHandler.py @@ -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) diff --git a/handlers/loadTestHandler.py b/handlers/loadTestHandler.py new file mode 100644 index 0000000..95e6ee0 --- /dev/null +++ b/handlers/loadTestHandler.py @@ -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") diff --git a/handlers/mapsHandler.py b/handlers/mapsHandler.py new file mode 100644 index 0000000..e5e39c9 --- /dev/null +++ b/handlers/mapsHandler.py @@ -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) \ No newline at end of file diff --git a/handlers/osuErrorHandler.py b/handlers/osuErrorHandler.py new file mode 100644 index 0000000..3f0137d --- /dev/null +++ b/handlers/osuErrorHandler.py @@ -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("") diff --git a/handlers/osuSearchHandler.py b/handlers/osuSearchHandler.py new file mode 100644 index 0000000..9332184 --- /dev/null +++ b/handlers/osuSearchHandler.py @@ -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) diff --git a/handlers/osuSearchSetHandler.py b/handlers/osuSearchSetHandler.py new file mode 100644 index 0000000..529d1b0 --- /dev/null +++ b/handlers/osuSearchSetHandler.py @@ -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) \ No newline at end of file diff --git a/handlers/redirectHandler.py b/handlers/redirectHandler.py new file mode 100644 index 0000000..7c4e62a --- /dev/null +++ b/handlers/redirectHandler.py @@ -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)) diff --git a/handlers/submitModularHandler.pyx b/handlers/submitModularHandler.pyx new file mode 100644 index 0000000..3853a52 --- /dev/null +++ b/handlers/submitModularHandler.pyx @@ -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) diff --git a/handlers/uploadScreenshotHandler.py b/handlers/uploadScreenshotHandler.py new file mode 100644 index 0000000..162fe5f --- /dev/null +++ b/handlers/uploadScreenshotHandler.py @@ -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 \ No newline at end of file diff --git a/helpers/__init__.py b/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/helpers/aeshelper.py b/helpers/aeshelper.py new file mode 100644 index 0000000..cca072b --- /dev/null +++ b/helpers/aeshelper.py @@ -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)) diff --git a/helpers/binaryHelper.py b/helpers/binaryHelper.py new file mode 100644 index 0000000..c2ce258 --- /dev/null +++ b/helpers/binaryHelper.py @@ -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 = " 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 + diff --git a/helpers/config.py b/helpers/config.py new file mode 100644 index 0000000..88d9da4 --- /dev/null +++ b/helpers/config.py @@ -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() diff --git a/helpers/consoleHelper.py b/helpers/consoleHelper.py new file mode 100644 index 0000000..1aa7e8c --- /dev/null +++ b/helpers/consoleHelper.py @@ -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) diff --git a/helpers/exceptionsTracker.py b/helpers/exceptionsTracker.py new file mode 100644 index 0000000..9845446 --- /dev/null +++ b/helpers/exceptionsTracker.py @@ -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 diff --git a/helpers/leaderboardHelper.py b/helpers/leaderboardHelper.py new file mode 100644 index 0000000..f5952b3 --- /dev/null +++ b/helpers/leaderboardHelper.py @@ -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)) diff --git a/helpers/levbodHelper.py b/helpers/levbodHelper.py new file mode 100644 index 0000000..03babf7 --- /dev/null +++ b/helpers/levbodHelper.py @@ -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) \ No newline at end of file diff --git a/helpers/mapsHelper.py b/helpers/mapsHelper.py new file mode 100644 index 0000000..d3f3895 --- /dev/null +++ b/helpers/mapsHelper.py @@ -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) diff --git a/helpers/osuapiHelper.py b/helpers/osuapiHelper.py new file mode 100644 index 0000000..7a297a1 --- /dev/null +++ b/helpers/osuapiHelper.py @@ -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 \ No newline at end of file diff --git a/helpers/replayHelper.py b/helpers/replayHelper.py new file mode 100644 index 0000000..d033964 --- /dev/null +++ b/helpers/replayHelper.py @@ -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 \ No newline at end of file diff --git a/lets.py b/lets.py new file mode 100644 index 0000000..0301ec7 --- /dev/null +++ b/lets.py @@ -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) diff --git a/objects/__init__.py b/objects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/objects/beatmap.pyx b/objects/beatmap.pyx new file mode 100644 index 0000000..f28ae0c --- /dev/null +++ b/objects/beatmap.pyx @@ -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]) \ No newline at end of file diff --git a/objects/glob.py b/objects/glob.py new file mode 100644 index 0000000..b7bf1b2 --- /dev/null +++ b/objects/glob.py @@ -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 = {} \ No newline at end of file diff --git a/objects/relaxboard.pyx b/objects/relaxboard.pyx new file mode 100644 index 0000000..54d06bd --- /dev/null +++ b/objects/relaxboard.pyx @@ -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 diff --git a/objects/rxscore.pyx b/objects/rxscore.pyx new file mode 100644 index 0000000..7036320 --- /dev/null +++ b/objects/rxscore.pyx @@ -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 diff --git a/objects/score.pyx b/objects/score.pyx new file mode 100644 index 0000000..83f30aa --- /dev/null +++ b/objects/score.pyx @@ -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 diff --git a/objects/scoreboard.pyx b/objects/scoreboard.pyx new file mode 100644 index 0000000..fcc9a10 --- /dev/null +++ b/objects/scoreboard.pyx @@ -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 diff --git a/personalBestCache.py b/personalBestCache.py new file mode 100644 index 0000000..3a38259 --- /dev/null +++ b/personalBestCache.py @@ -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") diff --git a/pp/__init__.py b/pp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pp/catch_the_pp/.gitignore b/pp/catch_the_pp/.gitignore new file mode 100644 index 0000000..4ecf4a4 --- /dev/null +++ b/pp/catch_the_pp/.gitignore @@ -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 \ No newline at end of file diff --git a/pp/catch_the_pp/LICENSE b/pp/catch_the_pp/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/pp/catch_the_pp/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/pp/catch_the_pp/README.md b/pp/catch_the_pp/README.md new file mode 100644 index 0000000..4ea0b13 --- /dev/null +++ b/pp/catch_the_pp/README.md @@ -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. \ No newline at end of file diff --git a/pp/catch_the_pp/__init__.py b/pp/catch_the_pp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pp/catch_the_pp/constants.pyx b/pp/catch_the_pp/constants.pyx new file mode 100644 index 0000000..84b2091 --- /dev/null +++ b/pp/catch_the_pp/constants.pyx @@ -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 \ No newline at end of file diff --git a/pp/catch_the_pp/osu/__init__.py b/pp/catch_the_pp/osu/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pp/catch_the_pp/osu/ctb/__init__.py b/pp/catch_the_pp/osu/ctb/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pp/catch_the_pp/osu/ctb/difficulty.pyx b/pp/catch_the_pp/osu/ctb/difficulty.pyx new file mode 100644 index 0000000..a5b53b5 --- /dev/null +++ b/pp/catch_the_pp/osu/ctb/difficulty.pyx @@ -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 diff --git a/pp/catch_the_pp/osu_parser/__init__.py b/pp/catch_the_pp/osu_parser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pp/catch_the_pp/osu_parser/beatmap.pyx b/pp/catch_the_pp/osu_parser/beatmap.pyx new file mode 100644 index 0000000..7deb020 --- /dev/null +++ b/pp/catch_the_pp/osu_parser/beatmap.pyx @@ -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 diff --git a/pp/catch_the_pp/osu_parser/curves.pyx b/pp/catch_the_pp/osu_parser/curves.pyx new file mode 100644 index 0000000..b159f7d --- /dev/null +++ b/pp/catch_the_pp/osu_parser/curves.pyx @@ -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) \ No newline at end of file diff --git a/pp/catch_the_pp/osu_parser/hitobject.pyx b/pp/catch_the_pp/osu_parser/hitobject.pyx new file mode 100644 index 0000000..529f2ea --- /dev/null +++ b/pp/catch_the_pp/osu_parser/hitobject.pyx @@ -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 diff --git a/pp/catch_the_pp/osu_parser/mathhelper.pyx b/pp/catch_the_pp/osu_parser/mathhelper.pyx new file mode 100644 index 0000000..5abe957 --- /dev/null +++ b/pp/catch_the_pp/osu_parser/mathhelper.pyx @@ -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) diff --git a/pp/catch_the_pp/ppCalc.pyx b/pp/catch_the_pp/ppCalc.pyx new file mode 100644 index 0000000..50ef762 --- /dev/null +++ b/pp/catch_the_pp/ppCalc.pyx @@ -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 diff --git a/pp/catch_the_pp/reanimate.osu b/pp/catch_the_pp/reanimate.osu new file mode 100644 index 0000000..7af35b1 --- /dev/null +++ b/pp/catch_the_pp/reanimate.osu @@ -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: diff --git a/pp/catch_the_pp/sample.py b/pp/catch_the_pp/sample.py new file mode 100644 index 0000000..b644b9a --- /dev/null +++ b/pp/catch_the_pp/sample.py @@ -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))) +""" diff --git a/pp/catch_the_pp/setup.py b/pp/catch_the_pp/setup.py new file mode 100644 index 0000000..d3bd341 --- /dev/null +++ b/pp/catch_the_pp/setup.py @@ -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), +) diff --git a/pp/catch_the_pp/test.osu b/pp/catch_the_pp/test.osu new file mode 100644 index 0000000..34098fb --- /dev/null +++ b/pp/catch_the_pp/test.osu @@ -0,0 +1,1219 @@ +osu file format v7 + +[General] +AudioFilename: Night of Knights.mp3 +AudioLeadIn: 0 +PreviewTime: 1598 +Countdown: 0 +SampleSet: Soft +StackLeniency: 0 +Mode: 0 +LetterboxInBreaks: 0 + +[Editor] +Bookmarks: 1585,13931,24598,35265,45931,56598,67265,77931,88598,99265,109931,120598,131265,141931,152598,163265,173931,184598,195265 +DistanceSpacing: 0.800000011920929 +BeatDivisor: 4 +GridSize: 32 + +[Metadata] +Title:Night of Knights +Artist:beatMARIO +Creator:DJPop +Version:TAG4 +Source: +Tags:COOL&CREATE Hanabata Touhou Izayoi Sakuya Hong Meiling ignorethis samiljul dksslqj + +[Difficulty] +HPDrainRate:3 +CircleSize:5 +OverallDifficulty:8 +SliderMultiplier:1.92 +SliderTickRate:4 + +[Events] +//Background and Video events +0,0,"Night of Knights 2.png" +Video,0,"Night of Knights.avi" +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Sound Samples +//Background Colour Transformations +3,100,0,0,255 + +[TimingPoints] +1585,392.156862745098,4,1,1,80,1,0 +13932,333.333333333333,4,1,1,80,1,0 +35265,-100,4,1,1,80,0,1 +45930,-100,4,1,1,80,0,0 +45931,-100,4,1,1,80,0,1 +56598,-100,4,1,1,80,0,0 +78015,-100,4,2,1,80,0,0 +97681,-200,4,2,1,80,0,0 +98181,-100,4,2,1,80,0,0 +99098,-100,4,1,1,80,0,0 +99265,-100,4,1,1,80,0,1 +109930,-100,4,1,1,80,0,0 +109931,-100,4,1,1,80,0,1 +120598,-100,4,1,1,80,0,0 +141931,-100,4,1,1,80,0,1 +152597,-100,4,1,1,80,0,0 +152598,-100,4,1,1,80,0,1 +163264,-100,4,1,1,80,0,0 +163265,-100,4,1,1,80,0,1 +173930,-100,4,1,1,80,0,0 +173931,-100,4,1,1,80,0,1 +184598,-100,4,1,1,80,0,0 + +[HitObjects] +480,32,13932,5,4 +416,64,14098,1,0 +352,32,14265,1,0 +352,32,14348,1,0 +288,64,14515,1,0 +224,32,14682,1,0 +160,64,14848,1,0 +128,32,14932,1,8 +96,64,15015,1,0 +64,32,15098,1,0 +32,64,15182,1,0 +464,128,15348,5,0 +432,160,15432,1,0 +368,128,15598,1,0 +336,160,15682,1,0 +272,128,15848,1,0 +208,160,16015,1,0 +144,128,16182,1,0 +112,160,16265,1,12 +48,128,16432,1,0 +480,224,16598,5,4 +416,256,16765,1,0 +352,224,16932,1,0 +352,224,17015,1,0 +288,256,17182,1,0 +224,224,17348,1,0 +160,256,17515,1,0 +128,224,17598,1,8 +96,256,17682,1,0 +64,224,17765,1,0 +32,256,17848,1,0 +464,320,18015,5,0 +432,352,18098,1,0 +368,320,18265,1,0 +336,352,18348,1,0 +272,320,18515,1,0 +208,352,18682,1,0 +144,320,18848,1,0 +112,352,18932,1,12 +48,320,19098,1,0 +32,64,19265,5,4 +96,32,19432,1,0 +160,64,19598,1,0 +160,64,19682,1,0 +224,32,19848,1,0 +288,64,20015,1,0 +352,32,20182,1,0 +384,64,20265,1,12 +416,32,20348,1,0 +448,64,20432,1,0 +480,32,20515,1,0 +32,160,20598,5,4 +64,128,20682,1,0 +96,160,20765,1,0 +160,128,20931,1,0 +192,160,21015,1,0 +256,128,21181,1,0 +320,160,21348,1,0 +384,128,21515,1,0 +416,160,21598,1,12 +480,128,21765,1,0 +32,256,21931,5,4 +96,224,22098,1,0 +160,256,22265,1,0 +160,256,22348,1,0 +224,224,22515,1,0 +256,256,22598,1,4 +288,224,22681,1,0 +352,256,22848,1,0 +384,224,22931,1,8 +416,256,23015,1,0 +448,224,23098,1,0 +480,256,23181,1,0 +32,352,23265,5,4 +64,320,23348,1,0 +96,352,23431,1,0 +160,320,23598,1,4 +160,320,23681,1,0 +224,352,23848,1,0 +256,320,23931,1,4 +288,352,24015,1,0 +320,320,24098,1,0 +352,352,24181,1,0 +384,320,24265,1,8 +416,352,24348,1,0 +448,320,24431,1,0 +480,352,24515,1,0 +480,32,24598,6,0,C|480:128,1,96,4|0 +416,32,24931,2,0,C|416:128,1,96 +352,32,25265,2,0,C|352:128,1,96 +288,32,25598,2,0,C|288:128,1,96,8|0 +32,32,25931,6,0,C|32:128,1,96 +96,32,26264,2,0,C|96:128,1,96 +160,32,26598,2,0,C|160:128,1,96 +224,32,26931,2,0,C|224:128,1,96,12|0 +480,352,27265,6,0,C|480:256,1,96,4|0 +416,352,27598,2,0,C|416:256,1,96 +352,352,27932,2,0,C|352:256,1,96 +288,352,28265,2,0,C|288:256,1,96,8|0 +32,352,28598,6,0,C|32:256,1,96 +96,352,28931,2,0,C|96:256,1,96 +160,352,29265,2,0,C|160:256,1,96 +224,352,29598,2,0,C|224:256,1,96,12|0 +480,32,29931,6,0,C|384:32,1,96,4|0 +480,96,30264,2,0,C|384:96,1,96 +480,352,30598,6,0,C|384:352,1,96,0|0 +480,288,30931,2,0,C|384:288,1,96,12|0 +32,32,31264,6,0,C|128:32,1,96,4|0 +32,96,31597,2,0,C|128:96,1,96 +32,352,31931,6,0,C|128:352,1,96,0|0 +32,288,32264,2,0,C|128:288,1,96,12|0 +256,128,32598,6,0,C|256:32,1,96,4|0 +320,192,32931,6,0,C|416:192,1,96 +256,256,33265,6,0,C|256:352,1,96,4|0 +192,192,33598,6,0,C|96:192,1,96,8|0 +256,32,33931,6,0,C|256:128,1,96,4|0 +416,192,34264,6,0,C|320:192,1,96,4|0 +256,352,34598,6,0,C|256:256,1,96,4|0 +96,192,34931,6,0,C|192:192,1,96,8|0 +480,352,35265,5,4 +480,288,35431,1,0 +480,224,35598,1,8 +448,224,35681,1,0 +416,224,35765,1,0 +416,160,35931,1,0 +480,160,36098,1,0 +480,96,36265,1,8 +480,72,36348,1,0 +480,48,36431,1,0 +480,24,36515,1,0 +288,352,36598,5,0 +288,288,36765,1,0 +288,224,36931,1,8 +320,224,37015,1,0 +352,224,37098,1,0 +352,192,37181,1,0 +352,160,37265,1,2 +288,160,37431,1,2 +288,96,37598,1,10 +288,32,37765,1,2 +32,352,37931,5,0 +32,288,38098,1,0 +32,224,38265,1,8 +64,224,38348,1,0 +96,224,38431,1,0 +96,160,38598,1,0 +32,160,38765,1,0 +32,96,38932,1,8 +32,72,39015,1,0 +32,48,39098,1,0 +32,24,39182,1,0 +224,352,39265,5,0 +224,288,39432,1,0 +224,224,39598,1,8 +192,224,39682,1,0 +160,224,39765,1,0 +160,192,39848,1,0 +160,160,39932,1,2 +224,160,40098,1,2 +224,96,40265,1,10 +224,32,40432,1,2 +480,48,40598,5,4 +416,48,40765,1,0 +416,112,40932,1,8 +416,144,41015,1,0 +416,176,41098,1,0 +480,176,41265,1,0 +480,240,41432,1,0 +416,240,41598,1,8 +416,272,41682,1,0 +416,304,41765,1,0 +416,336,41848,1,0 +288,48,41932,5,0 +352,48,42098,1,0 +352,112,42265,1,8 +352,144,42348,1,0 +352,176,42432,1,0 +352,208,42515,1,0 +352,240,42598,1,2 +288,240,42765,2,0,C|288:272,3,32,2|0|0|10 +288,336,43098,1,2 +32,32,43265,5,6 +96,128,43515,1,0 +96,160,43598,1,8 +96,192,43682,1,0 +96,224,43765,1,0 +32,224,43932,1,6 +96,320,44182,1,0 +96,352,44265,1,8 +32,352,44432,1,0 +224,32,44598,5,6 +224,96,44765,1,0 +192,96,44848,1,0 +160,96,44932,1,14 +160,160,45098,1,0 +192,224,45265,2,0,L|210:240|192:256|174:272|192:288|209:304|192:320|173:336|192:352,1,192,6|8 +480,32,45932,5,4 +416,64,46098,1,0 +352,32,46265,1,8 +320,48,46348,1,0 +288,64,46432,1,0 +224,32,46598,1,0 +160,64,46765,1,0 +96,32,46932,1,8 +72,48,47015,1,0 +48,64,47098,1,0 +24,48,47182,1,0 +480,128,47265,5,0 +416,160,47432,1,0 +352,128,47598,1,8 +320,144,47682,1,0 +288,160,47765,1,0 +256,144,47848,1,0 +224,128,47932,1,2 +160,160,48098,1,2 +96,128,48265,1,10 +32,160,48432,1,2 +480,224,48598,5,0 +416,256,48765,1,0 +352,224,48932,1,8 +320,240,49015,1,0 +288,256,49098,1,0 +224,224,49265,1,0 +160,256,49432,1,0 +96,224,49598,1,8 +72,240,49682,1,0 +48,256,49765,1,0 +24,240,49848,1,0 +480,320,49932,5,0 +416,352,50098,1,0 +352,320,50265,1,8 +288,352,50348,1,0 +256,336,50432,1,0 +224,320,50515,1,0 +192,336,50598,1,2 +160,352,50765,1,2 +96,320,50932,1,10 +32,352,51098,1,2 +32,64,51265,5,4 +96,32,51432,1,0 +160,64,51598,1,8 +192,48,51681,1,0 +224,32,51765,1,0 +288,64,51932,1,0 +352,32,52098,1,0 +416,64,52265,1,8 +440,48,52348,1,0 +464,32,52432,1,0 +488,48,52515,1,0 +48,160,52598,5,4 +112,128,52765,1,0 +176,160,52932,1,8 +208,144,53015,1,0 +240,128,53098,1,0 +272,144,53182,1,0 +304,160,53265,1,2 +368,128,53431,2,0,L|400:128,3,32,2|0|0|10 +464,160,53765,1,2 +32,256,53932,5,6 +152,224,54182,1,0 +184,240,54265,1,8 +216,256,54348,1,0 +248,240,54432,1,0 +320,256,54598,1,6 +384,224,54765,2,0,L|416:224,3,32,0|0|0|8 +480,256,55098,1,0 +32,352,55265,5,6 +96,320,55432,1,0 +160,352,55598,1,12 +224,320,55765,1,0 +256,336,55848,1,0 +288,352,55932,1,4 +320,336,56015,1,0 +352,320,56098,1,0 +384,336,56182,1,0 +416,352,56265,1,12 +480,320,56432,1,0 +448,64,56598,5,4 +384,96,56765,1,0 +320,128,56932,1,0 +320,128,57015,1,0 +320,128,57098,1,0 +192,64,57265,5,0 +128,96,57432,1,0 +64,128,57598,1,0 +64,128,57682,1,0 +64,128,57765,1,0 +64,128,57848,1,0 +448,256,57932,5,0 +384,288,58098,1,0 +320,320,58265,1,0 +320,320,58348,1,0 +320,320,58432,1,0 +320,320,58515,1,0 +320,320,58598,1,0 +192,256,58765,5,0 +128,288,58932,1,0 +64,320,59098,1,0 +64,64,59265,5,0 +128,96,59432,1,0 +192,128,59598,1,0 +192,128,59682,1,0 +192,128,59765,1,0 +320,64,59932,5,0 +384,96,60098,1,0 +448,128,60265,1,0 +448,128,60348,1,0 +448,128,60432,1,0 +448,128,60515,1,0 +64,256,60598,5,0 +128,288,60765,1,0 +192,320,60932,1,0 +192,320,61015,1,0 +192,320,61098,1,0 +192,320,61182,1,0 +320,256,61265,5,0 +384,288,61432,2,0,L|399:295|384:288,3,32 +448,320,61765,1,0 +32,192,61932,5,0 +96,192,62098,1,0 +128,192,62182,1,0 +160,192,62265,1,0 +480,192,62432,5,0 +416,192,62598,1,0 +384,192,62682,1,0 +352,192,62765,1,0 +256,32,62932,5,0 +256,352,63098,5,0 +480,32,63265,5,0 +416,96,63432,1,0 +32,32,63598,5,0 +96,96,63765,1,0 +32,352,63932,5,0 +96,288,64098,1,0 +480,352,64265,5,0 +416,288,64432,1,0 +256,192,64598,5,4 +256,112,64765,1,0 +256,32,64932,1,0 +256,32,65015,1,0 +256,32,65098,1,0 +256,192,65265,5,4 +176,192,65432,1,0 +96,192,65598,1,0 +96,192,65682,1,0 +96,192,65765,1,0 +96,192,65848,1,0 +256,192,65932,5,4 +256,272,66098,1,0 +256,352,66265,1,4 +256,352,66348,1,0 +256,352,66432,1,0 +256,352,66515,1,0 +256,192,66598,5,4 +336,192,66765,2,0,L|352:192|336:192,3,32 +416,192,67098,1,0 +32,32,67265,5,4 +32,32,67432,1,0 +96,96,67598,5,0 +96,96,67682,1,0 +96,96,67765,1,0 +32,160,67932,5,0 +32,160,68098,1,0 +96,224,68265,5,0 +96,224,68348,1,0 +96,224,68432,1,0 +96,224,68515,1,0 +96,224,68598,1,0 +32,288,68765,5,0 +96,352,68932,5,0 +96,352,69015,1,0 +96,352,69098,1,0 +96,352,69182,1,0 +96,352,69265,1,0 +224,352,69431,5,0 +160,288,69598,5,0 +224,224,69765,5,0 +160,160,69932,5,0 +160,160,70098,1,0 +224,96,70265,5,0 +224,96,70348,1,0 +224,96,70432,1,0 +160,32,70598,5,0 +160,32,70765,1,0 +288,32,70932,5,0 +288,32,71015,1,0 +288,32,71098,1,0 +288,32,71182,1,0 +288,32,71265,1,0 +352,96,71432,5,0 +288,160,71598,5,0 +288,160,71682,1,0 +288,160,71765,1,0 +288,160,71848,1,0 +288,160,71932,1,0 +352,224,72098,6,0,L|363:236|352:224,3,32 +288,288,72431,5,0 +352,352,72598,5,4 +480,352,72765,5,0 +480,352,72848,1,0 +480,352,72932,1,0 +416,288,73098,5,0 +480,224,73265,5,0 +480,224,73348,1,0 +480,224,73432,1,0 +416,160,73598,5,0 +480,96,73765,5,0 +416,32,73932,5,4 +96,32,74098,5,0 +96,352,74265,5,0 +416,352,74432,5,0 +352,96,74598,5,0 +160,96,74765,5,0 +160,288,74932,5,4 +352,288,75098,5,0 +256,32,75265,5,4 +256,32,75432,1,0 +96,192,75598,5,0 +96,192,75682,1,0 +96,192,75765,1,0 +256,352,75932,5,4 +256,352,76098,1,0 +416,192,76265,5,0 +416,192,76348,1,0 +416,192,76432,1,0 +416,192,76515,1,0 +416,192,76598,1,4 +256,272,76765,5,0 +336,192,76932,5,4 +336,192,77015,1,0 +336,192,77098,1,0 +336,192,77182,1,0 +336,192,77265,1,4 +256,112,77432,5,0 +176,192,77598,5,4 +256,192,77765,5,0 +32,32,77932,6,0,C|32:128,1,96,4|0 +96,32,78265,2,0,C|96:128,1,96,2|0 +160,32,78598,2,0,C|160:128,1,96,2|0 +224,32,78932,1,2 +288,32,79098,1,2 +480,32,79431,5,0 +480,32,79598,2,0,C|480:128,1,96,2|0 +416,32,79931,2,0,C|416:128,1,96,2|0 +352,32,80265,2,0,C|352:128,1,96,2|0 +480,352,80598,6,0,C|480:256,1,96,2|0 +416,352,80931,2,0,C|416:256,1,96,2|0 +352,352,81265,2,0,C|352:256,1,96,2|0 +288,352,81598,1,2 +224,352,81765,1,2 +32,352,82098,5,0 +32,352,82265,2,0,C|32:256,1,96,2|0 +96,352,82598,2,0,C|96:256,1,96,2|0 +160,352,82931,1,2 +224,352,83098,1,2 +32,32,83265,6,0,C|128:32,1,96,2|0 +32,96,83598,2,0,C|128:96,1,96,2|0 +32,160,83931,2,0,C|128:160,1,96,2|0 +192,128,84265,1,2 +192,64,84431,1,2 +480,32,84765,5,0 +480,32,84931,2,0,C|384:32,1,96,2|0 +480,96,85265,2,0,C|384:96,1,96,2|0 +480,160,85598,2,0,C|384:160,1,96,2|0 +192,352,85931,6,0,C|192:256,1,96,6|0 +256,352,86265,2,0,C|256:256,1,96,2|0 +320,352,86598,2,0,C|320:256,1,96,6|0 +288,192,86931,1,2 +224,192,87098,1,2 +32,352,87265,5,4 +32,32,87598,5,4 +480,32,87931,5,4 +480,352,88265,5,2 +256,352,88431,5,2 +192,32,88598,6,0,C|192:128,1,96,6|0 +256,32,88931,2,0,C|256:128,1,96,2|0 +320,32,89265,2,0,C|320:128,1,96,2|0 +288,192,89598,1,2 +224,192,89765,1,2 +32,352,90098,5,0 +32,352,90265,2,0,C|32:256,1,96,2|0 +96,352,90598,2,0,C|96:256,1,96,2|0 +160,352,90931,1,2 +160,256,91098,1,2 +480,352,91265,6,0,C|480:256,1,96,2|0 +416,352,91598,2,0,C|416:256,1,96,2|0 +352,352,91931,1,2 +352,352,92098,2,0,C|352:256,1,96,2|0 +416,192,92431,1,2 +32,32,92765,5,0 +32,32,92931,2,0,C|128:32,1,96,2|0 +32,96,93265,2,0,C|128:96,1,96,2|0 +32,160,93598,2,0,C|128:160,1,96,2|0 +480,32,93931,6,0,C|384:32,1,96,6|0 +480,96,94265,2,0,C|384:96,1,96,2|0 +480,160,94598,2,0,C|384:160,1,96,2|0 +480,224,94931,1,2 +480,224,95098,2,0,C|384:224,1,96,2|4 +480,352,95431,5,0 +480,352,95598,2,0,C|384:352,1,96,2|0 +480,288,95931,2,0,C|384:288,1,96,2|0 +320,352,96265,1,2 +320,288,96431,1,2 +32,352,96598,6,0,C|128:352,2,96,6|0|0 +32,288,97098,1,2 +32,288,97265,2,0,C|128:288,1,96,6|0 +32,224,97598,1,2 +32,224,97765,2,0,C|128:224,1,96,6|0 +256,32,98265,5,6 +256,96,98431,1,2 +256,160,98598,2,0,C|256:352,1,192,6|4 +480,32,99265,5,4 +448,96,99431,1,0 +416,160,99598,1,8 +416,160,99681,1,0 +416,160,99765,1,0 +448,224,99931,1,0 +480,288,100098,1,0 +448,352,100265,1,8 +448,352,100348,1,0 +448,352,100431,1,0 +448,352,100515,1,0 +448,352,100598,1,0 +320,352,100765,5,0 +352,288,100931,1,8 +320,272,101015,1,0 +288,256,101098,1,0 +320,240,101181,1,0 +352,224,101265,1,2 +320,160,101431,1,2 +288,96,101598,1,10 +320,32,101765,1,2 +32,352,101931,5,0 +64,288,102098,1,0 +96,224,102265,1,8 +96,224,102348,1,0 +96,224,102431,1,0 +64,160,102598,1,0 +32,96,102765,1,0 +64,32,102931,1,8 +64,32,103015,1,0 +64,32,103098,1,0 +64,32,103181,1,0 +64,32,103265,1,0 +192,32,103431,5,0 +160,96,103598,1,8 +192,112,103681,1,0 +224,128,103765,1,0 +192,144,103848,1,0 +160,160,103931,1,2 +192,224,104098,1,2 +224,288,104265,1,10 +192,352,104431,1,2 +480,352,104598,5,4 +416,320,104765,1,0 +384,256,104931,1,8 +384,256,105015,1,0 +384,256,105098,1,0 +416,192,105265,1,0 +480,160,105431,1,0 +480,96,105598,1,8 +448,80,105681,1,0 +416,64,105765,1,0 +448,48,105848,1,0 +480,32,105931,1,0 +320,32,106098,5,0 +288,96,106265,1,8 +304,120,106348,1,0 +320,144,106431,1,0 +304,168,106515,1,0 +288,192,106598,1,2 +320,256,106765,2,0,C|320:288,3,32,2|0|0|10 +288,352,107098,1,2 +32,32,107265,5,6 +112,112,107515,1,0 +80,128,107598,1,8 +48,144,107681,1,0 +32,176,107765,1,0 +104,208,107931,1,6 +32,296,108181,1,0 +48,328,108265,1,8 +112,352,108431,1,0 +224,352,108598,5,6 +176,304,108765,1,0 +208,288,108848,1,0 +176,272,108931,1,14 +224,224,109098,1,0 +192,160,109265,2,0,L|210:144|192:128|174:112|192:96|209:80|192:64|173:48|192:32,1,192,6|8 +464,352,109931,5,4 +432,288,110098,1,0 +400,224,110265,1,8 +400,224,110348,1,0 +400,224,110431,1,0 +464,32,110598,5,0 +432,96,110765,1,0 +400,160,110931,1,8 +400,160,111015,1,0 +400,160,111098,1,0 +400,160,111181,1,0 +368,352,111265,5,0 +336,288,111431,1,0 +304,224,111598,1,8 +304,224,111681,1,0 +304,224,111765,1,0 +304,224,111848,1,0 +304,224,111931,1,2 +368,32,112098,5,2 +336,96,112265,1,10 +304,160,112431,1,2 +48,352,112598,5,0 +80,288,112765,1,0 +112,224,112931,1,8 +112,224,113015,1,0 +112,224,113098,1,0 +48,32,113265,5,0 +80,96,113431,1,0 +112,160,113598,1,8 +112,160,113681,1,0 +112,160,113765,1,0 +112,160,113848,1,0 +144,352,113931,5,0 +176,288,114098,1,0 +208,224,114265,1,8 +208,224,114348,1,0 +208,224,114431,1,0 +208,224,114515,1,0 +208,224,114598,1,2 +144,32,114765,5,2 +176,96,114931,1,10 +208,160,115098,1,2 +448,64,115265,5,4 +384,96,115431,1,0 +320,128,115598,1,8 +320,128,115681,1,0 +320,128,115765,1,0 +448,320,115931,5,0 +384,288,116098,1,0 +320,256,116265,1,8 +320,256,116348,1,0 +320,256,116431,1,0 +320,256,116515,1,0 +64,320,116598,5,4 +128,288,116765,1,0 +192,256,116931,1,8 +192,256,117015,1,0 +192,256,117098,1,0 +192,256,117181,1,0 +64,64,117265,5,2 +128,96,117431,2,0,L|142:104|128:96,3,32,2|0|0|10 +192,128,117765,1,2 +480,192,117931,5,6 +392,192,118181,1,0 +368,192,118265,1,8 +344,192,118348,1,0 +320,192,118431,1,0 +32,192,118598,5,6 +96,192,118765,2,0,C|128:192,3,32,0|0|0|8 +192,192,119098,1,0 +256,352,119265,5,6 +256,288,119431,1,0 +256,224,119598,1,12 +256,32,119765,5,0 +256,40,119848,1,0 +256,48,119931,1,4 +256,56,120015,1,0 +256,64,120098,1,0 +256,72,120181,1,0 +256,80,120265,1,12 +256,160,120431,1,0 +32,320,120598,5,4 +96,352,120765,1,0 +160,320,120931,1,0 +160,320,121015,1,0 +224,352,121181,1,0 +288,320,121348,1,0 +352,352,121515,1,0 +384,320,121598,1,8 +416,352,121681,1,0 +448,320,121765,1,0 +480,352,121848,1,0 +48,224,122015,5,0 +80,256,122098,1,0 +144,224,122265,1,0 +176,256,122348,1,0 +240,224,122515,1,0 +304,256,122681,1,0 +368,224,122848,1,0 +400,256,122931,1,12 +464,224,123098,1,0 +32,128,123265,5,4 +96,160,123431,1,0 +160,128,123598,1,0 +160,128,123681,1,0 +224,160,123848,1,0 +288,128,124015,1,0 +352,160,124181,1,0 +384,128,124265,1,8 +416,160,124348,1,0 +448,128,124431,1,0 +480,160,124515,1,0 +48,32,124681,5,0 +80,64,124765,1,0 +144,32,124931,1,0 +176,64,125015,1,0 +240,32,125181,1,0 +304,64,125348,1,0 +368,32,125515,1,0 +400,64,125598,1,12 +464,32,125765,1,0 +480,352,125931,5,4 +416,320,126098,1,0 +352,352,126265,1,0 +352,352,126348,1,0 +288,320,126515,1,0 +224,352,126681,1,0 +160,320,126848,1,0 +128,352,126931,1,8 +96,320,127015,1,0 +64,352,127098,1,0 +32,320,127181,1,0 +464,256,127348,5,0 +432,224,127431,1,0 +368,256,127598,1,0 +336,224,127681,1,0 +272,256,127848,1,0 +208,224,128015,1,0 +144,256,128181,1,0 +112,224,128265,1,12 +48,256,128431,1,0 +480,160,128598,5,4 +416,128,128765,1,0 +352,160,128931,1,0 +352,160,129015,1,0 +288,128,129181,1,0 +224,160,129348,1,0 +160,128,129515,1,0 +128,160,129598,1,8 +96,128,129681,1,0 +64,160,129765,1,0 +32,128,129848,1,0 +480,64,130015,5,0 +448,32,130098,1,0 +384,64,130265,1,0 +352,32,130348,1,0 +288,64,130515,1,0 +256,32,130598,1,4 +224,64,130681,1,0 +192,32,130765,1,0 +160,64,130848,1,0 +128,32,130931,1,12 +96,64,131015,1,0 +64,32,131098,1,0 +32,64,131181,1,0 +32,352,131265,5,4 +96,288,131431,5,0 +160,352,131598,5,0 +160,352,131681,1,0 +224,288,131848,5,0 +288,352,132015,5,0 +352,288,132181,5,0 +352,288,132265,1,8 +352,288,132348,1,0 +352,288,132431,1,0 +352,288,132515,1,0 +416,352,132681,5,0 +416,352,132765,1,0 +480,288,132931,5,0 +480,288,133015,1,0 +480,160,133181,5,0 +416,224,133348,5,0 +352,160,133515,5,0 +352,160,133598,1,12 +288,224,133765,5,0 +224,160,133931,5,4 +160,224,134098,5,0 +96,160,134265,5,0 +96,160,134348,1,0 +32,224,134515,5,0 +32,96,134681,5,0 +96,32,134848,5,0 +96,32,134931,1,8 +96,32,135015,1,0 +96,32,135098,1,0 +96,32,135181,1,0 +160,96,135348,5,0 +160,96,135431,1,0 +224,32,135598,5,0 +224,32,135681,1,0 +288,96,135848,5,0 +352,32,136015,5,0 +416,96,136181,5,0 +416,96,136265,1,12 +480,32,136431,5,0 +480,320,136598,5,4 +352,320,136765,5,0 +224,320,136931,5,0 +224,320,137015,1,0 +96,320,137181,5,0 +32,256,137348,5,0 +160,256,137515,5,0 +160,256,137598,1,12 +160,256,137681,1,0 +160,256,137765,1,0 +160,256,137848,1,0 +160,256,137931,1,4 +160,256,138015,1,0 +160,256,138098,1,0 +288,256,138265,5,0 +288,256,138348,1,0 +416,256,138515,5,0 +480,192,138681,5,0 +352,192,138848,5,0 +352,192,138931,1,12 +224,192,139098,5,0 +96,192,139265,5,4 +32,128,139431,5,0 +160,128,139598,5,0 +160,128,139681,1,0 +288,128,139848,5,0 +288,128,139931,1,4 +288,128,140015,1,0 +416,128,140181,5,0 +416,128,140265,1,8 +416,128,140348,1,0 +416,128,140431,1,0 +416,128,140515,1,0 +480,64,140598,5,4 +480,64,140681,1,0 +480,64,140765,1,0 +352,64,140931,5,4 +352,64,141015,1,0 +224,64,141181,5,0 +224,64,141265,1,4 +224,64,141348,1,0 +96,64,141515,5,0 +96,64,141598,1,8 +96,64,141681,1,0 +96,64,141765,1,0 +96,64,141848,1,0 +16,352,141931,5,4 +64,304,142098,1,0 +112,256,142265,1,8 +112,256,142348,1,0 +112,256,142431,1,0 +160,304,142598,1,0 +208,352,142765,1,0 +256,304,142931,1,8 +256,304,143015,1,0 +256,304,143098,1,0 +256,304,143181,1,0 +256,304,143265,1,0 +304,256,143431,1,0 +352,304,143598,1,8 +352,304,143681,1,0 +352,304,143765,1,0 +352,304,143848,1,0 +352,304,143931,1,2 +400,352,144098,1,2 +448,304,144265,1,10 +496,256,144431,1,2 +496,32,144598,5,0 +448,80,144765,1,0 +400,128,144931,1,8 +400,128,145015,1,0 +400,128,145098,1,0 +352,80,145265,1,0 +304,32,145431,1,0 +256,80,145598,1,8 +256,80,145681,1,0 +256,80,145765,1,0 +256,80,145848,1,0 +256,80,145931,1,0 +208,128,146098,1,0 +160,80,146265,1,8 +160,80,146348,1,0 +160,80,146431,1,0 +160,80,146515,1,0 +160,80,146598,1,2 +112,32,146765,1,2 +64,80,146931,1,10 +16,128,147098,1,2 +480,256,147265,5,4 +448,320,147431,1,0 +384,352,147598,1,8 +384,352,147681,1,0 +384,352,147765,1,0 +320,320,147931,1,0 +288,256,148098,1,0 +256,320,148265,1,8 +256,320,148348,1,0 +256,320,148431,1,0 +256,320,148515,1,0 +256,320,148598,1,0 +192,352,148765,1,0 +128,320,148931,1,8 +128,320,149015,1,0 +128,320,149098,1,0 +128,320,149181,1,0 +128,320,149265,1,2 +96,256,149431,2,0,C|64:288,3,32,2|0|0|10 +32,352,149765,1,2 +32,160,149931,5,6 +96,64,150181,1,0 +96,64,150265,1,8 +96,64,150348,1,0 +96,64,150431,1,0 +160,64,150598,1,6 +224,160,150848,1,0 +224,160,150931,1,8 +288,160,151098,1,0 +256,96,151265,1,6 +224,32,151431,1,0 +224,32,151515,1,0 +224,32,151598,1,14 +288,32,151765,1,0 +352,64,151931,2,0,L|368:82|384:64|400:46|416:64|432:81|448:64|464:45|480:64,1,192,6|8 +16,352,152598,5,4 +80,320,152765,1,0 +144,288,152931,1,8 +176,272,153015,1,0 +208,256,153098,1,0 +272,224,153265,1,0 +336,192,153431,1,0 +400,160,153598,1,8 +432,144,153681,1,0 +464,128,153765,1,0 +496,112,153848,1,0 +480,32,153931,5,0 +416,64,154098,1,0 +352,96,154265,1,8 +320,112,154348,1,0 +288,128,154431,1,0 +256,144,154515,1,0 +224,160,154598,1,2 +160,192,154765,1,2 +96,224,154931,1,10 +32,256,155098,1,2 +496,352,155265,5,0 +432,320,155431,1,0 +368,288,155598,1,8 +336,272,155681,1,0 +304,256,155765,1,0 +240,224,155931,1,0 +176,192,156098,1,0 +112,160,156265,1,8 +80,144,156348,1,0 +48,128,156431,1,0 +16,112,156515,1,0 +32,32,156598,5,0 +96,64,156765,1,0 +160,96,156931,1,8 +192,112,157015,1,0 +224,128,157098,1,0 +256,144,157182,1,0 +288,160,157265,1,2 +352,192,157432,1,2 +416,224,157598,1,10 +480,256,157765,1,2 +32,352,157932,5,4 +96,320,158098,1,0 +128,256,158265,1,8 +128,256,158348,1,0 +128,256,158432,1,0 +96,192,158598,1,0 +32,160,158765,1,0 +32,96,158932,1,8 +64,80,159015,1,0 +96,64,159098,1,0 +64,48,159182,1,0 +32,32,159265,1,4 +224,352,159431,5,0 +224,288,159598,1,8 +208,256,159682,1,0 +192,224,159765,1,0 +208,192,159848,1,0 +224,160,159932,1,2 +176,112,160098,2,0,C|176:80,3,32,2|0|0|10 +224,32,160432,1,2 +480,32,160598,5,6 +416,128,160848,1,0 +416,144,160932,1,8 +416,160,161015,1,0 +416,176,161098,1,0 +480,208,161265,1,6 +416,304,161515,1,0 +416,320,161598,1,8 +480,352,161765,1,4 +288,32,161932,5,2 +352,64,162098,1,2 +352,96,162182,1,2 +352,128,162265,1,14 +288,160,162432,1,2 +320,224,162598,2,0,L|302:240|320:256|338:272|320:288|303:304|320:320|339:336|320:352,1,192,6|8 +32,352,163265,5,4 +32,288,163432,1,0 +32,224,163598,5,8 +32,192,163682,1,0 +32,160,163765,1,0 +32,96,163932,5,0 +32,32,164098,1,0 +160,352,164265,5,8 +160,320,164348,1,0 +160,288,164432,1,0 +160,256,164515,1,0 +160,224,164598,1,0 +96,192,164765,5,0 +160,160,164932,5,8 +160,128,165015,1,0 +160,96,165098,1,0 +160,64,165182,1,0 +160,32,165265,1,2 +224,352,165431,5,2 +224,192,165598,5,10 +224,32,165765,5,2 +480,352,165932,5,0 +480,288,166098,1,0 +480,224,166265,5,8 +480,192,166348,1,0 +480,160,166432,1,0 +480,96,166598,5,0 +480,32,166765,1,0 +352,352,166931,5,8 +352,320,167015,1,0 +352,288,167098,1,0 +352,256,167182,1,0 +352,224,167265,1,0 +416,192,167432,5,0 +352,160,167598,5,8 +352,128,167682,1,0 +352,96,167765,1,0 +352,64,167848,1,0 +352,32,167932,1,2 +288,352,168098,5,2 +288,192,168265,5,10 +288,32,168432,5,2 +32,32,168598,5,4 +32,96,168765,1,0 +32,160,168932,5,8 +32,192,169015,1,0 +32,224,169098,1,0 +32,288,169265,5,0 +32,352,169432,1,0 +160,32,169598,5,8 +160,64,169682,1,0 +160,96,169765,1,0 +160,128,169848,1,0 +160,160,169931,1,0 +224,32,170098,5,0 +160,224,170265,5,8 +160,256,170348,1,0 +160,288,170432,1,0 +160,320,170515,1,0 +160,352,170598,1,2 +96,192,170765,6,0,L|112:192|96:192,3,32,2|0|0|10 +224,352,171098,5,2 +224,192,171265,5,6 +464,32,171515,5,0 +432,32,171598,1,8 +400,32,171682,1,0 +368,32,171765,1,0 +304,32,171932,5,6 +432,96,172182,5,0 +400,96,172265,1,8 +336,96,172431,5,0 +480,160,172598,5,6 +416,160,172765,5,0 +384,160,172848,1,0 +352,160,172932,1,14 +288,160,173098,5,0 +384,224,173265,6,0,L|366:240|384:256|402:272|384:288|367:304|384:320|403:336|384:352,1,192,6|8 +16,336,173932,5,4 +16,240,174098,5,0 +16,144,174265,5,8 +16,48,174348,5,0 +48,336,174432,5,0 +48,240,174598,5,0 +48,144,174765,5,0 +48,48,174932,5,8 +80,336,175015,5,0 +80,240,175098,5,0 +80,144,175182,5,0 +80,48,175265,5,0 +112,336,175432,5,0 +112,240,175598,5,8 +112,144,175682,5,0 +112,48,175765,5,0 +144,336,175848,5,0 +144,240,175932,5,2 +144,144,176098,5,2 +144,48,176265,5,10 +176,336,176432,5,2 +176,240,176598,5,0 +176,144,176765,5,0 +176,48,176932,5,8 +208,336,177015,5,0 +208,240,177098,5,0 +208,144,177265,5,0 +208,48,177432,5,0 +240,336,177598,5,8 +240,240,177682,5,0 +240,144,177765,5,0 +240,48,177848,5,0 +272,336,177932,5,0 +272,240,178098,5,0 +272,144,178265,5,8 +272,48,178348,5,0 +304,336,178432,5,0 +304,240,178515,5,0 +304,144,178598,5,2 +304,48,178765,5,2 +336,336,178932,5,10 +336,240,179098,5,2 +336,144,179265,5,4 +336,48,179598,5,8 +368,336,179765,5,0 +368,240,179848,5,0 +368,144,179932,5,0 +368,48,180015,5,0 +400,336,180098,5,2 +400,240,180265,5,10 +400,144,180432,5,2 +400,48,180598,5,4 +432,336,180765,5,0 +432,240,180848,5,0 +432,144,180932,5,10 +432,48,181098,5,0 +464,336,181182,5,0 +464,240,181265,5,0 +464,144,181348,5,0 +464,48,181432,5,0 +496,336,181515,5,0 +496,240,181598,5,10 +496,144,181765,5,2 +496,48,181932,5,4 +32,48,182182,5,0 +32,144,182265,5,8 +32,240,182348,5,0 +32,336,182432,5,0 +32,48,182598,5,4 +32,144,182765,5,0 +32,240,182848,5,0 +32,336,182932,5,10 +32,48,183098,5,0 +256,128,183265,6,0,C|256:32,1,96,4|0 +320,192,183598,6,0,C|416:192,1,96,12|0 +256,256,183932,6,0,C|256:352,1,96,4|0 +192,192,184265,6,0,C|96:192,1,96,12|0 +256,192,184598,5,4 \ No newline at end of file diff --git a/pp/cicciobello.py b/pp/cicciobello.py new file mode 100644 index 0000000..57384e8 --- /dev/null +++ b/pp/cicciobello.py @@ -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)) \ No newline at end of file diff --git a/pp/cmyui-testing/CAy.rar b/pp/cmyui-testing/CAy.rar new file mode 100644 index 0000000..f6e5dea Binary files /dev/null and b/pp/cmyui-testing/CAy.rar differ diff --git a/pp/cmyui-testing/TiS.zip b/pp/cmyui-testing/TiS.zip new file mode 100644 index 0000000..4b45212 Binary files /dev/null and b/pp/cmyui-testing/TiS.zip differ diff --git a/pp/oppai-ng/.gitignore b/pp/oppai-ng/.gitignore new file mode 100644 index 0000000..b5b3634 --- /dev/null +++ b/pp/oppai-ng/.gitignore @@ -0,0 +1,11 @@ +tags +*.log +*.tar.xz +*.zip +/oppai +*.json +*.obj +*.exe +*.swp +/test/test_suite +/test/oppai_test diff --git a/pp/oppai-ng/.travis.yml b/pp/oppai-ng/.travis.yml new file mode 100644 index 0000000..2fac769 --- /dev/null +++ b/pp/oppai-ng/.travis.yml @@ -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 diff --git a/pp/oppai-ng/README.md b/pp/oppai-ng/README.md new file mode 100644 index 0000000..621b175 --- /dev/null +++ b/pp/oppai-ng/README.md @@ -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 + diff --git a/pp/oppai-ng/UNLICENSE b/pp/oppai-ng/UNLICENSE new file mode 100644 index 0000000..68a49da --- /dev/null +++ b/pp/oppai-ng/UNLICENSE @@ -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 diff --git a/pp/oppai-ng/build.bat b/pp/oppai-ng/build.bat new file mode 100644 index 0000000..460838f --- /dev/null +++ b/pp/oppai-ng/build.bat @@ -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 diff --git a/pp/oppai-ng/build_containers b/pp/oppai-ng/build_containers new file mode 100644 index 0000000..b34c3b2 --- /dev/null +++ b/pp/oppai-ng/build_containers @@ -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 diff --git a/pp/oppai-ng/build_containers.ps1 b/pp/oppai-ng/build_containers.ps1 new file mode 100644 index 0000000..4023bc1 --- /dev/null +++ b/pp/oppai-ng/build_containers.ps1 @@ -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 \ No newline at end of file diff --git a/pp/oppai-ng/cflags b/pp/oppai-ng/cflags new file mode 100644 index 0000000..b962c91 --- /dev/null +++ b/pp/oppai-ng/cflags @@ -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" + diff --git a/pp/oppai-ng/docker/musl-x86/Dockerfile b/pp/oppai-ng/docker/musl-x86/Dockerfile new file mode 100644 index 0000000..984e165 --- /dev/null +++ b/pp/oppai-ng/docker/musl-x86/Dockerfile @@ -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" ] diff --git a/pp/oppai-ng/docker/musl-x86_64/Dockerfile b/pp/oppai-ng/docker/musl-x86_64/Dockerfile new file mode 100644 index 0000000..1ba4cf1 --- /dev/null +++ b/pp/oppai-ng/docker/musl-x86_64/Dockerfile @@ -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" ] diff --git a/pp/oppai-ng/docker/windows/Dockerfile b/pp/oppai-ng/docker/windows/Dockerfile new file mode 100644 index 0000000..eb67773 --- /dev/null +++ b/pp/oppai-ng/docker/windows/Dockerfile @@ -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 \ No newline at end of file diff --git a/pp/oppai-ng/package b/pp/oppai-ng/package new file mode 100644 index 0000000..8f09d14 --- /dev/null +++ b/pp/oppai-ng/package @@ -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 diff --git a/pp/oppai-ng/package.ps1 b/pp/oppai-ng/package.ps1 new file mode 100644 index 0000000..01a271b --- /dev/null +++ b/pp/oppai-ng/package.ps1 @@ -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 \ No newline at end of file diff --git a/pp/oppai-ng/release b/pp/oppai-ng/release new file mode 100644 index 0000000..c70a4f7 --- /dev/null +++ b/pp/oppai-ng/release @@ -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 + diff --git a/pp/oppai-ng/release.ps1 b/pp/oppai-ng/release.ps1 new file mode 100644 index 0000000..68364cd --- /dev/null +++ b/pp/oppai-ng/release.ps1 @@ -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 diff --git a/pp/oppai-ng/test/download_suite b/pp/oppai-ng/test/download_suite new file mode 100644 index 0000000..5489cf8 --- /dev/null +++ b/pp/oppai-ng/test/download_suite @@ -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 diff --git a/pp/oppai-ng/test/gentest.py b/pp/oppai-ng/test/gentest.py new file mode 100644 index 0000000..c728433 --- /dev/null +++ b/pp/oppai-ng/test/gentest.py @@ -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)) + diff --git a/pp/oppai-ng/vcvarsall17.ps1 b/pp/oppai-ng/vcvarsall17.ps1 new file mode 100644 index 0000000..eb1f20d --- /dev/null +++ b/pp/oppai-ng/vcvarsall17.ps1 @@ -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 \ No newline at end of file diff --git a/pp/rippoppai.py b/pp/rippoppai.py new file mode 100644 index 0000000..120c46c --- /dev/null +++ b/pp/rippoppai.py @@ -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)) diff --git a/pp/rxoppai.py b/pp/rxoppai.py new file mode 100644 index 0000000..10150b1 --- /dev/null +++ b/pp/rxoppai.py @@ -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)) diff --git a/pp/rxoppai/b3.py b/pp/rxoppai/b3.py new file mode 100644 index 0000000..29bc1de --- /dev/null +++ b/pp/rxoppai/b3.py @@ -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) \ No newline at end of file diff --git a/pp/rxoppai/oppai b/pp/rxoppai/oppai new file mode 100644 index 0000000..fa5630e Binary files /dev/null and b/pp/rxoppai/oppai differ diff --git a/pp/rxoppai/oppai-old b/pp/rxoppai/oppai-old new file mode 100644 index 0000000..a5522ed Binary files /dev/null and b/pp/rxoppai/oppai-old differ diff --git a/pp/rxoppai/yes.osu b/pp/rxoppai/yes.osu new file mode 100644 index 0000000..4dab3dc --- /dev/null +++ b/pp/rxoppai/yes.osu @@ -0,0 +1,1179 @@ +osu file format v14 + +[General] +AudioFilename: audio.mp3 +AudioLeadIn: 0 +PreviewTime: -1 +Countdown: 1 +SampleSet: Normal +StackLeniency: 0.3 +Mode: 0 +LetterboxInBreaks: 0 +WidescreenStoryboard: 0 + +[Editor] +Bookmarks: 12098,13298,14498,22898,24098,25898,26948,28298,29498,32498,32798,33098,33398,33698,34298,36548,37898,38198,38498,40898,42098,42548,43298,44648,45098,45698,47198,47648,48098,49598,49898,50498,51998,52298,60098,69698,70898,72098,73448,74498,75998,76298,76898,78398,79598,79898,80198,80498,80798,84098,85898,86198,86498,87398,88448,88898,91298,92498,93698,94898,95348,96698,99998 +DistanceSpacing: 1 +BeatDivisor: 4 +GridSize: 8 +TimelineZoom: 1.1 + +[Metadata] +Title:Snow Goose +TitleUnicode:Snow Goose +Artist:Mutsuhiko Izumi +ArtistUnicode:Mutsuhiko Izumi +Creator:InnerSuffering +Version:Heavenly Hard +Source: +Tags: +BeatmapID:1856504 +BeatmapSetID:888152 + +[Difficulty] +HPDrainRate:6.5 +CircleSize:4.4 +OverallDifficulty:10 +ApproachRate:9.7 +SliderMultiplier:1.4 +SliderTickRate:1 + +[Events] +//Background and Video events +0,0,"ice-4320x2160-frost-blue-purple-neon-oneplus-5t-stock-4k-11234.jpg",0,0 +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Sound Samples + +[TimingPoints] +2498,300,4,2,1,50,1,0 +6023,-100,4,2,1,26,0,0 +7298,-100,4,1,1,26,0,0 +24098,-100,4,1,1,49,0,0 +33698,-100,4,1,1,49,0,0 +43298,-100,4,1,1,49,0,0 +52898,-200,4,1,1,70,0,1 +53198,-100,4,1,1,70,0,1 +54098,-200,4,1,1,70,0,1 +54398,-100,4,1,1,70,0,1 +55298,-200,4,1,1,70,0,1 +55598,-100,4,1,1,70,0,1 +57698,-200,4,1,1,70,0,1 +57998,-100,4,1,1,70,0,1 +58898,-200,4,1,1,70,0,1 +59198,-100,4,1,1,70,0,1 +62498,-200,4,1,1,70,0,1 +62798,-100,4,1,1,70,0,1 +63698,-200,4,1,1,70,0,1 +63998,-100,4,1,1,60,0,1 +64898,-200,4,1,1,70,0,1 +65198,-100,4,1,1,60,0,1 +67298,-200,4,1,1,70,0,1 +67598,-100,4,1,1,60,0,1 +68498,-200,4,1,1,70,0,1 +68798,-100,4,1,1,60,0,1 +72098,-100,4,1,1,60,0,0 +79298,-100,4,1,1,60,0,1 +81698,-100,4,1,1,60,0,0 +90098,-100,4,1,1,64,0,1 +96098,-100,4,1,1,25,0,0 + + +[Colours] +Combo1 : 40,148,255 +Combo2 : 20,96,222 +Combo3 : 184,225,237 +Combo4 : 87,197,255 + +[HitObjects] +453,86,2498,5,0,0:0:0:0: +430,101,2573,1,0,0:0:0:0: +403,109,2648,1,0,0:0:0:0: +375,109,2723,1,0,0:0:0:0: +349,102,2798,1,0,0:0:0:0: +319,91,2873,1,0,0:0:0:0: +287,90,2948,1,0,0:0:0:0: +258,98,3023,1,0,0:0:0:0: +233,113,3098,5,0,0:0:0:0: +205,149,3173,1,0,0:0:0:0: +196,193,3248,1,0,0:0:0:0: +207,237,3323,1,0,0:0:0:0: +236,271,3398,1,0,0:0:0:0: +277,290,3473,1,0,0:0:0:0: +317,289,3548,1,0,0:0:0:0: +352,271,3623,1,0,0:0:0:0: +369,250,3698,5,0,0:0:0:0: +360,212,3773,1,0,0:0:0:0: +338,181,3848,1,0,0:0:0:0: +307,159,3923,1,0,0:0:0:0: +269,150,3998,1,0,0:0:0:0: +223,156,4073,1,0,0:0:0:0: +183,179,4148,1,0,0:0:0:0: +146,206,4223,1,0,0:0:0:0: +100,216,4298,5,0,0:0:0:0: +53,204,4373,1,0,0:0:0:0: +19,175,4448,1,0,0:0:0:0: +0,132,4523,1,0,0:0:0:0: +4,93,4598,1,0,0:0:0:0: +22,60,4673,1,0,0:0:0:0: +52,34,4748,1,0,0:0:0:0: +91,22,4823,1,0,0:0:0:0: +133,26,4898,5,0,0:0:0:0: +173,47,4973,1,0,0:0:0:0: +201,83,5048,1,0,0:0:0:0: +214,128,5123,1,0,0:0:0:0: +209,175,5198,1,0,0:0:0:0: +193,228,5273,1,0,0:0:0:0: +205,279,5348,1,0,0:0:0:0: +239,322,5423,1,0,0:0:0:0: +280,339,5498,5,0,0:0:0:0: +324,339,5573,1,0,0:0:0:0: +367,322,5648,1,0,0:0:0:0: +401,288,5723,1,0,0:0:0:0: +419,252,5798,1,0,0:0:0:0: +416,213,5873,1,0,0:0:0:0: +399,176,5948,1,0,0:0:0:0: +369,149,6023,1,0,0:0:0:0: +332,128,6098,5,0,0:0:0:0: +290,174,6173,1,0,0:0:0:0: +280,235,6248,1,0,0:0:0:0: +306,292,6323,1,0,0:0:0:0: +358,326,6398,1,0,0:0:0:0: +423,326,6473,1,0,0:0:0:0: +478,290,6548,1,0,0:0:0:0: +504,225,6623,1,0,0:0:0:0: +489,155,6698,1,0,0:0:0:0: +434,105,6773,1,0,0:0:0:0: +359,96,6848,1,0,0:0:0:0: +294,130,6923,1,0,0:0:0:0: +261,195,6998,1,0,0:0:0:0: +216,251,7073,1,0,0:0:0:0: +153,282,7148,1,0,0:0:0:0: +85,274,7223,1,0,0:0:0:0: +37,231,7298,5,0,0:0:0:0: +84,172,7373,1,0,0:0:0:0: +156,153,7448,1,0,0:0:0:0: +227,179,7523,1,0,0:0:0:0: +272,238,7598,1,0,0:0:0:0: +330,287,7673,1,0,0:0:0:0: +408,298,7748,1,0,0:0:0:0: +479,253,7823,1,0,0:0:0:0: +512,179,7898,5,0,0:0:0:0: +501,97,7973,1,0,0:0:0:0: +449,33,8048,1,0,0:0:0:0: +375,6,8123,1,0,0:0:0:0: +302,21,8198,1,0,0:0:0:0: +247,71,8273,1,0,0:0:0:0: +228,140,8348,1,0,0:0:0:0: +247,208,8423,1,0,0:0:0:0: +296,257,8498,5,0,0:0:0:0: +350,228,8573,1,0,0:0:0:0: +376,172,8648,1,0,0:0:0:0: +363,111,8723,1,0,0:0:0:0: +316,71,8798,1,0,0:0:0:0: +254,68,8873,1,0,0:0:0:0: +203,102,8948,1,0,0:0:0:0: +183,161,9023,1,0,0:0:0:0: +203,220,9098,2,0,L|242:260,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +301,278,9398,5,0,0:0:0:0: +355,269,9473,1,0,0:0:0:0: +394,229,9548,1,0,0:0:0:0: +401,174,9623,1,0,0:0:0:0: +374,126,9698,5,0,0:0:0:0: +319,158,9773,1,0,0:0:0:0: +297,220,9848,1,0,0:0:0:0: +318,283,9923,1,0,0:0:0:0: +371,319,9998,1,0,0:0:0:0: +435,315,10073,1,0,0:0:0:0: +486,273,10148,1,0,0:0:0:0: +499,212,10223,1,0,0:0:0:0: +490,151,10298,2,0,L|466:102,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +409,71,10598,5,0,0:0:0:0: +348,89,10673,1,0,0:0:0:0: +302,133,10748,1,0,0:0:0:0: +286,194,10823,1,0,0:0:0:0: +284,259,10898,5,0,0:0:0:0: +345,246,10973,1,0,0:0:0:0: +386,196,11048,1,0,0:0:0:0: +389,130,11123,1,0,0:0:0:0: +352,78,11198,1,0,0:0:0:0: +289,59,11273,1,0,0:0:0:0: +228,81,11348,1,0,0:0:0:0: +194,135,11423,1,0,0:0:0:0: +180,198,11498,2,0,L|195:242,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +232,293,11798,5,0,0:0:0:0: +286,276,11873,1,0,0:0:0:0: +328,237,11948,1,0,0:0:0:0: +342,182,12023,1,0,0:0:0:0: +337,126,12098,5,0,0:0:0:0: +275,149,12173,1,0,0:0:0:0: +239,204,12248,1,0,0:0:0:0: +238,270,12323,1,0,0:0:0:0: +273,324,12398,1,0,0:0:0:0: +334,354,12473,1,0,0:0:0:0: +403,343,12548,1,0,0:0:0:0: +456,293,12623,1,0,0:0:0:0: +473,221,12698,1,0,0:0:0:0: +443,148,12773,1,0,0:0:0:0: +375,106,12848,1,0,0:0:0:0: +299,110,12923,1,0,0:0:0:0: +239,157,12998,1,0,0:0:0:0: +171,191,13073,1,0,0:0:0:0: +97,191,13148,1,0,0:0:0:0: +36,154,13223,1,0,0:0:0:0: +10,88,13298,5,0,0:0:0:0: +76,51,13373,1,0,0:0:0:0: +150,61,13448,1,0,0:0:0:0: +206,115,13523,1,0,0:0:0:0: +226,190,13598,1,0,0:0:0:0: +283,244,13673,1,0,0:0:0:0: +362,264,13748,1,0,0:0:0:0: +434,232,13823,1,0,0:0:0:0: +475,163,13898,1,0,0:0:0:0: +464,79,13973,1,0,0:0:0:0: +404,16,14048,1,0,0:0:0:0: +320,3,14123,1,0,0:0:0:0: +241,42,14198,1,0,0:0:0:0: +205,117,14273,1,0,0:0:0:0: +218,198,14348,1,0,0:0:0:0: +272,254,14423,1,0,0:0:0:0: +345,266,14498,6,0,L|398:256,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +451,242,14948,5,0,0:0:0:0: +439,196,15023,1,0,0:0:0:0: +414,158,15098,1,0,0:0:0:0: +377,131,15173,1,0,0:0:0:0: +333,121,15248,1,0,0:0:0:0: +287,125,15323,1,0,0:0:0:0: +248,144,15398,1,0,0:0:0:0: +211,169,15473,1,0,0:0:0:0: +165,177,15548,1,0,0:0:0:0: +121,171,15623,1,0,0:0:0:0: +81,147,15698,6,0,L|38:111,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +3,72,16148,5,0,0:0:0:0: +47,50,16223,1,0,0:0:0:0: +95,46,16298,1,0,0:0:0:0: +142,57,16373,1,0,0:0:0:0: +180,87,16448,1,0,0:0:0:0: +207,128,16523,1,0,0:0:0:0: +218,174,16598,1,0,0:0:0:0: +223,221,16673,1,0,0:0:0:0: +246,264,16748,1,0,0:0:0:0: +281,297,16823,1,0,0:0:0:0: +328,315,16898,6,0,L|384:325,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +436,305,17348,5,0,0:0:0:0: +418,263,17423,1,0,0:0:0:0: +384,235,17498,1,0,0:0:0:0: +339,227,17573,1,0,0:0:0:0: +296,242,17648,1,0,0:0:0:0: +267,276,17723,1,0,0:0:0:0: +238,309,17798,1,0,0:0:0:0: +195,323,17873,1,0,0:0:0:0: +150,316,17948,1,0,0:0:0:0: +115,288,18023,1,0,0:0:0:0: +99,247,18098,6,0,L|80:191,7,35,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:0: +58,187,18698,5,0,0:0:0:0: +98,161,18773,1,0,0:0:0:0: +147,155,18848,1,0,0:0:0:0: +192,172,18923,5,0,0:0:0:0: +233,189,18998,1,0,0:0:0:0: +277,185,19073,1,0,0:0:0:0: +317,160,19148,5,0,0:0:0:0: +350,138,19223,1,0,0:0:0:0: +391,133,19298,6,0,L|437:137,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +500,158,19748,5,0,0:0:0:0: +490,202,19823,1,0,0:0:0:0: +464,239,19898,1,0,0:0:0:0: +426,262,19973,1,0,0:0:0:0: +381,268,20048,1,0,0:0:0:0: +339,254,20123,1,0,0:0:0:0: +305,225,20198,1,0,0:0:0:0: +286,183,20273,1,0,0:0:0:0: +269,143,20348,1,0,0:0:0:0: +235,112,20423,1,0,0:0:0:0: +191,99,20498,6,0,L|142:99,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +81,115,20948,5,0,0:0:0:0: +100,158,21023,1,0,0:0:0:0: +137,189,21098,1,0,0:0:0:0: +183,203,21173,1,0,0:0:0:0: +232,196,21248,1,0,0:0:0:0: +272,169,21323,1,0,0:0:0:0: +309,137,21398,1,0,0:0:0:0: +353,122,21473,1,0,0:0:0:0: +401,127,21548,1,0,0:0:0:0: +443,152,21623,1,0,0:0:0:0: +471,192,21698,6,0,L|483:238,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +472,301,22148,5,0,0:0:0:0: +437,330,22223,1,0,0:0:0:0: +394,344,22298,1,0,0:0:0:0: +349,339,22373,1,0,0:0:0:0: +310,315,22448,1,0,0:0:0:0: +284,279,22523,1,0,0:0:0:0: +255,246,22598,1,0,0:0:0:0: +213,228,22673,1,0,0:0:0:0: +167,227,22748,1,0,0:0:0:0: +126,246,22823,1,0,0:0:0:0: +97,278,22898,5,0,0:0:0:0: +137,307,22973,1,0,0:0:0:0: +189,311,23048,1,0,0:0:0:0: +235,286,23123,1,0,0:0:0:0: +258,239,23198,1,0,0:0:0:0: +255,186,23273,1,0,0:0:0:0: +235,136,23348,1,0,0:0:0:0: +246,80,23423,1,0,0:0:0:0: +283,39,23498,1,0,0:0:0:0: +341,23,23573,1,0,0:0:0:0: +398,45,23648,1,0,0:0:0:0: +438,93,23723,1,0,0:0:0:0: +446,160,23798,1,0,0:0:0:0: +415,228,23873,1,0,0:0:0:0: +348,271,23948,1,0,0:0:0:0: +262,273,24023,1,0,0:0:0:0: +190,227,24098,5,0,0:0:0:0: +227,200,24173,1,0,0:0:0:0: +272,192,24248,1,0,0:0:0:0: +315,201,24323,1,0,0:0:0:0: +357,220,24398,1,0,0:0:0:0: +402,220,24473,1,0,0:0:0:0: +443,201,24548,1,0,0:0:0:0: +472,167,24623,1,0,0:0:0:0: +485,123,24698,5,0,0:0:0:0: +475,72,24773,1,0,0:0:0:0: +447,29,24848,1,0,0:0:0:0: +403,6,24923,1,0,0:0:0:0: +353,1,24998,1,0,0:0:0:0: +304,17,25073,1,0,0:0:0:0: +265,51,25148,1,0,0:0:0:0: +244,95,25223,1,0,0:0:0:0: +244,143,25298,5,0,0:0:0:0: +262,186,25373,1,0,0:0:0:0: +296,217,25448,1,0,0:0:0:0: +339,232,25523,1,0,0:0:0:0: +385,229,25598,5,0,0:0:0:0: +423,207,25673,1,0,0:0:0:0: +450,173,25748,1,0,0:0:0:0: +460,130,25823,1,0,0:0:0:0: +453,87,25898,5,0,0:0:0:0: +430,53,25973,1,0,0:0:0:0: +396,34,26048,1,0,0:0:0:0: +358,27,26123,1,0,0:0:0:0: +320,33,26198,5,0,0:0:0:0: +281,52,26273,1,0,0:0:0:0: +251,84,26348,1,0,0:0:0:0: +232,126,26423,1,0,0:0:0:0: +200,162,26498,5,0,0:0:0:0: +149,174,26573,1,0,0:0:0:0: +100,154,26648,1,0,0:0:0:0: +72,110,26723,1,0,0:0:0:0: +75,58,26798,1,0,0:0:0:0: +107,17,26873,1,0,0:0:0:0: +156,2,26948,5,0,0:0:0:0: +202,17,27023,1,0,0:0:0:0: +231,56,27098,1,0,0:0:0:0: +233,105,27173,1,0,0:0:0:0: +207,147,27248,1,0,0:0:0:0: +187,191,27323,1,0,0:0:0:0: +197,239,27398,5,0,0:0:0:0: +225,268,27473,1,0,0:0:0:0: +271,279,27548,1,0,0:0:0:0: +312,260,27623,1,0,0:0:0:0: +336,221,27698,5,0,0:0:0:0: +294,183,27773,1,0,0:0:0:0: +240,176,27848,1,0,0:0:0:0: +190,200,27923,1,0,0:0:0:0: +144,231,27998,1,0,0:0:0:0: +89,227,28073,1,0,0:0:0:0: +45,194,28148,1,0,0:0:0:0: +29,141,28223,1,0,0:0:0:0: +48,86,28298,5,0,0:0:0:0: +98,54,28373,1,0,0:0:0:0: +155,56,28448,1,0,0:0:0:0: +199,92,28523,1,0,0:0:0:0: +218,147,28598,1,0,0:0:0:0: +237,202,28673,1,0,0:0:0:0: +281,238,28748,1,0,0:0:0:0: +338,240,28823,1,0,0:0:0:0: +388,208,28898,5,0,0:0:0:0: +360,171,28973,1,0,0:0:0:0: +321,150,29048,1,0,0:0:0:0: +276,148,29123,1,0,0:0:0:0: +234,165,29198,1,0,0:0:0:0: +203,198,29273,1,0,0:0:0:0: +189,240,29348,1,0,0:0:0:0: +195,289,29423,1,0,0:0:0:0: +220,330,29498,5,0,0:0:0:0: +257,354,29573,1,0,0:0:0:0: +301,359,29648,1,0,0:0:0:0: +343,346,29723,1,0,0:0:0:0: +377,317,29798,1,0,0:0:0:0: +394,268,29873,1,0,0:0:0:0: +386,216,29948,1,0,0:0:0:0: +354,175,30023,1,0,0:0:0:0: +307,154,30098,5,0,0:0:0:0: +269,124,30173,1,0,0:0:0:0: +262,76,30248,1,0,0:0:0:0: +289,37,30323,1,0,0:0:0:0: +336,27,30398,1,0,0:0:0:0: +377,53,30473,1,0,0:0:0:0: +398,98,30548,5,0,0:0:0:0: +400,150,30623,1,0,0:0:0:0: +385,200,30698,1,0,0:0:0:0: +354,242,30773,1,0,0:0:0:0: +309,272,30848,1,0,0:0:0:0: +259,284,30923,1,0,0:0:0:0: +206,279,30998,5,0,0:0:0:0: +153,253,31073,1,0,0:0:0:0: +113,212,31148,1,0,0:0:0:0: +88,160,31223,1,0,0:0:0:0: +77,104,31298,5,0,0:0:0:0: +139,86,31373,1,0,0:0:0:0: +201,102,31448,1,0,0:0:0:0: +248,147,31523,1,0,0:0:0:0: +267,212,31598,5,0,0:0:0:0: +286,274,31673,1,0,0:0:0:0: +334,321,31748,1,0,0:0:0:0: +397,336,31823,1,0,0:0:0:0: +457,310,31898,1,0,0:0:0:0: +488,253,31973,1,0,0:0:0:0: +479,189,32048,1,0,0:0:0:0: +432,143,32123,1,0,0:0:0:0: +367,136,32198,5,0,0:0:0:0: +311,172,32273,1,0,0:0:0:0: +244,204,32348,1,0,0:0:0:0: +175,191,32423,1,0,0:0:0:0: +129,139,32498,5,0,0:0:0:0: +124,128,32573,1,0,0:0:0:0: +119,117,32648,1,0,0:0:0:0: +114,106,32723,1,0,0:0:0:0: +109,95,32798,5,0,0:0:0:0: +166,63,32873,1,0,0:0:0:0: +232,63,32948,1,0,0:0:0:0: +288,97,33023,1,0,0:0:0:0: +320,155,33098,5,0,0:0:0:0: +318,225,33173,1,0,0:0:0:0: +282,286,33248,1,0,0:0:0:0: +220,321,33323,1,0,0:0:0:0: +149,320,33398,1,0,0:0:0:0: +86,282,33473,1,0,0:0:0:0: +52,219,33548,1,0,0:0:0:0: +54,146,33623,1,0,0:0:0:0: +91,84,33698,5,0,0:0:0:0: +126,112,33773,1,0,0:0:0:0: +145,153,33848,1,0,0:0:0:0: +148,197,33923,1,0,0:0:0:0: +140,242,33998,1,0,0:0:0:0: +151,286,34073,1,0,0:0:0:0: +181,321,34148,1,0,0:0:0:0: +221,340,34223,1,0,0:0:0:0: +266,341,34298,5,0,0:0:0:0: +313,318,34373,1,0,0:0:0:0: +348,280,34448,1,0,0:0:0:0: +359,231,34523,1,0,0:0:0:0: +350,182,34598,1,0,0:0:0:0: +322,139,34673,1,0,0:0:0:0: +277,104,34748,1,0,0:0:0:0: +224,87,34823,1,0,0:0:0:0: +172,98,34898,5,0,0:0:0:0: +184,158,34973,1,0,0:0:0:0: +227,202,35048,1,0,0:0:0:0: +288,215,35123,1,0,0:0:0:0: +346,192,35198,1,0,0:0:0:0: +407,182,35273,1,0,0:0:0:0: +461,212,35348,1,0,0:0:0:0: +485,269,35423,1,0,0:0:0:0: +470,329,35498,5,0,0:0:0:0: +421,367,35573,1,0,0:0:0:0: +359,367,35648,1,0,0:0:0:0: +310,330,35723,1,0,0:0:0:0: +294,270,35798,5,0,0:0:0:0: +313,208,35873,1,0,0:0:0:0: +348,150,35948,1,0,0:0:0:0: +337,84,36023,1,0,0:0:0:0: +296,32,36098,5,0,0:0:0:0: +225,24,36173,1,0,0:0:0:0: +169,68,36248,1,0,0:0:0:0: +158,138,36323,1,0,0:0:0:0: +199,196,36398,1,0,0:0:0:0: +269,209,36473,1,0,0:0:0:0: +342,209,36548,1,0,0:0:0:0: +407,237,36623,1,0,0:0:0:0: +431,306,36698,5,0,0:0:0:0: +395,369,36773,1,0,0:0:0:0: +323,384,36848,1,0,0:0:0:0: +264,341,36923,1,0,0:0:0:0: +244,271,36998,1,0,0:0:0:0: +197,209,37073,1,0,0:0:0:0: +121,195,37148,1,0,0:0:0:0: +52,234,37223,1,0,0:0:0:0: +26,308,37298,5,0,0:0:0:0: +96,325,37373,1,0,0:0:0:0: +165,301,37448,1,0,0:0:0:0: +210,244,37523,1,0,0:0:0:0: +217,172,37598,1,0,0:0:0:0: +261,116,37673,1,0,0:0:0:0: +332,104,37748,1,0,0:0:0:0: +393,142,37823,1,0,0:0:0:0: +412,211,37898,5,0,0:0:0:0: +381,275,37973,1,0,0:0:0:0: +314,302,38048,1,0,0:0:0:0: +247,277,38123,1,0,0:0:0:0: +209,217,38198,5,0,0:0:0:0: +198,201,38273,1,0,0:0:0:0: +190,190,38348,1,0,0:0:0:0: +180,176,38423,1,0,0:0:0:0: +169,160,38498,5,0,0:0:0:0: +215,99,38573,1,0,0:0:0:0: +286,70,38648,1,0,0:0:0:0: +361,79,38723,1,0,0:0:0:0: +422,124,38798,1,0,0:0:0:0: +454,193,38873,1,0,0:0:0:0: +447,269,38948,1,0,0:0:0:0: +404,332,39023,1,0,0:0:0:0: +335,365,39098,5,0,0:0:0:0: +259,360,39173,1,0,0:0:0:0: +195,320,39248,1,0,0:0:0:0: +183,246,39323,1,0,0:0:0:0: +229,188,39398,1,0,0:0:0:0: +304,183,39473,1,0,0:0:0:0: +357,236,39548,1,0,0:0:0:0: +426,258,39623,1,0,0:0:0:0: +491,221,39698,5,0,0:0:0:0: +506,148,39773,1,0,0:0:0:0: +461,89,39848,1,0,0:0:0:0: +388,81,39923,1,0,0:0:0:0: +335,133,39998,1,0,0:0:0:0: +302,196,40073,1,0,0:0:0:0: +244,245,40148,1,0,0:0:0:0: +167,244,40223,1,0,0:0:0:0: +118,184,40298,5,0,0:0:0:0: +129,106,40373,1,0,0:0:0:0: +195,65,40448,1,0,0:0:0:0: +270,88,40523,1,0,0:0:0:0: +301,160,40598,1,0,0:0:0:0: +268,230,40673,1,0,0:0:0:0: +193,251,40748,1,0,0:0:0:0: +127,209,40823,1,0,0:0:0:0: +95,142,40898,5,0,0:0:0:0: +142,92,40973,1,0,0:0:0:0: +210,80,41048,1,0,0:0:0:0: +272,110,41123,1,0,0:0:0:0: +304,171,41198,1,0,0:0:0:0: +301,244,41273,1,0,0:0:0:0: +316,310,41348,1,0,0:0:0:0: +369,353,41423,1,0,0:0:0:0: +438,354,41498,5,0,0:0:0:0: +492,311,41573,1,0,0:0:0:0: +507,244,41648,1,0,0:0:0:0: +476,182,41723,1,0,0:0:0:0: +414,153,41798,1,0,0:0:0:0: +347,167,41873,1,0,0:0:0:0: +304,220,41948,1,0,0:0:0:0: +302,289,42023,1,0,0:0:0:0: +343,344,42098,5,0,0:0:0:0: +391,285,42173,1,0,0:0:0:0: +392,209,42248,1,0,0:0:0:0: +349,149,42323,1,0,0:0:0:0: +276,124,42398,1,0,0:0:0:0: +205,152,42473,1,0,0:0:0:0: +131,158,42548,1,0,0:0:0:0: +83,104,42623,1,0,0:0:0:0: +102,39,42698,5,0,0:0:0:0: +171,21,42773,1,0,0:0:0:0: +220,70,42848,1,0,0:0:0:0: +238,142,42923,1,0,0:0:0:0: +282,195,42998,1,0,0:0:0:0: +349,209,43073,1,0,0:0:0:0: +411,180,43148,1,0,0:0:0:0: +441,118,43223,1,0,0:0:0:0: +426,51,43298,5,0,0:0:0:0: +357,57,43373,1,0,0:0:0:0: +302,99,43448,1,0,0:0:0:0: +280,164,43523,1,0,0:0:0:0: +297,231,43598,1,0,0:0:0:0: +283,299,43673,1,0,0:0:0:0: +229,341,43748,1,0,0:0:0:0: +161,340,43823,1,0,0:0:0:0: +110,294,43898,5,0,0:0:0:0: +99,226,43973,1,0,0:0:0:0: +135,168,44048,1,0,0:0:0:0: +200,146,44123,1,0,0:0:0:0: +264,170,44198,1,0,0:0:0:0: +297,230,44273,1,0,0:0:0:0: +357,268,44348,1,0,0:0:0:0: +425,258,44423,1,0,0:0:0:0: +470,206,44498,1,0,0:0:0:0: +473,137,44573,1,0,0:0:0:0: +432,82,44648,5,0,0:0:0:0: +359,105,44723,1,0,0:0:0:0: +317,171,44798,1,0,0:0:0:0: +266,234,44873,1,0,0:0:0:0: +195,277,44948,1,0,0:0:0:0: +112,265,45023,1,0,0:0:0:0: +60,207,45098,5,0,0:0:0:0: +56,131,45173,1,0,0:0:0:0: +101,67,45248,1,0,0:0:0:0: +177,52,45323,1,0,0:0:0:0: +244,85,45398,1,0,0:0:0:0: +297,137,45473,1,0,0:0:0:0: +374,142,45548,1,0,0:0:0:0: +434,92,45623,1,0,0:0:0:0: +445,23,45698,5,0,0:0:0:0: +368,29,45773,1,0,0:0:0:0: +314,83,45848,1,0,0:0:0:0: +308,159,45923,1,0,0:0:0:0: +353,221,45998,1,0,0:0:0:0: +374,294,46073,1,0,0:0:0:0: +331,356,46148,5,0,0:0:0:0: +256,363,46223,1,0,0:0:0:0: +203,309,46298,1,0,0:0:0:0: +210,234,46373,1,0,0:0:0:0: +198,159,46448,1,0,0:0:0:0: +133,121,46523,1,0,0:0:0:0: +62,147,46598,5,0,0:0:0:0: +37,218,46673,1,0,0:0:0:0: +76,282,46748,1,0,0:0:0:0: +151,292,46823,1,0,0:0:0:0: +206,241,46898,1,0,0:0:0:0: +255,183,46973,1,0,0:0:0:0: +321,148,47048,1,0,0:0:0:0: +391,177,47123,1,0,0:0:0:0: +413,251,47198,5,0,0:0:0:0: +338,234,47273,1,0,0:0:0:0: +290,172,47348,1,0,0:0:0:0: +234,114,47423,1,0,0:0:0:0: +159,77,47498,1,0,0:0:0:0: +78,96,47573,1,0,0:0:0:0: +34,162,47648,5,0,0:0:0:0: +37,239,47723,1,0,0:0:0:0: +87,299,47798,1,0,0:0:0:0: +164,306,47873,1,0,0:0:0:0: +224,256,47948,1,0,0:0:0:0: +239,184,48023,1,0,0:0:0:0: +230,113,48098,5,0,0:0:0:0: +161,135,48173,1,0,0:0:0:0: +117,193,48248,1,0,0:0:0:0: +116,265,48323,1,0,0:0:0:0: +156,325,48398,1,0,0:0:0:0: +224,351,48473,1,0,0:0:0:0: +294,332,48548,1,0,0:0:0:0: +340,277,48623,1,0,0:0:0:0: +346,205,48698,5,0,0:0:0:0: +389,149,48773,1,0,0:0:0:0: +460,153,48848,1,0,0:0:0:0: +498,213,48923,1,0,0:0:0:0: +470,278,48998,1,0,0:0:0:0: +401,294,49073,1,0,0:0:0:0: +347,248,49148,1,0,0:0:0:0: +352,178,49223,1,0,0:0:0:0: +337,109,49298,1,0,0:0:0:0: +273,79,49373,1,0,0:0:0:0: +211,114,49448,1,0,0:0:0:0: +202,184,49523,1,0,0:0:0:0: +256,232,49598,5,0,0:0:0:0: +290,166,49673,1,0,0:0:0:0: +282,91,49748,1,0,0:0:0:0: +238,33,49823,1,0,0:0:0:0: +168,5,49898,1,0,0:0:0:0: +91,21,49973,1,0,0:0:0:0: +37,74,50048,1,0,0:0:0:0: +19,150,50123,1,0,0:0:0:0: +46,225,50198,1,0,0:0:0:0: +108,276,50273,1,0,0:0:0:0: +186,292,50348,1,0,0:0:0:0: +257,256,50423,1,0,0:0:0:0: +298,188,50498,5,0,0:0:0:0: +224,178,50573,1,0,0:0:0:0: +174,231,50648,1,0,0:0:0:0: +188,303,50723,1,0,0:0:0:0: +254,335,50798,5,0,0:0:0:0: +321,295,50873,1,0,0:0:0:0: +343,220,50948,1,0,0:0:0:0: +307,151,51023,1,0,0:0:0:0: +234,125,51098,5,0,0:0:0:0: +160,160,51173,1,0,0:0:0:0: +126,233,51248,1,0,0:0:0:0: +148,311,51323,1,0,0:0:0:0: +215,358,51398,5,0,0:0:0:0: +296,349,51473,1,0,0:0:0:0: +352,290,51548,1,0,0:0:0:0: +356,205,51623,1,0,0:0:0:0: +307,135,51698,5,0,0:0:0:0: +226,108,51773,1,0,0:0:0:0: +146,136,51848,1,0,0:0:0:0: +100,207,51923,1,0,0:0:0:0: +106,292,51998,5,0,0:0:0:0: +169,236,52073,1,0,0:0:0:0: +185,151,52148,1,0,0:0:0:0: +210,64,52223,1,0,0:0:0:0: +287,12,52298,1,0,0:0:0:0: +381,19,52373,1,0,0:0:0:0: +447,84,52448,1,0,0:0:0:0: +459,176,52523,1,0,0:0:0:0: +410,255,52598,1,0,0:0:0:0: +323,286,52673,1,0,0:0:0:0: +231,268,52748,1,0,0:0:0:0: +176,196,52823,1,0,0:0:0:0: +154,121,52898,6,0,L|150:77,1,35,0|0,0:0|0:0,0:0:0:0: +366,205,53198,6,0,L|339:162,1,35,0|0,0:0|0:0,0:0:0:0: +225,0,53348,2,0,L|252:43,1,35,0|0,0:0|0:0,0:0:0:0: +315,210,53498,2,0,L|293:175,1,35,0|0,0:0|0:0,0:0:0:0: +198,40,53648,2,0,L|219:74,1,35,0|0,0:0|0:0,0:0:0:0: +235,256,53798,5,0,0:0:0:0: +235,256,53873,1,0,0:0:0:0: +235,256,53948,1,0,0:0:0:0: +235,256,54023,1,0,0:0:0:0: +235,256,54098,2,0,L|256:290,1,35,0|0,0:0|0:0,0:0:0:0: +398,300,54398,6,0,L|355:272,1,35,0|0,0:0|0:0,0:0:0:0: +213,189,54548,2,0,L|255:216,1,35,0|0,0:0|0:0,0:0:0:0: +353,326,54698,2,0,L|318:303,1,35,0|0,0:0|0:0,0:0:0:0: +205,237,54848,2,0,L|239:259,1,35,0|0,0:0|0:0,0:0:0:0: +313,384,54998,5,0,0:0:0:0: +313,384,55073,1,0,0:0:0:0: +313,384,55148,1,0,0:0:0:0: +313,384,55223,1,0,0:0:0:0: +313,384,55298,2,0,L|278:361,1,35,0|0,0:0|0:0,0:0:0:0: +101,345,55598,6,0,L|114:312,1,35,0|0,0:0|0:0,0:0:0:0: +220,77,55748,2,0,L|206:109,1,35,0|0,0:0|0:0,0:0:0:0: +171,338,55896,2,0,L|184:305,1,35,0|0,0:0|0:0,0:0:0:0: +266,124,56046,2,0,L|252:156,1,35,0|0,0:0|0:0,0:0:0:0: +252,383,56198,5,0,0:0:0:0: +260,363,56273,1,0,0:0:0:0: +269,344,56348,1,0,0:0:0:0: +278,325,56423,1,0,0:0:0:0: +285,310,56498,5,0,0:0:0:0: +236,310,56573,1,0,0:0:0:0: +195,294,56648,1,0,0:0:0:0: +157,270,56723,1,0,0:0:0:0: +129,239,56798,1,0,0:0:0:0: +111,203,56873,1,0,0:0:0:0: +107,165,56948,1,0,0:0:0:0: +111,134,57023,1,0,0:0:0:0: +123,105,57098,5,0,0:0:0:0: +140,86,57173,1,0,0:0:0:0: +156,75,57248,1,0,0:0:0:0: +173,70,57323,1,0,0:0:0:0: +185,72,57398,1,0,0:0:0:0: +195,75,57473,1,0,0:0:0:0: +202,81,57548,1,0,0:0:0:0: +207,87,57623,1,0,0:0:0:0: +210,93,57698,6,0,L|227:131,1,35,0|0,0:0|0:0,0:0:0:0: +356,245,57998,6,0,L|293:236,1,35,0|0,0:0|0:0,0:0:0:0: +129,245,58148,2,0,L|192:236,1,35,0|0,0:0|0:0,0:0:0:0: +413,186,58298,2,0,L|358:179,1,35,0|0,0:0|0:0,0:0:0:0: +76,186,58448,2,0,L|139:177,1,35,0|0,0:0|0:0,0:0:0:0: +461,127,58598,5,0,0:0:0:0: +461,127,58673,1,0,0:0:0:0: +461,127,58748,1,0,0:0:0:0: +461,127,58823,1,0,0:0:0:0: +461,127,58898,2,0,L|398:117,1,35,0|0,0:0|0:0,0:0:0:0: +274,296,59198,6,0,L|284:230,1,35,0|0,0:0|0:0,0:0:0:0: +274,55,59348,2,0,L|284:122,1,35,0|0,0:0|0:0,0:0:0:0: +335,356,59498,2,0,L|343:298,1,35,0|0,0:0|0:0,0:0:0:0: +335,1,59648,2,0,L|346:67,1,35,0|0,0:0|0:0,0:0:0:0: +425,384,59798,5,0,0:0:0:0: +425,384,59873,1,0,0:0:0:0: +425,384,59948,1,0,0:0:0:0: +425,384,60023,1,0,0:0:0:0: +425,384,60098,1,0,0:0:0:0: +425,384,60173,1,0,0:0:0:0: +425,384,60248,6,0,L|434:321,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +504,332,60548,2,0,L|494:269,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +439,257,60848,2,0,L|448:194,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +512,197,61148,2,0,L|502:134,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +446,122,61448,5,0,0:0:0:0: +418,124,61523,1,0,0:0:0:0: +389,130,61598,1,0,0:0:0:0: +361,141,61673,1,0,0:0:0:0: +332,157,61748,1,0,0:0:0:0: +306,176,61823,1,0,0:0:0:0: +281,204,61898,1,0,0:0:0:0: +257,233,61973,1,0,0:0:0:0: +240,269,62048,5,0,0:0:0:0: +219,304,62123,1,0,0:0:0:0: +180,333,62198,1,0,0:0:0:0: +131,341,62273,1,0,0:0:0:0: +79,324,62348,1,0,0:0:0:0: +41,278,62423,1,0,0:0:0:0: +25,218,62498,6,0,L|16:176,1,35,0|0,0:0|0:0,0:0:0:0: +68,9,62798,6,0,L|79:58,1,35,0|0,0:0|0:0,0:0:0:0: +143,287,62948,2,0,L|131:237,1,35,0|0,0:0|0:0,0:0:0:0: +117,20,63098,2,0,L|126:60,1,35,0|0,0:0|0:0,0:0:0:0: +181,257,63248,2,0,L|172:218,1,35,0|0,0:0|0:0,0:0:0:0: +207,20,63398,5,0,0:0:0:0: +207,20,63473,1,0,0:0:0:0: +207,20,63548,1,0,0:0:0:0: +207,20,63623,1,0,0:0:0:0: +207,20,63698,2,0,L|216:60,1,35,0|0,0:0|0:0,0:0:0:0: +401,88,63998,6,0,L|356:97,1,35,0|0,0:0|0:0,0:0:0:0: +151,155,64148,2,0,L|195:144,1,35,0|0,0:0|0:0,0:0:0:0: +391,132,64298,2,0,L|355:140,1,35,0|0,0:0|0:0,0:0:0:0: +178,189,64448,2,0,L|213:180,1,35,0|0,0:0|0:0,0:0:0:0: +396,189,64598,5,0,0:0:0:0: +396,189,64673,1,0,0:0:0:0: +396,189,64748,1,0,0:0:0:0: +396,189,64823,1,0,0:0:0:0: +396,189,64898,2,0,L|356:198,1,35,0|0,0:0|0:0,0:0:0:0: +162,114,65198,6,0,L|187:162,1,35,0|0,0:0|0:0,0:0:0:0: +317,384,65348,2,0,L|290:335,1,35,0|0,0:0|0:0,0:0:0:0: +216,111,65498,2,0,L|236:150,1,35,0|0,0:0|0:0,0:0:0:0: +348,341,65648,2,0,L|327:302,1,35,0|0,0:0|0:0,0:0:0:0: +278,118,65798,5,0,0:0:0:0: +278,118,65873,1,0,0:0:0:0: +278,118,65948,1,0,0:0:0:0: +278,118,66023,1,0,0:0:0:0: +278,118,66098,5,0,0:0:0:0: +297,172,66173,1,0,0:0:0:0: +290,228,66248,1,0,0:0:0:0: +258,273,66323,1,0,0:0:0:0: +213,296,66398,1,0,0:0:0:0: +168,296,66473,1,0,0:0:0:0: +132,279,66548,1,0,0:0:0:0: +109,251,66623,1,0,0:0:0:0: +103,222,66698,5,0,0:0:0:0: +110,199,66773,1,0,0:0:0:0: +122,184,66848,1,0,0:0:0:0: +136,180,66923,1,0,0:0:0:0: +146,180,66998,1,0,0:0:0:0: +152,184,67073,1,0,0:0:0:0: +155,190,67148,1,0,0:0:0:0: +158,196,67223,1,0,0:0:0:0: +159,202,67298,6,0,L|152:246,1,35,0|0,0:0|0:0,0:0:0:0: +297,352,67598,6,0,L|248:341,1,35,0|0,0:0|0:0,0:0:0:0: +0,275,67748,2,0,L|50:287,1,35,0|0,0:0|0:0,0:0:0:0: +286,303,67898,2,0,L|246:294,1,35,0|0,0:0|0:0,0:0:0:0: +30,237,68048,2,0,L|69:246,1,35,0|0,0:0|0:0,0:0:0:0: +286,213,68198,5,0,0:0:0:0: +286,213,68273,1,0,0:0:0:0: +286,213,68348,1,0,0:0:0:0: +286,213,68423,1,0,0:0:0:0: +286,213,68498,2,0,L|246:204,1,35,0|0,0:0|0:0,0:0:0:0: +53,10,68798,6,0,L|66:71,1,35,0|0,0:0|0:0,0:0:0:0: +147,358,68948,2,0,L|131:295,1,35,0|0,0:0|0:0,0:0:0:0: +114,23,69098,2,0,L|125:73,1,35,0|0,0:0|0:0,0:0:0:0: +194,321,69248,2,0,L|182:272,1,35,0|0,0:0|0:0,0:0:0:0: +227,23,69398,5,0,0:0:0:0: +227,23,69473,1,0,0:0:0:0: +227,23,69548,1,0,0:0:0:0: +227,23,69623,1,0,0:0:0:0: +227,23,69698,1,0,0:0:0:0: +227,23,69773,1,0,0:0:0:0: +227,23,69848,6,0,L|240:65,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +310,89,70148,2,0,L|321:123,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +266,176,70448,2,0,L|279:218,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +349,242,70748,2,0,L|359:275,1,35,0|0,0:0|0:0,0:0:0:0: +304,341,70898,5,0,0:0:0:0: +278,368,70973,1,0,0:0:0:0: +242,383,71048,1,0,0:0:0:0: +202,382,71123,1,0,0:0:0:0: +167,364,71198,1,0,0:0:0:0: +144,333,71273,1,0,0:0:0:0: +135,290,71348,1,0,0:0:0:0: +147,250,71423,1,0,0:0:0:0: +176,219,71498,5,0,0:0:0:0: +209,189,71573,1,0,0:0:0:0: +226,151,71648,1,0,0:0:0:0: +222,105,71723,1,0,0:0:0:0: +201,61,71798,1,0,0:0:0:0: +158,30,71873,1,0,0:0:0:0: +105,24,71948,1,0,0:0:0:0: +46,39,72023,1,0,0:0:0:0: +6,87,72098,5,0,3:0:0:0: +70,126,72173,1,0,3:0:0:0: +144,143,72248,1,0,3:0:0:0: +220,132,72323,1,0,3:0:0:0: +287,97,72398,1,0,3:0:0:0: +363,110,72473,1,0,3:0:0:0: +415,166,72548,5,0,3:0:0:0: +425,241,72623,1,0,3:0:0:0: +388,306,72698,1,0,3:0:0:0: +320,337,72773,1,0,3:0:0:0: +245,324,72848,1,0,3:0:0:0: +198,265,72923,1,0,3:0:0:0: +199,190,72998,5,0,3:0:0:0: +248,133,73073,1,0,0:0:0:0: +323,123,73148,1,0,0:0:0:0: +386,163,73223,1,0,0:0:0:0: +408,235,73298,1,0,0:0:0:0: +378,304,73373,1,0,0:0:0:0: +307,339,73448,5,0,0:0:0:0: +288,264,73523,1,0,0:0:0:0: +343,209,73598,1,0,0:0:0:0: +419,227,73673,1,0,0:0:0:0: +493,195,73748,1,0,0:0:0:0: +507,114,73823,1,0,0:0:0:0: +446,60,73898,1,0,0:0:0:0: +366,73,73973,1,0,0:0:0:0: +317,139,74048,1,0,0:0:0:0: +258,202,74123,1,0,3:0:0:0: +170,212,74198,1,0,0:0:0:0: +111,141,74273,1,0,0:0:0:0: +142,52,74348,1,0,0:0:0:0: +236,28,74423,1,0,0:0:0:0: +303,95,74498,5,0,0:0:0:0: +242,140,74573,1,0,0:0:0:0: +167,127,74648,1,0,0:0:0:0: +92,139,74723,1,0,0:0:0:0: +43,197,74798,1,0,0:0:0:0: +46,273,74873,1,0,0:0:0:0: +100,326,74948,5,0,0:0:0:0: +175,333,75023,1,0,0:0:0:0: +238,289,75098,1,0,0:0:0:0: +257,215,75173,1,0,0:0:0:0: +276,143,75248,1,0,0:0:0:0: +318,78,75323,1,0,0:0:0:0: +392,58,75398,5,0,0:0:0:0: +460,90,75473,1,0,0:0:0:0: +493,159,75548,1,0,0:0:0:0: +474,233,75623,1,0,0:0:0:0: +413,278,75698,1,0,0:0:0:0: +337,274,75773,1,0,0:0:0:0: +280,223,75848,1,0,0:0:0:0: +269,148,75923,1,0,0:0:0:0: +309,82,75998,5,0,3:0:0:0: +343,146,76073,1,0,3:0:0:0: +306,214,76148,1,0,3:0:0:0: +228,219,76223,1,0,0:0:0:0: +181,240,76298,5,0,0:0:0:0: +156,286,76373,1,0,0:0:0:0: +163,336,76448,1,0,0:0:0:0: +196,370,76523,1,0,0:0:0:0: +241,377,76598,1,0,0:0:0:0: +277,357,76673,1,0,0:0:0:0: +294,322,76748,1,0,0:0:0:0: +287,290,76823,1,0,0:0:0:0: +276,274,76898,5,0,0:0:0:0: +246,203,76973,1,0,0:0:0:0: +269,131,77048,1,0,0:0:0:0: +334,90,77123,1,0,0:0:0:0: +409,100,77198,1,0,0:0:0:0: +460,156,77273,1,0,0:0:0:0: +464,232,77348,5,0,0:0:0:0: +417,293,77423,1,0,0:0:0:0: +343,310,77498,1,0,0:0:0:0: +275,275,77573,1,0,0:0:0:0: +246,205,77648,1,0,0:0:0:0: +198,145,77723,1,0,0:0:0:0: +124,129,77798,5,0,0:0:0:0: +56,164,77873,1,0,0:0:0:0: +27,234,77948,1,0,0:0:0:0: +52,306,78023,1,0,0:0:0:0: +117,345,78098,1,0,0:0:0:0: +192,333,78173,1,0,0:0:0:0: +241,275,78248,1,0,0:0:0:0: +241,199,78323,1,0,0:0:0:0: +193,138,78398,5,0,0:0:0:0: +147,200,78473,1,0,0:0:0:0: +149,278,78548,1,0,0:0:0:0: +199,337,78623,1,0,0:0:0:0: +278,355,78698,1,0,0:0:0:0: +350,318,78773,1,0,0:0:0:0: +384,243,78848,1,0,0:0:0:0: +365,160,78923,1,0,0:0:0:0: +294,105,78998,1,0,0:0:0:0: +204,101,79073,1,0,0:0:0:0: +140,165,79148,1,0,0:0:0:0: +141,259,79223,1,0,0:0:0:0: +206,318,79298,5,0,0:0:0:0: +233,244,79373,1,0,0:0:0:0: +198,174,79448,1,0,0:0:0:0: +124,151,79523,1,0,0:0:0:0: +57,193,79598,5,0,0:0:0:0: +43,271,79673,1,0,0:0:0:0: +82,342,79748,1,0,0:0:0:0: +157,370,79823,1,0,0:0:0:0: +233,344,79898,5,0,0:0:0:0: +276,271,79973,1,0,0:0:0:0: +265,187,80048,1,0,0:0:0:0: +206,127,80123,1,0,0:0:0:0: +121,118,80198,5,0,0:0:0:0: +48,166,80273,1,0,0:0:0:0: +18,248,80348,1,0,0:0:0:0: +45,331,80423,1,0,0:0:0:0: +117,382,80498,5,0,0:0:0:0: +206,376,80573,1,0,0:0:0:0: +280,325,80648,1,0,0:0:0:0: +316,243,80723,1,0,0:0:0:0: +303,156,80798,5,0,0:0:0:0: +227,204,80873,1,0,0:0:0:0: +217,295,80948,1,0,0:0:0:0: +281,359,81023,1,0,0:0:0:0: +373,351,81098,5,0,0:0:0:0: +427,270,81173,1,0,0:0:0:0: +416,176,81248,1,0,0:0:0:0: +339,119,81323,1,0,0:0:0:0: +249,140,81398,5,0,0:0:0:0: +194,225,81473,1,0,0:0:0:0: +219,323,81548,1,0,0:0:0:0: +302,383,81623,1,0,0:0:0:0: +256,192,81698,12,0,83948,0:0:0:0: +469,367,84098,5,0,3:0:0:0: +441,336,84173,1,0,3:0:0:0: +409,318,84248,1,0,3:0:0:0: +371,312,84323,1,0,3:0:0:0: +334,319,84398,1,0,3:0:0:0: +299,337,84473,1,0,3:0:0:0: +269,359,84548,1,0,3:0:0:0: +233,371,84623,1,0,3:0:0:0: +190,368,84698,5,0,3:0:0:0: +153,351,84773,1,0,3:0:0:0: +119,322,84848,1,0,3:0:0:0: +102,281,84923,1,0,3:0:0:0: +104,237,84998,1,0,3:0:0:0: +123,199,85073,1,0,3:0:0:0: +154,176,85148,1,0,3:0:0:0: +189,169,85223,1,0,3:0:0:0: +222,177,85298,5,0,3:0:0:0: +218,218,85373,1,0,3:0:0:0: +196,254,85448,1,0,3:0:0:0: +162,277,85523,1,0,3:0:0:0: +121,284,85598,5,0,3:0:0:0: +76,271,85673,1,0,3:0:0:0: +41,241,85748,1,0,3:0:0:0: +22,200,85823,1,0,3:0:0:0: +23,155,85898,1,0,3:0:0:0: +42,113,85973,1,0,3:0:0:0: +77,84,86048,1,0,3:0:0:0: +122,71,86123,1,0,3:0:0:0: +166,79,86198,5,0,3:0:0:0: +207,98,86273,1,0,3:0:0:0: +253,99,86348,1,0,3:0:0:0: +295,76,86423,1,0,3:0:0:0: +327,41,86498,5,0,3:0:0:0: +281,13,86573,1,0,3:0:0:0: +229,7,86648,1,0,3:0:0:0: +178,20,86723,1,0,3:0:0:0: +137,54,86798,1,0,3:0:0:0: +113,101,86873,1,0,3:0:0:0: +109,154,86948,1,0,3:0:0:0: +127,203,87023,1,0,3:0:0:0: +163,242,87098,1,0,3:0:0:0: +210,263,87173,1,0,3:0:0:0: +264,264,87248,1,0,3:0:0:0: +312,244,87323,1,0,3:0:0:0: +356,215,87398,5,0,3:0:0:0: +407,207,87473,1,0,3:0:0:0: +457,219,87548,1,0,3:0:0:0: +494,247,87623,1,0,3:0:0:0: +512,287,87698,5,0,0:0:0:0: +462,323,87773,1,0,0:0:0:0: +400,321,87848,1,0,0:0:0:0: +354,280,87923,1,0,0:0:0:0: +343,219,87998,1,0,0:0:0:0: +372,165,88073,1,0,0:0:0:0: +381,103,88148,1,0,0:0:0:0: +354,48,88223,1,0,0:0:0:0: +300,17,88298,1,0,0:0:0:0: +238,24,88373,1,0,0:0:0:0: +192,65,88448,5,0,0:0:0:0: +177,128,88523,1,0,0:0:0:0: +202,188,88598,1,0,0:0:0:0: +257,222,88673,1,0,0:0:0:0: +322,218,88748,1,0,0:0:0:0: +372,177,88823,1,0,0:0:0:0: +390,114,88898,5,0,0:0:0:0: +322,117,88973,1,0,0:0:0:0: +280,170,89048,1,0,0:0:0:0: +226,216,89123,1,0,3:0:0:0: +159,239,89198,1,0,3:0:0:0: +90,216,89273,1,0,3:0:0:0: +49,157,89348,1,0,3:0:0:0: +56,83,89423,1,0,0:0:0:0: +107,29,89498,5,0,0:0:0:0: +180,21,89573,1,0,0:0:0:0: +242,61,89648,1,0,0:0:0:0: +264,133,89723,1,0,0:0:0:0: +310,195,89798,1,0,3:0:0:0: +383,211,89873,1,0,3:0:0:0: +456,182,89948,1,0,3:0:0:0: +491,109,90023,1,0,3:0:0:0: +481,29,90098,5,0,0:0:0:0: +414,48,90173,1,0,0:0:0:0: +371,102,90248,1,0,0:0:0:0: +365,171,90323,1,0,0:0:0:0: +399,231,90398,1,0,0:0:0:0: +416,300,90473,1,0,0:0:0:0: +378,361,90548,1,0,0:0:0:0: +308,376,90623,1,0,0:0:0:0: +249,335,90698,5,0,0:0:0:0: +237,265,90773,1,0,0:0:0:0: +279,207,90848,1,0,0:0:0:0: +306,140,90923,1,0,0:0:0:0: +275,76,90998,1,0,0:0:0:0: +208,52,91073,1,0,0:0:0:0: +144,84,91148,1,0,0:0:0:0: +123,153,91223,1,0,0:0:0:0: +158,216,91298,5,0,0:0:0:0: +201,163,91373,1,0,0:0:0:0: +196,96,91448,1,0,0:0:0:0: +144,53,91523,1,0,0:0:0:0: +77,61,91598,1,0,0:0:0:0: +35,115,91673,1,0,0:0:0:0: +45,182,91748,1,0,0:0:0:0: +101,222,91823,1,0,0:0:0:0: +168,209,91898,5,0,0:0:0:0: +205,152,91973,1,0,0:0:0:0: +257,109,92048,1,0,0:0:0:0: +324,118,92123,1,0,0:0:0:0: +366,172,92198,1,0,0:0:0:0: +354,240,92273,1,0,0:0:0:0: +298,278,92348,1,0,0:0:0:0: +231,264,92423,1,0,0:0:0:0: +189,209,92498,5,0,0:0:0:0: +245,164,92573,1,0,0:0:0:0: +317,167,92648,1,0,0:0:0:0: +369,216,92723,1,0,0:0:0:0: +375,288,92798,1,0,0:0:0:0: +333,346,92873,1,0,0:0:0:0: +263,362,92948,1,0,0:0:0:0: +199,328,93023,1,0,0:0:0:0: +175,260,93098,5,0,0:0:0:0: +201,193,93173,1,0,0:0:0:0: +264,160,93248,1,0,0:0:0:0: +334,177,93323,1,0,0:0:0:0: +397,212,93398,1,0,0:0:0:0: +467,196,93473,1,0,0:0:0:0: +509,138,93548,1,0,0:0:0:0: +507,66,93623,1,0,0:0:0:0: +461,5,93698,5,0,0:0:0:0: +436,73,93773,1,0,0:0:0:0: +449,144,93848,1,0,0:0:0:0: +480,210,93923,1,0,0:0:0:0: +487,281,93998,1,0,0:0:0:0: +446,342,94073,1,0,0:0:0:0: +376,365,94148,1,0,0:0:0:0: +308,342,94223,1,0,0:0:0:0: +268,280,94298,5,0,0:0:0:0: +276,205,94373,1,0,0:0:0:0: +262,133,94448,1,0,0:0:0:0: +205,84,94523,1,0,0:0:0:0: +133,81,94598,1,0,0:0:0:0: +72,126,94673,1,0,0:0:0:0: +55,198,94748,1,0,0:0:0:0: +89,266,94823,1,0,0:0:0:0: +159,298,94898,5,0,0:0:0:0: +230,276,94973,1,0,0:0:0:0: +275,214,95048,1,0,0:0:0:0: +292,136,95123,1,0,3:0:0:0: +325,65,95198,1,0,3:0:0:0: +390,32,95273,1,0,3:0:0:0: +460,48,95348,5,0,0:0:0:0: +434,119,95423,1,0,0:0:0:0: +380,164,95498,1,0,0:0:0:0: +310,173,95573,1,0,0:0:0:0: +244,145,95648,1,0,0:0:0:0: +204,91,95723,1,0,0:0:0:0: +139,83,95798,1,0,3:0:0:0: +77,109,95873,1,0,3:0:0:0: +42,161,95948,1,0,3:0:0:0: +40,227,96023,1,0,0:0:0:0: +328,304,96698,5,0,0:0:0:0: +304,298,96773,1,0,0:0:0:0: +280,293,96848,1,0,0:0:0:0: +256,287,96923,1,0,0:0:0:0: +232,282,96998,1,0,0:0:0:0: +145,249,97148,5,0,0:0:0:0: +169,244,97223,1,0,0:0:0:0: +256,192,97598,5,0,0:0:0:0: + diff --git a/pp/rxoppai/yes2.osu b/pp/rxoppai/yes2.osu new file mode 100644 index 0000000..e0951ea --- /dev/null +++ b/pp/rxoppai/yes2.osu @@ -0,0 +1,1179 @@ +osu file format v14 + +[General] +AudioFilename: audio.mp3 +AudioLeadIn: 0 +PreviewTime: -1 +Countdown: 1 +SampleSet: Normal +StackLeniency: 0.3 +Mode: 0 +LetterboxInBreaks: 0 +WidescreenStoryboard: 0 + +[Editor] +Bookmarks: 12098,13298,14498,22898,24098,25898,26948,28298,29498,32498,32798,33098,33398,33698,34298,36548,37898,38198,38498,40898,42098,42548,43298,44648,45098,45698,47198,47648,48098,49598,49898,50498,51998,52298,60098,69698,70898,72098,73448,74498,75998,76298,76898,78398,79598,79898,80198,80498,80798,84098,85898,86198,86498,87398,88448,88898,91298,92498,93698,94898,95348,96698,99998 +DistanceSpacing: 1 +BeatDivisor: 4 +GridSize: 8 +TimelineZoom: 1.1 + +[Metadata] +Title:Snow Goose +TitleUnicode:Snow Goose +Artist:Mutsuhiko Izumi +ArtistUnicode:Mutsuhiko Izumi +Creator:InnerSuffering +Version:Heavenly Hard +Source: +Tags: +BeatmapID:1856504 +BeatmapSetID:888152 + +[Difficulty] +HPDrainRate:6.5 +CircleSize:4.4 +OverallDifficulty:10 +ApproachRate:9.7 +SliderMultiplier:1.4 +SliderTickRate:1 + +[Events] +//Background and Video events +0,0,"ice-4320x2160-frost-blue-purple-neon-oneplus-5t-stock-4k-11234.jpg",0,0 +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Sound Samples + +[TimingPoints] +2498,300,4,2,1,50,1,0 +6023,-100,4,2,1,26,0,0 +7298,-100,4,1,1,26,0,0 +24098,-100,4,1,1,49,0,0 +33698,-100,4,1,1,49,0,0 +43298,-100,4,1,1,49,0,0 +52898,-200,4,1,1,70,0,1 +53198,-100,4,1,1,70,0,1 +54098,-200,4,1,1,70,0,1 +54398,-100,4,1,1,70,0,1 +55298,-200,4,1,1,70,0,1 +55598,-100,4,1,1,70,0,1 +57698,-200,4,1,1,70,0,1 +57998,-100,4,1,1,70,0,1 +58898,-200,4,1,1,70,0,1 +59198,-100,4,1,1,70,0,1 +62498,-200,4,1,1,70,0,1 +62798,-100,4,1,1,70,0,1 +63698,-200,4,1,1,70,0,1 +63998,-100,4,1,1,60,0,1 +64898,-200,4,1,1,70,0,1 +65198,-100,4,1,1,60,0,1 +67298,-200,4,1,1,70,0,1 +67598,-100,4,1,1,60,0,1 +68498,-200,4,1,1,70,0,1 +68798,-100,4,1,1,60,0,1 +72098,-100,4,1,1,60,0,0 +79298,-100,4,1,1,60,0,1 +81698,-100,4,1,1,60,0,0 +90098,-100,4,1,1,64,0,1 +96098,-100,4,1,1,25,0,0 + + +[Colours] +Combo1 : 40,148,255 +Combo2 : 20,96,222 +Combo3 : 184,225,237 +Combo4 : 87,197,255 + +[HitObjects] +453,85,2498,5,0,0:0:0:0: +430,100,2573,1,0,0:0:0:0: +403,108,2648,1,0,0:0:0:0: +375,108,2723,1,0,0:0:0:0: +349,101,2798,1,0,0:0:0:0: +319,90,2873,1,0,0:0:0:0: +287,89,2948,1,0,0:0:0:0: +258,97,3023,1,0,0:0:0:0: +233,112,3098,5,0,0:0:0:0: +205,148,3173,1,0,0:0:0:0: +196,192,3248,1,0,0:0:0:0: +207,236,3323,1,0,0:0:0:0: +236,270,3398,1,0,0:0:0:0: +277,289,3473,1,0,0:0:0:0: +317,288,3548,1,0,0:0:0:0: +352,270,3623,1,0,0:0:0:0: +369,249,3698,5,0,0:0:0:0: +360,211,3773,1,0,0:0:0:0: +338,180,3848,1,0,0:0:0:0: +307,158,3923,1,0,0:0:0:0: +269,149,3998,1,0,0:0:0:0: +223,155,4073,1,0,0:0:0:0: +183,178,4148,1,0,0:0:0:0: +146,205,4223,1,0,0:0:0:0: +100,215,4298,5,0,0:0:0:0: +53,203,4373,1,0,0:0:0:0: +19,174,4448,1,0,0:0:0:0: +0,131,4523,1,0,0:0:0:0: +4,92,4598,1,0,0:0:0:0: +22,59,4673,1,0,0:0:0:0: +52,33,4748,1,0,0:0:0:0: +91,21,4823,1,0,0:0:0:0: +133,25,4898,5,0,0:0:0:0: +173,46,4973,1,0,0:0:0:0: +201,82,5048,1,0,0:0:0:0: +214,127,5123,1,0,0:0:0:0: +209,174,5198,1,0,0:0:0:0: +193,227,5273,1,0,0:0:0:0: +205,278,5348,1,0,0:0:0:0: +239,321,5423,1,0,0:0:0:0: +280,338,5498,5,0,0:0:0:0: +324,338,5573,1,0,0:0:0:0: +367,321,5648,1,0,0:0:0:0: +401,287,5723,1,0,0:0:0:0: +419,251,5798,1,0,0:0:0:0: +416,212,5873,1,0,0:0:0:0: +399,175,5948,1,0,0:0:0:0: +369,148,6023,1,0,0:0:0:0: +332,127,6098,5,0,0:0:0:0: +290,173,6173,1,0,0:0:0:0: +280,234,6248,1,0,0:0:0:0: +306,291,6323,1,0,0:0:0:0: +358,325,6398,1,0,0:0:0:0: +423,325,6473,1,0,0:0:0:0: +478,289,6548,1,0,0:0:0:0: +504,224,6623,1,0,0:0:0:0: +489,154,6698,1,0,0:0:0:0: +434,104,6773,1,0,0:0:0:0: +359,95,6848,1,0,0:0:0:0: +294,129,6923,1,0,0:0:0:0: +261,194,6998,1,0,0:0:0:0: +216,250,7073,1,0,0:0:0:0: +153,281,7148,1,0,0:0:0:0: +85,273,7223,1,0,0:0:0:0: +37,230,7298,5,0,0:0:0:0: +84,171,7373,1,0,0:0:0:0: +156,152,7448,1,0,0:0:0:0: +227,178,7523,1,0,0:0:0:0: +272,237,7598,1,0,0:0:0:0: +330,286,7673,1,0,0:0:0:0: +408,297,7748,1,0,0:0:0:0: +479,252,7823,1,0,0:0:0:0: +512,178,7898,5,0,0:0:0:0: +501,96,7973,1,0,0:0:0:0: +449,32,8048,1,0,0:0:0:0: +375,5,8123,1,0,0:0:0:0: +302,20,8198,1,0,0:0:0:0: +247,70,8273,1,0,0:0:0:0: +228,139,8348,1,0,0:0:0:0: +247,207,8423,1,0,0:0:0:0: +296,257,8498,5,0,0:0:0:0: +350,228,8573,1,0,0:0:0:0: +376,172,8648,1,0,0:0:0:0: +363,111,8723,1,0,0:0:0:0: +316,71,8798,1,0,0:0:0:0: +254,68,8873,1,0,0:0:0:0: +203,102,8948,1,0,0:0:0:0: +183,161,9023,1,0,0:0:0:0: +203,220,9098,2,0,L|242:260,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +301,278,9398,5,0,0:0:0:0: +355,269,9473,1,0,0:0:0:0: +394,229,9548,1,0,0:0:0:0: +401,174,9623,1,0,0:0:0:0: +374,126,9698,5,0,0:0:0:0: +319,158,9773,1,0,0:0:0:0: +297,220,9848,1,0,0:0:0:0: +318,283,9923,1,0,0:0:0:0: +371,319,9998,1,0,0:0:0:0: +435,315,10073,1,0,0:0:0:0: +486,273,10148,1,0,0:0:0:0: +499,212,10223,1,0,0:0:0:0: +490,151,10298,2,0,L|466:102,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +409,71,10598,5,0,0:0:0:0: +348,89,10673,1,0,0:0:0:0: +302,133,10748,1,0,0:0:0:0: +286,194,10823,1,0,0:0:0:0: +284,259,10898,5,0,0:0:0:0: +345,246,10973,1,0,0:0:0:0: +386,196,11048,1,0,0:0:0:0: +389,130,11123,1,0,0:0:0:0: +352,78,11198,1,0,0:0:0:0: +289,59,11273,1,0,0:0:0:0: +228,81,11348,1,0,0:0:0:0: +194,135,11423,1,0,0:0:0:0: +180,198,11498,2,0,L|195:242,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +232,293,11798,5,0,0:0:0:0: +286,276,11873,1,0,0:0:0:0: +328,237,11948,1,0,0:0:0:0: +342,182,12023,1,0,0:0:0:0: +338,123,12098,5,0,0:0:0:0: +276,146,12173,1,0,0:0:0:0: +240,201,12248,1,0,0:0:0:0: +239,267,12323,1,0,0:0:0:0: +274,321,12398,1,0,0:0:0:0: +335,351,12473,1,0,0:0:0:0: +404,340,12548,1,0,0:0:0:0: +457,290,12623,1,0,0:0:0:0: +474,218,12698,1,0,0:0:0:0: +444,145,12773,1,0,0:0:0:0: +376,103,12848,1,0,0:0:0:0: +300,107,12923,1,0,0:0:0:0: +240,154,12998,1,0,0:0:0:0: +172,188,13073,1,0,0:0:0:0: +98,188,13148,1,0,0:0:0:0: +37,151,13223,1,0,0:0:0:0: +11,85,13298,5,0,0:0:0:0: +77,48,13373,1,0,0:0:0:0: +151,58,13448,1,0,0:0:0:0: +207,112,13523,1,0,0:0:0:0: +227,187,13598,1,0,0:0:0:0: +284,241,13673,1,0,0:0:0:0: +363,261,13748,1,0,0:0:0:0: +435,229,13823,1,0,0:0:0:0: +476,160,13898,1,0,0:0:0:0: +465,76,13973,1,0,0:0:0:0: +405,13,14048,1,0,0:0:0:0: +321,0,14123,1,0,0:0:0:0: +242,39,14198,1,0,0:0:0:0: +206,114,14273,1,0,0:0:0:0: +219,195,14348,1,0,0:0:0:0: +273,251,14423,1,0,0:0:0:0: +345,266,14498,6,0,L|398:256,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +451,242,14948,5,0,0:0:0:0: +439,196,15023,1,0,0:0:0:0: +414,158,15098,1,0,0:0:0:0: +377,131,15173,1,0,0:0:0:0: +333,121,15248,1,0,0:0:0:0: +287,125,15323,1,0,0:0:0:0: +248,144,15398,1,0,0:0:0:0: +211,169,15473,1,0,0:0:0:0: +165,177,15548,1,0,0:0:0:0: +121,171,15623,1,0,0:0:0:0: +81,147,15698,6,0,L|38:111,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +3,72,16148,5,0,0:0:0:0: +47,50,16223,1,0,0:0:0:0: +95,46,16298,1,0,0:0:0:0: +142,57,16373,1,0,0:0:0:0: +180,87,16448,1,0,0:0:0:0: +207,128,16523,1,0,0:0:0:0: +218,174,16598,1,0,0:0:0:0: +223,221,16673,1,0,0:0:0:0: +246,264,16748,1,0,0:0:0:0: +281,297,16823,1,0,0:0:0:0: +328,315,16898,6,0,L|384:325,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +436,305,17348,5,0,0:0:0:0: +418,263,17423,1,0,0:0:0:0: +384,235,17498,1,0,0:0:0:0: +339,227,17573,1,0,0:0:0:0: +296,242,17648,1,0,0:0:0:0: +267,276,17723,1,0,0:0:0:0: +238,309,17798,1,0,0:0:0:0: +195,323,17873,1,0,0:0:0:0: +150,316,17948,1,0,0:0:0:0: +115,288,18023,1,0,0:0:0:0: +99,247,18098,6,0,L|80:191,7,35,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:0: +58,187,18698,5,0,0:0:0:0: +98,161,18773,1,0,0:0:0:0: +147,155,18848,1,0,0:0:0:0: +192,172,18923,5,0,0:0:0:0: +233,189,18998,1,0,0:0:0:0: +277,185,19073,1,0,0:0:0:0: +317,160,19148,5,0,0:0:0:0: +350,138,19223,1,0,0:0:0:0: +391,133,19298,6,0,L|437:137,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +500,158,19748,5,0,0:0:0:0: +490,202,19823,1,0,0:0:0:0: +464,239,19898,1,0,0:0:0:0: +426,262,19973,1,0,0:0:0:0: +381,268,20048,1,0,0:0:0:0: +339,254,20123,1,0,0:0:0:0: +305,225,20198,1,0,0:0:0:0: +286,183,20273,1,0,0:0:0:0: +269,143,20348,1,0,0:0:0:0: +235,112,20423,1,0,0:0:0:0: +191,99,20498,6,0,L|142:99,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +81,115,20948,5,0,0:0:0:0: +100,158,21023,1,0,0:0:0:0: +137,189,21098,1,0,0:0:0:0: +183,203,21173,1,0,0:0:0:0: +232,196,21248,1,0,0:0:0:0: +272,169,21323,1,0,0:0:0:0: +309,137,21398,1,0,0:0:0:0: +353,122,21473,1,0,0:0:0:0: +401,127,21548,1,0,0:0:0:0: +443,152,21623,1,0,0:0:0:0: +471,192,21698,6,0,L|483:238,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +472,301,22148,5,0,0:0:0:0: +437,330,22223,1,0,0:0:0:0: +394,344,22298,1,0,0:0:0:0: +349,339,22373,1,0,0:0:0:0: +310,315,22448,1,0,0:0:0:0: +284,279,22523,1,0,0:0:0:0: +255,246,22598,1,0,0:0:0:0: +213,228,22673,1,0,0:0:0:0: +167,227,22748,1,0,0:0:0:0: +126,246,22823,1,0,0:0:0:0: +97,278,22898,5,0,0:0:0:0: +137,307,22973,1,0,0:0:0:0: +189,311,23048,1,0,0:0:0:0: +235,286,23123,1,0,0:0:0:0: +258,239,23198,1,0,0:0:0:0: +255,186,23273,1,0,0:0:0:0: +235,136,23348,1,0,0:0:0:0: +246,80,23423,1,0,0:0:0:0: +283,39,23498,1,0,0:0:0:0: +341,23,23573,1,0,0:0:0:0: +398,45,23648,1,0,0:0:0:0: +438,93,23723,1,0,0:0:0:0: +446,160,23798,1,0,0:0:0:0: +415,228,23873,1,0,0:0:0:0: +348,271,23948,1,0,0:0:0:0: +262,273,24023,1,0,0:0:0:0: +190,227,24098,5,0,0:0:0:0: +227,200,24173,1,0,0:0:0:0: +272,192,24248,1,0,0:0:0:0: +315,201,24323,1,0,0:0:0:0: +357,220,24398,1,0,0:0:0:0: +402,220,24473,1,0,0:0:0:0: +443,201,24548,1,0,0:0:0:0: +472,167,24623,1,0,0:0:0:0: +485,123,24698,5,0,0:0:0:0: +475,72,24773,1,0,0:0:0:0: +447,29,24848,1,0,0:0:0:0: +403,6,24923,1,0,0:0:0:0: +353,1,24998,1,0,0:0:0:0: +304,17,25073,1,0,0:0:0:0: +265,51,25148,1,0,0:0:0:0: +244,95,25223,1,0,0:0:0:0: +244,143,25298,5,0,0:0:0:0: +262,186,25373,1,0,0:0:0:0: +296,217,25448,1,0,0:0:0:0: +339,232,25523,1,0,0:0:0:0: +385,229,25598,5,0,0:0:0:0: +423,207,25673,1,0,0:0:0:0: +450,173,25748,1,0,0:0:0:0: +460,130,25823,1,0,0:0:0:0: +453,87,25898,5,0,0:0:0:0: +430,53,25973,1,0,0:0:0:0: +396,34,26048,1,0,0:0:0:0: +358,27,26123,1,0,0:0:0:0: +320,33,26198,5,0,0:0:0:0: +281,52,26273,1,0,0:0:0:0: +251,84,26348,1,0,0:0:0:0: +232,126,26423,1,0,0:0:0:0: +200,162,26498,5,0,0:0:0:0: +149,174,26573,1,0,0:0:0:0: +100,154,26648,1,0,0:0:0:0: +72,110,26723,1,0,0:0:0:0: +75,58,26798,1,0,0:0:0:0: +107,17,26873,1,0,0:0:0:0: +156,2,26948,5,0,0:0:0:0: +202,17,27023,1,0,0:0:0:0: +231,56,27098,1,0,0:0:0:0: +233,105,27173,1,0,0:0:0:0: +207,147,27248,1,0,0:0:0:0: +187,191,27323,1,0,0:0:0:0: +197,239,27398,5,0,0:0:0:0: +225,268,27473,1,0,0:0:0:0: +271,279,27548,1,0,0:0:0:0: +312,260,27623,1,0,0:0:0:0: +336,221,27698,5,0,0:0:0:0: +294,183,27773,1,0,0:0:0:0: +240,176,27848,1,0,0:0:0:0: +190,200,27923,1,0,0:0:0:0: +144,231,27998,1,0,0:0:0:0: +89,227,28073,1,0,0:0:0:0: +45,194,28148,1,0,0:0:0:0: +29,141,28223,1,0,0:0:0:0: +48,86,28298,5,0,0:0:0:0: +98,54,28373,1,0,0:0:0:0: +155,56,28448,1,0,0:0:0:0: +199,92,28523,1,0,0:0:0:0: +218,147,28598,1,0,0:0:0:0: +237,202,28673,1,0,0:0:0:0: +281,238,28748,1,0,0:0:0:0: +338,240,28823,1,0,0:0:0:0: +388,208,28898,5,0,0:0:0:0: +360,171,28973,1,0,0:0:0:0: +321,150,29048,1,0,0:0:0:0: +276,148,29123,1,0,0:0:0:0: +234,165,29198,1,0,0:0:0:0: +203,198,29273,1,0,0:0:0:0: +189,240,29348,1,0,0:0:0:0: +195,289,29423,1,0,0:0:0:0: +220,330,29498,5,0,0:0:0:0: +257,354,29573,1,0,0:0:0:0: +301,359,29648,1,0,0:0:0:0: +343,346,29723,1,0,0:0:0:0: +377,317,29798,1,0,0:0:0:0: +394,268,29873,1,0,0:0:0:0: +386,216,29948,1,0,0:0:0:0: +354,175,30023,1,0,0:0:0:0: +307,154,30098,5,0,0:0:0:0: +269,124,30173,1,0,0:0:0:0: +262,76,30248,1,0,0:0:0:0: +289,37,30323,1,0,0:0:0:0: +336,27,30398,1,0,0:0:0:0: +377,53,30473,1,0,0:0:0:0: +398,98,30548,5,0,0:0:0:0: +400,150,30623,1,0,0:0:0:0: +385,200,30698,1,0,0:0:0:0: +354,242,30773,1,0,0:0:0:0: +309,272,30848,1,0,0:0:0:0: +259,284,30923,1,0,0:0:0:0: +206,279,30998,5,0,0:0:0:0: +153,253,31073,1,0,0:0:0:0: +113,212,31148,1,0,0:0:0:0: +88,160,31223,1,0,0:0:0:0: +77,104,31298,5,0,0:0:0:0: +139,86,31373,1,0,0:0:0:0: +201,102,31448,1,0,0:0:0:0: +248,147,31523,1,0,0:0:0:0: +267,212,31598,5,0,0:0:0:0: +286,274,31673,1,0,0:0:0:0: +334,321,31748,1,0,0:0:0:0: +397,336,31823,1,0,0:0:0:0: +457,310,31898,1,0,0:0:0:0: +488,253,31973,1,0,0:0:0:0: +479,189,32048,1,0,0:0:0:0: +432,143,32123,1,0,0:0:0:0: +367,136,32198,5,0,0:0:0:0: +311,172,32273,1,0,0:0:0:0: +244,204,32348,1,0,0:0:0:0: +175,191,32423,1,0,0:0:0:0: +129,139,32498,5,0,0:0:0:0: +124,128,32573,1,0,0:0:0:0: +119,117,32648,1,0,0:0:0:0: +114,106,32723,1,0,0:0:0:0: +109,95,32798,5,0,0:0:0:0: +166,63,32873,1,0,0:0:0:0: +232,63,32948,1,0,0:0:0:0: +288,97,33023,1,0,0:0:0:0: +320,155,33098,5,0,0:0:0:0: +318,225,33173,1,0,0:0:0:0: +282,286,33248,1,0,0:0:0:0: +220,321,33323,1,0,0:0:0:0: +149,320,33398,1,0,0:0:0:0: +86,282,33473,1,0,0:0:0:0: +52,219,33548,1,0,0:0:0:0: +54,146,33623,1,0,0:0:0:0: +91,84,33698,5,0,0:0:0:0: +126,112,33773,1,0,0:0:0:0: +145,153,33848,1,0,0:0:0:0: +148,197,33923,1,0,0:0:0:0: +140,242,33998,1,0,0:0:0:0: +151,286,34073,1,0,0:0:0:0: +181,321,34148,1,0,0:0:0:0: +221,340,34223,1,0,0:0:0:0: +266,341,34298,5,0,0:0:0:0: +313,318,34373,1,0,0:0:0:0: +348,280,34448,1,0,0:0:0:0: +359,231,34523,1,0,0:0:0:0: +350,182,34598,1,0,0:0:0:0: +322,139,34673,1,0,0:0:0:0: +277,104,34748,1,0,0:0:0:0: +224,87,34823,1,0,0:0:0:0: +172,98,34898,5,0,0:0:0:0: +184,158,34973,1,0,0:0:0:0: +227,202,35048,1,0,0:0:0:0: +288,215,35123,1,0,0:0:0:0: +346,192,35198,1,0,0:0:0:0: +407,182,35273,1,0,0:0:0:0: +461,212,35348,1,0,0:0:0:0: +485,269,35423,1,0,0:0:0:0: +470,329,35498,5,0,0:0:0:0: +421,367,35573,1,0,0:0:0:0: +359,367,35648,1,0,0:0:0:0: +310,330,35723,1,0,0:0:0:0: +294,270,35798,5,0,0:0:0:0: +313,208,35873,1,0,0:0:0:0: +348,150,35948,1,0,0:0:0:0: +337,84,36023,1,0,0:0:0:0: +296,32,36098,5,0,0:0:0:0: +225,24,36173,1,0,0:0:0:0: +169,68,36248,1,0,0:0:0:0: +158,138,36323,1,0,0:0:0:0: +199,196,36398,1,0,0:0:0:0: +269,209,36473,1,0,0:0:0:0: +342,209,36548,1,0,0:0:0:0: +407,237,36623,1,0,0:0:0:0: +431,306,36698,5,0,0:0:0:0: +395,369,36773,1,0,0:0:0:0: +323,384,36848,1,0,0:0:0:0: +264,341,36923,1,0,0:0:0:0: +244,271,36998,1,0,0:0:0:0: +197,209,37073,1,0,0:0:0:0: +121,195,37148,1,0,0:0:0:0: +52,234,37223,1,0,0:0:0:0: +26,308,37298,5,0,0:0:0:0: +96,325,37373,1,0,0:0:0:0: +165,301,37448,1,0,0:0:0:0: +210,244,37523,1,0,0:0:0:0: +217,172,37598,1,0,0:0:0:0: +261,116,37673,1,0,0:0:0:0: +332,104,37748,1,0,0:0:0:0: +393,142,37823,1,0,0:0:0:0: +412,211,37898,5,0,0:0:0:0: +381,275,37973,1,0,0:0:0:0: +314,302,38048,1,0,0:0:0:0: +247,277,38123,1,0,0:0:0:0: +209,217,38198,5,0,0:0:0:0: +198,201,38273,1,0,0:0:0:0: +190,190,38348,1,0,0:0:0:0: +180,176,38423,1,0,0:0:0:0: +169,160,38498,5,0,0:0:0:0: +215,99,38573,1,0,0:0:0:0: +286,70,38648,1,0,0:0:0:0: +361,79,38723,1,0,0:0:0:0: +422,124,38798,1,0,0:0:0:0: +454,193,38873,1,0,0:0:0:0: +447,269,38948,1,0,0:0:0:0: +404,332,39023,1,0,0:0:0:0: +335,365,39098,5,0,0:0:0:0: +259,360,39173,1,0,0:0:0:0: +195,320,39248,1,0,0:0:0:0: +183,246,39323,1,0,0:0:0:0: +229,188,39398,1,0,0:0:0:0: +304,183,39473,1,0,0:0:0:0: +357,236,39548,1,0,0:0:0:0: +426,258,39623,1,0,0:0:0:0: +491,221,39698,5,0,0:0:0:0: +506,148,39773,1,0,0:0:0:0: +461,89,39848,1,0,0:0:0:0: +388,81,39923,1,0,0:0:0:0: +335,133,39998,1,0,0:0:0:0: +302,196,40073,1,0,0:0:0:0: +244,245,40148,1,0,0:0:0:0: +167,244,40223,1,0,0:0:0:0: +118,184,40298,5,0,0:0:0:0: +129,106,40373,1,0,0:0:0:0: +195,65,40448,1,0,0:0:0:0: +270,88,40523,1,0,0:0:0:0: +301,160,40598,1,0,0:0:0:0: +268,230,40673,1,0,0:0:0:0: +193,251,40748,1,0,0:0:0:0: +127,209,40823,1,0,0:0:0:0: +95,142,40898,5,0,0:0:0:0: +142,92,40973,1,0,0:0:0:0: +210,80,41048,1,0,0:0:0:0: +272,110,41123,1,0,0:0:0:0: +304,171,41198,1,0,0:0:0:0: +301,244,41273,1,0,0:0:0:0: +316,310,41348,1,0,0:0:0:0: +369,353,41423,1,0,0:0:0:0: +438,354,41498,5,0,0:0:0:0: +492,311,41573,1,0,0:0:0:0: +507,244,41648,1,0,0:0:0:0: +476,182,41723,1,0,0:0:0:0: +414,153,41798,1,0,0:0:0:0: +347,167,41873,1,0,0:0:0:0: +304,220,41948,1,0,0:0:0:0: +302,289,42023,1,0,0:0:0:0: +343,344,42098,5,0,0:0:0:0: +391,285,42173,1,0,0:0:0:0: +392,209,42248,1,0,0:0:0:0: +349,149,42323,1,0,0:0:0:0: +276,124,42398,1,0,0:0:0:0: +205,152,42473,1,0,0:0:0:0: +131,158,42548,1,0,0:0:0:0: +83,104,42623,1,0,0:0:0:0: +102,39,42698,5,0,0:0:0:0: +171,21,42773,1,0,0:0:0:0: +220,70,42848,1,0,0:0:0:0: +238,142,42923,1,0,0:0:0:0: +282,195,42998,1,0,0:0:0:0: +349,209,43073,1,0,0:0:0:0: +411,180,43148,1,0,0:0:0:0: +441,118,43223,1,0,0:0:0:0: +426,52,43298,5,0,0:0:0:0: +354,58,43373,1,0,0:0:0:0: +297,100,43448,1,0,0:0:0:0: +275,168,43523,1,0,0:0:0:0: +292,236,43598,1,0,0:0:0:0: +278,306,43673,1,0,0:0:0:0: +223,349,43748,1,0,0:0:0:0: +152,348,43823,1,0,0:0:0:0: +100,301,43898,5,0,0:0:0:0: +88,231,43973,1,0,0:0:0:0: +126,172,44048,1,0,0:0:0:0: +192,148,44123,1,0,0:0:0:0: +258,174,44198,1,0,0:0:0:0: +292,235,44273,1,0,0:0:0:0: +354,274,44348,1,0,0:0:0:0: +423,263,44423,1,0,0:0:0:0: +470,210,44498,1,0,0:0:0:0: +473,139,44573,1,0,0:0:0:0: +436,80,44648,5,0,0:0:0:0: +358,104,44723,1,0,0:0:0:0: +313,174,44798,1,0,0:0:0:0: +260,240,44873,1,0,0:0:0:0: +185,286,44948,1,0,0:0:0:0: +99,273,45023,1,0,0:0:0:0: +43,211,45098,5,0,0:0:0:0: +38,131,45173,1,0,0:0:0:0: +86,64,45248,1,0,0:0:0:0: +166,49,45323,1,0,0:0:0:0: +237,83,45398,1,0,0:0:0:0: +292,137,45473,1,0,0:0:0:0: +375,142,45548,1,0,0:0:0:0: +438,90,45623,1,0,0:0:0:0: +449,18,45698,5,0,0:0:0:0: +370,24,45773,1,0,0:0:0:0: +315,80,45848,1,0,0:0:0:0: +309,158,45923,1,0,0:0:0:0: +355,221,45998,1,0,0:0:0:0: +377,296,46073,1,0,0:0:0:0: +332,360,46148,5,0,0:0:0:0: +256,367,46223,1,0,0:0:0:0: +201,312,46298,1,0,0:0:0:0: +208,235,46373,1,0,0:0:0:0: +196,158,46448,1,0,0:0:0:0: +129,119,46523,1,0,0:0:0:0: +56,145,46598,5,0,0:0:0:0: +31,218,46673,1,0,0:0:0:0: +71,284,46748,1,0,0:0:0:0: +148,294,46823,1,0,0:0:0:0: +204,242,46898,1,0,0:0:0:0: +254,182,46973,1,0,0:0:0:0: +322,146,47048,1,0,0:0:0:0: +394,176,47123,1,0,0:0:0:0: +417,252,47198,5,0,0:0:0:0: +340,235,47273,1,0,0:0:0:0: +290,171,47348,1,0,0:0:0:0: +233,111,47423,1,0,0:0:0:0: +156,74,47498,1,0,0:0:0:0: +73,93,47573,1,0,0:0:0:0: +28,161,47648,5,0,0:0:0:0: +31,240,47723,1,0,0:0:0:0: +82,301,47798,1,0,0:0:0:0: +161,308,47873,1,0,0:0:0:0: +223,257,47948,1,0,0:0:0:0: +238,183,48023,1,0,0:0:0:0: +229,110,48098,5,0,0:0:0:0: +158,133,48173,1,0,0:0:0:0: +113,193,48248,1,0,0:0:0:0: +112,266,48323,1,0,0:0:0:0: +153,328,48398,1,0,0:0:0:0: +223,355,48473,1,0,0:0:0:0: +294,335,48548,1,0,0:0:0:0: +342,279,48623,1,0,0:0:0:0: +348,205,48698,5,0,0:0:0:0: +392,147,48773,1,0,0:0:0:0: +465,151,48848,1,0,0:0:0:0: +504,213,48923,1,0,0:0:0:0: +475,280,48998,1,0,0:0:0:0: +404,296,49073,1,0,0:0:0:0: +349,249,49148,1,0,0:0:0:0: +354,177,49223,1,0,0:0:0:0: +339,106,49298,1,0,0:0:0:0: +273,76,49373,1,0,0:0:0:0: +209,111,49448,1,0,0:0:0:0: +200,183,49523,1,0,0:0:0:0: +256,233,49598,5,0,0:0:0:0: +290,165,49673,1,0,0:0:0:0: +282,88,49748,1,0,0:0:0:0: +237,28,49823,1,0,0:0:0:0: +165,0,49898,1,0,0:0:0:0: +86,16,49973,1,0,0:0:0:0: +31,70,50048,1,0,0:0:0:0: +12,148,50123,1,0,0:0:0:0: +40,225,50198,1,0,0:0:0:0: +104,278,50273,1,0,0:0:0:0: +184,294,50348,1,0,0:0:0:0: +257,257,50423,1,0,0:0:0:0: +298,187,50498,5,0,0:0:0:0: +223,177,50573,1,0,0:0:0:0: +172,231,50648,1,0,0:0:0:0: +187,304,50723,1,0,0:0:0:0: +253,337,50798,5,0,0:0:0:0: +321,296,50873,1,0,0:0:0:0: +344,220,50948,1,0,0:0:0:0: +307,150,51023,1,0,0:0:0:0: +233,124,51098,5,0,0:0:0:0: +158,159,51173,1,0,0:0:0:0: +124,233,51248,1,0,0:0:0:0: +146,312,51323,1,0,0:0:0:0: +214,360,51398,5,0,0:0:0:0: +296,351,51473,1,0,0:0:0:0: +353,291,51548,1,0,0:0:0:0: +357,205,51623,1,0,0:0:0:0: +307,134,51698,5,0,0:0:0:0: +225,106,51773,1,0,0:0:0:0: +144,135,51848,1,0,0:0:0:0: +97,207,51923,1,0,0:0:0:0: +103,293,51998,5,0,0:0:0:0: +167,236,52073,1,0,0:0:0:0: +184,150,52148,1,0,0:0:0:0: +209,62,52223,1,0,0:0:0:0: +287,9,52298,1,0,0:0:0:0: +382,16,52373,1,0,0:0:0:0: +449,82,52448,1,0,0:0:0:0: +461,175,52523,1,0,0:0:0:0: +412,255,52598,1,0,0:0:0:0: +323,287,52673,1,0,0:0:0:0: +230,269,52748,1,0,0:0:0:0: +174,196,52823,1,0,0:0:0:0: +154,121,52898,6,0,L|150:77,1,35,0|0,0:0|0:0,0:0:0:0: +366,205,53198,6,0,L|339:162,1,35,0|0,0:0|0:0,0:0:0:0: +225,0,53348,2,0,L|252:43,1,35,0|0,0:0|0:0,0:0:0:0: +315,210,53498,2,0,L|293:175,1,35,0|0,0:0|0:0,0:0:0:0: +198,40,53648,2,0,L|219:74,1,35,0|0,0:0|0:0,0:0:0:0: +235,256,53798,5,0,0:0:0:0: +235,256,53873,1,0,0:0:0:0: +235,256,53948,1,0,0:0:0:0: +235,256,54023,1,0,0:0:0:0: +235,256,54098,2,0,L|256:290,1,35,0|0,0:0|0:0,0:0:0:0: +398,300,54398,6,0,L|355:272,1,35,0|0,0:0|0:0,0:0:0:0: +213,189,54548,2,0,L|255:216,1,35,0|0,0:0|0:0,0:0:0:0: +353,326,54698,2,0,L|318:303,1,35,0|0,0:0|0:0,0:0:0:0: +205,237,54848,2,0,L|239:259,1,35,0|0,0:0|0:0,0:0:0:0: +313,384,54998,5,0,0:0:0:0: +313,384,55073,1,0,0:0:0:0: +313,384,55148,1,0,0:0:0:0: +313,384,55223,1,0,0:0:0:0: +313,384,55298,2,0,L|278:361,1,35,0|0,0:0|0:0,0:0:0:0: +101,345,55598,6,0,L|114:312,1,35,0|0,0:0|0:0,0:0:0:0: +220,77,55748,2,0,L|206:109,1,35,0|0,0:0|0:0,0:0:0:0: +171,338,55896,2,0,L|184:305,1,35,0|0,0:0|0:0,0:0:0:0: +266,124,56046,2,0,L|252:156,1,35,0|0,0:0|0:0,0:0:0:0: +252,383,56198,5,0,0:0:0:0: +260,363,56273,1,0,0:0:0:0: +269,344,56348,1,0,0:0:0:0: +278,325,56423,1,0,0:0:0:0: +285,310,56498,5,0,0:0:0:0: +236,310,56573,1,0,0:0:0:0: +195,294,56648,1,0,0:0:0:0: +157,270,56723,1,0,0:0:0:0: +129,239,56798,1,0,0:0:0:0: +111,203,56873,1,0,0:0:0:0: +107,165,56948,1,0,0:0:0:0: +111,134,57023,1,0,0:0:0:0: +123,105,57098,5,0,0:0:0:0: +140,86,57173,1,0,0:0:0:0: +156,75,57248,1,0,0:0:0:0: +173,70,57323,1,0,0:0:0:0: +185,72,57398,1,0,0:0:0:0: +195,75,57473,1,0,0:0:0:0: +202,81,57548,1,0,0:0:0:0: +207,87,57623,1,0,0:0:0:0: +210,93,57698,6,0,L|227:131,1,35,0|0,0:0|0:0,0:0:0:0: +356,245,57998,6,0,L|293:236,1,35,0|0,0:0|0:0,0:0:0:0: +129,245,58148,2,0,L|192:236,1,35,0|0,0:0|0:0,0:0:0:0: +413,186,58298,2,0,L|358:179,1,35,0|0,0:0|0:0,0:0:0:0: +76,186,58448,2,0,L|139:177,1,35,0|0,0:0|0:0,0:0:0:0: +461,127,58598,5,0,0:0:0:0: +461,127,58673,1,0,0:0:0:0: +461,127,58748,1,0,0:0:0:0: +461,127,58823,1,0,0:0:0:0: +461,127,58898,2,0,L|398:117,1,35,0|0,0:0|0:0,0:0:0:0: +274,296,59198,6,0,L|284:230,1,35,0|0,0:0|0:0,0:0:0:0: +274,55,59348,2,0,L|284:122,1,35,0|0,0:0|0:0,0:0:0:0: +335,356,59498,2,0,L|343:298,1,35,0|0,0:0|0:0,0:0:0:0: +335,1,59648,2,0,L|346:67,1,35,0|0,0:0|0:0,0:0:0:0: +425,384,59798,5,0,0:0:0:0: +425,384,59873,1,0,0:0:0:0: +425,384,59948,1,0,0:0:0:0: +425,384,60023,1,0,0:0:0:0: +425,384,60098,1,0,0:0:0:0: +425,384,60173,1,0,0:0:0:0: +425,384,60248,6,0,L|434:321,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +504,332,60548,2,0,L|494:269,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +439,257,60848,2,0,L|448:194,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +512,197,61148,2,0,L|502:134,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +446,122,61448,5,0,0:0:0:0: +418,124,61523,1,0,0:0:0:0: +389,130,61598,1,0,0:0:0:0: +361,141,61673,1,0,0:0:0:0: +332,157,61748,1,0,0:0:0:0: +306,176,61823,1,0,0:0:0:0: +281,204,61898,1,0,0:0:0:0: +257,233,61973,1,0,0:0:0:0: +240,269,62048,5,0,0:0:0:0: +219,304,62123,1,0,0:0:0:0: +180,333,62198,1,0,0:0:0:0: +131,341,62273,1,0,0:0:0:0: +79,324,62348,1,0,0:0:0:0: +41,278,62423,1,0,0:0:0:0: +25,218,62498,6,0,L|16:176,1,35,0|0,0:0|0:0,0:0:0:0: +68,9,62798,6,0,L|79:58,1,35,0|0,0:0|0:0,0:0:0:0: +143,287,62948,2,0,L|131:237,1,35,0|0,0:0|0:0,0:0:0:0: +117,20,63098,2,0,L|126:60,1,35,0|0,0:0|0:0,0:0:0:0: +181,257,63248,2,0,L|172:218,1,35,0|0,0:0|0:0,0:0:0:0: +207,20,63398,5,0,0:0:0:0: +207,20,63473,1,0,0:0:0:0: +207,20,63548,1,0,0:0:0:0: +207,20,63623,1,0,0:0:0:0: +207,20,63698,2,0,L|216:60,1,35,0|0,0:0|0:0,0:0:0:0: +401,88,63998,6,0,L|356:97,1,35,0|0,0:0|0:0,0:0:0:0: +151,155,64148,2,0,L|195:144,1,35,0|0,0:0|0:0,0:0:0:0: +391,132,64298,2,0,L|355:140,1,35,0|0,0:0|0:0,0:0:0:0: +178,189,64448,2,0,L|213:180,1,35,0|0,0:0|0:0,0:0:0:0: +396,189,64598,5,0,0:0:0:0: +396,189,64673,1,0,0:0:0:0: +396,189,64748,1,0,0:0:0:0: +396,189,64823,1,0,0:0:0:0: +396,189,64898,2,0,L|356:198,1,35,0|0,0:0|0:0,0:0:0:0: +162,114,65198,6,0,L|187:162,1,35,0|0,0:0|0:0,0:0:0:0: +317,384,65348,2,0,L|290:335,1,35,0|0,0:0|0:0,0:0:0:0: +216,111,65498,2,0,L|236:150,1,35,0|0,0:0|0:0,0:0:0:0: +348,341,65648,2,0,L|327:302,1,35,0|0,0:0|0:0,0:0:0:0: +278,118,65798,5,0,0:0:0:0: +278,118,65873,1,0,0:0:0:0: +278,118,65948,1,0,0:0:0:0: +278,118,66023,1,0,0:0:0:0: +278,118,66098,5,0,0:0:0:0: +297,172,66173,1,0,0:0:0:0: +290,228,66248,1,0,0:0:0:0: +258,273,66323,1,0,0:0:0:0: +213,296,66398,1,0,0:0:0:0: +168,296,66473,1,0,0:0:0:0: +132,279,66548,1,0,0:0:0:0: +109,251,66623,1,0,0:0:0:0: +103,222,66698,5,0,0:0:0:0: +110,199,66773,1,0,0:0:0:0: +122,184,66848,1,0,0:0:0:0: +136,180,66923,1,0,0:0:0:0: +146,180,66998,1,0,0:0:0:0: +152,184,67073,1,0,0:0:0:0: +155,190,67148,1,0,0:0:0:0: +158,196,67223,1,0,0:0:0:0: +159,202,67298,6,0,L|152:246,1,35,0|0,0:0|0:0,0:0:0:0: +297,352,67598,6,0,L|248:341,1,35,0|0,0:0|0:0,0:0:0:0: +0,275,67748,2,0,L|50:287,1,35,0|0,0:0|0:0,0:0:0:0: +286,303,67898,2,0,L|246:294,1,35,0|0,0:0|0:0,0:0:0:0: +30,237,68048,2,0,L|69:246,1,35,0|0,0:0|0:0,0:0:0:0: +286,213,68198,5,0,0:0:0:0: +286,213,68273,1,0,0:0:0:0: +286,213,68348,1,0,0:0:0:0: +286,213,68423,1,0,0:0:0:0: +286,213,68498,2,0,L|246:204,1,35,0|0,0:0|0:0,0:0:0:0: +53,10,68798,6,0,L|66:71,1,35,0|0,0:0|0:0,0:0:0:0: +147,358,68948,2,0,L|131:295,1,35,0|0,0:0|0:0,0:0:0:0: +114,23,69098,2,0,L|125:73,1,35,0|0,0:0|0:0,0:0:0:0: +194,321,69248,2,0,L|182:272,1,35,0|0,0:0|0:0,0:0:0:0: +227,23,69398,5,0,0:0:0:0: +227,23,69473,1,0,0:0:0:0: +227,23,69548,1,0,0:0:0:0: +227,23,69623,1,0,0:0:0:0: +227,23,69698,1,0,0:0:0:0: +227,23,69773,1,0,0:0:0:0: +227,23,69848,6,0,L|240:65,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +310,89,70148,2,0,L|321:123,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +266,176,70448,2,0,L|279:218,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +349,242,70748,2,0,L|359:275,1,35,0|0,0:0|0:0,0:0:0:0: +304,341,70898,5,0,0:0:0:0: +278,368,70973,1,0,0:0:0:0: +242,383,71048,1,0,0:0:0:0: +202,382,71123,1,0,0:0:0:0: +167,364,71198,1,0,0:0:0:0: +144,333,71273,1,0,0:0:0:0: +135,290,71348,1,0,0:0:0:0: +147,250,71423,1,0,0:0:0:0: +176,219,71498,5,0,0:0:0:0: +209,189,71573,1,0,0:0:0:0: +226,151,71648,1,0,0:0:0:0: +222,105,71723,1,0,0:0:0:0: +201,61,71798,1,0,0:0:0:0: +158,30,71873,1,0,0:0:0:0: +105,24,71948,1,0,0:0:0:0: +46,39,72023,1,0,0:0:0:0: +1,85,72098,5,0,3:0:0:0: +66,124,72173,1,0,3:0:0:0: +141,142,72248,1,0,3:0:0:0: +219,130,72323,1,0,3:0:0:0: +287,95,72398,1,0,3:0:0:0: +365,108,72473,1,0,3:0:0:0: +418,165,72548,5,0,3:0:0:0: +428,241,72623,1,0,3:0:0:0: +390,308,72698,1,0,3:0:0:0: +321,339,72773,1,0,3:0:0:0: +244,326,72848,1,0,3:0:0:0: +196,266,72923,1,0,3:0:0:0: +197,189,72998,5,0,3:0:0:0: +247,131,73073,1,0,0:0:0:0: +324,121,73148,1,0,0:0:0:0: +388,162,73223,1,0,0:0:0:0: +410,235,73298,1,0,0:0:0:0: +380,306,73373,1,0,0:0:0:0: +307,341,73448,5,0,0:0:0:0: +288,265,73523,1,0,0:0:0:0: +344,209,73598,1,0,0:0:0:0: +422,227,73673,1,0,0:0:0:0: +497,195,73748,1,0,0:0:0:0: +511,112,73823,1,0,0:0:0:0: +449,57,73898,1,0,0:0:0:0: +368,70,73973,1,0,0:0:0:0: +318,137,74048,1,0,0:0:0:0: +258,202,74123,1,0,3:0:0:0: +168,212,74198,1,0,0:0:0:0: +108,140,74273,1,0,0:0:0:0: +139,49,74348,1,0,0:0:0:0: +235,24,74423,1,0,0:0:0:0: +303,93,74498,5,0,0:0:0:0: +241,139,74573,1,0,0:0:0:0: +165,125,74648,1,0,0:0:0:0: +88,137,74723,1,0,0:0:0:0: +38,197,74798,1,0,0:0:0:0: +42,274,74873,1,0,0:0:0:0: +97,328,74948,5,0,0:0:0:0: +173,335,75023,1,0,0:0:0:0: +237,290,75098,1,0,0:0:0:0: +257,215,75173,1,0,0:0:0:0: +276,142,75248,1,0,0:0:0:0: +319,75,75323,1,0,0:0:0:0: +394,55,75398,5,0,0:0:0:0: +463,88,75473,1,0,0:0:0:0: +497,158,75548,1,0,0:0:0:0: +478,233,75623,1,0,0:0:0:0: +415,279,75698,1,0,0:0:0:0: +338,275,75773,1,0,0:0:0:0: +280,223,75848,1,0,0:0:0:0: +269,147,75923,1,0,0:0:0:0: +310,79,75998,5,0,3:0:0:0: +344,145,76073,1,0,3:0:0:0: +306,214,76148,1,0,3:0:0:0: +227,219,76223,1,0,0:0:0:0: +179,240,76298,5,0,0:0:0:0: +154,287,76373,1,0,0:0:0:0: +161,338,76448,1,0,0:0:0:0: +194,373,76523,1,0,0:0:0:0: +240,380,76598,1,0,0:0:0:0: +277,360,76673,1,0,0:0:0:0: +294,324,76748,1,0,0:0:0:0: +287,291,76823,1,0,0:0:0:0: +269,253,76898,5,0,3:0:0:0: +250,174,76973,1,0,3:0:0:0: +287,107,77048,1,0,3:0:0:0: +359,76,77123,1,0,3:0:0:0: +433,100,77198,1,0,3:0:0:0: +475,165,77273,1,0,3:0:0:0: +464,241,77348,5,0,3:0:0:0: +406,294,77423,1,0,3:0:0:0: +330,298,77498,1,0,3:0:0:0: +267,252,77573,1,0,3:0:0:0: +250,176,77648,1,0,3:0:0:0: +214,107,77723,1,0,3:0:0:0: +141,78,77798,5,0,3:0:0:0: +68,102,77873,1,0,3:0:0:0: +26,166,77948,1,0,3:0:0:0: +37,243,78023,1,0,3:0:0:0: +97,294,78098,1,0,3:0:0:0: +173,295,78173,1,0,3:0:0:0: +233,246,78248,1,0,3:0:0:0: +246,169,78323,1,0,3:0:0:0: +209,100,78398,5,0,3:0:0:0: +152,154,78473,1,0,3:0:0:0: +140,232,78548,1,0,3:0:0:0: +180,301,78623,1,0,3:0:0:0: +256,333,78698,1,0,3:0:0:0: +335,308,78773,1,0,3:0:0:0: +382,238,78848,1,0,3:0:0:0: +378,153,78923,1,0,3:0:0:0: +317,85,78998,1,0,3:0:0:0: +226,64,79073,1,0,3:0:0:0: +152,117,79148,1,0,3:0:0:0: +135,212,79223,1,0,3:0:0:0: +191,282,79298,5,0,3:0:0:0: +231,213,79373,1,0,3:0:0:0: +208,136,79448,1,0,3:0:0:0: +137,101,79523,1,0,3:0:0:0: +63,130,79598,5,0,3:0:0:0: +34,206,79673,1,0,3:0:0:0: +62,284,79748,1,0,3:0:0:0: +132,326,79823,1,0,3:0:0:0: +213,314,79898,5,0,3:0:0:0: +269,249,79973,1,0,3:0:0:0: +273,162,80048,1,0,3:0:0:0: +224,91,80123,1,0,3:0:0:0: +140,67,80198,5,0,3:0:0:0: +58,102,80273,1,0,3:0:0:0: +14,178,80348,1,0,3:0:0:0: +26,267,80423,1,0,3:0:0:0: +89,331,80498,5,0,3:0:0:0: +180,340,80573,1,0,3:0:0:0: +264,303,80648,1,0,3:0:0:0: +315,226,80723,1,0,3:0:0:0: +316,136,80798,5,0,3:0:0:0: +231,172,80873,1,0,3:0:0:0: +205,261,80948,1,0,3:0:0:0: +259,337,81023,1,0,3:0:0:0: +352,345,81098,5,0,3:0:0:0: +421,274,81173,1,0,3:0:0:0: +426,177,81248,1,0,3:0:0:0: +358,107,81323,1,0,3:0:0:0: +265,112,81398,5,0,3:0:0:0: +195,187,81473,1,0,3:0:0:0: +203,289,81548,1,0,3:0:0:0: +276,366,81623,1,0,3:0:0:0: +256,192,81698,12,0,83948,0:0:0:0: +467,374,84098,5,0,3:0:0:0: +438,342,84173,1,0,3:0:0:0: +405,322,84248,1,0,3:0:0:0: +365,316,84323,1,0,3:0:0:0: +327,323,84398,1,0,3:0:0:0: +291,343,84473,1,0,3:0:0:0: +261,365,84548,1,0,3:0:0:0: +223,378,84623,1,0,3:0:0:0: +179,375,84698,5,0,3:0:0:0: +141,357,84773,1,0,3:0:0:0: +105,326,84848,1,0,3:0:0:0: +88,284,84923,1,0,3:0:0:0: +90,239,84998,1,0,3:0:0:0: +109,200,85073,1,0,3:0:0:0: +142,176,85148,1,0,3:0:0:0: +178,169,85223,1,0,3:0:0:0: +212,177,85298,5,0,3:0:0:0: +207,220,85373,1,0,3:0:0:0: +185,257,85448,1,0,3:0:0:0: +150,280,85523,1,0,3:0:0:0: +107,288,85598,5,0,3:0:0:0: +61,274,85673,1,0,3:0:0:0: +25,243,85748,1,0,3:0:0:0: +6,201,85823,1,0,3:0:0:0: +7,154,85898,1,0,3:0:0:0: +26,111,85973,1,0,3:0:0:0: +62,82,86048,1,0,3:0:0:0: +108,68,86123,1,0,3:0:0:0: +154,77,86198,5,0,3:0:0:0: +196,96,86273,1,0,3:0:0:0: +243,97,86348,1,0,3:0:0:0: +287,74,86423,1,0,3:0:0:0: +320,37,86498,5,0,3:0:0:0: +273,8,86573,1,0,3:0:0:0: +219,2,86648,1,0,3:0:0:0: +167,15,86723,1,0,3:0:0:0: +125,51,86798,1,0,3:0:0:0: +99,99,86873,1,0,3:0:0:0: +95,153,86948,1,0,3:0:0:0: +114,205,87023,1,0,3:0:0:0: +151,244,87098,1,0,3:0:0:0: +199,266,87173,1,0,3:0:0:0: +256,267,87248,1,0,3:0:0:0: +305,247,87323,1,0,3:0:0:0: +350,217,87398,5,0,3:0:0:0: +403,209,87473,1,0,3:0:0:0: +454,221,87548,1,0,3:0:0:0: +492,250,87623,1,0,3:0:0:0: +511,291,87698,5,0,0:0:0:0: +459,327,87773,1,0,0:0:0:0: +395,325,87848,1,0,0:0:0:0: +348,283,87923,1,0,0:0:0:0: +336,221,87998,1,0,0:0:0:0: +366,165,88073,1,0,0:0:0:0: +375,101,88148,1,0,0:0:0:0: +348,44,88223,1,0,0:0:0:0: +292,12,88298,1,0,0:0:0:0: +228,19,88373,1,0,0:0:0:0: +181,62,88448,5,0,0:0:0:0: +165,127,88523,1,0,0:0:0:0: +191,188,88598,1,0,0:0:0:0: +247,224,88673,1,0,0:0:0:0: +315,220,88748,1,0,0:0:0:0: +366,177,88823,1,0,0:0:0:0: +385,112,88898,5,0,0:0:0:0: +315,115,88973,1,0,0:0:0:0: +272,170,89048,1,0,0:0:0:0: +216,218,89123,1,0,3:0:0:0: +147,241,89198,1,0,3:0:0:0: +75,218,89273,1,0,3:0:0:0: +33,156,89348,1,0,3:0:0:0: +41,81,89423,1,0,0:0:0:0: +93,24,89498,5,0,0:0:0:0: +169,16,89573,1,0,0:0:0:0: +232,58,89648,1,0,0:0:0:0: +256,132,89723,1,0,0:0:0:0: +303,196,89798,1,0,3:0:0:0: +377,213,89873,1,0,3:0:0:0: +453,182,89948,1,0,3:0:0:0: +489,107,90023,1,0,3:0:0:0: +479,24,90098,5,0,0:0:0:0: +410,44,90173,1,0,0:0:0:0: +365,100,90248,1,0,0:0:0:0: +359,171,90323,1,0,0:0:0:0: +394,233,90398,1,0,0:0:0:0: +412,304,90473,1,0,0:0:0:0: +372,367,90548,1,0,0:0:0:0: +301,383,90623,1,0,0:0:0:0: +239,341,90698,5,0,0:0:0:0: +227,268,90773,1,0,0:0:0:0: +271,209,90848,1,0,0:0:0:0: +299,139,90923,1,0,0:0:0:0: +267,74,90998,1,0,0:0:0:0: +197,49,91073,1,0,0:0:0:0: +132,82,91148,1,0,0:0:0:0: +109,152,91223,1,0,0:0:0:0: +146,218,91298,5,0,0:0:0:0: +190,163,91373,1,0,0:0:0:0: +185,94,91448,1,0,0:0:0:0: +132,50,91523,1,0,0:0:0:0: +62,58,91598,1,0,0:0:0:0: +19,113,91673,1,0,0:0:0:0: +29,182,91748,1,0,0:0:0:0: +87,224,91823,1,0,0:0:0:0: +156,211,91898,5,0,0:0:0:0: +194,151,91973,1,0,0:0:0:0: +247,107,92048,1,0,0:0:0:0: +317,117,92123,1,0,0:0:0:0: +360,172,92198,1,0,0:0:0:0: +348,242,92273,1,0,0:0:0:0: +290,281,92348,1,0,0:0:0:0: +221,267,92423,1,0,0:0:0:0: +178,211,92498,5,0,0:0:0:0: +235,164,92573,1,0,0:0:0:0: +310,167,92648,1,0,0:0:0:0: +363,218,92723,1,0,0:0:0:0: +369,292,92798,1,0,0:0:0:0: +326,352,92873,1,0,0:0:0:0: +254,368,92948,1,0,0:0:0:0: +188,333,93023,1,0,0:0:0:0: +163,263,93098,5,0,0:0:0:0: +190,194,93173,1,0,0:0:0:0: +256,160,93248,1,0,0:0:0:0: +327,177,93323,1,0,0:0:0:0: +392,214,93398,1,0,0:0:0:0: +464,197,93473,1,0,0:0:0:0: +507,137,93548,1,0,0:0:0:0: +505,63,93623,1,0,0:0:0:0: +458,1,93698,5,0,0:0:0:0: +433,70,93773,1,0,0:0:0:0: +446,143,93848,1,0,0:0:0:0: +478,212,93923,1,0,0:0:0:0: +485,284,93998,1,0,0:0:0:0: +443,348,94073,1,0,0:0:0:0: +370,371,94148,1,0,0:0:0:0: +301,348,94223,1,0,0:0:0:0: +260,283,94298,5,0,0:0:0:0: +268,207,94373,1,0,0:0:0:0: +253,132,94448,1,0,0:0:0:0: +194,82,94523,1,0,0:0:0:0: +120,79,94598,1,0,0:0:0:0: +57,125,94673,1,0,0:0:0:0: +40,199,94748,1,0,0:0:0:0: +74,269,94823,1,0,0:0:0:0: +147,302,94898,5,0,0:0:0:0: +220,279,94973,1,0,0:0:0:0: +267,216,95048,1,0,0:0:0:0: +284,135,95123,1,0,3:0:0:0: +318,62,95198,1,0,3:0:0:0: +385,27,95273,1,0,3:0:0:0: +457,44,95348,5,0,0:0:0:0: +431,118,95423,1,0,0:0:0:0: +374,164,95498,1,0,0:0:0:0: +303,173,95573,1,0,0:0:0:0: +234,144,95648,1,0,0:0:0:0: +193,89,95723,1,0,0:0:0:0: +127,81,95798,1,0,3:0:0:0: +62,107,95873,1,0,3:0:0:0: +26,161,95948,1,0,3:0:0:0: +24,229,96023,1,0,0:0:0:0: +328,304,96698,5,0,0:0:0:0: +304,298,96773,1,0,0:0:0:0: +280,293,96848,1,0,0:0:0:0: +256,287,96923,1,0,0:0:0:0: +232,282,96998,1,0,0:0:0:0: +145,249,97148,5,0,0:0:0:0: +169,244,97223,1,0,0:0:0:0: +256,192,97598,5,0,0:0:0:0: + diff --git a/pp/rxoppai/yes3.osu b/pp/rxoppai/yes3.osu new file mode 100644 index 0000000..9644160 --- /dev/null +++ b/pp/rxoppai/yes3.osu @@ -0,0 +1,2357 @@ +osu file format v14 + +[General] +AudioFilename: audio.mp3 +AudioLeadIn: 0 +PreviewTime: -1 +Countdown: 1 +SampleSet: Normal +StackLeniency: 0.3 +Mode: 0 +LetterboxInBreaks: 0 +WidescreenStoryboard: 0 + +[Editor] +Bookmarks: 12098,13298,14498,22898,24098,25898,26948,28298,29498,32498,32798,33098,33398,33698,34298,36548,37898,38198,38498,40898,42098,42548,43298,44648,45098,45698,47198,47648,48098,49598,49898,50498,51998,52298,60098,69698,70898,72098,73448,74498,75998,76298,76898,78398,79598,79898,80198,80498,80798,84098,85898,86198,86498,87398,88448,88898,91298,92498,93698,94898,95348,96698,99998 +DistanceSpacing: 1 +BeatDivisor: 4 +GridSize: 8 +TimelineZoom: 1.1 + +[Metadata] +Title:Snow Goose +TitleUnicode:Snow Goose +Artist:Mutsuhiko Izumi +ArtistUnicode:Mutsuhiko Izumi +Creator:InnerSuffering +Version:Heavenly Hard +Source: +Tags: +BeatmapID:1856504 +BeatmapSetID:888152 + +[Difficulty] +HPDrainRate:6.5 +CircleSize:4.4 +OverallDifficulty:10 +ApproachRate:9.7 +SliderMultiplier:1.4 +SliderTickRate:1 + +[Events] +//Background and Video events +0,0,"ice-4320x2160-frost-blue-purple-neon-oneplus-5t-stock-4k-11234.jpg",0,0 +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Sound Samples + +[TimingPoints] +2498,300,4,2,1,50,1,0 +6023,-100,4,2,1,26,0,0 +7298,-100,4,1,1,26,0,0 +24098,-100,4,1,1,49,0,0 +33698,-100,4,1,1,49,0,0 +43298,-100,4,1,1,49,0,0 +52898,-200,4,1,1,70,0,1 +53198,-100,4,1,1,70,0,1 +54098,-200,4,1,1,70,0,1 +54398,-100,4,1,1,70,0,1 +55298,-200,4,1,1,70,0,1 +55598,-100,4,1,1,70,0,1 +57698,-200,4,1,1,70,0,1 +57998,-100,4,1,1,70,0,1 +58898,-200,4,1,1,70,0,1 +59198,-100,4,1,1,70,0,1 +62498,-200,4,1,1,70,0,1 +62798,-100,4,1,1,70,0,1 +63698,-200,4,1,1,70,0,1 +63998,-100,4,1,1,60,0,1 +64898,-200,4,1,1,70,0,1 +65198,-100,4,1,1,60,0,1 +67298,-200,4,1,1,70,0,1 +67598,-100,4,1,1,60,0,1 +68498,-200,4,1,1,70,0,1 +68798,-100,4,1,1,60,0,1 +72098,-100,4,1,1,60,0,0 +79298,-100,4,1,1,60,0,1 +81698,-100,4,1,1,60,0,0 +90098,-100,4,1,1,64,0,1 +96098,-100,4,1,1,25,0,0 + + +[Colours] +Combo1 : 40,148,255 +Combo2 : 20,96,222 +Combo3 : 184,225,237 +Combo4 : 87,197,255 + +[HitObjects] +453,85,2498,5,0,0:0:0:0: +430,100,2573,1,0,0:0:0:0: +403,108,2648,1,0,0:0:0:0: +375,108,2723,1,0,0:0:0:0: +349,101,2798,1,0,0:0:0:0: +319,90,2873,1,0,0:0:0:0: +287,89,2948,1,0,0:0:0:0: +258,97,3023,1,0,0:0:0:0: +233,112,3098,5,0,0:0:0:0: +205,148,3173,1,0,0:0:0:0: +196,192,3248,1,0,0:0:0:0: +207,236,3323,1,0,0:0:0:0: +236,270,3398,1,0,0:0:0:0: +277,289,3473,1,0,0:0:0:0: +317,288,3548,1,0,0:0:0:0: +352,270,3623,1,0,0:0:0:0: +369,249,3698,5,0,0:0:0:0: +360,211,3773,1,0,0:0:0:0: +338,180,3848,1,0,0:0:0:0: +307,158,3923,1,0,0:0:0:0: +269,149,3998,1,0,0:0:0:0: +223,155,4073,1,0,0:0:0:0: +183,178,4148,1,0,0:0:0:0: +146,205,4223,1,0,0:0:0:0: +100,215,4298,5,0,0:0:0:0: +53,203,4373,1,0,0:0:0:0: +19,174,4448,1,0,0:0:0:0: +0,131,4523,1,0,0:0:0:0: +4,92,4598,1,0,0:0:0:0: +22,59,4673,1,0,0:0:0:0: +52,33,4748,1,0,0:0:0:0: +91,21,4823,1,0,0:0:0:0: +133,25,4898,5,0,0:0:0:0: +173,46,4973,1,0,0:0:0:0: +201,82,5048,1,0,0:0:0:0: +214,127,5123,1,0,0:0:0:0: +209,174,5198,1,0,0:0:0:0: +193,227,5273,1,0,0:0:0:0: +205,278,5348,1,0,0:0:0:0: +239,321,5423,1,0,0:0:0:0: +280,338,5498,5,0,0:0:0:0: +324,338,5573,1,0,0:0:0:0: +367,321,5648,1,0,0:0:0:0: +401,287,5723,1,0,0:0:0:0: +419,251,5798,1,0,0:0:0:0: +416,212,5873,1,0,0:0:0:0: +399,175,5948,1,0,0:0:0:0: +369,148,6023,1,0,0:0:0:0: +332,127,6098,5,0,0:0:0:0: +290,173,6173,1,0,0:0:0:0: +280,234,6248,1,0,0:0:0:0: +306,291,6323,1,0,0:0:0:0: +358,325,6398,1,0,0:0:0:0: +423,325,6473,1,0,0:0:0:0: +478,289,6548,1,0,0:0:0:0: +504,224,6623,1,0,0:0:0:0: +489,154,6698,1,0,0:0:0:0: +434,104,6773,1,0,0:0:0:0: +359,95,6848,1,0,0:0:0:0: +294,129,6923,1,0,0:0:0:0: +261,194,6998,1,0,0:0:0:0: +216,250,7073,1,0,0:0:0:0: +153,281,7148,1,0,0:0:0:0: +85,273,7223,1,0,0:0:0:0: +37,230,7298,5,0,0:0:0:0: +84,171,7373,1,0,0:0:0:0: +156,152,7448,1,0,0:0:0:0: +227,178,7523,1,0,0:0:0:0: +272,237,7598,1,0,0:0:0:0: +330,286,7673,1,0,0:0:0:0: +408,297,7748,1,0,0:0:0:0: +479,252,7823,1,0,0:0:0:0: +512,178,7898,5,0,0:0:0:0: +501,96,7973,1,0,0:0:0:0: +449,32,8048,1,0,0:0:0:0: +375,5,8123,1,0,0:0:0:0: +302,20,8198,1,0,0:0:0:0: +247,70,8273,1,0,0:0:0:0: +228,139,8348,1,0,0:0:0:0: +247,207,8423,1,0,0:0:0:0: +296,257,8498,5,0,0:0:0:0: +350,228,8573,1,0,0:0:0:0: +376,172,8648,1,0,0:0:0:0: +363,111,8723,1,0,0:0:0:0: +316,71,8798,1,0,0:0:0:0: +254,68,8873,1,0,0:0:0:0: +203,102,8948,1,0,0:0:0:0: +183,161,9023,1,0,0:0:0:0: +203,220,9098,2,0,L|242:260,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +301,278,9398,5,0,0:0:0:0: +355,269,9473,1,0,0:0:0:0: +394,229,9548,1,0,0:0:0:0: +401,174,9623,1,0,0:0:0:0: +374,126,9698,5,0,0:0:0:0: +319,158,9773,1,0,0:0:0:0: +297,220,9848,1,0,0:0:0:0: +318,283,9923,1,0,0:0:0:0: +371,319,9998,1,0,0:0:0:0: +435,315,10073,1,0,0:0:0:0: +486,273,10148,1,0,0:0:0:0: +499,212,10223,1,0,0:0:0:0: +490,151,10298,2,0,L|466:102,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +409,71,10598,5,0,0:0:0:0: +348,89,10673,1,0,0:0:0:0: +302,133,10748,1,0,0:0:0:0: +286,194,10823,1,0,0:0:0:0: +284,259,10898,5,0,0:0:0:0: +345,246,10973,1,0,0:0:0:0: +386,196,11048,1,0,0:0:0:0: +389,130,11123,1,0,0:0:0:0: +352,78,11198,1,0,0:0:0:0: +289,59,11273,1,0,0:0:0:0: +228,81,11348,1,0,0:0:0:0: +194,135,11423,1,0,0:0:0:0: +180,198,11498,2,0,L|195:242,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +232,293,11798,5,0,0:0:0:0: +286,276,11873,1,0,0:0:0:0: +328,237,11948,1,0,0:0:0:0: +342,182,12023,1,0,0:0:0:0: +338,123,12098,5,0,0:0:0:0: +276,146,12173,1,0,0:0:0:0: +240,201,12248,1,0,0:0:0:0: +239,267,12323,1,0,0:0:0:0: +274,321,12398,1,0,0:0:0:0: +335,351,12473,1,0,0:0:0:0: +404,340,12548,1,0,0:0:0:0: +457,290,12623,1,0,0:0:0:0: +474,218,12698,1,0,0:0:0:0: +444,145,12773,1,0,0:0:0:0: +376,103,12848,1,0,0:0:0:0: +300,107,12923,1,0,0:0:0:0: +240,154,12998,1,0,0:0:0:0: +172,188,13073,1,0,0:0:0:0: +98,188,13148,1,0,0:0:0:0: +37,151,13223,1,0,0:0:0:0: +11,85,13298,5,0,0:0:0:0: +77,48,13373,1,0,0:0:0:0: +151,58,13448,1,0,0:0:0:0: +207,112,13523,1,0,0:0:0:0: +227,187,13598,1,0,0:0:0:0: +284,241,13673,1,0,0:0:0:0: +363,261,13748,1,0,0:0:0:0: +435,229,13823,1,0,0:0:0:0: +476,160,13898,1,0,0:0:0:0: +465,76,13973,1,0,0:0:0:0: +405,13,14048,1,0,0:0:0:0: +321,0,14123,1,0,0:0:0:0: +242,39,14198,1,0,0:0:0:0: +206,114,14273,1,0,0:0:0:0: +219,195,14348,1,0,0:0:0:0: +273,251,14423,1,0,0:0:0:0: +345,266,14498,6,0,L|398:256,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +451,242,14948,5,0,0:0:0:0: +439,196,15023,1,0,0:0:0:0: +414,158,15098,1,0,0:0:0:0: +377,131,15173,1,0,0:0:0:0: +333,121,15248,1,0,0:0:0:0: +287,125,15323,1,0,0:0:0:0: +248,144,15398,1,0,0:0:0:0: +211,169,15473,1,0,0:0:0:0: +165,177,15548,1,0,0:0:0:0: +121,171,15623,1,0,0:0:0:0: +81,147,15698,6,0,L|38:111,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +3,72,16148,5,0,0:0:0:0: +47,50,16223,1,0,0:0:0:0: +95,46,16298,1,0,0:0:0:0: +142,57,16373,1,0,0:0:0:0: +180,87,16448,1,0,0:0:0:0: +207,128,16523,1,0,0:0:0:0: +218,174,16598,1,0,0:0:0:0: +223,221,16673,1,0,0:0:0:0: +246,264,16748,1,0,0:0:0:0: +281,297,16823,1,0,0:0:0:0: +328,315,16898,6,0,L|384:325,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +436,305,17348,5,0,0:0:0:0: +418,263,17423,1,0,0:0:0:0: +384,235,17498,1,0,0:0:0:0: +339,227,17573,1,0,0:0:0:0: +296,242,17648,1,0,0:0:0:0: +267,276,17723,1,0,0:0:0:0: +238,309,17798,1,0,0:0:0:0: +195,323,17873,1,0,0:0:0:0: +150,316,17948,1,0,0:0:0:0: +115,288,18023,1,0,0:0:0:0: +99,247,18098,6,0,L|80:191,7,35,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:0: +58,187,18698,5,0,0:0:0:0: +98,161,18773,1,0,0:0:0:0: +147,155,18848,1,0,0:0:0:0: +192,172,18923,5,0,0:0:0:0: +233,189,18998,1,0,0:0:0:0: +277,185,19073,1,0,0:0:0:0: +317,160,19148,5,0,0:0:0:0: +350,138,19223,1,0,0:0:0:0: +391,133,19298,6,0,L|437:137,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +500,158,19748,5,0,0:0:0:0: +490,202,19823,1,0,0:0:0:0: +464,239,19898,1,0,0:0:0:0: +426,262,19973,1,0,0:0:0:0: +381,268,20048,1,0,0:0:0:0: +339,254,20123,1,0,0:0:0:0: +305,225,20198,1,0,0:0:0:0: +286,183,20273,1,0,0:0:0:0: +269,143,20348,1,0,0:0:0:0: +235,112,20423,1,0,0:0:0:0: +191,99,20498,6,0,L|142:99,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +81,115,20948,5,0,0:0:0:0: +100,158,21023,1,0,0:0:0:0: +137,189,21098,1,0,0:0:0:0: +183,203,21173,1,0,0:0:0:0: +232,196,21248,1,0,0:0:0:0: +272,169,21323,1,0,0:0:0:0: +309,137,21398,1,0,0:0:0:0: +353,122,21473,1,0,0:0:0:0: +401,127,21548,1,0,0:0:0:0: +443,152,21623,1,0,0:0:0:0: +471,192,21698,6,0,L|483:238,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +472,301,22148,5,0,0:0:0:0: +437,330,22223,1,0,0:0:0:0: +394,344,22298,1,0,0:0:0:0: +349,339,22373,1,0,0:0:0:0: +310,315,22448,1,0,0:0:0:0: +284,279,22523,1,0,0:0:0:0: +255,246,22598,1,0,0:0:0:0: +213,228,22673,1,0,0:0:0:0: +167,227,22748,1,0,0:0:0:0: +126,246,22823,1,0,0:0:0:0: +97,278,22898,5,0,0:0:0:0: +137,307,22973,1,0,0:0:0:0: +189,311,23048,1,0,0:0:0:0: +235,286,23123,1,0,0:0:0:0: +258,239,23198,1,0,0:0:0:0: +255,186,23273,1,0,0:0:0:0: +235,136,23348,1,0,0:0:0:0: +246,80,23423,1,0,0:0:0:0: +283,39,23498,1,0,0:0:0:0: +341,23,23573,1,0,0:0:0:0: +398,45,23648,1,0,0:0:0:0: +438,93,23723,1,0,0:0:0:0: +446,160,23798,1,0,0:0:0:0: +415,228,23873,1,0,0:0:0:0: +348,271,23948,1,0,0:0:0:0: +262,273,24023,1,0,0:0:0:0: +190,227,24098,5,0,0:0:0:0: +227,200,24173,1,0,0:0:0:0: +272,192,24248,1,0,0:0:0:0: +315,201,24323,1,0,0:0:0:0: +357,220,24398,1,0,0:0:0:0: +402,220,24473,1,0,0:0:0:0: +443,201,24548,1,0,0:0:0:0: +472,167,24623,1,0,0:0:0:0: +485,123,24698,5,0,0:0:0:0: +475,72,24773,1,0,0:0:0:0: +447,29,24848,1,0,0:0:0:0: +403,6,24923,1,0,0:0:0:0: +353,1,24998,1,0,0:0:0:0: +304,17,25073,1,0,0:0:0:0: +265,51,25148,1,0,0:0:0:0: +244,95,25223,1,0,0:0:0:0: +244,143,25298,5,0,0:0:0:0: +262,186,25373,1,0,0:0:0:0: +296,217,25448,1,0,0:0:0:0: +339,232,25523,1,0,0:0:0:0: +385,229,25598,5,0,0:0:0:0: +423,207,25673,1,0,0:0:0:0: +450,173,25748,1,0,0:0:0:0: +460,130,25823,1,0,0:0:0:0: +453,87,25898,5,0,0:0:0:0: +430,53,25973,1,0,0:0:0:0: +396,34,26048,1,0,0:0:0:0: +358,27,26123,1,0,0:0:0:0: +320,33,26198,5,0,0:0:0:0: +281,52,26273,1,0,0:0:0:0: +251,84,26348,1,0,0:0:0:0: +232,126,26423,1,0,0:0:0:0: +200,162,26498,5,0,0:0:0:0: +149,174,26573,1,0,0:0:0:0: +100,154,26648,1,0,0:0:0:0: +72,110,26723,1,0,0:0:0:0: +75,58,26798,1,0,0:0:0:0: +107,17,26873,1,0,0:0:0:0: +156,2,26948,5,0,0:0:0:0: +202,17,27023,1,0,0:0:0:0: +231,56,27098,1,0,0:0:0:0: +233,105,27173,1,0,0:0:0:0: +207,147,27248,1,0,0:0:0:0: +187,191,27323,1,0,0:0:0:0: +197,239,27398,5,0,0:0:0:0: +225,268,27473,1,0,0:0:0:0: +271,279,27548,1,0,0:0:0:0: +312,260,27623,1,0,0:0:0:0: +336,221,27698,5,0,0:0:0:0: +294,183,27773,1,0,0:0:0:0: +240,176,27848,1,0,0:0:0:0: +190,200,27923,1,0,0:0:0:0: +144,231,27998,1,0,0:0:0:0: +89,227,28073,1,0,0:0:0:0: +45,194,28148,1,0,0:0:0:0: +29,141,28223,1,0,0:0:0:0: +48,86,28298,5,0,0:0:0:0: +98,54,28373,1,0,0:0:0:0: +155,56,28448,1,0,0:0:0:0: +199,92,28523,1,0,0:0:0:0: +218,147,28598,1,0,0:0:0:0: +237,202,28673,1,0,0:0:0:0: +281,238,28748,1,0,0:0:0:0: +338,240,28823,1,0,0:0:0:0: +388,208,28898,5,0,0:0:0:0: +360,171,28973,1,0,0:0:0:0: +321,150,29048,1,0,0:0:0:0: +276,148,29123,1,0,0:0:0:0: +234,165,29198,1,0,0:0:0:0: +203,198,29273,1,0,0:0:0:0: +189,240,29348,1,0,0:0:0:0: +195,289,29423,1,0,0:0:0:0: +220,330,29498,5,0,0:0:0:0: +257,354,29573,1,0,0:0:0:0: +301,359,29648,1,0,0:0:0:0: +343,346,29723,1,0,0:0:0:0: +377,317,29798,1,0,0:0:0:0: +394,268,29873,1,0,0:0:0:0: +386,216,29948,1,0,0:0:0:0: +354,175,30023,1,0,0:0:0:0: +307,154,30098,5,0,0:0:0:0: +269,124,30173,1,0,0:0:0:0: +262,76,30248,1,0,0:0:0:0: +289,37,30323,1,0,0:0:0:0: +336,27,30398,1,0,0:0:0:0: +377,53,30473,1,0,0:0:0:0: +398,98,30548,5,0,0:0:0:0: +400,150,30623,1,0,0:0:0:0: +385,200,30698,1,0,0:0:0:0: +354,242,30773,1,0,0:0:0:0: +309,272,30848,1,0,0:0:0:0: +259,284,30923,1,0,0:0:0:0: +206,279,30998,5,0,0:0:0:0: +153,253,31073,1,0,0:0:0:0: +113,212,31148,1,0,0:0:0:0: +88,160,31223,1,0,0:0:0:0: +77,104,31298,5,0,0:0:0:0: +139,86,31373,1,0,0:0:0:0: +201,102,31448,1,0,0:0:0:0: +248,147,31523,1,0,0:0:0:0: +267,212,31598,5,0,0:0:0:0: +286,274,31673,1,0,0:0:0:0: +334,321,31748,1,0,0:0:0:0: +397,336,31823,1,0,0:0:0:0: +457,310,31898,1,0,0:0:0:0: +488,253,31973,1,0,0:0:0:0: +479,189,32048,1,0,0:0:0:0: +432,143,32123,1,0,0:0:0:0: +367,136,32198,5,0,0:0:0:0: +311,172,32273,1,0,0:0:0:0: +244,204,32348,1,0,0:0:0:0: +175,191,32423,1,0,0:0:0:0: +129,139,32498,5,0,0:0:0:0: +124,128,32573,1,0,0:0:0:0: +119,117,32648,1,0,0:0:0:0: +114,106,32723,1,0,0:0:0:0: +109,95,32798,5,0,0:0:0:0: +166,63,32873,1,0,0:0:0:0: +232,63,32948,1,0,0:0:0:0: +288,97,33023,1,0,0:0:0:0: +320,155,33098,5,0,0:0:0:0: +318,225,33173,1,0,0:0:0:0: +282,286,33248,1,0,0:0:0:0: +220,321,33323,1,0,0:0:0:0: +149,320,33398,1,0,0:0:0:0: +86,282,33473,1,0,0:0:0:0: +52,219,33548,1,0,0:0:0:0: +54,146,33623,1,0,0:0:0:0: +91,84,33698,5,0,0:0:0:0: +126,112,33773,1,0,0:0:0:0: +145,153,33848,1,0,0:0:0:0: +148,197,33923,1,0,0:0:0:0: +140,242,33998,1,0,0:0:0:0: +151,286,34073,1,0,0:0:0:0: +181,321,34148,1,0,0:0:0:0: +221,340,34223,1,0,0:0:0:0: +266,341,34298,5,0,0:0:0:0: +313,318,34373,1,0,0:0:0:0: +348,280,34448,1,0,0:0:0:0: +359,231,34523,1,0,0:0:0:0: +350,182,34598,1,0,0:0:0:0: +322,139,34673,1,0,0:0:0:0: +277,104,34748,1,0,0:0:0:0: +224,87,34823,1,0,0:0:0:0: +172,98,34898,5,0,0:0:0:0: +184,158,34973,1,0,0:0:0:0: +227,202,35048,1,0,0:0:0:0: +288,215,35123,1,0,0:0:0:0: +346,192,35198,1,0,0:0:0:0: +407,182,35273,1,0,0:0:0:0: +461,212,35348,1,0,0:0:0:0: +485,269,35423,1,0,0:0:0:0: +470,329,35498,5,0,0:0:0:0: +421,367,35573,1,0,0:0:0:0: +359,367,35648,1,0,0:0:0:0: +310,330,35723,1,0,0:0:0:0: +294,270,35798,5,0,0:0:0:0: +313,208,35873,1,0,0:0:0:0: +348,150,35948,1,0,0:0:0:0: +337,84,36023,1,0,0:0:0:0: +296,32,36098,5,0,0:0:0:0: +225,24,36173,1,0,0:0:0:0: +169,68,36248,1,0,0:0:0:0: +158,138,36323,1,0,0:0:0:0: +199,196,36398,1,0,0:0:0:0: +269,209,36473,1,0,0:0:0:0: +342,209,36548,1,0,0:0:0:0: +407,237,36623,1,0,0:0:0:0: +431,306,36698,5,0,0:0:0:0: +395,369,36773,1,0,0:0:0:0: +323,384,36848,1,0,0:0:0:0: +264,341,36923,1,0,0:0:0:0: +244,271,36998,1,0,0:0:0:0: +197,209,37073,1,0,0:0:0:0: +121,195,37148,1,0,0:0:0:0: +52,234,37223,1,0,0:0:0:0: +26,308,37298,5,0,0:0:0:0: +96,325,37373,1,0,0:0:0:0: +165,301,37448,1,0,0:0:0:0: +210,244,37523,1,0,0:0:0:0: +217,172,37598,1,0,0:0:0:0: +261,116,37673,1,0,0:0:0:0: +332,104,37748,1,0,0:0:0:0: +393,142,37823,1,0,0:0:0:0: +412,211,37898,5,0,0:0:0:0: +381,275,37973,1,0,0:0:0:0: +314,302,38048,1,0,0:0:0:0: +247,277,38123,1,0,0:0:0:0: +209,217,38198,5,0,0:0:0:0: +198,201,38273,1,0,0:0:0:0: +190,190,38348,1,0,0:0:0:0: +180,176,38423,1,0,0:0:0:0: +169,160,38498,5,0,0:0:0:0: +215,99,38573,1,0,0:0:0:0: +286,70,38648,1,0,0:0:0:0: +361,79,38723,1,0,0:0:0:0: +422,124,38798,1,0,0:0:0:0: +454,193,38873,1,0,0:0:0:0: +447,269,38948,1,0,0:0:0:0: +404,332,39023,1,0,0:0:0:0: +335,365,39098,5,0,0:0:0:0: +259,360,39173,1,0,0:0:0:0: +195,320,39248,1,0,0:0:0:0: +183,246,39323,1,0,0:0:0:0: +229,188,39398,1,0,0:0:0:0: +304,183,39473,1,0,0:0:0:0: +357,236,39548,1,0,0:0:0:0: +426,258,39623,1,0,0:0:0:0: +491,221,39698,5,0,0:0:0:0: +506,148,39773,1,0,0:0:0:0: +461,89,39848,1,0,0:0:0:0: +388,81,39923,1,0,0:0:0:0: +335,133,39998,1,0,0:0:0:0: +302,196,40073,1,0,0:0:0:0: +244,245,40148,1,0,0:0:0:0: +167,244,40223,1,0,0:0:0:0: +118,184,40298,5,0,0:0:0:0: +129,106,40373,1,0,0:0:0:0: +195,65,40448,1,0,0:0:0:0: +270,88,40523,1,0,0:0:0:0: +301,160,40598,1,0,0:0:0:0: +268,230,40673,1,0,0:0:0:0: +193,251,40748,1,0,0:0:0:0: +127,209,40823,1,0,0:0:0:0: +95,142,40898,5,0,0:0:0:0: +142,92,40973,1,0,0:0:0:0: +210,80,41048,1,0,0:0:0:0: +272,110,41123,1,0,0:0:0:0: +304,171,41198,1,0,0:0:0:0: +301,244,41273,1,0,0:0:0:0: +316,310,41348,1,0,0:0:0:0: +369,353,41423,1,0,0:0:0:0: +438,354,41498,5,0,0:0:0:0: +492,311,41573,1,0,0:0:0:0: +507,244,41648,1,0,0:0:0:0: +476,182,41723,1,0,0:0:0:0: +414,153,41798,1,0,0:0:0:0: +347,167,41873,1,0,0:0:0:0: +304,220,41948,1,0,0:0:0:0: +302,289,42023,1,0,0:0:0:0: +343,344,42098,5,0,0:0:0:0: +391,285,42173,1,0,0:0:0:0: +392,209,42248,1,0,0:0:0:0: +349,149,42323,1,0,0:0:0:0: +276,124,42398,1,0,0:0:0:0: +205,152,42473,1,0,0:0:0:0: +131,158,42548,1,0,0:0:0:0: +83,104,42623,1,0,0:0:0:0: +102,39,42698,5,0,0:0:0:0: +171,21,42773,1,0,0:0:0:0: +220,70,42848,1,0,0:0:0:0: +238,142,42923,1,0,0:0:0:0: +282,195,42998,1,0,0:0:0:0: +349,209,43073,1,0,0:0:0:0: +411,180,43148,1,0,0:0:0:0: +441,118,43223,1,0,0:0:0:0: +427,49,43298,5,0,0:0:0:0: +350,58,43373,1,0,0:0:0:0: +294,102,43448,1,0,0:0:0:0: +272,173,43523,1,0,0:0:0:0: +293,243,43598,1,0,0:0:0:0: +280,316,43673,1,0,0:0:0:0: +224,364,43748,1,0,0:0:0:0: +151,365,43823,1,0,0:0:0:0: +96,317,43898,5,0,0:0:0:0: +80,245,43973,1,0,0:0:0:0: +118,182,44048,1,0,0:0:0:0: +185,156,44123,1,0,0:0:0:0: +255,179,44198,1,0,0:0:0:0: +293,242,44273,1,0,0:0:0:0: +358,281,44348,1,0,0:0:0:0: +428,267,44423,1,0,0:0:0:0: +477,210,44498,1,0,0:0:0:0: +477,136,44573,1,0,0:0:0:0: +438,77,44648,5,0,0:0:0:0: +356,104,44723,1,0,0:0:0:0: +313,177,44798,1,0,0:0:0:0: +259,248,44873,1,0,0:0:0:0: +183,299,44948,1,0,0:0:0:0: +94,289,45023,1,0,0:0:0:0: +34,227,45098,5,0,0:0:0:0: +26,144,45173,1,0,0:0:0:0: +72,73,45248,1,0,0:0:0:0: +156,52,45323,1,0,0:0:0:0: +231,87,45398,1,0,0:0:0:0: +289,141,45473,1,0,0:0:0:0: +375,143,45548,1,0,0:0:0:0: +440,87,45623,1,0,0:0:0:0: +448,11,45698,5,0,0:0:0:0: +366,20,45773,1,0,0:0:0:0: +311,81,45848,1,0,0:0:0:0: +308,161,45923,1,0,0:0:0:0: +357,225,45998,1,0,0:0:0:0: +383,302,46073,1,0,0:0:0:0: +339,371,46148,5,0,0:0:0:0: +260,381,46223,1,0,0:0:0:0: +200,325,46298,1,0,0:0:0:0: +205,245,46373,1,0,0:0:0:0: +190,165,46448,1,0,0:0:0:0: +119,128,46523,1,0,0:0:0:0: +45,157,46598,5,0,0:0:0:0: +22,234,46673,1,0,0:0:0:0: +65,301,46748,1,0,0:0:0:0: +145,309,46823,1,0,0:0:0:0: +201,252,46898,1,0,0:0:0:0: +251,188,46973,1,0,0:0:0:0: +321,149,47048,1,0,0:0:0:0: +396,176,47123,1,0,0:0:0:0: +422,256,47198,5,0,0:0:0:0: +342,240,47273,1,0,0:0:0:0: +288,175,47348,1,0,0:0:0:0: +228,116,47423,1,0,0:0:0:0: +145,81,47498,1,0,0:0:0:0: +60,103,47573,1,0,0:0:0:0: +16,175,47648,5,0,0:0:0:0: +23,256,47723,1,0,0:0:0:0: +77,318,47798,1,0,0:0:0:0: +159,322,47873,1,0,0:0:0:0: +221,269,47948,1,0,0:0:0:0: +235,189,48023,1,0,0:0:0:0: +223,115,48098,5,0,0:0:0:0: +150,142,48173,1,0,0:0:0:0: +105,206,48248,1,0,0:0:0:0: +107,282,48323,1,0,0:0:0:0: +152,344,48398,1,0,0:0:0:0: +224,370,48473,1,0,0:0:0:0: +299,347,48548,1,0,0:0:0:0: +346,286,48623,1,0,0:0:0:0: +349,209,48698,5,0,0:0:0:0: +393,147,48773,1,0,0:0:0:0: +469,149,48848,1,0,0:0:0:0: +512,212,48923,1,0,0:0:0:0: +484,283,48998,1,0,0:0:0:0: +410,301,49073,1,0,0:0:0:0: +352,254,49148,1,0,0:0:0:0: +354,179,49223,1,0,0:0:0:0: +337,107,49298,1,0,0:0:0:0: +267,79,49373,1,0,0:0:0:0: +201,117,49448,1,0,0:0:0:0: +195,191,49523,1,0,0:0:0:0: +255,241,49598,5,0,0:0:0:0: +288,169,49673,1,0,0:0:0:0: +276,91,49748,1,0,0:0:0:0: +229,29,49823,1,0,0:0:0:0: +153,4,49898,1,0,0:0:0:0: +71,22,49973,1,0,0:0:0:0: +17,81,50048,1,0,0:0:0:0: +0,162,50123,1,0,0:0:0:0: +32,241,50198,1,0,0:0:0:0: +99,294,50273,1,0,0:0:0:0: +183,307,50348,1,0,0:0:0:0: +257,267,50423,1,0,0:0:0:0: +298,192,50498,5,0,0:0:0:0: +218,184,50573,1,0,0:0:0:0: +168,242,50648,1,0,0:0:0:0: +186,317,50723,1,0,0:0:0:0: +256,350,50798,5,0,0:0:0:0: +325,304,50873,1,0,0:0:0:0: +346,225,50948,1,0,0:0:0:0: +306,153,51023,1,0,0:0:0:0: +228,130,51098,5,0,0:0:0:0: +150,168,51173,1,0,0:0:0:0: +118,246,51248,1,0,0:0:0:0: +143,327,51323,1,0,0:0:0:0: +215,375,51398,5,0,0:0:0:0: +302,363,51473,1,0,0:0:0:0: +357,298,51548,1,0,0:0:0:0: +358,209,51623,1,0,0:0:0:0: +305,137,51698,5,0,0:0:0:0: +217,111,51773,1,0,0:0:0:0: +134,144,51848,1,0,0:0:0:0: +90,221,51923,1,0,0:0:0:0: +99,309,51998,5,0,0:0:0:0: +163,247,52073,1,0,0:0:0:0: +177,158,52148,1,0,0:0:0:0: +200,67,52223,1,0,0:0:0:0: +278,8,52298,1,0,0:0:0:0: +378,11,52373,1,0,0:0:0:0: +451,78,52448,1,0,0:0:0:0: +466,173,52523,1,0,0:0:0:0: +417,260,52598,1,0,0:0:0:0: +327,295,52673,1,0,0:0:0:0: +230,280,52748,1,0,0:0:0:0: +169,207,52823,1,0,0:0:0:0: +154,121,52898,6,0,L|150:77,1,35,0|0,0:0|0:0,0:0:0:0: +366,205,53198,6,0,L|339:162,1,35,0|0,0:0|0:0,0:0:0:0: +225,0,53348,2,0,L|252:43,1,35,0|0,0:0|0:0,0:0:0:0: +315,210,53498,2,0,L|293:175,1,35,0|0,0:0|0:0,0:0:0:0: +198,40,53648,2,0,L|219:74,1,35,0|0,0:0|0:0,0:0:0:0: +235,256,53798,5,0,0:0:0:0: +235,256,53873,1,0,0:0:0:0: +235,256,53948,1,0,0:0:0:0: +235,256,54023,1,0,0:0:0:0: +235,256,54098,2,0,L|256:290,1,35,0|0,0:0|0:0,0:0:0:0: +398,300,54398,6,0,L|355:272,1,35,0|0,0:0|0:0,0:0:0:0: +213,189,54548,2,0,L|255:216,1,35,0|0,0:0|0:0,0:0:0:0: +353,326,54698,2,0,L|318:303,1,35,0|0,0:0|0:0,0:0:0:0: +205,237,54848,2,0,L|239:259,1,35,0|0,0:0|0:0,0:0:0:0: +313,384,54998,5,0,0:0:0:0: +313,384,55073,1,0,0:0:0:0: +313,384,55148,1,0,0:0:0:0: +313,384,55223,1,0,0:0:0:0: +313,384,55298,2,0,L|278:361,1,35,0|0,0:0|0:0,0:0:0:0: +101,345,55598,6,0,L|114:312,1,35,0|0,0:0|0:0,0:0:0:0: +220,77,55748,2,0,L|206:109,1,35,0|0,0:0|0:0,0:0:0:0: +171,338,55898,2,0,L|184:305,1,35 +266,124,56048,2,0,L|252:156,1,35 +252,383,56198,5,0,0:0:0:0: +260,363,56273,1,0,0:0:0:0: +269,344,56348,1,0,0:0:0:0: +278,325,56423,1,0,0:0:0:0: +285,310,56498,5,0,0:0:0:0: +236,310,56573,1,0,0:0:0:0: +195,294,56648,1,0,0:0:0:0: +157,270,56723,1,0,0:0:0:0: +129,239,56798,1,0,0:0:0:0: +111,203,56873,1,0,0:0:0:0: +107,165,56948,1,0,0:0:0:0: +111,134,57023,1,0,0:0:0:0: +123,105,57098,5,0,0:0:0:0: +140,86,57173,1,0,0:0:0:0: +156,75,57248,1,0,0:0:0:0: +173,70,57323,1,0,0:0:0:0: +185,72,57398,1,0,0:0:0:0: +195,75,57473,1,0,0:0:0:0: +202,81,57548,1,0,0:0:0:0: +207,87,57623,1,0,0:0:0:0: +210,93,57698,6,0,L|227:131,1,35,0|0,0:0|0:0,0:0:0:0: +356,245,57998,6,0,L|293:236,1,35,0|0,0:0|0:0,0:0:0:0: +129,245,58148,2,0,L|192:236,1,35,0|0,0:0|0:0,0:0:0:0: +413,186,58298,2,0,L|358:179,1,35,0|0,0:0|0:0,0:0:0:0: +76,186,58448,2,0,L|139:177,1,35,0|0,0:0|0:0,0:0:0:0: +461,127,58598,5,0,0:0:0:0: +461,127,58673,1,0,0:0:0:0: +461,127,58748,1,0,0:0:0:0: +461,127,58823,1,0,0:0:0:0: +461,127,58898,2,0,L|398:117,1,35,0|0,0:0|0:0,0:0:0:0: +274,296,59198,6,0,L|284:230,1,35,0|0,0:0|0:0,0:0:0:0: +274,55,59348,2,0,L|284:122,1,35,0|0,0:0|0:0,0:0:0:0: +335,356,59498,2,0,L|343:298,1,35,0|0,0:0|0:0,0:0:0:0: +335,1,59648,2,0,L|346:67,1,35,0|0,0:0|0:0,0:0:0:0: +425,384,59798,5,0,0:0:0:0: +425,384,59873,1,0,0:0:0:0: +425,384,59948,1,0,0:0:0:0: +425,384,60023,1,0,0:0:0:0: +425,384,60098,1,0,0:0:0:0: +425,384,60173,1,0,0:0:0:0: +425,384,60248,6,0,L|434:321,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +504,332,60548,2,0,L|494:269,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +439,257,60848,2,0,L|448:194,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +512,197,61148,2,0,L|502:134,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +446,122,61448,5,0,0:0:0:0: +418,124,61523,1,0,0:0:0:0: +389,130,61598,1,0,0:0:0:0: +361,141,61673,1,0,0:0:0:0: +332,157,61748,1,0,0:0:0:0: +306,176,61823,1,0,0:0:0:0: +281,204,61898,1,0,0:0:0:0: +257,233,61973,1,0,0:0:0:0: +240,269,62048,5,0,0:0:0:0: +219,304,62123,1,0,0:0:0:0: +180,333,62198,1,0,0:0:0:0: +131,341,62273,1,0,0:0:0:0: +79,324,62348,1,0,0:0:0:0: +41,278,62423,1,0,0:0:0:0: +25,218,62498,6,0,L|16:176,1,35,0|0,0:0|0:0,0:0:0:0: +68,9,62798,6,0,L|79:58,1,35,0|0,0:0|0:0,0:0:0:0: +143,287,62948,2,0,L|131:237,1,35,0|0,0:0|0:0,0:0:0:0: +117,20,63098,2,0,L|126:60,1,35,0|0,0:0|0:0,0:0:0:0: +181,257,63248,2,0,L|172:218,1,35,0|0,0:0|0:0,0:0:0:0: +207,20,63398,5,0,0:0:0:0: +207,20,63473,1,0,0:0:0:0: +207,20,63548,1,0,0:0:0:0: +207,20,63623,1,0,0:0:0:0: +207,20,63698,2,0,L|216:60,1,35,0|0,0:0|0:0,0:0:0:0: +401,88,63998,6,0,L|356:97,1,35,0|0,0:0|0:0,0:0:0:0: +151,155,64148,2,0,L|195:144,1,35,0|0,0:0|0:0,0:0:0:0: +391,132,64298,2,0,L|355:140,1,35,0|0,0:0|0:0,0:0:0:0: +178,189,64448,2,0,L|213:180,1,35,0|0,0:0|0:0,0:0:0:0: +396,189,64598,5,0,0:0:0:0: +396,189,64673,1,0,0:0:0:0: +396,189,64748,1,0,0:0:0:0: +396,189,64823,1,0,0:0:0:0: +396,189,64898,2,0,L|356:198,1,35,0|0,0:0|0:0,0:0:0:0: +162,114,65198,6,0,L|187:162,1,35,0|0,0:0|0:0,0:0:0:0: +317,384,65348,2,0,L|290:335,1,35,0|0,0:0|0:0,0:0:0:0: +216,111,65498,2,0,L|236:150,1,35,0|0,0:0|0:0,0:0:0:0: +348,341,65648,2,0,L|327:302,1,35,0|0,0:0|0:0,0:0:0:0: +278,118,65798,5,0,0:0:0:0: +278,118,65873,1,0,0:0:0:0: +278,118,65948,1,0,0:0:0:0: +278,118,66023,1,0,0:0:0:0: +278,118,66098,5,0,0:0:0:0: +297,172,66173,1,0,0:0:0:0: +290,228,66248,1,0,0:0:0:0: +258,273,66323,1,0,0:0:0:0: +213,296,66398,1,0,0:0:0:0: +168,296,66473,1,0,0:0:0:0: +132,279,66548,1,0,0:0:0:0: +109,251,66623,1,0,0:0:0:0: +103,222,66698,5,0,0:0:0:0: +110,199,66773,1,0,0:0:0:0: +122,184,66848,1,0,0:0:0:0: +136,180,66923,1,0,0:0:0:0: +146,180,66998,1,0,0:0:0:0: +152,184,67073,1,0,0:0:0:0: +155,190,67148,1,0,0:0:0:0: +158,196,67223,1,0,0:0:0:0: +159,202,67298,6,0,L|152:246,1,35,0|0,0:0|0:0,0:0:0:0: +297,352,67598,6,0,L|248:341,1,35,0|0,0:0|0:0,0:0:0:0: +0,275,67748,2,0,L|50:287,1,35,0|0,0:0|0:0,0:0:0:0: +286,303,67898,2,0,L|246:294,1,35,0|0,0:0|0:0,0:0:0:0: +30,237,68048,2,0,L|69:246,1,35,0|0,0:0|0:0,0:0:0:0: +286,213,68198,5,0,0:0:0:0: +286,213,68273,1,0,0:0:0:0: +286,213,68348,1,0,0:0:0:0: +286,213,68423,1,0,0:0:0:0: +286,213,68498,2,0,L|246:204,1,35,0|0,0:0|0:0,0:0:0:0: +53,10,68798,6,0,L|66:71,1,35,0|0,0:0|0:0,0:0:0:0: +147,358,68948,2,0,L|131:295,1,35,0|0,0:0|0:0,0:0:0:0: +114,23,69098,2,0,L|125:73,1,35,0|0,0:0|0:0,0:0:0:0: +194,321,69248,2,0,L|182:272,1,35,0|0,0:0|0:0,0:0:0:0: +227,23,69398,5,0,0:0:0:0: +227,23,69473,1,0,0:0:0:0: +227,23,69548,1,0,0:0:0:0: +227,23,69623,1,0,0:0:0:0: +227,23,69698,1,0,0:0:0:0: +227,23,69773,1,0,0:0:0:0: +227,23,69848,6,0,L|240:65,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +310,89,70148,2,0,L|321:123,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +266,176,70448,2,0,L|279:218,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +349,242,70748,2,0,L|359:275,1,35,0|0,0:0|0:0,0:0:0:0: +304,341,70898,5,0,0:0:0:0: +278,368,70973,1,0,0:0:0:0: +242,383,71048,1,0,0:0:0:0: +202,382,71123,1,0,0:0:0:0: +167,364,71198,1,0,0:0:0:0: +144,333,71273,1,0,0:0:0:0: +135,290,71348,1,0,0:0:0:0: +147,250,71423,1,0,0:0:0:0: +176,219,71498,5,0,0:0:0:0: +209,189,71573,1,0,0:0:0:0: +226,151,71648,1,0,0:0:0:0: +222,105,71723,1,0,0:0:0:0: +201,61,71798,1,0,0:0:0:0: +158,30,71873,1,0,0:0:0:0: +105,24,71948,1,0,0:0:0:0: +46,39,72023,1,0,0:0:0:0: +0,84,72098,5,0,3:0:0:0: +65,123,72173,1,0,3:0:0:0: +140,141,72248,1,0,3:0:0:0: +218,129,72323,1,0,3:0:0:0: +287,94,72398,1,0,3:0:0:0: +365,107,72473,1,0,3:0:0:0: +418,164,72548,5,0,3:0:0:0: +428,241,72623,1,0,3:0:0:0: +390,308,72698,1,0,3:0:0:0: +321,339,72773,1,0,3:0:0:0: +243,326,72848,1,0,3:0:0:0: +195,266,72923,1,0,3:0:0:0: +196,188,72998,5,0,3:0:0:0: +246,130,73073,1,0,0:0:0:0: +324,120,73148,1,0,0:0:0:0: +388,161,73223,1,0,0:0:0:0: +410,235,73298,1,0,0:0:0:0: +380,306,73373,1,0,0:0:0:0: +307,341,73448,5,0,0:0:0:0: +288,265,73523,1,0,0:0:0:0: +344,209,73598,1,0,0:0:0:0: +422,227,73673,1,0,0:0:0:0: +497,195,73748,1,0,0:0:0:0: +511,111,73823,1,0,0:0:0:0: +449,56,73898,1,0,0:0:0:0: +368,69,73973,1,0,0:0:0:0: +318,136,74048,1,0,0:0:0:0: +258,202,74123,1,0,3:0:0:0: +167,212,74198,1,0,0:0:0:0: +107,139,74273,1,0,0:0:0:0: +138,48,74348,1,0,0:0:0:0: +234,23,74423,1,0,0:0:0:0: +303,89,74498,5,0,0:0:0:0: +239,136,74573,1,0,0:0:0:0: +162,122,74648,1,0,0:0:0:0: +83,134,74723,1,0,0:0:0:0: +32,197,74798,1,0,0:0:0:0: +36,275,74873,1,0,0:0:0:0: +92,330,74948,5,0,0:0:0:0: +170,338,75023,1,0,0:0:0:0: +235,292,75098,1,0,0:0:0:0: +257,215,75173,1,0,0:0:0:0: +276,139,75248,1,0,0:0:0:0: +320,71,75323,1,0,0:0:0:0: +396,51,75398,5,0,0:0:0:0: +467,84,75473,1,0,0:0:0:0: +502,156,75548,1,0,0:0:0:0: +482,233,75623,1,0,0:0:0:0: +418,280,75698,1,0,0:0:0:0: +339,276,75773,1,0,0:0:0:0: +280,223,75848,1,0,0:0:0:0: +269,145,75923,1,0,0:0:0:0: +311,75,75998,5,0,3:0:0:0: +345,143,76073,1,0,3:0:0:0: +307,214,76148,1,0,3:0:0:0: +225,219,76223,1,0,0:0:0:0: +176,241,76298,5,0,0:0:0:0: +150,288,76373,1,0,0:0:0:0: +158,341,76448,1,0,0:0:0:0: +191,376,76523,1,0,0:0:0:0: +238,383,76598,1,0,0:0:0:0: +277,363,76673,1,0,0:0:0:0: +294,326,76748,1,0,0:0:0:0: +287,293,76823,1,0,0:0:0:0: +269,254,76898,5,0,3:0:0:0: +248,172,76973,1,0,3:0:0:0: +287,104,77048,1,0,3:0:0:0: +361,72,77123,1,0,3:0:0:0: +436,97,77198,1,0,3:0:0:0: +479,163,77273,1,0,3:0:0:0: +468,242,77348,5,0,3:0:0:0: +409,296,77423,1,0,3:0:0:0: +331,300,77498,1,0,3:0:0:0: +267,253,77573,1,0,3:0:0:0: +248,174,77648,1,0,3:0:0:0: +212,104,77723,1,0,3:0:0:0: +137,74,77798,5,0,3:0:0:0: +63,99,77873,1,0,3:0:0:0: +20,164,77948,1,0,3:0:0:0: +31,244,78023,1,0,3:0:0:0: +92,296,78098,1,0,3:0:0:0: +170,297,78173,1,0,3:0:0:0: +231,247,78248,1,0,3:0:0:0: +244,167,78323,1,0,3:0:0:0: +206,96,78398,5,0,3:0:0:0: +147,151,78473,1,0,3:0:0:0: +134,232,78548,1,0,3:0:0:0: +176,303,78623,1,0,3:0:0:0: +256,336,78698,1,0,3:0:0:0: +336,311,78773,1,0,3:0:0:0: +385,238,78848,1,0,3:0:0:0: +381,150,78923,1,0,3:0:0:0: +318,80,78998,1,0,3:0:0:0: +223,58,79073,1,0,3:0:0:0: +147,113,79148,1,0,3:0:0:0: +129,212,79223,1,0,3:0:0:0: +187,283,79298,5,0,3:0:0:0: +228,213,79373,1,0,3:0:0:0: +204,132,79448,1,0,3:0:0:0: +131,97,79523,1,0,3:0:0:0: +55,126,79598,5,0,3:0:0:0: +25,206,79673,1,0,3:0:0:0: +54,285,79748,1,0,3:0:0:0: +126,329,79823,1,0,3:0:0:0: +210,317,79898,5,0,3:0:0:0: +269,250,79973,1,0,3:0:0:0: +273,159,80048,1,0,3:0:0:0: +221,86,80123,1,0,3:0:0:0: +134,61,80198,5,0,3:0:0:0: +50,98,80273,1,0,3:0:0:0: +4,175,80348,1,0,3:0:0:0: +17,268,80423,1,0,3:0:0:0: +82,334,80498,5,0,3:0:0:0: +176,344,80573,1,0,3:0:0:0: +264,306,80648,1,0,3:0:0:0: +316,226,80723,1,0,3:0:0:0: +317,132,80798,5,0,3:0:0:0: +228,169,80873,1,0,3:0:0:0: +201,262,80948,1,0,3:0:0:0: +259,341,81023,1,0,3:0:0:0: +354,349,81098,5,0,3:0:0:0: +425,275,81173,1,0,3:0:0:0: +430,174,81248,1,0,3:0:0:0: +360,103,81323,1,0,3:0:0:0: +265,108,81398,5,0,3:0:0:0: +191,184,81473,1,0,3:0:0:0: +199,291,81548,1,0,3:0:0:0: +276,370,81623,1,0,3:0:0:0: +256,192,81698,12,0,83948,0:0:0:0: +467,368,84098,5,0,3:0:0:0: +436,336,84173,1,0,3:0:0:0: +403,316,84248,1,0,3:0:0:0: +363,310,84323,1,0,3:0:0:0: +325,318,84398,1,0,3:0:0:0: +289,339,84473,1,0,3:0:0:0: +260,362,84548,1,0,3:0:0:0: +222,376,84623,1,0,3:0:0:0: +178,374,84698,5,0,3:0:0:0: +140,356,84773,1,0,3:0:0:0: +102,325,84848,1,0,3:0:0:0: +85,284,84923,1,0,3:0:0:0: +86,239,84998,1,0,3:0:0:0: +104,199,85073,1,0,3:0:0:0: +138,174,85148,1,0,3:0:0:0: +173,167,85223,1,0,3:0:0:0: +208,175,85298,5,0,3:0:0:0: +203,218,85373,1,0,3:0:0:0: +182,254,85448,1,0,3:0:0:0: +147,279,85523,1,0,3:0:0:0: +104,287,85598,5,0,3:0:0:0: +57,274,85673,1,0,3:0:0:0: +21,243,85748,1,0,3:0:0:0: +1,202,85823,1,0,3:0:0:0: +1,155,85898,1,0,3:0:0:0: +20,111,85973,1,0,3:0:0:0: +55,82,86048,1,0,3:0:0:0: +101,67,86123,1,0,3:0:0:0: +148,76,86198,5,0,3:0:0:0: +190,93,86273,1,0,3:0:0:0: +237,94,86348,1,0,3:0:0:0: +281,70,86423,1,0,3:0:0:0: +313,31,86498,5,0,3:0:0:0: +266,4,86573,1,0,3:0:0:0: +212,0,86648,1,0,3:0:0:0: +160,12,86723,1,0,3:0:0:0: +117,50,86798,1,0,3:0:0:0: +92,99,86873,1,0,3:0:0:0: +89,153,86948,1,0,3:0:0:0: +109,204,87023,1,0,3:0:0:0: +148,243,87098,1,0,3:0:0:0: +196,263,87173,1,0,3:0:0:0: +253,263,87248,1,0,3:0:0:0: +302,242,87323,1,0,3:0:0:0: +346,212,87398,5,0,3:0:0:0: +399,203,87473,1,0,3:0:0:0: +450,214,87548,1,0,3:0:0:0: +490,243,87623,1,0,3:0:0:0: +510,283,87698,5,0,0:0:0:0: +457,320,87773,1,0,0:0:0:0: +393,319,87848,1,0,0:0:0:0: +345,278,87923,1,0,0:0:0:0: +332,216,87998,1,0,0:0:0:0: +361,159,88073,1,0,0:0:0:0: +369,95,88148,1,0,0:0:0:0: +341,39,88223,1,0,0:0:0:0: +285,7,88298,1,0,0:0:0:0: +221,15,88373,1,0,0:0:0:0: +175,60,88448,5,0,0:0:0:0: +160,125,88523,1,0,0:0:0:0: +187,185,88598,1,0,0:0:0:0: +243,220,88673,1,0,0:0:0:0: +311,215,88748,1,0,0:0:0:0: +362,171,88823,1,0,0:0:0:0: +379,107,88898,5,0,0:0:0:0: +310,110,88973,1,0,0:0:0:0: +267,166,89048,1,0,0:0:0:0: +212,215,89123,1,0,3:0:0:0: +144,240,89198,1,0,3:0:0:0: +70,217,89273,1,0,3:0:0:0: +27,157,89348,1,0,3:0:0:0: +34,82,89423,1,0,0:0:0:0: +84,22,89498,5,0,0:0:0:0: +162,13,89573,1,0,0:0:0:0: +226,55,89648,1,0,0:0:0:0: +251,128,89723,1,0,0:0:0:0: +299,191,89798,1,0,3:0:0:0: +373,208,89873,1,0,3:0:0:0: +449,175,89948,1,0,3:0:0:0: +484,99,90023,1,0,3:0:0:0: +486,21,90098,5,0,0:0:0:0: +413,40,90173,1,0,0:0:0:0: +365,96,90248,1,0,0:0:0:0: +355,168,90323,1,0,0:0:0:0: +389,233,90398,1,0,0:0:0:0: +406,307,90473,1,0,0:0:0:0: +362,369,90548,1,0,0:0:0:0: +289,385,90623,1,0,0:0:0:0: +226,339,90698,5,0,0:0:0:0: +217,263,90773,1,0,0:0:0:0: +264,204,90848,1,0,0:0:0:0: +296,134,90923,1,0,0:0:0:0: +265,66,90998,1,0,0:0:0:0: +194,37,91073,1,0,0:0:0:0: +125,69,91148,1,0,0:0:0:0: +99,140,91223,1,0,0:0:0:0: +136,209,91298,5,0,0:0:0:0: +183,154,91373,1,0,0:0:0:0: +180,83,91448,1,0,0:0:0:0: +126,36,91523,1,0,0:0:0:0: +55,41,91598,1,0,0:0:0:0: +8,97,91673,1,0,0:0:0:0: +16,168,91748,1,0,0:0:0:0: +74,213,91823,1,0,0:0:0:0: +146,202,91898,5,0,0:0:0:0: +187,142,91973,1,0,0:0:0:0: +243,99,92048,1,0,0:0:0:0: +314,112,92123,1,0,0:0:0:0: +356,169,92198,1,0,0:0:0:0: +342,241,92273,1,0,0:0:0:0: +281,278,92348,1,0,0:0:0:0: +211,262,92423,1,0,0:0:0:0: +169,202,92498,5,0,0:0:0:0: +229,157,92573,1,0,0:0:0:0: +306,162,92648,1,0,0:0:0:0: +358,217,92723,1,0,0:0:0:0: +362,292,92798,1,0,0:0:0:0: +315,353,92873,1,0,0:0:0:0: +241,366,92948,1,0,0:0:0:0: +175,329,93023,1,0,0:0:0:0: +151,256,93098,5,0,0:0:0:0: +182,186,93173,1,0,0:0:0:0: +251,153,93248,1,0,0:0:0:0: +323,174,93323,1,0,0:0:0:0: +388,213,93398,1,0,0:0:0:0: +462,199,93473,1,0,0:0:0:0: +510,138,93548,1,0,0:0:0:0: +510,63,93623,1,0,0:0:0:0: +457,0,93698,5,0,0:0:0:0: +432,69,93773,1,0,0:0:0:0: +445,142,93848,1,0,0:0:0:0: +478,211,93923,1,0,0:0:0:0: +485,283,93998,1,0,0:0:0:0: +442,347,94073,1,0,0:0:0:0: +369,370,94148,1,0,0:0:0:0: +300,347,94223,1,0,0:0:0:0: +259,282,94298,5,0,0:0:0:0: +267,206,94373,1,0,0:0:0:0: +252,131,94448,1,0,0:0:0:0: +193,81,94523,1,0,0:0:0:0: +118,78,94598,1,0,0:0:0:0: +55,124,94673,1,0,0:0:0:0: +38,198,94748,1,0,0:0:0:0: +72,268,94823,1,0,0:0:0:0: +146,301,94898,5,0,0:0:0:0: +219,278,94973,1,0,0:0:0:0: +266,215,95048,1,0,0:0:0:0: +283,134,95123,1,0,3:0:0:0: +317,61,95198,1,0,3:0:0:0: +384,25,95273,1,0,3:0:0:0: +459,43,95348,5,0,0:0:0:0: +431,119,95423,1,0,0:0:0:0: +372,167,95498,1,0,0:0:0:0: +298,176,95573,1,0,0:0:0:0: +226,146,95648,1,0,0:0:0:0: +184,89,95723,1,0,0:0:0:0: +114,81,95798,1,0,3:0:0:0: +47,107,95873,1,0,3:0:0:0: +9,164,95948,1,0,3:0:0:0: +6,234,96023,1,0,0:0:0:0: +328,304,96698,5,0,0:0:0:0: +304,298,96773,1,0,0:0:0:0: +280,293,96848,1,0,0:0:0:0: +256,287,96923,1,0,0:0:0:0: +232,282,96998,1,0,0:0:0:0: +145,249,97148,5,0,0:0:0:0: +169,244,97223,1,0,0:0:0:0: +256,192,97598,5,0,0:0:0:0: +osu file format v14 + +[General] +AudioFilename: audio.mp3 +AudioLeadIn: 0 +PreviewTime: -1 +Countdown: 1 +SampleSet: Normal +StackLeniency: 0.3 +Mode: 0 +LetterboxInBreaks: 0 +WidescreenStoryboard: 0 + +[Editor] +Bookmarks: 12098,13298,14498,22898,24098,25898,26948,28298,29498,32498,32798,33098,33398,33698,34298,36548,37898,38198,38498,40898,42098,42548,43298,44648,45098,45698,47198,47648,48098,49598,49898,50498,51998,52298,60098,69698,70898,72098,73448,74498,75998,76298,76898,78398,79598,79898,80198,80498,80798,84098,85898,86198,86498,87398,88448,88898,91298,92498,93698,94898,95348,96698,99998 +DistanceSpacing: 1 +BeatDivisor: 4 +GridSize: 8 +TimelineZoom: 1.1 + +[Metadata] +Title:Snow Goose +TitleUnicode:Snow Goose +Artist:Mutsuhiko Izumi +ArtistUnicode:Mutsuhiko Izumi +Creator:InnerSuffering +Version:Heavenly Hard +Source: +Tags: +BeatmapID:1856504 +BeatmapSetID:888152 + +[Difficulty] +HPDrainRate:6.5 +CircleSize:4.4 +OverallDifficulty:10 +ApproachRate:9.7 +SliderMultiplier:1.4 +SliderTickRate:1 + +[Events] +//Background and Video events +0,0,"ice-4320x2160-frost-blue-purple-neon-oneplus-5t-stock-4k-11234.jpg",0,0 +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Sound Samples + +[TimingPoints] +2498,300,4,2,1,50,1,0 +6023,-100,4,2,1,26,0,0 +7298,-100,4,1,1,26,0,0 +24098,-100,4,1,1,49,0,0 +33698,-100,4,1,1,49,0,0 +43298,-100,4,1,1,49,0,0 +52898,-200,4,1,1,70,0,1 +53198,-100,4,1,1,70,0,1 +54098,-200,4,1,1,70,0,1 +54398,-100,4,1,1,70,0,1 +55298,-200,4,1,1,70,0,1 +55598,-100,4,1,1,70,0,1 +57698,-200,4,1,1,70,0,1 +57998,-100,4,1,1,70,0,1 +58898,-200,4,1,1,70,0,1 +59198,-100,4,1,1,70,0,1 +62498,-200,4,1,1,70,0,1 +62798,-100,4,1,1,70,0,1 +63698,-200,4,1,1,70,0,1 +63998,-100,4,1,1,60,0,1 +64898,-200,4,1,1,70,0,1 +65198,-100,4,1,1,60,0,1 +67298,-200,4,1,1,70,0,1 +67598,-100,4,1,1,60,0,1 +68498,-200,4,1,1,70,0,1 +68798,-100,4,1,1,60,0,1 +72098,-100,4,1,1,60,0,0 +79298,-100,4,1,1,60,0,1 +81698,-100,4,1,1,60,0,0 +90098,-100,4,1,1,64,0,1 +96098,-100,4,1,1,25,0,0 + + +[Colours] +Combo1 : 40,148,255 +Combo2 : 20,96,222 +Combo3 : 184,225,237 +Combo4 : 87,197,255 + +[HitObjects] +453,85,2498,5,0,0:0:0:0: +430,100,2573,1,0,0:0:0:0: +403,108,2648,1,0,0:0:0:0: +375,108,2723,1,0,0:0:0:0: +349,101,2798,1,0,0:0:0:0: +319,90,2873,1,0,0:0:0:0: +287,89,2948,1,0,0:0:0:0: +258,97,3023,1,0,0:0:0:0: +233,112,3098,5,0,0:0:0:0: +205,148,3173,1,0,0:0:0:0: +196,192,3248,1,0,0:0:0:0: +207,236,3323,1,0,0:0:0:0: +236,270,3398,1,0,0:0:0:0: +277,289,3473,1,0,0:0:0:0: +317,288,3548,1,0,0:0:0:0: +352,270,3623,1,0,0:0:0:0: +369,249,3698,5,0,0:0:0:0: +360,211,3773,1,0,0:0:0:0: +338,180,3848,1,0,0:0:0:0: +307,158,3923,1,0,0:0:0:0: +269,149,3998,1,0,0:0:0:0: +223,155,4073,1,0,0:0:0:0: +183,178,4148,1,0,0:0:0:0: +146,205,4223,1,0,0:0:0:0: +100,215,4298,5,0,0:0:0:0: +53,203,4373,1,0,0:0:0:0: +19,174,4448,1,0,0:0:0:0: +0,131,4523,1,0,0:0:0:0: +4,92,4598,1,0,0:0:0:0: +22,59,4673,1,0,0:0:0:0: +52,33,4748,1,0,0:0:0:0: +91,21,4823,1,0,0:0:0:0: +133,25,4898,5,0,0:0:0:0: +173,46,4973,1,0,0:0:0:0: +201,82,5048,1,0,0:0:0:0: +214,127,5123,1,0,0:0:0:0: +209,174,5198,1,0,0:0:0:0: +193,227,5273,1,0,0:0:0:0: +205,278,5348,1,0,0:0:0:0: +239,321,5423,1,0,0:0:0:0: +280,338,5498,5,0,0:0:0:0: +324,338,5573,1,0,0:0:0:0: +367,321,5648,1,0,0:0:0:0: +401,287,5723,1,0,0:0:0:0: +419,251,5798,1,0,0:0:0:0: +416,212,5873,1,0,0:0:0:0: +399,175,5948,1,0,0:0:0:0: +369,148,6023,1,0,0:0:0:0: +332,127,6098,5,0,0:0:0:0: +290,173,6173,1,0,0:0:0:0: +280,234,6248,1,0,0:0:0:0: +306,291,6323,1,0,0:0:0:0: +358,325,6398,1,0,0:0:0:0: +423,325,6473,1,0,0:0:0:0: +478,289,6548,1,0,0:0:0:0: +504,224,6623,1,0,0:0:0:0: +489,154,6698,1,0,0:0:0:0: +434,104,6773,1,0,0:0:0:0: +359,95,6848,1,0,0:0:0:0: +294,129,6923,1,0,0:0:0:0: +261,194,6998,1,0,0:0:0:0: +216,250,7073,1,0,0:0:0:0: +153,281,7148,1,0,0:0:0:0: +85,273,7223,1,0,0:0:0:0: +37,230,7298,5,0,0:0:0:0: +84,171,7373,1,0,0:0:0:0: +156,152,7448,1,0,0:0:0:0: +227,178,7523,1,0,0:0:0:0: +272,237,7598,1,0,0:0:0:0: +330,286,7673,1,0,0:0:0:0: +408,297,7748,1,0,0:0:0:0: +479,252,7823,1,0,0:0:0:0: +512,178,7898,5,0,0:0:0:0: +501,96,7973,1,0,0:0:0:0: +449,32,8048,1,0,0:0:0:0: +375,5,8123,1,0,0:0:0:0: +302,20,8198,1,0,0:0:0:0: +247,70,8273,1,0,0:0:0:0: +228,139,8348,1,0,0:0:0:0: +247,207,8423,1,0,0:0:0:0: +296,257,8498,5,0,0:0:0:0: +350,228,8573,1,0,0:0:0:0: +376,172,8648,1,0,0:0:0:0: +363,111,8723,1,0,0:0:0:0: +316,71,8798,1,0,0:0:0:0: +254,68,8873,1,0,0:0:0:0: +203,102,8948,1,0,0:0:0:0: +183,161,9023,1,0,0:0:0:0: +203,220,9098,2,0,L|242:260,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +301,278,9398,5,0,0:0:0:0: +355,269,9473,1,0,0:0:0:0: +394,229,9548,1,0,0:0:0:0: +401,174,9623,1,0,0:0:0:0: +374,126,9698,5,0,0:0:0:0: +319,158,9773,1,0,0:0:0:0: +297,220,9848,1,0,0:0:0:0: +318,283,9923,1,0,0:0:0:0: +371,319,9998,1,0,0:0:0:0: +435,315,10073,1,0,0:0:0:0: +486,273,10148,1,0,0:0:0:0: +499,212,10223,1,0,0:0:0:0: +490,151,10298,2,0,L|466:102,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +409,71,10598,5,0,0:0:0:0: +348,89,10673,1,0,0:0:0:0: +302,133,10748,1,0,0:0:0:0: +286,194,10823,1,0,0:0:0:0: +284,259,10898,5,0,0:0:0:0: +345,246,10973,1,0,0:0:0:0: +386,196,11048,1,0,0:0:0:0: +389,130,11123,1,0,0:0:0:0: +352,78,11198,1,0,0:0:0:0: +289,59,11273,1,0,0:0:0:0: +228,81,11348,1,0,0:0:0:0: +194,135,11423,1,0,0:0:0:0: +180,198,11498,2,0,L|195:242,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +232,293,11798,5,0,0:0:0:0: +286,276,11873,1,0,0:0:0:0: +328,237,11948,1,0,0:0:0:0: +342,182,12023,1,0,0:0:0:0: +338,123,12098,5,0,0:0:0:0: +276,146,12173,1,0,0:0:0:0: +240,201,12248,1,0,0:0:0:0: +239,267,12323,1,0,0:0:0:0: +274,321,12398,1,0,0:0:0:0: +335,351,12473,1,0,0:0:0:0: +404,340,12548,1,0,0:0:0:0: +457,290,12623,1,0,0:0:0:0: +474,218,12698,1,0,0:0:0:0: +444,145,12773,1,0,0:0:0:0: +376,103,12848,1,0,0:0:0:0: +300,107,12923,1,0,0:0:0:0: +240,154,12998,1,0,0:0:0:0: +172,188,13073,1,0,0:0:0:0: +98,188,13148,1,0,0:0:0:0: +37,151,13223,1,0,0:0:0:0: +11,85,13298,5,0,0:0:0:0: +77,48,13373,1,0,0:0:0:0: +151,58,13448,1,0,0:0:0:0: +207,112,13523,1,0,0:0:0:0: +227,187,13598,1,0,0:0:0:0: +284,241,13673,1,0,0:0:0:0: +363,261,13748,1,0,0:0:0:0: +435,229,13823,1,0,0:0:0:0: +476,160,13898,1,0,0:0:0:0: +465,76,13973,1,0,0:0:0:0: +405,13,14048,1,0,0:0:0:0: +321,0,14123,1,0,0:0:0:0: +242,39,14198,1,0,0:0:0:0: +206,114,14273,1,0,0:0:0:0: +219,195,14348,1,0,0:0:0:0: +273,251,14423,1,0,0:0:0:0: +345,266,14498,6,0,L|398:256,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +451,242,14948,5,0,0:0:0:0: +439,196,15023,1,0,0:0:0:0: +414,158,15098,1,0,0:0:0:0: +377,131,15173,1,0,0:0:0:0: +333,121,15248,1,0,0:0:0:0: +287,125,15323,1,0,0:0:0:0: +248,144,15398,1,0,0:0:0:0: +211,169,15473,1,0,0:0:0:0: +165,177,15548,1,0,0:0:0:0: +121,171,15623,1,0,0:0:0:0: +81,147,15698,6,0,L|38:111,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +3,72,16148,5,0,0:0:0:0: +47,50,16223,1,0,0:0:0:0: +95,46,16298,1,0,0:0:0:0: +142,57,16373,1,0,0:0:0:0: +180,87,16448,1,0,0:0:0:0: +207,128,16523,1,0,0:0:0:0: +218,174,16598,1,0,0:0:0:0: +223,221,16673,1,0,0:0:0:0: +246,264,16748,1,0,0:0:0:0: +281,297,16823,1,0,0:0:0:0: +328,315,16898,6,0,L|384:325,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +436,305,17348,5,0,0:0:0:0: +418,263,17423,1,0,0:0:0:0: +384,235,17498,1,0,0:0:0:0: +339,227,17573,1,0,0:0:0:0: +296,242,17648,1,0,0:0:0:0: +267,276,17723,1,0,0:0:0:0: +238,309,17798,1,0,0:0:0:0: +195,323,17873,1,0,0:0:0:0: +150,316,17948,1,0,0:0:0:0: +115,288,18023,1,0,0:0:0:0: +99,247,18098,6,0,L|80:191,7,35,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:0: +58,187,18698,5,0,0:0:0:0: +98,161,18773,1,0,0:0:0:0: +147,155,18848,1,0,0:0:0:0: +192,172,18923,5,0,0:0:0:0: +233,189,18998,1,0,0:0:0:0: +277,185,19073,1,0,0:0:0:0: +317,160,19148,5,0,0:0:0:0: +350,138,19223,1,0,0:0:0:0: +391,133,19298,6,0,L|437:137,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +500,158,19748,5,0,0:0:0:0: +490,202,19823,1,0,0:0:0:0: +464,239,19898,1,0,0:0:0:0: +426,262,19973,1,0,0:0:0:0: +381,268,20048,1,0,0:0:0:0: +339,254,20123,1,0,0:0:0:0: +305,225,20198,1,0,0:0:0:0: +286,183,20273,1,0,0:0:0:0: +269,143,20348,1,0,0:0:0:0: +235,112,20423,1,0,0:0:0:0: +191,99,20498,6,0,L|142:99,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +81,115,20948,5,0,0:0:0:0: +100,158,21023,1,0,0:0:0:0: +137,189,21098,1,0,0:0:0:0: +183,203,21173,1,0,0:0:0:0: +232,196,21248,1,0,0:0:0:0: +272,169,21323,1,0,0:0:0:0: +309,137,21398,1,0,0:0:0:0: +353,122,21473,1,0,0:0:0:0: +401,127,21548,1,0,0:0:0:0: +443,152,21623,1,0,0:0:0:0: +471,192,21698,6,0,L|483:238,4,35,0|0|0|0|0,0:0|0:0|0:0|0:0|0:0,0:0:0:0: +472,301,22148,5,0,0:0:0:0: +437,330,22223,1,0,0:0:0:0: +394,344,22298,1,0,0:0:0:0: +349,339,22373,1,0,0:0:0:0: +310,315,22448,1,0,0:0:0:0: +284,279,22523,1,0,0:0:0:0: +255,246,22598,1,0,0:0:0:0: +213,228,22673,1,0,0:0:0:0: +167,227,22748,1,0,0:0:0:0: +126,246,22823,1,0,0:0:0:0: +97,278,22898,5,0,0:0:0:0: +137,307,22973,1,0,0:0:0:0: +189,311,23048,1,0,0:0:0:0: +235,286,23123,1,0,0:0:0:0: +258,239,23198,1,0,0:0:0:0: +255,186,23273,1,0,0:0:0:0: +235,136,23348,1,0,0:0:0:0: +246,80,23423,1,0,0:0:0:0: +283,39,23498,1,0,0:0:0:0: +341,23,23573,1,0,0:0:0:0: +398,45,23648,1,0,0:0:0:0: +438,93,23723,1,0,0:0:0:0: +446,160,23798,1,0,0:0:0:0: +415,228,23873,1,0,0:0:0:0: +348,271,23948,1,0,0:0:0:0: +262,273,24023,1,0,0:0:0:0: +190,227,24098,5,0,0:0:0:0: +227,200,24173,1,0,0:0:0:0: +272,192,24248,1,0,0:0:0:0: +315,201,24323,1,0,0:0:0:0: +357,220,24398,1,0,0:0:0:0: +402,220,24473,1,0,0:0:0:0: +443,201,24548,1,0,0:0:0:0: +472,167,24623,1,0,0:0:0:0: +485,123,24698,5,0,0:0:0:0: +475,72,24773,1,0,0:0:0:0: +447,29,24848,1,0,0:0:0:0: +403,6,24923,1,0,0:0:0:0: +353,1,24998,1,0,0:0:0:0: +304,17,25073,1,0,0:0:0:0: +265,51,25148,1,0,0:0:0:0: +244,95,25223,1,0,0:0:0:0: +244,143,25298,5,0,0:0:0:0: +262,186,25373,1,0,0:0:0:0: +296,217,25448,1,0,0:0:0:0: +339,232,25523,1,0,0:0:0:0: +385,229,25598,5,0,0:0:0:0: +423,207,25673,1,0,0:0:0:0: +450,173,25748,1,0,0:0:0:0: +460,130,25823,1,0,0:0:0:0: +453,87,25898,5,0,0:0:0:0: +430,53,25973,1,0,0:0:0:0: +396,34,26048,1,0,0:0:0:0: +358,27,26123,1,0,0:0:0:0: +320,33,26198,5,0,0:0:0:0: +281,52,26273,1,0,0:0:0:0: +251,84,26348,1,0,0:0:0:0: +232,126,26423,1,0,0:0:0:0: +200,162,26498,5,0,0:0:0:0: +149,174,26573,1,0,0:0:0:0: +100,154,26648,1,0,0:0:0:0: +72,110,26723,1,0,0:0:0:0: +75,58,26798,1,0,0:0:0:0: +107,17,26873,1,0,0:0:0:0: +156,2,26948,5,0,0:0:0:0: +202,17,27023,1,0,0:0:0:0: +231,56,27098,1,0,0:0:0:0: +233,105,27173,1,0,0:0:0:0: +207,147,27248,1,0,0:0:0:0: +187,191,27323,1,0,0:0:0:0: +197,239,27398,5,0,0:0:0:0: +225,268,27473,1,0,0:0:0:0: +271,279,27548,1,0,0:0:0:0: +312,260,27623,1,0,0:0:0:0: +336,221,27698,5,0,0:0:0:0: +294,183,27773,1,0,0:0:0:0: +240,176,27848,1,0,0:0:0:0: +190,200,27923,1,0,0:0:0:0: +144,231,27998,1,0,0:0:0:0: +89,227,28073,1,0,0:0:0:0: +45,194,28148,1,0,0:0:0:0: +29,141,28223,1,0,0:0:0:0: +48,86,28298,5,0,0:0:0:0: +98,54,28373,1,0,0:0:0:0: +155,56,28448,1,0,0:0:0:0: +199,92,28523,1,0,0:0:0:0: +218,147,28598,1,0,0:0:0:0: +237,202,28673,1,0,0:0:0:0: +281,238,28748,1,0,0:0:0:0: +338,240,28823,1,0,0:0:0:0: +388,208,28898,5,0,0:0:0:0: +360,171,28973,1,0,0:0:0:0: +321,150,29048,1,0,0:0:0:0: +276,148,29123,1,0,0:0:0:0: +234,165,29198,1,0,0:0:0:0: +203,198,29273,1,0,0:0:0:0: +189,240,29348,1,0,0:0:0:0: +195,289,29423,1,0,0:0:0:0: +220,330,29498,5,0,0:0:0:0: +257,354,29573,1,0,0:0:0:0: +301,359,29648,1,0,0:0:0:0: +343,346,29723,1,0,0:0:0:0: +377,317,29798,1,0,0:0:0:0: +394,268,29873,1,0,0:0:0:0: +386,216,29948,1,0,0:0:0:0: +354,175,30023,1,0,0:0:0:0: +307,154,30098,5,0,0:0:0:0: +269,124,30173,1,0,0:0:0:0: +262,76,30248,1,0,0:0:0:0: +289,37,30323,1,0,0:0:0:0: +336,27,30398,1,0,0:0:0:0: +377,53,30473,1,0,0:0:0:0: +398,98,30548,5,0,0:0:0:0: +400,150,30623,1,0,0:0:0:0: +385,200,30698,1,0,0:0:0:0: +354,242,30773,1,0,0:0:0:0: +309,272,30848,1,0,0:0:0:0: +259,284,30923,1,0,0:0:0:0: +206,279,30998,5,0,0:0:0:0: +153,253,31073,1,0,0:0:0:0: +113,212,31148,1,0,0:0:0:0: +88,160,31223,1,0,0:0:0:0: +77,104,31298,5,0,0:0:0:0: +139,86,31373,1,0,0:0:0:0: +201,102,31448,1,0,0:0:0:0: +248,147,31523,1,0,0:0:0:0: +267,212,31598,5,0,0:0:0:0: +286,274,31673,1,0,0:0:0:0: +334,321,31748,1,0,0:0:0:0: +397,336,31823,1,0,0:0:0:0: +457,310,31898,1,0,0:0:0:0: +488,253,31973,1,0,0:0:0:0: +479,189,32048,1,0,0:0:0:0: +432,143,32123,1,0,0:0:0:0: +367,136,32198,5,0,0:0:0:0: +311,172,32273,1,0,0:0:0:0: +244,204,32348,1,0,0:0:0:0: +175,191,32423,1,0,0:0:0:0: +129,139,32498,5,0,0:0:0:0: +124,128,32573,1,0,0:0:0:0: +119,117,32648,1,0,0:0:0:0: +114,106,32723,1,0,0:0:0:0: +109,95,32798,5,0,0:0:0:0: +166,63,32873,1,0,0:0:0:0: +232,63,32948,1,0,0:0:0:0: +288,97,33023,1,0,0:0:0:0: +320,155,33098,5,0,0:0:0:0: +318,225,33173,1,0,0:0:0:0: +282,286,33248,1,0,0:0:0:0: +220,321,33323,1,0,0:0:0:0: +149,320,33398,1,0,0:0:0:0: +86,282,33473,1,0,0:0:0:0: +52,219,33548,1,0,0:0:0:0: +54,146,33623,1,0,0:0:0:0: +91,84,33698,5,0,0:0:0:0: +126,112,33773,1,0,0:0:0:0: +145,153,33848,1,0,0:0:0:0: +148,197,33923,1,0,0:0:0:0: +140,242,33998,1,0,0:0:0:0: +151,286,34073,1,0,0:0:0:0: +181,321,34148,1,0,0:0:0:0: +221,340,34223,1,0,0:0:0:0: +266,341,34298,5,0,0:0:0:0: +313,318,34373,1,0,0:0:0:0: +348,280,34448,1,0,0:0:0:0: +359,231,34523,1,0,0:0:0:0: +350,182,34598,1,0,0:0:0:0: +322,139,34673,1,0,0:0:0:0: +277,104,34748,1,0,0:0:0:0: +224,87,34823,1,0,0:0:0:0: +172,98,34898,5,0,0:0:0:0: +184,158,34973,1,0,0:0:0:0: +227,202,35048,1,0,0:0:0:0: +288,215,35123,1,0,0:0:0:0: +346,192,35198,1,0,0:0:0:0: +407,182,35273,1,0,0:0:0:0: +461,212,35348,1,0,0:0:0:0: +485,269,35423,1,0,0:0:0:0: +470,329,35498,5,0,0:0:0:0: +421,367,35573,1,0,0:0:0:0: +359,367,35648,1,0,0:0:0:0: +310,330,35723,1,0,0:0:0:0: +294,270,35798,5,0,0:0:0:0: +313,208,35873,1,0,0:0:0:0: +348,150,35948,1,0,0:0:0:0: +337,84,36023,1,0,0:0:0:0: +296,32,36098,5,0,0:0:0:0: +225,24,36173,1,0,0:0:0:0: +169,68,36248,1,0,0:0:0:0: +158,138,36323,1,0,0:0:0:0: +199,196,36398,1,0,0:0:0:0: +269,209,36473,1,0,0:0:0:0: +342,209,36548,1,0,0:0:0:0: +407,237,36623,1,0,0:0:0:0: +431,306,36698,5,0,0:0:0:0: +395,369,36773,1,0,0:0:0:0: +323,384,36848,1,0,0:0:0:0: +264,341,36923,1,0,0:0:0:0: +244,271,36998,1,0,0:0:0:0: +197,209,37073,1,0,0:0:0:0: +121,195,37148,1,0,0:0:0:0: +52,234,37223,1,0,0:0:0:0: +26,308,37298,5,0,0:0:0:0: +96,325,37373,1,0,0:0:0:0: +165,301,37448,1,0,0:0:0:0: +210,244,37523,1,0,0:0:0:0: +217,172,37598,1,0,0:0:0:0: +261,116,37673,1,0,0:0:0:0: +332,104,37748,1,0,0:0:0:0: +393,142,37823,1,0,0:0:0:0: +412,211,37898,5,0,0:0:0:0: +381,275,37973,1,0,0:0:0:0: +314,302,38048,1,0,0:0:0:0: +247,277,38123,1,0,0:0:0:0: +209,217,38198,5,0,0:0:0:0: +198,201,38273,1,0,0:0:0:0: +190,190,38348,1,0,0:0:0:0: +180,176,38423,1,0,0:0:0:0: +169,160,38498,5,0,0:0:0:0: +215,99,38573,1,0,0:0:0:0: +286,70,38648,1,0,0:0:0:0: +361,79,38723,1,0,0:0:0:0: +422,124,38798,1,0,0:0:0:0: +454,193,38873,1,0,0:0:0:0: +447,269,38948,1,0,0:0:0:0: +404,332,39023,1,0,0:0:0:0: +335,365,39098,5,0,0:0:0:0: +259,360,39173,1,0,0:0:0:0: +195,320,39248,1,0,0:0:0:0: +183,246,39323,1,0,0:0:0:0: +229,188,39398,1,0,0:0:0:0: +304,183,39473,1,0,0:0:0:0: +357,236,39548,1,0,0:0:0:0: +426,258,39623,1,0,0:0:0:0: +491,221,39698,5,0,0:0:0:0: +506,148,39773,1,0,0:0:0:0: +461,89,39848,1,0,0:0:0:0: +388,81,39923,1,0,0:0:0:0: +335,133,39998,1,0,0:0:0:0: +302,196,40073,1,0,0:0:0:0: +244,245,40148,1,0,0:0:0:0: +167,244,40223,1,0,0:0:0:0: +118,184,40298,5,0,0:0:0:0: +129,106,40373,1,0,0:0:0:0: +195,65,40448,1,0,0:0:0:0: +270,88,40523,1,0,0:0:0:0: +301,160,40598,1,0,0:0:0:0: +268,230,40673,1,0,0:0:0:0: +193,251,40748,1,0,0:0:0:0: +127,209,40823,1,0,0:0:0:0: +95,142,40898,5,0,0:0:0:0: +142,92,40973,1,0,0:0:0:0: +210,80,41048,1,0,0:0:0:0: +272,110,41123,1,0,0:0:0:0: +304,171,41198,1,0,0:0:0:0: +301,244,41273,1,0,0:0:0:0: +316,310,41348,1,0,0:0:0:0: +369,353,41423,1,0,0:0:0:0: +438,354,41498,5,0,0:0:0:0: +492,311,41573,1,0,0:0:0:0: +507,244,41648,1,0,0:0:0:0: +476,182,41723,1,0,0:0:0:0: +414,153,41798,1,0,0:0:0:0: +347,167,41873,1,0,0:0:0:0: +304,220,41948,1,0,0:0:0:0: +302,289,42023,1,0,0:0:0:0: +343,344,42098,5,0,0:0:0:0: +391,285,42173,1,0,0:0:0:0: +392,209,42248,1,0,0:0:0:0: +349,149,42323,1,0,0:0:0:0: +276,124,42398,1,0,0:0:0:0: +205,152,42473,1,0,0:0:0:0: +131,158,42548,1,0,0:0:0:0: +83,104,42623,1,0,0:0:0:0: +102,39,42698,5,0,0:0:0:0: +171,21,42773,1,0,0:0:0:0: +220,70,42848,1,0,0:0:0:0: +238,142,42923,1,0,0:0:0:0: +282,195,42998,1,0,0:0:0:0: +349,209,43073,1,0,0:0:0:0: +411,180,43148,1,0,0:0:0:0: +441,118,43223,1,0,0:0:0:0: +427,49,43298,5,0,0:0:0:0: +350,58,43373,1,0,0:0:0:0: +294,102,43448,1,0,0:0:0:0: +272,173,43523,1,0,0:0:0:0: +293,243,43598,1,0,0:0:0:0: +280,316,43673,1,0,0:0:0:0: +224,364,43748,1,0,0:0:0:0: +151,365,43823,1,0,0:0:0:0: +96,317,43898,5,0,0:0:0:0: +80,245,43973,1,0,0:0:0:0: +118,182,44048,1,0,0:0:0:0: +185,156,44123,1,0,0:0:0:0: +255,179,44198,1,0,0:0:0:0: +293,242,44273,1,0,0:0:0:0: +358,281,44348,1,0,0:0:0:0: +428,267,44423,1,0,0:0:0:0: +477,210,44498,1,0,0:0:0:0: +477,136,44573,1,0,0:0:0:0: +438,77,44648,5,0,0:0:0:0: +356,104,44723,1,0,0:0:0:0: +313,177,44798,1,0,0:0:0:0: +259,248,44873,1,0,0:0:0:0: +183,299,44948,1,0,0:0:0:0: +94,289,45023,1,0,0:0:0:0: +34,227,45098,5,0,0:0:0:0: +26,144,45173,1,0,0:0:0:0: +72,73,45248,1,0,0:0:0:0: +156,52,45323,1,0,0:0:0:0: +231,87,45398,1,0,0:0:0:0: +289,141,45473,1,0,0:0:0:0: +375,143,45548,1,0,0:0:0:0: +440,87,45623,1,0,0:0:0:0: +448,11,45698,5,0,0:0:0:0: +366,20,45773,1,0,0:0:0:0: +311,81,45848,1,0,0:0:0:0: +308,161,45923,1,0,0:0:0:0: +357,225,45998,1,0,0:0:0:0: +383,302,46073,1,0,0:0:0:0: +339,371,46148,5,0,0:0:0:0: +260,381,46223,1,0,0:0:0:0: +200,325,46298,1,0,0:0:0:0: +205,245,46373,1,0,0:0:0:0: +190,165,46448,1,0,0:0:0:0: +119,128,46523,1,0,0:0:0:0: +45,157,46598,5,0,0:0:0:0: +22,234,46673,1,0,0:0:0:0: +65,301,46748,1,0,0:0:0:0: +145,309,46823,1,0,0:0:0:0: +201,252,46898,1,0,0:0:0:0: +251,188,46973,1,0,0:0:0:0: +321,149,47048,1,0,0:0:0:0: +396,176,47123,1,0,0:0:0:0: +422,256,47198,5,0,0:0:0:0: +342,240,47273,1,0,0:0:0:0: +288,175,47348,1,0,0:0:0:0: +228,116,47423,1,0,0:0:0:0: +145,81,47498,1,0,0:0:0:0: +60,103,47573,1,0,0:0:0:0: +16,175,47648,5,0,0:0:0:0: +23,256,47723,1,0,0:0:0:0: +77,318,47798,1,0,0:0:0:0: +159,322,47873,1,0,0:0:0:0: +221,269,47948,1,0,0:0:0:0: +235,189,48023,1,0,0:0:0:0: +223,115,48098,5,0,0:0:0:0: +150,142,48173,1,0,0:0:0:0: +105,206,48248,1,0,0:0:0:0: +107,282,48323,1,0,0:0:0:0: +152,344,48398,1,0,0:0:0:0: +224,370,48473,1,0,0:0:0:0: +299,347,48548,1,0,0:0:0:0: +346,286,48623,1,0,0:0:0:0: +349,209,48698,5,0,0:0:0:0: +393,147,48773,1,0,0:0:0:0: +469,149,48848,1,0,0:0:0:0: +512,212,48923,1,0,0:0:0:0: +484,283,48998,1,0,0:0:0:0: +410,301,49073,1,0,0:0:0:0: +352,254,49148,1,0,0:0:0:0: +354,179,49223,1,0,0:0:0:0: +337,107,49298,1,0,0:0:0:0: +267,79,49373,1,0,0:0:0:0: +201,117,49448,1,0,0:0:0:0: +195,191,49523,1,0,0:0:0:0: +255,241,49598,5,0,0:0:0:0: +288,169,49673,1,0,0:0:0:0: +276,91,49748,1,0,0:0:0:0: +229,29,49823,1,0,0:0:0:0: +153,4,49898,1,0,0:0:0:0: +71,22,49973,1,0,0:0:0:0: +17,81,50048,1,0,0:0:0:0: +0,162,50123,1,0,0:0:0:0: +32,241,50198,1,0,0:0:0:0: +99,294,50273,1,0,0:0:0:0: +183,307,50348,1,0,0:0:0:0: +257,267,50423,1,0,0:0:0:0: +298,192,50498,5,0,0:0:0:0: +218,184,50573,1,0,0:0:0:0: +168,242,50648,1,0,0:0:0:0: +186,317,50723,1,0,0:0:0:0: +256,350,50798,5,0,0:0:0:0: +325,304,50873,1,0,0:0:0:0: +346,225,50948,1,0,0:0:0:0: +306,153,51023,1,0,0:0:0:0: +228,130,51098,5,0,0:0:0:0: +150,168,51173,1,0,0:0:0:0: +118,246,51248,1,0,0:0:0:0: +143,327,51323,1,0,0:0:0:0: +215,375,51398,5,0,0:0:0:0: +302,363,51473,1,0,0:0:0:0: +357,298,51548,1,0,0:0:0:0: +358,209,51623,1,0,0:0:0:0: +305,137,51698,5,0,0:0:0:0: +217,111,51773,1,0,0:0:0:0: +134,144,51848,1,0,0:0:0:0: +90,221,51923,1,0,0:0:0:0: +99,309,51998,5,0,0:0:0:0: +163,247,52073,1,0,0:0:0:0: +177,158,52148,1,0,0:0:0:0: +200,67,52223,1,0,0:0:0:0: +278,8,52298,1,0,0:0:0:0: +378,11,52373,1,0,0:0:0:0: +451,78,52448,1,0,0:0:0:0: +466,173,52523,1,0,0:0:0:0: +417,260,52598,1,0,0:0:0:0: +327,295,52673,1,0,0:0:0:0: +230,280,52748,1,0,0:0:0:0: +169,207,52823,1,0,0:0:0:0: +154,121,52898,6,0,L|150:77,1,35,0|0,0:0|0:0,0:0:0:0: +366,205,53198,6,0,L|339:162,1,35,0|0,0:0|0:0,0:0:0:0: +225,0,53348,2,0,L|252:43,1,35,0|0,0:0|0:0,0:0:0:0: +315,210,53498,2,0,L|293:175,1,35,0|0,0:0|0:0,0:0:0:0: +198,40,53648,2,0,L|219:74,1,35,0|0,0:0|0:0,0:0:0:0: +235,256,53798,5,0,0:0:0:0: +235,256,53873,1,0,0:0:0:0: +235,256,53948,1,0,0:0:0:0: +235,256,54023,1,0,0:0:0:0: +235,256,54098,2,0,L|256:290,1,35,0|0,0:0|0:0,0:0:0:0: +398,300,54398,6,0,L|355:272,1,35,0|0,0:0|0:0,0:0:0:0: +213,189,54548,2,0,L|255:216,1,35,0|0,0:0|0:0,0:0:0:0: +353,326,54698,2,0,L|318:303,1,35,0|0,0:0|0:0,0:0:0:0: +205,237,54848,2,0,L|239:259,1,35,0|0,0:0|0:0,0:0:0:0: +313,384,54998,5,0,0:0:0:0: +313,384,55073,1,0,0:0:0:0: +313,384,55148,1,0,0:0:0:0: +313,384,55223,1,0,0:0:0:0: +313,384,55298,2,0,L|278:361,1,35,0|0,0:0|0:0,0:0:0:0: +101,345,55598,6,0,L|114:312,1,35,0|0,0:0|0:0,0:0:0:0: +220,77,55748,2,0,L|206:109,1,35,0|0,0:0|0:0,0:0:0:0: +171,338,55898,2,0,L|184:305,1,35 +266,124,56048,2,0,L|252:156,1,35 +252,383,56198,5,0,0:0:0:0: +260,363,56273,1,0,0:0:0:0: +269,344,56348,1,0,0:0:0:0: +278,325,56423,1,0,0:0:0:0: +285,310,56498,5,0,0:0:0:0: +236,310,56573,1,0,0:0:0:0: +195,294,56648,1,0,0:0:0:0: +157,270,56723,1,0,0:0:0:0: +129,239,56798,1,0,0:0:0:0: +111,203,56873,1,0,0:0:0:0: +107,165,56948,1,0,0:0:0:0: +111,134,57023,1,0,0:0:0:0: +123,105,57098,5,0,0:0:0:0: +140,86,57173,1,0,0:0:0:0: +156,75,57248,1,0,0:0:0:0: +173,70,57323,1,0,0:0:0:0: +185,72,57398,1,0,0:0:0:0: +195,75,57473,1,0,0:0:0:0: +202,81,57548,1,0,0:0:0:0: +207,87,57623,1,0,0:0:0:0: +210,93,57698,6,0,L|227:131,1,35,0|0,0:0|0:0,0:0:0:0: +356,245,57998,6,0,L|293:236,1,35,0|0,0:0|0:0,0:0:0:0: +129,245,58148,2,0,L|192:236,1,35,0|0,0:0|0:0,0:0:0:0: +413,186,58298,2,0,L|358:179,1,35,0|0,0:0|0:0,0:0:0:0: +76,186,58448,2,0,L|139:177,1,35,0|0,0:0|0:0,0:0:0:0: +461,127,58598,5,0,0:0:0:0: +461,127,58673,1,0,0:0:0:0: +461,127,58748,1,0,0:0:0:0: +461,127,58823,1,0,0:0:0:0: +461,127,58898,2,0,L|398:117,1,35,0|0,0:0|0:0,0:0:0:0: +274,296,59198,6,0,L|284:230,1,35,0|0,0:0|0:0,0:0:0:0: +274,55,59348,2,0,L|284:122,1,35,0|0,0:0|0:0,0:0:0:0: +335,356,59498,2,0,L|343:298,1,35,0|0,0:0|0:0,0:0:0:0: +335,1,59648,2,0,L|346:67,1,35,0|0,0:0|0:0,0:0:0:0: +425,384,59798,5,0,0:0:0:0: +425,384,59873,1,0,0:0:0:0: +425,384,59948,1,0,0:0:0:0: +425,384,60023,1,0,0:0:0:0: +425,384,60098,1,0,0:0:0:0: +425,384,60173,1,0,0:0:0:0: +425,384,60248,6,0,L|434:321,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +504,332,60548,2,0,L|494:269,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +439,257,60848,2,0,L|448:194,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +512,197,61148,2,0,L|502:134,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +446,122,61448,5,0,0:0:0:0: +418,124,61523,1,0,0:0:0:0: +389,130,61598,1,0,0:0:0:0: +361,141,61673,1,0,0:0:0:0: +332,157,61748,1,0,0:0:0:0: +306,176,61823,1,0,0:0:0:0: +281,204,61898,1,0,0:0:0:0: +257,233,61973,1,0,0:0:0:0: +240,269,62048,5,0,0:0:0:0: +219,304,62123,1,0,0:0:0:0: +180,333,62198,1,0,0:0:0:0: +131,341,62273,1,0,0:0:0:0: +79,324,62348,1,0,0:0:0:0: +41,278,62423,1,0,0:0:0:0: +25,218,62498,6,0,L|16:176,1,35,0|0,0:0|0:0,0:0:0:0: +68,9,62798,6,0,L|79:58,1,35,0|0,0:0|0:0,0:0:0:0: +143,287,62948,2,0,L|131:237,1,35,0|0,0:0|0:0,0:0:0:0: +117,20,63098,2,0,L|126:60,1,35,0|0,0:0|0:0,0:0:0:0: +181,257,63248,2,0,L|172:218,1,35,0|0,0:0|0:0,0:0:0:0: +207,20,63398,5,0,0:0:0:0: +207,20,63473,1,0,0:0:0:0: +207,20,63548,1,0,0:0:0:0: +207,20,63623,1,0,0:0:0:0: +207,20,63698,2,0,L|216:60,1,35,0|0,0:0|0:0,0:0:0:0: +401,88,63998,6,0,L|356:97,1,35,0|0,0:0|0:0,0:0:0:0: +151,155,64148,2,0,L|195:144,1,35,0|0,0:0|0:0,0:0:0:0: +391,132,64298,2,0,L|355:140,1,35,0|0,0:0|0:0,0:0:0:0: +178,189,64448,2,0,L|213:180,1,35,0|0,0:0|0:0,0:0:0:0: +396,189,64598,5,0,0:0:0:0: +396,189,64673,1,0,0:0:0:0: +396,189,64748,1,0,0:0:0:0: +396,189,64823,1,0,0:0:0:0: +396,189,64898,2,0,L|356:198,1,35,0|0,0:0|0:0,0:0:0:0: +162,114,65198,6,0,L|187:162,1,35,0|0,0:0|0:0,0:0:0:0: +317,384,65348,2,0,L|290:335,1,35,0|0,0:0|0:0,0:0:0:0: +216,111,65498,2,0,L|236:150,1,35,0|0,0:0|0:0,0:0:0:0: +348,341,65648,2,0,L|327:302,1,35,0|0,0:0|0:0,0:0:0:0: +278,118,65798,5,0,0:0:0:0: +278,118,65873,1,0,0:0:0:0: +278,118,65948,1,0,0:0:0:0: +278,118,66023,1,0,0:0:0:0: +278,118,66098,5,0,0:0:0:0: +297,172,66173,1,0,0:0:0:0: +290,228,66248,1,0,0:0:0:0: +258,273,66323,1,0,0:0:0:0: +213,296,66398,1,0,0:0:0:0: +168,296,66473,1,0,0:0:0:0: +132,279,66548,1,0,0:0:0:0: +109,251,66623,1,0,0:0:0:0: +103,222,66698,5,0,0:0:0:0: +110,199,66773,1,0,0:0:0:0: +122,184,66848,1,0,0:0:0:0: +136,180,66923,1,0,0:0:0:0: +146,180,66998,1,0,0:0:0:0: +152,184,67073,1,0,0:0:0:0: +155,190,67148,1,0,0:0:0:0: +158,196,67223,1,0,0:0:0:0: +159,202,67298,6,0,L|152:246,1,35,0|0,0:0|0:0,0:0:0:0: +297,352,67598,6,0,L|248:341,1,35,0|0,0:0|0:0,0:0:0:0: +0,275,67748,2,0,L|50:287,1,35,0|0,0:0|0:0,0:0:0:0: +286,303,67898,2,0,L|246:294,1,35,0|0,0:0|0:0,0:0:0:0: +30,237,68048,2,0,L|69:246,1,35,0|0,0:0|0:0,0:0:0:0: +286,213,68198,5,0,0:0:0:0: +286,213,68273,1,0,0:0:0:0: +286,213,68348,1,0,0:0:0:0: +286,213,68423,1,0,0:0:0:0: +286,213,68498,2,0,L|246:204,1,35,0|0,0:0|0:0,0:0:0:0: +53,10,68798,6,0,L|66:71,1,35,0|0,0:0|0:0,0:0:0:0: +147,358,68948,2,0,L|131:295,1,35,0|0,0:0|0:0,0:0:0:0: +114,23,69098,2,0,L|125:73,1,35,0|0,0:0|0:0,0:0:0:0: +194,321,69248,2,0,L|182:272,1,35,0|0,0:0|0:0,0:0:0:0: +227,23,69398,5,0,0:0:0:0: +227,23,69473,1,0,0:0:0:0: +227,23,69548,1,0,0:0:0:0: +227,23,69623,1,0,0:0:0:0: +227,23,69698,1,0,0:0:0:0: +227,23,69773,1,0,0:0:0:0: +227,23,69848,6,0,L|240:65,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +310,89,70148,2,0,L|321:123,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +266,176,70448,2,0,L|279:218,3,35,0|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +349,242,70748,2,0,L|359:275,1,35,0|0,0:0|0:0,0:0:0:0: +304,341,70898,5,0,0:0:0:0: +278,368,70973,1,0,0:0:0:0: +242,383,71048,1,0,0:0:0:0: +202,382,71123,1,0,0:0:0:0: +167,364,71198,1,0,0:0:0:0: +144,333,71273,1,0,0:0:0:0: +135,290,71348,1,0,0:0:0:0: +147,250,71423,1,0,0:0:0:0: +176,219,71498,5,0,0:0:0:0: +209,189,71573,1,0,0:0:0:0: +226,151,71648,1,0,0:0:0:0: +222,105,71723,1,0,0:0:0:0: +201,61,71798,1,0,0:0:0:0: +158,30,71873,1,0,0:0:0:0: +105,24,71948,1,0,0:0:0:0: +46,39,72023,1,0,0:0:0:0: +0,84,72098,5,0,3:0:0:0: +65,123,72173,1,0,3:0:0:0: +140,141,72248,1,0,3:0:0:0: +218,129,72323,1,0,3:0:0:0: +287,94,72398,1,0,3:0:0:0: +365,107,72473,1,0,3:0:0:0: +418,164,72548,5,0,3:0:0:0: +428,241,72623,1,0,3:0:0:0: +390,308,72698,1,0,3:0:0:0: +321,339,72773,1,0,3:0:0:0: +243,326,72848,1,0,3:0:0:0: +195,266,72923,1,0,3:0:0:0: +196,188,72998,5,0,3:0:0:0: +246,130,73073,1,0,0:0:0:0: +324,120,73148,1,0,0:0:0:0: +388,161,73223,1,0,0:0:0:0: +410,235,73298,1,0,0:0:0:0: +380,306,73373,1,0,0:0:0:0: +307,341,73448,5,0,0:0:0:0: +288,265,73523,1,0,0:0:0:0: +344,209,73598,1,0,0:0:0:0: +422,227,73673,1,0,0:0:0:0: +497,195,73748,1,0,0:0:0:0: +511,111,73823,1,0,0:0:0:0: +449,56,73898,1,0,0:0:0:0: +368,69,73973,1,0,0:0:0:0: +318,136,74048,1,0,0:0:0:0: +258,202,74123,1,0,3:0:0:0: +167,212,74198,1,0,0:0:0:0: +107,139,74273,1,0,0:0:0:0: +138,48,74348,1,0,0:0:0:0: +234,23,74423,1,0,0:0:0:0: +303,89,74498,5,0,0:0:0:0: +239,136,74573,1,0,0:0:0:0: +162,122,74648,1,0,0:0:0:0: +83,134,74723,1,0,0:0:0:0: +32,197,74798,1,0,0:0:0:0: +36,275,74873,1,0,0:0:0:0: +92,330,74948,5,0,0:0:0:0: +170,338,75023,1,0,0:0:0:0: +235,292,75098,1,0,0:0:0:0: +257,215,75173,1,0,0:0:0:0: +276,139,75248,1,0,0:0:0:0: +320,71,75323,1,0,0:0:0:0: +396,51,75398,5,0,0:0:0:0: +467,84,75473,1,0,0:0:0:0: +502,156,75548,1,0,0:0:0:0: +482,233,75623,1,0,0:0:0:0: +418,280,75698,1,0,0:0:0:0: +339,276,75773,1,0,0:0:0:0: +280,223,75848,1,0,0:0:0:0: +269,145,75923,1,0,0:0:0:0: +311,75,75998,5,0,3:0:0:0: +345,143,76073,1,0,3:0:0:0: +307,214,76148,1,0,3:0:0:0: +225,219,76223,1,0,0:0:0:0: +176,241,76298,5,0,0:0:0:0: +150,288,76373,1,0,0:0:0:0: +158,341,76448,1,0,0:0:0:0: +191,376,76523,1,0,0:0:0:0: +238,383,76598,1,0,0:0:0:0: +277,363,76673,1,0,0:0:0:0: +294,326,76748,1,0,0:0:0:0: +287,293,76823,1,0,0:0:0:0: +269,254,76898,5,0,3:0:0:0: +248,172,76973,1,0,3:0:0:0: +287,104,77048,1,0,3:0:0:0: +361,72,77123,1,0,3:0:0:0: +436,97,77198,1,0,3:0:0:0: +479,163,77273,1,0,3:0:0:0: +468,242,77348,5,0,3:0:0:0: +409,296,77423,1,0,3:0:0:0: +331,300,77498,1,0,3:0:0:0: +267,253,77573,1,0,3:0:0:0: +248,174,77648,1,0,3:0:0:0: +212,104,77723,1,0,3:0:0:0: +137,74,77798,5,0,3:0:0:0: +63,99,77873,1,0,3:0:0:0: +20,164,77948,1,0,3:0:0:0: +31,244,78023,1,0,3:0:0:0: +92,296,78098,1,0,3:0:0:0: +170,297,78173,1,0,3:0:0:0: +231,247,78248,1,0,3:0:0:0: +244,167,78323,1,0,3:0:0:0: +206,96,78398,5,0,3:0:0:0: +147,151,78473,1,0,3:0:0:0: +134,232,78548,1,0,3:0:0:0: +176,303,78623,1,0,3:0:0:0: +256,336,78698,1,0,3:0:0:0: +336,311,78773,1,0,3:0:0:0: +385,238,78848,1,0,3:0:0:0: +381,150,78923,1,0,3:0:0:0: +318,80,78998,1,0,3:0:0:0: +223,58,79073,1,0,3:0:0:0: +147,113,79148,1,0,3:0:0:0: +129,212,79223,1,0,3:0:0:0: +187,283,79298,5,0,3:0:0:0: +228,213,79373,1,0,3:0:0:0: +204,132,79448,1,0,3:0:0:0: +131,97,79523,1,0,3:0:0:0: +55,126,79598,5,0,3:0:0:0: +25,206,79673,1,0,3:0:0:0: +54,285,79748,1,0,3:0:0:0: +126,329,79823,1,0,3:0:0:0: +210,317,79898,5,0,3:0:0:0: +269,250,79973,1,0,3:0:0:0: +273,159,80048,1,0,3:0:0:0: +221,86,80123,1,0,3:0:0:0: +134,61,80198,5,0,3:0:0:0: +50,98,80273,1,0,3:0:0:0: +4,175,80348,1,0,3:0:0:0: +17,268,80423,1,0,3:0:0:0: +82,334,80498,5,0,3:0:0:0: +176,344,80573,1,0,3:0:0:0: +264,306,80648,1,0,3:0:0:0: +316,226,80723,1,0,3:0:0:0: +317,132,80798,5,0,3:0:0:0: +228,169,80873,1,0,3:0:0:0: +201,262,80948,1,0,3:0:0:0: +259,341,81023,1,0,3:0:0:0: +354,349,81098,5,0,3:0:0:0: +425,275,81173,1,0,3:0:0:0: +430,174,81248,1,0,3:0:0:0: +360,103,81323,1,0,3:0:0:0: +265,108,81398,5,0,3:0:0:0: +191,184,81473,1,0,3:0:0:0: +199,291,81548,1,0,3:0:0:0: +276,370,81623,1,0,3:0:0:0: +256,192,81698,12,0,83948,0:0:0:0: +467,368,84098,5,0,3:0:0:0: +436,336,84173,1,0,3:0:0:0: +403,316,84248,1,0,3:0:0:0: +363,310,84323,1,0,3:0:0:0: +325,318,84398,1,0,3:0:0:0: +289,339,84473,1,0,3:0:0:0: +260,362,84548,1,0,3:0:0:0: +222,376,84623,1,0,3:0:0:0: +178,374,84698,5,0,3:0:0:0: +140,356,84773,1,0,3:0:0:0: +102,325,84848,1,0,3:0:0:0: +85,284,84923,1,0,3:0:0:0: +86,239,84998,1,0,3:0:0:0: +104,199,85073,1,0,3:0:0:0: +138,174,85148,1,0,3:0:0:0: +173,167,85223,1,0,3:0:0:0: +208,175,85298,5,0,3:0:0:0: +203,218,85373,1,0,3:0:0:0: +182,254,85448,1,0,3:0:0:0: +147,279,85523,1,0,3:0:0:0: +104,287,85598,5,0,3:0:0:0: +57,274,85673,1,0,3:0:0:0: +21,243,85748,1,0,3:0:0:0: +1,202,85823,1,0,3:0:0:0: +1,155,85898,1,0,3:0:0:0: +20,111,85973,1,0,3:0:0:0: +55,82,86048,1,0,3:0:0:0: +101,67,86123,1,0,3:0:0:0: +148,76,86198,5,0,3:0:0:0: +190,93,86273,1,0,3:0:0:0: +237,94,86348,1,0,3:0:0:0: +281,70,86423,1,0,3:0:0:0: +313,31,86498,5,0,3:0:0:0: +266,4,86573,1,0,3:0:0:0: +212,0,86648,1,0,3:0:0:0: +160,12,86723,1,0,3:0:0:0: +117,50,86798,1,0,3:0:0:0: +92,99,86873,1,0,3:0:0:0: +89,153,86948,1,0,3:0:0:0: +109,204,87023,1,0,3:0:0:0: +148,243,87098,1,0,3:0:0:0: +196,263,87173,1,0,3:0:0:0: +253,263,87248,1,0,3:0:0:0: +302,242,87323,1,0,3:0:0:0: +346,212,87398,5,0,3:0:0:0: +399,203,87473,1,0,3:0:0:0: +450,214,87548,1,0,3:0:0:0: +490,243,87623,1,0,3:0:0:0: +510,283,87698,5,0,0:0:0:0: +457,320,87773,1,0,0:0:0:0: +393,319,87848,1,0,0:0:0:0: +345,278,87923,1,0,0:0:0:0: +332,216,87998,1,0,0:0:0:0: +361,159,88073,1,0,0:0:0:0: +369,95,88148,1,0,0:0:0:0: +341,39,88223,1,0,0:0:0:0: +285,7,88298,1,0,0:0:0:0: +221,15,88373,1,0,0:0:0:0: +175,60,88448,5,0,0:0:0:0: +160,125,88523,1,0,0:0:0:0: +187,185,88598,1,0,0:0:0:0: +243,220,88673,1,0,0:0:0:0: +311,215,88748,1,0,0:0:0:0: +362,171,88823,1,0,0:0:0:0: +379,107,88898,5,0,0:0:0:0: +310,110,88973,1,0,0:0:0:0: +267,166,89048,1,0,0:0:0:0: +212,215,89123,1,0,3:0:0:0: +144,240,89198,1,0,3:0:0:0: +70,217,89273,1,0,3:0:0:0: +27,157,89348,1,0,3:0:0:0: +34,82,89423,1,0,0:0:0:0: +84,22,89498,5,0,0:0:0:0: +162,13,89573,1,0,0:0:0:0: +226,55,89648,1,0,0:0:0:0: +251,128,89723,1,0,0:0:0:0: +299,191,89798,1,0,3:0:0:0: +373,208,89873,1,0,3:0:0:0: +449,175,89948,1,0,3:0:0:0: +484,99,90023,1,0,3:0:0:0: +486,21,90098,5,0,0:0:0:0: +413,40,90173,1,0,0:0:0:0: +365,96,90248,1,0,0:0:0:0: +355,168,90323,1,0,0:0:0:0: +389,233,90398,1,0,0:0:0:0: +406,307,90473,1,0,0:0:0:0: +362,369,90548,1,0,0:0:0:0: +289,385,90623,1,0,0:0:0:0: +226,339,90698,5,0,0:0:0:0: +217,263,90773,1,0,0:0:0:0: +264,204,90848,1,0,0:0:0:0: +296,134,90923,1,0,0:0:0:0: +265,66,90998,1,0,0:0:0:0: +194,37,91073,1,0,0:0:0:0: +125,69,91148,1,0,0:0:0:0: +99,140,91223,1,0,0:0:0:0: +136,209,91298,5,0,0:0:0:0: +183,154,91373,1,0,0:0:0:0: +180,83,91448,1,0,0:0:0:0: +126,36,91523,1,0,0:0:0:0: +55,41,91598,1,0,0:0:0:0: +8,97,91673,1,0,0:0:0:0: +16,168,91748,1,0,0:0:0:0: +74,213,91823,1,0,0:0:0:0: +146,202,91898,5,0,0:0:0:0: +187,142,91973,1,0,0:0:0:0: +243,99,92048,1,0,0:0:0:0: +314,112,92123,1,0,0:0:0:0: +356,169,92198,1,0,0:0:0:0: +342,241,92273,1,0,0:0:0:0: +281,278,92348,1,0,0:0:0:0: +211,262,92423,1,0,0:0:0:0: +169,202,92498,5,0,0:0:0:0: +229,157,92573,1,0,0:0:0:0: +306,162,92648,1,0,0:0:0:0: +358,217,92723,1,0,0:0:0:0: +362,292,92798,1,0,0:0:0:0: +315,353,92873,1,0,0:0:0:0: +241,366,92948,1,0,0:0:0:0: +175,329,93023,1,0,0:0:0:0: +151,256,93098,5,0,0:0:0:0: +182,186,93173,1,0,0:0:0:0: +251,153,93248,1,0,0:0:0:0: +323,174,93323,1,0,0:0:0:0: +388,213,93398,1,0,0:0:0:0: +462,199,93473,1,0,0:0:0:0: +510,138,93548,1,0,0:0:0:0: +510,63,93623,1,0,0:0:0:0: +457,0,93698,5,0,0:0:0:0: +432,69,93773,1,0,0:0:0:0: +445,142,93848,1,0,0:0:0:0: +478,211,93923,1,0,0:0:0:0: +485,283,93998,1,0,0:0:0:0: +442,347,94073,1,0,0:0:0:0: +369,370,94148,1,0,0:0:0:0: +300,347,94223,1,0,0:0:0:0: +259,282,94298,5,0,0:0:0:0: +267,206,94373,1,0,0:0:0:0: +252,131,94448,1,0,0:0:0:0: +193,81,94523,1,0,0:0:0:0: +118,78,94598,1,0,0:0:0:0: +55,124,94673,1,0,0:0:0:0: +38,198,94748,1,0,0:0:0:0: +72,268,94823,1,0,0:0:0:0: +146,301,94898,5,0,0:0:0:0: +219,278,94973,1,0,0:0:0:0: +266,215,95048,1,0,0:0:0:0: +283,134,95123,1,0,3:0:0:0: +317,61,95198,1,0,3:0:0:0: +384,25,95273,1,0,3:0:0:0: +459,43,95348,5,0,0:0:0:0: +431,119,95423,1,0,0:0:0:0: +372,167,95498,1,0,0:0:0:0: +298,176,95573,1,0,0:0:0:0: +226,146,95648,1,0,0:0:0:0: +184,89,95723,1,0,0:0:0:0: +114,81,95798,1,0,3:0:0:0: +47,107,95873,1,0,3:0:0:0: +9,164,95948,1,0,3:0:0:0: +6,234,96023,1,0,0:0:0:0: +328,304,96698,5,0,0:0:0:0: +304,298,96773,1,0,0:0:0:0: +280,293,96848,1,0,0:0:0:0: +256,287,96923,1,0,0:0:0:0: +232,282,96998,1,0,0:0:0:0: +145,249,97148,5,0,0:0:0:0: +169,244,97223,1,0,0:0:0:0: +256,192,97598,5,0,0:0:0:0: + diff --git a/pp/rxoppai~05f367344c838990e809394850fb2ee794cce14e b/pp/rxoppai~05f367344c838990e809394850fb2ee794cce14e new file mode 100644 index 0000000..896bc06 --- /dev/null +++ b/pp/rxoppai~05f367344c838990e809394850fb2ee794cce14e @@ -0,0 +1 @@ +C:/Users/cmyui/Desktop/codeMode/akatsuki-pp-relax \ No newline at end of file diff --git a/pp/rxoppai~41cc2cb18015293c90c699e1d2089aa38beec8aa b/pp/rxoppai~41cc2cb18015293c90c699e1d2089aa38beec8aa new file mode 100644 index 0000000..896bc06 --- /dev/null +++ b/pp/rxoppai~41cc2cb18015293c90c699e1d2089aa38beec8aa @@ -0,0 +1 @@ +C:/Users/cmyui/Desktop/codeMode/akatsuki-pp-relax \ No newline at end of file diff --git a/pp/wifipiano2.py b/pp/wifipiano2.py new file mode 100644 index 0000000..bc53bb0 --- /dev/null +++ b/pp/wifipiano2.py @@ -0,0 +1,117 @@ +""" +Wifipiano 2 + +This file has been written taking by reference code from +osu-performance (https://github.com/ppy/osu-performance) +by Tom94, licensed under the GNU AGPL 3 License. +""" +from common.constants import mods +from common.log import logUtils as log +from constants import exceptions +from helpers import mapsHelper + + +class piano: + __slots__ = ["beatmap", "score", "pp"] + + def __init__(self, __beatmap, __score): + self.beatmap = __beatmap + self.score = __score + self.pp = 0 + self.getPP() + + def getPP(self): + try: + stars = self.beatmap.starsMania + if stars == 0: + # This beatmap can't be converted to mania + raise exceptions.invalidBeatmapException() + + # Cache beatmap for cono + mapFile = mapsHelper.cachedMapPath(self.beatmap.beatmapID) + mapsHelper.cacheMap(mapFile, self.beatmap) + + od = self.beatmap.OD + objects = self.score.c50+self.score.c100+self.score.c300+self.score.cKatu+self.score.cGeki+self.score.cMiss + + score = self.score.score + accuracy = self.score.accuracy + scoreMods = self.score.mods + + log.debug("[WIFIPIANO2] SCORE DATA: Stars: {stars}, OD: {od}, obj: {objects}, score: {score}, acc: {acc}, mods: {mods}".format(stars=stars, od=od, objects=objects, score=score, acc=accuracy, mods=scoreMods)) + + # ---------- STRAIN PP + # Scale score to mods multiplier + scoreMultiplier = 1.0 + + # Doubles score if EZ/HT + if scoreMods & mods.EASY != 0: + scoreMultiplier *= 0.50 + #if scoreMods & mods.HALFTIME != 0: + # scoreMultiplier *= 0.50 + + # Calculate strain PP + if scoreMultiplier <= 0: + strainPP = 0 + else: + score *= int(1.0 / scoreMultiplier) + strainPP = pow(5.0 * max(1.0, stars / 0.0825) - 4.0, 3.0) / 110000.0 + strainPP *= 1 + 0.1 * min(1.0, float(objects) / 1500.0) + if score <= 500000: + strainPP *= (float(score) / 500000.0) * 0.1 + elif score <= 600000: + strainPP *= 0.1 + float(score - 500000) / 100000.0 * 0.2 + elif score <= 700000: + strainPP *= 0.3 + float(score - 600000) / 100000.0 * 0.35 + elif score <= 800000: + strainPP *= 0.65 + float(score - 700000) / 100000.0 * 0.20 + elif score <= 900000: + strainPP *= 0.85 + float(score - 800000) / 100000.0 * 0.1 + else: + strainPP *= 0.95 + float(score - 900000) / 100000.0 * 0.05 + + # ---------- ACC PP + # Makes sure OD is in range 0-10. If this is done elsewhere, remove this. + scrubbedOD = min(10.0, max(0, 10.0 - od)) + + # Old formula but done backwards. + hitWindow300 = (34 + 3 * scrubbedOD) + + # Increases hitWindow if EZ is on + if scoreMods & mods.EASY != 0: + hitWindow300 *= 1.4 + + # Fiddles with DT and HT to make them match hitWindow300's ingame. + if scoreMods & mods.DOUBLETIME != 0: + hitWindow300 *= 1.5 + elif scoreMods & mods.HALFTIME != 0: + hitWindow300 *= 0.75 + + # makes hit window match what it is ingame. + hitWindow300 = int(hitWindow300) + 0.5 + if scoreMods & mods.DOUBLETIME != 0: + hitWindow300 /= 1.5 + elif scoreMods & mods.HALFTIME != 0: + hitWindow300 /= 0.75 + + # Calculate accuracy PP + accPP = pow((150.0 / hitWindow300) * pow(accuracy, 16), 1.8) * 2.5 + accPP *= min(1.15, pow(float(objects) / 1500.0, 0.3)) + + # ---------- TOTAL PP + multiplier = 1.1 + if scoreMods & mods.NOFAIL != 0: + multiplier *= 0.90 + if scoreMods & mods.SPUNOUT != 0: + multiplier *= 0.95 + if scoreMods & mods.EASY != 0: + multiplier *= 0.50 + pp = pow(pow(strainPP, 1.1) + pow(accPP, 1.1), 1.0 / 1.1) * multiplier + log.debug("[WIFIPIANO2] Calculated PP: {}".format(pp)) + + self.pp = pp + except exceptions.invalidBeatmapException: + log.warning("Invalid beatmap {}".format(self.beatmap.beatmapID)) + self.pp = 0 + finally: + return self.pp diff --git a/pubSubHandlers/__init__.py b/pubSubHandlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pubSubHandlers/beatmapUpdateHandler.py b/pubSubHandlers/beatmapUpdateHandler.py new file mode 100644 index 0000000..1c1fcc0 --- /dev/null +++ b/pubSubHandlers/beatmapUpdateHandler.py @@ -0,0 +1,27 @@ +from common.redis import generalPubSubHandler +from helpers import osuapiHelper +from objects import beatmap + +def updateSet(beatmapSetID): + apiResponse = osuapiHelper.osuApiRequest("get_beatmaps", "s={}".format(beatmapSetID), False) + if len(apiResponse) == 0: + return + for i in apiResponse: + beatmap.beatmap(i["file_md5"], int(i["beatmapset_id"]), refresh=True) + +class handler(generalPubSubHandler.generalPubSubHandler): + def __init__(self): + super().__init__() + self.structure = {} + self.strict = False + + def handle(self, data): + data = super().parseData(data) + if data is None: + return + if "id" in data: + beatmapData = osuapiHelper.osuApiRequest("get_beatmaps", "b={}".format(data["id"])) + if beatmapData is not None and "beatmapset_id" in beatmapData: + updateSet(beatmapData["beatmapset_id"]) + elif "set_id" in data: + updateSet(data["set_id"]) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..851872a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +tornado==4.4.2 +mysqlclient==1.3.9 +progressbar2==3.38.0 +raven==5.32.0 +datadog==0.14.0 +bcrypt==3.1.1 +Cython==0.27.3 +requests==2.20.0 +redis==2.10.5 +dill==0.2.7.1 diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..96d6a4c --- /dev/null +++ b/run.sh @@ -0,0 +1,2 @@ +python3 setup.py build_ext --inplace +python3 lets.py diff --git a/secret/achievements/__init__.py b/secret/achievements/__init__.py new file mode 100644 index 0000000..6e19405 --- /dev/null +++ b/secret/achievements/__init__.py @@ -0,0 +1,2 @@ +from . import utils +from . import handlers diff --git a/secret/achievements/common.py b/secret/achievements/common.py new file mode 100644 index 0000000..0dda79a --- /dev/null +++ b/secret/achievements/common.py @@ -0,0 +1,57 @@ +import math +if __name__ != "common": + from objects import glob + import time + import json + from common.ripple import userUtils + +def load_achievement_data(ACHIEVEMENT_BASE, ACHIEVEMENT_KEYS, ACHIEVEMENT_STRUCT): + LENGTH = 0 + ACHIEVEMENTS = [] + + for struct in ACHIEVEMENT_STRUCT: + LENGTH = max(LENGTH, len(ACHIEVEMENT_KEYS[struct]) * ACHIEVEMENT_STRUCT[struct]) + + entry = {x:0 for x in ACHIEVEMENT_STRUCT} + for i in range(LENGTH): + for struct in ACHIEVEMENT_STRUCT: + entry[struct] = math.floor(i / ACHIEVEMENT_STRUCT[struct]) % len(ACHIEVEMENT_KEYS[struct]) + format_data = {x:ACHIEVEMENT_KEYS[x][entry[x]] for x in ACHIEVEMENT_KEYS} + ACHIEVEMENTS.append({x: ACHIEVEMENT_BASE[x].format_map(format_data) for x in ACHIEVEMENT_BASE}) + + return ACHIEVEMENTS, LENGTH + +def get_usercache(userID): + user_cache = glob.redis.get("lets:user_achievement_cache:{}".format(userID)) + if user_cache is None: + user_cache = {} + else: + user_cache = json.loads(user_cache.decode("utf-8")) + + if "version" not in user_cache: + # Load from sql database + user_cache["version"] = userUtils.getAchievementsVersion(userID) + db_achievements = [x["achievement_id"] for x in glob.db.fetchAll("SELECT achievement_id FROM users_achievements WHERE user_id = %s", [userID])] + if "achievements" in user_cache: + user_cache["achievements"] += db_achievements + else: + user_cache["achievements"] = db_achievements + # Remove duplicates after merge + user_cache["achievements"] = list(set(user_cache["achievements"])) + + return user_cache + +def add_pending_achievement(userID, achievementID): + user_cache = get_usercache(userID) + if len([x for x in user_cache["achievements"] if x in [achievementID, -achievementID]]) > 0: + print("Tried to add achievement:{} to user:{}, but failed due to duplicate entry.".format(achievementID, userID)) + return + + user_cache["achievements"].append(-achievementID) + + # Remove duplicates after merge + user_cache["achievements"] = list(set(user_cache["achievements"])) + + glob.redis.set("lets:user_achievement_cache:{}".format(userID), json.dumps(user_cache), 1800) + + userUtils.unlockAchievement(userID, achievementID) \ No newline at end of file diff --git a/secret/achievements/generate_sql.py b/secret/achievements/generate_sql.py new file mode 100644 index 0000000..6ae0987 --- /dev/null +++ b/secret/achievements/generate_sql.py @@ -0,0 +1,58 @@ +if __name__ != "__main__": + print("This is ment to be runned as a toolkit for generating achievement database") + exit() + +import common +from os.path import dirname, basename, isfile +import glob +import importlib +import time +import math + +SQL_STRING = """ +CREATE TABLE IF NOT EXISTS `achievements` ( + `id` int(11) NOT NULL, + `name` varchar(32) NOT NULL, + `description` varchar(128) NOT NULL, + `icon` varchar(32) NOT NULL, + `version` int(11) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=latin1; +INSERT INTO achievements (id, name, description, icon, version) VALUES +""" + +module_list = glob.glob("handlers/*.py") +module_list = [basename(f)[:-3] for f in module_list if isfile(f) and not f.endswith("__init__.py")] +# ^ cat face + +modules = [] +for module in module_list: + modules.append(importlib.import_module("handlers.{}".format(module))) + +modules = sorted(modules, key=lambda k: k.ORDER) + +SQL_INSERTS = [] +index = 1 +for module in modules: + module.load() + for achievement in module.ACHIEVEMENTS: + SQL_INSERTS.append("({}, '{}', '{}', '{}', {})".format( + index, + achievement["name"].replace('"', '\\"').replace("'", "\\'"), + achievement["description"].replace('"', '\\"').replace("'", "\\'"), + achievement["icon"].replace('"', '\\"').replace("'", "\\'"), + module.VERSION + )) + index += 1 + +SQL_STRING += ",\n".join(SQL_INSERTS) + ";" + +FILENAME = "achievements-{}.sql".format(math.floor(time.time())) +with open(FILENAME, "w") as f: + f.write(SQL_STRING) + +print("Saved sql export into {}".format(FILENAME)) +print("Import this table into your database.") +print("""NOTE: Avoid changing the ORDER variable inside the handlers at all cost as this will result in +new achievement sql data not matching data of already achieved achievements by users.""") +print("If you know what you are doing you know how to fix this if you still choose to ignore this warning") \ No newline at end of file diff --git a/secret/achievements/handlers/combo.py b/secret/achievements/handlers/combo.py new file mode 100644 index 0000000..db49071 --- /dev/null +++ b/secret/achievements/handlers/combo.py @@ -0,0 +1,55 @@ +if __name__ != "handlers.combo": + from secret.achievements import common + from objects import glob +else: + import common + +VERSION = 1 +ORDER = 1 + +# Loads the achievement length on load +LENGTH = 0 + +ACHIEVEMENT_BASE = { + "name": "{index} Combo (osu!{mode})", + "description": "{index} big ones! You're moving up in the world!", + "icon": "osu-combo-{index}" +} + +ACHIEVEMENT_KEYS = { + "index": [500, 750, 1000, 2000], + "mode": ["std", "taiko", "ctb", "mania"] +} + +# For every itteration index gets increased, while mode and mode_2 gets increased every 4 itterations +ACHIEVEMENT_STRUCT = { + "index": 1, + "mode": 4 +} + +ACHIEVEMENTS = [] + +def load(): + global ACHIEVEMENTS, LENGTH + ACHIEVEMENTS, LENGTH = common.load_achievement_data(ACHIEVEMENT_BASE, ACHIEVEMENT_KEYS, ACHIEVEMENT_STRUCT) + +def handle(mode, score, beatmap, user_data): + return check(mode, score.maxCombo) + +def check(mode, max_combo): + achievement_ids = [] + indexies = [x for x in ACHIEVEMENT_KEYS["index"] if x <= max_combo] + + for index in range(len(indexies)): + achievement_ids.append(index + mode * 4) + + return achievement_ids + +def update(userID): + achievement_ids = [] + + entries = glob.db.fetchAll("SELECT MAX(max_combo) AS max_combo, play_mode FROM scores WHERE userid = %s AND completed >= 2 GROUP BY play_mode", [userID]) + for entry in entries: + achievement_ids += check(entry["play_mode"], entry["max_combo"]) + + return achievement_ids diff --git a/secret/achievements/handlers/mods.py b/secret/achievements/handlers/mods.py new file mode 100644 index 0000000..51e9f69 --- /dev/null +++ b/secret/achievements/handlers/mods.py @@ -0,0 +1,116 @@ +if __name__ != "handlers.mods": + from secret.achievements import common + from objects import glob + from common.constants import mods +else: + import common + +VERSION = 4 +ORDER = 4 + +# Loads the achievement length on load +LENGTH = 0 + +ACHIEVEMENT_BASE = { + "name": "{name}", + "description": "{description}", + "icon": "all-intro-{mod}" +} + +ACHIEVEMENT_KEYS = { + "name": [ + "Finality", + "Perfectionist", + "Rock Around The Clock", + "Time And A Half", + "Sweet Rave Party", + "Blindsight", + "Are You Afraid Of The Dark?", + "Dial It Right Back", + "Risk Averse", + "Slowboat", + "Burned Out" + ], + "description": [ + "High stakes, no regrets.", + "Accept nothing but the best.", + "You can't stop the rock.", + "Having a right ol' time. One and a half of them, almost.", + "Founded in the fine tradition of changing things that were just fine as they were.", + "I can see just perfectly.", + "Harder than it looks, probably because it's hard to look.", + "Sometimes you just want to take it easy.", + "Safety nets are fun!", + "You got there. Eventually.", + "One cannot always spin to win." + ], + "mod": [ + "suddendeath", + "perfect", + "hardrock", + "doubletime", + "nightcore", + "hidden", + "flashlight", + "easy", + "nofail", + "halftime", + "spunout" + ] +} + +# For every itteration index gets increased, while mode and mode_2 gets increased every 4 itterations +ACHIEVEMENT_STRUCT = { + "name": 1, + "description": 1, + "mod": 1 +} + +ACHIEVEMENTS = [] + +def load(): + global ACHIEVEMENTS, LENGTH + ACHIEVEMENTS, LENGTH = common.load_achievement_data(ACHIEVEMENT_BASE, ACHIEVEMENT_KEYS, ACHIEVEMENT_STRUCT) + +def handle(mode, score, beatmap, user_data): + return check(score.mods) + +def check(m): + achievement_ids = [] + + # Yes I am braindead atm and dont want to think about it... + if m & mods.SUDDENDEATH > 0: + achievement_ids += [0] + if m & mods.PERFECT > 0: + achievement_ids += [1] + if m & mods.HARDROCK > 0: + achievement_ids += [2] + if m & mods.DOUBLETIME > 0: + achievement_ids += [3] + if m & mods.NIGHTCORE > 0: + achievement_ids += [4] + if m & mods.HIDDEN > 0: + achievement_ids += [5] + if m & mods.FLASHLIGHT > 0: + achievement_ids += [6] + if m & mods.EASY > 0: + achievement_ids += [7] + if m & mods.NOFAIL > 0: + achievement_ids += [8] + if m & mods.HALFTIME > 0: + achievement_ids += [9] + if m & mods.SPUNOUT > 0: + achievement_ids += [10] + if m & mods.RELAX > 0: + achievement_ids += [11] + + return achievement_ids + +def update(userID): + achievement_ids = [] + + entries = glob.db.fetchAll("SELECT mods FROM scores WHERE userid = %s GROUP BY mods", [userID]) + for entry in entries: + achievement_ids += check(entry["mods"]) + + return achievement_ids diff --git a/secret/achievements/handlers/playcount.py b/secret/achievements/handlers/playcount.py new file mode 100644 index 0000000..65b7068 --- /dev/null +++ b/secret/achievements/handlers/playcount.py @@ -0,0 +1,63 @@ +if __name__ != "handlers.playcount": + from secret.achievements import common + from objects import glob +else: + import common + +VERSION = 5 +ORDER = 5 + +# Loads the achievement length on load +LENGTH = 0 + +ACHIEVEMENT_BASE = { + "name": "{index_formatted} Plays", + "description": "{description}", + "icon": "osu-plays-{index}" +} + +ACHIEVEMENT_KEYS = { + "index": [5000, 15000, 25000, 50000], + "index_formatted": ["5,000", "15,000", "25,000", "50,000"], + "description": [ + "There's a lot more where that came from.", + "Must.. click.. circles..", + "There's no going back.", + "You're here forever." + ] +} + +# For every itteration index gets increased, while mode and mode_2 gets increased every 4 itterations +ACHIEVEMENT_STRUCT = { + "index": 1, + "index_formatted": 1, + "description": 1 +} + +ACHIEVEMENTS = [] + +def load(): + global ACHIEVEMENTS, LENGTH + ACHIEVEMENTS, LENGTH = common.load_achievement_data(ACHIEVEMENT_BASE, ACHIEVEMENT_KEYS, ACHIEVEMENT_STRUCT) + +def handle(mode, score, beatmap, user_data): + if mode is not 0: + return [] + return check(user_data["playcount"]) + +def check(playcount): + achievement_ids = [] + indexies = [x for x in ACHIEVEMENT_KEYS["index"] if x <= playcount] + + for index in range(len(indexies)): + achievement_ids.append(index) + + return achievement_ids + +def update(userID): + achievement_ids = [] + + playcount = glob.db.fetch("SELECT playcount_std FROM users_stats WHERE id = %s", [userID])["playcount_std"] + achievement_ids += check(playcount) + + return achievement_ids \ No newline at end of file diff --git a/secret/achievements/handlers/skillfc.py b/secret/achievements/handlers/skillfc.py new file mode 100644 index 0000000..5f54eba --- /dev/null +++ b/secret/achievements/handlers/skillfc.py @@ -0,0 +1,114 @@ +if __name__ != "handlers.skillfc": + import math + from secret.achievements import common + from common.ripple import scoreUtils + from objects import glob, beatmap +else: + import common + +VERSION = 3 +ORDER = 3 + +# Loads the achievement length on load +LENGTH = 0 + +ACHIEVEMENT_BASE = { + "name": "{name}", + "description": "{description}", + "icon": "{mode}-skill-fc-{index}" +} + +ACHIEVEMENT_KEYS = { + "index": [1, 2, 3, 4, 5, 6, 7, 8], + "mode": ["osu", "taiko", "fruits", "mania"], + "name": [ + "Totality", + "Keeping Time", + "Sweet And Sour", + "Keystruck", + "Business As Usual", + "To Your Own Beat", + "Reaching The Core", + "Keying In", + "Building Steam", + "Big Drums", + "Clean Platter", + "Hyperflow", + "Moving Forward", + "Adversity Overcome", + "Between The Rain", + "Breakthrough", + "Paradigm Shift", + "Demonslayer", + "Addicted", + "Everything Extra", + "Anguish Quelled", + "Rhythm's Call", + "Quickening", + "Level Breaker", + "Never Give Up", + "Time Everlasting", + "Supersonic", + "Step Up", + "Aberration", + "The Drummer's Throne", + "Dashing Scarlet", + "Behind The Veil" + ], + "description": [ + "All the notes. Every single one.", + "Two to go, please.", + "Hey, this isn't so bad.", + "Bet you feel good about that.", + "Surprisingly difficult.", + "Don't choke.", + "Excellence is its own reward.", + "They said it couldn't be done. They were wrong." + ] +} + +# For every itteration index gets increased, while mode and mode_2 gets increased every 4 itterations +ACHIEVEMENT_STRUCT = { + "name": 1, + "mode": 1, + "index": 4, + "description": 4 +} + +ACHIEVEMENTS = [] + +def load(): + global ACHIEVEMENTS, LENGTH + ACHIEVEMENTS, LENGTH = common.load_achievement_data(ACHIEVEMENT_BASE, ACHIEVEMENT_KEYS, ACHIEVEMENT_STRUCT) + +def handle(mode, score, beatmap, user_data): + if not score.fullCombo: # No need to check if the score were not a fullcombo + return [] + return check(mode, beatmap) + +def check(mode, beatmap): + achievement_ids = [] + + mode_str = scoreUtils.readableGameMode(mode) + + mode_2 = mode_str.replace("osu", "std") + stars = getattr(beatmap, "stars" + mode_2.title()) + + indexies = [x - 1 for x in ACHIEVEMENT_KEYS["index"] if x == math.floor(stars)] + + for index in indexies: + achievement_ids.append(mode + index * 4) + + return achievement_ids + +def update(userID): + achievement_ids = [] + + entries = glob.db.fetchAll("SELECT beatmap_md5, play_mode FROM scores WHERE full_combo = 1 AND completed >= 2 AND userid = %s GROUP BY beatmap_md5, play_mode", [userID]) + for entry in entries: + current_beatmap = beatmap.beatmap() + current_beatmap.setDataFromDB(entry["beatmap_md5"]) + + achievement_ids += check(entry["play_mode"], current_beatmap) + + return achievement_ids diff --git a/secret/achievements/handlers/skillpass.py b/secret/achievements/handlers/skillpass.py new file mode 100644 index 0000000..05803b1 --- /dev/null +++ b/secret/achievements/handlers/skillpass.py @@ -0,0 +1,112 @@ +if __name__ != "handlers.skillpass": + import math + from secret.achievements import common + from common.ripple import scoreUtils + from objects import glob, beatmap +else: + import common + +VERSION = 2 +ORDER = 2 + +# Loads the achievement length on load +LENGTH = 0 + +ACHIEVEMENT_BASE = { + "name": "{name}", + "description": "{description}", + "icon": "{mode}-skill-pass-{index}" +} + +ACHIEVEMENT_KEYS = { + "index": [1, 2, 3, 4, 5, 6, 7, 8], + "mode": ["osu", "taiko", "fruits", "mania"], + "name": [ + "Rising Star", + "My First Don", + "A Slice Of Life", + "First Steps", + "Constellation Prize", + "Katsu Katsu Katsu", + "Dashing Ever Forward", + "No Normal Player", + "Building Confidence", + "Not Even Trying", + "Zesty Disposition", + "Impulse Drive", + "Insanity Approaches", + "Face Your Demons", + "Hyperdash ON!", + "Hyperspeed", + "These Clarion Skies", + "The Demon Within", + "It's Raining Fruit", + "Ever Onwards", + "Above and Beyond", + "Drumbreaker", + "Fruit Ninja", + "Another Surpassed", + "Supremacy", + "The Godfather", + "Dreamcatcher", + "Extra Credit", + "Absolution", + "Rhythm Incarnate", + "Lord of the Catch", + "Maniac" + ], + "description": [ + "Can't go forward without the first steps.", + "Definitely not a consolation prize. Now things start getting hard!", + "Oh, you've SO got this.", + "You're not twitching, you're just ready.", + "Everything seems so clear now.", + "A cut above the rest.", + "All marvel before your prowess.", + "My god, you're full of stars!" + ] +} + +# For every itteration index gets increased, while mode and mode_2 gets increased every 4 itterations +ACHIEVEMENT_STRUCT = { + "name": 1, + "mode": 1, + "index": 4, + "description": 4 +} + +ACHIEVEMENTS = [] + +def load(): + global ACHIEVEMENTS, LENGTH + ACHIEVEMENTS, LENGTH = common.load_achievement_data(ACHIEVEMENT_BASE, ACHIEVEMENT_KEYS, ACHIEVEMENT_STRUCT) + +def handle(mode, score, beatmap, user_data): + return check(mode, beatmap) + +def check(mode, beatmap): + achievement_ids = [] + + mode_str = scoreUtils.readableGameMode(mode) + + mode_2 = mode_str.replace("osu", "std") + stars = getattr(beatmap, "stars" + mode_2.title()) + + indexies = [x - 1 for x in ACHIEVEMENT_KEYS["index"] if x == math.floor(stars)] + + for index in indexies: + achievement_ids.append(mode + index * 4) + + return achievement_ids + +def update(userID): + achievement_ids = [] + + entries = glob.db.fetchAll("SELECT beatmap_md5, play_mode FROM scores WHERE completed = 3 AND userid = %s", [userID]) + for entry in entries: + current_beatmap = beatmap.beatmap() + current_beatmap.setDataFromDB(entry["beatmap_md5"]) + + achievement_ids += check(entry["play_mode"], current_beatmap) + + return achievement_ids diff --git a/secret/achievements/install/db.py b/secret/achievements/install/db.py new file mode 100644 index 0000000..b5a955c --- /dev/null +++ b/secret/achievements/install/db.py @@ -0,0 +1,19 @@ +import MySQLdb +import install_glob as glob + +def run(): + c = glob.sqlcon.cursor() + make_0(c) + alt_0(c) + print("Eyy! Database has been updated.") + +def make_0(c): + c.execute("CREATE TABLE users_achievements (id int(11) NOT NULL,user_id int(11) NOT NULL,achievement_id int(11) NOT NULL,time int(11) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=latin1") + c.execute("ALTER TABLE users_achievements ADD PRIMARY KEY (id)") + c.execute("ALTER TABLE users_achievements MODIFY id int(11) NOT NULL AUTO_INCREMENT") + +def alt_0(c): + c.execute("ALTER TABLE users ADD achievements_version INT NOT NULL DEFAULT '0' AFTER rank") + +def make_connect(obj): + glob.sqlcon = MySQLdb.connect(**obj) \ No newline at end of file diff --git a/secret/achievements/install/init.py b/secret/achievements/install/init.py new file mode 100644 index 0000000..51ebb07 --- /dev/null +++ b/secret/achievements/install/init.py @@ -0,0 +1,21 @@ +import db + +def run(): + db.run() + +def setup_args(): + db.make_connect( + { + "host": input("Host: "), + "user": input("User: "), + "passwd": input("Password: "), + "db": input("Database: ") + } + ) + +def pass_args(obj): + db.make_connect(obj) + +if __name__ == "__main__": + setup_args() + run() \ No newline at end of file diff --git a/secret/achievements/install/install_glob.py b/secret/achievements/install/install_glob.py new file mode 100644 index 0000000..4cdcc8f --- /dev/null +++ b/secret/achievements/install/install_glob.py @@ -0,0 +1 @@ +sqlcon = None \ No newline at end of file diff --git a/secret/achievements/utils.py b/secret/achievements/utils.py new file mode 100644 index 0000000..448d4ad --- /dev/null +++ b/secret/achievements/utils.py @@ -0,0 +1,115 @@ +from objects import glob +from common.ripple import userUtils +from os.path import dirname, basename, isfile +import glob as _glob +import importlib +import json +from secret.achievements import common + +def load_achievements(): + """Load all the achievements from handler list into glob.achievementClasses, + and sets glob.ACHIEVEMENTS_VERSION to the highest version number in our achievement list. + """ + + modules = _glob.glob("secret/achievements/handlers/*.py") + modules = [basename(f)[:-3] for f in modules if isfile(f) and not f.endswith("__init__.py")] + # ^ cat face + + for module in modules: + module = importlib.import_module("secret.achievements.handlers." + module) + module.load() + if module.ORDER in glob.achievementClasses: + print("\n!!! FOUND OVERLAPPING ACHIEVEMENT ORDER FOR {}!!!".format(module.ORDER)) + print("Unable to load {} due to {} already loaded in slot {}\n".format(module.__name__, glob.achievementClasses[module.ORDER].__name__, module.ORDER)) + continue + glob.achievementClasses[module.ORDER] = module + glob.ACHIEVEMENTS_VERSION = max(glob.ACHIEVEMENTS_VERSION, module.VERSION) + + print("Loaded {} achievement classes!".format(len(glob.achievementClasses)), end=" ") + +def unlock_achievements_update(userID, version): + """Scans the user for past achievements they should have unlocked + + Arguments: + userID {int} -- User id of a player + version {int} -- Last achivement version the player had + + Returns: + Array -- List of achievements + """ + achievements = [] + + # Scan all past achivement versions from the user's achivement version to the latest + index = 1 + for handler in glob.achievementClasses.values(): + if handler.VERSION > version: + achievements += [x + index for x in handler.update(userID)] + index += handler.LENGTH + + # Update achivement version for user + userUtils.updateAchievementsVersion(userID) + + return achievements + +def unlock_achievements(score, beatmap, user_data): + """Return array of achievements the current play recived + + Arguments: + score {Score} -- Score data recived from replay + beatmap {Beatmap} -- Played beatmap + user_data {dict} -- Info about the current player + + Returns: + Array -- List of achievements for the current play + """ + achievements = [] + + userID = userUtils.getID(score.playerName) + user_cache = common.get_usercache(userID) + + # Get current gamemode and change value std to osu + gamemode_index = score.gameMode + + # Check if user should run achivement recheck + if user_cache["version"] < glob.ACHIEVEMENTS_VERSION: + achievements += unlock_achievements_update(userID, user_cache["version"]) + + # Check if gameplay should get new achivement + index = 1 + for handler in glob.achievementClasses.values(): + achievements += [x + index for x in handler.handle(gamemode_index, score, beatmap, user_data)] + index += handler.LENGTH + + # Add pending achievements that were added though redis or mysql + achievements += [-x for x in user_cache["achievements"] if x < 0] # Negative achievements id's means its pending + + # Remove pending achievements from redis object since we added it to the post achievements + user_cache["achievements"] = [x for x in user_cache["achievements"] if x > 0] + + # Remove duplicated achievements (incase of unlock_achievements_update adding stuff) + achievements = list(set(achievements)) + + # Remove already achived achievements from list + achievements = [x for x in achievements if x not in user_cache["achievements"]] + + user_cache["achievements"] += achievements + glob.redis.set("lets:user_achievement_cache:{}".format(userID), json.dumps(user_cache), 1800) + + for achievement in achievements: + userUtils.unlockAchievement(userID, achievement) + + return achievements + +def achievements_response(achievements): + achievement_objects = [] + + index = 1 + for handler in glob.achievementClasses.values(): + achievement_objects += [handler.ACHIEVEMENTS[x - index] for x in achievements if len(handler.ACHIEVEMENTS) > x - index and x - index >= 0] + index += handler.LENGTH + + achievements_packed = [] + for achievement_object in achievement_objects: + achievements_packed.append("+".join([achievement_object["icon"], achievement_object["name"], achievement_object["description"]])) + + return "/".join(achievements_packed) \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2cf03e9 --- /dev/null +++ b/setup.py @@ -0,0 +1,17 @@ +"""Cython build file""" +from distutils.core import setup +from distutils.extension import Extension +from Cython.Build import cythonize +import os + +cythonExt = [] +for root, dirs, files in os.walk(os.getcwd()): + for file in files: + if file.endswith(".pyx") and ".pyenv" not in root: # im sorry + filePath = os.path.relpath(os.path.join(root, file)) + cythonExt.append(Extension(filePath.replace("/", ".")[:-4], [filePath])) + +setup( + name = "lets pyx modules", + ext_modules = cythonize(cythonExt, nthreads = 4), +) \ No newline at end of file diff --git a/tomejerry.py b/tomejerry.py new file mode 100644 index 0000000..7fa72d1 --- /dev/null +++ b/tomejerry.py @@ -0,0 +1,616 @@ +import argparse +import logging +import math +import sys +import traceback +import warnings +from collections import namedtuple +from typing import Iterable, Optional, Union, List, Dict, Any + +import os +import threading +import time + +import MySQLdb.cursors +import progressbar +from abc import abstractmethod, ABC +from enum import Enum, IntEnum +from progressbar import DynamicMessage, FormatLabel + +from objects import beatmap +from objects import score +from common.db import dbConnector +from helpers import config +from objects import glob + + +MAX_WORKERS = 32 +UNIX = os.name == "posix" +FAILED_SCORES_LOGGER = None + + +RecalculatorQuery = namedtuple("RecalculatorQuery", "query parameters") + + +class WorkerStatus(IntEnum): + NOT_STARTED = 0 + RECALCULATING = 1 + SAVING = 2 + DONE = 3 + + +class LwScore: + """ + A lightweight score object, that can hold score id and pp only + """ + __slots__ = ("score_id", "pp") + + def __init__(self, score_id: Optional[int]=None, pp: Optional[int]=None, score_: Optional[score.score]=None): + """ + Initializes a new LwScore. Either score_id and pp OR just score must be provided. + + :param score_id: id of the score. Optional. + :param pp: pp. Optional. + :param score_: score object. Optional. + """ + if score_ is not None: + self.score_id = score_.scoreID + self.pp = score_.pp + elif score_id is not None and pp is not None: + self.score_id = score_id + self.pp = pp + else: + raise RuntimeError("") + + +class Recalculator(ABC): + """ + Base PP Recalculator + """ + def __init__(self, ids_query: RecalculatorQuery, count_query: RecalculatorQuery): + """ + Instantiates a new recalculator + + :param ids_query: `RecalculatorQuery` that fetches the `id`s of the scores of which pp will be recalculated + :param count_query: `RecalculatorQuery` that counts the _total_ number of the scoresof which pp will be + recalculated + """ + self.ids_query: RecalculatorQuery = ids_query + self.count_query: RecalculatorQuery = count_query + + @abstractmethod + def offset_ids_query(self, limit: int, offset: int) -> RecalculatorQuery: + """ + Returns a new `RecalculatorQuery` based on `self.ids_query`, but based with LIMIT and OFFSET. + Will be run by each worker to get their scores. + + :param limit: LIMIT value + :param offset: OFFSET value + :return: `RecalculatorQuery` with LIMIT and OFFSET + """ + raise NotImplementedError() + + +class SimpleRecalculator(Recalculator): + """ + A simple recalculator that can use a set of simple conditions, joined with logic ANDs + """ + def __init__( + self, + conditions: Union[Iterable[str], str], + parameters: Optional[Union[Iterable[str], Dict[str, Any]]]=None + ): + """ + Initializes a new SimpleRecalculator + + :param conditions: The conditions that will be joined with login ANDs. + They can be: + * an iterable (list, tuple, ...) of str (multiple conditions) + * str (one condition) + :param parameters: Iterable (list, tuple, ...) or dict that contains the query's parameters. + These will be passed to MySQLdb to bind the query's parameters (%s and %(name)s) + """ + if type(conditions) is list or type(conditions) is tuple: + conditions_str = " AND ".join(conditions) + elif type(conditions) is str: + conditions_str = conditions + else: + raise TypeError("`conditions` must be either a `str`, `tuple` or `list`") + q = "SELECT {} FROM scores JOIN beatmaps USING(beatmap_md5) WHERE {} ORDER BY scores.id DESC" + super(SimpleRecalculator, self).__init__( + ids_query=RecalculatorQuery(q.format("scores.id AS id", conditions_str), parameters), + count_query=RecalculatorQuery(q.format("COUNT(*) AS c", conditions_str), parameters) + ) + + def offset_ids_query(self, limit: int, offset: int) -> str: + return self.ids_query.query + " LIMIT {} OFFSET {}".format(limit, offset) + + +class ScoreIdsPool: + """ + Pool of score ids that needs to be recalculated. + """ + logger = logging.getLogger("score_ids_pool") + + def __init__(self): + """ + Initializes a new pool + """ + self._lock = threading.RLock() + self.scores = [] + + def load(self, recalculator: Recalculator): + """ + Loads score ids in the pool from a Recalculator instance + + :param recalculator: The recalculator instance that will be used to fetch the score ids + :return: + """ + with self._lock: + query_result = glob.db.fetchAll(recalculator.ids_query.query, recalculator.ids_query.parameters) + self.scores += [LwScore(x["id"], 0) for x in query_result] + self.logger.debug("Loaded {} scores".format(len(self.scores))) + + def chunk(self, chunk_size: int) -> List[int]: + """ + Returns a chunk of score ids of the specified size, and removes the chunk from the pool. + + :param chunk_size: size of the chunk + :return: score ids list + """ + with self._lock: + chunked_scores = self.scores[:chunk_size] + self.scores = self.scores[chunk_size:] + self.logger.debug("Chunked {} scores. Current scores in pool: {}".format(chunk_size, len(self.scores))) + return chunked_scores + + @property + def is_empty(self): + """ + Whether the pool is empty or not + + :return: `True` if the pool is empty else `False` + """ + return not bool(self.scores) + + +class Worker: + """ + A tomejerry worker. Recalculates pp for a set of scores. + """ + score_ids_pool = ScoreIdsPool() + + def __init__(self, chunk_size: int, worker_id: int=-1, start: bool=True): + """ + Initializes a new worker. + + :param chunk_size: Number of scores to process + :param worker_id: This worker's id. Optional. Default: -1. + :param start: Whether to start the worker immediately or not + :param + """ + self.worker_id: int = worker_id + self.thread: threading.Thread = None + self.logger: logging.Logger = logging.getLogger("w{}".format(worker_id)) + self.recalculated_scores_count: int = 0 + self.saved_scores_count: int = 0 + self.chunk_size: int = chunk_size + self.scores: List[LwScore] = self.score_ids_pool.chunk(self.chunk_size) + self.status: WorkerStatus = WorkerStatus.NOT_STARTED + self.failed_scores: int = 0 + if start: + self.threaded_work() + + def recycle(self, start: bool=True): + """ + Recycles this worker with a new chunk of scores + + :param start: Whether to start the worker immediately or not + :return: + """ + if self.thread.is_alive(): + raise RuntimeError("The thread is still alive") + del self.thread + self.thread = None + self.status = WorkerStatus.NOT_STARTED + self.scores = self.score_ids_pool.chunk(self.chunk_size) + self.logger.debug("Recycled with {} new scores".format(self.chunk_size)) + if start: + self.threaded_work() + + def recalc_score(self, score_data: Dict) -> score: + """ + Recalculates pp for a score + + :param score_data: dict containing score and beatmap information about a score. + :return: new `score` object, with `pp` attribute set to the new value + """ + # Create score object and set its data + s: score.score = score.score() + s.setDataFromDict(score_data) + s.passed = True + + # Create beatmap object and set its data + b: beatmap.beatmap = beatmap.beatmap() + b.setDataFromDict(score_data) + + # Calculate score pp + s.calculatePP(b) + del b + return s + + def _work(self): + """ + Run worker's work. Fetches scores, recalculates pp and saves the results in the database. + + :return: + """ + # Make sure the worker hasn't been disposed + if self.status == WorkerStatus.DONE: + raise RuntimeError("This worker has been disposed") + + self.logger.info("Started worker. Assigned {} scores".format(self.chunk_size)) + try: + # Recalculate all pp and save results in memory using LwScore objects + self.recalculate_pp() + + # Store the new pp values permanently in the database + self.save_recalculations() + finally: + # Mark the worker as disposed at the end + self.logger.debug("Disposing worker") + self.status = WorkerStatus.DONE + + def recalculate_pp(self): + """ + Recalculates the pp and saves results in memory + + :return: + """ + # We cannot use a SSDictCursor directly, because the connection will time out + # if the cursor doesn't consume every result before the `wait_timeout`, which is + # 600 seconds in MariaDB's default configuration. This means that we have to recalculate + # PPs for all scores in no more than 600 seconds, or we'll get a 'MySQL server has + # gone away error'. Fetching every score (joined with the respective beatmap) + # directly would take up too much RAM, so we fetch all the score_ids at the + # beginning with one query, store them in memory and fetch the data for + # each score, one by one, using the same connection (to avoid pool overhead) + self.status = WorkerStatus.RECALCULATING + # self.recalculated_scores_count = 0 + + # Fetch all score_ids + # self.scores = [LwScore(x["id"], 0) for x in glob.db.fetchAll(self.ids_query.query, self.ids_query.parameters)] + + # Get a db worker + cursor = None + db_worker = glob.db.pool.getWorker() + if db_worker is None: + self.logger.warning("Cannot fetch scores. No database worker available!!") + return + + try: + # Get a cursor (normal DictCursor) + cursor = db_worker.connection.cursor(MySQLdb.cursors.DictCursor) + for i, lw_score in enumerate(self.scores): + if i % self.log_every == 0: + self.logger.debug("Processed {}/{} scores".format(i, self.chunk_size)) + + # Fetch score and beatmap data for this id + cursor.execute( + "SELECT * FROM scores JOIN beatmaps USING(beatmap_md5) WHERE scores.id = %s LIMIT 1", + (lw_score.score_id,) + ) + score_ = cursor.fetchone() + try: + # Recalculate pp + recalculated_score = self.recalc_score(score_) + + if recalculated_score is not None: + # New score returned, store new pp in memory + self.scores[i].pp = recalculated_score.pp + if recalculated_score.pp == 0: + # PP calculator error + self.log_failed_score(score_, "0 pp") + + # Mark for garbage collection + del score_ + del recalculated_score + except Exception as e: + self.log_failed_score(score_, str(e), traceback_=True) + finally: + self.recalculated_scores_count += 1 + finally: + # Close cursor and connection + if cursor is not None: + cursor.close() + if db_worker is not None: + glob.db.pool.putWorker(db_worker) + self.logger.debug("PP Recalculated") + + def save_recalculations(self): + """ + Saves the recalculated performance points in the database + + :return: + """ + self.status = WorkerStatus.SAVING + # self.saved_scores_count = 0 + + # Make sure we've at least fetched the scores + if not self.scores: + self.logger.warning("No scores to update.") + return + + # Update db + self.logger.debug("Updating scores in database") + for i, lw_score in enumerate(self.scores): + if i % self.log_every == 0: + self.logger.debug("Updated {}/{} scores".format(i, self.chunk_size)) + glob.db.execute("UPDATE scores SET pp = %s WHERE id = %s LIMIT 1", (lw_score.pp, lw_score.score_id)) + self.saved_scores_count += 1 + + self.logger.debug("Scores updated") + + @property + def log_every(self) -> int: + """ + Number of scores that have to be processed before logging the worker's status + + :return: + """ + return max(min((self.chunk_size // 3), 1000), 1) + + def threaded_work(self): + """ + Starts this worker's work in a new thread + + :return: + """ + self.thread = threading.Thread(target=self._work) + self.thread.start() + + def log_failed_score(self, score_: Dict[str, Any], additional_information: str="", traceback_: bool=False): + """ + Logs a failed score. + + :param score_: score dict (from db) that triggered the error + :param additional_information: additional information (type of error) + :param traceback_: Whether the traceback should be logged or not. + It should be `True` if the logging was triggered by an unhandled exception + :return: + """ + msg = "" + if traceback_: + msg = "\n\n\nUnhandled exception: {}\n{}".format(sys.exc_info(), traceback.format_exc()) + msg += "score_id:{} ({})".format(score_["id"], additional_information).strip() + FAILED_SCORES_LOGGER.error(msg) + self.failed_scores += 1 + + +def mass_recalc(recalculator: Recalculator, workers_number: int=MAX_WORKERS, chunk_size: Optional[int]=None): + """ + Recalculate performance points for a set of scores, using multiple workers + + :param recalculator: the recalculator that will be used + :param workers_number: the number of workers to spawn + :return: + """ + start_time = time.time() + global FAILED_SCORES_LOGGER + workers = [] + + logging.info("Query: {} ({})".format(recalculator.ids_query.query, recalculator.ids_query.parameters)) + + # Fetch the total number of scores + total_scores = glob.db.fetch(recalculator.count_query.query, recalculator.count_query.parameters) + if total_scores is None: + logging.warning("No scores to recalc.") + return + + # Set up failed scores logger (creates file too) + FAILED_SCORES_LOGGER = logging.getLogger("failed_scores") + FAILED_SCORES_LOGGER.addHandler( + logging.FileHandler("tomejerry_failed_scores_{}.log".format(time.strftime("%d-%m-%Y--%H-%M-%S"))) + ) + + # Get the number of total scores from the result dict + total_scores = total_scores[next(iter(total_scores))] + logging.info("Total scores: {}".format(total_scores)) + if total_scores == 0: + return + + # for some reason `typing` believes that `math.ceil` returns a `float`, so we need an extra cast here... + scores_per_worker = int(math.ceil(total_scores / workers_number)) + logging.info("Using {} workers and {} scores per worker".format(workers_number, scores_per_worker)) + + # Load score ids in the pool + logging.info("Filling score ids pool") + Worker.score_ids_pool.load(recalculator) + + # Spawn the workers and start them + for i in range(workers_number): + workers.append( + Worker( + chunk_size=chunk_size + if chunk_size is not None + else len(Worker.score_ids_pool.scores) // workers_number // 3, + worker_id=i, + start=True + ) + ) + + # Progress bar loop + steps_text = { + WorkerStatus.NOT_STARTED: "Starting workers", + WorkerStatus.RECALCULATING: "Recalculating pp", + WorkerStatus.SAVING: "Updating db" + } + recycles = 0 + widgets = [ + "[ ", "Starting", " ]", + "w_pp:<>", "w_db:<>", "w_done:<>", "rec:0", + progressbar.FormatLabel(" %(value)s/%(max)s "), + progressbar.Bar(marker="#", left="[", right="]", fill="."), + progressbar.Percentage(), + " (", progressbar.ETA(), ") " + ] + with progressbar.ProgressBar( + widgets=widgets, + max_value=total_scores, + redirect_stdout=True, + redirect_stderr=True + ) as bar: + while True: + lowest_status = min([x.status for x in workers]) + + # Loop through all workers to get progress value + total_progress_value = sum( + [ + x.recalculated_scores_count if lowest_status != WorkerStatus.SAVING else x.saved_scores_count + for x in workers + ] + ) + + # Recycle the workers if needed + workers_done = [x for x in workers if x.status == WorkerStatus.DONE] + if workers_done and not Worker.score_ids_pool.is_empty: + logging.info("Recycling workers") + recycles += 1 + for worker in workers_done: + worker.recycle(start=True) + + # Output total status information + widgets[1] = steps_text.get(lowest_status, "...") + widgets[3] = " w_pp:<{}/{}>".format( + len([x for x in workers if x.status == WorkerStatus.RECALCULATING]), len(workers) + ) + widgets[4] = " w_db:<{}/{}>".format( + len([x for x in workers if x.status == WorkerStatus.SAVING]), len(workers) + ) + widgets[5] = " w_done:<{}/{}>".format(len(workers_done), len(workers)) + widgets[6] = " rec:{}".format(recycles) + bar.update(total_progress_value) + + # Exit from the loop if every worker has finished its work + if len(workers_done) == len(workers): + break + + # Wait 0.5 s and update the progress bar again + time.sleep(0.5) + + # Recalc done. Print some stats + end_time = time.time() + failed_scores = sum([x.failed_scores for x in workers]) + logging.info( + "\n\nDone!\n" + ":: Recalculated\t{} scores\n" + ":: Failed\t{} scores\n" + ":: Total\t{} scores\n\n" + ":: Took\t{:.2f} seconds".format( + total_scores - failed_scores, + failed_scores, + total_scores, + end_time - start_time + ) + ) + + +def main(): + # CLI stuff + parser = argparse.ArgumentParser(description="pp recalc tool for ripple, new version.") + recalc_group = parser.add_mutually_exclusive_group(required=False) + recalc_group.add_argument( + "-r", "--recalc", help="calculates pp for all high scores", required=False, action="store_true" + ) + recalc_group.add_argument( + "-z", "--zero", help="calculates pp for 0 pp high scores", required=False, action="store_true" + ) + recalc_group.add_argument("-i", "--id", help="calculates pp for the score with this score_id", required=False) + recalc_group.add_argument( + "-m", "--mods", help="calculates pp for high scores with these mods (flags)", required=False + ) + recalc_group.add_argument( + "-g", "--gamemode", help="calculates pp for scores played on this game mode (std:0, taiko:1, ctb:2, mania:3)", + required=False + ) + recalc_group.add_argument( + "-u", "--userid", help="calculates pp for high scores set by a specific user (user_id)", required=False + ) + recalc_group.add_argument( + "-b", "--beatmapid", help="calculates pp for high scores played on a specific beatmap (beatmap_id)", required=False + ) + recalc_group.add_argument( + "-fhd", "--fixstdhd", help="calculates pp for std hd high scores (14/05/2018 pp algorithm changes)", + required=False, action="store_true" + ) + parser.add_argument("-w", "--workers", help="number of workers. {} by default. Max {}".format( + MAX_WORKERS // 2, MAX_WORKERS + ), required=False) + parser.add_argument("-cs", "--chunksize", help="score chunks size", required=False) + parser.add_argument("-v", "--verbose", help="verbose/debug mode", required=False, action="store_true") + args = parser.parse_args() + + # Logging + progressbar.streams.wrap_stderr() + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) + logging.info("Running under {}".format("UNIX" if UNIX else "WIN32")) + + # Load config + logging.info("Reading config file") + glob.conf = config.config("config.ini") + + # Get workers from arguments if set + workers_number = MAX_WORKERS // 2 + if args.workers is not None: + workers_number = int(args.workers) + + # Get chunk size from arguments if set + chunk_size = None + if args.chunksize is not None: + chunk_size = int(args.chunksize) + + # Disable MySQL db warnings (it spams 'Unsafe statement written to the binary log using statement...' + # because we use UPDATE with LIMIT 1 when updating performance points after recalculation + warnings.filterwarnings("ignore", category=MySQLdb.Warning) + + # Connect to MySQL + logging.info("Connecting to MySQL db") + glob.db = dbConnector.db( + glob.conf.config["db"]["host"], + glob.conf.config["db"]["username"], + glob.conf.config["db"]["password"], + glob.conf.config["db"]["database"], + max(workers_number, MAX_WORKERS) + ) + + # Set verbose + glob.debug = args.verbose + + # Get recalculator + recalculators_gen = { + "zero": lambda: SimpleRecalculator(("scores.completed = 3", "pp = 0")), + "recalc": lambda: SimpleRecalculator(("scores.completed = 3",)), + "mods": lambda: SimpleRecalculator(("scores.completed = 3", "mods & %s > 0"), (args.mods,)), + "id": lambda: SimpleRecalculator(("scores.id = %s",), (args.id,)), + "gamemode": lambda: SimpleRecalculator(("scores.completed = 3", "scores.play_mode = %s",), (args.gamemode,)), + "userid": lambda: SimpleRecalculator(("scores.completed = 3", "scores.userid = %s",), (args.userid,)), + "beatmapid": lambda: SimpleRecalculator(("scores.completed = 3", "beatmaps.beatmap_id = %s",), (args.beatmapid,)), + "fixstdhd": lambda: SimpleRecalculator(("scores.completed = 3", "scores.play_mode = 0", "scores.mods & 8 > 0")) + } + recalculator = None + for k, v in vars(args).items(): + if v is not None and ((type(v) is bool and v) or type(v) is not bool): + if k in recalculators_gen: + recalculator = recalculators_gen[k]() + break + + # Execute mass recalc + if recalculator is not None: + mass_recalc(recalculator, workers_number, chunk_size) + else: + logging.warning("No recalc option specified") + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/tomejerryrelax.py b/tomejerryrelax.py new file mode 100644 index 0000000..10d95c2 --- /dev/null +++ b/tomejerryrelax.py @@ -0,0 +1,621 @@ +import argparse +import logging +import math +import sys +import traceback +import warnings +from collections import namedtuple +from typing import Iterable, Optional, Union, List, Dict, Any + +import os +import threading +import time + +import MySQLdb.cursors +import progressbar +from abc import abstractmethod, ABC +from enum import Enum, IntEnum +from progressbar import DynamicMessage, FormatLabel + +from objects import beatmap +from objects import rxscore +from common.db import dbConnector +from helpers import config +from objects import glob + + +MAX_WORKERS = 32 +UNIX = os.name == "posix" +FAILED_SCORES_LOGGER = None + + +RecalculatorQuery = namedtuple("RecalculatorQuery", "query parameters") + + +class WorkerStatus(IntEnum): + NOT_STARTED = 0 + RECALCULATING = 1 + SAVING = 2 + DONE = 3 + + +class LwScore: + """ + A lightweight score object, that can hold score id and pp only + """ + __slots__ = ("score_id", "pp") + + def __init__(self, score_id: Optional[int]=None, pp: Optional[int]=None, score_: Optional[rxscore.score]=None): + """ + Initializes a new LwScore. Either score_id and pp OR just score must be provided. + + :param score_id: id of the score. Optional. + :param pp: pp. Optional. + :param score_: score object. Optional. + """ + if score_ is not None: + self.score_id = score_.scoreID + self.pp = score_.pp + elif score_id is not None and pp is not None: + self.score_id = score_id + self.pp = pp + else: + raise RuntimeError("") + + +class Recalculator(ABC): + """ + Base PP Recalculator + """ + def __init__(self, ids_query: RecalculatorQuery, count_query: RecalculatorQuery): + """ + Instantiates a new recalculator + + :param ids_query: `RecalculatorQuery` that fetches the `id`s of the scores of which pp will be recalculated + :param count_query: `RecalculatorQuery` that counts the _total_ number of the scoresof which pp will be + recalculated + """ + self.ids_query: RecalculatorQuery = ids_query + self.count_query: RecalculatorQuery = count_query + + @abstractmethod + def offset_ids_query(self, limit: int, offset: int) -> RecalculatorQuery: + """ + Returns a new `RecalculatorQuery` based on `self.ids_query`, but based with LIMIT and OFFSET. + Will be run by each worker to get their scores. + + :param limit: LIMIT value + :param offset: OFFSET value + :return: `RecalculatorQuery` with LIMIT and OFFSET + """ + raise NotImplementedError() + + +class SimpleRecalculator(Recalculator): + """ + A simple recalculator that can use a set of simple conditions, joined with logic ANDs + """ + def __init__( + self, + conditions: Union[Iterable[str], str], + parameters: Optional[Union[Iterable[str], Dict[str, Any]]]=None + ): + """ + Initializes a new SimpleRecalculator + + :param conditions: The conditions that will be joined with login ANDs. + They can be: + * an iterable (list, tuple, ...) of str (multiple conditions) + * str (one condition) + :param parameters: Iterable (list, tuple, ...) or dict that contains the query's parameters. + These will be passed to MySQLdb to bind the query's parameters (%s and %(name)s) + """ + if type(conditions) is list or type(conditions) is tuple: + conditions_str = " AND ".join(conditions) + elif type(conditions) is str: + conditions_str = conditions + else: + raise TypeError("`conditions` must be either a `str`, `tuple` or `list`") + q = "SELECT {} FROM scores_relax JOIN beatmaps USING(beatmap_md5) WHERE {} ORDER BY scores_relax.id DESC" + super(SimpleRecalculator, self).__init__( + ids_query=RecalculatorQuery(q.format("scores_relax.id AS id", conditions_str), parameters), + count_query=RecalculatorQuery(q.format("COUNT(*) AS c", conditions_str), parameters) + ) + + def offset_ids_query(self, limit: int, offset: int) -> str: + return self.ids_query.query + " LIMIT {} OFFSET {}".format(limit, offset) + + +class ScoreIdsPool: + """ + Pool of score ids that needs to be recalculated. + """ + logger = logging.getLogger("score_ids_pool") + + def __init__(self): + """ + Initializes a new pool + """ + self._lock = threading.RLock() + self.scores = [] + + def load(self, recalculator: Recalculator): + """ + Loads score ids in the pool from a Recalculator instance + + :param recalculator: The recalculator instance that will be used to fetch the score ids + :return: + """ + with self._lock: + query_result = glob.db.fetchAll(recalculator.ids_query.query, recalculator.ids_query.parameters) + self.scores += [LwScore(x["id"], 0) for x in query_result] + self.logger.debug("Loaded {} scores".format(len(self.scores))) + + def chunk(self, chunk_size: int) -> List[int]: + """ + Returns a chunk of score ids of the specified size, and removes the chunk from the pool. + + :param chunk_size: size of the chunk + :return: score ids list + """ + with self._lock: + chunked_scores = self.scores[:chunk_size] + self.scores = self.scores[chunk_size:] + self.logger.debug("Chunked {} scores. Current scores in pool: {}".format(chunk_size, len(self.scores))) + return chunked_scores + + @property + def is_empty(self): + """ + Whether the pool is empty or not + + :return: `True` if the pool is empty else `False` + """ + return not bool(self.scores) + + +class Worker: + """ + A tomejerry worker. Recalculates pp for a set of scores. + """ + score_ids_pool = ScoreIdsPool() + + def __init__(self, chunk_size: int, worker_id: int=-1, start: bool=True): + """ + Initializes a new worker. + + :param chunk_size: Number of scores to process + :param worker_id: This worker's id. Optional. Default: -1. + :param start: Whether to start the worker immediately or not + :param + """ + self.worker_id: int = worker_id + self.thread: threading.Thread = None + self.logger: logging.Logger = logging.getLogger("w{}".format(worker_id)) + self.recalculated_scores_count: int = 0 + self.saved_scores_count: int = 0 + self.chunk_size: int = chunk_size + self.scores: List[LwScore] = self.score_ids_pool.chunk(self.chunk_size) + self.status: WorkerStatus = WorkerStatus.NOT_STARTED + self.failed_scores: int = 0 + if start: + self.threaded_work() + + def recycle(self, start: bool=True): + """ + Recycles this worker with a new chunk of scores + + :param start: Whether to start the worker immediately or not + :return: + """ + if self.thread.is_alive(): + raise RuntimeError("The thread is still alive") + del self.thread + self.thread = None + self.status = WorkerStatus.NOT_STARTED + self.scores = self.score_ids_pool.chunk(self.chunk_size) + self.logger.debug("Recycled with {} new scores".format(self.chunk_size)) + if start: + self.threaded_work() + + def recalc_score(self, score_data: Dict) -> rxscore: + """ + Recalculates pp for a score + + :param score_data: dict containing score and beatmap information about a score. + :return: new `score` object, with `pp` attribute set to the new value + """ + # Create score object and set its data + s: rxscore.score = rxscore.score() + s.setDataFromDict(score_data) + s.passed = True + + # Create beatmap object and set its data + b: beatmap.beatmap = beatmap.beatmap() + b.setDataFromDict(score_data) + + # Calculate score pp + s.calculatePP(b) + del b + return s + + def _work(self): + """ + Run worker's work. Fetches scores, recalculates pp and saves the results in the database. + + :return: + """ + # Make sure the worker hasn't been disposed + if self.status == WorkerStatus.DONE: + raise RuntimeError("This worker has been disposed") + + self.logger.info("Started worker. Assigned {} scores".format(self.chunk_size)) + try: + # Recalculate all pp and save results in memory using LwScore objects + self.recalculate_pp() + + # Store the new pp values permanently in the database + self.save_recalculations() + finally: + # Mark the worker as disposed at the end + self.logger.debug("Disposing worker") + self.status = WorkerStatus.DONE + + def recalculate_pp(self): + """ + Recalculates the pp and saves results in memory + + :return: + """ + # We cannot use a SSDictCursor directly, because the connection will time out + # if the cursor doesn't consume every result before the `wait_timeout`, which is + # 600 seconds in MariaDB's default configuration. This means that we have to recalculate + # PPs for all scores in no more than 600 seconds, or we'll get a 'MySQL server has + # gone away error'. Fetching every score (joined with the respective beatmap) + # directly would take up too much RAM, so we fetch all the score_ids at the + # beginning with one query, store them in memory and fetch the data for + # each score, one by one, using the same connection (to avoid pool overhead) + self.status = WorkerStatus.RECALCULATING + # self.recalculated_scores_count = 0 + + # Fetch all score_ids + # self.scores = [LwScore(x["id"], 0) for x in glob.db.fetchAll(self.ids_query.query, self.ids_query.parameters)] + + # Get a db worker + cursor = None + db_worker = glob.db.pool.getWorker() + if db_worker is None: + self.logger.warning("Cannot fetch scores. No database worker available!!") + return + + try: + # Get a cursor (normal DictCursor) + cursor = db_worker.connection.cursor(MySQLdb.cursors.DictCursor) + for i, lw_score in enumerate(self.scores): + if i % self.log_every == 0: + self.logger.debug("Processed {}/{} scores".format(i, self.chunk_size)) + + # Fetch score and beatmap data for this id + cursor.execute( + "SELECT * FROM scores_relax JOIN beatmaps USING(beatmap_md5) WHERE scores_relax.id = %s LIMIT 1", + (lw_score.score_id,) + ) + score_ = cursor.fetchone() + try: + # Recalculate pp + recalculated_score = self.recalc_score(score_) + + if recalculated_score is not None: + # New score returned, store new pp in memory + self.scores[i].pp = recalculated_score.pp + if recalculated_score.pp == 0: + # PP calculator error + self.log_failed_score(score_, "0 pp") + + # Mark for garbage collection + del score_ + del recalculated_score + except Exception as e: + self.log_failed_score(score_, str(e), traceback_=True) + finally: + self.recalculated_scores_count += 1 + finally: + # Close cursor and connection + if cursor is not None: + cursor.close() + if db_worker is not None: + glob.db.pool.putWorker(db_worker) + self.logger.debug("PP Recalculated") + + def save_recalculations(self): + """ + Saves the recalculated performance points in the database + + :return: + """ + self.status = WorkerStatus.SAVING + # self.saved_scores_count = 0 + + # Make sure we've at least fetched the scores + if not self.scores: + self.logger.warning("No scores to update.") + return + + # Update db + self.logger.debug("Updating scores in database") + for i, lw_score in enumerate(self.scores): + if i % self.log_every == 0: + self.logger.debug("Updated {}/{} scores".format(i, self.chunk_size)) + glob.db.execute("UPDATE scores_relax SET pp = %s WHERE id = %s LIMIT 1", (lw_score.pp, lw_score.score_id)) + self.saved_scores_count += 1 + + self.logger.debug("Scores updated") + + @property + def log_every(self) -> int: + """ + Number of scores that have to be processed before logging the worker's status + + :return: + """ + return max(min((self.chunk_size // 3), 1000), 1) + + def threaded_work(self): + """ + Starts this worker's work in a new thread + + :return: + """ + self.thread = threading.Thread(target=self._work) + self.thread.start() + + def log_failed_score(self, score_: Dict[str, Any], additional_information: str="", traceback_: bool=False): + """ + Logs a failed score. + + :param score_: score dict (from db) that triggered the error + :param additional_information: additional information (type of error) + :param traceback_: Whether the traceback should be logged or not. + It should be `True` if the logging was triggered by an unhandled exception + :return: + """ + msg = "" + if traceback_: + msg = "\n\n\nUnhandled exception: {}\n{}".format(sys.exc_info(), traceback.format_exc()) + msg += "score_id:{} ({})".format(score_["id"], additional_information).strip() + FAILED_SCORES_LOGGER.error(msg) + self.failed_scores += 1 + + +def mass_recalc(recalculator: Recalculator, workers_number: int=MAX_WORKERS, chunk_size: Optional[int]=None): + """ + Recalculate performance points for a set of scores, using multiple workers + + :param recalculator: the recalculator that will be used + :param workers_number: the number of workers to spawn + :return: + """ + start_time = time.time() + global FAILED_SCORES_LOGGER + workers = [] + + logging.info("Query: {} ({})".format(recalculator.ids_query.query, recalculator.ids_query.parameters)) + + # Fetch the total number of scores + total_scores = glob.db.fetch(recalculator.count_query.query, recalculator.count_query.parameters) + if total_scores is None: + logging.warning("No scores to recalc.") + return + + # Set up failed scores logger (creates file too) + FAILED_SCORES_LOGGER = logging.getLogger("failed_scores") + FAILED_SCORES_LOGGER.addHandler( + logging.FileHandler("tomejerry_failed_scores_{}.log".format(time.strftime("%d-%m-%Y--%H-%M-%S"))) + ) + + # Get the number of total scores from the result dict + total_scores = total_scores[next(iter(total_scores))] + logging.info("Total scores: {}".format(total_scores)) + if total_scores == 0: + return + + # for some reason `typing` believes that `math.ceil` returns a `float`, so we need an extra cast here... + scores_per_worker = int(math.ceil(total_scores / workers_number)) + logging.info("Using {} workers and {} scores per worker".format(workers_number, scores_per_worker)) + + # Load score ids in the pool + logging.info("Filling score ids pool") + Worker.score_ids_pool.load(recalculator) + + # Spawn the workers and start them + for i in range(workers_number): + workers.append( + Worker( + chunk_size=chunk_size + if chunk_size is not None + else len(Worker.score_ids_pool.scores) // workers_number // 3, + worker_id=i, + start=True + ) + ) + + # Progress bar loop + steps_text = { + WorkerStatus.NOT_STARTED: "Starting workers", + WorkerStatus.RECALCULATING: "Recalculating pp", + WorkerStatus.SAVING: "Updating db" + } + recycles = 0 + widgets = [ + "[ ", "Starting", " ]", + "w_pp:<>", "w_db:<>", "w_done:<>", "rec:0", + progressbar.FormatLabel(" %(value)s/%(max)s "), + progressbar.Bar(marker="#", left="[", right="]", fill="."), + progressbar.Percentage(), + " (", progressbar.ETA(), ") " + ] + with progressbar.ProgressBar( + widgets=widgets, + max_value=total_scores, + redirect_stdout=True, + redirect_stderr=True + ) as bar: + while True: + lowest_status = min([x.status for x in workers]) + + # Loop through all workers to get progress value + total_progress_value = sum( + [ + x.recalculated_scores_count if lowest_status != WorkerStatus.SAVING else x.saved_scores_count + for x in workers + ] + ) + + # Recycle the workers if needed + workers_done = [x for x in workers if x.status == WorkerStatus.DONE] + if workers_done and not Worker.score_ids_pool.is_empty: + logging.info("Recycling workers") + recycles += 1 + for worker in workers_done: + worker.recycle(start=True) + + # Output total status information + widgets[1] = steps_text.get(lowest_status, "...") + widgets[3] = " w_pp:<{}/{}>".format( + len([x for x in workers if x.status == WorkerStatus.RECALCULATING]), len(workers) + ) + widgets[4] = " w_db:<{}/{}>".format( + len([x for x in workers if x.status == WorkerStatus.SAVING]), len(workers) + ) + widgets[5] = " w_done:<{}/{}>".format(len(workers_done), len(workers)) + widgets[6] = " rec:{}".format(recycles) + bar.update(total_progress_value) + + # Exit from the loop if every worker has finished its work + if len(workers_done) == len(workers): + break + + # Wait 0.5 s and update the progress bar again + time.sleep(0.5) + + # Recalc done. Print some stats + end_time = time.time() + failed_scores = sum([x.failed_scores for x in workers]) + logging.info( + "\n\nDone!\n" + ":: Recalculated\t{} scores\n" + ":: Failed\t{} scores\n" + ":: Total\t{} scores\n\n" + ":: Took\t{:.2f} seconds".format( + total_scores - failed_scores, + failed_scores, + total_scores, + end_time - start_time + ) + ) + + +def main(): + # CLI stuff + parser = argparse.ArgumentParser(description="pp recalc tool for ripple, new version.") + recalc_group = parser.add_mutually_exclusive_group(required=False) + recalc_group.add_argument( + "-r", "--recalc", help="calculates pp for all high scores", required=False, action="store_true" + ) + recalc_group.add_argument( + "-z", "--zero", help="calculates pp for 0 pp high scores", required=False, action="store_true" + ) + recalc_group.add_argument("-i", "--id", help="calculates pp for the score with this score_id", required=False) + recalc_group.add_argument( + "-m", "--mods", help="calculates pp for high scores with these mods (flags)", required=False + ) + recalc_group.add_argument( + "-g", "--gamemode", help="calculates pp for scores played on this game mode (std:0, taiko:1, ctb:2, mania:3)", + required=False + ) + recalc_group.add_argument( + "-l", "--loved", help="calculate pp for loved maps", required=False + ) + recalc_group.add_argument( + "-u", "--userid", help="calculates pp for high scores set by a specific user (user_id)", required=False + ) + recalc_group.add_argument( + "-b", "--beatmapid", help="calculates pp for high scores played on a specific beatmap (beatmap_id)", required=False + ) + recalc_group.add_argument( + "-fhd", "--fixstdhd", help="calculates pp for std hd high scores (14/05/2018 pp algorithm changes)", + required=False, action="store_true" + ) + parser.add_argument("-w", "--workers", help="number of workers. {} by default. Max {}".format( + MAX_WORKERS // 2, MAX_WORKERS + ), required=False) + parser.add_argument("-cs", "--chunksize", help="score chunks size", required=False) + parser.add_argument("-v", "--verbose", help="verbose/debug mode", required=False, action="store_true") + args = parser.parse_args() + + # Logging + progressbar.streams.wrap_stderr() + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) + logging.info("Running under {}".format("UNIX" if UNIX else "WIN32")) + + # Load config + logging.info("Reading config file") + glob.conf = config.config("config.ini") + + # Get workers from arguments if set + workers_number = MAX_WORKERS // 2 + if args.workers is not None: + workers_number = int(args.workers) + + # Get chunk size from arguments if set + chunk_size = None + if args.chunksize is not None: + chunk_size = int(args.chunksize) + + # Disable MySQL db warnings (it spams 'Unsafe statement written to the binary log using statement...' + # because we use UPDATE with LIMIT 1 when updating performance points after recalculation + warnings.filterwarnings("ignore", category=MySQLdb.Warning) + + # Connect to MySQL + logging.info("Connecting to MySQL db") + glob.db = dbConnector.db( + glob.conf.config["db"]["host"], + glob.conf.config["db"]["username"], + glob.conf.config["db"]["password"], + glob.conf.config["db"]["database"], + max(workers_number, MAX_WORKERS) + ) + + # Set verbose + glob.debug = args.verbose + + # Get recalculator + recalculators_gen = { + "zero": lambda: SimpleRecalculator(("scores_relax.completed = 3", "pp = 0")), + "recalc": lambda: SimpleRecalculator(("scores_relax.completed = 3", "pp > 750")), + "mods": lambda: SimpleRecalculator(("scores_relax.completed = 3", "mods & %s > 0"), (args.mods,)), + "id": lambda: SimpleRecalculator(("scores_relax.id = %s",), (args.id,)), + "gamemode": lambda: SimpleRecalculator(("scores_relax.completed = 3", "scores_relax.play_mode = %s",), (args.gamemode,)), + "loved": lambda: SimpleRecalculator(("scores_relax.completed = 3", "beatmaps.ranked = 5")), + "userid": lambda: SimpleRecalculator(("scores_relax.completed = 3", "scores_relax.userid = %s",), (args.userid,)), + "beatmapid": lambda: SimpleRecalculator(("scores_relax.completed = 3", "beatmaps.beatmap_id = %s",), (args.beatmapid,)), + "fixstdhd": lambda: SimpleRecalculator(("scores_relax.completed = 3", "scores_relax.play_mode = 0", "scores_relax.mods & 8 > 0")) + } + recalculator = None + for k, v in vars(args).items(): + if v is not None and ((type(v) is bool and v) or type(v) is not bool): + if k in recalculators_gen: + recalculator = recalculators_gen[k]() + break + + # Execute mass recalc + if recalculator is not None: + mass_recalc(recalculator, workers_number, chunk_size) + else: + logging.warning("No recalc option specified") + parser.print_help() + + +if __name__ == "__main__": + main() + diff --git a/userStatsCache.py b/userStatsCache.py new file mode 100644 index 0000000..ff26bcd --- /dev/null +++ b/userStatsCache.py @@ -0,0 +1,77 @@ +from common.log import logUtils as log +from common.ripple import userUtils +from objects import glob +import json + +class userStatsCache: + def get(self, userID, gameMode): + """ + Get cached user stats from redis. + If user stats are not cached, they'll be read from db, cached and returned + + :param userID: userID + :param gameMode: game mode number + :return: userStats dictionary (rankedScore, totalScore, pp, accuracy, playcount) + """ + data = glob.redis.get("lets:user_stats_cache:{}:{}".format(gameMode, userID)) + if data is None: + # If data is not cached, cache it and call get function again + log.debug("userStatsCache miss") + self.update(userID, gameMode) + return self.get(userID, gameMode) + + log.debug("userStatsCache hit") + retData = json.loads(data.decode("utf-8")) + return retData + + def update(self, userID, gameMode, data = None): + """ + Update cached user stats in redis with new values + + :param userID: userID + :param gameMode: game mode number + :param data: data to cache. Optional. If not passed, will get from db + :return: + """ + if data is None: + data = {} + if len(data) == 0: + data = userUtils.getUserStats(userID, gameMode) + log.debug("userStatsCache set {}".format(data)) + glob.redis.set("lets:user_stats_cache:{}:{}".format(gameMode, userID), json.dumps(data), 1800) + + def rxget(self, userID, gameMode): + """ + Get cached user stats from redis. + If user stats are not cached, they'll be read from db, cached and returned + + :param userID: userID + :param gameMode: game mode number + :return: userStats dictionary (rankedScore, totalScore, pp, accuracy, playcount) + """ + data = glob.redis.get("lets:rx_user_stats_cache:{}:{}".format(gameMode, userID)) + if data is None: + # If data is not cached, cache it and call get function again + log.debug("userStatsCache miss") + self.update(userID, gameMode) + return self.get(userID, gameMode) + + log.debug("userStatsCache hit") + retData = json.loads(data.decode("utf-8")) + return retData + + def rxupdate(self, userID, gameMode, data = None): + """ + Update cached user stats in redis with new values + + :param userID: userID + :param gameMode: game mode number + :param data: data to cache. Optional. If not passed, will get from db + :return: + """ + if data is None: + data = {} + if len(data) == 0: + data = userUtils.getUserStats(userID, gameMode) + log.debug("userStatsCache set {}".format(data)) + glob.redis.set("lets:rx_user_stats_cache:{}:{}".format(gameMode, userID), json.dumps(data), 1800) \ No newline at end of file diff --git a/version b/version new file mode 100644 index 0000000..867e524 --- /dev/null +++ b/version @@ -0,0 +1 @@ +1.2.0 \ No newline at end of file