diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bc81a86..6e70e49 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,7 @@ name: CI
on:
push:
- branches: [main]
+ branches: [main, develop]
pull_request:
permissions:
@@ -34,6 +34,8 @@ jobs:
persist-credentials: false
- name: Validate public-readiness contracts
run: bash tests/scripts/public-readiness-contract-tests.sh
+ - name: Validate MPL licensing contracts
+ run: bash tests/scripts/licensing-contract-tests.sh
- name: Validate website localization contracts
run: bash tests/scripts/website-localization-contract-tests.sh
- name: Validate public Wiki contracts
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index d538817..94f3af4 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -2,7 +2,7 @@ name: CodeQL
on:
push:
- branches: [main]
+ branches: [main, develop]
pull_request:
schedule:
- cron: '17 3 * * 1'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abdbed0..9cc9ac4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@ semantico a partire dalla prima release pubblica stabile.
## [Unreleased]
+## [0.6.0-beta.1] - 2026-08-20
+
### Added
- build e packaging Windows x64 keyboard-only con esclusione verificata di CoreMIDI;
@@ -19,13 +21,22 @@ semantico a partire dalla prima release pubblica stabile.
- metronomo opzionale sincronizzato al clock DSP della sessione;
- controlli CI, CodeQL, Dependabot e template per contributi;
- documentazione privacy, sicurezza e pubblicazione.
+- Chart Creator locale con import audio, registrazione, editing delle note,
+ waveform, scrubbing e pacchetti portabili `.htksong` senza audio incorporato;
+- Practice Lab con loop di sezioni e punti A/B;
+- Performance Error Map e Auto Tempo Coach basati sui risultati reali;
+- sound check guidato per la compensazione locale della latenza;
+- Ghost Replay visuale locale, isolato da matcher e punteggio;
+- palco reattivo procedurale con movimento ridotto e contrasto elevato.
+- pannello risultati scorrevole con controlli diagnostici e azioni sempre
+ separati e leggibili.
### Changed
- timestamp MIDI preservati fino al clock della canzone;
- catalogo incluso limitato a contenuti originali o esempi locali neutrali;
- toolchain xUnit aggiornata per rimuovere dipendenze transitive vulnerabili;
-- identità applicazione impostata su HitTheKit 0.5.0.
+- identità applicazione impostata su HitTheKit 0.6.0.
### Removed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3986d7e..e53d978 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,9 +4,9 @@ Thank you for helping make HitTheKit more reliable, accessible, and useful.
Before participating, read the [Code of Conduct](CODE_OF_CONDUCT.md),
[support guide](SUPPORT.md), and [project governance](GOVERNANCE.md).
-HitTheKit is intended to remain available under both an open-source GPL license
-and a possible separate commercial-license path. That model requires clear
-copyright permissions for every substantial contribution.
+HitTheKit is intended to remain available under both the open-source MPL-2.0
+Community license and a possible separate commercial-license path. That model
+requires clear copyright permissions for every substantial contribution.
## Current contribution policy
@@ -57,11 +57,32 @@ Approval to discuss an idea is not approval to contribute substantial code or
assets. A merged change must still satisfy provenance, security, accessibility,
and release requirements.
+### Branch target
+
+Authorized feature, fix, documentation, and maintenance pull requests normally
+target `develop`. The `main` branch represents the latest stable, releasable
+source state and accepts only reviewed release-candidate or urgent hotfix pull
+requests. Use the repository prefixes `feature/`, `fix/`, `chore/`, `docs/`, or
+`hotfix/` for short-lived branches; do not create permanent `beta` or `stable`
+branches.
+
+The complete development, beta, release-candidate, stable, and hotfix workflow
+is documented in
+[`docs/development/branching-and-releases.md`](docs/development/branching-and-releases.md).
+
A Contributor License Agreement, copyright assignment, or another contribution
mechanism may be introduced after professional legal review. This document is
not a CLA, does not transfer copyright, and does not create an implied
assignment or commercial-license grant.
+A Harmony-based copyright-license CLA is currently being evaluated in
+[`docs/legal/CONTRIBUTOR_LICENSE_AGREEMENT_DRAFT.md`](docs/legal/CONTRIBUTOR_LICENSE_AGREEMENT_DRAFT.md),
+with unresolved adoption gates recorded in
+[`docs/legal/CLA_ADOPTION_CHECKLIST.md`](docs/legal/CLA_ADOPTION_CHECKLIST.md).
+Both documents are non-operative drafts. Do not sign or rely on them. Opening a
+pull request, issue, or discussion does not accept the draft, and no CLA
+automation or electronic acceptance process is active.
+
Until a reviewed mechanism exists, maintainers should not merge a substantial
external code or asset contribution merely because it was submitted under the
repository's Community license. The required rights for the proposed
diff --git a/FUNDING.md b/FUNDING.md
index 3e36c52..b06ed7f 100644
--- a/FUNDING.md
+++ b/FUNDING.md
@@ -54,7 +54,7 @@ help without creating a recurring commitment:
accessibility, and focused development time a meaningful one-time boost.
Custom monthly and one-time support remains available through GitHub Sponsors.
-Rewards are digital, low-maintenance, privacy-conscious and independent of GPL
+Rewards are digital, low-maintenance, privacy-conscious and independent of MPL
rights. Sponsorship does not purchase guaranteed features, roadmap control,
private GoMyRO access, commercial-song rights or a commercial HitTheKit license.
diff --git a/LICENSE b/LICENSE
index f288702..d0a1fa1 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,674 +1,373 @@
- 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
-.
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of others (if any) used
+ by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+ means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+ means Source Code Form to which the initial Contributor has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+ means
+
+ (a) that the initial Contributor has attached the notice described
+ in Exhibit B to the Covered Software; or
+
+ (b) that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the
+ terms of a Secondary License.
+
+1.6. "Executable Form"
+ means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+ means a work that combines Covered Software with other material, in
+ a separate file or files, that is not Covered Software.
+
+1.8. "License"
+ means this document.
+
+1.9. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently, any and
+ all of the rights conveyed by this License.
+
+1.10. "Modifications"
+ means any of the following:
+
+ (a) any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered
+ Software; or
+
+ (b) any new file in Source Code Form that contains any Covered
+ Software.
+
+1.11. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by the making, using, selling, offering for sale, having
+ made, import, or transfer of either its Contributions or its
+ Contributor Version.
+
+1.12. "Secondary License"
+ means either the GNU General Public License, Version 2.0, the GNU
+ Lesser General Public License, Version 2.1, the GNU Affero General
+ Public License, Version 3.0, or any later versions of those
+ licenses.
+
+1.13. "Source Code Form"
+ means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You. For
+ purposes of this definition, "control" means (a) the power, direct
+ or indirect, to cause the direction or management of such entity,
+ whether by contract or otherwise, or (b) ownership of more than
+ fifty percent (50%) of the outstanding shares or beneficial
+ ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+ or
+
+(b) for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+* *
+* 6. Disclaimer of Warranty *
+* ------------------------- *
+* *
+* Covered Software is provided under this License on an "as is" *
+* basis, without warranty of any kind, either expressed, implied, or *
+* statutory, including, without limitation, warranties that the *
+* Covered Software is free of defects, merchantable, fit for a *
+* particular purpose or non-infringing. The entire risk as to the *
+* quality and performance of the Covered Software is with You. *
+* Should any Covered Software prove defective in any respect, You *
+* (not any Contributor) assume the cost of any necessary servicing, *
+* repair, or correction. This disclaimer of warranty constitutes an *
+* essential part of this License. No use of any Covered Software is *
+* authorized under this License except under this disclaimer. *
+* *
+************************************************************************
+
+************************************************************************
+* *
+* 7. Limitation of Liability *
+* -------------------------- *
+* *
+* Under no circumstances and under no legal theory, whether tort *
+* (including negligence), contract, or otherwise, shall any *
+* Contributor, or anyone who distributes Covered Software as *
+* permitted above, be liable to You for any direct, indirect, *
+* special, incidental, or consequential damages of any character *
+* including, without limitation, damages for lost profits, loss of *
+* goodwill, work stoppage, computer failure or malfunction, or any *
+* and all other commercial damages or losses, even if such party *
+* shall have been informed of the possibility of such damages. This *
+* limitation of liability shall not apply to liability for death or *
+* personal injury resulting from such party's negligence to the *
+* extent applicable law prohibits such limitation. Some *
+* jurisdictions do not allow the exclusion or limitation of *
+* incidental or consequential damages, so this exclusion and *
+* limitation may not apply to You. *
+* *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+ This Source Code Form is "Incompatible With Secondary Licenses", as
+ defined by the Mozilla Public License, v. 2.0.
diff --git a/LICENSING.md b/LICENSING.md
index efd440c..82f396b 100644
--- a/LICENSING.md
+++ b/LICENSING.md
@@ -1,66 +1,68 @@
# HitTheKit Licensing
-HitTheKit is intended to be available under a dual-licensing model.
-
-This repository currently grants the Community license under version 3 of the
-GNU General Public License. No project-specific "or any later version" grant
-was found during the licensing audit, so the current grant is treated as
-GPL-3.0-only. The complete, unmodified license text is in [`LICENSE`](LICENSE).
+HitTheKit uses a dual-licensing model.
## Community license
-The Community edition may be used, studied, modified, shared, and used
-commercially under the GNU General Public License version 3, provided that the
-applicable GPL obligations are respected.
+Unless a file or directory says otherwise, original HitTheKit source code is
+available under the [Mozilla Public License 2.0](LICENSE) (MPL-2.0).
+Third-party components, Unity packages, generated engine material, trademarks,
+and content retain their own terms; see
+[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), [NOTICE](NOTICE), and
+[TRADEMARKS.md](TRADEMARKS.md).
+
+The MPL permits personal, educational, community, and commercial use. When
+MPL-covered source files are modified and distributed, those covered files
+must remain available under the MPL. A larger work may contain separate files
+under other terms, provided the MPL requirements for covered files are met.
+The full license text controls; this overview does not replace it.
+
+## Unity players and other executable distributions
-In particular, conveying modified or combined covered works may require
-providing corresponding source code and licensing the covered work under the
-GPL. Consult the full GPL text for the actual terms; this overview does not
-replace it.
+MPL-2.0 permits covered HitTheKit code to be distributed in a larger executable
+under different terms while preserving recipients' rights to the MPL-covered
+source. This removes the former GPL whole-work copyleft concern for a player
+that incorporates the proprietary Unity Runtime.
+
+It does not itself approve a particular binary release. Every macOS or Windows
+candidate must still satisfy the applicable Unity terms, dependency notices,
+asset and content rights, signing requirements, platform testing, and the
+project release checklist. Unity and third-party technology are not relicensed
+by HitTheKit.
## Commercial license
-A separate commercial license may be available for organizations or
-individuals that want to use HitTheKit under terms incompatible with the GPL,
-including use within proprietary or closed-source products. Possible use cases
-include:
+A separate commercial license may be available to organizations or individuals
+that need terms different from MPL-2.0, including:
-- proprietary desktop or mobile applications;
-- closed-source training platforms;
-- commercial hardware integrations;
-- bundled products;
-- private-label or white-label distributions; and
-- redistribution without GPL copyleft obligations for HitTheKit code.
+- keeping modifications to MPL-covered HitTheKit files proprietary;
+- proprietary hardware or software integrations;
+- bundled, private-label, or white-label distribution; or
+- separately negotiated support, warranty, or maintenance terms.
-The commercial license is not automatically granted by this repository.
-Commercial terms require a separate written agreement with a copyright holder
-who has authority to license the relevant code. Such an agreement covers only
-the rights that licensor controls; it does not replace licenses or commercial
-terms for Unity or any other third-party component.
+The commercial license is not automatically granted and is not required merely
+because an activity generates revenue. It requires a separate written agreement
+with a copyright holder authorized to license the relevant code. It cannot
+replace Unity's terms or the licenses of third-party components.
-## Personal, educational, and commercial use
+## Existing GPL copies
-Personal, educational, and community use is welcome under the GPL Community
-license. No commercial license is required merely because an activity
-generates revenue, provided that the use and any distribution comply with the
-GPL.
+Copies validly received under GPL-3.0-only before the MPL relicensing remain
+available to their recipients under GPL-3.0-only. The MPL change does not revoke
+rights already granted. A recipient must use the license offered for the exact
+copy or version they received.
## Contributions
-Contributions require separate governance rules so that the project can remain
-eligible for dual licensing. See [`CONTRIBUTING.md`](CONTRIBUTING.md).
+Future contribution terms must preserve a clear rights chain for both the MPL
+Community edition and any separate commercial licensing. Until that mechanism
+has been reviewed and adopted, follow [CONTRIBUTING.md](CONTRIBUTING.md).
## No trademark permission
-The software license does not automatically grant permission to use the
-HitTheKit name, logo, or branding to market a modified or competing product.
-Trademark rights, if any, are separate from copyright licensing. See
-[`TRADEMARKS.md`](TRADEMARKS.md).
-
-## Contact
-
-Commercial licensing inquiries: open a private inquiry with the copyright
-holder. A dedicated contact address will be published later.
+MPL-2.0 does not grant permission to use the HitTheKit name, logo, or branding
+to market a modified or competing product. Truthful attribution remains
+permitted. See [TRADEMARKS.md](TRADEMARKS.md).
This document is a project licensing overview, not legal advice and not a
-substitute for the full GPL text or a signed commercial agreement.
+substitute for the MPL text or a signed commercial agreement.
diff --git a/NOTICE b/NOTICE
index 06d7692..dd79329 100644
--- a/NOTICE
+++ b/NOTICE
@@ -3,18 +3,18 @@ HitTheKit public source notice
Copyright (c) 2026 HitTheKit contributors and the respective rightsholders
identified in COPYRIGHT.md.
-Original HitTheKit source code is made available under GPL-3.0-only. See
-LICENSE. Third-party software and tools retain their own licenses and notices;
-see THIRD_PARTY_NOTICES.md.
+Unless stated otherwise, original HitTheKit source code is made available under
+MPL-2.0. See LICENSE and LICENSING.md. Third-party software and tools retain
+their own licenses and notices; see THIRD_PARTY_NOTICES.md.
This source repository contains no commercial recordings, stems, MIDI files,
lyrics, transcriptions, album artwork, or download links. Player-owned local
content is outside the repository and remains the player's responsibility.
-The initial public publication is source-and-website only. No Unity player
-binary is approved for public distribution. Unity-runtime redistribution and
-the proposed separate commercial-license path remain subject to qualified
-legal review and are not granted by this notice.
+No Unity player binary is approved merely by this notice. Each binary candidate
+must independently pass the Unity terms, dependency, provenance, signing,
+platform-testing, and release gates. The proposed separate commercial-license
+path requires a written agreement and is not granted by this notice.
HitTheKit is independent and is not sponsored, endorsed, certified by, or
affiliated with Unity Technologies, Apple Inc., or referenced hardware
diff --git a/README.md b/README.md
index 3181fbc..766eb9a 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,8 @@
-
-
+
+
@@ -66,10 +66,10 @@ for HitTheKit.
## Project status
> [!IMPORTANT]
-> HitTheKit is an active pre-release project. This public repository distributes
-> source code and the website only. No Unity player binary is currently approved
-> for public distribution. Historical builds and private development history are
-> intentionally excluded.
+> HitTheKit is an active pre-release project. Signed and notarized Apple-silicon
+> beta builds, when available, are published only through the official GitHub
+> prereleases. They are tester-facing candidates rather than stable releases.
+> Historical private builds and private development history remain excluded.
| Capability | Current status |
| --- | --- |
@@ -77,7 +77,7 @@ for HitTheKit.
| Electronic-drum MIDI | macOS Apple Silicon through CoreMIDI |
| Learning path | 12 playable lessons; 12 more planned |
| Included music | Original rights-clean demo only |
-| Public binary | Not yet approved for distribution |
+| Public binary | macOS Apple-silicon beta only; see GitHub Releases |
Current limitations and release gates are tracked in
[SUPPORT.md](SUPPORT.md) and the
@@ -176,8 +176,9 @@ hardware result, improve documentation or pick a
## Licensing and content boundary
Original project code is licensed under
-[GPL-3.0-only](LICENSE). A possible separate commercial-license path is under
-preparation and remains subject to legal review; see [LICENSING.md](LICENSING.md).
+[MPL-2.0](LICENSE). A separate commercial-license path remains available only
+through a written agreement; see [LICENSING.md](LICENSING.md). Existing copies
+previously received under GPL-3.0-only retain those rights.
The public repository does not include commercial songs, recordings, charts,
lyrics or artwork. Third-party names describe compatibility only. HitTheKit is
diff --git a/ROADMAP.md b/ROADMAP.md
index 81e9dfb..c142fa0 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -15,8 +15,8 @@ documented, and represented honestly in the release notes.
reduced motion;
- publish screenshots, checksums, known limitations, and reproducible release
notes from the exact candidate commit; and
-- complete professional review of GPL, Unity distribution, contribution, and
- commercial-licensing terms.
+- validate the MPL-based Unity distribution, contribution, and
+ commercial-licensing terms for the exact release candidate.
## Next: broader compatibility and confidence
diff --git a/SUPPORT.md b/SUPPORT.md
index 018d9b0..f197ba9 100644
--- a/SUPPORT.md
+++ b/SUPPORT.md
@@ -29,7 +29,7 @@ proposals so that actionable reports retain the information needed for review.
## Supported versions
-Only the current `0.5.x` pre-release line receives best-effort fixes. Historical
+Only the current `0.6.x` pre-release line receives best-effort fixes. Historical
playtest tags and binaries are unsupported and must not be redistributed as
current releases.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 6f99ee6..f47116a 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -8,7 +8,7 @@ provenance is recorded separately in
| Component | Version | Declared license or terms | Role | Redistributed by this repository? | Review status / action |
| --- | --- | --- | --- | --- | --- |
-| Unity Editor and built-in Unity modules | Editor 6000.5.6f1; modules 1.0.0 | Unity Terms of Service and Unity Editor Software Terms | Authoring, compilation, runtime APIs | Editor and module source/binaries are not tracked; a future player build may contain Unity runtime components | **Legal review required:** confirm current plan eligibility, player redistribution, GPL interaction, and separate Unity permission for embedded systems. |
+| Unity Editor and built-in Unity modules | Editor 6000.5.6f1; modules 1.0.0 | Unity Terms of Service and Unity Editor Software Terms | Authoring, compilation, runtime APIs | Editor and module source/binaries are not tracked; a future player build may contain Unity runtime components | Confirm current plan eligibility, player redistribution terms, required notices, and separate Unity permission for embedded systems for every candidate. MPL-2.0 does not relicense Unity. |
| Unity Test Framework | 1.7.0 | Unity Companion License v1.4 | EditMode and PlayMode testing | Package source is resolved by Unity Package Manager, not tracked or intended for player distribution | Preserve the package license and copyright notice if substantial package portions are redistributed. |
| Unity Custom NUnit (`com.unity.ext.nunit`) | 2.1.0, based on NUnit 3.5 | Unity Package Distribution License v2.1; bundled NUnit portions under MIT | Transitive Unity test dependency | Resolved by Unity Package Manager, not tracked or intended for player distribution | Preserve Unity package terms and the bundled NUnit MIT notice when applicable. |
| .NET SDK | 8.0.411 requested by `global.json` | MIT plus the distribution's third-party notices | Build tooling | No | Build tool only; follow the license and notices of the installed distribution. |
@@ -21,7 +21,7 @@ provenance is recorded separately in
## Verified sources
-- GNU GPL version 3:
+- Mozilla Public License 2.0:
- Unity terms:
- Unity Companion License:
- Unity Package Distribution License:
diff --git a/TRADEMARKS.md b/TRADEMARKS.md
index de7ab9a..84cc21f 100644
--- a/TRADEMARKS.md
+++ b/TRADEMARKS.md
@@ -1,10 +1,10 @@
# HitTheKit Name and Branding
-The GPL license for HitTheKit source code does not itself grant trademark
+The MPL-2.0 license for HitTheKit source code does not itself grant trademark
rights in the HitTheKit name, logo, or other project branding. No claim that a
mark is registered is made here.
-The GPL permits recipients to copy, modify, and distribute covered code under
+MPL-2.0 permits recipients to copy, modify, and distribute covered code under
its terms. A fork may identify the code it came from in a truthful, descriptive
way, but should not imply sponsorship, endorsement, certification, or official
status that has not been granted.
@@ -14,7 +14,7 @@ brand for a modified, competing, private-label, or commercial product. Any
separate branding permission should be documented in writing.
This guidance is provisional and requires legal review. It does not restrict
-the copyright permissions granted by the GPL.
+the copyright permissions granted by MPL-2.0.
## Third-party names
diff --git a/VERSION b/VERSION
index 8f0916f..a918a2a 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.5.0
+0.6.0
diff --git a/docs/README.md b/docs/README.md
index 5fc5e9d..694819f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -41,8 +41,16 @@ without creating an unreviewed second source of truth.
## Development and testing
+- [Branching and release channels](development/branching-and-releases.md)
- [DSP song clock](development/dsp-song-clock.md)
- [Chart timeline](development/chart-timeline.md)
+- [Practice Lab](development/practice-lab.md)
+- [Chart Creator foundation](development/chart-creator.md)
+- [Performance error map](development/performance-error-map.md)
+- [Auto Tempo Coach](development/auto-tempo-coach.md)
+- [Audio and latency sound check](development/audio-latency-sound-check.md)
+- [Ghost Replay](development/ghost-replay.md)
+- [Accessible reactive stage](development/reactive-stage.md)
- [Demo-song vertical slice](development/demo-song-vertical-slice.md)
- [Keyboard hit matching](development/keyboard-hit-matching.md)
- [Pad visuals](development/pad-visuals.md)
@@ -68,8 +76,11 @@ without creating an unreviewed second source of truth.
## Rights and governance
- [Asset provenance](legal/ASSET_PROVENANCE.md)
+- [Contributor License Agreement draft](legal/CONTRIBUTOR_LICENSE_AGREEMENT_DRAFT.md)
+- [CLA adoption checklist](legal/CLA_ADOPTION_CHECKLIST.md)
- [Dual-licensing readiness report](legal/dual-licensing-readiness-report.md)
-- [Dual-licensing decision record](governance/dual-licensing-decision.md)
+- [MPL Community license decision](governance/mpl-community-license-decision.md)
+- [Historical GPL dual-licensing proposal](governance/dual-licensing-decision.md)
- [Commercial-license draft](legal/COMMERCIAL-LICENSE-DRAFT.md)
- [Project governance](../GOVERNANCE.md)
- [License](../LICENSE)
diff --git a/docs/development/audio-latency-sound-check.md b/docs/development/audio-latency-sound-check.md
new file mode 100644
index 0000000..0cce5a0
--- /dev/null
+++ b/docs/development/audio-latency-sound-check.md
@@ -0,0 +1,25 @@
+# Audio and latency sound check
+
+The guided sound check lives in **Settings → Input calibration** and reuses the
+same local timing-offset preferences consumed by gameplay.
+
+## Flow
+
+1. **Test audio** plays an original generated click through Unity's active
+ output device. HitTheKit does not choose the hardware output; macOS or the
+ drum module must route it to the desired headphones/speakers.
+2. Select **Keyboard** or **MIDI**. The two recommendations never share samples.
+3. Start the check, listen to four count-in clicks, then strike with the next
+ twelve clicks.
+4. At least eight matched strikes are required. The existing
+ `TimingCalibrationAdvisor` calculates a median and rejects out-of-window
+ hits. Applying the recommendation updates only the selected input offset.
+
+The generated track is scheduled against Unity DSP time. Target timestamps use
+the corresponding unscaled-time origin, so the measurement does not depend on
+frame-by-frame accumulated time. All samples remain in memory and local to the
+running app; there is no telemetry, upload, or raw-hit persistence.
+
+This is a guided user calibration, not laboratory round-trip latency
+measurement: human timing variation is reduced by the median and the minimum
+sample gate, not claimed to be eliminated.
diff --git a/docs/development/auto-tempo-coach.md b/docs/development/auto-tempo-coach.md
new file mode 100644
index 0000000..2114204
--- /dev/null
+++ b/docs/development/auto-tempo-coach.md
@@ -0,0 +1,28 @@
+# Auto Tempo Coach
+
+Auto Tempo Coach turns a completed lesson or Song Library attempt into a safe
+next-speed recommendation. It uses the existing score and hit-matching results;
+it does not introduce a second scoring model.
+
+## Progression
+
+Song Library sessions follow the already supported song speeds. Lessons follow
+the existing study progression (`0.5x`, `0.75x`, `1.0x`). A step unlocks when:
+
+- accuracy is at least 85%;
+- misses are no more than 10% of chart notes;
+- unmatched hits are no more than 10% of chart notes.
+
+Below those guardrails the coach asks the player to repeat the current speed.
+At the final supported speed it reports the target as mastered.
+
+## Applying a recommendation
+
+The results screen displays the recommendation. The player explicitly confirms
+the next speed; the gameplay scene then reloads the same lesson or song with a
+new immutable `GameplaySessionDefinition`. Original BPM is recovered from the
+current effective BPM and multiplier, then chart timing, audio pitch, count-in
+and display metadata are rebuilt together.
+
+This avoids changing playback speed halfway through a scored attempt and keeps
+the DSP clock, chart timeline and external audio on one timing contract.
diff --git a/docs/development/branching-and-releases.md b/docs/development/branching-and-releases.md
new file mode 100644
index 0000000..5352d21
--- /dev/null
+++ b/docs/development/branching-and-releases.md
@@ -0,0 +1,96 @@
+# Branching and release channels
+
+HitTheKit separates active development from stable public source without
+keeping duplicate permanent `beta` or `stable` branches.
+
+## Permanent branches
+
+| Branch | Meaning | Accepted changes |
+| --- | --- | --- |
+| `main` | Latest stable, releasable source state | Reviewed release candidates and urgent hotfixes |
+| `develop` | Integration state for the next version | Reviewed feature, fix, documentation, and maintenance pull requests |
+
+Both branches are protected. Pull requests, required CI and CodeQL checks,
+linear history, and resolved review conversations are required. Force pushes
+and deletion are disabled. The single-maintainer project currently requires
+zero external approvals so that protection does not make maintenance
+impossible; review evidence and passing gates are still required by the release
+process.
+
+The repository default branch remains `main`. It is the public landing point
+and must not expose partially integrated work.
+
+## Short-lived branches
+
+Start normal work from the latest `develop` and target `develop`:
+
+- `feature/` for product or engineering capabilities;
+- `fix/` for defects;
+- `chore/` for maintenance and automation; and
+- `docs/` for documentation-only changes.
+
+Delete these branches after merge. Keep each pull request focused and do not
+mix unrelated changes.
+
+## Beta and release candidate
+
+When `develop` contains a coherent candidate, create
+`release/v..` from its exact reviewed commit. Freeze feature
+work on that branch: only release blockers, evidence, versioning, and release
+documentation may change.
+
+Preview milestones are immutable tags and GitHub prereleases, not permanent
+branches:
+
+- `vX.Y.Z-alpha.N` for an early integration snapshot;
+- `vX.Y.Z-beta.N` for a tester-facing candidate; and
+- `vX.Y.Z-rc.N` for a candidate expected to become stable if its gates pass.
+
+Source-only prereleases must use the attested source-snapshot workflow and must
+not attach Unity player binaries. A prerelease label communicates maturity; it
+does not waive content, provenance, security, or release checks.
+
+## Stable release
+
+After the release checklist passes:
+
+1. open a pull request from `release/vX.Y.Z` to `main`;
+2. verify the exact head commit and all required checks;
+3. merge with the repository's linear-history policy;
+4. create the stable `vX.Y.Z` tag from the resulting `main` commit;
+5. publish the approved source-only release and its checksum/attestation; and
+6. merge or replay any release-only fixes into `develop` before deleting the
+ release branch.
+
+A stable tag is never moved or reused. If a published version needs a fix,
+release a new patch version.
+
+## Hotfix
+
+Create `hotfix/vX.Y.Z` from `main` only for a defect that cannot wait for the
+next normal release. Validate it through a pull request to `main`, create the
+new patch tag, then merge or replay the same fix into `develop`. Never force
+push a protected branch or silently leave the two permanent branches with
+different fixes.
+
+## Flow summary
+
+```text
+feature/* fix/* chore/* docs/*
+ └── pull request ──> develop
+ │
+ ├── optional alpha tag
+ │
+ └──> release/vX.Y.Z
+ ├── beta / rc tags
+ └── pull request ──> main
+ └── stable vX.Y.Z
+
+main ──> hotfix/vX.Y.Z ── pull request ──> main
+ └── replay the fix ──> develop
+```
+
+GitHub Pages continues to deploy only from `main`. CI and CodeQL run for pull
+requests and after merges to both permanent branches. Unity CI remains governed
+by the separate license-aware gate described in
+[`unity-test-gate.md`](unity-test-gate.md).
diff --git a/docs/development/chart-creator.md b/docs/development/chart-creator.md
new file mode 100644
index 0000000..07753a0
--- /dev/null
+++ b/docs/development/chart-creator.md
@@ -0,0 +1,101 @@
+# Chart Creator foundation
+
+Chart Creator turns a performance from the existing keyboard/CoreMIDI gameplay
+input into a reviewable schema-v1 chart draft. It deliberately reuses the real
+gameplay scene rather than maintaining a second audio clock, input mapper, or
+timeline.
+
+## Workflow
+
+1. Select **Import audio & create** in the Song Library and choose a local WAV
+ or OGG file, or select an existing playable song.
+2. For new audio, enter title, artist, verified BPM, bar count and beats per bar.
+ HitTheKit copies the selected audio into a private local authoring folder; it
+ never modifies the source file.
+3. Choose practice speed and select **Record chart**. A new audio-only source
+ starts with a real empty schema-v1 timeline; an existing song keeps its
+ current chart only as a playback reference.
+4. Play during the normal count-in and backing track. Hits before song time zero
+ or beyond the declared song duration are ignored.
+5. At the result screen, review the captured notes against the bounded waveform.
+ Drag its playhead to scrub the source timeline, zoom around a passage, and
+ preview audio from the selected time. Select a note to change its time, drum
+ pad, velocity or physical articulation, add missing notes, or delete unwanted
+ notes. The original recorded take remains unchanged in memory.
+6. Save the edited timing as recorded, or quantize the edited draft
+ non-destructively to an eighth- or sixteenth-note grid.
+
+Keyboard and MIDI events reach the recorder through `HitMatchingPrototype`'s
+`InputProcessed` boundary. Consequently, the existing per-source timing offset
+is applied before recording. A take made at a reduced practice speed is scaled
+back to the source song's original timeline before it is serialized.
+
+## Output and rights boundary
+
+The exporter creates a new, never-overwritten folder under
+`~/Documents/HTKSongs` containing only:
+
+- `song.json`;
+- `notes.json`.
+
+It also creates one portable `.htksong` file alongside the folder.
+The package is a ZIP-compatible, chart-only container with exactly three
+entries:
+
+- `htksong-version` (`1`);
+- `song.json`;
+- `notes.json`.
+
+To transfer a take, copy only the `.htksong` file into
+`Documents/HTKSongs` on the other computer and refresh the Song Library. The
+game validates and atomically imports it. Existing song folders are never
+overwritten. Select the imported entry, choose **Bind local audio**, select
+your own authorized WAV/OGG copy, and confirm the local-only binding. The game
+copies that audio into the imported song folder and makes the entry playable;
+the source file and portable package are not modified.
+
+The publish is atomic and both documents are parsed by the production loaders
+before the folder or package becomes visible. When the recording used a local
+WAV/OGG source, the private exported folder receives its own local audio copy and
+is immediately playable. The `.htksong` manifest still declares audio as
+`missing`: Chart Creator never embeds, downloads, or redistributes source audio
+in the portable package. A recipient supplies their own authorized local copy.
+
+Import is fail-closed. Unknown/archive entries, audio declarations, symbolic
+links, duplicate names, unsupported versions, malformed JSON, invalid charts,
+oversized data and path traversal are rejected before extraction. Version 1 is
+intentionally chart-only; adding optional distributable audio requires a future
+explicit package version and rights-aware UX.
+
+The exported title is marked `Recorded Take` and the difficulty hint says that
+the performance must be reviewed before sharing. This is a captured performance,
+not a claim of authoritative transcription.
+
+## Note expression
+
+Schema v1 remains backward compatible: `velocity` and `articulation` are
+optional note properties. A legacy note without them keeps unknown velocity and
+the `default` articulation wildcard. Newly recorded/exported notes preserve
+velocity; non-default articulations use explicit identifiers such as `rim`,
+`bell`, `bow`, `edge`, `open`, or `pedal`.
+
+Known articulations are validated against the selected pad. An explicit Ride
+Bell target, for example, matches only a bell hit and highlights the bell zone
+of the instructional kit. Changing a note to an incompatible pad resets its
+articulation to the backward-compatible default rather than inventing a zone.
+
+The waveform is an editor envelope, not a second clock or audio importer. It is
+sampled from the already-loaded `AudioClip` into a bounded 512-point model, and
+preview playback reuses the existing `DspSongClockPrototype` audio source. The
+chart timing remains source-time based and the recorded take remains immutable.
+
+## Current foundation limits
+
+- The waveform provides envelope scrubbing, zoom and preview; it is not yet a
+ sample-accurate destructive audio editor.
+- The native picker currently targets macOS. WAV and OGG are supported; MP3 is
+ intentionally rejected by the production loader.
+- The author must enter BPM, bars and meter explicitly. Unknown timing never
+ receives a hidden default.
+- Portable packages remain chart-only; the receiving computer must explicitly
+ bind an authorized local WAV/OGG copy before the imported chart is playable.
diff --git a/docs/development/ghost-replay.md b/docs/development/ghost-replay.md
new file mode 100644
index 0000000..0f7ec8f
--- /dev/null
+++ b/docs/development/ghost-replay.md
@@ -0,0 +1,21 @@
+# Ghost Replay
+
+Ghost Replay is a local visual comparison against the immediately preceding
+take.
+
+After a run, **Riprova con Ghost** freezes the player's evaluated input times
+and restarts the same session. The previous hits appear as white outlined
+cross-markers on the existing DSP-driven highway. Their position is derived
+from absolute song time, exactly like chart notes.
+
+The ghost is deliberately isolated from gameplay rules:
+
+- it never enters `HitMatchingSession`;
+- it never changes score, combo, accuracy, misses, or audio;
+- count-in hits are ignored;
+- a take is bounded to 8,192 hits;
+- it is kept in memory only and is discarded with the gameplay scene.
+
+Normal restart discards the unfinished current take while preserving an
+already committed ghost. This keeps pause/restart deterministic and makes the
+comparison an explicit player choice.
diff --git a/docs/development/macos-playtest-package.md b/docs/development/macos-playtest-package.md
index 01c0588..843985d 100644
--- a/docs/development/macos-playtest-package.md
+++ b/docs/development/macos-playtest-package.md
@@ -14,7 +14,9 @@ Build and package it with Unity `6000.5.6f1`:
The script builds the native plug-in, synchronizes Core, invokes the checked-in
Unity build method, embeds the arm64 plug-in, applies an ad-hoc signature,
verifies the bundle, and creates a resource-fork-safe ZIP under
-`artifacts/game/`.
+`artifacts/game/`. The application and ZIP include `Legal/` with the MPL text,
+project and third-party notices, and `SOURCE-CODE.txt`, which identifies the
+exact public Git revision corresponding to the build.
This playtest packaging is intentionally not a production distribution flow.
Unity gives the local build an ad-hoc signature, but downloading it on another
diff --git a/docs/development/macos-signing-notarization.md b/docs/development/macos-signing-notarization.md
index a737dff..067522b 100644
--- a/docs/development/macos-signing-notarization.md
+++ b/docs/development/macos-signing-notarization.md
@@ -72,7 +72,10 @@ is the simpler and safer release procedure.
`build-macos-distribution-app.sh` builds the native arm64 CoreMIDI plug-in,
synchronizes the managed Core assembly, and asks Unity to create a non-
Development macOS player. The bundle identifier remains
-`com.codewriter90x.hitthekit`.
+`com.codewriter90x.hitthekit`. Before signing, it installs the MPL license,
+project notice, third-party notices, licensing overview, and an exact-revision
+`SOURCE-CODE.txt` under `Contents/Resources/Legal`. The final DMG exposes the
+same `Legal/` directory at its top level.
`sign-macos-app.sh` signs a staged copy and replaces the input only after the
staged bundle passes strict verification. It signs every nested Mach-O first,
diff --git a/docs/development/performance-error-map.md b/docs/development/performance-error-map.md
new file mode 100644
index 0000000..c1f6695
--- /dev/null
+++ b/docs/development/performance-error-map.md
@@ -0,0 +1,30 @@
+# Performance error map
+
+The results screen groups real `HitResult` values by four-bar Practice Lab
+section and drum piece. It highlights where the player struggled, rather than
+showing only a whole-song average.
+
+## Analysis
+
+`PracticeErrorMapAnalyzer` lives in Core and consumes the same resolved notes
+used by scoring. Each section/piece cell records Perfect, Good, Early, Late and
+Miss counts. Its displayed accuracy uses the existing gameplay weights:
+
+- Perfect: 100%;
+- Good: 75%;
+- Early or Late: 50%;
+- Miss: 0%.
+
+The map is deterministic and its section boundaries are start-inclusive and
+end-exclusive. Results outside the declared song sections are ignored rather
+than assigned to a misleading bucket.
+
+## Targeted practice
+
+The results screen lists the four weakest cells and exposes **Train weakest
+area**. That action selects the corresponding Practice Lab range, filters the
+existing matcher and seeks the existing DSP/audio transport with the normal
+two-beat preparation window. It does not build a second chart or matcher.
+
+Error-map state is per attempt and in-memory. Long-term history is deliberately
+left to a later schema change in the existing progress persistence service.
diff --git a/docs/development/practice-lab.md b/docs/development/practice-lab.md
new file mode 100644
index 0000000..731ccdb
--- /dev/null
+++ b/docs/development/practice-lab.md
@@ -0,0 +1,37 @@
+# Practice Lab
+
+Practice Lab adds repeatable song sections and manual A–B loops to the existing
+gameplay scene. It deliberately reuses the production DSP clock, chart timeline,
+hit matcher, score tracker and audio sources.
+
+## Player flow
+
+Pause a song with `Esc` or `P`, then use the Practice Lab panel:
+
+- choose the previous or next four-bar section and select **Loop section**;
+- select **Set A**, resume and pause later, then select **Set B** for a custom
+ range;
+- select **Whole song** to leave practice mode and restart normally.
+
+Every repetition includes a two-beat preparation window before point A when the
+range does not start at the beginning. Only notes inside `[A, B)` are sent to the
+matcher. Score, combo and timing analysis restart for each pass, while recorded
+practice time remains cumulative.
+
+## Timing contract
+
+`DspSongClock.Seek` re-anchors absolute song position to the current DSP time.
+`DspSongClockPrototype.SeekPlayback` moves the audio playhead to the equivalent
+clip position, including playback-speed conversion. The highway continues to
+derive marker position from absolute chart time, so repeated loops do not
+accumulate frame-based drift.
+
+Automatic sections are derived from verified session timing in groups of four
+bars. The final section may contain fewer bars. A selected section is clamped to
+the real audio duration before it can become active.
+
+## Boundaries
+
+This foundation does not persist loop selections and does not add a second
+timeline, scoring engine or audio transport. Named musical sections can be added
+later as optional chart metadata without changing the loop transport contract.
diff --git a/docs/development/reactive-stage.md b/docs/development/reactive-stage.md
new file mode 100644
index 0000000..066e5a0
--- /dev/null
+++ b/docs/development/reactive-stage.md
@@ -0,0 +1,21 @@
+# Accessible reactive stage
+
+The gameplay stage reacts to real runtime signals already produced by the hit matcher and score tracker. It does not own timing, scoring, input, or song state.
+
+## Signals
+
+- Combo selects a bounded energy band: calm, building, live, or peak.
+- The latest hit grade provides a short accent.
+- The latest drum pad selects the accent color already used by that lane.
+- Misses and unmatched inputs enter a visible recovery state.
+
+The stage uses text and geometric patterns as well as color. A miss therefore remains distinguishable in high-contrast and color-impaired viewing conditions.
+
+## Motion and intensity safety
+
+- Full visual intensity is capped at `0.78`.
+- `Reduce motion` replaces moving patterns with a steady composition and caps intensity at `0.22`.
+- Pulses last at least `0.18` seconds and the stage never alternates the full screen on/off.
+- `High contrast` increases outlines and preserves the textual stage state.
+
+All visuals are procedural UI Toolkit drawing. No commercial assets, per-frame material creation, telemetry, or additional gameplay systems are introduced.
diff --git a/docs/development/song-library.md b/docs/development/song-library.md
index 10485f6..81cbfa1 100644
--- a/docs/development/song-library.md
+++ b/docs/development/song-library.md
@@ -94,3 +94,39 @@ machine-specific path into bundled `song.json`.
The refresh button performs an explicit rescan. Discovery does not watch the
filesystem continuously, allocate every frame, or access folders outside the
two configured roots.
+
+## Local audio authoring
+
+The Song Library's **Import audio & create** action opens the macOS file picker
+for WAV/OGG content. After the player supplies title, artist and verified timing
+metadata, the importer copies the selected file into a new, never-overwritten
+folder under `~/Documents/HTKSongs`. The source file is not changed and its
+absolute path is not persisted in `song.json`.
+
+The resulting entry is intentionally audio-only: it is not playable as a normal
+song, but it can start Chart Creator with the existing DSP clock and an empty
+timeline. Once a take is saved, the local folder contains authorized audio plus
+the new chart and is playable immediately. Its sibling `.htksong` remains
+chart-only for safe transfer.
+
+## Portable `.htksong` chart packages
+
+Chart Creator writes a portable chart-only package next to each recorded take.
+Copy a `.htksong` file directly into `~/Documents/HTKSongs` and select
+**Refresh library**. Before normal folder discovery, the game validates the
+container and atomically installs its `song.json` and `notes.json` into a folder
+named after the validated song ID. The package remains in place so it can be
+copied to another computer; subsequent refreshes are idempotent.
+
+Package schema version 1 contains no audio and rejects any audio declaration or
+extra archive entry. Imports are bounded to 5 MiB, reject links, duplicate or
+case-colliding names, malformed ZIP/JSON/chart data, unsupported versions and
+path traversal, and never replace an existing song folder.
+
+An imported chart is therefore visible but unavailable until the player
+selects **Bind local audio** and supplies a WAV/OGG file they are entitled to
+use. The confirmation panel identifies the selected song and local filename;
+on confirmation the game copies the audio into that song's direct user-library
+folder, atomically updates its manifest, and refreshes the entry as playable.
+The source audio and `.htksong` package are never modified, and no absolute
+machine path is persisted.
diff --git a/docs/development/windows-playtest-package.md b/docs/development/windows-playtest-package.md
index 4b938d5..594b257 100644
--- a/docs/development/windows-playtest-package.md
+++ b/docs/development/windows-playtest-package.md
@@ -20,7 +20,9 @@ Build and package with:
The script synchronizes the tested Core assembly, asks Unity for a clean
`StandaloneWindows64` player, rejects a package containing the CoreMIDI native
-plug-in, adds player instructions, creates a ZIP, and prints its SHA-256.
+plug-in, adds player instructions and a `Legal/` directory, creates a ZIP, and
+prints its SHA-256. `Legal/SOURCE-CODE.txt` identifies the exact public Git
+revision for the MPL-covered HitTheKit files in that executable distribution.
Generated output remains under the ignored `artifacts/game` directory.
The Windows player contains `HitTheKit.exe`, `HitTheKit_Data`, Unity runtime
@@ -41,6 +43,13 @@ test the exact ZIP on clean Windows 10 and Windows 11 machines:
6. confirm Device Setup states clearly that production MIDI is macOS-only; and
7. collect logs and screenshots without personal paths or third-party content.
+## MIDI support
+
+Installing Unity Windows Build Support enables player compilation only. It
+does not provide production MIDI input. Windows electronic-drum support needs
+a separately implemented and tested Windows MIDI backend; until then the
+Windows player is keyboard-only.
+
## Signing and public distribution
The current script intentionally creates an **unsigned playtest**. It is not a
diff --git a/docs/governance/dual-licensing-decision.md b/docs/governance/dual-licensing-decision.md
index 20c77b0..1f52d0b 100644
--- a/docs/governance/dual-licensing-decision.md
+++ b/docs/governance/dual-licensing-decision.md
@@ -1,9 +1,14 @@
# Dual-Licensing Decision Record
-Status: `PROPOSED_PENDING_LEGAL_REVIEW`
+Status: `SUPERSEDED_BY_MPL_COMMUNITY_LICENSE_DECISION`
Date: 2026-08-05
+> Historical record. The project subsequently adopted MPL-2.0 as its Community
+> license. See
+> [MPL Community License Decision](mpl-community-license-decision.md). Existing
+> GPL copies retain the rights already granted to their recipients.
+
## Decision
HitTheKit will preserve its Community edition under GNU General Public License
diff --git a/docs/governance/mpl-community-license-decision.md b/docs/governance/mpl-community-license-decision.md
new file mode 100644
index 0000000..65d5956
--- /dev/null
+++ b/docs/governance/mpl-community-license-decision.md
@@ -0,0 +1,93 @@
+# MPL Community License Decision
+
+Status: `ADOPTED`
+
+Decision date: 2026-08-19
+
+## Decision
+
+HitTheKit changes the Community license for original project source code from
+`GPL-3.0-only` to the Mozilla Public License 2.0 (`MPL-2.0`) beginning with
+the repository version that merges this decision.
+
+The project retains a possible separate commercial-license path for recipients
+who need terms different from MPL-2.0. A commercial license is not granted by
+this repository and requires a separate written agreement.
+
+## Scope
+
+Unless a file or directory declares different terms, MPL-2.0 applies to
+original HitTheKit source code distributed with the effective repository
+version. It does not relicense:
+
+- Unity Editor, Unity Runtime, Unity packages, or Unity-generated technology;
+- third-party dependencies or their bundled notices;
+- player-owned songs, recordings, charts, MIDI data, or other local content;
+- third-party trademarks or compatibility names; or
+- HitTheKit branding and trademark rights.
+
+The exact candidate dependency and asset inventories still control binary
+release readiness.
+
+## Reason
+
+MPL-2.0 preserves source-level reciprocity for modified HitTheKit files while
+allowing those files to be combined in a larger work with separately licensed
+software. That model fits a Unity application better than whole-work GPL
+copyleft: the proprietary Unity Runtime can retain its own terms while covered
+HitTheKit source remains available under MPL-2.0.
+
+The change removes the identified GPL whole-work compatibility concern; it does
+not alone approve macOS or Windows binaries. Signing, notarization, platform
+testing, dependency notices, content provenance, Unity plan eligibility, and
+the applicable Unity distribution terms remain independent release gates.
+
+## Existing GPL copies
+
+Licenses already granted are not revoked. Anyone who validly received an older
+HitTheKit copy under GPL-3.0-only may continue to exercise those GPL rights for
+that copy. The maintainer does not represent that downstream GPL copies have
+been converted to MPL.
+
+## Commercial option
+
+Commercial use is allowed by MPL-2.0 and does not by itself require payment.
+The separate commercial option is intended for needs such as:
+
+- proprietary modifications to MPL-covered files;
+- private-label or white-label distribution;
+- proprietary hardware and platform integrations; or
+- separately negotiated warranty, support, or maintenance.
+
+Prices, warranties, support, territory, governing law, and transaction terms
+remain to be agreed in writing. HitTheKit can license only rights controlled by
+the relevant copyright holder.
+
+## Contributions
+
+MPL-2.0 does not automatically give the maintainer permission to offer another
+contributor's work under a proprietary commercial license. Substantial external
+contributions remain paused until a reviewed CLA, assignment, dual-license
+grant, or equivalent mechanism is adopted.
+
+The repository history identifies the maintainer through the `Baron Luca` and
+`Codewriter90x` aliases, plus automated dependency updates. By merging this
+decision, the maintainer represents that they are authorized to offer the
+original project material in the effective repository version under MPL-2.0.
+That representation is not a professional chain-of-title opinion.
+
+## Implementation
+
+- replace the root `LICENSE` with the unmodified official MPL-2.0 text;
+- identify the Community grant as MPL-2.0 in README, NOTICE, website, wiki, UI,
+ packaging, release, contribution, and governance material;
+- keep third-party licenses and trademark guidance separate;
+- retain the earlier GPL audit and decision as explicitly superseded historical
+ records; and
+- validate every future executable from its exact source and dependency set.
+
+## Review boundary
+
+This record is an engineering and project-governance decision, not legal advice
+or a guarantee that a particular distribution satisfies every applicable
+contract or law.
diff --git a/docs/legal/ASSET_PROVENANCE.sha256 b/docs/legal/ASSET_PROVENANCE.sha256
index c219137..d88912f 100644
--- a/docs/legal/ASSET_PROVENANCE.sha256
+++ b/docs/legal/ASSET_PROVENANCE.sha256
@@ -36,6 +36,7 @@ aeb85dcd4d2146f541a20b059d0202b4b74e26d36bb0c4d6a69e3216b53d32b3 branding/app-i
33d46381305d6bfc6fd2e46756f60161d72c3dce538ad91159cb535867d8621a branding/app-icon/web/favicon-48.png
dd14c102c960582dce3a4a2f666acbd5fb6534aba34f065ce48ba176734e300f branding/app-icon/web/pwa-icon-192.png
09f9ef07c0a1e723f81ee00a8116eb2750d8d3138e679ca73f49e4d7e8652c73 branding/app-icon/web/pwa-icon-512.png
+dc7b225e09748d09fcf02c46b71f575d3d42547b4e49143da01a7710703f0ee3 branding/app-icon/windows/HitTheKit.ico
8b02be0ccd2c4565006246b3f8ce576367bed735bc122716c28d5120c53afd70 docs/design/evidence/gameplay-environments-v2-comparison.jpg
1503f47d91d43f2e7ea3484036329c839c338d757d6a7051e54ce97c9bd70512 docs/design/references/main-menu-stage-command-concept.png
0a150d383929d087670d0aefe28964e3786875964f88901fe8053a96e8895321 docs/design/references/modern-drum-kit-hd-concept.png
diff --git a/docs/legal/CLA_ADOPTION_CHECKLIST.md b/docs/legal/CLA_ADOPTION_CHECKLIST.md
new file mode 100644
index 0000000..6b43274
--- /dev/null
+++ b/docs/legal/CLA_ADOPTION_CHECKLIST.md
@@ -0,0 +1,226 @@
+# HitTheKit CLA adoption checklist
+
+> **DRAFT PROCESS — NOT IN FORCE — NOT LEGALLY REVIEWED**
+>
+> This checklist does not activate the accompanying CLA draft, request a
+> signature, or authorize substantial external contributions.
+
+## Selected planning model
+
+The current planning candidate is the Harmony Combined Contributor Agreement
+Template 1.0 configured as:
+
+- copyright license, not copyright assignment;
+- contributor retains copyright;
+- broad sublicensable copyright grant;
+- patent grant;
+- outbound licensing based on Harmony Option Five; and
+- separate treatment of individual and Legal Entity contributors still to be
+ decided.
+
+Option Five is being evaluated because it permits open-source and commercial
+or proprietary outbound licenses while promising that an incorporated
+Contribution is also available under the community license used for the
+Material. Its breadth is not yet approved.
+
+## Blocking legal and identity decisions
+
+- [ ] Obtain review from a qualified lawyer, FOSS legal clinic, or equivalent
+ professional familiar with contributor agreements and dual licensing.
+- [ ] Verify the legal identity and capacity of the party receiving the grant.
+- [ ] Decide whether the recipient is an individual, company, foundation, or
+ another legal structure.
+- [ ] Record the recipient's legal address and a private legal contact.
+- [ ] Select governing law, forum, and dispute process with qualified advice.
+- [ ] Verify chain of title and authority for the existing HitTheKit code.
+- [ ] Confirm the intended MPL-2.0 transition separately; the CLA must not be
+ used to imply that a repository license change has already occurred.
+- [ ] Review the scope of “transferable,” “irrevocable,” “otherwise exploit,”
+ multi-tier sublicensing, and future commercial/proprietary licensing.
+- [ ] Review patent scope, Affiliate coverage, subsequently acquired claims,
+ combination claims, limitations, and termination/retaliation.
+- [ ] Decide whether and how to address moral rights without an unnecessarily
+ broad waiver.
+- [ ] Draft jurisdiction-appropriate warranty, disclaimer, liability,
+ severability, assignment, notices, and entire-agreement terms.
+
+## Contributor types and authority
+
+- [ ] Decide whether to publish separate Individual and Entity agreements.
+- [ ] Define when an individual contributor needs employer consent.
+- [ ] Define acceptable evidence of employer consent.
+- [ ] Define the Entity signatory's required authority and title.
+- [ ] Decide whether entities maintain a list of authorized contributors.
+- [ ] Define how an entity adds or removes authorized contributors.
+- [ ] Decide how contractors, students, public employees, and grant-funded
+ contributors are handled.
+- [ ] Decide whether contributions from minors will be accepted.
+- [ ] If minors may contribute, obtain qualified advice on age, guardian
+ consent, identity verification, privacy, and revocation.
+
+## Contributions and third-party material
+
+- [ ] Define the channels that count as intentionally “Submitted.”
+- [ ] Define a clear “Not a Contribution” mechanism.
+- [ ] Keep issues, discussions, hardware observations, ideas, and facts outside
+ contractual acceptance unless qualified review says otherwise.
+- [ ] Define a non-owner/mixed-submission review process—or continue to reject
+ all mixed submissions.
+- [ ] Explicitly reject unapproved third-party code, music, recordings,
+ performances, charts, MIDI captures, artwork, fonts, trademarks,
+ confidential information, and personal data.
+- [ ] Require provenance and license evidence for any exceptional third-party
+ material accepted through a separate process.
+- [ ] Confirm that a CLA grants only rights the contributor controls and never
+ substitutes for third-party licenses.
+
+## Existing and future contributions
+
+- [ ] Inventory any substantial external contribution made before activation.
+- [ ] Decide whether prior contributors must separately opt in.
+- [ ] Do not presume retroactive acceptance based on an old pull request,
+ commit, discussion, or repository license.
+- [ ] Define the effective date for each contributor and Contribution.
+- [ ] Define treatment when the community license changes after acceptance.
+- [ ] Define the procedure if the receiving legal entity changes.
+- [ ] Define a transparent amendment process and when re-acceptance is needed.
+
+## Privacy, signatures, and records
+
+- [ ] Publish a privacy notice before collecting acceptances.
+- [ ] Identify the data controller, purposes, lawful basis, processors,
+ transfers, and contact.
+- [ ] Decide which identity, name, email, employer, timestamp, IP address, and
+ other evidence is actually necessary; minimize collection.
+- [ ] Define retention periods rather than retaining personal data by default.
+- [ ] Define access, correction, objection, deletion, export, and complaint
+ handling as applicable.
+- [ ] Record the exact CLA version and immutable digest accepted.
+- [ ] Preserve an auditable timestamp and authenticated contributor identity.
+- [ ] Design the electronic signature/acceptance action with qualified review.
+- [ ] Define recovery, backup, access control, incident response, and processor
+ exit procedures for acceptance records.
+
+## CLA Assistant and repository controls
+
+- [ ] Complete legal and privacy review **before** enabling CLA Assistant.
+- [ ] Publish only the approved, versioned CLA text—not this draft—as the
+ acceptance source.
+- [ ] Review CLA Assistant's hosting, authentication, custom fields, privacy,
+ retention, availability, and version-change behavior.
+- [ ] Configure a test repository before connecting HitTheKit.
+- [ ] Verify individual and Entity workflows, bot comments, re-signing, and
+ failure/recovery behavior.
+- [ ] Add the CLA status check to branch protection only after a successful
+ test run and with a unique check name.
+- [ ] Keep maintainer bypass disabled or document narrowly reviewed emergency
+ handling.
+- [ ] Update the pull-request template without claiming that opening a PR
+ constitutes acceptance.
+- [ ] Update `CONTRIBUTING.md`, `GOVERNANCE.md`, privacy documentation, and the
+ public website only when the agreement actually enters into force.
+- [ ] Publish a plain-language announcement of the effective date, scope,
+ contributor choices, community-license promise, and contact route.
+- [ ] Confirm substantial external contributions remain blocked until every
+ activation item is complete.
+
+GitHub required status checks enforce workflow results; they do not determine
+whether the CLA text or acceptance process is legally sufficient. CLA
+Assistant automates prompts, GitHub authentication, acceptance records, and a
+pull-request status check, but it is not the legal reviewer or receiving party.
+
+## DCO, CLA, and CLA Assistant are different
+
+| Mechanism | Primary function | What it does not establish by itself |
+| --- | --- | --- |
+| Developer Certificate of Origin (DCO) | Contributor attests provenance and a right to submit under the indicated open-source terms, commonly recorded with `Signed-off-by` | It does not grant HitTheKit the separate, broad sublicensing rights needed for proprietary/commercial outbound licensing. |
+| Contributor License Agreement (CLA) | A contract can expressly grant copyright and patent rights while the contributor retains ownership | A draft is not operative without identified parties, reviewed terms, valid acceptance, and appropriate records. |
+| CLA Assistant | Automates the presentation and collection of a project's chosen CLA and reports status to GitHub | It does not draft, approve, interpret, or make legally sufficient the CLA and privacy process. |
+
+MPL 2.0 grants rights from contributors to recipients of MPL-covered software,
+but its contributor grant does not automatically appoint the HitTheKit
+maintainer to relicense every external Contribution under unrelated proprietary
+terms. The proposed CLA is intended to address that separate inbound-rights
+need if qualified review approves it.
+
+## Review findings to resolve before adoption
+
+### P0
+
+None identified in a non-operative documentation draft.
+
+### P1 — adoption blockers
+
+- The receiving legal party, governing law, forum, legal contact, privacy
+ controller, and acceptance process are unknown.
+- Harmony Option Five and the copyright grant are deliberately broad; their
+ sublicensing, transferability, irrevocability, and proprietary-license scope
+ require qualified review and plain-language disclosure.
+- Patent scope and any retaliation/termination mechanism are unresolved.
+- Individual, Entity, employer-consent, Affiliate, and minor-contributor
+ processes are unresolved.
+- The public `main` license and the intended MPL-2.0 state are temporarily
+ different; activation before the community-license transition is settled
+ would create avoidable ambiguity.
+- Privacy, retention, version evidence, electronic signature, and treatment of
+ prior Contributions are unresolved.
+
+### P2 — process and clarity risks
+
+- One combined draft is convenient for review but separate Individual and
+ Entity forms may be clearer operationally.
+- Media and third-party-content rules may need their own contributor guide and
+ provenance workflow rather than additional breadth in the CLA.
+- A CLA tool outage, Gist change, account rename, or bot-status ambiguity needs
+ an operational recovery procedure.
+
+### P3 — editorial follow-up
+
+- Defined terms, capitalization, translations, accessibility, and
+ plain-language summaries should be normalized after legal text stabilizes.
+- A version identifier and digest format should be selected before publication.
+
+## Pro bono review request template
+
+Subject: Limited pro bono review request — open-source contributor agreement
+
+> Hello,
+>
+> I maintain HitTheKit, an open-source electronic-drum learning game. The
+> project is preparing to use Mozilla Public License 2.0 for its community
+> edition and may later offer separate commercial licenses. I would like
+> contributors to retain their copyright while granting the project the
+> copyright and patent permissions needed to distribute accepted contributions
+> under MPL 2.0 and, transparently, under separate commercial/proprietary terms.
+>
+> I have prepared a non-operative draft based on the Harmony Agreements
+> copyright-license model and outbound Option Five. The project is not yet
+> accepting substantial external code or assets, no one is being asked to sign
+> the draft, and CLA Assistant has not been configured.
+>
+> I currently have no budget for a full commercial engagement. Would your
+> clinic, practice, or open-source legal community be willing to provide a
+> limited pro bono review of the draft, focusing on the receiving party,
+> sublicensing scope, patent grant, employer/entity contributions, governing
+> law, privacy and electronic acceptance? I can provide the short draft,
+> adoption checklist, current license documents, and a concise list of open
+> questions.
+>
+> I understand that availability may be limited and that an informal community
+> response may not create a lawyer-client relationship or replace advice from a
+> qualified professional in the relevant jurisdiction.
+
+## Primary drafting references
+
+- Harmony agreement templates and guide:
+
+- Mozilla Public License 2.0:
+
+- Developer Certificate of Origin 1.1:
+
+- CLA Assistant project documentation:
+
+- GitHub protected branches and required status checks:
+
+
+This checklist is engineering and governance preparation, not legal advice.
diff --git a/docs/legal/CONTRIBUTOR_LICENSE_AGREEMENT_DRAFT.md b/docs/legal/CONTRIBUTOR_LICENSE_AGREEMENT_DRAFT.md
new file mode 100644
index 0000000..ddbbd5e
--- /dev/null
+++ b/docs/legal/CONTRIBUTOR_LICENSE_AGREEMENT_DRAFT.md
@@ -0,0 +1,327 @@
+# DRAFT — NOT IN FORCE — NOT LEGALLY REVIEWED — DO NOT ACCEPT OR RELY ON THIS DOCUMENT
+
+> **No one should sign, accept, or rely on this draft.** It does not currently
+> govern any contribution to HitTheKit, and opening a pull request, issue, or
+> discussion does not constitute acceptance. Substantial external code and
+> asset contributions remain paused under
+> [`CONTRIBUTING.md`](../../CONTRIBUTING.md).
+
+## Drafting basis and unresolved decisions
+
+This planning draft is adapted from the Harmony Combined Contributor Agreement
+Template 1.0. It selects the **copyright-license** model rather than copyright
+assignment, includes a patent grant, and follows the substance of Harmony
+Outbound License Option Five so that a covered Contribution could be offered
+both under HitTheKit's community license and under separate commercial or
+proprietary terms.
+
+This is a modified planning document, not an official Harmony agreement. The
+Harmony template and guide remain the authoritative sources for the template
+options. The draft must be replaced or approved by a qualified human reviewer
+before adoption.
+
+The following decisions are deliberately unresolved:
+
+- receiving party: **[LEGAL REVIEW REQUIRED — LEGAL NAME AND CAPACITY]**;
+- receiving-party address: **[TO BE AGREED]**;
+- legal contact: **[TO BE AGREED]**;
+- governing law and forum: **[TO BE AGREED]**;
+- separate individual and entity forms: **[TO BE AGREED]**;
+- treatment of minors: **[LEGAL REVIEW REQUIRED]**;
+- employer approval and entity authorization evidence: **[TO BE AGREED]**;
+- electronic acceptance and signature process: **[TO BE AGREED]**;
+- privacy notice, data controller, retention, access, correction, and deletion
+ process: **[LEGAL REVIEW REQUIRED]**;
+- versioning and record of the exact text accepted: **[TO BE AGREED]**;
+- treatment of contributions made before the effective date:
+ **[LEGAL REVIEW REQUIRED — NO RETROACTIVE ASSUMPTION]**; and
+- exact community-license transition: HitTheKit's public `main` currently uses
+ GPL-3.0-only, while a possible MPL-2.0 transition is separately under review.
+ This draft does not change the repository license.
+
+---
+
+# HitTheKit Contributor License Agreement — planning draft
+
+Thank you for your interest in contributing to HitTheKit (the “Project”). This
+draft uses “Project Recipient” for the person or entity that would receive the
+rights below. Before adoption, “Project Recipient” must be replaced with the
+verified legal identity of **[RECEIVING PARTY]**.
+
+If adopted after legal review, this agreement (the “Agreement”) would document
+rights granted by a contributor (“You”) to the Project Recipient (“We” or
+“Us”). It would become effective only through the approved acceptance process
+described at **[ACCEPTANCE AND SUBMISSION INSTRUCTIONS]**. No such process is
+currently active.
+
+## 1. Definitions
+
+### 1.1 Contributor
+
+“You” means either:
+
+1. the individual who submits a Contribution; or
+2. the Legal Entity on whose behalf an authorized person submits a
+ Contribution.
+
+“Legal Entity” means an organization rather than a natural person.
+“Affiliates” means entities that control, are controlled by, or are under
+common control with that Legal Entity. Whether and how Affiliates are included
+is **[LEGAL REVIEW REQUIRED]**.
+
+The adopted process must distinguish individual contributors from contributors
+acting for a Legal Entity. An individual may not bind an employer or another
+organization without authority.
+
+### 1.2 Contribution
+
+“Contribution” means the portion of an original work of authorship that You
+Submit for inclusion in HitTheKit and for which You own, or have authority to
+license, all rights required by this Agreement.
+
+A Submission is not a Contribution to the extent it contains third-party code,
+music, recordings, performances, charts, MIDI captures, artwork, fonts,
+trademarks, personal data, confidential material, or other material that You
+do not have authority to license under this Agreement. Do not submit such
+material. HitTheKit currently has no non-owner or mixed-submission acceptance
+procedure.
+
+Bug reports, ideas, functional requests, facts, and general discussion may not
+be copyrightable Contributions. Their treatment must be assessed separately;
+this definition does not convert an idea or fact into owned project material.
+
+### 1.3 Submit
+
+“Submit” means intentionally providing a proposed Contribution through a
+Project channel designated in the future adoption instructions for that
+purpose. Ordinary participation in an issue, discussion, hardware report, or
+support request is not acceptance of this Agreement.
+
+A communication conspicuously marked “Not a Contribution” is excluded, subject
+to the adopted submission procedure. The final procedure must define which
+repositories, branches, pull requests, email addresses, or other channels are
+designated for Contributions.
+
+### 1.4 Material
+
+“Material” means the HitTheKit software, documentation, or other project work
+into which a Contribution may be incorporated. We are not required to
+incorporate or distribute any Contribution.
+
+### 1.5 Copyright and Patent Claims
+
+“Copyright” includes copyright and, where applicable, closely related rights
+that You own or control and may license for the full term of those rights.
+
+“Patent Claims” means only patent claims that You own or control and may
+license, and that would necessarily be infringed by exercising the patent
+rights expressly granted in Section 2.2 with respect to the Contribution alone
+or the Contribution combined with the Material to which it was submitted.
+
+The treatment of Affiliates, subsequently acquired claims, and patent
+termination or retaliation is **[LEGAL REVIEW REQUIRED]**.
+
+### 1.6 Effective Date
+
+“Effective Date” means **[TO BE AGREED]**. The final text must not presume that
+this Agreement retroactively covers a contribution or period that the
+contributor did not knowingly accept.
+
+## 2. Grant of rights
+
+### 2.1 Copyright license — no assignment
+
+You retain ownership of the Copyright in Your Contribution and retain the same
+rights to use or license it that You would have had without this Agreement.
+This Agreement does not assign copyright to Us.
+
+Subject to the community-license commitment in Section 2.3, and to the maximum
+extent You are legally able to do so, You grant Us a perpetual, worldwide,
+non-exclusive, transferable, royalty-free, irrevocable license under the
+Copyright in the Contribution, with the right to sublicense through multiple
+tiers, to use, reproduce, make available, modify, prepare derivative works of,
+display, perform, distribute, and otherwise exploit the Contribution as part
+of or in connection with the Material.
+
+The words “transferable,” “irrevocable,” “otherwise exploit,” and “multiple
+tiers” are intentionally broad adaptations of the Harmony license model and
+must receive specific human legal review before adoption.
+
+### 2.2 Patent license
+
+Subject to the community-license commitment in Section 2.3, and to the maximum
+extent You are legally able to do so, You grant Us a perpetual, worldwide,
+non-exclusive, transferable, royalty-free, irrevocable patent license, with
+the right to sublicense through multiple tiers, under the Patent Claims to
+make, have made, use, sell, offer for sale, import, and otherwise transfer the
+Contribution and the Contribution combined with the Material to which it was
+submitted.
+
+No patent license is intended for claims infringed solely by unrelated changes
+or combinations added after Your Contribution. The final patent scope,
+limitations, Affiliate coverage, and any termination provision require
+qualified review for consistency with MPL 2.0 and future commercial terms.
+
+### 2.3 Outbound licensing
+
+If We include Your Contribution in the Material, the grants in Sections 2.1
+and 2.2 are intended to allow Us to license the Contribution under open-source,
+copyleft, permissive, commercial, or proprietary terms, including:
+
+- Mozilla Public License 2.0, if and when that becomes the adopted community
+ license for the relevant Material; and
+- a separate commercial or proprietary HitTheKit license offered by a party
+ with authority over all relevant rights.
+
+Consistent with the purpose of Harmony Outbound Option Five, whenever We first
+include a Contribution in publicly distributed Material, We would also make
+that Contribution available under the community license that applies to that
+Material at the time of inclusion. Exercising the grants in Sections 2.1 and
+2.2 would remain subject to this commitment. The final text must specify how
+the commitment works during license transitions and must not imply that MPL
+2.0 itself grants Us the right to relicense a contributor's work commercially.
+
+This Agreement would grant rights only in Your Contribution. It would not
+relicense Unity, package dependencies, third-party code, music, recordings,
+charts, MIDI data, artwork, trademarks, or other material outside Your rights.
+
+### 2.4 Moral and similar rights
+
+Harmony includes a waiver and non-assertion provision for moral rights. Whether
+such a provision is necessary, proportionate, or enforceable for HitTheKit is
+**[LEGAL REVIEW REQUIRED]**. No waiver is included in this non-operative draft
+as an approved term.
+
+### 2.5 Reservation of rights
+
+Rights not expressly licensed under this Agreement remain reserved by You.
+Neither this Agreement nor a Contribution grants rights to Your name,
+trademarks, or personal likeness except for accurate attribution and record
+keeping expressly described in the final agreement and privacy notice.
+
+## 3. Contributor confirmations
+
+If this Agreement is adopted, You would confirm that:
+
+1. You have legal authority to enter into it and grant the stated rights.
+2. You own or control the Copyright and Patent Claims needed for the grants.
+3. The grants do not violate an agreement or obligation to an employer,
+ client, school, funder, or other third party.
+4. If the Contribution was created in the course of employment or using an
+ employer's resources, You have obtained any required written employer
+ consent, or the authorized Legal Entity has accepted the appropriate entity
+ agreement.
+5. You have not knowingly included third-party material unless a separately
+ documented Project procedure approved it in writing. No such procedure is
+ currently active.
+6. You will identify any known restriction, provenance issue, or third-party
+ claim before submission.
+
+These confirmations are limited to matters within Your knowledge and authority
+as specified in the final reviewed text. They are not a promise that the
+Contribution is error-free or suitable for a particular purpose.
+
+Contributors who may be minors must not accept this Agreement until a qualified
+reviewer has defined an appropriate age, guardian-consent, identity, and data
+handling process: **[LEGAL REVIEW REQUIRED]**.
+
+## 4. Project decisions and no obligation
+
+We may accept, reject, modify, defer, or remove a Contribution. Acceptance of
+this Agreement would not guarantee merge, attribution in a particular form,
+support, payment, employment, or participation in project governance.
+
+## 5. Disclaimer and liability provisions
+
+Harmony includes conspicuous warranty disclaimers and a consequential-damages
+waiver. The exact language and whether obligations should be mutual depend on
+the chosen parties and jurisdiction. The adopted agreement must include
+counsel-reviewed provisions at **[LEGAL REVIEW REQUIRED]**.
+
+Until then, this draft makes no warranty and does not create contractual
+rights or obligations.
+
+## 6. Privacy, records, and electronic acceptance
+
+Before activation, the Project must publish a privacy notice that identifies:
+
+- the legal recipient/data controller and contact;
+- personal data collected for acceptance;
+- purpose and lawful basis;
+- processors, recipients, and cross-border transfers;
+- retention and deletion rules;
+- contributor access, correction, objection, and other applicable rights;
+- the version, timestamp, account identity, and evidence retained; and
+- the process for security incidents and future agreement updates.
+
+The final agreement must identify the exact acceptance action and the version
+accepted. CLA Assistant or another tool may automate identity checks, prompts,
+records, and status checks, but using a tool does not itself make the legal
+text sufficient or enforceable.
+
+## 7. Employer and entity contributions
+
+Before accepting work performed for an employer or other Legal Entity, the
+Project must decide whether to require:
+
+- a separate Harmony-style Entity Contributor License Agreement;
+- individual acceptance plus employer consent;
+- a list of authorized contributors maintained by the entity; or
+- another professionally reviewed process.
+
+The authorized signatory's name, title, authority, entity identity, Affiliates,
+and changes to authorization must be recorded under the final privacy and
+retention process.
+
+## 8. Miscellaneous terms
+
+The adopted agreement requires complete provisions for governing law, forum or
+dispute process, assignment, successors, severability, waiver, entire
+agreement, amendments, notices, language, and counterparts or electronic
+signatures. Those provisions are **[TO BE AGREED AFTER QUALIFIED REVIEW]** and
+are not inferred from this draft.
+
+## 9. Acceptance blocks — intentionally disabled
+
+### Individual contributor
+
+- Legal name: **[DO NOT COMPLETE — PROCESS NOT ACTIVE]**
+- Address/contact: **[TO BE AGREED WITH PRIVACY REVIEW]**
+- GitHub identity: **[TO BE AGREED]**
+- Agreement version: **[TO BE AGREED]**
+- Signature/acceptance: **[TO BE AGREED]**
+
+### Legal Entity
+
+- Entity legal name: **[DO NOT COMPLETE — PROCESS NOT ACTIVE]**
+- Authorized signatory and title: **[TO BE AGREED]**
+- Address/contact: **[TO BE AGREED WITH PRIVACY REVIEW]**
+- Authorized contributors: **[TO BE AGREED]**
+- Agreement version: **[TO BE AGREED]**
+- Signature/acceptance: **[TO BE AGREED]**
+
+### Project Recipient
+
+- Legal identity: **[LEGAL REVIEW REQUIRED]**
+- Capacity/title: **[TO BE AGREED]**
+- Address/contact: **[TO BE AGREED]**
+- Signature/acceptance: **[TO BE AGREED]**
+
+---
+
+## Source attribution
+
+Planning source: Harmony Combined Contributor Agreement Template, version 1.0,
+and the Harmony Guide to the Contributor Agreements, published at
+ and licensed by Project Harmony
+under Creative Commons Attribution 3.0 Unported.
+
+Other reference points used to identify—not resolve—the required boundaries:
+
+- official MPL 2.0 text: ;
+- Developer Certificate of Origin 1.1: ;
+- CLA Assistant project documentation:
+ .
+
+No reference source, template, automation, or repository maintainer has
+approved this HitTheKit draft as legally sufficient.
diff --git a/docs/legal/dual-licensing-readiness-report.md b/docs/legal/dual-licensing-readiness-report.md
index 3b1d700..6c12207 100644
--- a/docs/legal/dual-licensing-readiness-report.md
+++ b/docs/legal/dual-licensing-readiness-report.md
@@ -10,6 +10,11 @@ Baseline: `44d47f3acc8e6f305fb95fc8f9a0baae06c57f84`
> Current visual-asset evidence is maintained in
> [`ASSET_PROVENANCE.md`](ASSET_PROVENANCE.md), and every public release still
> requires a fresh exact-commit legal and dependency review.
+>
+> The GPL findings below describe the audited 2026-08-05 baseline and are not
+> the current Community license. HitTheKit subsequently adopted MPL-2.0; see
+> [the current licensing overview](../../LICENSING.md) and
+> [MPL Community License Decision](../governance/mpl-community-license-decision.md).
## Scope and limitations
diff --git a/docs/release/0.6.0-beta.1-release-notes.md b/docs/release/0.6.0-beta.1-release-notes.md
new file mode 100644
index 0000000..445be8c
--- /dev/null
+++ b/docs/release/0.6.0-beta.1-release-notes.md
@@ -0,0 +1,56 @@
+# HitTheKit 0.6.0-beta.1
+
+This tester-facing beta consolidates the current `develop` integration into a
+signed and notarized Apple-silicon candidate. It contains only original,
+rights-clean demo content and does not bundle commercial songs, charts, MIDI,
+lyrics, or artwork.
+
+## Highlights
+
+- Practice Lab section loops and explicit A/B practice ranges;
+- section and drum-piece Performance Error Map;
+- Auto Tempo Coach recommendations that remain isolated from Chart Creator;
+- guided keyboard/MIDI audio-latency sound check;
+- local Ghost Replay markers that never enter matching or scoring;
+- accessible procedural Reactive Stage with reduced-motion and high-contrast
+ behavior;
+- scroll-safe results diagnostics that keep Practice Lab, Auto Tempo,
+ calibration, Ghost, and navigation controls readable;
+- local Chart Creator foundation with audio import, waveform navigation,
+ editable timing/velocity/articulation, and portable `.htksong` packages;
+- existing keyboard and macOS CoreMIDI gameplay, lessons, settings, progress,
+ device setup, and the original `Neon Circuit` demo.
+
+## Validation
+
+- .NET build: 0 warnings, 0 errors;
+- Core tests: 58/58;
+- MIDI tool tests: 39/39;
+- Unity EditMode: 518/518;
+- Unity PlayMode: 54/54;
+- GitHub CI, repository contracts, packaging contracts, and CodeQL C#/C++:
+ passed on the feature integration heads.
+
+The final GitHub prerelease records the exact source commit, artifact SHA-256,
+Developer ID signature, notarization, Gatekeeper, and runtime-smoke evidence.
+
+## Known limitations
+
+- the public player candidate targets Apple Silicon;
+- production MIDI input is CoreMIDI on macOS; Windows remains keyboard-only;
+- electronic-drum compatibility is limited to the guided mapping boundary and
+ models actually reported by testers;
+- this pass does not claim a new real-hardware certification across two drum
+ modules or two audio interfaces;
+- GitHub-hosted Unity CI remains disabled until real Unity license secrets are
+ configured; the candidate uses the documented local Unity gate;
+- beta support is best effort and no compatibility promise is implied.
+
+## Privacy and content
+
+Preferences, mappings, progress, created charts, and local audio bindings stay
+on the player's machine. HitTheKit has no configured application telemetry.
+Only content the player is authorized to use should be imported or shared.
+
+Use GitHub Issues for reproducible bugs and sanitized hardware reports. Report
+security vulnerabilities privately according to `SECURITY.md`.
diff --git a/docs/release/PUBLICATION_RUNBOOK.md b/docs/release/PUBLICATION_RUNBOOK.md
index 9e74153..25b90a9 100644
--- a/docs/release/PUBLICATION_RUNBOOK.md
+++ b/docs/release/PUBLICATION_RUNBOOK.md
@@ -36,9 +36,9 @@ remotes; do not rely on the temporary rename redirect.
- the exact source commit is approved;
- the initial publication is source-and-website only;
- the technical rights/provenance audit and source contracts are green;
-- unresolved Unity-runtime/GPL and commercial-license questions are disclosed,
- with qualified legal review still required before public binaries or a
- separate commercial license;
+- MPL-2.0 is the current Community license, while Unity-runtime terms,
+ exact-candidate notices, and the commercial-license agreement remain
+ separately gated before public binaries or commercial licensing;
- the worktree is clean;
- historical releases remain private and are not linked as current downloads;
- the target owner, repository name, visibility, and maintainer identity are
diff --git a/docs/release/PUBLIC_RELEASE_CHECKLIST.md b/docs/release/PUBLIC_RELEASE_CHECKLIST.md
index 1164d1e..7e5c9db 100644
--- a/docs/release/PUBLIC_RELEASE_CHECKLIST.md
+++ b/docs/release/PUBLIC_RELEASE_CHECKLIST.md
@@ -9,7 +9,8 @@ come completata.
- [x] Provenienza e SHA-256 di tutti gli asset visuali correnti registrati e verificati in CI.
- [x] Test core e MIDI disponibili senza Unity.
- [x] CI, CodeQL e aggiornamenti dipendenze configurati.
-- [ ] Revisione professionale della compatibilità GPL/Unity e dei termini di distribuzione, obbligatoria prima di qualsiasi binario pubblico o licenza commerciale separata.
+- [x] Licenza Community migrata a MPL-2.0 per evitare il precedente conflitto di copyleft dell'intera opera con Unity.
+- [ ] Verifica della licenza MPL, dei termini Unity e del modello contributivo sull'esatto candidato binario.
- [x] Prima pubblicazione limitata a sorgenti e sito; nessun binario Unity pubblico.
- [x] Strategia scelta: nuovo repository pubblico pulito da uno snapshot dell'HEAD, senza cronologia privata.
- [x] Repository storico destinato a `HitTheKit-private-archive`, con tag e release mantenuti privati.
diff --git a/docs/release/RELEASE_PROCESS.md b/docs/release/RELEASE_PROCESS.md
index c34c26d..1956f5b 100644
--- a/docs/release/RELEASE_PROCESS.md
+++ b/docs/release/RELEASE_PROCESS.md
@@ -4,6 +4,11 @@ This process separates source readiness, gameplay validation, legal approval,
and public distribution. Passing an early stage does not imply that later
stages passed.
+Release candidates follow the branch and tag lifecycle defined in
+[`branching-and-releases.md`](../development/branching-and-releases.md). In
+particular, normal work integrates through `develop`, while stable release tags
+are created only from reviewed commits on `main`.
+
## 1. Freeze an exact candidate
1. Choose an exact commit on a clean worktree.
@@ -12,6 +17,8 @@ stages passed.
3. Stop feature work on the candidate; only reviewed release blockers may
change it.
4. Record the commit SHA in the release evidence.
+5. Keep the worktree clean so `SOURCE-CODE.txt` can identify the exact public
+ revision corresponding to every executable candidate.
## 2. Run source and packaging contracts
@@ -74,7 +81,8 @@ Hardware observations must not be generalized beyond the tested models.
1. confirm the bundled catalog contains only authorized original material;
2. refresh asset provenance and third-party notices from the exact candidate;
3. review dependency and CodeQL results;
-4. obtain the required professional GPL/Unity and contribution-model review;
+4. validate MPL compliance, current Unity distribution terms, and the
+ contribution model for the exact candidate;
5. confirm no secrets, certificates, private captures, or personal data are in
the candidate; and
6. decide the clean-history public repository boundary.
@@ -89,6 +97,9 @@ Signing credentials remain outside Git. On the authorized release Mac:
The pipeline must finish with `HITTHEKIT_MACOS_DISTRIBUTION_READY`. Verify the
stapled application and each archive again after copying to the clean test Mac.
+Confirm that the application resources and DMG both include `Legal/` with
+`LICENSE`, `NOTICE`, `THIRD_PARTY_NOTICES.md`,
+`LICENSING.md`, and an exact-revision `SOURCE-CODE.txt`.
## 7. Prepare evidence
diff --git a/docs/wiki/_Footer.md b/docs/wiki/_Footer.md
index 04c6995..6b06a6f 100644
--- a/docs/wiki/_Footer.md
+++ b/docs/wiki/_Footer.md
@@ -1,4 +1,4 @@
HitTheKit is an independent open-source project. ·
[Website](https://codewriter90x.github.io/HitTheKit/en/) ·
[Source](https://github.com/Codewriter90x/HitTheKit) ·
-[GPL-3.0-only](https://github.com/Codewriter90x/HitTheKit/blob/main/LICENSE)
+[MPL-2.0](https://github.com/Codewriter90x/HitTheKit/blob/main/LICENSE)
diff --git a/scripts/build-macos-distribution-app.sh b/scripts/build-macos-distribution-app.sh
index 9062ab5..5f482dd 100755
--- a/scripts/build-macos-distribution-app.sh
+++ b/scripts/build-macos-distribution-app.sh
@@ -21,6 +21,13 @@ fail() {
[ ! -e "$APP_PATH" ] || fail "output already exists: $APP_PATH"
[ -x "$UNITY_PATH" ] || fail "Unity 6000.5.6f1 was not found at: $UNITY_PATH"
+SOURCE_COMMIT=${HITTHEKIT_SOURCE_COMMIT:-}
+if [ -z "$SOURCE_COMMIT" ]; then
+ [ -z "$(git -C "$REPOSITORY_ROOT" status --porcelain)" ] ||
+ fail "the release candidate must be committed and clean before building"
+ SOURCE_COMMIT=$(git -C "$REPOSITORY_ROOT" rev-parse HEAD)
+fi
+
OUTPUT_ROOT=$(dirname -- "$APP_PATH")
mkdir -p "$OUTPUT_ROOT"
APP_PATH=$(cd "$OUTPUT_ROOT" && pwd)/$(basename -- "$APP_PATH")
@@ -58,6 +65,9 @@ PLIST="$APP_PATH/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST"
"$REPOSITORY_ROOT/scripts/apply-macos-app-icon.sh" "$APP_PATH"
+HITTHEKIT_SOURCE_COMMIT="$SOURCE_COMMIT" \
+ "$REPOSITORY_ROOT/scripts/install-distribution-notices.sh" \
+ "$APP_PATH/Contents/Resources/Legal" "$VERSION"
EXECUTABLE=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$PLIST")
MAIN_BINARY="$APP_PATH/Contents/MacOS/$EXECUTABLE"
diff --git a/scripts/install-distribution-notices.sh b/scripts/install-distribution-notices.sh
new file mode 100755
index 0000000..3d8a0da
--- /dev/null
+++ b/scripts/install-distribution-notices.sh
@@ -0,0 +1,69 @@
+#!/bin/bash
+set -euo pipefail
+
+SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
+REPOSITORY_ROOT=$(CDPATH='' cd -- "$SCRIPT_DIR/.." && pwd)
+DESTINATION=${1:-}
+VERSION=${2:-}
+SOURCE_REPOSITORY=https://github.com/Codewriter90x/HitTheKit
+
+fail() {
+ echo "distribution notices: $*" >&2
+ exit 1
+}
+
+[ -n "$DESTINATION" ] && [ -n "$VERSION" ] ||
+ fail "usage: $0 "
+[[ "$VERSION" =~ ^[0-9]+([.][0-9]+){1,2}([-.][0-9A-Za-z.-]+)?$ ]] ||
+ fail "version must identify a release, for example 0.5.0 or 0.5.0-rc.1"
+
+SOURCE_COMMIT=${HITTHEKIT_SOURCE_COMMIT:-}
+if [ -z "$SOURCE_COMMIT" ]; then
+ [ -z "$(git -C "$REPOSITORY_ROOT" status --porcelain)" ] ||
+ fail "the repository is dirty; commit the exact candidate or set HITTHEKIT_SOURCE_COMMIT explicitly"
+ SOURCE_COMMIT=$(git -C "$REPOSITORY_ROOT" rev-parse HEAD)
+fi
+
+[[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] ||
+ fail "HITTHEKIT_SOURCE_COMMIT must be a full 40-character lowercase Git SHA"
+git -C "$REPOSITORY_ROOT" cat-file -e "$SOURCE_COMMIT^{commit}" 2>/dev/null ||
+ fail "source commit is not present in this repository: $SOURCE_COMMIT"
+
+for required_file in LICENSE NOTICE THIRD_PARTY_NOTICES.md LICENSING.md; do
+ [ -f "$REPOSITORY_ROOT/$required_file" ] ||
+ fail "required legal file is missing: $required_file"
+done
+
+mkdir -p "$DESTINATION"
+install -m 0644 "$REPOSITORY_ROOT/LICENSE" "$DESTINATION/LICENSE"
+install -m 0644 "$REPOSITORY_ROOT/NOTICE" "$DESTINATION/NOTICE"
+install -m 0644 "$REPOSITORY_ROOT/THIRD_PARTY_NOTICES.md" \
+ "$DESTINATION/THIRD_PARTY_NOTICES.md"
+install -m 0644 "$REPOSITORY_ROOT/LICENSING.md" \
+ "$DESTINATION/LICENSING.md"
+
+printf '%s\n' \
+ 'HitTheKit corresponding source code notice' \
+ '' \
+ "Binary version: $VERSION" \
+ "Exact source revision: $SOURCE_COMMIT" \
+ '' \
+ 'The Source Code Form of MPL-2.0-covered HitTheKit files in this' \
+ 'distribution is available from the exact public revision below:' \
+ '' \
+ "$SOURCE_REPOSITORY/tree/$SOURCE_COMMIT" \
+ '' \
+ 'A downloadable archive of that revision is available at:' \
+ '' \
+ "$SOURCE_REPOSITORY/archive/$SOURCE_COMMIT.zip" \
+ '' \
+ 'The MPL-2.0 license text, project notice, licensing overview, and' \
+ 'third-party notices are included in this Legal directory.' \
+ '' \
+ 'Unity and third-party components retain their own licenses and are not' \
+ 'relicensed by HitTheKit.' \
+ > "$DESTINATION/SOURCE-CODE.txt"
+
+echo "HITTHEKIT_DISTRIBUTION_NOTICES_INSTALLED"
+echo "destination=$DESTINATION"
+echo "sourceCommit=$SOURCE_COMMIT"
diff --git a/scripts/package-game-macos-arm64.sh b/scripts/package-game-macos-arm64.sh
index 0c7ff84..7fdb736 100755
--- a/scripts/package-game-macos-arm64.sh
+++ b/scripts/package-game-macos-arm64.sh
@@ -1,8 +1,8 @@
-#!/bin/sh
-set -eu
+#!/bin/bash
+set -euo pipefail
-SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-REPOSITORY_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
+SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
+REPOSITORY_ROOT=$(CDPATH='' cd -- "$SCRIPT_DIR/.." && pwd)
UNITY_PATH=${UNITY_PATH:-/Applications/Unity/Hub/Editor/6000.5.6f1/Unity.app/Contents/MacOS/Unity}
BUILD_VERSION=${1:-0.1.0}
OUTPUT_ROOT=${2:-"$REPOSITORY_ROOT/artifacts/game/macos-arm64-$BUILD_VERSION"}
@@ -21,6 +21,15 @@ if [ -e "$OUTPUT_ROOT" ]; then
exit 3
fi
+SOURCE_COMMIT=${HITTHEKIT_SOURCE_COMMIT:-}
+if [ -z "$SOURCE_COMMIT" ]; then
+ [ -z "$(git -C "$REPOSITORY_ROOT" status --porcelain)" ] || {
+ echo "The playtest source must be committed and clean before packaging." >&2
+ exit 4
+ }
+ SOURCE_COMMIT=$(git -C "$REPOSITORY_ROOT" rev-parse HEAD)
+fi
+
mkdir -p "$OUTPUT_ROOT"
"$REPOSITORY_ROOT/scripts/build-coremidi-plugin-macos-arm64.sh"
@@ -51,11 +60,19 @@ cp "$SOURCE_COREMIDI_PLUGIN" "$COREMIDI_PLUGIN"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $BUILD_VERSION" "$APP_PATH/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_VERSION" "$APP_PATH/Contents/Info.plist"
"$REPOSITORY_ROOT/scripts/apply-macos-app-icon.sh" "$APP_PATH"
+HITTHEKIT_SOURCE_COMMIT="$SOURCE_COMMIT" \
+ "$REPOSITORY_ROOT/scripts/install-distribution-notices.sh" \
+ "$APP_PATH/Contents/Resources/Legal" "$BUILD_VERSION"
BINARY_NAME=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$APP_PATH/Contents/Info.plist")
MAIN_BINARY="$APP_PATH/Contents/MacOS/$BINARY_NAME"
-codesign --force --sign - "$COREMIDI_PLUGIN"
-codesign --force --deep --sign - "$APP_PATH"
+while IFS= read -r -d '' candidate; do
+ [ "$candidate" != "$MAIN_BINARY" ] || continue
+ if file -b "$candidate" | grep -q 'Mach-O'; then
+ codesign --force --sign - "$candidate"
+ fi
+done < <(find "$APP_PATH/Contents" -type f -print0)
+codesign --force --sign - "$APP_PATH"
test -x "$MAIN_BINARY"
test -f "$COREMIDI_PLUGIN"
@@ -65,6 +82,7 @@ codesign --verify --deep --strict --verbose=2 "$APP_PATH"
mkdir -p "$PACKAGE_ROOT"
cp -R "$APP_PATH" "$PACKAGE_ROOT/HitTheKit.app"
+ditto "$APP_PATH/Contents/Resources/Legal" "$PACKAGE_ROOT/Legal"
printf '%s\n' \
'HitTheKit macOS Apple Silicon playtest' \
'' \
@@ -74,6 +92,7 @@ printf '%s\n' \
'' \
'This early playtest is ad-hoc signed and not yet Apple-notarized.' \
'No Unity installation is required.' \
+ 'License, notices, and exact source information are in Legal/.' \
> "$PACKAGE_ROOT/README-FIRST.txt"
ditto -c -k --sequesterRsrc --keepParent "$PACKAGE_ROOT" "$ZIP_PATH"
diff --git a/scripts/package-game-windows-x64.sh b/scripts/package-game-windows-x64.sh
index daed283..37b0d3c 100755
--- a/scripts/package-game-windows-x64.sh
+++ b/scripts/package-game-windows-x64.sh
@@ -20,8 +20,16 @@ OUTPUT_ROOT=${OUTPUT_ROOT:-"$REPOSITORY_ROOT/artifacts/game/windows-x64-$VERSION
[ ! -e "$OUTPUT_ROOT" ] || fail "output directory already exists: $OUTPUT_ROOT"
[ -x "$UNITY_PATH" ] || fail "Unity 6000.5.6f1 was not found at: $UNITY_PATH"
+SOURCE_COMMIT=${HITTHEKIT_SOURCE_COMMIT:-}
+if [ -z "$SOURCE_COMMIT" ]; then
+ [ -z "$(git -C "$REPOSITORY_ROOT" status --porcelain)" ] ||
+ fail "the playtest source must be committed and clean before packaging"
+ SOURCE_COMMIT=$(git -C "$REPOSITORY_ROOT" rev-parse HEAD)
+fi
+
UNITY_APP=$(CDPATH='' cd -- "$(dirname -- "$UNITY_PATH")/../.." && pwd)
-WINDOWS_SUPPORT="$UNITY_APP/Contents/PlaybackEngines/WindowsStandaloneSupport"
+UNITY_INSTALL_ROOT=$(CDPATH='' cd -- "$UNITY_APP/.." && pwd)
+WINDOWS_SUPPORT="$UNITY_INSTALL_ROOT/PlaybackEngines/WindowsStandaloneSupport"
[ -d "$WINDOWS_SUPPORT" ] || \
fail "Unity Windows Build Support (Mono) is not installed for 6000.5.6f1"
@@ -56,6 +64,10 @@ UNEXPECTED_PLUGIN=$(find "$PLAYER_ROOT" -iname '*HitTheKitCoreMidi*' -print -qui
[ -z "$UNEXPECTED_PLUGIN" ] || \
fail "macOS CoreMIDI plug-in leaked into the Windows package: $UNEXPECTED_PLUGIN"
+HITTHEKIT_SOURCE_COMMIT="$SOURCE_COMMIT" \
+ "$REPOSITORY_ROOT/scripts/install-distribution-notices.sh" \
+ "$PLAYER_ROOT/Legal" "$VERSION"
+
printf '%s\n' \
'HitTheKit Windows x64 playtest' \
'' \
@@ -66,6 +78,7 @@ printf '%s\n' \
'Electronic-drum MIDI is not implemented on Windows yet.' \
'This playtest package is unsigned and is not approved for public release.' \
'Verify the published SHA-256 before running any future release package.' \
+ 'License, notices, and exact source information are in Legal/.' \
> "$PLAYER_ROOT/README-FIRST.txt"
ditto --norsrc --noextattr -c -k --keepParent "$PLAYER_ROOT" "$ZIP_PATH"
diff --git a/scripts/package-macos-dmg.sh b/scripts/package-macos-dmg.sh
index 4e293d1..20f2340 100755
--- a/scripts/package-macos-dmg.sh
+++ b/scripts/package-macos-dmg.sh
@@ -31,6 +31,9 @@ resolve_identity() {
fail "usage: $0 "
[[ "$APP_PATH" == *.app ]] || fail "input must be an .app bundle"
[ -d "$APP_PATH/Contents" ] || fail "app bundle does not exist or is invalid: $APP_PATH"
+LEGAL_SOURCE="$APP_PATH/Contents/Resources/Legal"
+[ -f "$LEGAL_SOURCE/SOURCE-CODE.txt" ] ||
+ fail "app bundle lacks the MPL source disclosure: $LEGAL_SOURCE/SOURCE-CODE.txt"
[[ "$OUTPUT_DMG" == *.dmg ]] || fail "output must use the .dmg extension"
[ ! -e "$OUTPUT_DMG" ] || fail "output already exists: $OUTPUT_DMG"
@@ -59,6 +62,7 @@ trap cleanup EXIT INT TERM
mkdir -p "$STAGE_ROOT"
ditto "$APP_PATH" "$STAGE_ROOT/HitTheKit.app"
+ditto "$LEGAL_SOURCE" "$STAGE_ROOT/Legal"
ln -s /Applications "$STAGE_ROOT/Applications"
hdiutil create -quiet -volname "HitTheKit" -srcfolder "$STAGE_ROOT" -format UDZO "$TEMP_DMG"
hdiutil verify "$TEMP_DMG" >/dev/null
diff --git a/scripts/package-midi-capture-macos-arm64.sh b/scripts/package-midi-capture-macos-arm64.sh
index 463ed09..6d3d403 100755
--- a/scripts/package-midi-capture-macos-arm64.sh
+++ b/scripts/package-midi-capture-macos-arm64.sh
@@ -20,7 +20,7 @@ cp "$PUBLISH_DIRECTORY/hitthekit-midi-capture" "$PACKAGE_DIRECTORY/"
cp "$PACKAGE_SOURCE/README.txt" "$PACKAGE_DIRECTORY/"
cp "$PACKAGE_SOURCE/run.command" "$PACKAGE_DIRECTORY/"
cp "$PACKAGE_SOURCE/LICENSES/DryWetMIDI-MIT.txt" "$PACKAGE_DIRECTORY/LICENSES/"
-cp "$REPOSITORY_ROOT/LICENSE" "$PACKAGE_DIRECTORY/LICENSES/HitTheKit-GPL-3.0.txt"
+cp "$REPOSITORY_ROOT/LICENSE" "$PACKAGE_DIRECTORY/LICENSES/HitTheKit-MPL-2.0.txt"
chmod +x "$PACKAGE_DIRECTORY/hitthekit-midi-capture" "$PACKAGE_DIRECTORY/run.command"
mkdir -p "$(dirname "$ARCHIVE")"
diff --git a/src/HitTheKit.Core/ChartNote.cs b/src/HitTheKit.Core/ChartNote.cs
index 36d09f2..cba7952 100644
--- a/src/HitTheKit.Core/ChartNote.cs
+++ b/src/HitTheKit.Core/ChartNote.cs
@@ -4,7 +4,11 @@ namespace HitTheKit.Core
{
public sealed class ChartNote
{
- public ChartNote(double timeSeconds, DrumPad pad)
+ public ChartNote(
+ double timeSeconds,
+ DrumPad pad,
+ int? velocity = null,
+ DrumArticulation articulation = DrumArticulation.Default)
{
if (!IsFinite(timeSeconds) || timeSeconds < 0)
{
@@ -13,14 +17,24 @@ public ChartNote(double timeSeconds, DrumPad pad)
"A chart note time must be finite and non-negative.");
}
+ if (velocity.HasValue && (velocity.Value < 1 || velocity.Value > 127))
+ throw new ArgumentOutOfRangeException(nameof(velocity), "Target velocity must be between 1 and 127.");
+ DrumArticulationValidator.EnsureValid(pad, articulation);
+
TimeSeconds = timeSeconds;
Pad = pad;
+ Velocity = velocity;
+ Articulation = articulation;
}
public double TimeSeconds { get; }
public DrumPad Pad { get; }
+ public int? Velocity { get; }
+
+ public DrumArticulation Articulation { get; }
+
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
diff --git a/src/HitTheKit.Core/DrumArticulation.cs b/src/HitTheKit.Core/DrumArticulation.cs
new file mode 100644
index 0000000..c4bac2c
--- /dev/null
+++ b/src/HitTheKit.Core/DrumArticulation.cs
@@ -0,0 +1,66 @@
+using System;
+
+namespace HitTheKit.Core
+{
+ public enum DrumArticulation
+ {
+ Default,
+ Head,
+ Rim,
+ Bow,
+ Edge,
+ Bell,
+ Closed,
+ HalfOpen,
+ Open,
+ Pedal,
+ Choke
+ }
+
+ public static class DrumArticulationValidator
+ {
+ public static bool IsValid(DrumPad pad, DrumArticulation articulation)
+ {
+ if (!Enum.IsDefined(typeof(DrumPad), pad) ||
+ !Enum.IsDefined(typeof(DrumArticulation), articulation))
+ return false;
+ if (articulation == DrumArticulation.Default) return true;
+
+ switch (pad)
+ {
+ case DrumPad.Kick:
+ return false;
+ case DrumPad.Snare:
+ case DrumPad.Tom1:
+ case DrumPad.Tom2:
+ case DrumPad.FloorTom:
+ return articulation == DrumArticulation.Head || articulation == DrumArticulation.Rim;
+ case DrumPad.HiHat:
+ return articulation == DrumArticulation.Bow ||
+ articulation == DrumArticulation.Edge ||
+ articulation == DrumArticulation.Closed ||
+ articulation == DrumArticulation.HalfOpen ||
+ articulation == DrumArticulation.Open ||
+ articulation == DrumArticulation.Pedal ||
+ articulation == DrumArticulation.Choke;
+ case DrumPad.Crash:
+ return articulation == DrumArticulation.Bow ||
+ articulation == DrumArticulation.Edge ||
+ articulation == DrumArticulation.Choke;
+ case DrumPad.Ride:
+ return articulation == DrumArticulation.Bow ||
+ articulation == DrumArticulation.Edge ||
+ articulation == DrumArticulation.Bell ||
+ articulation == DrumArticulation.Choke;
+ default:
+ return false;
+ }
+ }
+
+ public static void EnsureValid(DrumPad pad, DrumArticulation articulation)
+ {
+ if (!IsValid(pad, articulation))
+ throw new ArgumentException($"Articulation '{articulation}' is not valid for drum pad '{pad}'.");
+ }
+ }
+}
diff --git a/src/HitTheKit.Core/DrumHit.cs b/src/HitTheKit.Core/DrumHit.cs
index 88c8acf..1514ad7 100644
--- a/src/HitTheKit.Core/DrumHit.cs
+++ b/src/HitTheKit.Core/DrumHit.cs
@@ -4,7 +4,11 @@ namespace HitTheKit.Core
{
public readonly struct DrumHit
{
- public DrumHit(DrumPad pad, double timeSeconds, int velocity = 127)
+ public DrumHit(
+ DrumPad pad,
+ double timeSeconds,
+ int velocity = 127,
+ DrumArticulation articulation = DrumArticulation.Default)
{
if (double.IsNaN(timeSeconds) || double.IsInfinity(timeSeconds))
{
@@ -20,9 +24,11 @@ public DrumHit(DrumPad pad, double timeSeconds, int velocity = 127)
"Velocity must be between 0 and 127.");
}
+ DrumArticulationValidator.EnsureValid(pad, articulation);
Pad = pad;
TimeSeconds = timeSeconds;
Velocity = velocity;
+ Articulation = articulation;
}
public DrumPad Pad { get; }
@@ -30,5 +36,7 @@ public DrumHit(DrumPad pad, double timeSeconds, int velocity = 127)
public double TimeSeconds { get; }
public int Velocity { get; }
+
+ public DrumArticulation Articulation { get; }
}
}
diff --git a/src/HitTheKit.Core/GuidedLatencySoundCheck.cs b/src/HitTheKit.Core/GuidedLatencySoundCheck.cs
new file mode 100644
index 0000000..4cc358c
--- /dev/null
+++ b/src/HitTheKit.Core/GuidedLatencySoundCheck.cs
@@ -0,0 +1,153 @@
+using System;
+
+namespace HitTheKit.Core
+{
+ public enum GuidedSoundCheckState
+ {
+ Idle,
+ Running,
+ Complete
+ }
+
+ public enum GuidedSoundCheckInput
+ {
+ Keyboard,
+ Midi
+ }
+
+ public sealed class GuidedSoundCheckSnapshot
+ {
+ internal GuidedSoundCheckSnapshot(
+ GuidedSoundCheckState state,
+ GuidedSoundCheckInput source,
+ int targetCount,
+ int acceptedCount,
+ int missedCount,
+ double? nextTargetTimeSeconds,
+ TimingCalibrationSnapshot calibration)
+ {
+ State = state;
+ Source = source;
+ TargetCount = targetCount;
+ AcceptedCount = acceptedCount;
+ MissedCount = missedCount;
+ NextTargetTimeSeconds = nextTargetTimeSeconds;
+ Calibration = calibration ?? throw new ArgumentNullException(nameof(calibration));
+ }
+
+ public GuidedSoundCheckState State { get; }
+ public GuidedSoundCheckInput Source { get; }
+ public int TargetCount { get; }
+ public int AcceptedCount { get; }
+ public int MissedCount { get; }
+ public int ResolvedCount => AcceptedCount + MissedCount;
+ public double? NextTargetTimeSeconds { get; }
+ public TimingCalibrationSnapshot Calibration { get; }
+ public bool CanApplyRecommendation => State == GuidedSoundCheckState.Complete && Calibration.HasRecommendation;
+ }
+
+ public sealed class GuidedLatencySoundCheck
+ {
+ public const int DefaultTargetCount = 12;
+ public const double DefaultIntervalSeconds = 0.5;
+
+ private readonly TimingCalibrationAdvisor advisor = new TimingCalibrationAdvisor();
+ private readonly int targetCount;
+ private GuidedSoundCheckState state;
+ private GuidedSoundCheckInput source;
+ private double firstTargetTimeSeconds;
+ private double intervalSeconds;
+ private int targetIndex;
+ private int acceptedCount;
+ private int missedCount;
+
+ public GuidedLatencySoundCheck(int targetCount = DefaultTargetCount)
+ {
+ if (targetCount < TimingCalibrationAdvisor.MinimumSamples)
+ throw new ArgumentOutOfRangeException(nameof(targetCount));
+ this.targetCount = targetCount;
+ }
+
+ public GuidedSoundCheckSnapshot Snapshot => new GuidedSoundCheckSnapshot(
+ state,
+ source,
+ targetCount,
+ acceptedCount,
+ missedCount,
+ state == GuidedSoundCheckState.Running ? TargetTime(targetIndex) : (double?)null,
+ advisor.Snapshot);
+
+ public void Begin(GuidedSoundCheckInput source, double firstTargetTimeSeconds, double intervalSeconds = DefaultIntervalSeconds)
+ {
+ if (source != GuidedSoundCheckInput.Keyboard && source != GuidedSoundCheckInput.Midi)
+ throw new ArgumentOutOfRangeException(nameof(source));
+ if (!IsFinite(firstTargetTimeSeconds) || firstTargetTimeSeconds < 0)
+ throw new ArgumentOutOfRangeException(nameof(firstTargetTimeSeconds));
+ if (!IsFinite(intervalSeconds) || intervalSeconds <= 0)
+ throw new ArgumentOutOfRangeException(nameof(intervalSeconds));
+
+ this.source = source;
+ this.firstTargetTimeSeconds = firstTargetTimeSeconds;
+ this.intervalSeconds = intervalSeconds;
+ targetIndex = 0;
+ acceptedCount = 0;
+ missedCount = 0;
+ advisor.Reset();
+ state = GuidedSoundCheckState.Running;
+ }
+
+ public bool TryRecord(GuidedSoundCheckInput inputSource, double hitTimeSeconds)
+ {
+ if (!IsFinite(hitTimeSeconds)) throw new ArgumentOutOfRangeException(nameof(hitTimeSeconds));
+ if (state != GuidedSoundCheckState.Running || inputSource != source) return false;
+ Advance(hitTimeSeconds);
+ if (state != GuidedSoundCheckState.Running) return false;
+
+ double delta = hitTimeSeconds - TargetTime(targetIndex);
+ if (Math.Abs(delta) > TimingWindows.Default.HitSeconds) return false;
+ advisor.Add(delta);
+ acceptedCount++;
+ targetIndex++;
+ CompleteIfResolved();
+ return true;
+ }
+
+ public void Advance(double currentTimeSeconds)
+ {
+ if (!IsFinite(currentTimeSeconds)) throw new ArgumentOutOfRangeException(nameof(currentTimeSeconds));
+ if (state != GuidedSoundCheckState.Running) return;
+ while (targetIndex < targetCount &&
+ currentTimeSeconds > TargetTime(targetIndex) + TimingWindows.Default.HitSeconds)
+ {
+ missedCount++;
+ targetIndex++;
+ }
+ CompleteIfResolved();
+ }
+
+ public double RecommendOffsetSeconds(double currentOffsetSeconds)
+ {
+ if (!Snapshot.CanApplyRecommendation)
+ throw new InvalidOperationException("The sound check does not have enough accepted hits.");
+ return advisor.RecommendOffsetSeconds(currentOffsetSeconds);
+ }
+
+ public void Reset()
+ {
+ state = GuidedSoundCheckState.Idle;
+ targetIndex = 0;
+ acceptedCount = 0;
+ missedCount = 0;
+ advisor.Reset();
+ }
+
+ private double TargetTime(int index) => firstTargetTimeSeconds + index * intervalSeconds;
+
+ private void CompleteIfResolved()
+ {
+ if (targetIndex >= targetCount) state = GuidedSoundCheckState.Complete;
+ }
+
+ private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
+ }
+}
diff --git a/src/HitTheKit.Core/HitMatcher.cs b/src/HitTheKit.Core/HitMatcher.cs
index 55468b6..32cdef9 100644
--- a/src/HitTheKit.Core/HitMatcher.cs
+++ b/src/HitTheKit.Core/HitMatcher.cs
@@ -47,7 +47,8 @@ public bool TryMatch(
"The candidate list cannot contain null notes.",
nameof(notes));
- if (_resolvedNotes.Contains(note) || note.Pad != hit.Pad)
+ if (_resolvedNotes.Contains(note) || note.Pad != hit.Pad ||
+ note.Articulation != DrumArticulation.Default && note.Articulation != hit.Articulation)
{
continue;
}
diff --git a/src/HitTheKit.Core/PerformanceGhostReplay.cs b/src/HitTheKit.Core/PerformanceGhostReplay.cs
new file mode 100644
index 0000000..29e5fb5
--- /dev/null
+++ b/src/HitTheKit.Core/PerformanceGhostReplay.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+
+namespace HitTheKit.Core
+{
+ public sealed class GhostReplayHit
+ {
+ public GhostReplayHit(double timeSeconds, DrumPad pad, int velocity, HitGrade? grade)
+ {
+ if (!IsFinite(timeSeconds) || timeSeconds < 0) throw new ArgumentOutOfRangeException(nameof(timeSeconds));
+ if (!Enum.IsDefined(typeof(DrumPad), pad)) throw new ArgumentOutOfRangeException(nameof(pad));
+ if (velocity < 0 || velocity > 127) throw new ArgumentOutOfRangeException(nameof(velocity));
+ if (grade.HasValue && !Enum.IsDefined(typeof(HitGrade), grade.Value))
+ throw new ArgumentOutOfRangeException(nameof(grade));
+ TimeSeconds = timeSeconds;
+ Pad = pad;
+ Velocity = velocity;
+ Grade = grade;
+ }
+
+ public double TimeSeconds { get; }
+ public DrumPad Pad { get; }
+ public int Velocity { get; }
+ public HitGrade? Grade { get; }
+
+ private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
+ }
+
+ public sealed class PerformanceGhostReplay
+ {
+ public const int MaximumHitsPerTake = 8192;
+
+ private readonly List current = new List();
+ private IReadOnlyList ghost = Array.Empty();
+
+ public IReadOnlyList Ghost => ghost;
+ public int CurrentHitCount => current.Count;
+ public bool HasGhost => ghost.Count > 0;
+
+ public bool Record(double timeSeconds, DrumPad pad, int velocity, HitGrade? grade)
+ {
+ if (timeSeconds < 0) return false;
+ if (current.Count >= MaximumHitsPerTake) return false;
+ current.Add(new GhostReplayHit(timeSeconds, pad, velocity, grade));
+ return true;
+ }
+
+ public bool CommitCurrentTake()
+ {
+ if (current.Count == 0) return false;
+ var copy = current.ToArray();
+ Array.Sort(copy, CompareHits);
+ ghost = Array.AsReadOnly(copy);
+ current.Clear();
+ return true;
+ }
+
+ public void ResetCurrent() => current.Clear();
+ public void ClearGhost() => ghost = Array.Empty();
+
+ private static int CompareHits(GhostReplayHit left, GhostReplayHit right)
+ {
+ int byTime = left.TimeSeconds.CompareTo(right.TimeSeconds);
+ return byTime != 0 ? byTime : left.Pad.CompareTo(right.Pad);
+ }
+ }
+}
diff --git a/src/HitTheKit.Core/PracticeErrorMapAnalyzer.cs b/src/HitTheKit.Core/PracticeErrorMapAnalyzer.cs
new file mode 100644
index 0000000..473ebb4
--- /dev/null
+++ b/src/HitTheKit.Core/PracticeErrorMapAnalyzer.cs
@@ -0,0 +1,184 @@
+using System;
+using System.Collections.Generic;
+
+namespace HitTheKit.Core
+{
+ public sealed class PracticeSectionDefinition
+ {
+ public PracticeSectionDefinition(int index, string label, double startSeconds, double endSeconds)
+ {
+ if (index < 0) throw new ArgumentOutOfRangeException(nameof(index));
+ if (string.IsNullOrWhiteSpace(label)) throw new ArgumentException("Section label is required.", nameof(label));
+ if (!IsFinite(startSeconds) || startSeconds < 0) throw new ArgumentOutOfRangeException(nameof(startSeconds));
+ if (!IsFinite(endSeconds) || endSeconds <= startSeconds) throw new ArgumentOutOfRangeException(nameof(endSeconds));
+ Index = index;
+ Label = label;
+ StartSeconds = startSeconds;
+ EndSeconds = endSeconds;
+ }
+
+ public int Index { get; }
+ public string Label { get; }
+ public double StartSeconds { get; }
+ public double EndSeconds { get; }
+
+ public bool Contains(double noteTimeSeconds) =>
+ noteTimeSeconds >= StartSeconds && noteTimeSeconds < EndSeconds;
+
+ private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
+ }
+
+ public sealed class PracticeErrorCell
+ {
+ internal PracticeErrorCell(
+ PracticeSectionDefinition section,
+ DrumPad pad,
+ int perfect,
+ int good,
+ int early,
+ int late,
+ int miss)
+ {
+ Section = section;
+ Pad = pad;
+ Perfect = perfect;
+ Good = good;
+ Early = early;
+ Late = late;
+ Miss = miss;
+ }
+
+ public PracticeSectionDefinition Section { get; }
+ public DrumPad Pad { get; }
+ public int Perfect { get; }
+ public int Good { get; }
+ public int Early { get; }
+ public int Late { get; }
+ public int Miss { get; }
+ public int Resolved => Perfect + Good + Early + Late + Miss;
+ public double Accuracy => Resolved == 0
+ ? 0
+ : (Perfect + Good * 0.75 + (Early + Late) * 0.5) * 100.0 / Resolved;
+ }
+
+ public sealed class PracticeErrorMapAnalyzer
+ {
+ private readonly IReadOnlyList sections;
+ private readonly Dictionary cells = new Dictionary();
+
+ public PracticeErrorMapAnalyzer(IReadOnlyList sections)
+ {
+ if (sections == null) throw new ArgumentNullException(nameof(sections));
+ if (sections.Count == 0) throw new ArgumentException("At least one practice section is required.", nameof(sections));
+ var copy = new PracticeSectionDefinition[sections.Count];
+ for (int index = 0; index < sections.Count; index++)
+ {
+ PracticeSectionDefinition section = sections[index]
+ ?? throw new ArgumentException("Practice sections cannot contain null entries.", nameof(sections));
+ if (section.Index != index)
+ throw new ArgumentException("Practice section indices must be contiguous and ordered.", nameof(sections));
+ if (index > 0 && Math.Abs(copy[index - 1].EndSeconds - section.StartSeconds) > 0.000001)
+ throw new ArgumentException("Practice sections must be contiguous.", nameof(sections));
+ copy[index] = section;
+ }
+ this.sections = Array.AsReadOnly(copy);
+ }
+
+ public IReadOnlyList Sections => sections;
+
+ public void Record(HitResult result)
+ {
+ if (result == null) throw new ArgumentNullException(nameof(result));
+ PracticeSectionDefinition? section = FindSection(result.Note.TimeSeconds);
+ if (section == null) return;
+ var key = new CellKey(section.Index, result.Note.Pad);
+ if (!cells.TryGetValue(key, out MutableCell cell))
+ {
+ cell = new MutableCell();
+ cells.Add(key, cell);
+ }
+ switch (result.Grade)
+ {
+ case HitGrade.Perfect: cell.Perfect++; break;
+ case HitGrade.Good: cell.Good++; break;
+ case HitGrade.Early: cell.Early++; break;
+ case HitGrade.Late: cell.Late++; break;
+ case HitGrade.Miss: cell.Miss++; break;
+ default: throw new ArgumentOutOfRangeException();
+ }
+ }
+
+ public IReadOnlyList Snapshot()
+ {
+ var result = new List(cells.Count);
+ foreach (KeyValuePair pair in cells)
+ {
+ MutableCell value = pair.Value;
+ result.Add(new PracticeErrorCell(
+ sections[pair.Key.SectionIndex],
+ pair.Key.Pad,
+ value.Perfect,
+ value.Good,
+ value.Early,
+ value.Late,
+ value.Miss));
+ }
+ result.Sort(CompareCells);
+ return result.AsReadOnly();
+ }
+
+ public PracticeErrorCell? Weakest()
+ {
+ IReadOnlyList snapshot = Snapshot();
+ if (snapshot.Count == 0) return null;
+ PracticeErrorCell weakest = snapshot[0];
+ for (int index = 1; index < snapshot.Count; index++)
+ {
+ PracticeErrorCell candidate = snapshot[index];
+ if (candidate.Accuracy < weakest.Accuracy ||
+ (Math.Abs(candidate.Accuracy - weakest.Accuracy) < 0.0001 && candidate.Resolved > weakest.Resolved))
+ weakest = candidate;
+ }
+ return weakest;
+ }
+
+ public void Reset() => cells.Clear();
+
+ private PracticeSectionDefinition? FindSection(double noteTimeSeconds)
+ {
+ for (int index = 0; index < sections.Count; index++)
+ if (sections[index].Contains(noteTimeSeconds)) return sections[index];
+ return null;
+ }
+
+ private static int CompareCells(PracticeErrorCell left, PracticeErrorCell right)
+ {
+ int bySection = left.Section.Index.CompareTo(right.Section.Index);
+ return bySection != 0 ? bySection : left.Pad.CompareTo(right.Pad);
+ }
+
+ private readonly struct CellKey : IEquatable
+ {
+ public CellKey(int sectionIndex, DrumPad pad)
+ {
+ SectionIndex = sectionIndex;
+ Pad = pad;
+ }
+
+ public int SectionIndex { get; }
+ public DrumPad Pad { get; }
+ public bool Equals(CellKey other) => SectionIndex == other.SectionIndex && Pad == other.Pad;
+ public override bool Equals(object obj) => obj is CellKey other && Equals(other);
+ public override int GetHashCode() => (SectionIndex * 397) ^ (int)Pad;
+ }
+
+ private sealed class MutableCell
+ {
+ public int Perfect;
+ public int Good;
+ public int Early;
+ public int Late;
+ public int Miss;
+ }
+ }
+}
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs
index 6e6f8c6..1aac2aa 100644
--- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs
@@ -75,6 +75,27 @@ public void Resume()
IsPaused = false;
}
+ public void Seek(double positionSeconds)
+ {
+ EnsureScheduled();
+ EnsureFinite(positionSeconds, nameof(positionSeconds));
+ if (positionSeconds < 0 || positionSeconds >= DurationSeconds)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(positionSeconds),
+ "Song position must be within the scheduled duration.");
+ }
+
+ if (IsPaused)
+ {
+ StartDspTime = pausedAtDspTime - positionSeconds;
+ }
+ else
+ {
+ StartDspTime = timeSource.Now - positionSeconds;
+ }
+ }
+
private static void EnsureFinite(double value, string parameterName)
{
if (double.IsNaN(value) || double.IsInfinity(value))
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs
index 3735fbd..dc24a2b 100644
--- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs
@@ -25,6 +25,7 @@ public sealed class DspSongClockPrototype : MonoBehaviour
private string externalAudioPath;
private double audioPlaybackSpeed = 1.0;
private UnityWebRequest audioRequest;
+ private bool seekedWhilePaused;
public DspSongClock Clock { get; private set; }
public double StartDspTime => Clock != null && Clock.IsScheduled ? Clock.StartDspTime : double.NaN;
@@ -37,6 +38,7 @@ public sealed class DspSongClockPrototype : MonoBehaviour
public double CountInSeconds => countInBeats * 60.0 / bpm;
public string ExternalAudioPath => externalAudioPath;
public double AudioPlaybackSpeed => audioPlaybackSpeed;
+ public bool IsPreviewing { get; private set; }
public string LoadError { get; private set; }
public void Configure(
@@ -141,7 +143,15 @@ public void ResumePlayback()
{
if (Clock == null || !Clock.IsPaused) return;
Clock.Resume();
- audioSource.UnPause();
+ if (seekedWhilePaused)
+ {
+ audioSource.Play();
+ seekedWhilePaused = false;
+ }
+ else
+ {
+ audioSource.UnPause();
+ }
}
public void RestartPlayback()
@@ -153,8 +163,60 @@ public void RestartPlayback()
completedLogged = false;
}
+ public void SeekPlayback(double positionSeconds)
+ {
+ if (generatedClip == null || Clock == null || !Clock.IsScheduled)
+ throw new InvalidOperationException("Song playback must be scheduled before seeking.");
+ if (double.IsNaN(positionSeconds) || double.IsInfinity(positionSeconds) ||
+ positionSeconds < 0 || positionSeconds >= Clock.DurationSeconds)
+ throw new ArgumentOutOfRangeException(nameof(positionSeconds));
+
+ bool wasPaused = Clock.IsPaused;
+ double clipPosition = Math.Min(
+ generatedClip.length - (1.0 / Math.Max(1, generatedClip.frequency)),
+ positionSeconds * audioPlaybackSpeed);
+
+ audioSource.Stop();
+ audioSource.time = (float)Math.Max(0, clipPosition);
+ Clock.Seek(positionSeconds);
+ if (wasPaused)
+ {
+ seekedWhilePaused = true;
+ }
+ else
+ {
+ audioSource.Play();
+ seekedWhilePaused = false;
+ }
+ startedLogged = positionSeconds >= 0;
+ completedLogged = false;
+ }
+
+ public void PreviewFromSourceTime(double sourceTimeSeconds, double speed = 1.0)
+ {
+ if (generatedClip == null || audioSource == null)
+ throw new InvalidOperationException("Song audio is not loaded.");
+ if (double.IsNaN(sourceTimeSeconds) || double.IsInfinity(sourceTimeSeconds) ||
+ sourceTimeSeconds < 0 || sourceTimeSeconds >= generatedClip.length)
+ throw new ArgumentOutOfRangeException(nameof(sourceTimeSeconds));
+ if (double.IsNaN(speed) || double.IsInfinity(speed) || speed <= 0 || speed > 3)
+ throw new ArgumentOutOfRangeException(nameof(speed));
+ audioSource.Stop();
+ audioSource.pitch = (float)speed;
+ audioSource.time = (float)sourceTimeSeconds;
+ audioSource.Play();
+ IsPreviewing = true;
+ }
+
+ public void StopPreview()
+ {
+ if (audioSource != null) audioSource.Stop();
+ IsPreviewing = false;
+ }
+
private void SchedulePlayback()
{
+ IsPreviewing = false;
audioSource.clip = generatedClip;
audioSource.pitch = (float)audioPlaybackSpeed;
var timeSource = new UnityDspTimeSource();
@@ -162,6 +224,7 @@ private void SchedulePlayback()
Clock = new DspSongClock(timeSource);
Clock.Schedule(startDspTime, generatedClip.length / audioPlaybackSpeed);
audioSource.PlayScheduled(startDspTime);
+ seekedWhilePaused = false;
}
private void Update()
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartLoader.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartLoader.cs
index 44880b2..e95a9b7 100644
--- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartLoader.cs
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartLoader.cs
@@ -75,7 +75,17 @@ public LoadedChart Load(string json, string difficulty)
}
DrumPad pad = ParsePad(noteDto.pad, index);
- notes.Add(new IndexedChartNote(new ChartNote(noteDto.time, pad), index));
+ int? velocity = noteDto.velocity == int.MinValue ? (int?)null : noteDto.velocity;
+ DrumArticulation articulation = ParseArticulation(noteDto.articulation, pad, index);
+ try
+ {
+ notes.Add(new IndexedChartNote(
+ new ChartNote(noteDto.time, pad, velocity, articulation), index));
+ }
+ catch (ArgumentException exception)
+ {
+ throw new ChartLoadException($"Chart note at index {index} is invalid: {exception.Message}", exception);
+ }
}
notes.Sort((left, right) =>
@@ -161,6 +171,34 @@ private static DrumPad ParsePad(string pad, int index)
}
}
+ private static DrumArticulation ParseArticulation(string value, DrumPad pad, int index)
+ {
+ DrumArticulation articulation;
+ switch (value)
+ {
+ case null:
+ case "default": articulation = DrumArticulation.Default; break;
+ case "head": articulation = DrumArticulation.Head; break;
+ case "rim": articulation = DrumArticulation.Rim; break;
+ case "bow": articulation = DrumArticulation.Bow; break;
+ case "edge": articulation = DrumArticulation.Edge; break;
+ case "bell": articulation = DrumArticulation.Bell; break;
+ case "closed": articulation = DrumArticulation.Closed; break;
+ case "halfOpen": articulation = DrumArticulation.HalfOpen; break;
+ case "open": articulation = DrumArticulation.Open; break;
+ case "pedal": articulation = DrumArticulation.Pedal; break;
+ case "choke": articulation = DrumArticulation.Choke; break;
+ default:
+ throw new ChartLoadException(
+ $"Chart note at index {index} has unknown articulation '{value}'.");
+ }
+
+ if (!DrumArticulationValidator.IsValid(pad, articulation))
+ throw new ChartLoadException(
+ $"Chart note at index {index} articulation '{value}' is invalid for pad '{pad}'.");
+ return articulation;
+ }
+
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
@@ -190,6 +228,8 @@ private sealed class ChartNoteDto
{
public double time = double.NaN;
public string pad;
+ public int velocity = int.MinValue;
+ public string articulation;
}
}
}
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs
index 22b2bd7..e04042f 100644
--- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs
@@ -85,6 +85,22 @@ public IReadOnlyList GetElapsed(double songPositionSeconds)
return result.AsReadOnly();
}
+ public IReadOnlyList GetRange(double startSeconds, double endSeconds)
+ {
+ ValidateFinite(startSeconds, nameof(startSeconds));
+ ValidateFinite(endSeconds, nameof(endSeconds));
+ if (startSeconds < 0 || endSeconds <= startSeconds)
+ throw new ArgumentOutOfRangeException(nameof(endSeconds), "Range end must be after a non-negative start.");
+
+ var result = new List();
+ foreach (TimelineNote note in notes)
+ {
+ if (note.EffectiveTimeSeconds >= startSeconds && note.EffectiveTimeSeconds < endSeconds)
+ result.Add(note);
+ }
+ return result.AsReadOnly();
+ }
+
private static void ValidateFinite(double value, string parameterName)
{
if (!IsFinite(value))
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs
index cbbc55b..e66a68d 100644
--- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs
@@ -65,9 +65,27 @@ private void Start()
public IReadOnlyList CreateMatchingNotes()
{
if (Timeline == null) throw new InvalidOperationException("The chart timeline has not started.");
- var result = new ChartNote[Timeline.Notes.Count];
+ return CreateMatchingNotes(Timeline.Notes);
+ }
+
+ public IReadOnlyList CreateMatchingNotes(double startSeconds, double endSeconds)
+ {
+ if (Timeline == null) throw new InvalidOperationException("The chart timeline has not started.");
+ return CreateMatchingNotes(Timeline.GetRange(startSeconds, endSeconds));
+ }
+
+ private static IReadOnlyList CreateMatchingNotes(IReadOnlyList source)
+ {
+ var result = new ChartNote[source.Count];
for (int index = 0; index < result.Length; index++)
- result[index] = new ChartNote(Timeline.Notes[index].EffectiveTimeSeconds, Timeline.Notes[index].Note.Pad);
+ {
+ ChartNote note = source[index].Note;
+ result[index] = new ChartNote(
+ source[index].EffectiveTimeSeconds,
+ note.Pad,
+ note.Velocity,
+ note.Articulation);
+ }
return Array.AsReadOnly(result);
}
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Devices/MidiKitMappingEngine.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Devices/MidiKitMappingEngine.cs
index e4f544b..c9541d9 100644
--- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Devices/MidiKitMappingEngine.cs
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Devices/MidiKitMappingEngine.cs
@@ -232,8 +232,28 @@ public MvpDrumInputMappingStatus Map(
pad,
hit.Velocity,
hit.TimestampSeconds.Value,
- DrumInputSource.Midi);
+ DrumInputSource.Midi,
+ ToDrumArticulation(hit.Articulation));
return MvpDrumInputMappingStatus.Mapped;
}
+
+ private static DrumArticulation ToDrumArticulation(KitArticulation articulation)
+ {
+ switch (articulation)
+ {
+ case KitArticulation.Default: return DrumArticulation.Default;
+ case KitArticulation.Head: return DrumArticulation.Head;
+ case KitArticulation.Rim: return DrumArticulation.Rim;
+ case KitArticulation.Bow: return DrumArticulation.Bow;
+ case KitArticulation.Edge: return DrumArticulation.Edge;
+ case KitArticulation.Bell: return DrumArticulation.Bell;
+ case KitArticulation.Closed: return DrumArticulation.Closed;
+ case KitArticulation.HalfOpen: return DrumArticulation.HalfOpen;
+ case KitArticulation.Open: return DrumArticulation.Open;
+ case KitArticulation.Pedal: return DrumArticulation.Pedal;
+ case KitArticulation.Choke: return DrumArticulation.Choke;
+ default: throw new ArgumentOutOfRangeException(nameof(articulation));
+ }
+ }
}
}
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs
new file mode 100644
index 0000000..00b0d11
--- /dev/null
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs
@@ -0,0 +1,240 @@
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using UnityEngine;
+
+namespace HitTheKit.Unity.Gameplay
+{
+ public sealed class ChartAuthoringAudioRequest
+ {
+ public ChartAuthoringAudioRequest(
+ string sourceAudioPath,
+ string title,
+ string artist,
+ double bpm,
+ int bars,
+ int beatsPerBar)
+ {
+ if (string.IsNullOrWhiteSpace(sourceAudioPath))
+ throw new ArgumentException("An audio file is required.", nameof(sourceAudioPath));
+ if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Title is required.", nameof(title));
+ if (string.IsNullOrWhiteSpace(artist)) throw new ArgumentException("Artist is required.", nameof(artist));
+ if (!IsFinite(bpm) || bpm <= 0 || bpm > 400) throw new ArgumentOutOfRangeException(nameof(bpm));
+ if (bars <= 0 || bars > 10000) throw new ArgumentOutOfRangeException(nameof(bars));
+ if (beatsPerBar <= 0 || beatsPerBar > 32) throw new ArgumentOutOfRangeException(nameof(beatsPerBar));
+
+ SourceAudioPath = Path.GetFullPath(sourceAudioPath);
+ Title = title.Trim();
+ Artist = artist.Trim();
+ Bpm = bpm;
+ Bars = bars;
+ BeatsPerBar = beatsPerBar;
+ }
+
+ public string SourceAudioPath { get; }
+ public string Title { get; }
+ public string Artist { get; }
+ public double Bpm { get; }
+ public int Bars { get; }
+ public int BeatsPerBar { get; }
+
+ private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
+ }
+
+ public sealed class ChartAuthoringAudioImportResult
+ {
+ internal ChartAuthoringAudioImportResult(SongLibraryEntry song, string sourcePath)
+ {
+ Song = song;
+ SourcePath = sourcePath;
+ }
+
+ public SongLibraryEntry Song { get; }
+ public string SourcePath { get; }
+ }
+
+ public sealed class ChartAuthoringAudioImportException : Exception
+ {
+ public ChartAuthoringAudioImportException(string message) : base(message) { }
+ public ChartAuthoringAudioImportException(string message, Exception innerException) : base(message, innerException) { }
+ }
+
+ public sealed class ChartAuthoringAudioImporter
+ {
+ public const long MaximumAudioBytes = 1024L * 1024 * 1024;
+ private static readonly UTF8Encoding Utf8WithoutBom = new UTF8Encoding(false);
+
+ public ChartAuthoringAudioImportResult Import(
+ ChartAuthoringAudioRequest request,
+ string libraryRoot,
+ DateTimeOffset createdAtUtc)
+ {
+ if (request == null) throw new ArgumentNullException(nameof(request));
+ if (string.IsNullOrWhiteSpace(libraryRoot))
+ throw new ArgumentException("Song-library root is required.", nameof(libraryRoot));
+ if (createdAtUtc.Offset != TimeSpan.Zero)
+ throw new ArgumentException("Creation time must be UTC.", nameof(createdAtUtc));
+
+ string source = ValidateSource(request.SourceAudioPath);
+ string root = Path.GetFullPath(libraryRoot);
+ Directory.CreateDirectory(root);
+ string baseId = BuildId(request.Artist, request.Title, createdAtUtc);
+ string songId = UniqueSongId(root, baseId);
+ string destination = Path.Combine(root, songId);
+ string temporary = Path.Combine(root, $".hitthekit-audio-{Guid.NewGuid():N}");
+ string audioFileName = "source-audio" + Path.GetExtension(source).ToLowerInvariant();
+ Directory.CreateDirectory(temporary);
+
+ try
+ {
+ string copiedAudio = Path.Combine(temporary, audioFileName);
+ File.Copy(source, copiedAudio, false);
+ string manifest = Manifest(songId, request, audioFileName);
+ File.WriteAllText(Path.Combine(temporary, SongLibraryDiscovery.ManifestFileName), manifest, Utf8WithoutBom);
+ SongLibraryEntry validated = new SongLibraryDiscovery().Parse(
+ manifest,
+ temporary,
+ SongLibraryOrigin.UserFolder);
+ if (!validated.CanAuthorChart || validated.IsPlayable)
+ throw new ChartAuthoringAudioImportException("The imported audio did not produce a valid authoring source.");
+
+ Directory.Move(temporary, destination);
+ SongLibraryEntry published = new SongLibraryDiscovery().Parse(
+ File.ReadAllText(Path.Combine(destination, SongLibraryDiscovery.ManifestFileName)),
+ destination,
+ SongLibraryOrigin.UserFolder);
+ return new ChartAuthoringAudioImportResult(published, source);
+ }
+ catch (ChartAuthoringAudioImportException)
+ {
+ if (Directory.Exists(temporary)) Directory.Delete(temporary, true);
+ throw;
+ }
+ catch (Exception exception)
+ {
+ if (Directory.Exists(temporary)) Directory.Delete(temporary, true);
+ throw new ChartAuthoringAudioImportException("The selected audio could not be imported.", exception);
+ }
+ }
+
+ public static string ValidateSource(string sourceAudioPath)
+ {
+ if (string.IsNullOrWhiteSpace(sourceAudioPath))
+ throw new ChartAuthoringAudioImportException("Select a WAV or OGG audio file.");
+ string source = Path.GetFullPath(sourceAudioPath);
+ var info = new FileInfo(source);
+ if (!info.Exists || info.Length <= 0 || info.Length > MaximumAudioBytes)
+ throw new ChartAuthoringAudioImportException("Audio must be between 1 byte and 1 GiB.");
+ if ((File.GetAttributes(source) & FileAttributes.ReparsePoint) != 0)
+ throw new ChartAuthoringAudioImportException("The selected audio cannot be a symbolic link.");
+ string extension = Path.GetExtension(source);
+ if (!string.Equals(extension, ".wav", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(extension, ".ogg", StringComparison.OrdinalIgnoreCase))
+ throw new ChartAuthoringAudioImportException("Chart Creator supports WAV and OGG audio.");
+ return source;
+ }
+
+ private static string UniqueSongId(string root, string baseId)
+ {
+ string candidate = baseId;
+ int suffix = 2;
+ while (Directory.Exists(Path.Combine(root, candidate)) ||
+ File.Exists(Path.Combine(root, candidate + HtkSongPackageService.Extension)))
+ candidate = $"{baseId}-{suffix++}";
+ ChartCreatorMetadata.ValidateIdentifier(candidate, nameof(baseId));
+ return candidate;
+ }
+
+ private static string BuildId(string artist, string title, DateTimeOffset createdAtUtc)
+ {
+ string text = (artist + "-" + title).Normalize(NormalizationForm.FormD);
+ var id = new StringBuilder(64);
+ bool separator = false;
+ for (int index = 0; index < text.Length && id.Length < 48; index++)
+ {
+ char value = char.ToLowerInvariant(text[index]);
+ if (value >= 'a' && value <= 'z' || value >= '0' && value <= '9')
+ {
+ id.Append(value);
+ separator = false;
+ }
+ else if (id.Length > 0 && !separator)
+ {
+ id.Append('-');
+ separator = true;
+ }
+ }
+ string prefix = id.ToString().Trim('-');
+ if (prefix.Length == 0) prefix = "local-song";
+ return $"{prefix}-authoring-{createdAtUtc:yyyyMMdd-HHmmss}";
+ }
+
+ private static string Manifest(
+ string songId,
+ ChartAuthoringAudioRequest request,
+ string audioFileName)
+ {
+ return "{\n" +
+ " \"schemaVersion\": 1,\n" +
+ $" \"id\": \"{Escape(songId)}\",\n" +
+ $" \"title\": \"{Escape(request.Title)}\",\n" +
+ $" \"artist\": \"{Escape(request.Artist)}\",\n" +
+ $" \"bpm\": {request.Bpm.ToString("0.#########", CultureInfo.InvariantCulture)},\n" +
+ $" \"bars\": {request.Bars},\n" +
+ $" \"beatsPerBar\": {request.BeatsPerBar},\n" +
+ " \"difficultyHint\": \"New local chart\",\n" +
+ " \"sortOrder\": 0,\n" +
+ " \"audioAvailability\": \"available\",\n" +
+ $" \"audioFile\": \"{Escape(audioFileName)}\",\n" +
+ " \"chartAvailability\": \"unavailable\"\n" +
+ "}\n";
+ }
+
+ private static string Escape(string value) => value
+ .Replace("\\", "\\\\")
+ .Replace("\"", "\\\"")
+ .Replace("\r", "\\r")
+ .Replace("\n", "\\n");
+ }
+
+ public interface IChartAuthoringAudioPicker
+ {
+ string PickAudioFile();
+ }
+
+ public sealed class MacOsChartAuthoringAudioPicker : IChartAuthoringAudioPicker
+ {
+ public string PickAudioFile()
+ {
+#if UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
+ const string script =
+ "const app = Application.currentApplication(); " +
+ "app.includeStandardAdditions = true; " +
+ "const chosen = app.chooseFile({withPrompt: 'Choose a WAV or OGG backing track'}); " +
+ "Path(chosen).toString();";
+ var start = new ProcessStartInfo
+ {
+ FileName = "/usr/bin/osascript",
+ Arguments = "-l JavaScript -e \"" +
+ script.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+ using (Process process = Process.Start(start))
+ {
+ if (process == null) throw new InvalidOperationException("The macOS audio picker could not start.");
+ string output = process.StandardOutput.ReadToEnd();
+ process.StandardError.ReadToEnd();
+ process.WaitForExit();
+ return process.ExitCode == 0 ? output.Trim() : null;
+ }
+#else
+ throw new PlatformNotSupportedException("The native audio picker currently supports macOS.");
+#endif
+ }
+ }
+}
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs.meta
new file mode 100644
index 0000000..f8f807d
--- /dev/null
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 3e8f45ac662e4b50b9fd231315fd1419
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs
new file mode 100644
index 0000000..1b5634f
--- /dev/null
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs
@@ -0,0 +1,448 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using HitTheKit.Core;
+using HitTheKit.Unity.Charts;
+using HitTheKit.Unity.Input;
+
+namespace HitTheKit.Unity.Gameplay
+{
+ public enum ChartQuantization
+ {
+ None,
+ EighthNote,
+ SixteenthNote
+ }
+
+ public sealed class RecordedChartHit
+ {
+ internal RecordedChartHit(DrumInputEvent input, int recordingIndex)
+ {
+ Pad = input.Pad;
+ Velocity = input.Velocity;
+ TimeSeconds = input.SongTimeSeconds;
+ Source = input.Source;
+ Articulation = input.Articulation;
+ RecordingIndex = recordingIndex;
+ }
+
+ public DrumPad Pad { get; }
+ public int Velocity { get; }
+ public double TimeSeconds { get; }
+ public DrumInputSource Source { get; }
+ public DrumArticulation Articulation { get; }
+ internal int RecordingIndex { get; }
+ }
+
+ public sealed class ChartRecordingDraft
+ {
+ internal ChartRecordingDraft(double durationSeconds, IReadOnlyList hits)
+ {
+ DurationSeconds = durationSeconds;
+ var copy = new RecordedChartHit[hits.Count];
+ for (int index = 0; index < hits.Count; index++) copy[index] = hits[index];
+ Hits = Array.AsReadOnly(copy);
+ }
+
+ public double DurationSeconds { get; }
+ public IReadOnlyList Hits { get; }
+ }
+
+ public sealed class ChartRecordingSession
+ {
+ public const int MaximumHits = 100000;
+ private readonly double durationSeconds;
+ private readonly double outputTimeScale;
+ private readonly List hits = new List();
+
+ public ChartRecordingSession(double durationSeconds, double outputTimeScale = 1.0)
+ {
+ if (!IsFinite(durationSeconds) || durationSeconds <= 0)
+ throw new ArgumentOutOfRangeException(nameof(durationSeconds));
+ if (!IsFinite(outputTimeScale) || outputTimeScale <= 0)
+ throw new ArgumentOutOfRangeException(nameof(outputTimeScale));
+ this.durationSeconds = durationSeconds;
+ this.outputTimeScale = outputTimeScale;
+ }
+
+ public bool IsRecording { get; private set; } = true;
+ public int HitCount => hits.Count;
+ public int IgnoredCount { get; private set; }
+
+ public bool Record(DrumInputEvent input)
+ {
+ if (!IsRecording) return false;
+ if (input.SongTimeSeconds < 0 || input.SongTimeSeconds > durationSeconds)
+ {
+ IgnoredCount++;
+ return false;
+ }
+ if (hits.Count >= MaximumHits)
+ throw new InvalidOperationException($"A chart recording cannot exceed {MaximumHits} hits.");
+
+ hits.Add(new RecordedChartHit(input.WithSongTime(input.SongTimeSeconds * outputTimeScale), hits.Count));
+ return true;
+ }
+
+ public ChartRecordingDraft Finish()
+ {
+ IsRecording = false;
+ return new ChartRecordingDraft(durationSeconds * outputTimeScale, hits);
+ }
+
+ public void Restart()
+ {
+ hits.Clear();
+ IgnoredCount = 0;
+ IsRecording = true;
+ }
+
+ private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
+ }
+
+ public sealed class ChartCreatorMetadata
+ {
+ public ChartCreatorMetadata(
+ string sourceSongId,
+ string title,
+ string artist,
+ string difficulty,
+ double bpm,
+ int bars,
+ int beatsPerBar)
+ {
+ ValidateIdentifier(sourceSongId, nameof(sourceSongId));
+ if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Title is required.", nameof(title));
+ if (string.IsNullOrWhiteSpace(artist)) throw new ArgumentException("Artist is required.", nameof(artist));
+ if (!Contains(ChartLoader.SupportedDifficulties, difficulty))
+ throw new ArgumentOutOfRangeException(nameof(difficulty));
+ if (!IsFinite(bpm) || bpm <= 0) throw new ArgumentOutOfRangeException(nameof(bpm));
+ if (bars <= 0) throw new ArgumentOutOfRangeException(nameof(bars));
+ if (beatsPerBar <= 0) throw new ArgumentOutOfRangeException(nameof(beatsPerBar));
+
+ SourceSongId = sourceSongId;
+ Title = title.Trim();
+ Artist = artist.Trim();
+ Difficulty = difficulty;
+ Bpm = bpm;
+ Bars = bars;
+ BeatsPerBar = beatsPerBar;
+ }
+
+ public string SourceSongId { get; }
+ public string Title { get; }
+ public string Artist { get; }
+ public string Difficulty { get; }
+ public double Bpm { get; }
+ public int Bars { get; }
+ public int BeatsPerBar { get; }
+
+ internal static void ValidateIdentifier(string value, string parameter)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length > 80)
+ throw new ArgumentException("Song ID is required and must not exceed 80 characters.", parameter);
+ for (int index = 0; index < value.Length; index++)
+ {
+ char character = value[index];
+ if (!(character >= 'a' && character <= 'z') &&
+ !(character >= '0' && character <= '9') && character != '-')
+ throw new ArgumentException("Song ID must use lowercase letters, numbers, and hyphens.", parameter);
+ }
+ }
+
+ private static bool Contains(IReadOnlyList values, string value)
+ {
+ for (int index = 0; index < values.Count; index++)
+ if (string.Equals(values[index], value, StringComparison.Ordinal)) return true;
+ return false;
+ }
+
+ private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
+ }
+
+ public static class ChartCreatorJson
+ {
+ public static string Serialize(
+ ChartRecordingDraft draft,
+ string difficulty,
+ double bpm,
+ ChartQuantization quantization)
+ {
+ if (draft == null) throw new ArgumentNullException(nameof(draft));
+ if (!Enum.IsDefined(typeof(ChartQuantization), quantization))
+ throw new ArgumentOutOfRangeException(nameof(quantization));
+ if (!IsFinite(bpm) || bpm <= 0) throw new ArgumentOutOfRangeException(nameof(bpm));
+ bool supported = false;
+ for (int index = 0; index < ChartLoader.SupportedDifficulties.Count; index++)
+ if (string.Equals(ChartLoader.SupportedDifficulties[index], difficulty, StringComparison.Ordinal))
+ supported = true;
+ if (!supported) throw new ArgumentOutOfRangeException(nameof(difficulty));
+
+ var projected = new List(draft.Hits.Count);
+ for (int index = 0; index < draft.Hits.Count; index++)
+ {
+ RecordedChartHit hit = draft.Hits[index];
+ double time = Quantize(hit.TimeSeconds, bpm, quantization);
+ time = Math.Max(0, Math.Min(draft.DurationSeconds, time));
+ projected.Add(new ProjectedHit(
+ time,
+ hit.Pad,
+ hit.Velocity,
+ hit.Articulation,
+ hit.RecordingIndex));
+ }
+ projected.Sort((left, right) =>
+ {
+ int byTime = left.TimeSeconds.CompareTo(right.TimeSeconds);
+ return byTime != 0 ? byTime : left.RecordingIndex.CompareTo(right.RecordingIndex);
+ });
+
+ var json = new StringBuilder(128 + projected.Count * 48);
+ json.Append("{\n \"version\": 1,\n \"offsetSeconds\": 0,\n \"difficulties\": {\n \"")
+ .Append(difficulty)
+ .Append("\": [");
+ for (int index = 0; index < projected.Count; index++)
+ {
+ ProjectedHit hit = projected[index];
+ json.Append(index == 0 ? "\n " : ",\n ")
+ .Append("{ \"time\": ")
+ .Append(hit.TimeSeconds.ToString("0.#########", CultureInfo.InvariantCulture))
+ .Append(", \"pad\": \"")
+ .Append(PadId(hit.Pad))
+ .Append("\", \"velocity\": ")
+ .Append(hit.Velocity.ToString(CultureInfo.InvariantCulture));
+ if (hit.Articulation != DrumArticulation.Default)
+ json.Append(", \"articulation\": \"")
+ .Append(ArticulationId(hit.Articulation))
+ .Append('"');
+ json.Append(" }");
+ }
+ if (projected.Count > 0) json.Append('\n').Append(" ");
+ json.Append("]\n }\n}\n");
+
+ string result = json.ToString();
+ new ChartLoader().Load(result, difficulty);
+ return result;
+ }
+
+ private static double Quantize(double value, double bpm, ChartQuantization quantization)
+ {
+ int subdivisions;
+ switch (quantization)
+ {
+ case ChartQuantization.None: return value;
+ case ChartQuantization.EighthNote: subdivisions = 2; break;
+ case ChartQuantization.SixteenthNote: subdivisions = 4; break;
+ default: throw new ArgumentOutOfRangeException(nameof(quantization));
+ }
+ double step = 60.0 / bpm / subdivisions;
+ return Math.Round(value / step, MidpointRounding.AwayFromZero) * step;
+ }
+
+ private static string PadId(DrumPad pad)
+ {
+ switch (pad)
+ {
+ case DrumPad.Kick: return "kick";
+ case DrumPad.Snare: return "snare";
+ case DrumPad.HiHat: return "hiHat";
+ case DrumPad.Tom1: return "tom1";
+ case DrumPad.Tom2: return "tom2";
+ case DrumPad.FloorTom: return "floorTom";
+ case DrumPad.Crash: return "crash";
+ case DrumPad.Ride: return "ride";
+ default: throw new ArgumentOutOfRangeException(nameof(pad));
+ }
+ }
+
+ private static string ArticulationId(DrumArticulation articulation)
+ {
+ switch (articulation)
+ {
+ case DrumArticulation.Default: return "default";
+ case DrumArticulation.Head: return "head";
+ case DrumArticulation.Rim: return "rim";
+ case DrumArticulation.Bow: return "bow";
+ case DrumArticulation.Edge: return "edge";
+ case DrumArticulation.Bell: return "bell";
+ case DrumArticulation.Closed: return "closed";
+ case DrumArticulation.HalfOpen: return "halfOpen";
+ case DrumArticulation.Open: return "open";
+ case DrumArticulation.Pedal: return "pedal";
+ case DrumArticulation.Choke: return "choke";
+ default: throw new ArgumentOutOfRangeException(nameof(articulation));
+ }
+ }
+
+ private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
+
+ private sealed class ProjectedHit
+ {
+ public ProjectedHit(
+ double timeSeconds,
+ DrumPad pad,
+ int velocity,
+ DrumArticulation articulation,
+ int recordingIndex)
+ {
+ TimeSeconds = timeSeconds;
+ Pad = pad;
+ Velocity = velocity;
+ Articulation = articulation;
+ RecordingIndex = recordingIndex;
+ }
+ public double TimeSeconds { get; }
+ public DrumPad Pad { get; }
+ public int Velocity { get; }
+ public DrumArticulation Articulation { get; }
+ public int RecordingIndex { get; }
+ }
+ }
+
+ public sealed class ChartCreatorExportResult
+ {
+ internal ChartCreatorExportResult(
+ string songId,
+ string folderPath,
+ string chartPath,
+ string manifestPath,
+ string packagePath,
+ string localAudioPath)
+ {
+ SongId = songId;
+ FolderPath = folderPath;
+ ChartPath = chartPath;
+ ManifestPath = manifestPath;
+ PackagePath = packagePath;
+ LocalAudioPath = localAudioPath;
+ }
+ public string SongId { get; }
+ public string FolderPath { get; }
+ public string ChartPath { get; }
+ public string ManifestPath { get; }
+ public string PackagePath { get; }
+ public string LocalAudioPath { get; }
+ public bool IsLocallyPlayable => !string.IsNullOrWhiteSpace(LocalAudioPath);
+ }
+
+ public sealed class ChartCreatorExporter
+ {
+ private static readonly UTF8Encoding Utf8WithoutBom = new UTF8Encoding(false);
+
+ public ChartCreatorExportResult ExportChartOnly(
+ ChartRecordingDraft draft,
+ ChartCreatorMetadata metadata,
+ ChartQuantization quantization,
+ string libraryRoot,
+ DateTimeOffset createdAtUtc,
+ string sourceAudioPath = null)
+ {
+ if (draft == null) throw new ArgumentNullException(nameof(draft));
+ if (draft.Hits.Count == 0)
+ throw new InvalidOperationException("A recorded chart must contain at least one hit.");
+ if (metadata == null) throw new ArgumentNullException(nameof(metadata));
+ if (string.IsNullOrWhiteSpace(libraryRoot)) throw new ArgumentException("Library root is required.", nameof(libraryRoot));
+ if (createdAtUtc.Offset != TimeSpan.Zero) throw new ArgumentException("Creation time must be UTC.", nameof(createdAtUtc));
+
+ string root = Path.GetFullPath(libraryRoot);
+ Directory.CreateDirectory(root);
+ string prefix = metadata.SourceSongId.Length <= 48 ? metadata.SourceSongId : metadata.SourceSongId.Substring(0, 48).TrimEnd('-');
+ string baseId = $"{prefix}-take-{createdAtUtc:yyyyMMdd-HHmmss}";
+ string songId = UniqueSongId(root, baseId);
+ string destination = Path.Combine(root, songId);
+ string packagePath = Path.Combine(root, songId + HtkSongPackageService.Extension);
+ string temporary = Path.Combine(root, $".hitthekit-chart-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(temporary);
+ bool packagePublished = false;
+ try
+ {
+ string chartJson = ChartCreatorJson.Serialize(draft, metadata.Difficulty, metadata.Bpm, quantization);
+ string chartPath = Path.Combine(temporary, "notes.json");
+ string manifestPath = Path.Combine(temporary, "song.json");
+ File.WriteAllText(chartPath, chartJson, Utf8WithoutBom);
+ File.WriteAllText(manifestPath, Manifest(songId, metadata, null), Utf8WithoutBom);
+
+ // First validate and publish the shareable chart-only package. Local audio is
+ // added only to the private folder afterwards and never enters .htksong v1.
+ new ChartLoader().Load(chartJson, metadata.Difficulty);
+ new SongLibraryDiscovery().Parse(File.ReadAllText(manifestPath), temporary, SongLibraryOrigin.UserFolder);
+ new HtkSongPackageService().CreateChartOnlyPackage(temporary, packagePath);
+ packagePublished = true;
+
+ string localAudioPath = null;
+ if (!string.IsNullOrWhiteSpace(sourceAudioPath))
+ {
+ string source = ChartAuthoringAudioImporter.ValidateSource(sourceAudioPath);
+ string audioFileName = "source-audio" + Path.GetExtension(source).ToLowerInvariant();
+ localAudioPath = Path.Combine(temporary, audioFileName);
+ File.Copy(source, localAudioPath, false);
+ File.WriteAllText(manifestPath, Manifest(songId, metadata, audioFileName), Utf8WithoutBom);
+ SongLibraryEntry local = new SongLibraryDiscovery().Parse(
+ File.ReadAllText(manifestPath),
+ temporary,
+ SongLibraryOrigin.UserFolder);
+ if (!local.IsPlayable)
+ throw new InvalidOperationException("The local recorded take did not become playable.");
+ }
+
+ Directory.Move(temporary, destination);
+ return new ChartCreatorExportResult(
+ songId,
+ destination,
+ Path.Combine(destination, "notes.json"),
+ Path.Combine(destination, "song.json"),
+ packagePath,
+ localAudioPath == null ? null : Path.Combine(destination, Path.GetFileName(localAudioPath)));
+ }
+ catch
+ {
+ if (Directory.Exists(temporary)) Directory.Delete(temporary, true);
+ if (Directory.Exists(destination)) Directory.Delete(destination, true);
+ if (packagePublished && File.Exists(packagePath)) File.Delete(packagePath);
+ throw;
+ }
+ }
+
+ private static string UniqueSongId(string root, string baseId)
+ {
+ string value = baseId;
+ int suffix = 2;
+ while (Directory.Exists(Path.Combine(root, value)) ||
+ File.Exists(Path.Combine(root, value + HtkSongPackageService.Extension)))
+ value = $"{baseId}-{suffix++}";
+ ChartCreatorMetadata.ValidateIdentifier(value, nameof(baseId));
+ return value;
+ }
+
+ private static string Manifest(string songId, ChartCreatorMetadata metadata, string audioFileName)
+ {
+ string audio = string.IsNullOrWhiteSpace(audioFileName)
+ ? " \"audioAvailability\": \"missing\",\n"
+ : " \"audioAvailability\": \"available\",\n" +
+ $" \"audioFile\": \"{Escape(audioFileName)}\",\n";
+ return "{\n" +
+ " \"schemaVersion\": 1,\n" +
+ $" \"id\": \"{Escape(songId)}\",\n" +
+ $" \"title\": \"{Escape(metadata.Title + " · Recorded Take")}\",\n" +
+ $" \"artist\": \"{Escape(metadata.Artist)}\",\n" +
+ $" \"bpm\": {metadata.Bpm.ToString("0.#########", CultureInfo.InvariantCulture)},\n" +
+ $" \"bars\": {metadata.Bars},\n" +
+ $" \"beatsPerBar\": {metadata.BeatsPerBar},\n" +
+ " \"difficultyHint\": \"Recorded performance · review before sharing\",\n" +
+ " \"sortOrder\": 0,\n" +
+ audio +
+ " \"chartAvailability\": \"available\",\n" +
+ " \"chartFile\": \"notes.json\"\n" +
+ "}\n";
+ }
+
+ private static string Escape(string value) => value
+ .Replace("\\", "\\\\")
+ .Replace("\"", "\\\"")
+ .Replace("\r", "\\r")
+ .Replace("\n", "\\n");
+ }
+}
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs.meta
new file mode 100644
index 0000000..ea60eb3
--- /dev/null
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f78758e52aef4c5b8c593dc737ae4a4d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs
new file mode 100644
index 0000000..6634fe6
--- /dev/null
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using HitTheKit.Core;
+using HitTheKit.Unity.Input;
+
+namespace HitTheKit.Unity.Gameplay
+{
+ public sealed class EditableChartNote
+ {
+ internal EditableChartNote(
+ int identity,
+ DrumPad pad,
+ int velocity,
+ DrumArticulation articulation,
+ double timeSeconds,
+ DrumInputSource source)
+ {
+ Identity = identity;
+ Pad = pad;
+ Velocity = velocity;
+ Articulation = articulation;
+ TimeSeconds = timeSeconds;
+ Source = source;
+ }
+
+ public int Identity { get; }
+ public DrumPad Pad { get; internal set; }
+ public int Velocity { get; internal set; }
+ public DrumArticulation Articulation { get; internal set; }
+ public double TimeSeconds { get; internal set; }
+ public DrumInputSource Source { get; }
+ }
+
+ public sealed class ChartDraftEditor
+ {
+ private readonly double durationSeconds;
+ private readonly List notes;
+ private readonly ReadOnlyCollection readOnlyNotes;
+ private int nextIdentity;
+
+ public ChartDraftEditor(ChartRecordingDraft draft)
+ {
+ if (draft == null) throw new ArgumentNullException(nameof(draft));
+ durationSeconds = draft.DurationSeconds;
+ notes = new List(draft.Hits.Count);
+ for (int index = 0; index < draft.Hits.Count; index++)
+ {
+ RecordedChartHit hit = draft.Hits[index];
+ notes.Add(new EditableChartNote(
+ nextIdentity++, hit.Pad, hit.Velocity, hit.Articulation, hit.TimeSeconds, hit.Source));
+ }
+ Sort();
+ readOnlyNotes = notes.AsReadOnly();
+ }
+
+ public double DurationSeconds => durationSeconds;
+ public IReadOnlyList Notes => readOnlyNotes;
+
+ public int Update(int index, double timeSeconds, DrumPad pad)
+ {
+ EditableChartNote note = At(index);
+ DrumArticulation articulation = DrumArticulationValidator.IsValid(pad, note.Articulation)
+ ? note.Articulation
+ : DrumArticulation.Default;
+ return Update(index, timeSeconds, pad, note.Velocity, articulation);
+ }
+
+ public int Update(
+ int index,
+ double timeSeconds,
+ DrumPad pad,
+ int velocity,
+ DrumArticulation articulation)
+ {
+ EditableChartNote note = At(index);
+ ValidateTime(timeSeconds);
+ ValidatePad(pad);
+ ValidateVelocity(velocity);
+ DrumArticulationValidator.EnsureValid(pad, articulation);
+ note.TimeSeconds = timeSeconds;
+ note.Pad = pad;
+ note.Velocity = velocity;
+ note.Articulation = articulation;
+ Sort();
+ return notes.IndexOf(note);
+ }
+
+ public int Add(
+ double timeSeconds,
+ DrumPad pad,
+ int velocity = 100,
+ DrumArticulation articulation = DrumArticulation.Default)
+ {
+ ValidateTime(timeSeconds);
+ ValidatePad(pad);
+ ValidateVelocity(velocity);
+ DrumArticulationValidator.EnsureValid(pad, articulation);
+ if (notes.Count >= ChartRecordingSession.MaximumHits)
+ throw new InvalidOperationException($"A chart cannot exceed {ChartRecordingSession.MaximumHits} notes.");
+ var note = new EditableChartNote(
+ nextIdentity++, pad, velocity, articulation, timeSeconds, DrumInputSource.Test);
+ notes.Add(note);
+ Sort();
+ return notes.IndexOf(note);
+ }
+
+ public void Delete(int index) => notes.RemoveAt(ValidatedIndex(index));
+
+ public ChartRecordingDraft BuildDraft()
+ {
+ var hits = new RecordedChartHit[notes.Count];
+ for (int index = 0; index < notes.Count; index++)
+ {
+ EditableChartNote note = notes[index];
+ hits[index] = new RecordedChartHit(
+ new DrumInputEvent(
+ note.Pad,
+ note.Velocity,
+ note.TimeSeconds,
+ note.Source,
+ note.Articulation),
+ index);
+ }
+ return new ChartRecordingDraft(durationSeconds, hits);
+ }
+
+ private EditableChartNote At(int index) => notes[ValidatedIndex(index)];
+
+ private int ValidatedIndex(int index)
+ {
+ if (index < 0 || index >= notes.Count) throw new ArgumentOutOfRangeException(nameof(index));
+ return index;
+ }
+
+ private void ValidateTime(double value)
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value) || value < 0 || value > durationSeconds)
+ throw new ArgumentOutOfRangeException(nameof(value), $"Note time must be between 0 and {durationSeconds:0.###} seconds.");
+ }
+
+ private static void ValidatePad(DrumPad pad)
+ {
+ if (!Enum.IsDefined(typeof(DrumPad), pad)) throw new ArgumentOutOfRangeException(nameof(pad));
+ }
+
+ private static void ValidateVelocity(int velocity)
+ {
+ if (velocity < 1 || velocity > 127) throw new ArgumentOutOfRangeException(nameof(velocity));
+ }
+
+ private void Sort() => notes.Sort((left, right) =>
+ {
+ int byTime = left.TimeSeconds.CompareTo(right.TimeSeconds);
+ return byTime != 0 ? byTime : left.Identity.CompareTo(right.Identity);
+ });
+ }
+}
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs.meta
new file mode 100644
index 0000000..66036f9
--- /dev/null
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a1f6bc7d8a244151b6712fdd0bf0fc77
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs
new file mode 100644
index 0000000..849bd1f
--- /dev/null
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs
@@ -0,0 +1,257 @@
+using System;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace HitTheKit.Unity.Gameplay
+{
+ public sealed class ChartWaveformModel
+ {
+ private readonly float[] peaks;
+
+ public ChartWaveformModel(float[] sourcePeaks, double durationSeconds)
+ {
+ if (sourcePeaks == null || sourcePeaks.Length < 2)
+ throw new ArgumentException("A waveform requires at least two peak values.", nameof(sourcePeaks));
+ if (!IsFinite(durationSeconds) || durationSeconds <= 0)
+ throw new ArgumentOutOfRangeException(nameof(durationSeconds));
+ peaks = new float[sourcePeaks.Length];
+ for (int index = 0; index < peaks.Length; index++)
+ {
+ float value = sourcePeaks[index];
+ if (float.IsNaN(value) || float.IsInfinity(value))
+ throw new ArgumentException("Waveform peaks must be finite.", nameof(sourcePeaks));
+ peaks[index] = Mathf.Clamp01(Mathf.Abs(value));
+ }
+ DurationSeconds = durationSeconds;
+ ResetZoom();
+ }
+
+ public double DurationSeconds { get; }
+ public double ViewStartSeconds { get; private set; }
+ public double ViewEndSeconds { get; private set; }
+ public double SelectedTimeSeconds { get; private set; }
+ public int PeakCount => peaks.Length;
+ public float PeakAt(int index) => peaks[index];
+
+ public double Scrub(double normalizedPosition)
+ {
+ if (!IsFinite(normalizedPosition)) throw new ArgumentOutOfRangeException(nameof(normalizedPosition));
+ double clamped = Math.Max(0, Math.Min(1, normalizedPosition));
+ SelectedTimeSeconds = ViewStartSeconds + (ViewEndSeconds - ViewStartSeconds) * clamped;
+ return SelectedTimeSeconds;
+ }
+
+ public void Select(double timeSeconds)
+ {
+ if (!IsFinite(timeSeconds)) throw new ArgumentOutOfRangeException(nameof(timeSeconds));
+ SelectedTimeSeconds = Math.Max(0, Math.Min(DurationSeconds, timeSeconds));
+ }
+
+ public void Zoom(double factor)
+ {
+ if (!IsFinite(factor) || factor <= 0) throw new ArgumentOutOfRangeException(nameof(factor));
+ double current = ViewEndSeconds - ViewStartSeconds;
+ double minimum = Math.Min(DurationSeconds, 0.5);
+ double next = Math.Max(minimum, Math.Min(DurationSeconds, current / factor));
+ double anchor = SelectedTimeSeconds;
+ double ratio = current > 0 ? (anchor - ViewStartSeconds) / current : 0.5;
+ double start = anchor - next * ratio;
+ start = Math.Max(0, Math.Min(DurationSeconds - next, start));
+ ViewStartSeconds = start;
+ ViewEndSeconds = start + next;
+ }
+
+ public void ResetZoom()
+ {
+ ViewStartSeconds = 0;
+ ViewEndSeconds = DurationSeconds;
+ SelectedTimeSeconds = Math.Max(0, Math.Min(DurationSeconds, SelectedTimeSeconds));
+ }
+
+ public static ChartWaveformModel FromInterleaved(
+ float[] samples,
+ int channels,
+ double durationSeconds,
+ int pointCount = 512)
+ {
+ if (samples == null || samples.Length == 0) throw new ArgumentException("Samples are required.", nameof(samples));
+ if (channels <= 0 || samples.Length % channels != 0) throw new ArgumentOutOfRangeException(nameof(channels));
+ if (pointCount < 2 || pointCount > 4096) throw new ArgumentOutOfRangeException(nameof(pointCount));
+ var result = new float[pointCount];
+ int frames = samples.Length / channels;
+ for (int point = 0; point < pointCount; point++)
+ {
+ int startFrame = (int)((long)point * frames / pointCount);
+ int endFrame = Math.Max(startFrame + 1, (int)((long)(point + 1) * frames / pointCount));
+ endFrame = Math.Min(frames, endFrame);
+ float peak = 0;
+ for (int frame = startFrame; frame < endFrame; frame++)
+ for (int channel = 0; channel < channels; channel++)
+ peak = Math.Max(peak, Math.Abs(samples[frame * channels + channel]));
+ result[point] = peak;
+ }
+ return new ChartWaveformModel(result, durationSeconds);
+ }
+
+ public static ChartWaveformModel FromAudioClip(
+ AudioClip clip,
+ int pointCount = 512,
+ int framesPerRead = 4096)
+ {
+ if (clip == null) throw new ArgumentNullException(nameof(clip));
+ if (clip.samples <= 0 || clip.channels <= 0 || clip.length <= 0)
+ throw new ArgumentException("Audio clip is empty.", nameof(clip));
+ if (pointCount < 2 || pointCount > 4096) throw new ArgumentOutOfRangeException(nameof(pointCount));
+ if (framesPerRead <= 0 || framesPerRead > 32768)
+ throw new ArgumentOutOfRangeException(nameof(framesPerRead));
+
+ int boundedRead = Math.Min(framesPerRead, clip.samples);
+ var buffer = new float[boundedRead * clip.channels];
+ var result = new float[pointCount];
+ for (int point = 0; point < pointCount; point++)
+ {
+ int startFrame = (int)((long)point * clip.samples / pointCount);
+ int endFrame = Math.Max(startFrame + 1, (int)((long)(point + 1) * clip.samples / pointCount));
+ endFrame = Math.Min(clip.samples, endFrame);
+ float peak = 0;
+ for (int offset = startFrame; offset < endFrame; offset += boundedRead)
+ {
+ int frames = Math.Min(boundedRead, endFrame - offset);
+ if (!clip.GetData(buffer, offset))
+ throw new InvalidOperationException("Audio samples could not be read.");
+ int sampleCount = frames * clip.channels;
+ for (int index = 0; index < sampleCount; index++)
+ peak = Math.Max(peak, Math.Abs(buffer[index]));
+ }
+ result[point] = peak;
+ }
+ return new ChartWaveformModel(result, clip.length);
+ }
+
+ private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
+ }
+
+ public sealed class ChartWaveformView : VisualElement
+ {
+ private bool dragging;
+ private ChartWaveformModel model;
+
+ public ChartWaveformView()
+ {
+ focusable = true;
+ generateVisualContent += Draw;
+ RegisterCallback(OnPointerDown);
+ RegisterCallback(OnPointerMove);
+ RegisterCallback(OnPointerUp);
+ RegisterCallback(_ => dragging = false);
+ }
+
+ public event Action Scrubbed;
+ public ChartWaveformModel Model => model;
+
+ public void SetModel(ChartWaveformModel value)
+ {
+ model = value;
+ MarkDirtyRepaint();
+ }
+
+ public void SetSelectedTime(double value)
+ {
+ model?.Select(value);
+ MarkDirtyRepaint();
+ }
+
+ public void Zoom(double factor)
+ {
+ model?.Zoom(factor);
+ MarkDirtyRepaint();
+ }
+
+ public void ResetZoom()
+ {
+ model?.ResetZoom();
+ MarkDirtyRepaint();
+ }
+
+ private void OnPointerDown(PointerDownEvent evt)
+ {
+ if (evt.button != 0 || model == null) return;
+ dragging = true;
+ this.CapturePointer(evt.pointerId);
+ Scrub(evt.localPosition.x);
+ evt.StopPropagation();
+ }
+
+ private void OnPointerMove(PointerMoveEvent evt)
+ {
+ if (!dragging || model == null) return;
+ Scrub(evt.localPosition.x);
+ evt.StopPropagation();
+ }
+
+ private void OnPointerUp(PointerUpEvent evt)
+ {
+ dragging = false;
+ if (this.HasPointerCapture(evt.pointerId)) this.ReleasePointer(evt.pointerId);
+ }
+
+ private void Scrub(float x)
+ {
+ double width = Math.Max(1, contentRect.width);
+ double selected = model.Scrub(x / width);
+ MarkDirtyRepaint();
+ Scrubbed?.Invoke(selected);
+ }
+
+ private void Draw(MeshGenerationContext context)
+ {
+ Painter2D painter = context.painter2D;
+ Rect rect = contentRect;
+ painter.fillColor = new Color(0.01f, 0.04f, 0.075f, 0.98f);
+ painter.BeginPath();
+ painter.MoveTo(new Vector2(0, 0));
+ painter.LineTo(new Vector2(rect.width, 0));
+ painter.LineTo(new Vector2(rect.width, rect.height));
+ painter.LineTo(new Vector2(0, rect.height));
+ painter.ClosePath();
+ painter.Fill();
+ if (model == null || rect.width <= 1 || rect.height <= 1) return;
+
+ float center = rect.height * 0.5f;
+ painter.strokeColor = new Color(0.22f, 0.55f, 0.68f, 0.45f);
+ painter.lineWidth = 1;
+ painter.BeginPath();
+ painter.MoveTo(new Vector2(0, center));
+ painter.LineTo(new Vector2(rect.width, center));
+ painter.Stroke();
+
+ double viewDuration = model.ViewEndSeconds - model.ViewStartSeconds;
+ int first = Math.Max(0, (int)Math.Floor(model.ViewStartSeconds / model.DurationSeconds * (model.PeakCount - 1)));
+ int last = Math.Min(model.PeakCount - 1, (int)Math.Ceiling(model.ViewEndSeconds / model.DurationSeconds * (model.PeakCount - 1)));
+ painter.strokeColor = new Color(0.24f, 0.86f, 1f, 0.9f);
+ painter.lineWidth = Math.Max(1, rect.width / Math.Max(1, last - first + 1));
+ for (int index = first; index <= last; index++)
+ {
+ double time = index / (double)(model.PeakCount - 1) * model.DurationSeconds;
+ float x = (float)((time - model.ViewStartSeconds) / viewDuration * rect.width);
+ float amplitude = model.PeakAt(index) * (rect.height * 0.42f);
+ painter.BeginPath();
+ painter.MoveTo(new Vector2(x, center - amplitude));
+ painter.LineTo(new Vector2(x, center + amplitude));
+ painter.Stroke();
+ }
+
+ if (model.SelectedTimeSeconds >= model.ViewStartSeconds &&
+ model.SelectedTimeSeconds <= model.ViewEndSeconds)
+ {
+ float x = (float)((model.SelectedTimeSeconds - model.ViewStartSeconds) / viewDuration * rect.width);
+ painter.strokeColor = new Color(1f, 0.67f, 0.18f, 1f);
+ painter.lineWidth = 2.5f;
+ painter.BeginPath();
+ painter.MoveTo(new Vector2(x, 0));
+ painter.LineTo(new Vector2(x, rect.height));
+ painter.Stroke();
+ }
+ }
+ }
+}
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs.meta
new file mode 100644
index 0000000..d4d7a02
--- /dev/null
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 3b0d8b53ae8a42c2bfc569bf322e7f35
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs
new file mode 100644
index 0000000..410aad7
--- /dev/null
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Collections.Generic;
+using HitTheKit.Unity.Matching;
+
+namespace HitTheKit.Unity.Gameplay
+{
+ public enum GameplayAutoTempoStatus
+ {
+ Unavailable,
+ Repeat,
+ Advance,
+ Mastered
+ }
+
+ public sealed class GameplayAutoTempoRecommendation
+ {
+ internal GameplayAutoTempoRecommendation(
+ GameplayAutoTempoStatus status,
+ double currentSpeed,
+ double nextSpeed,
+ string message)
+ {
+ Status = status;
+ CurrentSpeed = currentSpeed;
+ NextSpeed = nextSpeed;
+ Message = message ?? throw new ArgumentNullException(nameof(message));
+ }
+
+ public GameplayAutoTempoStatus Status { get; }
+ public double CurrentSpeed { get; }
+ public double NextSpeed { get; }
+ public string Message { get; }
+ public bool CanAdvance => Status == GameplayAutoTempoStatus.Advance;
+ }
+
+ public static class GameplayAutoTempoCoach
+ {
+ public const double MinimumAccuracy = 85.0;
+ public const double MaximumMissRatio = 0.10;
+ public const double MaximumNoMatchRatio = 0.10;
+
+ public static GameplayAutoTempoRecommendation Evaluate(
+ GameplaySessionDefinition session,
+ GameplayScoreSnapshot score,
+ HitMatchingSnapshot matching)
+ {
+ if (score == null) throw new ArgumentNullException(nameof(score));
+ if (matching == null) throw new ArgumentNullException(nameof(matching));
+ return Evaluate(
+ session,
+ score.Accuracy,
+ matching.MissCount,
+ matching.NoMatchCount,
+ matching.TotalNoteCount);
+ }
+
+ public static GameplayAutoTempoRecommendation Evaluate(
+ GameplaySessionDefinition session,
+ double accuracy,
+ int misses,
+ int noMatches,
+ int totalNotes)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (double.IsNaN(accuracy) || double.IsInfinity(accuracy) || accuracy < 0 || accuracy > 100)
+ throw new ArgumentOutOfRangeException(nameof(accuracy));
+ if (misses < 0) throw new ArgumentOutOfRangeException(nameof(misses));
+ if (noMatches < 0) throw new ArgumentOutOfRangeException(nameof(noMatches));
+ if (totalNotes <= 0) throw new ArgumentOutOfRangeException(nameof(totalNotes));
+
+ IReadOnlyList speeds = SpeedsFor(session);
+ if (speeds == null)
+ {
+ return new GameplayAutoTempoRecommendation(
+ GameplayAutoTempoStatus.Unavailable,
+ session.SpeedMultiplier,
+ session.SpeedMultiplier,
+ "AUTO TEMPO DISPONIBILE PER LEZIONI E BRANI DELLA LIBRERIA");
+ }
+
+ int currentIndex = FindSpeed(speeds, session.SpeedMultiplier);
+ if (currentIndex < 0)
+ throw new InvalidOperationException("The current session speed is outside the Auto Tempo progression.");
+
+ double missRatio = misses / (double)totalNotes;
+ double noMatchRatio = noMatches / (double)totalNotes;
+ bool passed = accuracy >= MinimumAccuracy &&
+ missRatio <= MaximumMissRatio &&
+ noMatchRatio <= MaximumNoMatchRatio;
+ if (!passed)
+ {
+ return new GameplayAutoTempoRecommendation(
+ GameplayAutoTempoStatus.Repeat,
+ session.SpeedMultiplier,
+ session.SpeedMultiplier,
+ $"RIPETI {session.SpeedMultiplier:0.##}× · SERVONO {MinimumAccuracy:0}% E POCHI ERRORI");
+ }
+
+ if (currentIndex == speeds.Count - 1)
+ {
+ return new GameplayAutoTempoRecommendation(
+ GameplayAutoTempoStatus.Mastered,
+ session.SpeedMultiplier,
+ session.SpeedMultiplier,
+ "TEMPO OBIETTIVO RAGGIUNTO · 100%");
+ }
+
+ double next = speeds[currentIndex + 1];
+ return new GameplayAutoTempoRecommendation(
+ GameplayAutoTempoStatus.Advance,
+ session.SpeedMultiplier,
+ next,
+ $"PRONTO PER {next:0.##}× · PRECISIONE {accuracy:0.0}%");
+ }
+
+ private static IReadOnlyList SpeedsFor(GameplaySessionDefinition session)
+ {
+ if (session.IsChartCreator) return null;
+ if (session.Kind == GameplaySessionKind.Lesson) return GameplayStudySpeeds.All;
+ if (!string.IsNullOrWhiteSpace(session.SongId)) return GameplaySongSpeeds.All;
+ return null;
+ }
+
+ private static int FindSpeed(IReadOnlyList speeds, double value)
+ {
+ for (int index = 0; index < speeds.Count; index++)
+ if (Math.Abs(speeds[index] - value) < 0.0001) return index;
+ return -1;
+ }
+ }
+}
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs.meta
new file mode 100644
index 0000000..5e9a41d
--- /dev/null
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b31ad33e30d04494a1ad4c9ff350b94e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs
index 4f14249..3b98c37 100644
--- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs
+++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs
@@ -1,6 +1,8 @@
using System;
+using System.Collections;
using System.Collections.Generic;
using System.Globalization;
+using System.IO;
using HitTheKit.Core;
using HitTheKit.Unity.Audio;
using HitTheKit.Unity.Charts;
@@ -19,6 +21,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
private const double HighwayLookAheadSeconds = 4.0;
private const double KitPreparationSeconds = 1.35;
private const float PulseDurationSeconds = 0.18f;
+ private const int PracticeLeadInBeats = 2;
[SerializeField] private UIDocument document;
[SerializeField] private ChartTimelinePrototype chartTimeline;
@@ -51,12 +54,14 @@ public sealed class GameplayHighwayController : MonoBehaviour
private Label environmentTitleLabel;
private Label environmentSubtitleLabel;
private Label currentInputLabel;
+ private Label ghostStatusLabel;
private Label deviceLabel;
private Label keyGuideCymbalsLabel;
private Label keyGuideTomsLabel;
private Label keyGuideSnareHiHatLabel;
private Label keyGuideFloorKickLabel;
private Label impactCueLabel;
+ private Label reactiveStageStatusLabel;
private Button menuButton;
private Button pauseButton;
private Button resumeButton;
@@ -64,6 +69,15 @@ public sealed class GameplayHighwayController : MonoBehaviour
private Button resultRestartButton;
private Button resultMenuButton;
private Button resultApplyCalibrationButton;
+ private Button resultGhostButton;
+ private Button resultPracticeWeakestButton;
+ private Button autoTempoAdvanceButton;
+ private Button practicePreviousSectionButton;
+ private Button practiceNextSectionButton;
+ private Button practiceLoopSectionButton;
+ private Button practiceSetAButton;
+ private Button practiceSetBButton;
+ private Button practiceClearButton;
private VisualElement pauseOverlay;
private VisualElement resultsOverlay;
private VisualElement countdownOverlay;
@@ -75,8 +89,39 @@ public sealed class GameplayHighwayController : MonoBehaviour
private Label resultBreakdownLabel;
private Label resultPracticeLabel;
private Label resultCalibrationLabel;
+ private Label resultErrorMapLabel;
+ private Label autoTempoStatusLabel;
+ private Label practiceSectionLabel;
+ private Label practiceStatusLabel;
+ private VisualElement resultPerformancePanel;
+ private VisualElement chartCreatorResults;
+ private Label chartCreatorSummaryLabel;
+ private Label chartCreatorStatusLabel;
+ private Button chartSaveRawButton;
+ private Button chartSaveEighthButton;
+ private Button chartSaveSixteenthButton;
+ private ListView chartNoteList;
+ private Label chartNoteSelectionLabel;
+ private TextField chartNoteTimeField;
+ private DropdownField chartNotePadField;
+ private TextField chartNoteVelocityField;
+ private DropdownField chartNoteArticulationField;
+ private Button chartNoteAddButton;
+ private Button chartNoteApplyButton;
+ private Button chartNoteDeleteButton;
+ private Label chartNoteStatusLabel;
+ private VisualElement chartWaveformHost;
+ private ChartWaveformView chartWaveformView;
+ private Label chartWaveformTimeLabel;
+ private Button chartWaveformZoomInButton;
+ private Button chartWaveformZoomOutButton;
+ private Button chartWaveformResetButton;
+ private Button chartWaveformPreviewButton;
+ private Button chartWaveformStopButton;
+ private readonly List chartArticulationChoices = new List();
private GameplayHighwaySurface surface;
private GameplayKitSurface kitSurface;
+ private GameplayReactiveStageSurface reactiveStageSurface;
private bool showInstructionalKit;
private HitMatchingPrototype subscribedMatching;
private readonly GameplayScoreTracker scoreTracker = new GameplayScoreTracker();
@@ -84,6 +129,9 @@ public sealed class GameplayHighwayController : MonoBehaviour
private readonly TimingCalibrationAdvisor keyboardCalibration = new TimingCalibrationAdvisor();
private readonly TimingCalibrationAdvisor midiCalibration = new TimingCalibrationAdvisor();
private readonly PracticePerformanceAnalyzer performanceAnalyzer = new PracticePerformanceAnalyzer();
+ private readonly PerformanceGhostReplay ghostReplay = new PerformanceGhostReplay();
+ private PracticeErrorMapAnalyzer errorMapAnalyzer;
+ private PracticeErrorCell weakestPracticeError;
private DrumInputSource lastCalibrationSource = DrumInputSource.Keyboard;
private DrumPad? latestPulsePad;
private bool invalidConfigurationLogged;
@@ -92,13 +140,28 @@ public sealed class GameplayHighwayController : MonoBehaviour
private AudioSource metronomeSource;
private AudioClip metronomeClip;
private bool metronomeScheduled;
+ private bool metronomeSeekedWhilePaused;
private bool resultRecorded;
+ private HitGrade? latestStageGrade;
+ private bool latestStageWrongInput;
+ private float stagePulseDeadline;
+ private GameplayAutoTempoRecommendation autoTempoRecommendation;
+ private bool isChangingTempo;
+ private readonly GameplayPracticeLoop practiceLoop = new GameplayPracticeLoop();
+ private IReadOnlyList practiceSections = Array.Empty();
+ private int selectedPracticeSectionIndex;
+ private ChartRecordingSession chartRecording;
+ private ChartRecordingDraft chartDraft;
+ private ChartDraftEditor chartDraftEditor;
public event Action ThemeChanged;
public GameplayPresentationTheme Theme { get; private set; }
public GameplayHighwaySurface Surface => surface;
public GameplayKitSurface KitSurface => kitSurface;
+ public GameplayReactiveStageSurface ReactiveStageSurface => reactiveStageSurface;
+ public GameplayReactiveStageState ReactiveStageState => reactiveStageSurface?.State ??
+ GameplayReactiveStageCalculator.Calculate(0, null, null, 0, false, false, false);
public bool IsInstructionalKitVisible => showInstructionalKit;
public Texture2D ActiveBackground => BackgroundFor(Theme);
public string EnvironmentTitle => GameplayEnvironmentProfile.For(Theme).Title;
@@ -114,6 +177,15 @@ public sealed class GameplayHighwayController : MonoBehaviour
public GameplaySessionDefinition CurrentSession =>
sessionCoordinator?.Session ?? GameplaySessionContext.Current;
public double CurrentAttemptPracticeSeconds => practiceTimer.CurrentAttemptSeconds;
+ public GameplayPracticeRange ActivePracticeRange => practiceLoop.Range;
+ public IReadOnlyList PracticeSections => practiceSections;
+ public int RecordedChartHitCount => chartRecording?.HitCount ?? chartDraft?.Hits.Count ?? 0;
+ public string LastChartExportPath { get; private set; }
+ public string LastChartPackagePath { get; private set; }
+ public int EditableChartNoteCount => chartDraftEditor?.Notes.Count ?? 0;
+ public PracticeErrorCell WeakestPracticeError => weakestPracticeError;
+ public IReadOnlyList GhostHits => ghostReplay.Ghost;
+ public int CurrentGhostTakeHitCount => ghostReplay.CurrentHitCount;
private void Awake()
{
@@ -141,6 +213,8 @@ private void Start()
BindNavigation();
Subscribe();
InitializeAudioFeedback();
+ InitializePracticeLab();
+ InitializeChartCreator();
SetTheme(CurrentSession.Theme);
RefreshSessionCopy();
}
@@ -150,6 +224,7 @@ private void Update()
HandleShortcuts();
TrackPracticeTime();
TryScheduleMetronome();
+ UpdatePracticeLoop();
UpdatePulseState();
RefreshPresentation();
}
@@ -165,6 +240,7 @@ private void OnDisable()
private void OnDestroy()
{
FlushPracticeTime();
+ songClock?.StopPreview();
ReleaseAudioFeedback();
}
@@ -205,6 +281,7 @@ private void SetTheme(GameplayPresentationTheme theme)
}
surface?.SetTheme(theme);
+ reactiveStageSurface?.SetTheme(theme);
if (impactCueLabel != null)
impactCueLabel.style.top = Length.Percent(environment.StrikeRatio * 100f);
if (kitSurface != null)
@@ -238,9 +315,11 @@ private void BindView()
positionLabel = root.Q