diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d122b7e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,63 @@
+# Changelog
+
+All notable changes to the Drum Highway plugin. The format follows
+[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow `plugin.json`.
+
+## [4.4.0] - Unreleased
+
+### Added
+- **Drums settings page** (Plugins → Drums, `screen.html`): every drum setting on one page — difficulty and
+ assists, kit with **Play test** and volumes, MIDI input and channel, view, offset, keyboard, 2D options —
+ plus a **pad tester** (lights the lane each pad counts as; Learn works there), a **MIDI status** line
+ (hits received, last note, why a hit was dropped) and **Reset drum settings**. In sync with the player's
+ ⚙ panel, which also gains the MIDI status line.
+- **End-of-song card** (#1): final score with bonuses, stars, accuracy, notes hit, best streak / full combo
+ and a breakdown of the points; the **best score per song and difficulty** is kept in this browser and
+ listed on the settings page.
+
+## [4.3.0] - Unreleased
+
+### Added
+- **Drum kits** (#3) — a **Kit** setting with six sounds: two real multi-sampled acoustic kits with velocity
+ layers, round-robins and hi-hat chokes — **Crocell** (rock, CC BY 4.0, the new default) and
+ **Virtuosity** (jazz, CC0), played by `assets/kits.js` — and four WebAudioFont GM kits (JCLive,
+ FluidR3 GM, Sound Blaster Live!, Chaos). `tools/build_sample_kit.py` rebuilds the sampled kits from their
+ source libraries.
+
+### Changed
+- The drum sounds are bundled in `assets/sounds/` (about 12 MB) instead of loading WebAudioFont from
+ surikov.github.io, so the synth works offline and loads nothing from third-party sites.
+
+## [4.2.0] - Unreleased
+
+### Added
+- **Rock Band-style 3D drum track** (`assets/highway3d.js`, on core's vendored three.js): pad lanes and a
+ kick bar, bevelled rectangle pad gems and domed cymbal gems, rectangular strikeline targets, hit flashes
+ and sparks, star power and fill highlighting, drum-solo tint, and a HUD with streak, multiplier, star
+ power, score, stars, accuracy and the difficulty badge. Scored by the same session as the 2D view.
+- **View** setting — Auto (3D with WebGL2) / 3D / 2D; the main player swaps renderers on change.
+ `feedBackViz_drums3d` / `feedBackViz_drums2d` for hosts that want one view.
+- **Keyboard drumming** in both views (**Keys**, on by default): B kick, F red, J/K/L toms,
+ Shift+J/K/L or U/I/O cymbals, Enter = star power.
+
+## [4.1.0] - Unreleased
+
+### Added
+- **Rock Band-style scoring** from a JavaScript port of YARG.Core's drums engine (`assets/engine.js`,
+ LGPL-3.0, see `NOTICE.md`): ±70 ms hit window, multiplier up to x4 (x8 in star power), pro-drums
+ cymbal/tom lanes, accent/ghost dynamics bonus, stars, and star power with fill activation. Driven
+ through `assets/session.js`, which also adds spot-on timing, roll and drum-solo bonuses. The 2D view
+ marks hits / misses from the engine and shows a score HUD when **Score** is on.
+- **Difficulty per player** — Easy, Medium, Hard, Expert, Expert+ (`drums_difficulty_v1`), from the
+ settings panel, the HUD badge or D / Shift+D; Expert drops the double-kick notes. Easier levels, star
+ power, fills and solos come from the drum tab's feedpak 1.20.0 gameplay metadata; without it the song
+ plays Expert only, without star power.
+- **Assists** — auto kick / auto cymbals up to a chosen difficulty (played on the drum sound at their own
+ volume), hit-timing presets (Relaxed / Forgiving / Normal / Precision), **Pro cymbals** toggle and an
+ input **Offset** (ms) for audio / MIDI latency.
+
+### Changed
+- A drum-tab hit is an accent only when the tab marks it (`a`, feedpak 1.20.0) or, for older tabs, when it
+ has a written velocity of 100 or more; a hit with no velocity is no longer drawn as an accent. A kick
+ marked `x2` is a double kick (GM 35).
+- The "Hits" checkbox is now **Score**.
diff --git a/LICENSE.GPL-3.0 b/LICENSE.GPL-3.0
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE.GPL-3.0
@@ -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/LICENSE.LGPL-3.0 b/LICENSE.LGPL-3.0
new file mode 100644
index 0000000..153d416
--- /dev/null
+++ b/LICENSE.LGPL-3.0
@@ -0,0 +1,165 @@
+ GNU LESSER 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.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser 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
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
\ No newline at end of file
diff --git a/NOTICE.md b/NOTICE.md
new file mode 100644
index 0000000..feaa3ed
--- /dev/null
+++ b/NOTICE.md
@@ -0,0 +1,37 @@
+# Licensing notice for the drums plugin
+
+Most of this plugin is original code under the **MIT** license (see `README.md`). That includes
+`screen.js`, `assets/session.js`, `assets/highway3d.js` (the 3D view), `assets/kits.js`, `tools/`, `plugin.json`, the other assets and the tests other than
+`tests/engine.test.js`. The bundled drum sounds have their own licences (below).
+
+Two files are ports of [YARG.Core](https://github.com/YARC-Official/YARG.Core) and are licensed under the
+**GNU Lesser General Public License v3.0** (full text in [`LICENSE.LGPL-3.0`](LICENSE.LGPL-3.0); the LGPL
+builds on the GNU GPL v3.0, whose text is in [`LICENSE.GPL-3.0`](LICENSE.GPL-3.0)):
+
+| File | Status |
+|------|--------|
+| `assets/engine.js` | LGPL-3.0. JavaScript port, with modifications, of YARG.Core's drums engine (`Engine/Drums/DrumsEngine.cs`, `Engine/Drums/Engines/YargDrumsEngine.cs`, `DrumsEngineParameters.cs`, `DrumsStats.cs`, `Engine/BaseEngine*.cs`, `BaseStats.cs`, `HitWindowSettings.cs`, `Chart/Notes/DrumNote.cs`, `Note.cs`, drum activation flags from `InstrumentDifficultyExtensions.cs`, and defaults from `Game/Presets/EnginePreset*.cs`). It also takes the drum star thresholds from YARG's `DrumsPlayer.cs` (LGPL-3.0). Copyright (c) YARC (YARG) contributors. |
+| `tests/engine.test.js` | LGPL-3.0. Tests for `engine.js`, partly ported from `YARG.Core.UnitTests/Engine/DrumEngineTester.cs` and `DrumsStatsTests.cs`. |
+
+`assets/engine.js` is a standalone, unminified script with no dependencies, shipped as its own file and
+loaded at runtime. `screen.js`, `assets/session.js` and `assets/highway3d.js` only use it through its public API
+(`window.DrumsEngine` / `require('./engine.js')`), so they remain separate MIT works that use the Library
+as described in LGPL-3.0 section 4 ("Combined Works"). You may replace `assets/engine.js` with a modified
+version. If you distribute changes to `assets/engine.js`, they must stay under LGPL-3.0 and their source
+must stay available. Combining an LGPL-3.0 file with MIT code this way is permitted by both licenses, and
+both are compatible with feedBack's AGPL-3.0.
+
+The header of `assets/engine.js` lists every behaviour that deviates from YARG ("Deviations from YARG").
+The scoring look and feel imitates rhythm-game drum tracks but uses no artwork, fonts or logos from them.
+
+## Bundled drum sounds (`assets/sounds/`)
+
+| Files | License |
+|-------|---------|
+| `assets/sounds/WebAudioFontPlayer.js` | **GPL-3.0** (text in [`LICENSE.GPL-3.0`](LICENSE.GPL-3.0)). WebAudioFont player, unmodified, by Sergey Surikov (https://github.com/surikov/webaudiofont). A separate script the synth loads at runtime, only for the General MIDI kits; the rest of the plugin only calls its public `WebAudioFontPlayer` API. Its source is the file itself. |
+| `assets/sounds/128*_0_JCLive_sf2_file.js`, `128*_0_FluidR3_GM_sf2_file.js`, `128*_0_SBLive_sf2.js`, `128*_0_Chaos_sf2_file.js` | **MIT**. General MIDI drum samples (JCLive, FluidR3 GM, SB Live! and Chaos sets) from https://github.com/surikov/webaudiofontdata, unmodified. |
+| `assets/sounds/kits/crocell/*` | **CC BY 4.0** (https://creativecommons.org/licenses/by/4.0/). Derived from **CrocellKit 1.1** for DrumGizmo, stereo mix (`CrocellKit_Stereo_MIX.rar`, https://drumgizmo.org/wiki/doku.php?id=kits:crocellkit). Kit of the Danish band Crocell; kit setup by Andreas (Crocell, crocell.dk), mic setup by Jacob Olsen (JBOSound, jbosound.com), sampled, edited and prepared by Lars Muldjord / the DrumGizmo team (drumgizmo.org). **Changes:** a subset of hits (4 velocity layers x 2 per piece) picked, trimmed, faded, level-balanced and re-encoded to Ogg Vorbis by `tools/build_sample_kit.py`; some pieces are reused for other GM notes. |
+| `assets/sounds/kits/virtuosity/*` | **CC0 1.0** (public domain dedication, https://creativecommons.org/publicdomain/zero/1.0/). Derived from **Virtuosity Drums** by Versilian Studios and Karoryfer Samples, played by Austin McMahon (https://github.com/sfzinstruments/virtuosity_drums). Mixed from its overhead, kick and snare mics like the library's "basic kit", trimmed and re-encoded by `tools/build_sample_kit.py`; the mid tom is the high tom tuned down 4 semitones. Credit given as a courtesy. |
+
+When you use the plugin's drum sounds in a recording, CC BY 4.0 asks that the CrocellKit credit above goes
+with it.
diff --git a/README.md b/README.md
index baa3cd9..6ca57d3 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,15 @@ A plugin for [Slopsmith](https://github.com/got-feedback/feedback) that replaces
- **Auto-activate** — switches on automatically for Drums/Percussion arrangements
- **MIDI drum pad input** — connect any MIDI drum pad, electronic kit, or controller via Web MIDI API
- **Custom MIDI mapping** — "Learn" mode to assign any MIDI note to any lane, for non-standard drum pads
-- **Built-in drum sounds** — WebAudioFont-powered GM drum kit playback on MIDI hit
-- **Accuracy scoring** — hit detection with tight +/-50ms timing window, accuracy %, streak counter
+- **Built-in drum sounds** — six kits, all bundled (works offline): two real multi-sampled acoustic kits with velocity layers, **Crocell** (rock, default) and **Virtuosity** (jazz), and four WebAudioFont GM kits (JCLive, FluidR3 GM, Sound Blaster Live!, Chaos). See [Drum kits](#drum-kits).
+- **Rock Band-style 3D drum track** (default with WebGL2) — four pad lanes and a kick bar, rectangle pad gems, domed cymbals, star power and fill highlighting, and a score HUD. See [3D view](#3d-view).
+- **Keyboard drumming** — play without a kit: B kick, F snare, J/K/L toms, U/I/O cymbals, Enter = star power.
+- **Rock Band-style scoring** — score, multiplier, streak, stars and star power from a port of the YARG drums engine (`assets/engine.js`). See [Scoring](#scoring).
+- **Difficulty** — Easy, Medium, Hard, Expert or Expert+ per player (browser), changeable mid-song. See [Difficulty](#difficulty).
+- **Assists** — auto kick, auto cymbals (each up to a chosen difficulty) and hit-timing presets. See [Assists](#assists).
- **Inline settings** — MIDI device, volume, channel filter, lane labels, hit detection, and mapping table
+- **Drums settings page** — Plugins → **Drums**: every drum setting on one page, a pad tester for your kit and your best scores. See [Settings page](#settings-page).
+- **End-of-song card** — final score, stars, accuracy, streak and where the points came from, with your best score per song and difficulty.
## Drum Lanes
@@ -32,6 +38,155 @@ A plugin for [Slopsmith](https://github.com/got-feedback/feedback) that replaces
| Ride | Ri | 51, 59 | White | Diamond |
| Kick | Ki | 35, 36 | Red | Full-width bar |
+## 3D view
+
+The 3D view is a WebGL renderer (`assets/highway3d.js`, on the three.js copy that ships with feedBack core)
+in the style of the classic drum games:
+
+- **Lanes** — red, yellow, blue and green pad lanes plus an orange kick bar across the track; rectangular
+ pad targets in the lane colours sit on the strikeline.
+- **Gems** — snare and toms are bevelled rounded-rectangle blocks, cymbals (hi-hat, ride, crash) are raised
+ domes, kicks are flat orange bars (double kicks magenta with a white stripe). Accents are bigger and
+ brighter, ghost notes smaller and translucent; missed notes go grey and slide past the strikeline.
+- **Feedback** — a hit flashes its target and throws sparks; a miss or a hit on an empty lane flashes the
+ lane red. Beat and measure lines scroll with the chart; drum solos tint the track violet.
+- **Star power** — phrase notes are silver-white; with half a meter the fills are marked green and the note
+ that ends the fill gets a spinning ring. While active the track edges glow blue and the multiplier doubles.
+- **HUD** — streak and multiplier left of the track; star power meter, score, stars, accuracy and the
+ difficulty badge (click it, or D / Shift+D) right of it. Core draw hooks
+ (`window.highway.fireDrawHooks`) run on the HUD canvas, so overlay plugins still work.
+
+### Choosing the view
+
+The plugin registers one picker entry, **Drum Highway** (`window.feedBackViz_drums`), which Auto mode picks
+for drum parts. Each renderer it creates reads the **View** setting in the settings panel (gear):
+
+- **Auto** (default) — 3D when WebGL2 is available, otherwise 2D.
+- **3D** / **2D** — always that view (3D still falls back to 2D without WebGL2).
+
+Changing it swaps the renderer in the main player right away; splitscreen panels pick it up the next time
+their renderer is created. `window.feedBackViz_drums3d` / `feedBackViz_drums2d` are exported for hosts
+that want one view regardless of the setting (not in the picker).
+
+### Keyboard
+
+**Keys** (on by default) lets you play with the keyboard in either view:
+
+| Key | Pad |
+|---|---|
+| B | kick |
+| F | red (snare) |
+| J / K / L | yellow / blue / green tom |
+| Shift+J/K/L, or U / I / O | yellow / blue / green cymbal |
+| Enter | activate star power (when the chart has no fills) |
+
+The keys work only while the drum view is visible and focused (in splitscreen: the focused panel), and not
+while you type in a text field. Space stays play/pause; D / Shift+D change the difficulty.
+
+## Scoring
+
+Turn on **Score** in the settings panel (gear) to see the scoring HUD: multiplier, score, streak, stars
+and the star power meter. Hit notes turn green, missed notes grey, and a pad hit on an empty lane flashes it
+red.
+
+Scoring is a JavaScript port of YARG.Core's drums engine (`assets/engine.js`, LGPL-3.0 — see
+[License](#license)), used through `assets/session.js`:
+
+- **Hit window** ±70 ms (see [Assists](#assists) for wider ones); one pad hit hits at most one note.
+- **Multiplier** grows every 10 notes up to x4, doubled during star power (x8). 60 points per note (pro
+ drums), plus a dynamics bonus for hitting an accent hard or a ghost note soft (needs a velocity-sensitive
+ kit). Pro drums: a cymbal and the tom of the same colour are different notes; with **Pro cymbals** off
+ either one hits that colour's notes.
+- **Star power** — notes in a star power phrase are drawn silver-white; hitting every note of a phrase fills
+ a quarter of the meter. With half a meter, the chart's **fills** are marked green: the hit that ends the
+ fill activates star power.
+- **Bonuses** (shown in the score) — +10 x multiplier for a hit within 25 ms, extra points for snare / tom
+ rolls, and a drum-solo bonus (100 per note hit, doubled for a perfect solo) with a live percentage during
+ a solo.
+- **Offset** (ms) — subtracted from the time of every hit; raise it if your hits register late (audio or
+ MIDI latency). Hits between frames are placed using the playback rate, not quantised to the frame rate.
+- Seeking (or a loop jumping back) restarts scoring from the new position; skipped notes aren't misses.
+
+The scoring files load on first use from core's plugin assets route (`/api/plugins/drums/assets/`). Until
+they have loaded the view shows the chart as it is.
+
+### Difficulty
+
+Each player picks **Easy**, **Medium**, **Hard**, **Expert** or **Expert+**, stored per browser
+(`localStorage` key `drums_difficulty_v1`, default Expert), so in splitscreen / multiplayer every player
+keeps their own. Change it in the settings panel, by clicking the difficulty badge (top-left of the lanes),
+or with **D** (harder) / **Shift+D** (easier) while the view is focused.
+
+- **Expert+** is the drum part as charted, double kicks included; **Expert** drops the double-kick notes
+ (`x2`). **Easy / Medium / Hard** come from the chart's own easier levels; the badge says **AUTO** when a
+ level was reduced by software rather than hand-charted.
+- A level the chart doesn't have is greyed out with the reason; a song without your saved level plays
+ Expert (the badge is dimmed) and your choice is kept for the next song.
+- Changing difficulty mid-song rebuilds the chart from the current position, like a seek.
+
+### Where star power, fills and levels come from
+
+They are part of the song's drum tab (`drum_tab.json`, feedpak 1.20.0 "drum gameplay metadata"): per-hit
+`a` (accent) and `x2` (double kick) flags, `star_power` / `fills` / `solos` as `{start_time, end_time}`
+ranges, and `levels.easy|medium|hard` with their own `hits` and a `generated` marker. feedBack's Rock Band /
+Clone Hero / YARG chart importer writes them, and core streams them to the plugin with the tab
+(`bundle.drumTab`). A tab without them — an older tab, a core that doesn't stream them yet, or drums encoded
+as guitar notes — plays **Expert only, without star power**; everything else works the same.
+
+### Assists
+
+- **Auto kick / Auto cymbals** (`drums_auto_kick_v1`, `drums_auto_cymbals_v1`): `Off`, `Easy only`,
+ `Easy – Medium`, `Easy – Hard` or `Every difficulty` — the assist is on at that difficulty and the ones
+ below it. Those notes are left out of scoring, marked as played when they reach the now line, and pad hits
+ on them are ignored. Cymbals = the chart's hi-hat, ride and crash notes. They are **played on the drum
+ sound** (scheduled ~60 ms ahead on the audio clock) so muting the drum stem leaves no holes, at their own
+ **Auto vol** (`drums_auto_volume_v1`, default 80%), so pads can stay silent when the kit module makes the
+ sound.
+- **Timing** (`drums_timing_v1`): Relaxed ±130 ms, Forgiving ±100 ms, Normal ±70 ms (YARG's default),
+ Precision (YARG's dynamic window that tightens on fast notes).
+
+## Drum kits
+
+**Kit** in the settings panel (`drums_kit_v1`) picks the sound for pad hits and the auto notes; only the
+chosen kit is loaded.
+
+- **Crocell (rock)**, the default — a real sampled kit (CrocellKit, CC BY 4.0) with 4 velocity layers x 2
+ round-robins per piece, so soft and hard hits sound different and repeated hits don't sound identical; a
+ closed / pedal hi-hat cuts a ringing open hi-hat.
+- **Virtuosity (jazz)** — the same kind of kit (Virtuosity Drums, CC0).
+- **JCLive**, **FluidR3 GM**, **Sound Blaster Live!**, **Chaos** — WebAudioFont General MIDI kits, one
+ sample per note.
+
+Everything ships in `assets/sounds/` (about 12 MB, Ogg Vorbis for the sampled kits) and loads from core's
+plugin assets route, so nothing comes from a CDN and the kits work offline. The sampled kits are fetched and
+decoded when picked and play through plain `AudioBufferSourceNode`s for low latency. A missing kit folder
+falls back to JCLive. Sources, licences and the build script: `assets/sounds/README.md`, `NOTICE.md`,
+`tools/build_sample_kit.py`.
+
+## Settings page
+
+**Plugins → Drums** (the plugin's `nav` entry) collects every drum setting on one page, grouped into
+*Difficulty & assists*, *Drum sounds* (kit, **▶ Play test**, volume), *Your kit (MIDI)* and *Display &
+controls*, plus:
+
+- **MIDI status** — the device in use, how many hits arrived, the last note / velocity / channel, and why a
+ hit was dropped (no drum view focused, channel filter) or why the device failed to open.
+- **Pad tester** — hit your pads: the lane each one counts as lights up and the kit sound plays. **Learn**
+ works here too (open *Pad mapping*, click Learn, hit the pad).
+- **Best scores** — your best score per song and difficulty.
+- **Reset drum settings** — back to the defaults, keeping your MIDI input, pad mapping and scores.
+
+The controls are the same per-browser settings as the ⚙ panel in the player and stay in sync with it.
+
+## End-of-song card
+
+When a song you played ends, a card shows the final score (engine score plus the timing, roll and solo
+bonuses), stars, accuracy, notes hit, best streak or **FC**, and chips for where the points came from: notes ×
+multiplier, star power, accents / ghosts, spot-on timing, rolls and drum solos. The best score per song and
+difficulty is kept in this browser (`localStorage` key `drums_best_v1:|`); a higher score
+shows **NEW BEST SCORE**. Close it with the button, Esc or Enter; it also closes when the next song loads.
+Watching a chart without playing it shows no card.
+
## Requirements
- **Chrome or Edge** for MIDI drum pad input (Firefox does not support Web MIDI)
@@ -71,6 +226,16 @@ Different drum pads send different MIDI note numbers. Use the "Learn" mode in se
Click "Reset Map" to return to standard GM mapping.
+## Development
+
+Tests run with plain Node (the org CI runs each `tests/*.test.js`): `node tests/engine.test.js`,
+`node tests/session.test.js`, `node tests/highway3d.test.js`, `node tests/kits.test.js`,
+`node tests/screen.test.js`. `tools/build_sample_kit.py` rebuilds the sampled kits (needs numpy, scipy,
+soundfile and ffmpeg).
+
## License
-MIT
+MIT, except the bundled sounds (see [Drum kits](#drum-kits) and NOTICE.md) and `assets/engine.js` / `tests/engine.test.js`: they are ports of
+[YARG.Core](https://github.com/YARC-Official/YARG.Core) and stay under the **LGPL-3.0**
+(`LICENSE.LGPL-3.0`, with the GPL-3.0 it builds on in `LICENSE.GPL-3.0`). The engine is a separate,
+replaceable file that the MIT code only calls through its public API. Details in [NOTICE.md](NOTICE.md).
diff --git a/assets/engine.js b/assets/engine.js
new file mode 100644
index 0000000..4c6aeb8
--- /dev/null
+++ b/assets/engine.js
@@ -0,0 +1,1081 @@
+/*
+ * engine.js: Rock Band-style drums hit detection and scoring for the feedBack drums plugin.
+ *
+ * Ported from YARG.Core (https://github.com/YARC-Official/YARG.Core):
+ * YARG.Core/Engine/Drums/DrumsEngine.cs
+ * YARG.Core/Engine/Drums/Engines/YargDrumsEngine.cs
+ * YARG.Core/Engine/Drums/DrumsEngineParameters.cs
+ * YARG.Core/Engine/Drums/DrumsStats.cs
+ * YARG.Core/Engine/BaseEngine.cs, BaseEngine.Generic.cs, BaseEngineParameters.cs, BaseStats.cs
+ * YARG.Core/Engine/HitWindowSettings.cs
+ * YARG.Core/Chart/Notes/DrumNote.cs, YARG.Core/Chart/Notes/Note.cs
+ * YARG.Core/Chart/Tracks/InstrumentDifficultyExtensions.cs (SetDrumActivationFlags)
+ * YARG.Core/Chart/Events/WaitCountdown.cs (MIN_SECONDS)
+ * YARG.Core/Game/Presets/EnginePreset.Instruments.cs and EnginePreset.Defaults.cs (default parameters)
+ * and from the YARG game (https://github.com/YARC-Official/YARG, also LGPL-3.0):
+ * Assets/Script/Gameplay/Player/DrumsPlayer.cs (drum star score thresholds)
+ *
+ * Copyright (c) YARC (YARG) contributors
+ * Licensed under the GNU Lesser General Public License v3.0
+ * (see https://github.com/YARC-Official/YARG.Core/blob/master/LICENSE, and LICENSE.LGPL-3.0 at the plugin root; LGPL-3.0 builds on the GPL-3.0 in LICENSE.GPL-3.0).
+ *
+ * Ported and modified to JavaScript for the feedBack drums plugin. This file is a derivative work of the
+ * above files and stays under LGPL-3.0. The rest of the plugin is MIT (see NOTICE.md).
+ * Modifications: rewritten as a dependency-free JavaScript module, uses an immediate hit()/update() API
+ * in place of YARG's input queue, works in seconds/measures in place of chart ticks, and decodes feedBack
+ * wire notes (General MIDI drum numbers). The deviations are listed below.
+ *
+ * -------------------------------------------------------------------------------------------------
+ * Rules ported from YARG (defaults = EnginePreset.Default):
+ * - Hit window: 140 ms total, front/back ratio 1.0, so it runs from 70 ms early to 70 ms late. It is not
+ * dynamic by default. YARG's "Dark YARG" dynamic window is ported (params.hitWindow.isDynamic) along with
+ * the "Precision" preset (DrumsEngine.PRESETS.precision). The window scales with params.songSpeed.
+ * A note can still be hit exactly at the back edge. It is missed once time > t + backEnd.
+ * - Matching (YargDrumsEngine.CheckForNoteHit): scan from the oldest unresolved chord forward while the
+ * chords are inside the hit window, and hit the first note whose lane equals the input lane. Every note
+ * in a chord is hit separately. Hitting a later chord counts every unresolved note in earlier chords as
+ * a miss (SkipPreviousNotes). One input hits at most one note.
+ * - Lanes (FourLaneDrumPad): with pro drums, a yellow, blue or green cymbal is a separate lane from the tom
+ * of the same colour, so a cymbal hit on a tom note does not hit it and is an overhit. Non-pro drums map
+ * cymbal inputs onto the drum lanes. The engine has no cymbal/tom fallback.
+ * - Overhit (DrumsEngine.Overhit): a pad hit that hits no note resets the combo, adds 1 to overhits and
+ * fails the star power phrase in progress (unless the current note starts the phrase). It does not
+ * change the score. An overhit is ignored before the first note is resolved, after the last note, and
+ * during a WaitCountdown (a gap of 9 s or more between notes).
+ * - Combo: +1 for each note hit, so a chord adds 1 per note. Multiplier = min(floor(combo / 10) + 1, 4),
+ * doubled while star power is active (up to 8x). The multiplier updates before the note is scored, so
+ * the 10th note already scores at 2x.
+ * - Score: 60 points per note with pro drums, 50 without, times the multiplier. Dynamics: an accent or ghost
+ * note hit with the right velocity scores +25 x multiplier (ApplyVelocity, threshold 0.35, 1.5 s window
+ * for relative velocity). This only applies when hit() gets a velocity.
+ * - Star power: hitting every note of a phrase awards a quarter bar (2 measures). The bar holds 8 measures.
+ * Activation needs half a bar (4 measures). While active, the bar drains 1 measure per measure of song
+ * time, so a full bar lasts 8 measures. A phrase hit while star power is active adds to the bar and
+ * pushes the end time back (unless params.noStarPowerOverlap is set). A miss in a phrase fails it.
+ * - Drum fill activation (SetDrumActivationFlags + DrumsEngine.HitNote): the activation chord of a fill
+ * gets the "activator" flag. With YARG's default RightmostNote type, only the rightmost lane of that
+ * chord gets it. Hitting the activator when the bar is at least half full activates star power.
+ * If the player lets the activator pass while star power could activate, it is auto-hit: it scores and
+ * adds to the combo but does not activate star power and fires no 'hit' event.
+ * - Stars: BaseScore = the score of a full combo with no star power and no dynamics bonus, using the same
+ * pre-increment multiplier as CalculateChartScores. Thresholds come from DrumsPlayer.cs:
+ * [0.06, 0.12, 0.2, 0.45, 0.75, 1.09] x BaseScore for 1-5 stars and gold.
+ *
+ * Deviations from YARG:
+ * 1. Time model: YARG runs a queued-input simulation in chart ticks. This engine uses seconds and a
+ * time->measure map built from opts.measures (barline times), opts.beats (numbers or {time, measure}
+ * objects, where a measure >= 0 marks a downbeat), or opts.tempo ([{t, bpm}] plus opts.beatsPerMeasure,
+ * default 4). The fallback is 120 BPM in 4/4, so 1 measure = 2 s. Star power is tracked as fractional
+ * measures rather than integer measure ticks, so there is no tick rounding.
+ * 2. hit() and update() run immediately. Times earlier than the engine clock are clamped forward, as
+ * YARG's QueueInput does. Misses and the star power end are processed in time order inside each call.
+ * 3. Not ported: trill/tremolo lanes, kick lanes and their lane leniency, BRE/coda, solos and solo bonus,
+ * band/unison bonus, revives, the Elite and 5-lane modes, bots, and replays. The wildcard pad is not
+ * reachable from this API.
+ * 4. Chart import: notes less than 1 ms apart form a chord (YARG uses identical ticks). A second note on
+ * the same lane in one chord is dropped, like DrumNote.AddChildNote. With proDrums:false a yellow tom
+ * and a yellow cymbal in one chord collapse into one note, and noteState() of the dropped id reports
+ * the surviving note. 35 (kick 2x) is kept unless opts.kick2x === false, rather than YARG's
+ * Expert+ difficulty switch.
+ * 5. Star power phrase membership: start - 0.1 ms <= chord.t < end - 0.1 ms (half-open, like tick ranges).
+ * Phrases that contain no notes are left out of phrasesTotal.
+ * 6. Phrase failure is tracked per phrase. Missing any note of a phrase, including a child note of a
+ * chord, fails the whole phrase. YARG's StripStarPower only strips the child note in that case, which
+ * looks like a bug.
+ * 7. Activation window -> activator chord: the last chord with start - 0.1 ms <= t <= end + 0.1 ms
+ * (Rock Band: the fill ends on the activation chord). If no chord falls in the window, YARG's rule is
+ * used: the first chord at or after `end`. Without opts.activation, eng.activateStarPower() works
+ * whenever the bar is half full. With fills, manual activation is off unless opts.manualActivation is
+ * true. YARG drums have no manual activation; it auto-generates fills.
+ * 8. Hitting a pad that matches a chord note already hit is consumed with no effect, as in YARG. This
+ * engine returns {type:'ignored'} and, unlike YARG, does not run the velocity bonus again.
+ * 9. Velocity comparison (ApplyVelocity): the comparison note is looked up across all notes of earlier
+ * chords on the same lane, not only parent notes. Velocities above 1 are read as MIDI 0-127.
+ * 10. Reporting: getState().notesMissed counts notes actually missed so far (YARG reports
+ * TotalNotes - NotesHit). accuracy = hit / (hit + missed) so far, and percent = YARG's
+ * NotesHit / TotalNotes. maxMultiplier is the cap (params.maxMultiplier) and peakMultiplier is the
+ * highest multiplier reached. 'combo-break' fires only when a combo above 0 resets.
+ * -------------------------------------------------------------------------------------------------
+ */
+(function (root, factory) {
+ 'use strict';
+ const api = factory();
+ if (typeof module === 'object' && module && module.exports) module.exports = api;
+ if (typeof window !== 'undefined' && window) window.DrumsEngine = api;
+ else if (typeof module !== 'object' && root) root.DrumsEngine = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ // ── Constants ───────────────────────────────────────────────────────────────
+
+ // Plugin-facing pad colours (shared with screen.js).
+ const PAD = Object.freeze({ KICK: 0, RED: 1, YELLOW: 2, BLUE: 3, GREEN: 4 });
+
+ // YARG FourLaneDrumPad (DrumNote.cs). These are the engine's internal "lanes".
+ const LANE = Object.freeze({
+ KICK: 0, RED: 1, YELLOW_DRUM: 2, BLUE_DRUM: 3, GREEN_DRUM: 4,
+ YELLOW_CYMBAL: 5, BLUE_CYMBAL: 6, GREEN_CYMBAL: 7,
+ });
+
+ // BaseEngine.cs
+ const POINTS_PER_NOTE = 50;
+ const POINTS_PER_PRO_NOTE = POINTS_PER_NOTE + 10;
+ // YargDrumsEngine.CheckForNoteHit: velocityBonus = POINTS_PER_NOTE / 2
+ const VELOCITY_BONUS = POINTS_PER_NOTE / 2;
+ // BaseEngine.UpdateMultiplier: (Combo / 10) + 1
+ const NOTES_PER_MULTIPLIER = 10;
+
+ const CHORD_EPS = 0.001; // notes closer than 1 ms form a chord
+ const PHRASE_EPS = 0.0001; // tolerance for phrase/window boundaries
+ const SP_EPS = 1e-9;
+
+ // General MIDI drum number -> pad/cymbal (the drums wire encoding is midi = s*24 + f).
+ const MIDI_MAP = Object.freeze({
+ 36: { pad: PAD.KICK, cymbal: false },
+ 35: { pad: PAD.KICK, cymbal: false, kick2x: true },
+ 38: { pad: PAD.RED, cymbal: false }, // acoustic snare
+ 37: { pad: PAD.RED, cymbal: false }, // side stick / cross-stick
+ 40: { pad: PAD.RED, cymbal: false }, // electric snare
+ 42: { pad: PAD.YELLOW, cymbal: true }, // closed hi-hat
+ 46: { pad: PAD.YELLOW, cymbal: true }, // open hi-hat
+ 44: { pad: PAD.YELLOW, cymbal: true, hatPedal: true }, // pedal hi-hat (opt-in)
+ 48: { pad: PAD.YELLOW, cymbal: false }, // hi-mid tom
+ 50: { pad: PAD.YELLOW, cymbal: false }, // high tom
+ 45: { pad: PAD.BLUE, cymbal: false }, // low tom
+ 47: { pad: PAD.BLUE, cymbal: false }, // low-mid tom
+ 41: { pad: PAD.GREEN, cymbal: false }, // low floor tom
+ 43: { pad: PAD.GREEN, cymbal: false }, // high floor tom
+ 58: { pad: PAD.GREEN, cymbal: false }, // vibraslap (some kits send it from the floor tom)
+ 51: { pad: PAD.BLUE, cymbal: true }, // ride
+ 53: { pad: PAD.BLUE, cymbal: true }, // ride bell
+ 59: { pad: PAD.BLUE, cymbal: true }, // ride 2
+ 49: { pad: PAD.GREEN, cymbal: true }, // crash
+ 57: { pad: PAD.GREEN, cymbal: true }, // crash 2
+ 55: { pad: PAD.GREEN, cymbal: true }, // splash
+ 52: { pad: PAD.GREEN, cymbal: true }, // china
+ });
+
+ // ── Default parameters ─────────────────────────────────────────────────────
+
+ /**
+ * YARG's default drum engine parameters. Sources:
+ * - hitWindow: EnginePreset.Instruments.cs DrumsPreset.HitWindow (MaxWindow 0.14, MinWindow 0.14,
+ * IsDynamic false, FrontToBackRatio 1.0, LaneAutohitWindow 0.160) plus HitWindowPreset defaults
+ * (DynamicScale 1.0, DynamicSlope 0.93, DynamicGamma 1.5, LaneProximityProtectionWindow 0.080).
+ * - maxMultiplier: EnginePreset.DEFAULT_MAX_MULTIPLIER = 4.
+ * - velocityThreshold 0.35, situationalVelocityWindow 1.5: DrumsEngineParameters constructor.
+ * - noStarPowerOverlap false: DrumsPreset.NoStarPowerOverlap.
+ * - starPowerMeasures 8: BaseEngine.STAR_POWER_MAX_MEASURES (TicksPerFullSpBar = 8 measures).
+ * A phrase gives TicksPerQuarterSpBar (2 measures), and activation needs TicksPerHalfSpBar (4 measures).
+ * - starMultiplierThresholds: YARG DrumsPlayer.cs StarMultiplierThresholds.
+ * - waitCountdownSeconds 9: WaitCountdown.MIN_SECONDS.
+ * - activationType 'rightmost': YargProfile default StarPowerActivationType.RightmostNote.
+ * - pointsPerNote/pointsPerProNote/velocityBonus/notesPerMultiplier: BaseEngine.cs constants.
+ */
+ function defaultParams() {
+ return {
+ hitWindow: {
+ maxWindow: 0.14,
+ minWindow: 0.14,
+ isDynamic: false,
+ frontToBackRatio: 1.0,
+ dynamicScale: 1.0,
+ dynamicSlope: 0.93,
+ dynamicGamma: 1.5,
+ laneAutohitWindow: 0.160, // unused (lanes not ported); kept for parity
+ laneProximityProtectionWindow: 0.080, // unused (lanes not ported); kept for parity
+ },
+ songSpeed: 1.0,
+ maxMultiplier: 4,
+ notesPerMultiplier: NOTES_PER_MULTIPLIER,
+ pointsPerNote: POINTS_PER_NOTE,
+ pointsPerProNote: POINTS_PER_PRO_NOTE,
+ velocityBonus: VELOCITY_BONUS,
+ velocityThreshold: 0.35,
+ situationalVelocityWindow: 1.5,
+ noStarPowerOverlap: false,
+ starPowerMeasures: 8,
+ starMultiplierThresholds: [0.06, 0.12, 0.2, 0.45, 0.75, 1.09],
+ waitCountdownSeconds: 9,
+ activationType: 'rightmost', // 'rightmost' (YARG default) | 'all' (old YARG style)
+ };
+ }
+
+ // EnginePreset.Defaults.cs: overrides applied by YARG's built-in presets for drums.
+ const PRESETS = Object.freeze({
+ default: Object.freeze({}),
+ casual: Object.freeze({ hitWindow: { laneAutohitWindow: 0.200, laneProximityProtectionWindow: 0.100 } }),
+ precision: Object.freeze({
+ hitWindow: {
+ maxWindow: 0.13, minWindow: 0.05, isDynamic: true,
+ dynamicScale: 1, dynamicSlope: 0.60615, dynamicGamma: 2,
+ laneAutohitWindow: 0.160, laneProximityProtectionWindow: 0.080,
+ },
+ }),
+ });
+
+ function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
+
+ function mergeParams(over) {
+ const p = defaultParams();
+ if (!over || typeof over !== 'object') return finalizeParams(p);
+ for (const k of Object.keys(over)) {
+ if (k === 'hitWindow' && over.hitWindow && typeof over.hitWindow === 'object') {
+ Object.assign(p.hitWindow, over.hitWindow);
+ } else if (over[k] !== undefined) {
+ p[k] = over[k];
+ }
+ }
+ return finalizeParams(p);
+ }
+
+ // HitWindowSettings constructor normalisation.
+ function finalizeParams(p) {
+ const hw = p.hitWindow;
+ if (hw.maxWindow < hw.minWindow) { const t = hw.maxWindow; hw.maxWindow = hw.minWindow; hw.minWindow = t; }
+ hw.dynamicSlope = clamp(hw.dynamicSlope, 0, 1);
+ hw.dynamicScale = clamp(hw.dynamicScale, 0.3, 3);
+ hw.dynamicGamma = clamp(hw.dynamicGamma, 0.1, 10);
+ if (!(p.songSpeed > 0)) p.songSpeed = 1;
+ return p;
+ }
+
+ // HitWindowSettings.CalculateHitWindow (+ Dark_Yarg_Impl)
+ function calculateHitWindow(hw, averageTimeDistance) {
+ if (!hw.isDynamic) return hw.maxWindow;
+ const x = averageTimeDistance * 1000;
+ const minMs = hw.minWindow * 1000;
+ const maxMs = hw.maxWindow * 1000;
+ const maxMultiScale = maxMs * hw.dynamicScale;
+ const gammaPow = Math.pow(x / maxMultiScale, hw.dynamicGamma);
+ const minMultiSlope = minMs * hw.dynamicSlope;
+ const result = (gammaPow * (maxMs - minMultiSlope) + minMultiSlope) / 1000;
+ return clamp(result, hw.minWindow, hw.maxWindow);
+ }
+ // HitWindowSettings.GetFrontEnd / GetBackEnd (Scale = song speed)
+ function frontEnd(hw, full, scale) { return -(Math.abs(full / 2) * hw.frontToBackRatio) * scale; }
+ function backEnd(hw, full, scale) { return Math.abs(full / 2) * (2 - hw.frontToBackRatio) * scale; }
+
+ // ── Chart decoding ─────────────────────────────────────────────────────────
+
+ function padFromMidi(midi, opts) {
+ const m = MIDI_MAP[midi];
+ if (!m) return null;
+ if (m.hatPedal && !(opts && opts.includeHatPedal)) return null;
+ return { pad: m.pad, cymbal: m.cymbal };
+ }
+
+ function wireMidi(n) {
+ if (n.midi != null && Number.isFinite(+n.midi)) return +n.midi;
+ return ((n.s | 0) * 24) + (n.f | 0);
+ }
+
+ /**
+ * Decode drums wire notes ({t, s, f, ac, mt}) into a drum chart.
+ * opts.kick2x (default true): keep 35 (expert+ double bass).
+ * opts.includeHatPedal (default false): keep 44 (pedal hi-hat) as a yellow cymbal.
+ */
+ function decodeNotes(wireNotes, opts) {
+ opts = opts || {};
+ const raw = [];
+ const list = Array.isArray(wireNotes) ? wireNotes : [];
+ for (let i = 0; i < list.length; i++) {
+ const n = list[i];
+ if (!n || typeof n !== 'object') continue;
+ const t = +n.t;
+ if (!Number.isFinite(t)) continue;
+ const midi = wireMidi(n);
+ const m = MIDI_MAP[midi];
+ if (!m) continue;
+ if (m.hatPedal && !opts.includeHatPedal) continue;
+ if (m.kick2x && opts.kick2x === false) continue;
+ raw.push({
+ t,
+ pad: m.pad,
+ cymbal: !!m.cymbal,
+ kick2x: !!m.kick2x,
+ dyn: n.ac ? 'accent' : (n.mt ? 'ghost' : null),
+ midi,
+ _order: i,
+ });
+ }
+ raw.sort((a, b) => (a.t - b.t) || (a._order - b._order));
+
+ const notes = [];
+ const chords = [];
+ let cur = null;
+ for (const r of raw) {
+ if (!cur || r.t - cur.t > CHORD_EPS) {
+ cur = { t: r.t, notes: [] };
+ chords.push(cur);
+ } else if (cur.notes.some(x => x.pad === r.pad && x.cymbal === r.cymbal)) {
+ continue; // DrumNote.AddChildNote ignores a duplicate pad in a chord
+ }
+ delete r._order;
+ r.id = notes.length;
+ r.chord = chords.length - 1;
+ notes.push(r);
+ cur.notes.push(r);
+ }
+ return { notes, chords };
+ }
+
+ function groupNotes(notes) {
+ const sorted = notes.slice().sort((a, b) => a.t - b.t);
+ const chords = [];
+ let cur = null;
+ for (const n of sorted) {
+ if (!cur || n.t - cur.t > CHORD_EPS) { cur = { t: n.t, notes: [] }; chords.push(cur); }
+ cur.notes.push(n);
+ }
+ return chords;
+ }
+
+ // Lane for a pad/cymbal pair (ConvertInputToPad). Pro drums keep cymbals separate on Y/B/G.
+ function laneFor(pad, cymbal, proDrums) {
+ if (proDrums !== false && cymbal) {
+ if (pad === PAD.YELLOW) return LANE.YELLOW_CYMBAL;
+ if (pad === PAD.BLUE) return LANE.BLUE_CYMBAL;
+ if (pad === PAD.GREEN) return LANE.GREEN_CYMBAL;
+ }
+ return pad; // KICK/RED/drum lanes share numbers with PAD
+ }
+
+ // ── Time -> measure map (stands in for YARG's SyncTrack measure ticks) ─────
+
+ function buildMeasureMap(opts) {
+ const bpmDefault = 120;
+ const bpmPerMeasure = (opts && opts.beatsPerMeasure > 0) ? opts.beatsPerMeasure : 4;
+ let times = [];
+ let pos = [];
+ let slopeBefore = null;
+ let slopeAfter = null;
+
+ const pushKnot = (t, p) => {
+ if (!Number.isFinite(t) || !Number.isFinite(p)) return;
+ if (times.length && t <= times[times.length - 1]) return; // keep strictly increasing
+ times.push(t); pos.push(p);
+ };
+
+ if (opts && Array.isArray(opts.measures) && opts.measures.length) {
+ const ms = opts.measures.map(Number).filter(Number.isFinite).sort((a, b) => a - b);
+ ms.forEach((t, i) => pushKnot(t, i));
+ } else if (opts && Array.isArray(opts.beats) && opts.beats.length) {
+ const beats = opts.beats;
+ const isObj = typeof beats[0] === 'object' && beats[0] !== null;
+ if (isObj) {
+ const bt = beats.map(b => +(b.time != null ? b.time : b.t)).filter(Number.isFinite);
+ const down = beats.filter(b => b && b.measure != null && +b.measure >= 0)
+ .map(b => +(b.time != null ? b.time : b.t)).filter(Number.isFinite).sort((a, b) => a - b);
+ if (down.length >= 2) down.forEach((t, i) => pushKnot(t, i));
+ else bt.sort((a, b) => a - b).forEach((t, i) => pushKnot(t, i / bpmPerMeasure));
+ } else {
+ beats.map(Number).filter(Number.isFinite).sort((a, b) => a - b)
+ .forEach((t, i) => pushKnot(t, i / bpmPerMeasure));
+ }
+ } else if (opts && Array.isArray(opts.tempo) && opts.tempo.length) {
+ const tempos = opts.tempo
+ .map(x => ({ t: +x.t, bpm: +x.bpm }))
+ .filter(x => Number.isFinite(x.t) && x.bpm > 0)
+ .sort((a, b) => a.t - b.t);
+ let p = 0;
+ for (let i = 0; i < tempos.length; i++) {
+ if (i > 0) p += (tempos[i].t - tempos[i - 1].t) * tempos[i - 1].bpm / 60 / bpmPerMeasure;
+ pushKnot(tempos[i].t, p);
+ }
+ if (tempos.length) {
+ slopeBefore = tempos[0].bpm / 60 / bpmPerMeasure;
+ slopeAfter = tempos[tempos.length - 1].bpm / 60 / bpmPerMeasure;
+ }
+ }
+
+ const fallbackSlope = bpmDefault / 60 / bpmPerMeasure; // 0.5 measures/s at 120 BPM 4/4
+ if (times.length === 0) { times = [0]; pos = [0]; }
+ if (times.length >= 2) {
+ const n = times.length;
+ if (slopeBefore == null) slopeBefore = (pos[1] - pos[0]) / (times[1] - times[0]);
+ if (slopeAfter == null) slopeAfter = (pos[n - 1] - pos[n - 2]) / (times[n - 1] - times[n - 2]);
+ }
+ if (!(slopeBefore > 0)) slopeBefore = fallbackSlope;
+ if (!(slopeAfter > 0)) slopeAfter = fallbackSlope;
+
+ function upper(arr, v) { // first index with arr[i] > v
+ let lo = 0, hi = arr.length;
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid] > v) hi = mid; else lo = mid + 1; }
+ return lo;
+ }
+ function measureAt(t) {
+ const n = times.length;
+ if (t <= times[0]) return pos[0] - (times[0] - t) * slopeBefore;
+ if (t >= times[n - 1]) return pos[n - 1] + (t - times[n - 1]) * slopeAfter;
+ const i = upper(times, t) - 1;
+ return pos[i] + (t - times[i]) * (pos[i + 1] - pos[i]) / (times[i + 1] - times[i]);
+ }
+ function timeAtMeasure(m) {
+ const n = pos.length;
+ if (m <= pos[0]) return times[0] - (pos[0] - m) / slopeBefore;
+ if (m >= pos[n - 1]) return times[n - 1] + (m - pos[n - 1]) / slopeAfter;
+ const i = upper(pos, m) - 1;
+ return times[i] + (m - pos[i]) * (times[i + 1] - times[i]) / (pos[i + 1] - pos[i]);
+ }
+ return { measureAt, timeAtMeasure };
+ }
+
+ // ── Engine ──────────────────────────────────────────────────────────────────
+
+ const EVENTS = ['hit', 'miss', 'overhit', 'combo-break', 'multiplier', 'sp-phrase', 'sp-phrase-fail',
+ 'sp-ready', 'sp-activate', 'sp-end'];
+
+ function Engine(chart, opts) {
+ opts = opts || {};
+ if (Array.isArray(chart)) chart = decodeNotes(chart, opts);
+ chart = chart || { notes: [], chords: [] };
+ this.opts = opts;
+ this.params = mergeParams(opts.params);
+ this.proDrums = opts.proDrums !== false;
+ this._tempo = buildMeasureMap(opts);
+ this._listeners = Object.create(null);
+
+ const p = this.params;
+ const hw = p.hitWindow;
+ const srcChords = Array.isArray(chart.chords) && chart.chords.length
+ ? chart.chords.slice().sort((a, b) => a.t - b.t)
+ : groupNotes(Array.isArray(chart.notes) ? chart.notes : []);
+
+ // Build engine chords (YARG parent note + child notes).
+ this.chords = [];
+ this._byId = new Map();
+ this._alias = new Map();
+ let autoId = 0;
+ for (const sc of srcChords) {
+ if (!sc || !Array.isArray(sc.notes) || !sc.notes.length) continue;
+ const t = Number.isFinite(+sc.t) ? +sc.t : +sc.notes[0].t;
+ const ec = { index: this.chords.length, t, notes: [], phrase: null, front: 0, back: 0 };
+ for (const sn of sc.notes) {
+ const id = sn.id != null ? sn.id : ('n' + (autoId++));
+ const pad = sn.pad | 0;
+ if (pad < 0 || pad > 4) continue;
+ const lane = laneFor(pad, !!sn.cymbal, this.proDrums);
+ const dup = ec.notes.find(x => x.lane === lane);
+ if (dup) { this._alias.set(id, dup.id); continue; }
+ const en = {
+ id, src: sn, lane, pad, cymbal: !!sn.cymbal,
+ dyn: sn.dyn === 'accent' || sn.dyn === 'ghost' ? sn.dyn : null,
+ chord: ec, activator: false,
+ hit: false, missed: false, autoHit: false,
+ hitTime: null, offset: null, hitVelocity: null, bonus: false,
+ };
+ ec.notes.push(en);
+ this._byId.set(id, en);
+ }
+ if (ec.notes.length) this.chords.push(ec);
+ }
+
+ // Hit window per chord (GetAverageNoteDistance + CalculateHitWindow).
+ const ch = this.chords;
+ for (let i = 0; i < ch.length; i++) {
+ const c = ch[i];
+ const currentToNext = i + 1 < ch.length ? (ch[i + 1].t - c.t) / 2 : hw.maxWindow / 2;
+ const prevToCurrent = i > 0 ? (c.t - ch[i - 1].t) / 2 : currentToNext;
+ const full = calculateHitWindow(hw, prevToCurrent + currentToNext);
+ c.front = frontEnd(hw, full, p.songSpeed);
+ c.back = backEnd(hw, full, p.songSpeed);
+ }
+
+ // Star power phrases.
+ this.phrases = [];
+ const sp = Array.isArray(opts.starPower) ? opts.starPower.slice() : [];
+ sp.sort((a, b) => (+a.start) - (+b.start));
+ for (const ph of sp) {
+ const start = +ph.start, end = +ph.end;
+ if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
+ const members = ch.filter(c => !c.phrase && c.t >= start - PHRASE_EPS && c.t < end - PHRASE_EPS);
+ if (!members.length) continue;
+ const phrase = { index: this.phrases.length, start, end, first: members[0], last: members[members.length - 1],
+ chords: members, failed: false, awarded: false };
+ for (const c of members) c.phrase = phrase;
+ this.phrases.push(phrase);
+ }
+
+ // Drum fill activation phrases (SetDrumActivationFlags).
+ const act = Array.isArray(opts.activation) ? opts.activation : [];
+ this.activationWindows = [];
+ for (const w of act) {
+ const start = +w.start, end = +w.end;
+ if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
+ let target = null;
+ for (const c of ch) {
+ if (c.t >= start - PHRASE_EPS && c.t <= end + PHRASE_EPS) target = c;
+ else if (c.t > end + PHRASE_EPS) break;
+ }
+ if (!target) target = ch.find(c => c.t >= end) || null; // YARG: first note at/after the fill end
+ if (!target) continue;
+ if (p.activationType === 'all') {
+ for (const n of target.notes) n.activator = true;
+ } else {
+ let right = target.notes[0];
+ for (const n of target.notes) if (n.lane > right.lane) right = n;
+ right.activator = true;
+ }
+ this.activationWindows.push({ start, end, chord: target });
+ }
+ this.manualActivation = opts.manualActivation != null ? !!opts.manualActivation : act.length === 0;
+
+ // Chart totals (SetInitialStats / DrumsEngine constructor).
+ this.totalNotes = 0;
+ this.totalAccents = 0;
+ this.totalGhosts = 0;
+ for (const c of ch) {
+ this.totalNotes += c.notes.length;
+ for (const n of c.notes) {
+ if (n.dyn === 'accent') this.totalAccents++;
+ else if (n.dyn === 'ghost') this.totalGhosts++;
+ }
+ }
+ const scores = this._calculateChartScores();
+ this.baseScore = scores.baseScore;
+ this.baseNoteScore = scores.noteScore;
+ this.starThresholds = (p.starMultiplierThresholds || []).map(x => Math.floor(this.baseScore * x));
+
+ this.reset();
+ }
+
+ Engine.prototype._pointsPerNote = function () {
+ return this.proDrums ? this.params.pointsPerProNote : this.params.pointsPerNote;
+ };
+
+ // DrumsEngine.CalculateChartScores
+ Engine.prototype._calculateChartScores = function () {
+ const p = this.params;
+ let base = 0, noteScore = 0, combo = 0;
+ for (const c of this.chords) {
+ const mult = Math.min(Math.floor(combo / p.notesPerMultiplier) + 1, p.maxMultiplier);
+ const s = this._pointsPerNote() * c.notes.length;
+ base += mult * s;
+ noteScore += s;
+ combo += c.notes.length;
+ }
+ return { baseScore: Math.round(base), noteScore: Math.round(noteScore) };
+ };
+
+ Engine.prototype.reset = function () {
+ this.noteIndex = 0;
+ this.currentTime = -Infinity;
+ this.stats = {
+ score: 0, noteScore: 0, multiplierScore: 0, starPowerScore: 0,
+ combo: 0, maxCombo: 0, multiplier: 1, peakMultiplier: 1,
+ notesHit: 0, notesMissed: 0, overhits: 0, overhitsByLane: {},
+ dynamicsBonus: 0, accentsHit: 0, ghostsHit: 0,
+ totalOffset: 0, offsetSamples: 0,
+ stars: 0, starIndex: 0,
+ };
+ this.sp = {
+ amount: 0, // measures (full bar = params.starPowerMeasures)
+ active: false,
+ phrasesHit: 0,
+ activations: 0,
+ activationTime: null,
+ lastPos: 0,
+ endTime: Infinity,
+ timeInStarPower: 0,
+ totalGained: 0,
+ };
+ for (const c of this.chords) {
+ for (const n of c.notes) {
+ n.hit = false; n.missed = false; n.autoHit = false;
+ n.hitTime = null; n.offset = null; n.hitVelocity = null; n.bonus = false;
+ }
+ }
+ for (const ph of this.phrases) { ph.failed = false; ph.awarded = false; }
+ this._updateStars();
+ };
+
+ // ── Events ──
+
+ Engine.prototype.on = function (name, cb) {
+ if (typeof cb !== 'function') return () => {};
+ (this._listeners[name] || (this._listeners[name] = [])).push(cb);
+ return () => this.off(name, cb);
+ };
+ Engine.prototype.off = function (name, cb) {
+ const l = this._listeners[name];
+ if (!l) return;
+ if (cb === undefined) { delete this._listeners[name]; return; }
+ const i = l.indexOf(cb);
+ if (i >= 0) l.splice(i, 1);
+ };
+ Engine.prototype._emit = function (name, payload) {
+ const l = this._listeners[name];
+ if (!l || !l.length) return;
+ for (const cb of l.slice()) {
+ try { cb(payload); } catch (e) {
+ if (typeof console !== 'undefined' && console.error) console.error('[DrumsEngine] listener error', name, e);
+ }
+ }
+ };
+
+ // ── Helpers ──
+
+ function chordResolved(c) { return c.notes.every(n => n.hit || n.missed); }
+ function chordFullyHit(c) { return c.notes.every(n => n.hit); }
+ function isSp(c) { return !!(c.phrase && !c.phrase.failed); }
+
+ Engine.prototype._fullBar = function () { return this.params.starPowerMeasures; };
+ Engine.prototype._halfBar = function () { return this.params.starPowerMeasures / 2; };
+ Engine.prototype._quarterBar = function () { return this.params.starPowerMeasures / 4; };
+ Engine.prototype._canActivate = function () { return this.sp.amount >= this._halfBar() - SP_EPS; };
+
+ Engine.prototype._advanceIndex = function () {
+ while (this.noteIndex < this.chords.length && chordResolved(this.chords[this.noteIndex])) this.noteIndex++;
+ };
+
+ // BaseEngine.UpdateMultiplier
+ Engine.prototype._updateMultiplier = function (time) {
+ const p = this.params;
+ let m = Math.min(Math.floor(this.stats.combo / p.notesPerMultiplier) + 1, p.maxMultiplier);
+ if (this.sp.active) m *= 2;
+ const prev = this.stats.multiplier;
+ this.stats.multiplier = m;
+ if (m > this.stats.peakMultiplier) this.stats.peakMultiplier = m;
+ if (m !== prev) this._emit('multiplier', { time, multiplier: m, previous: prev });
+ };
+
+ Engine.prototype._incrementCombo = function () {
+ this.stats.combo++;
+ if (this.stats.combo > this.stats.maxCombo) this.stats.maxCombo = this.stats.combo;
+ };
+
+ Engine.prototype._resetCombo = function (time, reason) {
+ const prev = this.stats.combo;
+ this.stats.combo = 0;
+ if (prev > 0) this._emit('combo-break', { time, combo: prev, reason });
+ };
+
+ // BaseEngine.AddScore(int)
+ Engine.prototype._addScore = function (points) {
+ const s = this.stats;
+ const scored = points * s.multiplier;
+ s.score += scored;
+ if (this.sp.active) {
+ const spScore = Math.trunc(scored / 2);
+ s.starPowerScore += spScore;
+ s.multiplierScore += spScore - points;
+ } else {
+ s.multiplierScore += scored - points;
+ }
+ this._updateStars();
+ };
+
+ // BaseEngine.UpdateStars
+ Engine.prototype._updateStars = function () {
+ const s = this.stats;
+ const th = this.starThresholds || [];
+ while (s.starIndex < th.length && s.score > th[s.starIndex]) s.starIndex++;
+ let progress = 0;
+ if (s.starIndex < th.length) {
+ const prev = s.starIndex > 0 ? th[s.starIndex - 1] : 0;
+ const next = th[s.starIndex];
+ progress = next === prev ? 0 : clamp((s.score - prev) / (next - prev), 0, 1);
+ }
+ s.stars = s.starIndex + progress;
+ };
+
+ // ── Star power ──
+
+ Engine.prototype._drainTo = function (time) {
+ const sp = this.sp;
+ if (!sp.active) return;
+ const pos = this._tempo.measureAt(time);
+ const d = pos - sp.lastPos;
+ if (d > 0) {
+ sp.amount -= d;
+ if (sp.amount < SP_EPS) sp.amount = 0;
+ sp.lastPos = pos;
+ }
+ };
+
+ Engine.prototype._recomputeSpEnd = function () {
+ this.sp.endTime = this._tempo.timeAtMeasure(this.sp.lastPos + this.sp.amount);
+ };
+
+ // BaseEngine.GainStarPower
+ Engine.prototype._gainStarPower = function (measures, time) {
+ const sp = this.sp;
+ const prev = sp.amount;
+ if (!sp.active && prev < this._halfBar() - SP_EPS && prev + measures >= this._halfBar() - SP_EPS) {
+ this._emit('sp-ready', { time, amount: (prev + measures) / this._fullBar() });
+ }
+ sp.amount = Math.min(this._fullBar(), sp.amount + measures);
+ sp.totalGained += measures;
+ if (sp.active) this._recomputeSpEnd();
+ };
+
+ // BaseEngine.ActivateStarPower
+ Engine.prototype._activateStarPower = function (time) {
+ const sp = this.sp;
+ if (sp.active) return false;
+ sp.active = true;
+ sp.activationTime = time;
+ sp.lastPos = this._tempo.measureAt(time);
+ this._recomputeSpEnd();
+ sp.activations++;
+ this._updateMultiplier(time);
+ this._emit('sp-activate', { time, amount: sp.amount / this._fullBar(), endTime: sp.endTime });
+ return true;
+ };
+
+ // BaseEngine.ReleaseStarPower
+ Engine.prototype._releaseStarPower = function (time) {
+ const sp = this.sp;
+ sp.active = false;
+ sp.amount = 0;
+ sp.timeInStarPower += time - sp.activationTime;
+ sp.endTime = Infinity;
+ this._updateMultiplier(time);
+ this._emit('sp-end', { time });
+ };
+
+ // BaseEngine.StripStarPower: fail the whole phrase.
+ Engine.prototype._failPhrase = function (phrase, time) {
+ if (!phrase || phrase.failed || phrase.awarded) return;
+ phrase.failed = true;
+ this._emit('sp-phrase-fail', { time, phrase: phrase.index, start: phrase.start, end: phrase.end });
+ };
+
+ // BaseEngine.AwardStarPower
+ Engine.prototype._awardPhrase = function (phrase, time) {
+ phrase.awarded = true;
+ this.sp.phrasesHit++;
+ this._gainStarPower(this._quarterBar(), time);
+ this._emit('sp-phrase', { time, phrase: phrase.index, start: phrase.start, end: phrase.end,
+ amount: this.sp.amount / this._fullBar() });
+ };
+
+ // ── Clock ──
+
+ // Process note misses and the star power end up to `time`, in time order.
+ Engine.prototype._advance = function (time) {
+ if (!(time >= this.currentTime)) return;
+ for (;;) {
+ const c = this.chords[this.noteIndex];
+ const missAt = c ? c.t + c.back : Infinity;
+ const spEnd = this.sp.active ? this.sp.endTime : Infinity;
+ if (spEnd <= time && spEnd <= missAt) {
+ this._drainTo(spEnd);
+ this.currentTime = Math.max(this.currentTime, spEnd);
+ this._releaseStarPower(spEnd);
+ continue;
+ }
+ if (time > missAt) {
+ this._drainTo(missAt);
+ this.currentTime = Math.max(this.currentTime, missAt);
+ this._missChord(c, missAt);
+ continue;
+ }
+ break;
+ }
+ this._drainTo(time);
+ this.currentTime = time;
+ };
+
+ // YargDrumsEngine.CheckForNoteHit: the first chord was missed out the back end.
+ Engine.prototype._missChord = function (c, time) {
+ for (const n of c.notes) {
+ if (n.hit || n.missed) continue;
+ // Players may skip SP activation notes without penalty.
+ if (n.activator && this._canActivate()) {
+ this._hitNote(n, time, true);
+ continue;
+ }
+ this._missNote(n, time);
+ }
+ this._advanceIndex();
+ };
+
+ // DrumsEngine.MissNote
+ Engine.prototype._missNote = function (n, time) {
+ if (n.hit || n.missed) return;
+ n.missed = true;
+ n.hitTime = time;
+ this.stats.notesMissed++;
+ if (isSp(n.chord)) this._failPhrase(n.chord.phrase, time);
+ this._resetCombo(time, 'miss');
+ this._updateMultiplier(time);
+ this._emit('miss', { time, id: n.id, note: n.src, lane: n.lane, pad: n.pad, cymbal: n.cymbal, noteTime: n.chord.t });
+ this._advanceIndex();
+ };
+
+ // BaseEngine.SkipPreviousNotes
+ Engine.prototype._skipPreviousNotes = function (c, time) {
+ for (let i = c.index - 1; i >= 0; i--) {
+ const prev = this.chords[i];
+ if (chordResolved(prev)) break;
+ for (const n of prev.notes) if (!n.hit && !n.missed) this._missNote(n, time);
+ }
+ };
+
+ // DrumsEngine.HitNote
+ Engine.prototype._hitNote = function (n, time, autoHit) {
+ if (n.hit || n.missed) return false;
+ const c = n.chord;
+ n.hit = true;
+ n.autoHit = !!autoHit;
+ n.hitTime = time;
+ n.offset = time - c.t;
+
+ this._skipPreviousNotes(c, time);
+
+ if (isSp(c)) {
+ if (this.sp.active && this.params.noStarPowerOverlap) {
+ this._failPhrase(c.phrase, time);
+ } else if (c === c.phrase.last && chordFullyHit(c)) {
+ this._awardPhrase(c.phrase, time);
+ }
+ }
+
+ if (!autoHit && n.activator && this._canActivate() && c.notes.every(x => !x.activator || x.hit)) {
+ this._activateStarPower(time);
+ }
+
+ this._incrementCombo();
+ this.stats.notesHit++;
+ if (!autoHit) { this.stats.totalOffset += n.offset; this.stats.offsetSamples++; }
+ this._updateMultiplier(time);
+ const pts = this._pointsPerNote();
+ this._addScore(pts);
+ this.stats.noteScore += pts;
+
+ this._advanceIndex();
+ return true;
+ };
+
+ // DrumsEngine.ApplyVelocity
+ Engine.prototype._applyVelocity = function (n, velocity) {
+ if (!n.dyn) return false;
+ if (velocity == null) return false;
+ n.hitVelocity = velocity;
+ const p = this.params;
+ let threshold = p.velocityThreshold;
+ let compare = null;
+ for (let i = n.chord.index - 1; i >= 0 && !compare; i--) {
+ const pc = this.chords[i];
+ if (n.chord.t - pc.t > p.situationalVelocityWindow) break;
+ compare = pc.notes.find(x => x.lane === n.lane && x.hitVelocity != null) || null;
+ }
+ if (compare) {
+ const rel = compare.dyn === n.dyn ? compare.hitVelocity : compare.hitVelocity - p.velocityThreshold;
+ threshold = Math.max(threshold, rel != null ? rel : 0);
+ }
+ if (n.dyn === 'ghost') return velocity < threshold;
+ if (n.dyn === 'accent') return velocity > (1 - threshold);
+ return false;
+ };
+
+ function normVelocity(v) {
+ if (v == null) return null;
+ v = +v;
+ if (!Number.isFinite(v)) return null;
+ if (v > 1) v = v / 127;
+ return clamp(v, 0, 1);
+ }
+
+ // BaseEngine.WaitCountdown: a gap of >= MIN_SECONDS between notes (or song start) and the next note.
+ Engine.prototype._inWaitCountdown = function (time) {
+ const ch = this.chords;
+ let k = this.noteIndex;
+ while (k < ch.length && ch[k].t <= time) k++;
+ if (k >= ch.length) return false;
+ const prevEnd = k > 0 ? ch[k - 1].t : 0;
+ return ch[k].t - prevEnd >= this.params.waitCountdownSeconds && time >= prevEnd && time < ch[k].t;
+ };
+
+ // DrumsEngine.Overhit. Returns null if the overhit was counted, or the reason it was ignored.
+ Engine.prototype._overhit = function (time, lane, pad, cymbal) {
+ if (this.noteIndex === 0) return 'before-first-note';
+ if (this.noteIndex > this.chords.length - 1) return 'after-last-note';
+ if (this._inWaitCountdown(time)) return 'countdown';
+
+ const c = this.chords[this.noteIndex];
+ if (isSp(c) && c !== c.phrase.first) this._failPhrase(c.phrase, time);
+
+ this._resetCombo(time, 'overhit');
+ this.stats.overhits++;
+ this.stats.overhitsByLane[lane] = (this.stats.overhitsByLane[lane] || 0) + 1;
+ this._updateMultiplier(time);
+ this._emit('overhit', { time, pad, cymbal, lane });
+ return null;
+ };
+
+ // ── Public API ──
+
+ Engine.prototype.update = function (time) {
+ time = +time;
+ if (!Number.isFinite(time)) return;
+ this._advance(time);
+ };
+
+ /**
+ * Register a pad hit. pad: DrumsEngine.PAD value. opts.cymbal: cymbal input (pro drums only).
+ * opts.velocity: 0..1 (or MIDI 1..127). Returns {type:'hit'|'overhit'|'ignored', note?, offset?, bonus?, reason?}.
+ */
+ Engine.prototype.hit = function (time, pad, opts) {
+ opts = opts || {};
+ time = +time;
+ if (!Number.isFinite(time)) return { type: 'ignored', reason: 'bad-time' };
+ pad = pad | 0;
+ if (pad < 0 || pad > 4) return { type: 'ignored', reason: 'bad-pad' };
+ if (time < this.currentTime) time = this.currentTime; // QueueInput moves stale inputs forward
+ this._advance(time);
+
+ const cymbal = !!opts.cymbal;
+ const lane = laneFor(pad, cymbal, this.proDrums);
+ const velocity = normVelocity(opts.velocity);
+
+ for (let i = this.noteIndex; i < this.chords.length; i++) {
+ const c = this.chords[i];
+ if (time < c.t + c.front || time > c.t + c.back) break; // you can't skip past a note outside the window
+ for (const n of c.notes) {
+ if (n.lane !== lane) continue;
+ if (n.hit || n.missed) return { type: 'ignored', reason: 'already-hit', note: n.src };
+ const bonus = this._applyVelocity(n, velocity);
+ this._hitNote(n, time, false);
+ if (bonus) {
+ this._addScore(this.params.velocityBonus);
+ this.stats.dynamicsBonus += this.params.velocityBonus;
+ if (n.dyn === 'accent') this.stats.accentsHit++;
+ else if (n.dyn === 'ghost') this.stats.ghostsHit++;
+ }
+ n.bonus = bonus;
+ this._emit('hit', {
+ time, id: n.id, note: n.src, lane: n.lane, pad: n.pad, cymbal: n.cymbal,
+ noteTime: c.t, offset: n.offset, bonus, velocity,
+ combo: this.stats.combo, multiplier: this.stats.multiplier, score: this.stats.score,
+ });
+ return { type: 'hit', note: n.src, id: n.id, offset: n.offset, bonus };
+ }
+ }
+
+ const reason = this._overhit(time, lane, pad, cymbal);
+ if (reason === null) return { type: 'overhit' };
+ return { type: 'ignored', reason };
+ };
+
+ Engine.prototype.activateStarPower = function (time) {
+ time = +time;
+ if (Number.isFinite(time)) {
+ if (time < this.currentTime) time = this.currentTime;
+ this._advance(time);
+ } else {
+ time = this.currentTime;
+ }
+ if (!this.manualActivation) return false;
+ if (this.sp.active || !this._canActivate()) return false;
+ return this._activateStarPower(time);
+ };
+
+ Engine.prototype.getState = function () {
+ const s = this.stats;
+ const sp = this.sp;
+ const resolved = s.notesHit + s.notesMissed;
+ return {
+ score: s.score,
+ combo: s.combo,
+ maxCombo: s.maxCombo,
+ multiplier: s.multiplier,
+ maxMultiplier: this.params.maxMultiplier,
+ peakMultiplier: s.peakMultiplier,
+ notesHit: s.notesHit,
+ notesMissed: s.notesMissed,
+ totalNotes: this.totalNotes,
+ overhits: s.overhits,
+ accuracy: resolved > 0 ? s.notesHit / resolved : 1,
+ percent: this.totalNotes === 0 ? 1 : s.notesHit / this.totalNotes,
+ stars: s.stars,
+ starThresholds: this.starThresholds.slice(),
+ baseScore: this.baseScore,
+ dynamicsBonus: s.dynamicsBonus,
+ starPowerScore: s.starPowerScore,
+ accentsHit: s.accentsHit,
+ ghostsHit: s.ghostsHit,
+ totalAccents: this.totalAccents,
+ totalGhosts: this.totalGhosts,
+ averageOffset: s.offsetSamples ? s.totalOffset / s.offsetSamples : 0,
+ fullCombo: s.notesMissed === 0 && s.overhits === 0 && s.notesHit === this.totalNotes,
+ time: this.currentTime,
+ starPower: {
+ amount: sp.amount / this._fullBar(),
+ active: sp.active,
+ canActivate: this._canActivate() && !sp.active,
+ phrasesHit: sp.phrasesHit,
+ phrasesTotal: this.phrases.length,
+ activations: sp.activations,
+ endTime: sp.active ? sp.endTime : null,
+ timeInStarPower: sp.timeInStarPower + (sp.active ? Math.max(0, this.currentTime - sp.activationTime) : 0),
+ },
+ };
+ };
+
+ Engine.prototype._note = function (id) {
+ if (this._alias.has(id)) id = this._alias.get(id);
+ return this._byId.get(id) || null;
+ };
+
+ Engine.prototype.noteState = function (id) {
+ const n = this._note(id);
+ if (!n) return null;
+ return n.hit ? 'hit' : (n.missed ? 'miss' : null);
+ };
+
+ Engine.prototype.noteInfo = function (id) {
+ const n = this._note(id);
+ if (!n) return null;
+ return {
+ state: n.hit ? 'hit' : (n.missed ? 'miss' : null),
+ offset: n.hit ? n.offset : null,
+ time: n.hitTime,
+ velocity: n.hitVelocity,
+ bonus: n.bonus,
+ autoHit: n.autoHit,
+ starPower: isSp(n.chord),
+ activator: n.activator,
+ lane: n.lane,
+ };
+ };
+
+ Engine.prototype.isStarPowerNote = function (id) {
+ const n = this._note(id);
+ return !!(n && isSp(n.chord));
+ };
+
+ Engine.prototype.isActivatorNote = function (id) {
+ const n = this._note(id);
+ return !!(n && n.activator);
+ };
+
+ Engine.prototype.hitWindow = function () {
+ const c = this.chords[this.noteIndex];
+ if (c) return { front: c.front, back: c.back };
+ const hw = this.params.hitWindow;
+ return { front: frontEnd(hw, hw.maxWindow, this.params.songSpeed), back: backEnd(hw, hw.maxWindow, this.params.songSpeed) };
+ };
+
+ function create(chart, opts) { return new Engine(chart, opts); }
+
+ return {
+ PAD,
+ LANE,
+ MIDI_MAP,
+ PRESETS,
+ EVENTS,
+ POINTS_PER_NOTE,
+ POINTS_PER_PRO_NOTE,
+ decodeNotes,
+ padFromMidi,
+ laneFor,
+ create,
+ defaultParams,
+ calculateHitWindow,
+ buildMeasureMap,
+ Engine,
+ };
+});
diff --git a/assets/highway3d.js b/assets/highway3d.js
new file mode 100644
index 0000000..3d51d6c
--- /dev/null
+++ b/assets/highway3d.js
@@ -0,0 +1,1120 @@
+/*
+ * highway3d.js: Rock Band-style 3D drum highway for the feedBack drums plugin (MIT, original code).
+ *
+ * A perspective drum track in the style of the classic plastic-instrument drum games: four coloured
+ * pad lanes (red, yellow, blue, green) plus a full-width kick bar, a strikeline with pad targets,
+ * rounded-rectangle gems for pads/toms, raised domed gems for cymbals, star power and drum-fill
+ * highlighting, hit sparks and a 2D HUD (score, multiplier, streak, star power meter, stars,
+ * accuracy). It contains no ported game logic and no artwork from those games; scoring comes from
+ * session.js / engine.js through their public APIs.
+ *
+ * Exported on window.DrumsHighway3D (browser) and module.exports (Node tests):
+ * - keyboard helpers (keyToPad, synthMidiForPad) used by screen.js in both views;
+ * - createView(THREE, canvas, opts): the Three.js scene that renders a session (session.js
+ * createSession) into the given webgl2 canvas plus a 2D HUD canvas. THREE is passed in
+ * (screen.js imports core's vendored three.module.min.js).
+ * Needs session.js loaded first (window.DrumsSession, or require('./session.js') in Node).
+ */
+(function (root, factory) {
+ 'use strict';
+ const S = (typeof module === 'object' && module && module.exports)
+ ? require('./session.js')
+ : (typeof window !== 'undefined' ? window.DrumsSession : root && root.DrumsSession);
+ const api = factory(S);
+ if (typeof module === 'object' && module && module.exports) module.exports = api;
+ if (typeof window !== 'undefined' && window) window.DrumsHighway3D = api;
+ else if (root) root.DrumsHighway3D = api;
+})(typeof self !== 'undefined' ? self : this, function (S) {
+ 'use strict';
+ if (!S) throw new Error('highway3d.js needs session.js (window.DrumsSession)');
+ const { PAD, LANE_NAMES, LANE_COLORS, COLORS, visibleRange, difficultyBadge, formatScore, hudModel, multiplierColor } = S;
+
+ // Track geometry (world units). The strikeline is at z = 0 and notes approach from -z.
+ const TRACK = Object.freeze({
+ laneWidth: 1.0,
+ lanes: 4,
+ length: 30, // strikeline -> far end
+ behind: 2.0, // visible track behind the strikeline (toward the camera)
+ defaultLookahead: 1.9, // seconds of chart visible on the track
+ gemScale: 1.12, // gem size relative to the 1-unit lane
+ });
+
+ // Representative GM notes for the synth when a hit has no MIDI note (keyboard input).
+ const PAD_SYNTH_MIDI = Object.freeze({
+ kick: 36, red: 38, yellowTom: 48, yellowCymbal: 42, blueTom: 45, blueCymbal: 51, greenTom: 41, greenCymbal: 49,
+ });
+
+ function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
+ function defaultNow() {
+ return (typeof performance !== 'undefined' && performance && typeof performance.now === 'function')
+ ? performance.now() : Date.now();
+ }
+
+ // ── Keyboard fallback ───────────────────────────────────────────────────
+
+ const _KEY_CODES = {
+ KeyB: { pad: PAD.KICK, cymbal: false },
+ KeyF: { pad: PAD.RED, cymbal: false },
+ KeyJ: { pad: PAD.YELLOW, cymbal: false },
+ KeyK: { pad: PAD.BLUE, cymbal: false },
+ KeyL: { pad: PAD.GREEN, cymbal: false },
+ KeyU: { pad: PAD.YELLOW, cymbal: true },
+ KeyI: { pad: PAD.BLUE, cymbal: true },
+ KeyO: { pad: PAD.GREEN, cymbal: true },
+ };
+ const _KEY_CHARS = { b: 'KeyB', f: 'KeyF', j: 'KeyJ', k: 'KeyK', l: 'KeyL', u: 'KeyU', i: 'KeyI', o: 'KeyO' };
+
+ /**
+ * Keyboard fallback: B = kick (Space stays play/pause), F = red, J/K/L = yellow/blue/green pads (Shift+J/K/L or
+ * U/I/O = the cymbal of that colour), Enter = activate star power. Matches the physical key
+ * (e.code) first so it works on any layout. Returns {pad, cymbal}, {action:'activate'} or null.
+ */
+ function keyToPad(e) {
+ if (!e || e.ctrlKey || e.metaKey || e.altKey) return null;
+ const key = typeof e.key === 'string' ? e.key : '';
+ const code = typeof e.code === 'string' ? e.code : '';
+ if (code === 'Enter' || code === 'NumpadEnter' || key === 'Enter') return { action: 'activate' };
+ let m = _KEY_CODES[code];
+ if (!m) { const c = _KEY_CHARS[key.toLowerCase()]; if (c) m = _KEY_CODES[c]; }
+ if (!m) return null;
+ const cymbal = m.cymbal || (!!e.shiftKey && m.pad >= PAD.YELLOW);
+ return { pad: m.pad, cymbal };
+ }
+
+ /** GM note for the synth when the hit came from the keyboard. */
+ function synthMidiForPad(pad, cymbal) {
+ switch (pad) {
+ case PAD.KICK: return PAD_SYNTH_MIDI.kick;
+ case PAD.RED: return PAD_SYNTH_MIDI.red;
+ case PAD.YELLOW: return cymbal ? PAD_SYNTH_MIDI.yellowCymbal : PAD_SYNTH_MIDI.yellowTom;
+ case PAD.BLUE: return cymbal ? PAD_SYNTH_MIDI.blueCymbal : PAD_SYNTH_MIDI.blueTom;
+ case PAD.GREEN: return cymbal ? PAD_SYNTH_MIDI.greenCymbal : PAD_SYNTH_MIDI.greenTom;
+ default: return null;
+ }
+ }
+
+ // ── View: Three.js scene + HUD ──────────────────────────────────────────
+
+ function _cssHex(hex) { return '#' + (hex >>> 0).toString(16).padStart(6, '0'); }
+
+ function _radialTexture(T, size, stops) {
+ const c = document.createElement('canvas');
+ c.width = c.height = size;
+ const g = c.getContext('2d');
+ const grad = g.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
+ for (const [o, col] of stops) grad.addColorStop(o, col);
+ g.fillStyle = grad;
+ g.fillRect(0, 0, size, size);
+ const tex = new T.CanvasTexture(c);
+ tex.colorSpace = T.SRGBColorSpace;
+ return tex;
+ }
+
+ function _trackTexture(T) {
+ const w = 512, h = 64;
+ const c = document.createElement('canvas');
+ c.width = w; c.height = h;
+ const g = c.getContext('2d');
+ g.fillStyle = _cssHex(COLORS.track);
+ g.fillRect(0, 0, w, h);
+ const lw = w / 4;
+ for (let i = 0; i < 4; i++) {
+ const col = LANE_COLORS[LANE_NAMES[i]];
+ const r = (col >> 16) & 255, gg = (col >> 8) & 255, b = col & 255;
+ const grad = g.createLinearGradient(i * lw, 0, (i + 1) * lw, 0);
+ grad.addColorStop(0, `rgba(${r},${gg},${b},0.02)`);
+ grad.addColorStop(0.5, `rgba(${r},${gg},${b},0.075)`);
+ grad.addColorStop(1, `rgba(${r},${gg},${b},0.02)`);
+ g.fillStyle = grad;
+ g.fillRect(i * lw, 0, lw, h);
+ }
+ // faint scanline texture so the track reads as moving
+ g.fillStyle = 'rgba(255,255,255,0.025)';
+ for (let y = 0; y < h; y += 8) g.fillRect(0, y, w, 2);
+ const tex = new T.CanvasTexture(c);
+ tex.colorSpace = T.SRGBColorSpace;
+ tex.wrapS = T.ClampToEdgeWrapping;
+ tex.wrapT = T.RepeatWrapping;
+ tex.anisotropy = 4;
+ return tex;
+ }
+
+ // A rounded-rectangle Shape (w x h, corner radius r) centred on the origin; with hw/hh/hr it gets a
+ // centred rounded-rectangle hole, which makes a frame.
+ function _roundedRect(T, w, h, r, hw, hh, hr) {
+ const path = (P, w, h, r) => {
+ const x = -w / 2, y = -h / 2;
+ r = Math.min(r, w / 2, h / 2);
+ P.moveTo(x + r, y);
+ P.lineTo(x + w - r, y);
+ P.quadraticCurveTo(x + w, y, x + w, y + r);
+ P.lineTo(x + w, y + h - r);
+ P.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
+ P.lineTo(x + r, y + h);
+ P.quadraticCurveTo(x, y + h, x, y + h - r);
+ P.lineTo(x, y + r);
+ P.quadraticCurveTo(x, y, x + r, y);
+ return P;
+ };
+ const s = path(new T.Shape(), w, h, r);
+ if (hw > 0 && hh > 0) s.holes.push(path(new T.Path(), hw, hh, hr));
+ return s;
+ }
+
+ // Pixel-store state survives on a context shared by successive renderers (same