diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml new file mode 100644 index 000000000..97b8f6bf7 --- /dev/null +++ b/.github/workflows/gen2-picture-presentation.yml @@ -0,0 +1,30 @@ +name: Native GSC picture and animation owners +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gen2-picture-${{ github.ref }} + cancel-in-progress: true +jobs: + picture-contract: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - name: Install LuaJIT + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Native picture commands and emitted images + run: | + luajit tests/engine/gen2_picture_presentation.lua + luajit tests/engine/gen2_picture_pixels.lua + luajit tests/engine/gen2_picture_art_import.lua + luajit tests/engine/gen2_picture_art_draw.lua + luajit tests/engine/gen2_battler_rows.lua + luajit tests/engine/gen2_animation_catalog.lua + - name: Existing command, object and background owners + run: | + luajit tests/engine/gen2_object_runtime.lua + luajit tests/engine/gen2_object_motion_pixels.lua + luajit tests/engine/gen2_bg_presenter.lua diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..fbcffd47c --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, Gen1Recomp / BOIS CLUB GAMES +work, other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/README.md b/README.md index 4cd31e7c0..86a86f35c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +Based on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC +(https://github.com/bryanthaboi/gen1recomp). + +GSC BG-effect source provenance and its retained licence are recorded in +[compat/gen1-gsc/provenance.json](compat/gen1-gsc/provenance.json). + # gen2recomp

diff --git a/compat/gen1-gsc/LICENSE.MD b/compat/gen1-gsc/LICENSE.MD new file mode 100644 index 000000000..806424f26 --- /dev/null +++ b/compat/gen1-gsc/LICENSE.MD @@ -0,0 +1,759 @@ +# License + +Copyright (C) 2026 BOIS CLUB GAMES, LLC + +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, version 3 of the License, with the Additional Terms below, which +are added under Section 7 of that License. + +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. + +## Additional Terms (GPLv3 Section 7) + +### 1. Attribution (Section 7(b)) + +Any copy, fork, modified version, or work based on this program, in source or +object form, and for any purpose, must: + +- keep this license file and every copyright and author notice in the source; +- give clear, visible credit to the original project in its README (or + equivalent project page) and in any in-app credits, "about", or title + screen, reading at minimum: + "Based on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC + (https://github.com/bryanthaboi/gen1recomp)"; +- show that same credit, visibly, inside any launcher, front end, menu shell, + or other program used to start or select the game, including any launcher + written to replace the one excluded in term 2. + +Using this program, in whole or in part, for other projects is permitted, as +long as this credit is always given. + +### 2. The Launcher is not licensed under the GPL + +The project launcher (the "Launcher") is NOT part of the program licensed +above. It is proprietary: Copyright (C) 2026 BOIS CLUB GAMES, LLC, all rights +reserved. The Launcher may not be copied, modified, redistributed, or used in +any fork, modified version, or other project, in whole or in part. A fork or +derivative must provide its own launcher, which must carry the credit +required by term 1. + +The Launcher consists of the following files, and any later version of them: + +- `src/import/LauncherView.lua` +- `src/import/LauncherSettings.lua` +- `src/import/OnlinePanel.lua` +- `src/import/CartLabelArt.lua` +- `src/import/CartShape.lua` +- `src/mods/LauncherMods.lua` +- the launcher user interface code in `src/import/RomImporter.lua` (the + ROM import, verification, and cache extraction logic in that file remains + under the GPL) +- `assets/launcher/` and `assets/labels/` + +Distributing the Launcher together with this program is permitted only in +unmodified official releases from BOIS CLUB GAMES, LLC. + +### PortMaster distribution permission + +As an exception to the Launcher distribution restriction above, BOIS CLUB +GAMES, LLC grants permission to distribute the Launcher with this program +through PortMaster, including the PortMaster-MV repository and its release +packages. This permission covers PortMaster packaging and launch scripts; +it does not grant a general right to reuse or modify the proprietary Launcher +in other projects. Retain this license and the attribution required above. + +This exception records the explicit PortMaster permission granted by project +owner bryanthaboi on 6 October 2026. All other terms remain unchanged. + +### 3. Names and marks (Section 7(e)) + +This license grants no rights to use the names, logos, or trademarks of BOIS +CLUB GAMES, LLC or of the project, except as needed to give the credit +required by term 1. + +### 4. Third-party rights + +This project contains no ROM data and no extracted game content. Pokemon and +all related names and content are the property of Nintendo, Creatures Inc., +and GAME FREAK inc. This license covers only the original code and assets of +this project. + +--- + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json new file mode 100644 index 000000000..596614a59 --- /dev/null +++ b/compat/gen1-gsc/provenance.json @@ -0,0 +1,35 @@ +{ + "repository": "https://github.com/bryanthaboi/gen1recomp", + "commit": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "files": { + "src/battle/gen2/BgEffects.lua": "e50650aa3315a2545cf0044fbf226076be0086a63b097ef5d682e2beefb5491e", + "src/battle/gen2/AnimObjects.lua": "7713be1ee3b88284a3399baeb9e81d5c0a98c421990336bec3ebb31e832982f6", + "src/ui/gen2/SpriteAnims.lua": "31c213a712edab13d03f925d3f4cd4f487b2cbd724ac50692f576cd04261ce65", + "tools/rom_manifest_gold.json": "ebfd906ef715c634c12ca168dbf711161385720f02c6e0a63d9481a815550a1e", + "tools/rom_manifest_silver.json": "7575919b843c2addf096ed3db7b37dedb9f4b9c8129d32eb0d9ed6c9e5333f52", + "tools/rom_manifest_crystal.json": "be373e361090d79f413ad4d1e57d28c5ac1aa00650d4fa361289a78ef8397b31", + "LICENSE.MD": "b369ce45656b3d87453541816fac6982b13e09ea0734ab9cfe61cf3faf5e95ed", + "src/ui/gen2/BattleAnimView.lua": "c67613148f14987bdc76d01ace2cd90e89f7d742e4c4a0e95889707f5a9d0e61", + "src/ui/gen2/BattleState.lua": "6789c83e5124701d7ebfb1bdc74389102fa85c02f5ecdb812baf0905bded723c", + "src/ui/gen2/Chrome.lua": "a81231beac5334eae58df0936fed1f4fec90b3d986e8caf8b31b5e522180dc52", + "src/battle/gen2/AnimRunner.lua": "4f31c23a94e14d5796a125237ab205342e6b3b63134915cda67373005536ce71" + }, + "scope": "GSC BG and object state machines, command VM, BG presenter, arithmetic and identifier orders. No launcher, game assets, ROM bytes or player state copied.", + "modifications": [ + "BgEffects arithmetic dependency uses Gen2AnimMath instead of the full reference object pool.", + "A missing jump-table list resolves through edition-specific Gen2BgEffectIds for older native caches.", + "Existing Gen2AnimPlayer dispatches BG/palette commands and steps the pool from its current VM update. Native scene and picture owners consume these registers directly, replacing the legacy SE approximations.", + "BG presenter uses native palette/chrome/canvas owners, supports the real wide surface, preserves transparency and restores graphics state on supplied-panel errors.", + "Battler resize, hide, palette and lifted-row drawing remain attached to native image owners. The genuine object pool and command VM now drive native OAM; complete send-out event sequencing remains separate work.", + "The BG-only paper and clipping subset uses the native indexed palette owner, without requiring the full menu Chrome module on older engines.", + "Full object pool uses the already pinned native Gen2AnimMath sine/cosine subset. Native imported numeric animation tables are converted without inventing ROM programs or changing their operands.", + "Existing native animation player now uses the complete command interpreter and object pool as its sole clock. The native view consumes emitted OAM through the actual loaded GFX dictionary; no interpolated motion, 48-frame age cap or 16-frame drain remains.", + "The native importer preserves the crossed battler-row macro/handler names. A Gen2-only cache revision rejects old generated commands and preserves game saves.", + "Crystal actor copies now distinguish vTiles0 staging from BG publication. Positive imported symbol capabilities preserve GS dummy commands; this intentionally corrects the pinned reference for Transform and Minimize.", + "ROM-derived full Substitute/Minimize boxes use native indexed image/palette/geometry owners. Opposite captures retain actual battler identity with requested box size. No ROM bytes/images are distributed.", + "Picture pins reconcile the actual interpreter stopped field; programs returning without an image opcode cannot retain stale visuals." + ], + "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", + "qualification": "Crystal ROM source f5572ee1: fresh native move IDs/GFX/palette/art import,10880 picture pixels independently checked,2804 cases and355718 frames. Exact primary actor staging/publication at frames8/57 replaces picOverride equality only for two known pinned-reference bugs (Transform/Minimize); all other command/OAM/BG observations remain compared. No generated-cache input. Fork CPU source c50da691 passes27 selected groups including shared native picture/Gen3 Substitute regressions; independent upstream e9ec0554 passes10 targeted groups. Publication changes after these sources are documentation/comments; new broad upstream qualification and any GPU/client/Windows/release acceptance remain separate.", + "historicalObjectQualification": "Actual Crystal ROM command/object/BG/OAM comparison against pinned Gen1Recomp535a5ec0: 251 moves and 23 extra programs, both sides, five parameters plus ball/shake/catch cases, 2804 cases and355718 compared frames, no mismatch or observation-limit case. Source e96a8edc; later publication changes only comments, provenance and cache-gate fixture access. Fork a0474fcd passes7893 checks in19 ROM-free groups; independent upstream7ea2eff1 passes5313 in14. These are CPU observations; the old ce12c485 Metal battle predates this VM. New unchanged-client GPU, all picture-override rendering, full send-out/event integration, Windows and packaged release qualification remain open." +} diff --git a/docs/NATIVE-GEN2-DISPLAY.md b/docs/NATIVE-GEN2-DISPLAY.md new file mode 100644 index 000000000..05e36f705 --- /dev/null +++ b/docs/NATIVE-GEN2-DISPLAY.md @@ -0,0 +1,64 @@ +# Native Gen2 display selection + +Gen2 display selection belongs to `PaletteFX`, the same owner used by the +native tile, sprite and battle renderers. `GbcPalette` exposes the Gen1Recomp +public vocabulary over that state: `gbc`, `dmg`, `classic`, and `custom`. +The generic native ID for DMG remains `og`, preserving existing option files. + +GBC uses the cartridge's indexed palettes. DMG retains the four original +indexed shades as grayscale. Classic uses that same grayscale intermediate +and maps the completed frame through the green ramp once. A custom ramp +replaces indexed palette rows; already-coloured art keeps its existing +true-colour contract. The unrelated Gen1 colourization packs and Gen3 +display behaviour retain their existing paths. + +`setMode`, `cycle(delta)`, `setCustomRamp`, `applyOptions`, `modeLabel`, and +`presentColors` are available through `GbcPalette`. The mode, custom ramp and +byte BGP all share native state. Raw indexed drawing bypasses display/BGP +selection. A custom ramp takes priority over the selected indexed mode, as +in the reference protocol; Classic's final presentation remains a separate +operation. Missing custom ramps fall back to grayscale. + +`PaletteFX.setDisplay(mode, ramp)` applies a selection atomically. It invalidates +tile and animation textures, OBJ images, battle images and cached maps, then +reloads the entered map. The shared `Assets.register` invalidation bus also +notifies external texture producers, including repeated ramps with the same mode +ID. Retained battle image references resolve through their +original palette and crop on the next draw. Reapplying an edited ramp table +also updates its key and invalidates dependent caches. Transient BGP writes do +not enter static atlas cache keys. Mono/custom world bakes retain cave darkness; +battle palette permutations and blackout apply after display selection. + +Native Gen2 options and hotkey 2 use the GBC → DMG → Classic ladder, with a +backward step supported. Cycling clears a selected custom ramp and saved +palette ID. Native `options.colors` and legacy `options.color` record the same +selection. Applying legacy mode plus `options.palette` performs one rebuild. + +`src.render.Palette` supplies the public palette ID/library operations. Mono IDs +are `m:rrggbb`; named IDs are `p:group/name`. An optional authored +`data.gb_palettes` module supplies named entries; no palette catalogue is +invented or extracted by this library. Categories, labels, lookup, swatches, +ramp caching and invalidation use the reference vocabulary. + +## Completed-frame zones and composition + +For native Gen2 gameplay, `render.zones` receives the final display zones once, +after native drawing has settled its palettes. Classic starts with one green +whole-frame zone; other modes start with nil. Consumers can replace, remove or +extend those zones. Each dispatch gets its own list. This final pass uses raw +palette destinations so Classic cannot resolve its own green ramp back to gray. + +The native Gen2 compositor first combines world and UI into one scene. Its +window-menu callback includes a window-resolution drawer when one is supplied; +this offer does not add the separate native window-menu classes or a palette-picker UI. `render.compose` then receives that completed texture in +`sceneCanvas`, `worldCanvas` and `uiCanvas`, with `generation = 2`, the native +renderer and an explicit `game` owner. The render target is different from the +source texture. A consumer taking composition ownership also owns presentation +of the supplied final zones; `Renderer:blitZones` provides the raw zone blit. +Otherwise the engine applies final zones before present/output effects. HUD and +touch controls retain their screen-space placement outside this game scene. + +This is display work toward Gen1 parity. It does not establish complete UI +raster parity, a full unchanged-mod playthrough, save/link acceptance, hardware +performance or full engine parity. Existing upstream/Gen1 licences and the +[Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md) remain applicable. diff --git a/docs/NATIVE-GEN2-PALETTE-REGISTER.md b/docs/NATIVE-GEN2-PALETTE-REGISTER.md new file mode 100644 index 000000000..9f5188eed --- /dev/null +++ b/docs/NATIVE-GEN2-PALETTE-REGISTER.md @@ -0,0 +1,66 @@ +# Native palette register and frame remapping + +The public `src.render.GbcPalette` module resolves to the engine's +`Gen2PaletteDraw`. Its byte-register API shares `PaletteFX`'s native shade map; +there is no second register owned by a compatibility facade. + +## Forward drawing + +`BGP_IDENTITY` is `0xe4`. `bgpShades(byte)` returns the four two-bit fields, +with colour zero first, in a one-based array. `remap(colors, byte)` applies +those indices to a four-row RGB palette. Nil and identity preserve the original +palette table. + +`setBgp(byte)` returns the previous byte. Identity is represented by nil. +Reading or assigning `GbcPalette.bgp` accesses this same native state; native +`PaletteFX.setShadeMap` writes are immediately visible through it. The existing +native frame reset clears it as well. + +`resolve(colors)` selects the display palette without applying the register. +`color`, `use`, `with` and keyed draws apply the current register once. +`useRaw` and `withRaw` bypass display selection and the register. Native +`PaletteFX.sendColors` consumes the same state. The separate palette-upload +optimization uses this owner when present. + +## Already-coloured frames + +`remapTable(bgPalettes, byte, objPalettes, ramp, objRamped)` returns source +colours, destination colours, a live count and an ambiguity count. Tables hold +0–255 RGB triples, padded to `REMAP_MAX` (64) with detached rows. BG palettes +take the explicit byte permutation. OBJ palettes normally retain their colours. +An optional forced ramp supplies BG destinations; selected `objRamped` entries +take its unpermuted colours. + +Shared RGB values can no longer identify the original palette after composition. +The first BG interpretation wins; conflicting destinations increase the ambiguity +count. This is a limitation of remapping RGB frames, not proof of indexed +per-pixel equivalence. Extra entries beyond 64 are omitted with BG slots first. + +`remapUniforms(...)` builds normalized shader inputs, or returns nil for an empty +table. `useRemapUniforms(inputs)` and `useRemap(...)` bind the actual remap shader +and return `true, ambiguityCount` on success. Matching uses squared RGB distance +below `(3/255)^2`; unmatched pixels retain their RGB. Alpha and draw tint are +preserved. Compilation/upload failure returns false, retaining the previous +shader. A failed remap shader can recover when the graphics compiler changes. + +Public uniform uploads are unconditional. Nested `with`, `withRaw` and +`keyedWith` restore both shader identity and prior palette/remap uniforms, +including after a callback or partial upload fails. + +## Verification and remaining display work + +`tests/engine/gen2_palette_register.lua` checks all 256 bytes against independent +field decoding in Red, Gold, Silver and Crystal, native/public register ownership, +BG/OBJ remapping, collisions, forced ramps, bounds, uniform ownership and failures. +`tests/gpu/gen2_palette_register.lua` verifies actual pixels for all 256 bytes +through public and native shaders, including the optimized native upload path, +OBJ preservation, unrelated RGB, alpha and tint. Run `run()` from an isolated +LÖVE host separately on each backend. It requires no cartridge or private mod. + +This register change alone does not establish full Gen1 display parity. See +`NATIVE-GEN2-DISPLAY.md` for the native display-selection and cache/presentation +owners. Full unchanged-mod story/save/link acceptance and wider engine parity +remain separate requirements. + +The public method protocol follows the Gen1Recomp reference interface. Existing +Gen1Recomp and upstream credits and licences remain applicable; see `NOTICE.md`. diff --git a/docs/native-gen2-animation-catalog.md b/docs/native-gen2-animation-catalog.md new file mode 100644 index 000000000..aa00c41ef --- /dev/null +++ b/docs/native-gen2-animation-catalog.md @@ -0,0 +1,41 @@ +# Complete native GSC animation data + +Contributions by Roxas2712. Existing interpreter and identifier provenance +and applicable licences remain in `compat/gen1-gsc/` and `NOTICE.md`. + +The importer previously stored a private flat program per animation index. +The player converted only its selected program into VM rows. Consequently +`AnimRunner.scriptForMove(data.battle_anims, move)` returned nothing and a +runner's data omitted every unselected move and shared call-only program. + +The ROM decoder now retains actual instruction addresses. The importer +publishes the complete native interpreter schema: `moves`, named `ids`, +`scripts`, `scriptOrder`, objects, framesets, OAM sets, GFX and palettes. +Programs use their genuine hexadecimal ROM address, including shared +subroutines and the dummy table entry. Each program appears once; different +bytes claiming the same address are rejected. Branches contain one resolved +target key, without leftover pointer bytes. Registered non-ROM flat test +programs use an explicit `program::` namespace rather than +pretending to have cartridge addresses. + +Native `Gen2AnimPlayer` and its actual runtime/view use that complete imported +table directly. Changing moves changes the entry point and VM state, not the +data owner. Standalone source calls and source edits observe the same pool. +The original source-native `generation=2` schema is accepted directly, +without requiring the fork's private format or legacy index flags. +The previous flat input remains accepted for existing program registrations; +there is still one real interpreter, object pool and battle clock. + +Gen2 cache revision 3 requests reimport of earlier generated animation data. +Saves and unrelated generation cache markers retain their existing contracts. +The import edition supplies the BG-function order independently of whatever +cartridge happens to be selected by a test or loader. + +`tests/engine/gen2_animation_catalog.lua` reads generated ROM bytes through +the actual extractor and player, checks shared and call-only programs, +unselected moves, numeric/dummy entries, source edits and native wait/return +time. The GPU row fixture now also enters through the complete native data +schema. Actual ROM differential and GPU acceptance results belong to their +recorded source revisions; adding this path alone is not full unchanged-mod, +Windows or packaged-release acceptance. No mod or ROM program is edited or +committed. diff --git a/docs/native-gen2-battler-rows.md b/docs/native-gen2-battler-rows.md new file mode 100644 index 000000000..2a636ace5 --- /dev/null +++ b/docs/native-gen2-battler-rows.md @@ -0,0 +1,50 @@ +# Native GSC battler rows + +Contributions by Roxas2712. Existing GSC interpreter and object-drawing +provenance and licences remain in `compat/gen1-gsc/` and `NOTICE.md`. + +The real interpreter already registers the player-head/enemy-feet pseudo +sheets. The renderer previously rejected every loaded entry carrying a +`battler` field, so its genuine OAM entries had no pixels to draw. The frozen +Gen1 reference also skips these entries; VM-state equality alone does not +qualify this drawing path. + +At the existing `battlergfx` command the native player now snapshots the +actual screen-owned indexed picture into one/two tile rows. Enemy feet come +from the bottom of the seven-tile front slot; player head comes from the top +of the six-tile back slot. Five/six-tile native fronts have the cartridge's +one-tile left padding. The VM retains its actual command timing, descriptors, +OAM positions, flips and palettes. No second interpreter or battle state is +introduced. Source image data is shared/read-only where the native shared +decoder is available; older upstream uses its existing owned decoder once +per copy. Copying performs no GPU readback. Repeated drawing does not decode +or upload images. + +OBJ colour zero is removed before palette application, independently of BG +register changes. Custom RGB art keeps its alpha and opts out of indexed +recolouring. Crystal crop descriptors select a single frame. Replacement +art and queued Transform use the existing picture presentation authority; +reading the source never reveals a queued future picture. + +The OAM pass also restores the caller's indexed palette uniforms, including +when drawing throws. Graphics push/pop alone restores the shader reference +but cannot undo uploads to the same shader object. The stronger actual-draw +fixture reproduces 96 uniform mismatches before this palette-scope fix. + +Copied images belong to the native view, keyed weakly by the actual loaded +descriptor. Retaining an old runner retains its copied picture; a restarted +animation cannot substitute its current image into old OAM. View retirement +releases remaining images. No mod, ROM, player save or Gen1 installation is +changed. No extracted ROM pixels are committed. + +Primary source: + +- [Crystal battlergfx copy commands](https://github.com/pret/pokecrystal/blob/master/engine/battle_anims/anim_commands.asm) +- [Native front-picture padding](https://github.com/pret/pokecrystal/blob/master/engine/gfx/load_pics.asm) + +`tests/engine/gen2_battler_rows.lua` drives actual native battles, commands +and OAM, checking copied pixel values, column-major strip addressing, +attacker mirroring, both flips, palette uploads, read-only drawing, retained +runners and failure cleanup in Gold/Silver/Crystal. It also checks native +front sizes and custom RGB/crop input. This is CPU/device-record validation, +not a new GPU, full unchanged-client, Windows or release qualification. diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md new file mode 100644 index 000000000..8efd85550 --- /dev/null +++ b/docs/native-gen2-clock.md @@ -0,0 +1,74 @@ +# Shared native Gen2 RTC + +The Pokégear previously displayed elapsed play time while world palettes, +object time masks and script variables used the host clock. The native +`Gen2Clock` now provides the same saved RTC-base reads to these consumers, +and the native happiness evolution gate. +The existing `src.core.gen2.Clock` name resolves to this native module. +`world.hour()`, `world.minute()` and `world.weekday()` read that same save. + +`setTime(save, hour, minute)` anchors the chosen minute, current game +weekday and calendar date to elapsed RTC seconds. Hours, weekdays and daily +calendar identity cross the same in-game midnight, including after a saved +session has been closed for days. Automatic host DST and time-zone changes do +not add another hour to a clock the player already set. Explicit system clock +adjustments still affect the host RTC; this is not a monotonic stopwatch. + +The legacy `rtc.startMinute`, `rtc.startDay` and `g2DayOffset` projections are +retained for older readers. New writes also store `anchorTime`, `anchorMinute`, +`anchorDay` and `anchorDate` in the same `save.rtc` record. Consumers should use +the native setters/readers instead of mutating these projections directly. +`setWeekday` edits the current game weekday without changing elapsed calendar +age. Editing time reanchors the clock without resetting its current date. + +Old saves without the elapsed anchor retain their original host-hour and +weekday-offset semantics, including their old midnight relationship, until +the clock is explicitly set. Read calls never create or migrate clock data. +Invalid/non-finite writes are rejected. The normal native save codec retains +both formats; elapsed play time is separate. + +`calendarDate(save, now)` and `dateKey(save, now)` give native daily and weekly +services the calendar belonging to that RTC. Daily flags/fruit use and the +lottery now expire on game midnight and the selected game Friday instead of +an unrelated host-calendar boundary. The optional timestamp on clock reads +uses the same snapshot for minute, weekday and calendar queries. + +World palettes, object-load period bits and `VAR_HOUR` share the saved hour; +the normal object-load freeze still applies. An explicit palette-query hour +retains precedence. The existing `world.tod` hook remains a presentation +override and does not rewrite the saved RTC. A raw scene-clock query uses +the current native save, independently of a room's forced palette. +`PokegearMenu:clockParts()` exposes hour/minute/one-based UI weekday from +the live native world or shared saved clock. The built-in card and external +presenters use this same getter, retaining authored card-clock overrides. +This prevents an external presenter from showing its fallback zero clock +while the internal RTC and built-in text are already correct. +AUTO neighbours share the effective clock presentation while the entered +map also follows AUTO. From a pinned room/cave, an AUTO neighbour instead +uses the saved RTC; that room's unused world-TOD hook is not a neighbour +palette override. This keeps explicit native/external hour queries aligned. + +`clock.day_changed` is produced by actual weekday reads, including the live +world clock poll. It reports `day`, `previous` and `reason` (`rollover` or +`set`). A save's first observation is silent. Observations are per save, +reentrant-safe, subscription-aware and retired on mod/edition reload. An +observed offline whole week still produces one change, even when the weekday +number is the same; its calendar date is different. +Native API 1/2 use the existing event dispatch boundary. The native GSC +producer remains generation-scoped. + +The clock card, native script readers, evolution time gate and world methods +are exercised directly in synthetic engine tests, including minute wrap, +weekly rollover, save roundtrip, RTC-versus-play-time display, the native callback +path and actual mod loading. Graphics IO alone is replaced for the clock +card. These checks do not establish a complete original-story clock-setting +sequence, manual DST-setting/check dialogs, battery-backed SRAM RTC +conversion, all daily services or fresh GPU acceptance for this change. +Automatic host DST isolation is covered; the original story clock-setting +screen now has a native implementation described in +[native-gen2-init-clock.md](native-gen2-init-clock.md); fresh original-story/GPU +acceptance and the remaining manual DST lifecycle are separately gated. + +The public contract is compared with the read-only Gen1Recomp reference; +the implementation is native fork code. No VASC implementation, private +assets, game text or ROM-derived clock data is included. diff --git a/docs/native-gen2-init-clock.md b/docs/native-gen2-init-clock.md new file mode 100644 index 000000000..058717d8a --- /dev/null +++ b/docs/native-gen2-init-clock.md @@ -0,0 +1,37 @@ +# Native Gen2 initial clock and weekday selection + +Gold, Silver and Crystal now open their native Oak speech with an +`init_clock` step. This opens `Gen2InitClock` before the professor's welcome +and music. The required hour and minute pickers wrap in both directions, +retain their selection after NO, read the complete time back, and commit to +the shared saved RTC only after the final acknowledgement. Completion resumes +the original Oak parent once. Other Gen2 introductions keep their existing +step list. + +The native `SetDayOfWeek` special uses the same screen in day mode. It opens +on Sunday and remains suspended through selection and confirmation. NO +returns to the chosen value without writing the save or resuming the script; +YES writes through `Gen2Clock.setWeekday` and resumes the original runner once. + +`src.ui.gen2.InitClock` resolves to this actual native screen. Its constructor +accepts mode, save, hour/minute/day, onDone and autoConfirm options. Common +hour/time/response formatting helpers use the native clock labels. Native +TextBox and ChoiceBox own text paging, typing and YES/NO input; the clock +screen owns picker state, transitions, save commitment and optional fades. + +Dialogue is read from the active imported native text bank. Native wrapper +and underscore text spellings are supported; short engine prompts are used +only if an older cache lacks a line. No ROM dialogue, artwork, palette or +coordinate table is embedded in the implementation. The current panel layout +is engine-owned. This is not a claim of exact reference raster/layout helper +parity; the remaining reference drawing/metadata helpers and original clock +art require separate work. + +ROM-free coverage exercises the actual native widgets and stack, wrapped +selection and NO retries, final acknowledgement, save-codec retention, the +real special compiler/ScriptRunner yield/resume, the real Oak step and loaded +native API1/2 clients. These tests do not establish fresh +GPU or complete natural new-game/story acceptance. Manual DST dialogs/checks, +SRAM RTC conversion, broader story/hardware acceptance and full VASC/Gen1 +parity remain open. Private VASC code and protected Gen1/player instances are +unchanged. diff --git a/docs/native-gen2-palette-context.md b/docs/native-gen2-palette-context.md new file mode 100644 index 000000000..bda03ba07 --- /dev/null +++ b/docs/native-gen2-palette-context.md @@ -0,0 +1,54 @@ +# Native Gen2 map palette context + +Native GSC maps store the header's palette nibble in `mapPalette`. External +renderers also read the symbolic `def.palette`, `world.daytime` and +`world.flashUsed` contracts. These read views now expose native facts without +seeding another clock, Flash bit or palette catalogue. + +Map palette names are computed from the current byte. Explicit authored +values and existing record metatables are preserved. Unused native header +slots 5..7 follow AUTO; invalid/missing bytes do not invent a named palette. +The public world's effective daytime reads the actual palette bake state, +including darkness; Flash reads the actual `save.flashLit` field written by +the native script command. Explicit authored Lua fields retain precedence. +This does not implement the separate saved RTC-base protocol or every legacy +world mutation method. + +`PaletteFX.daytimeFor(def, hour, flashUsed)` resolves a known map from its own +header. A neighbouring room stays DAY while the current cave is dark, and an +unentered DARK cave stays dark until Flash is supplied. Metadata-free old +queries retain their prior live-state fallback. The native renderer uses +`gen2RowFor` to distinguish the entered map's actual presentation from a +neighbour's own header and the active clock/Flash presentation state. Native +map entry and Flash update that state; atlas cache keys include the selected +row. Animated overdraw receives the same selected palette as the static bake. +An omitted hour shares the entered world's `world.tod` selection when +that scene follows AUTO. From a pinned room/cave, AUTO neighbours instead +read the saved RTC. Outside an entered context the query reads that RTC +without inheriting a map presentation override. An explicit hour remains authoritative. Mod load/unload and edition +changes retire the entered context, so returning to the launcher cannot +revive a previous world's clock selection. + +`PaletteFX.bgSet` accepts the full native dataset or its published +`gen2Palettes` view. The latter carries a private association with its owner, +so imported special-map rules and roof pairs remain available. A superseded +view cannot combine old tileset rows with new metadata. Colourless caches and +DMG mode still decline colouring. + +Special-map rule ordering and roof selection are shared with `TileRenderer`. +The first matching special rule owns the decision even if its set is missing; +darkness skips specials. Roof replacement copies only the row list and two +middle roof colours, retaining endpoints and the source tileset. Live metadata +edits are observed through the normal native invalidation/rebuild lifecycle. +No ROM colours, dialogue, source art or private mod implementation is bundled. + +Synthetic tests exercise the actual Loader, public module calls, native Flash +writer and `TileRenderer` CPU atlas bake. Owned in-memory pixel/image adapters +replace graphics IO, and baked RGB samples are compared with the public query +for AUTO, forced rows, dark caves, special maps and roofs. These are not GPU +or complete unchanged-VASC gameplay acceptance tests. General legacy +`map.palette` row-hook semantics and saved RTC integration remain separate +parity work; the existing SGB palette-name hook is preserved. + +Original native selection routines retain Cedric's rights; additional +integration is covered by [the Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-picture-art.md b/docs/native-gen2-picture-art.md new file mode 100644 index 000000000..24513d8b4 --- /dev/null +++ b/docs/native-gen2-picture-art.md @@ -0,0 +1,56 @@ +# GSC replacement art and Crystal actor copies + +The native importer reads `MonsterSpriteGFX` and `MinimizePic` from the mounted +ROM. It writes the two Substitute and two Minimize pictures in full 6x6/7x7 +tile boxes, plus the indexed Substitute sheet for the public getter. Cartridge +scratch tiles are column-major; generated PNG tiles are row-major. No ROM +image is committed or published with this contribution. Missing symbols or +invalid byte ranges do not fabricate art. + +Native drawing uses these images through its existing image/palette cache. +The full picture footprint composes with slide, resize and lifted-row owners; +Substitute no longer bypasses those operations through a fixed Gen1 doll draw. +Minimize cannot turn into a Stadium model. The native faint picture retains +its owner. Gen2 backs use the 6x6 box at (16,48), including trainer and grow-in +placement, rather than Gen1's left-padding compensation. Image-specific scale +settings still refer to the underlying Pokemon image. Opposite-orientation +captures use the requested box dimensions with the real battler's palette. + +Where the public `pic`/`drawPic` boundary already exists, the getter keeps its +original indexed-Pokemon contract and the capture paints the current replacement. +The upstream adaptation does not invent those missing methods. `substituteDoll` exposes +the actual indexed 16x32 ROM sheet with cached front/back quads, allowing the +source caller to own its palette operation. + +Crystal differs from Gold/Silver: Transform and Minimize stage actor tiles in +vTiles0; `updateactorpic` later copies them into the actor's BG slot. The pinned +Gen1 reference's immediate Transform and empty Minimize/updateactorpic handlers +missed this distinction. Positive ROM symbols advertise these capabilities; +Gold/Silver retain their direct Transform and dummy $e8/$e9 commands. The same +existing VM owns staging, delays and publication. The native pin reconciler +also reads that VM's actual `stopped` field, including a program returning +without an image opcode. No additional animation or combat clock is added. + +The primary definitions are in [Crystal's commands](https://github.com/pret/pokecrystal/blob/master/engine/battle_anims/anim_commands.asm), +[Crystal's programs](https://github.com/pret/pokecrystal/blob/master/data/moves/animations.asm) +and [Gold/Silver's commands](https://github.com/pret/pokegold/blob/master/engine/battle_anims/anim_commands.asm). +The Gen2-only animation cache revision is now 2 so an existing v1 cache gets +the new art/capabilities on import; other generations and saves retain their +existing contracts. Renamed editions advertise only data their symbols expose. + +ROM-free tests check every decoded pixel, real native drawing, palettes, +requested capture geometry, resize/lifted composition, image-scale settings, +error restoration and native terminal ownership. CPU checks with the supplied +Crystal ROM independently validate 10,880 picture pixels against its bitplanes, +then exercise 2,804 cases and 355,718 frames. Crystal's actor staging is checked +at VM frame 8 and publication at frame 57 against the actual imported programs. +For Transform/Minimize only, picture-override equality with the buggy pinned +reference is replaced by that primary-source oracle; all other command/object, +OAM and BG observations remain compared. No old generated-cache input is used. + +These checks do not establish GPU, unchanged-mod, Windows or release parity. +The native OAM drawer still skips battler-row pseudo-sheets, as does the pinned +reference; that missing renderer path needs separate work. Complete send-out, +identity/EXP/capture/menu events, model/download options, the full native battle +factory/turn API and broader release qualification also remain open. Mods, +protected instances, ROM originals and player saves are unchanged. diff --git a/docs/native-gen2-picture-presentation.md b/docs/native-gen2-picture-presentation.md new file mode 100644 index 000000000..a3d13ea1d --- /dev/null +++ b/docs/native-gen2-picture-presentation.md @@ -0,0 +1,44 @@ +# Native GSC picture-command ownership + +The shared native controller resolves a move before its queued animation runs. +Changing the sprite at that earlier point exposed Transform's copied Pokemon +and Substitute's doll before the ROM command. The existing renderer also read +`substituteHP` despite an explicit `dropsub` override, and ignored Beat Up's +temporary species image. + +`Gen2PicturePresentation` holds only presentation identity. A pin belongs to the +actual battler, saved Pokemon and queued row, then to the actual GSC runner. +Native stats, moves, HP, PP and turn resolution retain their existing owners. +The native queue retires each pin at the real image opcode or completion; no +additional update clock, combat model or authored ROM program is introduced. +Missing, disabled and cancelled animations reconcile immediately. Repeated +transforms commit in queue order; a replaced slot cannot inherit an old pin. +Transformed front animations get their own native entrance runner. + +Both the native picture layer and the existing source-facing captures use the +selected native image. Placement and image-specific scale metadata are measured +from that same selected image. An explicit false override hides the doll while +preserving its combat HP. A newly created substitute appears at `raisesub`. +The imported Beat Up parameter selects a species index, rather than a party +slot; the existing VM's public `slot` field is retained. Its catalog lookup is +cached for a stable override and catalog. + +The picture tests exercise Gold, Silver, Crystal, Prism and Polished Crystal; +the emitted-image comparison covers both sides in the three retail editions. +They run the real native effects, queue and GSC interpreter. Only device draw +calls are recorded. The pixel suite is also runnable against the previous +engine without adding the new helper, so its failures are wrong emitted +images rather than missing modules. CI runs these tests alongside the existing +object and background owner tests. + +The upstream controller has no source-facing `pic`/`drawPic` boundary yet. +Its tests therefore exercise the native picture layer; optional public-capture +assertions run on the fork where that boundary already exists. The upstream +adaptation uses its existing inline queue admission, not a copied fork queue. + +The follow-up [native picture-art implementation](native-gen2-picture-art.md) +adds Minimize/Substitute ROM art, GSC placement/palette/resize composition and +Crystal's staged actor copy. Full per-hit Beat Up combat sequencing, battler-row +OAM drawing, send-out events, natural-move GPU comparisons, Windows and packaged +release validation remain separate work. No supplied mod, protected instance, +ROM or player save is changed. No new release is implied by these CPU tests. diff --git a/docs/native-gen2-pokemon-palettes.md b/docs/native-gen2-pokemon-palettes.md new file mode 100644 index 000000000..d6537cc18 --- /dev/null +++ b/docs/native-gen2-pokemon-palettes.md @@ -0,0 +1,50 @@ +# Native Gen2 Pokemon palette drawing + +`src.world.gen2.Palettes.monColors(data, species, shiny)` reads the existing +native Pokemon/palette records. It accepts a full native dataset, its current +published `gen2Palettes` view, or reference palette-only normal/shiny pairs. +Native species keys resolve to the same native records. Existing dataset +aliases are honoured; this patch does not install another species catalogue. Four detached 0–255 RGB triples are returned; missing or +invalid colours return `nil`. Retired dataset views cannot revive old data. +The built-in `PaletteFX.monPal` uses the same native selection without copying +its source palette. Transformed Pokemon retain their existing precedence. + +`src.render.GbcPalette` now resolves to `src.render.Gen2PaletteDraw`, rather +than the unrelated LCD post-process `src.render.GBCFX`. Its indexed-image +shader recovers the four source grayscale shades and substitutes the supplied +palette while preserving texture alpha and drawing tint. `use`, `with`, +`useKeyed`, `keyedWith`, `useRaw`, `withRaw`, `shader`, `keyedShader`, +`available`, `resolve`, `color` and `clear` are provided. Keyed drawing makes +source shade zero transparent. Normal drawing resolves native `PaletteFX` +display/register state; raw drawing bypasses that resolution. +The public `mode` read follows that native resolution, so external evolution +presenters can preserve already coloured art instead of passing it through a +grayscale-index shader. It does not create another display option. + +Scoped drawing restores both the previous shader object and managed palette +uniforms, including nested calls, callback failures and partial upload errors. +Public calls upload all four rows because callers can modify exposed shader +objects directly. No private renderer or ROM-derived colour catalogue is +embedded in the implementation. + +## Validation and remaining work + +ROM-free suites cover public Loader consumers, Gold/Silver/Crystal species +queries, native source identity, shiny/normal/transformed precedence, authored +changes, detached results, retired views, invalid values and graphics failure +paths. The GPU probe `tests/gpu/gen2_palette_draw.lua` compares actual canvas +pixels for every shade, alpha, tint, keying, native register remap, nested +success/error and foreign shader restoration on each tested backend. + +Real-ROM validation must additionally compare the native palette producer and +public queries with an independent decoder, using actual imported Pokemon +metadata. Those private inputs and outputs are excluded from the repository. +A successful pixel probe is not proof of every unchanged mod presentation or +full gameplay acceptance. + +The complete reference `GbcPalette` API is not yet implemented: its separate +mode/custom-ramp controls and backwards remapping of already coloured frames +still need integration with native display state. The LCD effects API remains +separate. Full colour art, UI layout, original story admission, broader hardware +and complete Gen1/VASC parity remain independent acceptance work. No VASC code +or assets are changed by this feature. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..702c0a97a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -639,7 +639,16 @@ local imageMeta = setmetatable({}, WEAK_KEYS) local function getImage(path, pal, trueColor, crop) if not path then return nil end if trueColor then pal = nil end + local sourcePal, displayKey = pal + if GameVersion.isGen2() and not trueColor and pal then + local display = require("src.render.PaletteFX") + displayKey = display.displayKey() + if not pal.displayResolved then + pal = {name=pal.name, colors=display.displayColors(pal.colors), displayResolved=true} + end + end local key = pal and (path .. "#" .. pal.name) or path + if displayKey then key = key .. "#display:" .. displayKey end if crop then key = key .. "@" .. crop.index end if not imageCache[key] then local img, pad, padL = nil, 0, 0 @@ -693,7 +702,7 @@ local function getImage(path, pal, trueColor, crop) -- crop rides along so fadeImage / grayImage / blackImage re-bake the -- SAME cell of the strip, not the whole strip imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil, - crop = crop } + crop = crop, sourcePal = sourcePal, displayKey = displayKey } end return imageCache[key] end @@ -820,7 +829,7 @@ local function fadeImage(img, bgp) local name = (meta.pal and meta.pal.name or "GB") .. "&" .. bgp[0] .. bgp[1] .. bgp[2] .. bgp[3] return getImage(meta.path, - { name = name, colors = PaletteFX.permute(base, bgp) }, + { name = name, colors = PaletteFX.permute(base, bgp), displayResolved=GameVersion.isGen2() }, nil, meta.crop) end @@ -848,8 +857,12 @@ local function blackImage(data, img) local pack = PaletteFX.pack(data) local colors = pack and pack.palettes and pack.palettes.BLACK if not colors then return img end + if GameVersion.isGen2() then + local ramp = PaletteFX.displayColors(colors) + colors = {ramp[4],ramp[4],ramp[4],ramp[4]} + end local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK" - return getImage(meta.path, { name = name, colors = colors }, nil, meta.crop) + return getImage(meta.path, { name = name, colors = colors, displayResolved=GameVersion.isGen2() }, nil, meta.crop) or img end @@ -862,8 +875,21 @@ local function imagePathOf(img) end -- the image a battler pic actually draws with this frame +-- The native image cache keeps static display bakes separate from transient +-- hardware BGP. Resolve that byte at the actual battle picture draw boundary. +-- At most 256 immutable index maps; no per-picture register-map allocation. +local gen2PicRegisterMaps={} function BattleState:picImage(img) local PaletteFX = require("src.render.PaletteFX") + if GameVersion.isGen2() then + local meta = imageMeta[img] + -- Battlers retain image references across a live option change. Dropping + -- imageCache alone does not replace those pictures or animation cells. + if meta and not meta.trueColor and meta.sourcePal + and meta.displayKey ~= PaletteFX.displayKey() then + img = getImage(meta.path, meta.sourcePal, nil, meta.crop) or img + end + end -- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that -- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone -- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the @@ -878,7 +904,7 @@ function BattleState:picImage(img) -- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync. local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" or PaletteFX.mode == "classic" - if self.grayPics or mono then return grayImage(img) end + if self.grayPics or (mono and not GameVersion.isGen2()) then return grayImage(img) end -- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go -- dark with the HP bars while the blackout text is up (#292). The intro -- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the @@ -891,7 +917,20 @@ function BattleState:picImage(img) if self.blackedOut or (self.introSlide or 0) > 0 then return blackImage(self.data, img) end - return fadeImage(img, self:activeBgp()) + img=fadeImage(img,self:activeBgp()) + if GameVersion.isGen2()then + local byte=PaletteFX.bgp() + if byte then + local map=gen2PicRegisterMaps[byte] + if not map then + local shades=PaletteFX.bgpShades(byte) + map={[0]=shades[1],shades[2],shades[3],shades[4]} + gen2PicRegisterMaps[byte]=map + end + img=fadeImage(img,map) + end + end + return img end -- Gen 1 trainer Pokémon have fixed DVs (engine/battle/core.asm); @@ -1215,10 +1254,14 @@ local function newBattle(game) -- rather than Gen 1's subanimation/frame-block tables), so they get -- their own player. if game.data.battle_anims then - if game.data.battle_anims.gen2 then + if require('src.battle.Gen2AnimData').isGen2(game.data.battle_anims) then local ok, Gen2AnimPlayer = pcall(require, "src.battle.Gen2AnimPlayer") if ok then self.animPlayer = Gen2AnimPlayer.new(game.data) + self.animPlayer.battle = self.battle + self.animPlayer.screen = self + self.animView = self.animPlayer.view + self.anims = self.animPlayer.anims end else local ok, AnimPlayer = pcall(require, "src.battle.AnimPlayer") @@ -2299,7 +2342,7 @@ end -- `isPlayer` follows the poof's convention (Gen2AnimPlayer flips it): false -- for the player's own mon, true for the foe's. function BattleState:shinyAnim(battler, isPlayer) - if not (self.data and self.data.battle_anims and self.data.battle_anims.gen2) then + if not (self.data and require('src.battle.Gen2AnimData').isGen2(self.data.battle_anims)) then return false end local mon = battler and battler.mon @@ -2312,7 +2355,7 @@ end -- before the queue starts running, where animNext's insert point does not -- apply. function BattleState:shinyAnimAppend(battler, isPlayer) - if not (self.data and self.data.battle_anims and self.data.battle_anims.gen2) then + if not (self.data and require('src.battle.Gen2AnimData').isGen2(self.data.battle_anims)) then return false end local mon = battler and battler.mon @@ -2695,6 +2738,7 @@ function BattleState:updateQueue() end if self.animPlaying then self.animPlayer:update() + if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').sync(self) end if self.animPlayer.pollEffects and self.applyAnimEffect then for _, ev in ipairs(self.animPlayer:pollEffects()) do self:applyAnimEffect(ev) @@ -2717,16 +2761,24 @@ function BattleState:updateQueue() -- again. Dig/Fly's charge hide and a genuinely invulnerable battler -- are the two that must survive, same as in resetPicFx. self:restoreHiddenPics() + if GameVersion.isGen2() then + if self:startPendingAfterAnim() then return true end + if not self.animPlayer.keepSprites then self.anim = nil end + end -- the target's hit blink + damage sound follow the animation -- (pokered plays them after PlayMoveAnimation returns) if self.pendingHit then - self:applyHitFx(self.pendingHit) + if not (GameVersion.isGen2() and self.afterAnimPlayed) then + self:applyHitFx(self.pendingHit) + end self.pendingHit = nil end + if GameVersion.isGen2() then self.afterAnimPlayed = nil end end return true end if not self.current then + if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').sync(self) end local item = table.remove(self.queue, 1) if not item then return false end if item.fn then @@ -2843,9 +2895,7 @@ function BattleState:updateQueue() self.waitFrames = math.max(self.waitFrames or 0, self.gen3Anim.total or 0) elseif self.animPlayer then - local ok, started = pcall(self.animPlayer.start, self.animPlayer, - item.anim, item.attackerIsPlayer, - (item.shakes or item.ball) + local opts = (item.shakes or item.ball) and { shakes = item.shakes, ball = item.ball, caught = item.caught, -- Gen 2's toss script branches on the @@ -2853,9 +2903,26 @@ function BattleState:updateQueue() ballIndex = item.ball and (self:itemDef(item.ball) or {}).index or nil, + ballPalette = GameVersion.isGen2() and item.ball + and self:ballPalette((self:itemDef(item.ball) or {}).key or item.ball) + or nil, ballFlicker = item.ball and self:ballFlicker(item.ball) or nil } - or nil) + or nil + local ok, started + if GameVersion.isGen2() then + if mdef and not BALL_ANIMS[item.anim] then + ok, started = pcall(self.animForMove, self, item.anim, + item.attackerIsPlayer and 'player' or 'enemy', + item.animParam, item.effectiveness, item.afterAnim) + else + ok, started = pcall(require('src.battle.Gen2MovePresentation').start, + self, item.anim, item.attackerIsPlayer, opts) + end + else + ok, started = pcall(self.animPlayer.start, self.animPlayer, + item.anim, item.attackerIsPlayer, opts) + end -- a player that reports no data for the move (Gen 2 returns -- false) falls through to the single-sound path below self.animPlaying = ok and started ~= false @@ -2924,11 +2991,13 @@ function BattleState:updateQueue() -- plays each row's MoveSoundTable entry with its pitch/tempo -- modifiers); which side the pic effects target follows the -- attacker (hWhoseTurn) - self.animName = item.anim - self.animAttackerIsPlayer = item.attackerIsPlayer - self:resetPicFx() - for _, ev in ipairs(self.animPlayer:pollEffects()) do - self:applyAnimEffect(ev) -- frame-0 rows (first sound/effect) + if not GameVersion.isGen2() then + self.animName = item.anim + self.animAttackerIsPlayer = item.attackerIsPlayer + self:resetPicFx() + for _, ev in ipairs(self.animPlayer:pollEffects()) do + self:applyAnimEffect(ev) -- frame-0 rows (first sound/effect) + end end self.pendingHit = item.hit else @@ -2955,6 +3024,10 @@ function BattleState:updateQueue() self:applyHitFx(item.hit) end end + if GameVersion.isGen2() then + require('src.battle.Gen2PicturePresentation').admitted(self,item, + self.animPlaying and self.animPlayer.runtime or nil) + end self.current = nil return true end @@ -4109,6 +4182,18 @@ end -- frame if one is up, otherwise the still. Placement is always measured off -- the still (same dimensions, same ground padding), so only the texture -- changes -- see drawBattlerPic and the enemy branch of drawPics. +function BattleState:pictureSprite(battler) + return battler.sprite +end + +function BattleState:pictureScalePath(battler, path) + return path +end + +function BattleState:pictureStill(battler) + return self:picImage(self:pictureSprite(battler)) +end + function BattleState:battlerPic(battler) local still = self:picImage(battler.sprite) local anim = battler and battler.picAnim @@ -6756,6 +6841,21 @@ function BattleState:animationsOn() return not o or o.animations ~= false end +-- GetBallAnimPal / BallColors. Native item IDs retain their registry key; +-- the public item spelling selects the palette on the actual queued throw. +-- Names/order follow Gen1Recomp's Gen2 BattleState and cartridge BallColors. +local GEN2_BALL_PALETTES = { + MASTER_BALL='PAL_BATTLE_OB_GREEN', ULTRA_BALL='PAL_BATTLE_OB_YELLOW', + GREAT_BALL='PAL_BATTLE_OB_BLUE', POKE_BALL='PAL_BATTLE_OB_RED', + HEAVY_BALL='PAL_BATTLE_OB_GRAY', LEVEL_BALL='PAL_BATTLE_OB_BROWN', + LURE_BALL='PAL_BATTLE_OB_BLUE', FAST_BALL='PAL_BATTLE_OB_BLUE', + FRIEND_BALL='PAL_BATTLE_OB_YELLOW', MOON_BALL='PAL_BATTLE_OB_GRAY', + LOVE_BALL='PAL_BATTLE_OB_RED', +} +function BattleState:ballPalette(itemId) + return GEN2_BALL_PALETTES[itemId] or 'PAL_BATTLE_OB_GRAY' +end + -- Drop the announcement-time move-anim row. Gen 1 queues PlayMoveAnimation -- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips -- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment @@ -10288,7 +10388,7 @@ end function BattleState:gen3ShinyBurst(battler) if not battler then return false end if not self:gen3ThrowsSendOut() then return false end - if self.data and self.data.battle_anims and self.data.battle_anims.gen2 then + if self.data and require('src.battle.Gen2AnimData').isGen2(self.data.battle_anims) then return false -- Johto has its own SHINY_ANIM; this is the Gen 3 dataset end local mon = battler.mon @@ -10432,7 +10532,7 @@ function BattleState:ballChain(tossAnim, caught, shakes, ball) -- (anim_checkpokeball -> GetPokeBallWobble) and finally the click or the -- break-out, all from one script. Replaying Gen 1's POOF/HIDEPIC/SHAKE -- rows on top of it would only double up pieces of what it just did. - if self.data and self.data.battle_anims and self.data.battle_anims.gen2 then + if self.data and require('src.battle.Gen2AnimData').isGen2(self.data.battle_anims) then self:animNext(tossAnim, true, shakes, ball, caught) if caught then self:actNext(function() @@ -11613,6 +11713,10 @@ function BattleState:drawBattlerPic(battler, x, y, scale) if not ok then error(err) end end +function BattleState:substituteVisible(battler) + return battler and battler.substituteHP +end + function BattleState:drawBattlerPicAt(battler, x, y, scale) local shakeX, shakeY = self:gen3AnimShake(battler) x, y = x + shakeX, y + shakeY @@ -11648,7 +11752,8 @@ function BattleState:drawBattlerPicAt(battler, x, y, scale) end end local img = self:battlerPic(battler) - if battler.substituteHP and not self:fxFaintActive(battler) + local replacement=GameVersion.isGen2() and require('src.battle.Gen2PictureArt').replacement(self,battler)~=nil + if not replacement and self:substituteVisible(battler) and not self:fxFaintActive(battler) and not battler.fainted then self:drawSubstituteDoll(battler) return @@ -11701,7 +11806,7 @@ function BattleState:drawBattlerPicAt(battler, x, y, scale) end return end - if not battler.gen3Transformed + if not replacement and not battler.gen3Transformed and self:drawStadiumBattlerPic(battler, img, x, y, scale) then return end local tr, tg, tb, tc = self:gen3AnimTint(battler) if tc then @@ -12149,6 +12254,10 @@ end -- top-left x, y and the scale (slide/shake offsets are added by the -- caller). Feet stay at 96 for every scale: y + (h - pad) * scale == 96. function BattleState.backPlacement(w, h, pad, padL, scale) + if GameVersion.isGen2() then + return 16 + math.max(0, math.floor((48-w)/2)) + math.floor(w*(1-scale)/2), + 96-h + math.floor(h*(1-scale)), scale + end return 8 - padL * scale, 96 - (h - pad) * scale, scale end @@ -12259,11 +12368,11 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- ball's timeline: it shrinks into the ball and comes back out of it, -- and once it is inside there is nothing to draw at all. and not ((self:gen3BallFor(self.enemy) or {}).monHidden) then - local img = self:picImage(self.enemy.sprite) + local img = self:pictureStill(self.enemy) love.graphics.setColor(1, 1, 1, 1) local ex, ey = enemyPicXY(img, slide, sx, sy) local s = BattleState.resolveBattleScale(self.data, "front", - imagePathOf(img), self.enemy.mon and self.enemy.mon.species) + self:pictureScalePath(self.enemy,imagePathOf(img)), self.enemy.mon and self.enemy.mon.species) -- EMERALD PLACES ITS POKEMON ON A PLATFORM, and the 7x7 tile slot above -- is the Game Boy's rule for a 160x144 letterbox that has none. On the -- Gen 3 field it left the foe up in the corner well clear of the ground @@ -12386,13 +12495,14 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- air there is no Pokemon on that side to draw; without this it stood -- there at full size waiting for its own ball to arrive. and not ((self:gen3BallFor(self.player) or {}).monHidden) then - local img = self:picImage(self.player.sprite) + local img = self:pictureStill(self.player) love.graphics.setColor(1, 1, 1, 1) -- feet flush on the text box top (y=96), ignoring baked-in padding - local pad = imagePadBottom[self.player.sprite] or 0 - local padL = imagePadLeft[self.player.sprite] or 0 + local handle = self:pictureSprite(self.player) + local pad = imagePadBottom[handle] or 0 + local padL = imagePadLeft[handle] or 0 local s = BattleState.resolveBattleScale(self.data, "back", - imagePathOf(self.player.sprite), + self:pictureScalePath(self.player,imagePathOf(handle)), self.player.mon and self.player.mon.species) local gs = self:growInScale(self.player) local ballOut = self:gen3BallFor(self.player) @@ -12403,9 +12513,14 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- pinned, mod scale composed with the grow stage local eff = s * gs if eff > 0 then - love.graphics.draw(img, - 8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx, - 96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff) + if GameVersion.isGen2() then + local dx,dy=BattleState.backPlacement(img:getWidth(),img:getHeight(),pad,padL,eff) + love.graphics.draw(img,dx+sx,dy+sy,0,eff,eff) + else + love.graphics.draw(img, + 8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx, + 96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff) + end end elseif ballOut then -- COMING OUT OF THE BALL, which is the absorb run backwards: the pic @@ -12415,7 +12530,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) local dx, dy if self:gen3Layout() then dx, dy = Gen3Battle.picPlacement(self, self.player, img, - imagePathOf(self.player.sprite), s) + imagePathOf(handle), s) else dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(), pad, padL, s) @@ -12435,7 +12550,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) love.graphics.setColor(1, 1, 1, 1) elseif self:gen3Layout() then local dx, dy = Gen3Battle.picPlacement(self, self.player, img, - imagePathOf(self.player.sprite), s) + imagePathOf(handle), s) self:drawBattlerPic(self.player, dx + sx, dy + sy, s) else local dx, dy = BattleState.backPlacement(img:getWidth(), @@ -12850,6 +12965,7 @@ function BattleState:drawBattleField() end function BattleState:drawClassic() + if GameVersion.isGen2() then return self:drawScene() end -- AskName: ClearSprites + wild ClearScreenArea -- white field under the -- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top. if self.blankForAskName then @@ -12963,4 +13079,13 @@ function BattleState:drawClassic() end end +require('src.battle.Gen2MovePresentation').install(BattleState) +require('src.battle.Gen2BattleScene').install(BattleState) + +-- Compose after native/source view installers, retaining their real draw owners. +require('src.battle.Gen2BgPictures').install(BattleState) +require('src.battle.Gen2PicturePresentation').install(BattleState) +require('src.battle.Gen2PictureArt').install(BattleState,{load=getImage,palette=monPalette,path=imagePathOf, + source=function(image)return imageMeta[image]end}) + return BattleState diff --git a/src/battle/Gen2AnimCatalog.lua b/src/battle/Gen2AnimCatalog.lua new file mode 100644 index 000000000..a0579ae50 --- /dev/null +++ b/src/battle/Gen2AnimCatalog.lua @@ -0,0 +1,82 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native GSC ROM programs in the actual interpreter's complete data schema. +-- Command/identifier provenance: compat/gen1-gsc; no ROM scripts authored here. +local Data=require('src.battle.Gen2AnimData') +local M={} +local function same(a,b) + if type(a)~=type(b)then return false end + if type(a)~='table'then return a==b end + for k,v in pairs(a)do if not same(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +function M.compile(imported,constants,version) + if Data.isNative(imported)then return imported end + local catalog=Data.tables(imported,constants,version) + catalog.gen2,catalog.generation,catalog.format=true,2,'gsc-native-v1' + catalog.commandFormat=imported.commandFormat + catalog.source=imported.source or (imported.bank + and 'ROM:BattleAnimations + data/battle_anims/*'or 'Registered GSC animation programs') + catalog.bank,catalog.gameVersion=imported.bank,version + catalog.scripts,catalog.scriptOrder,catalog.moves,catalog.ids,catalog.indices={},{},{},{},{} + catalog.misc,catalog.moveAnims,catalog.move,catalog.effects={},{},{},{} + local ids={};for id in pairs(imported.scripts or {})do ids[#ids+1]=id end + table.sort(ids,function(a,b)if type(a)==type(b)then return a genuine GSC runtime data. +-- Identifier/command formats derive from Gen1Recomp535a5ec0 under GPLv3 +-- plus Additional Terms; see compat/gen1-gsc/. No ROM program is invented. +local Ids=require('src.battle.Gen2AnimObjectIds') +local M={} +M.CACHE_REVISION=3 +function M.isNative(anims) + return anims and anims.generation==2 and type(anims.scripts)=='table' + and type(anims.moves)=='table'and type(anims.oamsets)=='table' +end +function M.isGen2(anims) + return anims and (anims.gen2==true or M.isNative(anims)) +end +local function order(constants,key) + return constants and constants[key] or Ids[key] +end +function M.tables(anims,constants,version) + anims=anims or {} + local c={} + for _,key in ipairs({'battleAnimFramesetOrder','battleAnimObjectOrder', + 'battleAnimFuncOrder','battleAnimGfxOrder','battleAnimObPaletteOrder'})do c[key]=order(constants,key)end + c.battleBgEffectOrder=constants and constants.battleBgEffectOrder + or require('src.battle.Gen2BgEffectIds').forVersion(version or anims.gameVersion) + if M.isNative(anims)then return anims,c end + local data={objects={},framesets={},oamsets={},gfx={},palettes=anims.palettes, + pictureArt=anims.pictureArt} + for id,row in pairs(anims.objects or {})do + local name=c.battleAnimObjectOrder[id+1]or id + local native=anims.functions and anims.functions[row.fn] + local fn=constants and constants.battleAnimFuncOrder + and constants.battleAnimFuncOrder[(row.fn or 0)+1] + or Ids.nativeFunctions[native] or native + or c.battleAnimFuncOrder[(row.fn or 0)+1] + data.objects[name]={flags=row.oamFlags,fixY=row.fixY, + frameset=c.battleAnimFramesetOrder[row.frameset+1]or row.frameset, + func=fn,palette=c.battleAnimObPaletteOrder[(row.palette or 0)+1]or row.palette, + gfx=c.battleAnimGfxOrder[(row.gfx or 0)+1]or row.gfx} + end + for id,set in pairs(anims.framesets or {})do + local rows={} + for _,frame in ipairs(set.frames or {})do + rows[#rows+1]=frame.wait and {'wait',frame.duration or 0} + or {'frame',frame.oam,frame.duration or 0, + (frame.xflip and 0x20 or 0)+(frame.yflip and 0x40 or 0)} + end + rows[#rows+1]={set.loop=='restart'and 'restart' + or set.loop=='delete'and 'delete'or 'end'} + data.framesets[c.battleAnimFramesetOrder[id+1]or id]=rows + end + for id,row in pairs(anims.oam or {})do + data.oamsets[id]={vtile=row.tile,sprites=row.sprites} + end + for id,sheet in pairs(anims.gfx or {})do + data.gfx[c.battleAnimGfxOrder[id+1]or id]=sheet + end + return data,c +end + +local ops={delay='wait',ifvarequal='if_var_equal', + ifparamequal='if_param_equal',ifparamand='if_param_and'} +function M.row(inst,target) + local op=ops[inst.op]or inst.op + local row={op} + for _,value in ipairs(inst.args or {})do row[#row+1]=type(value)=='number'and value%256 or value end + if op=='wait'then row[2]=inst.frames or 0 + elseif op=='gfx'then + row={tostring(#(inst.ids or {}))..'gfx'} + for _,id in ipairs(inst.ids or {})do row[#row+1]=id end + elseif op=='jump'or op=='call'or op=='jumpuntil'or op=='ifpmode'then + row={op,target} + elseif op=='loop'or op=='if_var_equal'or op=='if_param_equal'or op=='if_param_and'then + row={op,row[2],target} + end + return row +end +-- Native scripts already contain all reached subroutines in one flat ROM +-- walk. A target remains that walk's instruction index; suffix lists let the +-- GSC interpreter fetch it without synthesizing addresses or instructions. +function M.program(tables,script) + local data={} + for key,value in pairs(tables)do data[key]=value end + data.scripts={} + if not script then return data end + local rows,targets={},{} + targets[script.entry or 1]=true + for i,inst in ipairs(script.code or {})do + local row=M.row(inst,inst.to) + if inst.to then targets[inst.to]=true end + rows[i]=row + end + for start in pairs(targets)do + local suffix={} + for i=start,#rows do suffix[#suffix+1]=rows[i]end + data.scripts[start]=suffix + end + return data,script.entry or 1 +end +return M diff --git a/src/battle/Gen2AnimMath.lua b/src/battle/Gen2AnimMath.lua new file mode 100644 index 000000000..aa4d7f846 --- /dev/null +++ b/src/battle/Gen2AnimMath.lua @@ -0,0 +1,30 @@ +-- Gen1Recomp-derived code: GPLv3 plus Additional Terms; see +-- compat/gen1-gsc/LICENSE.MD and provenance.json. Existing rights retained. +-- GSC byte arithmetic and sine from Gen1Recomp's AnimObjects/SpriteAnims at +-- 535a5ec0. Existing source rights/licences remain under compat/gen1-gsc/LICENSE.MD. +-- This is arithmetic only; the native VM still owns its objects and clock. +local bit = require('bit') +local M = {} +function M.u8(value) return value % 256 end +local function s8(value) + value = value % 256 + return value < 0x80 and value or value - 256 +end +function M.sra(value) return M.u8(math.floor(s8(value) / 2)) end +function M.swap(value) + value = M.u8(value) + return bit.bor(bit.rshift(value,4), bit.band(bit.lshift(value,4),0xf0)) +end +local sine = {} +for i=0,31 do sine[i] = math.floor(math.sin(i*math.pi/32)*256+0.5) end +function M.sine(angle, amplitude) + angle = angle % 64 + local negative = angle >= 32 + if negative then angle = angle - 32 end + local product = (amplitude % 256) * sine[angle] % 0x10000 + local result = math.floor(product / 256) + if negative then result = -result end + return result % 256 +end +function M.cosine(angle, amplitude) return M.sine(angle+0x10,amplitude) end +return M diff --git a/src/battle/Gen2AnimObjectIds.lua b/src/battle/Gen2AnimObjectIds.lua new file mode 100644 index 000000000..15c35121b --- /dev/null +++ b/src/battle/Gen2AnimObjectIds.lua @@ -0,0 +1,600 @@ +-- GSC identifier order from pinned Gen1Recomp535a5ec0 manifests. +-- GPLv3 plus Additional Terms; compat/gen1-gsc/LICENSE.MD. +-- Gold, Silver and Crystal have identical orders for these tables. +local M={} +M.battleAnimFramesetOrder={ + "BATTLE_ANIM_FRAMESET_HIT_BIG", + "BATTLE_ANIM_FRAMESET_HIT", + "BATTLE_ANIM_FRAMESET_HIT_SMALL", + "BATTLE_ANIM_FRAMESET_PUNCH", + "BATTLE_ANIM_FRAMESET_KICK", + "BATTLE_ANIM_FRAMESET_PALM", + "BATTLE_ANIM_FRAMESET_FANG", + "BATTLE_ANIM_FRAMESET_PUNCH_SHAKE", + "BATTLE_ANIM_FRAMESET_BALL_POOF", + "BATTLE_ANIM_FRAMESET_POKE_BALL_1", + "BATTLE_ANIM_FRAMESET_POKE_BALL_2", + "BATTLE_ANIM_FRAMESET_POKE_BALL_3", + "BATTLE_ANIM_FRAMESET_POKE_BALL_4", + "BATTLE_ANIM_FRAMESET_POKE_BALL_5", + "BATTLE_ANIM_FRAMESET_DRAGON_RAGE", + "BATTLE_ANIM_FRAMESET_FLAMETHROWER", + "BATTLE_ANIM_FRAMESET_EMBER", + "BATTLE_ANIM_FRAMESET_BURNED", + "BATTLE_ANIM_FRAMESET_BLIZZARD", + "BATTLE_ANIM_FRAMESET_ICE", + "BATTLE_ANIM_FRAMESET_ICE_BEAM", + "BATTLE_ANIM_FRAMESET_POWDER_SNOW", + "BATTLE_ANIM_FRAMESET_RAZOR_LEAF_1", + "BATTLE_ANIM_FRAMESET_RAZOR_LEAF_2", + "BATTLE_ANIM_FRAMESET_EXPLOSION", + "BATTLE_ANIM_FRAMESET_BIG_ROCK", + "BATTLE_ANIM_FRAMESET_SMALL_ROCK", + "BATTLE_ANIM_FRAMESET_STRENGTH", + "BATTLE_ANIM_FRAMESET_SKULL_CROSSBONE", + "BATTLE_ANIM_FRAMESET_ACID", + "BATTLE_ANIM_FRAMESET_POISON_DROPLET_UNUSED", + "BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE", + "BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE_BURST", + "BATTLE_ANIM_FRAMESET_SMALL_BUBBLE", + "BATTLE_ANIM_FRAMESET_PULSING_BUBBLE", + "BATTLE_ANIM_FRAMESET_SURF", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_2", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_3", + "BATTLE_ANIM_FRAMESET_WATER_GUN_1", + "BATTLE_ANIM_FRAMESET_WATER_GUN_2", + "BATTLE_ANIM_FRAMESET_WATER_GUN_3", + "BATTLE_ANIM_FRAMESET_HYDRO_PUMP", + "BATTLE_ANIM_FRAMESET_POWDER", + "BATTLE_ANIM_FRAMESET_BEAM", + "BATTLE_ANIM_FRAMESET_BEAM_TIP", + "BATTLE_ANIM_FRAMESET_ICE_BUILDUP", + "BATTLE_ANIM_FRAMESET_FROZEN", + "BATTLE_ANIM_FRAMESET_CIRCLING_SPARKLE", + "BATTLE_ANIM_FRAMESET_THUNDER_CENTER", + "BATTLE_ANIM_FRAMESET_THUNDER_LEFT", + "BATTLE_ANIM_FRAMESET_THUNDER_RIGHT", + "BATTLE_ANIM_FRAMESET_THUNDER_WAVE_DISABLE", + "BATTLE_ANIM_FRAMESET_THUNDER_WAVE_EXTRA", + "BATTLE_ANIM_FRAMESET_THUNDERBOLT_SPARKS", + "BATTLE_ANIM_FRAMESET_THUNDERBOLT_CORE", + "BATTLE_ANIM_FRAMESET_THUNDERSHOCK_SPARKS", + "BATTLE_ANIM_FRAMESET_THUNDERSHOCK_CORE", + "BATTLE_ANIM_FRAMESET_CLAMP", + "BATTLE_ANIM_FRAMESET_CLAMP_FLIPPED", + "BATTLE_ANIM_FRAMESET_BITE_1", + "BATTLE_ANIM_FRAMESET_BITE_2", + "BATTLE_ANIM_FRAMESET_CUT_DOWN_LEFT", + "BATTLE_ANIM_FRAMESET_CUT_DOWN_RIGHT", + "BATTLE_ANIM_FRAMESET_CUT_UP_RIGHT", + "BATTLE_ANIM_FRAMESET_CUT_LONG_DOWN_LEFT", + "BATTLE_ANIM_FRAMESET_CUT_LONG_DOWN_RIGHT", + "BATTLE_ANIM_FRAMESET_CHARGE_ORB_1", + "BATTLE_ANIM_FRAMESET_ABSORB_CENTER", + "BATTLE_ANIM_FRAMESET_GUST", + "BATTLE_ANIM_FRAMESET_VINE_WHIP_1", + "BATTLE_ANIM_FRAMESET_VINE_WHIP_2", + "BATTLE_ANIM_FRAMESET_RAZOR_WIND_1", + "BATTLE_ANIM_FRAMESET_RAZOR_WIND_2", + "BATTLE_ANIM_FRAMESET_SONICBOOM_JP", + "BATTLE_ANIM_FRAMESET_WARP", + "BATTLE_ANIM_FRAMESET_CHARGE_ORB_2", + "BATTLE_ANIM_FRAMESET_EGG", + "BATTLE_ANIM_FRAMESET_EGG_WOBBLE", + "BATTLE_ANIM_FRAMESET_EGG_CRACKED_TOP", + "BATTLE_ANIM_FRAMESET_EGG_CRACKED_BOTTOM", + "BATTLE_ANIM_FRAMESET_FOCUS", + "BATTLE_ANIM_FRAMESET_BIND_1", + "BATTLE_ANIM_FRAMESET_BIND_2", + "BATTLE_ANIM_FRAMESET_BIND_3", + "BATTLE_ANIM_FRAMESET_BIND_4", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_1", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_2", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_3", + "BATTLE_ANIM_FRAMESET_SOUND_1", + "BATTLE_ANIM_FRAMESET_SOUND_2", + "BATTLE_ANIM_FRAMESET_SOUND_3", + "BATTLE_ANIM_FRAMESET_WAVE", + "BATTLE_ANIM_FRAMESET_CONFUSE_RAY_1", + "BATTLE_ANIM_FRAMESET_CONFUSE_RAY_2", + "BATTLE_ANIM_FRAMESET_LEER", + "BATTLE_ANIM_FRAMESET_REFLECT", + "BATTLE_ANIM_FRAMESET_CHICK_1", + "BATTLE_ANIM_FRAMESET_CHICK_2", + "BATTLE_ANIM_FRAMESET_AMNESIA_1", + "BATTLE_ANIM_FRAMESET_AMNESIA_2", + "BATTLE_ANIM_FRAMESET_AMNESIA_3", + "BATTLE_ANIM_FRAMESET_ASLEEP", + "BATTLE_ANIM_FRAMESET_DIG_SAND", + "BATTLE_ANIM_FRAMESET_DIG_PILE", + "BATTLE_ANIM_FRAMESET_SAND", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_1", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_2", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_3", + "BATTLE_ANIM_FRAMESET_PARALYZED", + "BATTLE_ANIM_FRAMESET_PARALYZED_FLIPPED", + "BATTLE_ANIM_FRAMESET_HAZE", + "BATTLE_ANIM_FRAMESET_MIST", + "BATTLE_ANIM_FRAMESET_HORN", + "BATTLE_ANIM_FRAMESET_NEEDLE", + "BATTLE_ANIM_FRAMESET_FLOWER", + "BATTLE_ANIM_FRAMESET_BARRAGE_BALL", + "BATTLE_ANIM_FRAMESET_PAY_DAY", + "BATTLE_ANIM_FRAMESET_HEART", + "BATTLE_ANIM_FRAMESET_SPINNING_BONE", + "BATTLE_ANIM_FRAMESET_STAR", + "BATTLE_ANIM_FRAMESET_SPOON", + "BATTLE_ANIM_FRAMESET_SPARKLE", + "BATTLE_ANIM_FRAMESET_SKY_ATTACK", + "BATTLE_ANIM_FRAMESET_LICK", + "BATTLE_ANIM_FRAMESET_WITHDRAW_SHELL", + "BATTLE_ANIM_FRAMESET_SHRINKING_CHARGE_ORB", + "BATTLE_ANIM_FRAMESET_CONVERSION", + "BATTLE_ANIM_FRAMESET_SWORD", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_1", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_2", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_3", + "BATTLE_ANIM_FRAMESET_SEISMIC_TOSS", + "BATTLE_ANIM_FRAMESET_SHARPEN", + "BATTLE_ANIM_FRAMESET_DEFENSE_CURL", + "BATTLE_ANIM_FRAMESET_METRONOME_HAND", + "BATTLE_ANIM_FRAMESET_AGILITY", + "BATTLE_ANIM_FRAMESET_COTTON", + "BATTLE_ANIM_FRAMESET_MILK_BOTTLE", + "BATTLE_ANIM_FRAMESET_SPIKE", + "BATTLE_ANIM_FRAMESET_ANGER_VEIN", + "BATTLE_ANIM_FRAMESET_HEAL_BELL", + "BATTLE_ANIM_FRAMESET_BATON_PASS", + "BATTLE_ANIM_FRAMESET_LOCK_ON_1", + "BATTLE_ANIM_FRAMESET_LOCK_ON_2", + "BATTLE_ANIM_FRAMESET_LOCK_ON_3", + "BATTLE_ANIM_FRAMESET_LOCK_ON_4", + "BATTLE_ANIM_FRAMESET_MIND_READER_1", + "BATTLE_ANIM_FRAMESET_MIND_READER_2", + "BATTLE_ANIM_FRAMESET_MIND_READER_3", + "BATTLE_ANIM_FRAMESET_MIND_READER_4", + "BATTLE_ANIM_FRAMESET_SAFEGUARD", + "BATTLE_ANIM_FRAMESET_MEGAPHONE_UNUSED", + "BATTLE_ANIM_FRAMESET_ITEM_BAG", + "BATTLE_ANIM_FRAMESET_SPIDER_WEB", + "BATTLE_ANIM_FRAMESET_UNUSED_CAKE", + "BATTLE_ANIM_FRAMESET_IMP", + "BATTLE_ANIM_FRAMESET_IMP_FLIPPED", + "BATTLE_ANIM_FRAMESET_CHERUB", + "BATTLE_ANIM_FRAMESET_PENCIL", + "BATTLE_ANIM_FRAMESET_ENCORE_HAND", + "BATTLE_ANIM_FRAMESET_ENCORE_HAND_FLIPPED", + "BATTLE_ANIM_FRAMESET_DESTINY_BOND", + "BATTLE_ANIM_FRAMESET_MORNING_SUN", + "BATTLE_ANIM_FRAMESET_GLIMMER", + "BATTLE_ANIM_FRAMESET_MOONLIGHT", + "BATTLE_ANIM_FRAMESET_CROSS_CHOP_1", + "BATTLE_ANIM_FRAMESET_CROSS_CHOP_2", + "BATTLE_ANIM_FRAMESET_ZAP_CANNON", + "BATTLE_ANIM_FRAMESET_CURSE_NAIL", + "BATTLE_ANIM_FRAMESET_FORESIGHT_SHINE", + "BATTLE_ANIM_FRAMESET_RAPID_SPIN", + "BATTLE_ANIM_FRAMESET_SWAGGER", + "BATTLE_ANIM_FRAMESET_MEAN_LOOK", + "BATTLE_ANIM_FRAMESET_UPSIDE_DOWN_PUNCH", + "BATTLE_ANIM_FRAMESET_GROWING_SPARKLE", + "BATTLE_ANIM_FRAMESET_RAIN", + "BATTLE_ANIM_FRAMESET_PSYCH_UP", + "BATTLE_ANIM_FRAMESET_SPIRAL_UNUSED", + "BATTLE_ANIM_FRAMESET_AEROBLAST", + "BATTLE_ANIM_FRAMESET_SANDSTORM", + "BATTLE_ANIM_FRAMESET_ENEMYFEET_1ROW", + "BATTLE_ANIM_FRAMESET_PLAYERHEAD_1ROW", + "BATTLE_ANIM_FRAMESET_ENEMYFEET_2ROW", + "BATTLE_ANIM_FRAMESET_PLAYERHEAD_2ROW", +} +M.battleAnimObjectOrder={ + "BATTLE_ANIM_OBJ_HIT_BIG_YFIX", + "BATTLE_ANIM_OBJ_HIT_YFIX", + "BATTLE_ANIM_OBJ_HIT_SMALL_YFIX", + "BATTLE_ANIM_OBJ_HIT_BIG", + "BATTLE_ANIM_OBJ_HIT", + "BATTLE_ANIM_OBJ_HIT_SMALL", + "BATTLE_ANIM_OBJ_PUNCH", + "BATTLE_ANIM_OBJ_KICK", + "BATTLE_ANIM_OBJ_PALM", + "BATTLE_ANIM_OBJ_FANG", + "BATTLE_ANIM_OBJ_PUNCH_SHAKE", + "BATTLE_ANIM_OBJ_EMBER", + "BATTLE_ANIM_OBJ_DRAGON_RAGE", + "BATTLE_ANIM_OBJ_FLAMETHROWER", + "BATTLE_ANIM_OBJ_FIRE_SPIN", + "BATTLE_ANIM_OBJ_FIRE_BLAST", + "BATTLE_ANIM_OBJ_BURNED", + "BATTLE_ANIM_OBJ_BLIZZARD", + "BATTLE_ANIM_OBJ_ICE", + "BATTLE_ANIM_OBJ_ICE_BEAM", + "BATTLE_ANIM_OBJ_RAZOR_LEAF", + "BATTLE_ANIM_OBJ_POKE_BALL", + "BATTLE_ANIM_OBJ_POKE_BALL_BLOCKED", + "BATTLE_ANIM_OBJ_EXPLOSION1", + "BATTLE_ANIM_OBJ_EXPLOSION2", + "BATTLE_ANIM_OBJ_ACID", + "BATTLE_ANIM_OBJ_SLUDGE", + "BATTLE_ANIM_OBJ_BETA_BALL_POOF", + "BATTLE_ANIM_OBJ_BALL_POOF", + "BATTLE_ANIM_OBJ_BIG_ROCK", + "BATTLE_ANIM_OBJ_SMALL_ROCK", + "BATTLE_ANIM_OBJ_STRENGTH", + "BATTLE_ANIM_OBJ_SEISMIC_TOSS", + "BATTLE_ANIM_OBJ_BUBBLE", + "BATTLE_ANIM_OBJ_SURF", + "BATTLE_ANIM_OBJ_SING", + "BATTLE_ANIM_OBJ_WATER_GUN", + "BATTLE_ANIM_OBJ_HYDRO_PUMP", + "BATTLE_ANIM_OBJ_POWDER", + "BATTLE_ANIM_OBJ_BEAM", + "BATTLE_ANIM_OBJ_BEAM_TIP", + "BATTLE_ANIM_OBJ_ICE_BUILDUP", + "BATTLE_ANIM_OBJ_FROZEN", + "BATTLE_ANIM_OBJ_MASTER_BALL_SPARKLE", + "BATTLE_ANIM_OBJ_RECOVER", + "BATTLE_ANIM_OBJ_THUNDER_CENTER", + "BATTLE_ANIM_OBJ_THUNDER_LEFT", + "BATTLE_ANIM_OBJ_THUNDER_RIGHT", + "BATTLE_ANIM_OBJ_THUNDER_WAVE", + "BATTLE_ANIM_OBJ_SPARKS_CIRCLE_BIG", + "BATTLE_ANIM_OBJ_THUNDERBOLT_BALL", + "BATTLE_ANIM_OBJ_SPARKS_CIRCLE", + "BATTLE_ANIM_OBJ_THUNDERSHOCK_BALL", + "BATTLE_ANIM_OBJ_CLAMP", + "BATTLE_ANIM_OBJ_BITE", + "BATTLE_ANIM_OBJ_CUT_DOWN_LEFT", + "BATTLE_ANIM_OBJ_CUT_DOWN_RIGHT", + "BATTLE_ANIM_OBJ_CUT_UP_RIGHT", + "BATTLE_ANIM_OBJ_CUT_LONG_DOWN_LEFT", + "BATTLE_ANIM_OBJ_CUT_LONG_DOWN_RIGHT", + "BATTLE_ANIM_OBJ_SOLAR_BEAM_CHARGE", + "BATTLE_ANIM_OBJ_ABSORB_CENTER", + "BATTLE_ANIM_OBJ_GUST", + "BATTLE_ANIM_OBJ_VINE_WHIP1", + "BATTLE_ANIM_OBJ_VINE_WHIP2", + "BATTLE_ANIM_OBJ_RAZOR_WIND1", + "BATTLE_ANIM_OBJ_RAZOR_WIND2", + "BATTLE_ANIM_OBJ_SONICBOOM_JP", + "BATTLE_ANIM_OBJ_WARP", + "BATTLE_ANIM_OBJ_ABSORB", + "BATTLE_ANIM_OBJ_EGG", + "BATTLE_ANIM_OBJ_FOCUS", + "BATTLE_ANIM_OBJ_BIND1", + "BATTLE_ANIM_OBJ_BIND2", + "BATTLE_ANIM_OBJ_LEECH_SEED", + "BATTLE_ANIM_OBJ_SOUND", + "BATTLE_ANIM_OBJ_WAVE", + "BATTLE_ANIM_OBJ_CONFUSE_RAY", + "BATTLE_ANIM_OBJ_LEER", + "BATTLE_ANIM_OBJ_LEER_TIP", + "BATTLE_ANIM_OBJ_SCREEN", + "BATTLE_ANIM_OBJ_HARDEN", + "BATTLE_ANIM_OBJ_CHICK", + "BATTLE_ANIM_OBJ_AMNESIA", + "BATTLE_ANIM_OBJ_ASLEEP", + "BATTLE_ANIM_OBJ_SKULL", + "BATTLE_ANIM_OBJ_DIG_SAND", + "BATTLE_ANIM_OBJ_DIG_PILE", + "BATTLE_ANIM_OBJ_SAND", + "BATTLE_ANIM_OBJ_PARALYZED", + "BATTLE_ANIM_OBJ_STRING_SHOT", + "BATTLE_ANIM_OBJ_HAZE", + "BATTLE_ANIM_OBJ_MIST", + "BATTLE_ANIM_OBJ_SMOG", + "BATTLE_ANIM_OBJ_POISON_GAS", + "BATTLE_ANIM_OBJ_HORN", + "BATTLE_ANIM_OBJ_NEEDLE", + "BATTLE_ANIM_OBJ_PETAL_DANCE", + "BATTLE_ANIM_OBJ_SLUDGE_BOMB", + "BATTLE_ANIM_OBJ_PAY_DAY", + "BATTLE_ANIM_OBJ_SONICBOOM_JP_UNUSED", + "BATTLE_ANIM_OBJ_MIMIC", + "BATTLE_ANIM_OBJ_ATTRACT", + "BATTLE_ANIM_OBJ_BONEMERANG", + "BATTLE_ANIM_OBJ_BONE_CLUB", + "BATTLE_ANIM_OBJ_BONE_RUSH", + "BATTLE_ANIM_OBJ_SWIFT", + "BATTLE_ANIM_OBJ_KINESIS", + "BATTLE_ANIM_OBJ_FLASH", + "BATTLE_ANIM_OBJ_SHINY", + "BATTLE_ANIM_OBJ_SKY_ATTACK", + "BATTLE_ANIM_OBJ_LICK", + "BATTLE_ANIM_OBJ_WITHDRAW", + "BATTLE_ANIM_OBJ_DRAIN", + "BATTLE_ANIM_OBJ_GROWTH", + "BATTLE_ANIM_OBJ_CONVERSION2", + "BATTLE_ANIM_OBJ_SMOKE", + "BATTLE_ANIM_OBJ_SMOKESCREEN", + "BATTLE_ANIM_OBJ_SWORDS_DANCE", + "BATTLE_ANIM_OBJ_SPEED_LINE", + "BATTLE_ANIM_OBJ_SHARPEN", + "BATTLE_ANIM_OBJ_DEFENSE_CURL", + "BATTLE_ANIM_OBJ_METRONOME_HAND", + "BATTLE_ANIM_OBJ_METRONOME_SPARKLE", + "BATTLE_ANIM_OBJ_DISABLE", + "BATTLE_ANIM_OBJ_AGILITY", + "BATTLE_ANIM_OBJ_HEART", + "BATTLE_ANIM_OBJ_FLAME_WHEEL", + "BATTLE_ANIM_OBJ_SACRED_FIRE", + "BATTLE_ANIM_OBJ_COTTON_SPORE", + "BATTLE_ANIM_OBJ_MILK_DRINK", + "BATTLE_ANIM_OBJ_ANGER", + "BATTLE_ANIM_OBJ_HEAL_BELL", + "BATTLE_ANIM_OBJ_HEAL_BELL_NOTE", + "BATTLE_ANIM_OBJ_BATON_PASS", + "BATTLE_ANIM_OBJ_LOCK_ON", + "BATTLE_ANIM_OBJ_MIND_READER", + "BATTLE_ANIM_OBJ_SAFEGUARD", + "BATTLE_ANIM_OBJ_PROTECT", + "BATTLE_ANIM_OBJ_THIEF", + "BATTLE_ANIM_OBJ_OCTAZOOKA", + "BATTLE_ANIM_OBJ_PRESENT", + "BATTLE_ANIM_OBJ_SPIKES", + "BATTLE_ANIM_OBJ_POWDER_SNOW", + "BATTLE_ANIM_OBJ_DRAGONBREATH", + "BATTLE_ANIM_OBJ_CONVERSION", + "BATTLE_ANIM_OBJ_SPIDER_WEB", + "BATTLE_ANIM_OBJ_CAKE_UNUSED", + "BATTLE_ANIM_OBJ_NIGHTMARE", + "BATTLE_ANIM_OBJ_IN_NIGHTMARE", + "BATTLE_ANIM_OBJ_LOVELY_KISS", + "BATTLE_ANIM_OBJ_SWEET_KISS", + "BATTLE_ANIM_OBJ_SKETCH", + "BATTLE_ANIM_OBJ_ENCORE_HAND", + "BATTLE_ANIM_OBJ_ENCORE_STAR", + "BATTLE_ANIM_OBJ_DESTINY_BOND", + "BATTLE_ANIM_OBJ_MORNING_SUN", + "BATTLE_ANIM_OBJ_GLIMMER", + "BATTLE_ANIM_OBJ_MOONLIGHT", + "BATTLE_ANIM_OBJ_HIDDEN_POWER", + "BATTLE_ANIM_OBJ_CROSS_CHOP1", + "BATTLE_ANIM_OBJ_CROSS_CHOP2", + "BATTLE_ANIM_OBJ_SANDSTORM", + "BATTLE_ANIM_OBJ_ZAP_CANNON", + "BATTLE_ANIM_OBJ_SPITE", + "BATTLE_ANIM_OBJ_CURSE", + "BATTLE_ANIM_OBJ_PERISH_SONG", + "BATTLE_ANIM_OBJ_FORESIGHT", + "BATTLE_ANIM_OBJ_RAPID_SPIN", + "BATTLE_ANIM_OBJ_SWAGGER", + "BATTLE_ANIM_OBJ_BELLY_DRUM_HAND", + "BATTLE_ANIM_OBJ_BELLY_DRUM_NOTE", + "BATTLE_ANIM_OBJ_MEAN_LOOK", + "BATTLE_ANIM_OBJ_BETA_PURSUIT", + "BATTLE_ANIM_OBJ_SHOOTING_SPARKLE", + "BATTLE_ANIM_OBJ_RAIN", + "BATTLE_ANIM_OBJ_B0", + "BATTLE_ANIM_OBJ_PSYCH_UP", + "BATTLE_ANIM_OBJ_ANCIENTPOWER", + "BATTLE_ANIM_OBJ_AEROBLAST", + "BATTLE_ANIM_OBJ_SHADOW_BALL", + "BATTLE_ANIM_OBJ_ROCK_SMASH", + "BATTLE_ANIM_OBJ_FLOWER", + "BATTLE_ANIM_OBJ_COTTON", + "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", + "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", + "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", +} +M.battleAnimFuncOrder={ + "BATTLE_ANIM_FUNC_NULL", + "BATTLE_ANIM_FUNC_USER_TO_TARGET", + "BATTLE_ANIM_FUNC_USER_TO_TARGET_DISAPPEAR", + "BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE", + "BATTLE_ANIM_FUNC_WAVE_TO_TARGET", + "BATTLE_ANIM_FUNC_THROW_TO_TARGET", + "BATTLE_ANIM_FUNC_THROW_TO_TARGET_DISAPPEAR", + "BATTLE_ANIM_FUNC_DROP", + "BATTLE_ANIM_FUNC_USER_TO_TARGET_SPIN", + "BATTLE_ANIM_FUNC_SHAKE", + "BATTLE_ANIM_FUNC_FIRE_BLAST", + "BATTLE_ANIM_FUNC_RAZOR_LEAF", + "BATTLE_ANIM_FUNC_BUBBLE", + "BATTLE_ANIM_FUNC_SURF", + "BATTLE_ANIM_FUNC_SING", + "BATTLE_ANIM_FUNC_WATER_GUN", + "BATTLE_ANIM_FUNC_EMBER", + "BATTLE_ANIM_FUNC_POWDER", + "BATTLE_ANIM_FUNC_POKEBALL", + "BATTLE_ANIM_FUNC_POKEBALL_BLOCKED", + "BATTLE_ANIM_FUNC_RECOVER", + "BATTLE_ANIM_FUNC_THUNDER_WAVE", + "BATTLE_ANIM_FUNC_CLAMP_ENCORE", + "BATTLE_ANIM_FUNC_BITE", + "BATTLE_ANIM_FUNC_SOLAR_BEAM", + "BATTLE_ANIM_FUNC_GUST", + "BATTLE_ANIM_FUNC_RAZOR_WIND", + "BATTLE_ANIM_FUNC_KICK", + "BATTLE_ANIM_FUNC_ABSORB", + "BATTLE_ANIM_FUNC_EGG", + "BATTLE_ANIM_FUNC_MOVE_UP", + "BATTLE_ANIM_FUNC_WRAP", + "BATTLE_ANIM_FUNC_LEECH_SEED", + "BATTLE_ANIM_FUNC_SOUND", + "BATTLE_ANIM_FUNC_CONFUSE_RAY", + "BATTLE_ANIM_FUNC_DIZZY", + "BATTLE_ANIM_FUNC_AMNESIA", + "BATTLE_ANIM_FUNC_FLOAT_UP", + "BATTLE_ANIM_FUNC_DIG", + "BATTLE_ANIM_FUNC_STRING", + "BATTLE_ANIM_FUNC_PARALYZED", + "BATTLE_ANIM_FUNC_SPIRAL_DESCENT", + "BATTLE_ANIM_FUNC_POISON_GAS", + "BATTLE_ANIM_FUNC_HORN", + "BATTLE_ANIM_FUNC_NEEDLE", + "BATTLE_ANIM_FUNC_PETAL_DANCE", + "BATTLE_ANIM_FUNC_THIEF_PAYDAY", + "BATTLE_ANIM_FUNC_ABSORB_CIRCLE", + "BATTLE_ANIM_FUNC_BONEMERANG", + "BATTLE_ANIM_FUNC_SHINY", + "BATTLE_ANIM_FUNC_SKY_ATTACK", + "BATTLE_ANIM_FUNC_GROWTH_SWORDS_DANCE", + "BATTLE_ANIM_FUNC_SMOKE_FLAME_WHEEL", + "BATTLE_ANIM_FUNC_PRESENT_SMOKESCREEN", + "BATTLE_ANIM_FUNC_STRENGTH_SEISMIC_TOSS", + "BATTLE_ANIM_FUNC_SPEED_LINE", + "BATTLE_ANIM_FUNC_SLUDGE", + "BATTLE_ANIM_FUNC_METRONOME_HAND", + "BATTLE_ANIM_FUNC_METRONOME_SPARKLE_SKETCH", + "BATTLE_ANIM_FUNC_AGILITY", + "BATTLE_ANIM_FUNC_SACRED_FIRE", + "BATTLE_ANIM_FUNC_SAFEGUARD_PROTECT", + "BATTLE_ANIM_FUNC_LOCK_ON_MIND_READER", + "BATTLE_ANIM_FUNC_SPIKES", + "BATTLE_ANIM_FUNC_HEAL_BELL_NOTES", + "BATTLE_ANIM_FUNC_BATON_PASS", + "BATTLE_ANIM_FUNC_CONVERSION", + "BATTLE_ANIM_FUNC_ENCORE_BELLY_DRUM", + "BATTLE_ANIM_FUNC_SWAGGER_MORNING_SUN", + "BATTLE_ANIM_FUNC_HIDDEN_POWER", + "BATTLE_ANIM_FUNC_CURSE", + "BATTLE_ANIM_FUNC_PERISH_SONG", + "BATTLE_ANIM_FUNC_RAPID_SPIN", + "BATTLE_ANIM_FUNC_BETA_PURSUIT", + "BATTLE_ANIM_FUNC_RAIN_SANDSTORM", + "BATTLE_ANIM_FUNC_BATTLE_ANIM_OBJ_B0", + "BATTLE_ANIM_FUNC_PSYCH_UP", + "BATTLE_ANIM_FUNC_ANCIENT_POWER", + "BATTLE_ANIM_FUNC_ROCK_SMASH", + "BATTLE_ANIM_FUNC_COTTON", +} +M.battleAnimGfxOrder={ + "BATTLE_ANIM_GFX_NONE", + "BATTLE_ANIM_GFX_HIT", + "BATTLE_ANIM_GFX_CUT", + "BATTLE_ANIM_GFX_FIRE", + "BATTLE_ANIM_GFX_WATER", + "BATTLE_ANIM_GFX_LIGHTNING", + "BATTLE_ANIM_GFX_PLANT", + "BATTLE_ANIM_GFX_SMOKE", + "BATTLE_ANIM_GFX_EXPLOSION", + "BATTLE_ANIM_GFX_ROCKS", + "BATTLE_ANIM_GFX_ICE", + "BATTLE_ANIM_GFX_POKE_BALL", + "BATTLE_ANIM_GFX_POISON", + "BATTLE_ANIM_GFX_BUBBLE", + "BATTLE_ANIM_GFX_NOISE", + "BATTLE_ANIM_GFX_POWDER", + "BATTLE_ANIM_GFX_BEAM", + "BATTLE_ANIM_GFX_SPEED", + "BATTLE_ANIM_GFX_CHARGE", + "BATTLE_ANIM_GFX_WIND", + "BATTLE_ANIM_GFX_WHIP", + "BATTLE_ANIM_GFX_EGG", + "BATTLE_ANIM_GFX_ROPE", + "BATTLE_ANIM_GFX_PSYCHIC", + "BATTLE_ANIM_GFX_REFLECT", + "BATTLE_ANIM_GFX_STATUS", + "BATTLE_ANIM_GFX_SAND", + "BATTLE_ANIM_GFX_WEB", + "BATTLE_ANIM_GFX_HAZE", + "BATTLE_ANIM_GFX_HORN", + "BATTLE_ANIM_GFX_FLOWER", + "BATTLE_ANIM_GFX_MISC", + "BATTLE_ANIM_GFX_SKY_ATTACK", + "BATTLE_ANIM_GFX_GLOBE", + "BATTLE_ANIM_GFX_SHAPES", + "BATTLE_ANIM_GFX_OBJECTS", + "BATTLE_ANIM_GFX_SHINE", + "BATTLE_ANIM_GFX_ANGELS", + "BATTLE_ANIM_GFX_WAVE", + "BATTLE_ANIM_GFX_AEROBLAST", + "BATTLE_ANIM_GFX_PLAYERHEAD", + "BATTLE_ANIM_GFX_ENEMYFEET", +} +M.nativeFunctions={ + ["Null"]="BATTLE_ANIM_FUNC_NULL", + ["MoveFromUserToTarget"]="BATTLE_ANIM_FUNC_USER_TO_TARGET", + ["MoveFromUserToTargetAndDisappear"]="BATTLE_ANIM_FUNC_USER_TO_TARGET_DISAPPEAR", + ["MoveInCircle"]="BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE", + ["MoveWaveToTarget"]="BATTLE_ANIM_FUNC_WAVE_TO_TARGET", + ["ThrowFromUserToTarget"]="BATTLE_ANIM_FUNC_THROW_TO_TARGET", + ["ThrowFromUserToTargetAndDisappear"]="BATTLE_ANIM_FUNC_THROW_TO_TARGET_DISAPPEAR", + ["Drop"]="BATTLE_ANIM_FUNC_DROP", + ["MoveFromUserToTargetSpinAround"]="BATTLE_ANIM_FUNC_USER_TO_TARGET_SPIN", + ["Shake"]="BATTLE_ANIM_FUNC_SHAKE", + ["FireBlast"]="BATTLE_ANIM_FUNC_FIRE_BLAST", + ["RazorLeaf"]="BATTLE_ANIM_FUNC_RAZOR_LEAF", + ["Bubble"]="BATTLE_ANIM_FUNC_BUBBLE", + ["Surf"]="BATTLE_ANIM_FUNC_SURF", + ["Sing"]="BATTLE_ANIM_FUNC_SING", + ["WaterGun"]="BATTLE_ANIM_FUNC_WATER_GUN", + ["Ember"]="BATTLE_ANIM_FUNC_EMBER", + ["Powder"]="BATTLE_ANIM_FUNC_POWDER", + ["PokeBall"]="BATTLE_ANIM_FUNC_POKEBALL", + ["PokeBallBlocked"]="BATTLE_ANIM_FUNC_POKEBALL_BLOCKED", + ["Recover"]="BATTLE_ANIM_FUNC_RECOVER", + ["ThunderWave"]="BATTLE_ANIM_FUNC_THUNDER_WAVE", + ["Clamp_Encore"]="BATTLE_ANIM_FUNC_CLAMP_ENCORE", + ["Bite"]="BATTLE_ANIM_FUNC_BITE", + ["SolarBeam"]="BATTLE_ANIM_FUNC_SOLAR_BEAM", + ["Gust"]="BATTLE_ANIM_FUNC_GUST", + ["RazorWind"]="BATTLE_ANIM_FUNC_RAZOR_WIND", + ["Kick"]="BATTLE_ANIM_FUNC_KICK", + ["Absorb"]="BATTLE_ANIM_FUNC_ABSORB", + ["Egg"]="BATTLE_ANIM_FUNC_EGG", + ["MoveUp"]="BATTLE_ANIM_FUNC_MOVE_UP", + ["Wrap"]="BATTLE_ANIM_FUNC_WRAP", + ["LeechSeed"]="BATTLE_ANIM_FUNC_LEECH_SEED", + ["Sound"]="BATTLE_ANIM_FUNC_SOUND", + ["ConfuseRay"]="BATTLE_ANIM_FUNC_CONFUSE_RAY", + ["Dizzy"]="BATTLE_ANIM_FUNC_DIZZY", + ["Amnesia"]="BATTLE_ANIM_FUNC_AMNESIA", + ["FloatUp"]="BATTLE_ANIM_FUNC_FLOAT_UP", + ["Dig"]="BATTLE_ANIM_FUNC_DIG", + ["String"]="BATTLE_ANIM_FUNC_STRING", + ["Paralyzed"]="BATTLE_ANIM_FUNC_PARALYZED", + ["SpiralDescent"]="BATTLE_ANIM_FUNC_SPIRAL_DESCENT", + ["PoisonGas"]="BATTLE_ANIM_FUNC_POISON_GAS", + ["Horn"]="BATTLE_ANIM_FUNC_HORN", + ["Needle"]="BATTLE_ANIM_FUNC_NEEDLE", + ["PetalDance"]="BATTLE_ANIM_FUNC_PETAL_DANCE", + ["ThiefPayday"]="BATTLE_ANIM_FUNC_THIEF_PAYDAY", + ["AbsorbCircle"]="BATTLE_ANIM_FUNC_ABSORB_CIRCLE", + ["Bonemerang"]="BATTLE_ANIM_FUNC_BONEMERANG", + ["Shiny"]="BATTLE_ANIM_FUNC_SHINY", + ["SkyAttack"]="BATTLE_ANIM_FUNC_SKY_ATTACK", + ["GrowthSwordsDance"]="BATTLE_ANIM_FUNC_GROWTH_SWORDS_DANCE", + ["SmokeFlameWheel"]="BATTLE_ANIM_FUNC_SMOKE_FLAME_WHEEL", + ["PresentSmokescreen"]="BATTLE_ANIM_FUNC_PRESENT_SMOKESCREEN", + ["StrengthSeismicToss"]="BATTLE_ANIM_FUNC_STRENGTH_SEISMIC_TOSS", + ["SpeedLine"]="BATTLE_ANIM_FUNC_SPEED_LINE", + ["Sludge"]="BATTLE_ANIM_FUNC_SLUDGE", + ["MetronomeHand"]="BATTLE_ANIM_FUNC_METRONOME_HAND", + ["MetronomeSparkleSketch"]="BATTLE_ANIM_FUNC_METRONOME_SPARKLE_SKETCH", + ["Agility"]="BATTLE_ANIM_FUNC_AGILITY", + ["SacredFire"]="BATTLE_ANIM_FUNC_SACRED_FIRE", + ["SafeguardProtect"]="BATTLE_ANIM_FUNC_SAFEGUARD_PROTECT", + ["LockOnMindReader"]="BATTLE_ANIM_FUNC_LOCK_ON_MIND_READER", + ["Spikes"]="BATTLE_ANIM_FUNC_SPIKES", + ["HealBellNotes"]="BATTLE_ANIM_FUNC_HEAL_BELL_NOTES", + ["BatonPass"]="BATTLE_ANIM_FUNC_BATON_PASS", + ["Conversion"]="BATTLE_ANIM_FUNC_CONVERSION", + ["EncoreBellyDrum"]="BATTLE_ANIM_FUNC_ENCORE_BELLY_DRUM", + ["SwaggerMorningSun"]="BATTLE_ANIM_FUNC_SWAGGER_MORNING_SUN", + ["HiddenPower"]="BATTLE_ANIM_FUNC_HIDDEN_POWER", + ["Curse"]="BATTLE_ANIM_FUNC_CURSE", + ["PerishSong"]="BATTLE_ANIM_FUNC_PERISH_SONG", + ["RapidSpin"]="BATTLE_ANIM_FUNC_RAPID_SPIN", + ["BetaPursuit"]="BATTLE_ANIM_FUNC_BETA_PURSUIT", + ["RainSandstorm"]="BATTLE_ANIM_FUNC_RAIN_SANDSTORM", + ["AnimObjB0"]="BATTLE_ANIM_FUNC_BATTLE_ANIM_OBJ_B0", + ["PsychUp"]="BATTLE_ANIM_FUNC_PSYCH_UP", + ["AncientPower"]="BATTLE_ANIM_FUNC_ANCIENT_POWER", + ["RockSmash"]="BATTLE_ANIM_FUNC_ROCK_SMASH", + ["Cotton"]="BATTLE_ANIM_FUNC_COTTON", +} +M.battleAnimObPaletteOrder={ + "PAL_BATTLE_OB_ENEMY", + "PAL_BATTLE_OB_PLAYER", + "PAL_BATTLE_OB_GRAY", + "PAL_BATTLE_OB_YELLOW", + "PAL_BATTLE_OB_RED", + "PAL_BATTLE_OB_GREEN", + "PAL_BATTLE_OB_BLUE", + "PAL_BATTLE_OB_BROWN", +} +return M diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index 2a5075488..0117e5b04 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -1,172 +1,38 @@ --- Copyright (c) 2026 Cedric. All rights reserved. --- Source-available under the Gen2Recomped License (see LICENSE.md): you may --- read, build and privately modify this file; you may not redistribute it or --- use it commercially. Cartridge-derived data is excluded and is not the --- copyright holder's to license. - --- Gen 2 battle-animation player. --- --- GSC's animations are a bytecode VM, not Gen 1's subanimation/frame-block --- tables, so this is a separate player from src/battle/AnimPlayer.lua rather --- than another code path inside it. The extractor --- (RomExtractorGen2:gen2BattleAnims) hands over: --- --- scripts[animId] = { entry = , code = { , ... } } --- objects[id] = { oamFlags, fixY, frameset, fn, palette, gfx } --- framesets[id] = { frames = { { oam, duration, xflip, yflip } }, --- loop = "hold" | "restart" } --- oam[id] = { tile = , --- sprites = { { y, x, tile, attr } } } --- gfx[id] = { tiles, cols, image } --- palettes[i] = { {r,g,b}, {r,g,b}, {r,g,b}, {r,g,b} } --- moveAnims[MOVE] = animId --- --- The engine's own object `function`s (~200 hand-written asm routines that --- push objects along arcs, spin them, track the target and eventually --- delete them) are not portable as data. Objects here therefore hold the --- position their script spawned them at and simply run their real frameset, --- which reproduces the correct sprites, palettes, placement and timing for --- every move while motion paths read as static bursts. --- --- Coordinates follow the hardware: an object's x/y are OAM coordinates, so --- screen space is (x - 8, y - 16). InitBattleAnimBuffer (33:$49F9) mirrors --- an object for the enemy's side when its oamFlags bit 0 is set: --- x' = 180 - x --- y' = fixY == $FF and 40 + y or fixY - y --- xOffset' = -xOffset - -local Gen2AnimPlayer = {} -Gen2AnimPlayer.__index = Gen2AnimPlayer - -local MAX_OBJECTS = 10 -- wActiveAnimObjects holds ten slots -local MAX_STEPS_PER_FRAME = 256 -- guard against a script looping forever -local TAIL_FRAMES = 16 -- let the last objects finish after `ret` -local MAX_OBJECT_AGE = 48 -- see tickObjects -local TRAVEL_FRAMES = 20 -- how long a *ToTarget function takes - --- DoBattleAnimFrame's 80 object functions are hand-written asm, so the port --- reads their names out of the manifest (BATTLE_ANIM_FUNC_*) and sorts them --- into the motion archetypes below. This is an approximation of the paths, --- not a recompilation of them: what it buys is that every object actually --- travels the way its routine travels instead of sitting where the script --- spawned it. Anchors are OAM coordinates: an animation is written from the --- player's side, with the user around (64, 96) and the target pic around --- (136, 48); InitBattleAnimBuffer's mirror maps those to the enemy's side as --- x' = 180 - x and y' = 40 + y. -local TARGET_ANCHOR = { x = 136, y = 48 } -local TARGET_ANCHOR_MIRRORED = { x = 44, y = 88 } -local USER_ANCHOR = { x = 64, y = 96 } -local USER_ANCHOR_MIRRORED = { x = 116, y = 136 } - --- name -> archetype. "gone" suffixes delete the object when it arrives, --- matching the routines whose names end in Disappear. -local MOTION = { - Null = "static", LockOnMindReader = "static", AnimObjB0 = "static", - Sound = "static", - - MoveFromUserToTarget = "toTarget", - MoveFromUserToTargetSpinAround = "toTarget", - MoveFromUserToTargetAndDisappear = "toTargetGone", - Kick = "toTargetGone", SpeedLine = "toTargetGone", - Bite = "toTarget", Horn = "toTarget", Needle = "toTarget", - WaterGun = "toTarget", Surf = "toTarget", String = "toTarget", - SkyAttack = "toTarget", RockSmash = "toTarget", BetaPursuit = "toTarget", - PresentSmokescreen = "toTarget", Clamp_Encore = "toTarget", - Wrap = "toTarget", Dig = "toTarget", - - MoveWaveToTarget = "wave", RazorLeaf = "wave", Gust = "wave", - RazorWind = "wave", PetalDance = "wave", Sing = "wave", - PoisonGas = "wave", Powder = "wave", Cotton = "wave", - - ThrowFromUserToTarget = "arc", PokeBall = "arc", PokeBallBlocked = "arc", - ThrowFromUserToTargetAndDisappear = "arcGone", - Ember = "arc", FireBlast = "arc", SacredFire = "arc", - SmokeFlameWheel = "arc", Sludge = "arc", Egg = "arc", - LeechSeed = "arc", Spikes = "arc", ThiefPayday = "arc", - Bonemerang = "arc", StrengthSeismicToss = "arc", BatonPass = "arc", - - Absorb = "toUser", AbsorbCircle = "circle", Bubble = "toUser", - - MoveInCircle = "circle", ConfuseRay = "circle", Dizzy = "circle", - RapidSpin = "circle", SafeguardProtect = "circle", Conversion = "circle", - HiddenPower = "circle", SolarBeam = "circle", Shiny = "circle", - MetronomeSparkleSketch = "circle", - - MoveUp = "rise", FloatUp = "rise", Recover = "rise", Amnesia = "rise", - GrowthSwordsDance = "rise", HealBellNotes = "rise", PerishSong = "rise", - PsychUp = "rise", AncientPower = "rise", SwaggerMorningSun = "rise", - Agility = "rise", - - Drop = "fall", Curse = "fall", RainSandstorm = "fall", - SpiralDescent = "spiral", - - Shake = "shake", ThunderWave = "shake", Paralyzed = "shake", - EncoreBellyDrum = "shake", MetronomeHand = "shake", -} - -local function shade(value) - return { value, value, value } -end - -local DEFAULT_PALETTE = { - shade(1), shade(0.66), shade(0.33), shade(0), -} - --- Lua 5.1 has no bitwise operators -local function band(a, b) - local result, bit = 0, 1 - for _ = 1, 8 do - if a % 2 == 1 and b % 2 == 1 then result = result + bit end - a, b, bit = math.floor(a / 2), math.floor(b / 2), bit * 2 - end - return result -end - -local function bxor(a, b) - local result, bit = 0, 1 - for _ = 1, 8 do - if a % 2 ~= b % 2 then result = result + bit end - a, b, bit = math.floor(a / 2), math.floor(b / 2), bit * 2 +-- Copyright (c) 2026 Cedric. Existing source rights retained; see NOTICE.md. +-- Native player integration of the Gen1Recomp GSC runtime under GPLv3 plus +-- Additional Terms; compat/gen1-gsc/LICENSE.MD and provenance.json. +-- One real command VM owns BG, ten object structs and forty OAM entries. +local Runtime=require('src.battle.gen2.AnimRunner') +local Data=require('src.battle.Gen2AnimData') +local Gen2AnimPlayer={} +Gen2AnimPlayer.__index=function(self,key) + local method=Gen2AnimPlayer[key] + if method~=nil then return method end + local runner=rawget(self,'runtime') + if runner then + if key=='done'then return runner.stopped end + if key=='frames'or key=='bg'or key=='objects'or key=='keepSprites'then return runner[key]end end - return result end - function Gen2AnimPlayer.new(data) - local anims = data and data.battle_anims or {} - return setmetatable({ - data = data, - anims = anims, - images = {}, - quads = {}, - warned = {}, - objects = {}, - effects = {}, - -- WideBattle reads these two off the Gen 1 player; keep them defined - steps = {}, - stepIndex = 1, - done = true, - }, Gen2AnimPlayer) + local anims=data and data.battle_anims or {} + require('src.battle.Gen2AnimationIds').install(anims) + local tables,constants=Data.tables(anims,data and data.constants) + local runner=Runtime.new({data=tables,constants=constants}) + runner.stopped=true + runner.retire=function()end + local self=setmetatable({data=data,anims=anims,warned={},effects={},runtime=runner},Gen2AnimPlayer) + self.view=require('src.battle.Gen2AnimView').new(tables,data) + return self end - function Gen2AnimPlayer:release() - for _, img in pairs(self.images) do - if img and img.release then pcall(img.release, img) end - end - self.images, self.quads = {}, {} + if self.view then self.view:release();self.view=nil end end - -function Gen2AnimPlayer:warnOnce(key, fmt, ...) - if self.warned[key] then return end - self.warned[key] = true - print(string.format(fmt, ...)) +function Gen2AnimPlayer:warnOnce(key,fmt,...) + if self.warned[key]then return end + self.warned[key]=true + print(string.format(fmt,...)) end - --- BattleState drives the ball toss and the send-out with Gen 1's animation --- ids (TossBallAnimation's TOSS/POOF/HIDEPIC/SHAKE chain). Gen 2 keeps the --- whole toss in one script -- ANIM_THROW_POKE_BALL reads wPokeBallAnimData --- through anim_checkpokeball and wobbles or breaks out by itself -- so the --- toss row maps onto it and the chain's remaining rows have nothing of their --- own to play. Without this nothing resolved and no ball was ever thrown. local GEN1_BALL_ANIMS = { TOSS_ANIM = "throwPokeBall", GREATTOSS_ANIM = "throwPokeBall", @@ -190,7 +56,18 @@ local GEN1_BALL_ANIMS = { -- Resolve a move id (or one of the misc animation names) to a script. function Gen2AnimPlayer:scriptFor(moveId) local anims = self.anims + if Data.isNative(anims)then + local key=anims.moves and anims.moves[moveId]or anims.ids and anims.ids[moveId] + if not key then + local alias=GEN1_BALL_ANIMS[moveId] + if alias==false then return nil end + key=anims.misc and anims.misc[alias or moveId] + end + if not key and type(moveId)=='number'then key=anims.indices and anims.indices[moveId]end + return key and anims.scripts and anims.scripts[key]and key or nil + end local id = anims.moveAnims and anims.moveAnims[moveId] + or anims.ids and anims.ids[moveId] if not id then local alias = GEN1_BALL_ANIMS[moveId] if alias == false then return nil end @@ -236,132 +113,104 @@ local function ballParam(self, opts) or DEFAULT_BALL_ITEM end -function Gen2AnimPlayer:start(moveId, attackerIsPlayer, opts) - self.objects = {} - self.effects = {} - self.callStack = {} - self.vars = { var = 0, param = 0, loop = nil } - self.wait = 0 - self.tail = 0 - self.ranFrames = 0 - self.wobbles = 0 - self.keepSprites = false - self.keptSprites = nil - -- Gen 1 queues its send-out poof with attackerIsPlayer = false, because - -- TossBallAnimation's flag names the animation's ATTACKER and the poof has - -- to land on the player's own pic (the toss row passes true, for the ball - -- reaching the foe). Gen 2's BattleAnim_SendOutMon is already authored on - -- the sender's side, so reading that flag straight through mirrored the - -- player's own release onto the wild mon. - -- SHINY_ANIM is the same script on the same side as the send-out it - -- follows, so it takes the same flag flip. - if moveId == "POOF_ANIM" or moveId == "SHINY_ANIM" then - attackerIsPlayer = not attackerIsPlayer - end - self.enemySide = not attackerIsPlayer - self.opts = opts - self.moveId = moveId - self.loops = {} - self.bgSides = {} - self.script = self:scriptFor(moveId) - if not self.script then - if GEN1_BALL_ANIMS[moveId] == nil then - self:warnOnce("anim:" .. tostring(moveId), - "Gen2AnimPlayer: no animation data for move %s", - tostring(moveId)) - end - self.done = true - return false - end - if GEN1_BALL_ANIMS[moveId] == "throwPokeBall" then - self.vars.param = ballParam(self, opts) - elseif moveId == "SHINY_ANIM" then - self.vars.param = 1 -- wBattleAnimParam = 1 -> .Shiny - end - -- objects the script addresses by index outlive the port's usual object - -- bound: on hardware they only retire when their asm function says so, - -- and the ball has to sit through four 48-frame wobbles - self.objIndex = 0 - self.scriptObjs = {} - for _, inst in ipairs(self.script.code or {}) do - if inst.op == "incobj" or inst.op == "setobj" then - self.scriptObjs[inst.args[1]] = true - end - end - self.pc = self.script.entry - self.done = false - return true +function Gen2AnimPlayer:start(moveId,attackerIsPlayer,opts) + if self.presentation then self.presentation.retire()end + self.presentation=nil + self.effects={} + self.keptSprites=nil + self.opts=opts or {} + self.moveId=moveId + if moveId=='POOF_ANIM'or moveId=='SHINY_ANIM'then attackerIsPlayer=not attackerIsPlayer end + self.enemySide=not attackerIsPlayer + self.script=self:scriptFor(moveId) + self.presentationId=moveId + local alias=GEN1_BALL_ANIMS[moveId] + local canonical=alias=='throwPokeBall'and 'ANIM_THROW_POKE_BALL' + or alias=='sendOutMon'and 'ANIM_SEND_OUT_MON' + if self.script and canonical and self:scriptFor(canonical)==self.script then self.presentationId=canonical end + local param=self.opts.param or 0 + if alias=='throwPokeBall'then param=ballParam(self,self.opts) + elseif moveId=='SHINY_ANIM'then param=1 end + local tables,constants=Data.tables(self.anims,self.data and self.data.constants) + local program,entry + if Data.isNative(self.anims)then program,entry=tables,self.script + else program,entry=Data.program(tables,self.script)end + local data,events,options=self.data,self.effects,self.opts + local wobbles=0 + local runner=Runtime.new({data=program,constants=constants, + battleTurn=self.enemySide and 1 or 0,animId=self.presentationId, + param=param,ballPalette=options.ballPalette,sgb=options.sgb, + flying=options.flying, + hooks={ + battlerGfx=function(runner,front,back) + self.view:loadBattlerRows(self.screen,front,back) + end, + pokeballWobble=function() + wobbles=wobbles+1 + return wobbles<=(options.shakes or 0)and 0 or options.caught and 1 or 2 + end, + sound=function(_,panning,duration,id) + local Sound=require('src.core.Sound') + local audio=data and data.audio + local name=audio and audio.sfxIndex and audio.sfxIndex[tostring(id)] + if name and audio.sfx and audio.sfx[name]then return Sound.play(data,name)end + local def=data and data.moves and data.moves[moveId] + if def and def.anim then return Sound.playMove(data,def.anim)end + end, + cry=function(side,pitch,length) + events[#events+1]={cry=true,side=side,pitch=pitch,length=length} + end, + }}) + runner:start(entry) + if not self.script then runner.stopped=true end + -- A restarted native player creates a new VM. Old public runners already + -- own their old BG/objects/OAM and have no pointer back to this player. + runner.retire=function()end + self.runtime=runner + self.view.data=program + self.vars=setmetatable({}, { + __index=function(_,key)return runner[key]end, + __newindex=function(_,key,value)runner[key]=value end, + }) + if not self.script and GEN1_BALL_ANIMS[moveId]==nil then + self:warnOnce('anim:'..tostring(moveId),'Gen2AnimPlayer: no animation data for move %s',tostring(moveId)) + end + return self.script~=nil +end +function Gen2AnimPlayer:isDone()return self.runtime.stopped end +function Gen2AnimPlayer:pollEffects() + -- The hook holds this same channel for the life of this real runner. + local out={} + for i,event in ipairs(self.effects)do out[i]=event end + for i=#self.effects,1,-1 do self.effects[i]=nil end + return out end - -function Gen2AnimPlayer:isDone() - -- BattleAnimRunScript also waits on wNumActiveBattleAnims, but objects - -- only clear themselves from inside their asm functions, so the port ends - -- on the top-level `ret` plus a short drain instead of hanging forever. - return self.done and self.tail <= 0 +function Gen2AnimPlayer:update() + if self.runtime.stopped then return false end + local active=self.runtime:step() + if not active and self.runtime.keepSprites then + local kept={} + for i,row in ipairs(self.runtime:oam())do + local copy={};for key,value in pairs(row)do copy[key]=value end + kept[i]=copy + end + kept.runner=self.runtime + self.keptSprites=#kept>0 and kept or nil + end + return active +end +function Gen2AnimPlayer:oam(objects)return objects or self.runtime:oam()end +function Gen2AnimPlayer:finalSprites()return self.keptSprites end +function Gen2AnimPlayer:draw()return self.view:drawObjects(self.runtime,self.battle)end +function Gen2AnimPlayer:drawSprites(objects) + if not objects then return self:draw()end + local runner=setmetatable({oam=function()return objects end},{__index=objects.runner or self.runtime}) + return self.view:drawObjects(runner,self.battle) end --- `anim_bgeffect` queues one of BattleBGEffects' asm routines --- (BATTLE_BG_EFFECT_*, constants/battle_anim_constants.asm). Those are the --- screen shakes, palette flashes and mon-pic moves -- everything an --- animation does outside its own objects -- so without them a Gen 2 battle --- showed the sprites and none of the impact. BattleState already implements --- Gen 1's SE_* vocabulary for exactly these effects, so each id routes onto --- its closest counterpart rather than a second fx layer. --- --- The side each one acts on is NOT baked into the id: every routine resolves --- it through BGEffect_CheckBattleTurn, which is `hBattleTurn XOR --- BG_EFFECT_STRUCT_BATTLE_TURN` -- the third `anim_bgeffect` argument, 1 for --- the user and 0 for the target. So the names below are all the unflipped --- SE_* row and `side` on the queued event picks the battler. -local BG_EFFECTS = { - [0x01] = "SE_DARK_SCREEN_FLASH", -- FLASH_INVERTED - [0x02] = "SE_DARK_SCREEN_FLASH", -- FLASH_WHITE - [0x03] = "SE_LIGHT_SCREEN_PALETTE",-- WHITE_HUES - [0x04] = "SE_DARK_SCREEN_PALETTE", -- BLACK_HUES - [0x05] = "SE_FLASH_SCREEN_LONG", -- ALTERNATE_HUES - [0x08] = "SE_FLASH_SCREEN_LONG", -- CYCLE_BGPALS_INVERTED - [0x09] = "SE_HIDE_MON_PIC", -- HIDE_MON - [0x0A] = "SE_SHOW_MON_PIC", -- SHOW_MON - [0x0B] = "SE_SHOW_MON_PIC", -- ENTER_MON - [0x0C] = "SE_HIDE_MON_PIC", -- RETURN_MON - [0x0F] = "SE_SLIDE_MON_UP", -- TELEPORT - [0x13] = "SE_BLINK_MON", -- DOUBLE_TEAM - [0x14] = "SE_HIDE_MON_PIC", -- ACID_ARMOR - [0x15] = "SE_FLASH_SCREEN_LONG", -- RAPID_FLASH - [0x16] = "SE_LIGHT_SCREEN_PALETTE",-- FADE_MON_TO_LIGHT - [0x17] = "SE_DARKEN_MON_PALETTE", -- FADE_MON_TO_BLACK - [0x18] = "SE_LIGHT_SCREEN_PALETTE",-- FADE_MON_TO_LIGHT_REPEATING - [0x19] = "SE_DARKEN_MON_PALETTE", -- FADE_MON_TO_BLACK_REPEATING - [0x1A] = "SE_FLASH_SCREEN_LONG", -- CYCLE_MON_LIGHT_DARK_REPEATING - [0x1B] = "SE_BLINK_MON", -- FLASH_MON_REPEATING - [0x1C] = "SE_DARKEN_MON_PALETTE", -- FADE_MONS_TO_BLACK_REPEATING - [0x1D] = "SE_LIGHT_SCREEN_PALETTE",-- FADE_MON_TO_WHITE_WAIT_FADE_BACK - [0x1E] = "SE_RESET_SCREEN_PALETTE",-- FADE_MON_FROM_WHITE - [0x1F] = "SE_SHAKE_SCREEN", -- SHAKE_SCREEN_X - [0x20] = "SE_ROCK_SLIDE_SHAKE", -- SHAKE_SCREEN_Y - [0x21] = "SE_SQUISH_MON_PIC", -- WITHDRAW - [0x22] = "SE_BOUNCE_UP_AND_DOWN", -- BOUNCE_DOWN - [0x23] = "SE_SLIDE_MON_DOWN_AND_HIDE", -- DIG - [0x24] = "SE_MOVE_MON_HORIZONTALLY", -- TACKLE - [0x25] = "SE_SHAKE_BACK_AND_FORTH",-- WOBBLE_MON - [0x26] = "SE_HIDE_MON_PIC", -- REMOVE_MON - [0x27] = "SE_WAVY_SCREEN", -- WAVE_DEFORM_MON - [0x28] = "SE_WAVY_SCREEN", -- PSYCHIC - [0x2B] = "SE_SHAKE_BACK_AND_FORTH",-- FLAIL - [0x2D] = "SE_SHAKE_SCREEN", -- ROLLOUT - [0x2E] = "SE_SHAKE_SCREEN", -- VITAL_THROW - [0x32] = "SE_SHAKE_BACK_AND_FORTH",-- VIBRATE_MON - [0x33] = "SE_SHAKE_BACK_AND_FORTH",-- WOBBLE_PLAYER - [0x34] = "SE_WAVY_SCREEN", -- WOBBLE_SCREEN -} - --- `anim_incbgeffect id` bumps that effect's BG_EFFECT_STRUCT_JT_INDEX, and --- for every routine a script ends this way the state it lands on is the one --- that puts back what the effect changed and calls EndBattleBGEffect: --- BGEffect_RapidCyclePals' .two_dmg reloads rBGP with $e4, the deformations' --- last state is BattleAnim_ResetLCDStatCustom. The port ran none of it, so --- Growl's FADE_MON_TO_BLACK_REPEATING left its darkened palette up for the --- rest of the battle and Tail Whip's WOBBLE_MON never put the mon back. +-- The historical SE-reset lookup remains available to existing consumers. +-- The native VM no longer emits these substitutes: Gen2BgView and native +-- picture drawing consume the genuine BG owner directly. local BG_EFFECTS_END = { [0x03] = "SE_RESET_SCREEN_PALETTE", -- WHITE_HUES [0x04] = "SE_RESET_SCREEN_PALETTE", -- BLACK_HUES @@ -388,588 +237,10 @@ local BG_EFFECTS_END = { } Gen2AnimPlayer.BG_EFFECTS_END = BG_EFFECTS_END --- BGEffect_CheckBattleTurn: BG_EFFECT_STRUCT_BATTLE_TURN is 1 for the move's --- user and 0 for its target. Anything else (the flash routines reuse the --- field as a repeat count) only ever drives screen-wide effects, which --- ignore the side. -local function bgSide(turn) - return turn == 1 and "user" or "target" -end +-- BG commands now execute their genuine register/picture state machines. +-- Battler-picture requests are drawn by the native lifted-row pass, while +-- the remaining approximate object runtime is replaced independently. +Gen2AnimPlayer.BG_EFFECTS_DROPPED = {} --- The effects that are deliberately dropped rather than unhandled, so the --- coverage test can tell the two apart. -Gen2AnimPlayer.BG_EFFECTS_DROPPED = { - -- object palette cycling; the port draws each object with its own - -- BattleObjectPals entry and has no OBP timer to cycle - [0x06] = true, [0x07] = true, - -- per-scanline BG raster (Surf/Whirlpool/the water backdrop) - [0x0D] = true, [0x0E] = true, [0x2F] = true, [0x30] = true, [0x31] = true, - -- BG tilemap dissolve - [0x10] = true, - -- BATTLEROBJ_1ROW/2ROW copy rows of the battler's own picture into the - -- object tiles. With no VRAM to copy into, the objects that depend on it - -- would draw the raw PLAYERHEAD/ENEMYFEET sheets instead -- the stray foot - -- that turned up in half the physical moves -- so spawn drops them. - [0x11] = true, [0x12] = true, - -- unused beta routines - [0x29] = true, [0x2A] = true, [0x2C] = true, -} - --- BATTLE_ANIM_GFX_PLAYERHEAD / _ENEMYFEET: these two sheets are placeholders --- that BATTLE_BG_EFFECT_BATTLEROBJ_*ROW overwrites with a slice of the --- battler's pic before the object appears. See BG_EFFECTS_DROPPED. -local BATTLER_PIC_GFX = { [0x28] = true, [0x29] = true } - --- BattleState drains this every frame and routes each row through --- applyAnimEffect, the same path Gen 1's player uses. -function Gen2AnimPlayer:pollEffects() - local out = self.effects or {} - self.effects = {} - return out -end - -function Gen2AnimPlayer:spawn(id, x, y, param) - if #self.objects >= MAX_OBJECTS then return end - local def = self.anims.objects and self.anims.objects[id] - if not def then - self:warnOnce("obj:" .. tostring(id), - "Gen2AnimPlayer: unknown animation object %d", id) - return - end - local ox, oy, xOffset = x, y, 0 - if BATTLER_PIC_GFX[def.gfx] then return end - if self.enemySide and (def.oamFlags or 0) % 2 == 1 then - ox = 180 - x - if (def.fixY or 0) == 0xFF then oy = 40 + y else oy = (def.fixY or 0) - y end - xOffset = -xOffset - end - local set = self.anims.framesets and self.anims.framesets[def.frameset] - local first = set and set.frames[1] - if not first then return end - -- BATTLEANIMSTRUCT_INDEX is wLastAnimObjectIndex, bumped once per queued - -- object, and it is what anim_incobj/anim_setobj address. - local index = (self.objIndex or 0) + 1 - self.objIndex = index - self.objects[#self.objects + 1] = { - def = def, x = ox, y = oy, xOffset = xOffset, yOffset = 0, - fn = self.anims.functions and self.anims.functions[def.fn or 0], - param = param, frame = 1, left = first.duration + 1, cycles = 0, age = 0, - index = index, frameset = def.frameset, - -- BATTLEANIMSTRUCT_JUMPTABLE_INDEX / VAR1 / VAR2 - state = 0, var1 = 0, var2 = 0, - persistent = self.scriptObjs and self.scriptObjs[index] or nil, - } -end - -local function frameset(self, obj) - local sets = self.anims.framesets - return sets and sets[obj.frameset or obj.def.frameset] -end - -local function objectByIndex(self, index) - for _, obj in ipairs(self.objects) do - if obj.index == index then return obj end - end - return nil -end - --- ReinitBattleAnimFrameset: point an object at a frameset and restart it -local function reframe(self, obj, framesetId) - local set = self.anims.framesets and self.anims.framesets[framesetId] - local first = set and set.frames[1] - if not first then return end - obj.frameset = framesetId - obj.frame, obj.cycles, obj.age = 1, 0, 0 - obj.left = first.duration + 1 -end - --- BattleAnim_Sine: a = d * sin(a * pi/32), with `a` a wrapping byte angle -local function animSine(a, d) - return math.floor(d * math.sin((a % 64) * math.pi / 32)) -end - --- BATTLE_ANIM_FRAMESET_POKE_BALL_1..5 -local BALL_FRAMESET_1 = 9 -local BALL_FRAMESET_2 = 10 -local BALL_FRAMESET_3 = 11 -local BALL_FRAMESET_4 = 12 -local BALL_FRAMESET_5 = 13 - --- BattleAnimFunc_PokeBall's twelve-state jumptable. anim_setobj/anim_incobj --- only write BATTLEANIMSTRUCT_JUMPTABLE_INDEX; it is each state that picks --- the next frameset, so the port has to run the machine. Treating --- anim_incobj as "next frameset" instead re-ran ReinitBattleAnimFrameset on --- the thrown ball, which reset its travel and made it look like a fresh ball --- was hurled at the mon once per wobble, and left the resting ball stuck on --- POKE_BALL_2 -- the squat 16x8 tile that reads as half a ball. -local function pokeBallStep(self, obj) - local s = obj.state or 0 - if s == 0 then -- init (GetBallAnimPal) - obj.state = 1 - elseif s == 1 then -- BattleAnimFunc_ThrowFromUserToTarget - if obj.x >= 0x88 then - obj.y = obj.y + obj.yOffset - obj.yOffset = 0 - reframe(self, obj, BALL_FRAMESET_3) - obj.state = 2 - return - end - obj.x = obj.x + 2 - obj.y = obj.y - 1 - obj.yOffset = animSine(obj.var1, obj.param or 0) - obj.var1 = (obj.var1 - 1) % 256 - elseif s == 3 then - obj.state = 4 - reframe(self, obj, BALL_FRAMESET_1) - obj.var1, obj.var2 = 0, 0x10 - elseif s == 4 then - obj.yOffset = animSine(obj.var1, obj.var2) - obj.var1 = (obj.var1 - 1) % 256 - if obj.var1 % 32 == 0 then - obj.var1 = 0 - obj.var2 = obj.var2 - 4 - if obj.var2 <= 0 then - reframe(self, obj, BALL_FRAMESET_4) - obj.state = 5 - end - end - elseif s == 6 then - reframe(self, obj, BALL_FRAMESET_5) - obj.state = 5 -- .six falls through to .five after decrementing the index - elseif s == 7 then - reframe(self, obj, BALL_FRAMESET_2) - obj.state = 8 - obj.var2 = 0x20 - elseif s == 8 or s == 10 then - obj.yOffset = animSine(obj.var1, obj.var2) - obj.var1 = (obj.var1 - 1) % 256 - if obj.var1 % 32 == 0 then - obj.state = 11 - elseif obj.var1 % 16 == 0 then - obj.state = s + 1 - end - elseif s == 11 then - obj.dead = true - end - -- .two/.five/.nine simply hold the object where it is -end - -function Gen2AnimPlayer:moveObject(obj) - if obj.fn == "PokeBall" then return pokeBallStep(self, obj) end - local kind = MOTION[obj.fn or "Null"] - if not kind or kind == "static" then return end - local t = math.min(1, obj.age / TRAVEL_FRAMES) - local age = obj.age - - if kind == "circle" then - local angle = age / 6 - obj.xOffset = math.floor(math.cos(angle) * 16 + 0.5) - obj.yOffset = math.floor(math.sin(angle) * 10 + 0.5) - return - elseif kind == "spiral" then - local angle = age / 6 - obj.xOffset = math.floor(math.cos(angle) * (4 + age / 3) + 0.5) - obj.yOffset = math.floor(math.sin(angle) * 6 + 0.5) + age - return - elseif kind == "shake" then - obj.xOffset = (math.floor(age / 2) % 2 == 0) and 1 or -1 - obj.yOffset = 0 - return - elseif kind == "rise" then - obj.xOffset = 0 - obj.yOffset = -age - return - elseif kind == "fall" then - obj.xOffset = 0 - obj.yOffset = age - return - end - - -- everything left travels between the two battlers - local anchor - if kind == "toUser" then - anchor = self.enemySide and USER_ANCHOR_MIRRORED or USER_ANCHOR - else - anchor = self.enemySide and TARGET_ANCHOR_MIRRORED or TARGET_ANCHOR - end - obj.xOffset = math.floor((anchor.x - obj.x) * t + 0.5) - obj.yOffset = math.floor((anchor.y - obj.y) * t + 0.5) - if kind == "arc" or kind == "arcGone" then - obj.yOffset = obj.yOffset - math.floor(math.sin(t * math.pi) * 16 + 0.5) - elseif kind == "wave" then - obj.yOffset = obj.yOffset + math.floor(math.sin(t * math.pi * 4) * 6 + 0.5) - end - if t >= 1 and (kind == "toTargetGone" or kind == "arcGone") then - obj.dead = true - end -end - -function Gen2AnimPlayer:tickObjects() - local live = {} - for _, obj in ipairs(self.objects) do - local set = frameset(self, obj) - if set then - obj.age = obj.age + 1 - self:moveObject(obj) - obj.left = obj.left - 1 - if obj.left <= 0 then - obj.frame = obj.frame + 1 - -- a $FE-terminated frameset restarts; $FF holds the last frame on - -- hardware until the object's asm function deletes it, and with no - -- function to run the object retires instead so the burst clears - if not set.frames[obj.frame] and set.loop == "restart" - and (obj.persistent or obj.cycles < 8) then - obj.frame, obj.cycles = 1, obj.cycles + 1 - elseif not set.frames[obj.frame] and obj.persistent then - -- ...unless the script is still driving this object by index, in - -- which case it really does hold its last frame until told - -- otherwise (the thrown ball waiting out a wobble) - obj.frame = #set.frames - end - local frame = set.frames[obj.frame] - if frame then obj.left = frame.duration + 1 end - end - -- a $FE frameset restarts forever on hardware until the object's own - -- function retires it, so the port bounds the lifetime instead - if set.frames[obj.frame] and not obj.dead - and (obj.persistent or obj.age < MAX_OBJECT_AGE) then - live[#live + 1] = obj - end - end - end - self.objects = live -end - -function Gen2AnimPlayer:step(inst) - local op = inst.op - if op == "delay" then - self.wait = inst.frames - self.pc = self.pc + 1 - return - elseif op == "obj" then - local a = inst.args - self:spawn(a[1], a[2], a[3], a[4]) - elseif op == "clearobjs" then - self.objects = {} - elseif op == "incobj" then - -- BattleAnimCmd_IncObj bumps the addressed object's - -- BATTLEANIMSTRUCT_JUMPTABLE_INDEX, i.e. it advances that object's asm - -- state machine one step; for every object a script drives this way the - -- state's job is to reinit the frameset to the next one in the run - -- (BattleAnimFunction_PokeBall's wobble, Bind, the Amnesia letters). - -- The port has no asm to run, so it advances the frameset directly -- - -- without it the thrown ball never rocked. - local obj = objectByIndex(self, inst.args[1]) - if obj and obj.fn == "PokeBall" then - obj.state = (obj.state or 0) + 1 - elseif obj then - reframe(self, obj, (obj.frameset or obj.def.frameset) + 1) - end - elseif op == "setobj" then - -- the same field, written outright. Only the Poké Ball's jumptable is - -- modelled, so everything else still just restarts its own frameset. - local obj = objectByIndex(self, inst.args[1]) - if obj and obj.fn == "PokeBall" then - obj.state = inst.args[2] or 0 - elseif obj then - reframe(self, obj, obj.def.frameset) - end - elseif op == "bgeffect" then - local id = inst.args[1] - local side = bgSide(inst.args[3]) - -- anim_incbgeffect addresses a running effect by id alone, so keep the - -- side the queue entry was made with - self.bgSides = self.bgSides or {} - self.bgSides[id] = side - local se = BG_EFFECTS[id] - if se then - self.effects[#self.effects + 1] = { effect = se, side = side, gen2 = true } - end - elseif op == "incbgeffect" then - local id = inst.args[1] - local se = BG_EFFECTS_END[id] - if se then - local side = self.bgSides and self.bgSides[id] or "target" - self.effects[#self.effects + 1] = { effect = se, side = side, gen2 = true } - end - elseif op == "checkpokeball" then - -- BattleAnimCmd_CheckPokeball calls GetPokeBallWobble and stores its `c` - -- into wBattleAnimVar, which the toss script then compares against 1 and - -- 2: 0 = wobble once more, 1 = the click (caught), 2 = it broke free. - -- It is read once per wobble, so count them off against the roll - -- BattleState already made and finish on the branch it decided. - self.wobbles = (self.wobbles or 0) + 1 - local opts = self.opts or {} - if self.wobbles <= (opts.shakes or 0) then - self.vars.var = 0 - else - self.vars.var = opts.caught and 1 or 2 - end - elseif op == "keepsprites" then - -- the capture branch ends on anim_keepsprites: the GB never clears the - -- resting closed ball's OAM, so it stays up through the caught text. - -- Snapshot it here rather than at the end of the script -- the object - -- would otherwise retire itself during the drain. - self.keepSprites = true - local kept = {} - for i, obj in ipairs(self.objects) do kept[i] = obj end - self.keptSprites = #kept > 0 and kept or nil - elseif op == "sound" then - self:playSound(inst) - elseif op == "cry" then - self:playCry() - elseif op == "setvar" then - self.vars.var = inst.args[1] - elseif op == "incvar" then - self.vars.var = (self.vars.var + 1) % 256 - elseif op == "jump" then - if inst.to then self.pc = inst.to end - return - elseif op == "call" then - if inst.to then - self.callStack[#self.callStack + 1] = self.pc + 1 - self.pc = inst.to - return - end - elseif op == "ret" then - local back = table.remove(self.callStack) - if back then - self.pc = back - else - self.done = true - self.tail = TAIL_FRAMES - end - return - elseif op == "loop" then - -- anim_loop count, address: the count lives in the instruction, so the - -- player keeps its own counter keyed by the instruction table itself - self.loops = self.loops or {} - local left = self.loops[inst] - if left == nil then left = inst.args[1] end - left = left - 1 - if left > 0 then - self.loops[inst] = left - if inst.to then self.pc = inst.to; return end - else - self.loops[inst] = nil - end - elseif op == "ifvarequal" then - if self.vars.var == inst.args[1] and inst.to then - self.pc = inst.to - return - end - elseif op == "ifparamequal" then - if self.vars.param == inst.args[1] and inst.to then - self.pc = inst.to - return - end - elseif op == "ifparamand" then - if band(self.vars.param, inst.args[1]) ~= 0 and inst.to then - self.pc = inst.to - return - end - elseif op == "jumpuntil" then - -- BattleAnimCmd_JumpUntil is a countdown, not a wait: it jumps while - -- wBattleAnimParam is nonzero and DECREMENTS it on the way, so a script - -- like Fury Cutter's repeats its slash `param` extra times and then - -- falls through. Spinning on the object list instead never terminated - -- (each pass respawns objects), so those moves only ever ended on the - -- runaway bound below -- the "glitchy and far too long" animations. - if self.vars.param ~= 0 and inst.to then - self.vars.param = self.vars.param - 1 - self.pc = inst.to - return - end - end - self.pc = self.pc + 1 -end - -function Gen2AnimPlayer:playSound(inst) - local Sound = require("src.core.Sound") - -- `anim_sound duration, tracks, id` packs duration/tracks into the first - -- byte and the SFX id into the second. That id is the row of the ROM's - -- SFX pointer table, which is exactly how audio.sfxIndex is keyed, so the - -- animation names its own sample -- moves.lua's anim.sound is only the - -- fallback for moves whose script the importer could not walk. - local audio = self.data and self.data.audio - local id = inst and inst.args and inst.args[2] - local name = id and audio and audio.sfxIndex and audio.sfxIndex[tostring(id)] - if name and audio.sfx and audio.sfx[name] then - return Sound.play(self.data, name) - end - local moves = self.data and self.data.moves - local def = moves and self.moveId and moves[self.moveId] - if def and def.anim then - pcall(Sound.playMove, self.data, def.anim) - end -end - -function Gen2AnimPlayer:playCry() - -- anim_cry plays the ATTACKER's cry -- GROWL and ROAR are the two moves - -- that use it (Gen 1 spelled the same rule IsCryMove). - -- - -- This player does not own the battlers, so it cannot name the species - -- itself; queue the request on the very same effect channel every other op - -- reports through and let BattleState, which does know who is attacking, - -- resolve it. Leaving this an empty stub is why Growl fell silent in Gen 2: - -- the "no subanimation player" fallback in BattleState only runs when there - -- is no player at all, and for a Gen 2 move there always is one. - self.effects[#self.effects + 1] = { cry = true } -end - -function Gen2AnimPlayer:update() - if self.done then - if self.tail > 0 then self.tail = self.tail - 1 end - if self.tail <= 0 and not self.keepSprites then self.objects = {} end - self:tickObjects() - return - end - if self.wait > 0 then - self.wait = self.wait - 1 - self:tickObjects() - return - end - local code = self.script.code - self.ranFrames = (self.ranFrames or 0) + 1 - -- a script whose branches depend on engine state the port does not model - -- can still spin; bound it well past the longest real animation - if self.ranFrames > 180 then - self.done = true - self.tail = TAIL_FRAMES - self:tickObjects() - return - end - for _ = 1, MAX_STEPS_PER_FRAME do - local inst = code[self.pc] - if not inst then - self.done = true - self.tail = TAIL_FRAMES - break - end - self:step(inst) - if self.done or self.wait > 0 then break end - end - if self.wait > 0 then self.wait = self.wait - 1 end - self:tickObjects() -end - -function Gen2AnimPlayer:sheetImage(gfxId) - local cached = self.images[gfxId] - if cached ~= nil then return cached or nil end - local sheet = self.anims.gfx and self.anims.gfx[gfxId] - local img - if sheet and love and love.graphics and love.graphics.newImage then - local ok, loaded = pcall(love.graphics.newImage, sheet.image) - if ok then img = loaded end - end - if not img then - self:warnOnce("gfx:" .. tostring(gfxId), - "Gen2AnimPlayer: anim sheet %s unavailable", - tostring(sheet and sheet.image or gfxId)) - end - self.images[gfxId] = img or false - return img -end - -function Gen2AnimPlayer:tileQuad(gfxId, tile) - local sheet = self.anims.gfx and self.anims.gfx[gfxId] - if not sheet or tile >= sheet.tiles then return nil end - local perSheet = self.quads[gfxId] - if not perSheet then - perSheet = {} - self.quads[gfxId] = perSheet - end - local q = perSheet[tile] - if q == nil and love and love.graphics and love.graphics.newQuad then - local cols = sheet.cols or 16 - local rows = math.ceil(sheet.tiles / cols) - q = love.graphics.newQuad((tile % cols) * 8, math.floor(tile / cols) * 8, - 8, 8, cols * 8, rows * 8) - perSheet[tile] = q - end - return q -end - --- BattleObjectPals holds six palettes that CGBCopyBattleObjectPals loads --- into OBJ slots 2..7; slots 0 and 1 are the two battlers' own palettes, so --- an object using those falls back to the DMG greys. -local OBJ_PALETTE_BASE = 2 - -function Gen2AnimPlayer:paletteFor(index) - self.palCache = self.palCache or {} - local cached = self.palCache[index] - if cached then return cached end - local pals = self.anims.palettes - local pal = pals and pals[index - OBJ_PALETTE_BASE] - if not pal or #pal < 4 then return DEFAULT_PALETTE end - -- the extractor stores BattleObjectPals as 0-255 channels; the shader - -- takes 0-1 triples - local out = {} - for i = 1, 4 do - out[i] = { pal[i][1] / 255, pal[i][2] / 255, pal[i][3] / 255 } - end - self.palCache[index] = out - return out -end - --- Draw the live objects onto the 160x144 battle canvas. Gen 2 is native --- CGB, so the object palettes come straight from BattleObjectPals and --- BattleState's SGB colorFn is ignored. -function Gen2AnimPlayer:draw() - self:drawSprites(self.objects) -end - --- BattleState keeps the resting closed ball on screen after a capture; Gen 2 --- marks it with anim_keepsprites at the end of the .Click branch, so hand --- back whatever OAM the script deliberately left behind. -function Gen2AnimPlayer:finalSprites() - return self.keptSprites -end - -function Gen2AnimPlayer:drawSprites(objects) - local g = love and love.graphics - if not g or not objects then return end - local shader = require("src.render.PaletteFX").shader() - for _, obj in ipairs(objects) do - local set = frameset(self, obj) - local frame = set and set.frames[obj.frame] - -- an oamwait row holds the object in place with nothing drawn - local row = frame and frame.oam and self.anims.oam - and self.anims.oam[frame.oam] - if row then - local img = self:sheetImage(obj.def.gfx) - local pal = self:paletteFor(obj.def.palette or 0) - if img and shader and g.setShader then - g.setShader(shader) - shader:send("c0", pal[1]) - shader:send("c1", pal[2]) - shader:send("c2", pal[3]) - shader:send("c3", pal[4]) - end - -- BattleAnimOAMUpdate keeps one flip state per object per frame - -- ($CA19 = frameFlags XOR obj.oamFlags, masked $E0); it negates the - -- sprite offsets and is then XORed into each sprite's own attribute. - local flip = band(bxor((frame.xflip and 0x20 or 0) - + (frame.yflip and 0x40 or 0), - obj.def.oamFlags or 0), 0xE0) - local xf = math.floor(flip / 32) % 2 == 1 - local yf = math.floor(flip / 64) % 2 == 1 - for _, sprite in ipairs(row.sprites) do - local ox = obj.x + obj.xOffset + (xf and -sprite.x - 8 or sprite.x) - local oy = obj.y + obj.yOffset + (yf and -sprite.y - 8 or sprite.y) - local quad = img and self:tileQuad(obj.def.gfx, row.tile + sprite.tile) - if quad and ox > 0 and ox < 168 and oy > 0 and oy < 160 then - local attr = bxor(sprite.attr, flip) - local sx = math.floor(attr / 32) % 2 == 1 - local sy = math.floor(attr / 64) % 2 == 1 - g.draw(img, quad, - ox - 8 + (sx and 8 or 0), oy - 16 + (sy and 8 or 0), 0, - sx and -1 or 1, sy and -1 or 1) - end - end - if img and shader and g.setShader then g.setShader() end - end - end -end return Gen2AnimPlayer diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua new file mode 100644 index 000000000..7e235a8dd --- /dev/null +++ b/src/battle/Gen2AnimView.lua @@ -0,0 +1,114 @@ +-- Native object drawing over the genuine GSC VM's emitted OAM. +-- Object layout/flip math retains Gen2AnimPlayer provenance (Cedric); +-- public owner integration by Roxas2712, see NOTICE.md and contribution licence. +-- GSC BG/OBJ register integration follows Gen1Recomp535a5ec0 under +-- compat/gen1-gsc/LICENSE.MD; inherited object-drawing rights remain intact. +local Palette = require('src.render.Gen2PaletteDraw') +local PaletteFX = require('src.render.PaletteFX') +local BattlerRows = require('src.battle.Gen2BattlerRows') +local View = {} +View.__index = View +local slots = { + PAL_BATTLE_OB_ENEMY=0, PAL_BATTLE_OB_PLAYER=1, PAL_BATTLE_OB_GRAY=2, + PAL_BATTLE_OB_YELLOW=3, PAL_BATTLE_OB_RED=4, PAL_BATTLE_OB_GREEN=5, + PAL_BATTLE_OB_BLUE=6, PAL_BATTLE_OB_BROWN=7, +} +function View.new(anims, data) + return setmetatable({data=anims or {}, gameData=data or {}, images={}, quads={}, + battlerRows=setmetatable({},{__mode='k'})}, View) +end +function View:loadBattlerRows(screen,...) + if not(screen and screen.gen2ObjectPictureSource)then return end + for _,entry in ipairs({...})do + self.battlerRows[entry]=BattlerRows.snapshot(screen:gen2ObjectPictureSource(entry.battler),entry) + end +end +function View:image(path) + if not path then return nil end + if self.images[path] == nil then + local ok, image = pcall(love.graphics.newImage, path) + if ok and image and image.setFilter then image:setFilter('nearest','nearest') end + self.images[path] = ok and image or false + end + return self.images[path] or nil +end +function View:release() + for _, image in pairs(self.images) do + if image and image.release then image:release() end + end + self.images, self.quads = {}, {} + for _,snapshot in pairs(self.battlerRows)do + if snapshot.image.release then snapshot.image:release()end + end + self.battlerRows=setmetatable({},{__mode='k'}) + self:releaseBg() +end +function View:objPalette(name, battle) + local index = slots[name] or tonumber(name) + if index == 0 or index == 1 then + local mon = battle and battle[index == 0 and 'enemy' or 'player'] + mon = mon and (mon.mon or mon) + if mon then + return PaletteFX.monPal(self.gameData, mon.species, nil, + require('src.pokemon.Pokemon').isShiny(mon)) or PaletteFX.GRAYS + end + end + return self.data.palettes and self.data.palettes[(index or 2)-2] or PaletteFX.GRAYS +end +function View:quad(gfx, tile, image) + local sheet = self.data.gfx and self.data.gfx[gfx] + if not sheet or tile < 0 or tile >= sheet.tiles then return nil end + local bySheet = self.quads[gfx] or {}; self.quads[gfx] = bySheet + if not bySheet[tile] then + local w,h = image:getDimensions() + local cols = sheet.cols or sheet.wide or 16 + bySheet[tile] = love.graphics.newQuad((tile%cols)*8, math.floor(tile/cols)*8, 8,8,w,h) + end + return bySheet[tile] +end +function View:drawObjects(runner, battle) + local g = love and love.graphics + if not g then return end + g.push('all') + local ok, err = pcall(Palette.preserving,function() + g.setColor(1,1,1,1) + local active,activeByte,activeRaw + for _, obj in ipairs(runner:oam()) do + local entry,index + for i=#runner.loaded,1,-1 do + local loaded=runner.loaded[i] + if obj.tile>=loaded.tile and obj.tile 0 then + sx = self.frame % 4 < 2 and 2 or -2 + end + -- intro slide-in offset: 2 px per frame, so 144 px over 72 frames + local slide = (self.introSlide or 0) * Timing.BATTLE_SLIDE_PX_PER_FRAME + + if self:colorMode() then + -- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the + -- BGP fade -> mon pics -> OAM anim sprites (never BGP-faded) + local g = love.graphics + local wavy = fx and fx.wavy + -- push("all")/pop restores the bound canvas, a mod's depth set included + g.push("all") + g.setCanvas(self.bgCanvas) + -- The white paper the whole battle is drawn on -- unless something has + -- put a WORLD where the paper goes, in which case painting it is what + -- hides the world. Clearing to transparent instead leaves only what is + -- actually drawn (HUDs, boxes, pics) and lets the 3D frame underneath + -- show between them. The zone shader passes alpha through unchanged + -- (PaletteFX.shader: `vec4(mapped, p.a)`), so transparency survives the + -- colour pass rather than being remapped to a palette shade. + if self:worldBackdrop() then + g.clear(0, 0, 0, 0) + else + g.setColor(1, 1, 1, 1) + g.rectangle("fill", 0, 0, 160, 144) + end + g.setColor(1, 1, 1, 1) + -- the party ball rows are OBJ sprites on hardware (see drawBallRow): + -- collect them here and draw them over the finished zone pass instead + local balls = self:gen2BallColors() and {} or nil + self.deferBalls = balls + self:drawHUDs(slide) + self.deferBalls = nil + self:drawTextArea() + if wavy then + -- the mon pics are BG tiles on the GB, so SE_WAVY_SCREEN bends + -- them too: bake them into the canvas as DMG grays and let the + -- zone pass color them by region (exactly what the SGB did) + self.grayPics = true + g.setScissor(0, 0, 160, 96) -- BG pics live above the text box + self:drawPicsLayer(slide, 0, 0) + g.setScissor() + self.grayPics = nil + end + g.pop() + self:drawZonePass(self:applyWavy(self.bgCanvas), sx, sy) + if balls and balls[1] then + -- they ride the window shake with the HUD chrome they sit in + local shifted = sx ~= 0 or sy ~= 0 + if shifted then g.push() g.translate(sx, sy) end + g.setColor(1, 1, 1, 1) + for _, row in ipairs(balls) do + self:drawBallRow(row[1], row[2], row[3], row[4]) + end + if shifted then g.pop() end + end + if not wavy then + -- the pics are BG tiles in rows 0-11 on the GB: they can never + -- cover the text box, whatever the SE offsets do (a vertical + -- window shake moves the box down with everything else) + g.setScissor(0, 0, 160, 96 + math.max(0, sy)) + self:drawPicsLayer(slide, sx, sy) + g.setScissor() + end + else + -- flat fallback (headless / no shader support): pre-colorized pics + -- on white, no palette fades -- and the same exception as above, so the + -- two paths agree about when there is paper to paint + love.graphics.setColor(1, 1, 1, 1) + if not self:worldBackdrop() then + love.graphics.rectangle("fill", 0, 0, 160, 144) + end + local shaking = sx ~= 0 or sy ~= 0 + if shaking then + love.graphics.push() + love.graphics.translate(sx, sy) + end + self:drawPicsLayer(slide, 0, 0) + self:drawHUDs(slide) + self:drawTextArea() + if shaking then + love.graphics.pop() + end + end + end + + -- panelFn replaces the BG panel; native wide layout also supplies its + -- translated OAM pass and surface dimensions through the same real site. + function Screen:drawSceneBody(panelFn,objectsFn,width,height) + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + local panel=panelFn or function()self:drawPanel()end + if self.anim and self.anim.bg and self.animView then + self.animView:present(self.anim,panel,self,width or 160) + else panel()end + if self.blankForAskName then return end + self:drawLiftedRows(width) + if objectsFn then objectsFn()else self:drawAnimLayer(self:colorMode())end + local fx=self.fx + if fx and fx.flash and fx.flash>0 and self.frame%4<2 then + g.setShader();g.setColor(1,1,1,.85) + g.rectangle('fill',0,0,width or 160,height or 144) + end + end) + g.pop() + if not ok then error(why,0)end + end + function Screen:drawScene(bodyFn) + if bodyFn then bodyFn()else self:drawSceneBody()end + love.graphics.setColor(1,1,1,1) + if Runtime.wantsHook('battle.overlay')then + Runtime.call('battle.overlay',function()end,self) + end + end +end +return M diff --git a/src/battle/Gen2BattlerRows.lua b/src/battle/Gen2BattlerRows.lua new file mode 100644 index 000000000..da2ccc89e --- /dev/null +++ b/src/battle/Gen2BattlerRows.lua @@ -0,0 +1,52 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC copies enemy feet / player head from BG picture tiles into OBJ VRAM. +-- Snapshot at the real battlergfx command; drawing never rereads live art. +local Assets=require('src.render.Assets') +local M={} +function M.snapshot(source,entry) + if not(source and source.path and love.image and love.image.newImageData)then return end + local shared=Assets.imageDataShared + local raw=(shared or Assets.imageData)(source.path) + local w,h=raw:getDimensions() + local x0=0 + if source.crop then w=source.crop.width;x0=(source.crop.index-1)*w end + if w<=0 or h<=0 then return end + local back=entry.battler=='player' + local size=back and 48 or 56 + -- Native fronts are 5/6/7 tiles. PadFrontpic puts both smaller sizes + -- one tile from the left and bottom-aligns them in the seven-tile box. + -- Replacement sheets already contain their full box. Custom larger art + -- is sampled into the same logical OBJ cells, preserving its own alpha. + local lw,lh=size,size + if not back and (w==40 or w==48)then lw=w end + if not back and (h==40 or h==48)then lh=h end + local left=lw=0 and py>=0 and px0.83 then a=0 end + out:setPixel(x,y,r,g,b,a) + end + end end + local image=love.graphics.newImage(out) + if out.release then out:release()end + if not shared and raw.release then raw:release()end + image:setFilter('nearest','nearest') + return {image=image,rows=rows,cols=cols,trueColor=source.trueColor,quads={}} +end +function M.quad(snapshot,index) + if index<0 or index>=snapshot.rows*snapshot.cols then return end + if not snapshot.quads[index]then + snapshot.quads[index]=love.graphics.newQuad(math.floor(index/snapshot.rows)*8, + (index%snapshot.rows)*8,8,8,snapshot.cols*8,snapshot.rows*8) + end + return snapshot.quads[index] +end +return M diff --git a/src/battle/Gen2BgEffectIds.lua b/src/battle/Gen2BgEffectIds.lua new file mode 100644 index 000000000..21f34e0fc --- /dev/null +++ b/src/battle/Gen2BgEffectIds.lua @@ -0,0 +1,73 @@ +-- Gen1Recomp-derived code: GPLv3 plus Additional Terms; see +-- compat/gen1-gsc/LICENSE.MD and provenance.json. Existing rights retained. +-- GSC BG-effect jump-table identifiers from Gen1Recomp's Gold/Silver/Crystal +-- ROM manifests at535a5ec0, constants.battleBgEffectOrder. Numeric command +-- arguments retain the cartridge's order; no program or frame is synthesized. +local base = { + "BATTLE_BG_EFFECT_END", + "BATTLE_BG_EFFECT_FLASH_INVERTED", + "BATTLE_BG_EFFECT_FLASH_WHITE", + "BATTLE_BG_EFFECT_WHITE_HUES", + "BATTLE_BG_EFFECT_BLACK_HUES", + "BATTLE_BG_EFFECT_ALTERNATE_HUES", + "BATTLE_BG_EFFECT_CYCLE_OBPALS_GRAY_AND_YELLOW", + "BATTLE_BG_EFFECT_CYCLE_MID_OBPALS_GRAY_AND_YELLOW", + "BATTLE_BG_EFFECT_CYCLE_BGPALS_INVERTED", + "BATTLE_BG_EFFECT_HIDE_MON", + "BATTLE_BG_EFFECT_SHOW_MON", + "BATTLE_BG_EFFECT_ENTER_MON", + "BATTLE_BG_EFFECT_RETURN_MON", + "BATTLE_BG_EFFECT_SURF", + "BATTLE_BG_EFFECT_WHIRLPOOL", + "BATTLE_BG_EFFECT_TELEPORT", + "BATTLE_BG_EFFECT_NIGHT_SHADE", + "BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", + "BATTLE_BG_EFFECT_BATTLEROBJ_2ROW", + "BATTLE_BG_EFFECT_DOUBLE_TEAM", + "BATTLE_BG_EFFECT_ACID_ARMOR", + "BATTLE_BG_EFFECT_RAPID_FLASH", + "BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT", + "BATTLE_BG_EFFECT_FADE_MON_TO_BLACK", + "BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT_REPEATING", + "BATTLE_BG_EFFECT_FADE_MON_TO_BLACK_REPEATING", + "BATTLE_BG_EFFECT_CYCLE_MON_LIGHT_DARK_REPEATING", + "BATTLE_BG_EFFECT_FLASH_MON_REPEATING", + "BATTLE_BG_EFFECT_FADE_MONS_TO_BLACK_REPEATING", + "BATTLE_BG_EFFECT_FADE_MON_TO_WHITE_WAIT_FADE_BACK", + "BATTLE_BG_EFFECT_FADE_MON_FROM_WHITE", + "BATTLE_BG_EFFECT_SHAKE_SCREEN_X", + "BATTLE_BG_EFFECT_SHAKE_SCREEN_Y", + "BATTLE_BG_EFFECT_WITHDRAW", + "BATTLE_BG_EFFECT_BOUNCE_DOWN", + "BATTLE_BG_EFFECT_DIG", + "BATTLE_BG_EFFECT_TACKLE", + "BATTLE_BG_EFFECT_WOBBLE_MON", + "BATTLE_BG_EFFECT_REMOVE_MON", + "BATTLE_BG_EFFECT_WAVE_DEFORM_MON", + "BATTLE_BG_EFFECT_PSYCHIC", + "BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1", + "BATTLE_BG_EFFECT_BETA_SEND_OUT_MON2", + "BATTLE_BG_EFFECT_FLAIL", + "BATTLE_BG_EFFECT_BETA_PURSUIT", + "BATTLE_BG_EFFECT_ROLLOUT", + "BATTLE_BG_EFFECT_VITAL_THROW", + "BATTLE_BG_EFFECT_START_WATER", + "BATTLE_BG_EFFECT_WATER", + "BATTLE_BG_EFFECT_END_WATER", + "BATTLE_BG_EFFECT_VIBRATE_MON", + "BATTLE_BG_EFFECT_WOBBLE_PLAYER", + "BATTLE_BG_EFFECT_WOBBLE_SCREEN", +} +local M = {} +function M.forVersion(version) + version = version or require('src.core.GameVersion').get() + local order = {} + for i,name in ipairs(base) do + if i==38 and version~='gold' and version~='silver' then + order[#order+1] = 'BATTLE_BG_EFFECT_BODY_SLAM' + end + order[#order+1] = name + end + return order +end +return M diff --git a/src/battle/Gen2BgPaint.lua b/src/battle/Gen2BgPaint.lua new file mode 100644 index 000000000..60190fe1a --- /dev/null +++ b/src/battle/Gen2BgPaint.lua @@ -0,0 +1,27 @@ +-- Gen1Recomp-derived BG paper/clip drawing. GPLv3 plus Additional Terms: +-- compat/gen1-gsc/LICENSE.MD and provenance.json. Uses native indexed colour. +local Palette=require('src.render.Gen2PaletteDraw') +local M={} +-- The GSC battle/menu paper palette from the pinned Gen1 Chrome owner. +local BOX={{255,255,255},{255,255,255},{255,255,255},{0,0,0}} +function M.paletteFill(x,y,w,h) + local g=love.graphics + local previous=g.getShader and g.getShader() + g.setColor(1,1,1,1) + if Palette.available()then Palette.use(BOX)end + g.rectangle('fill',x,y,w,h) + g.setShader(previous) + g.setColor(0,0,0,1) +end +function M.clipTo(x,y,w,h) + local g=love.graphics + if g.transformPoint then + local x1,y1=g.transformPoint(x,y) + local x2,y2=g.transformPoint(x+w,y+h) + x,y=math.floor(math.min(x1,x2)),math.floor(math.min(y1,y2)) + w,h=math.ceil(math.abs(x2-x1)),math.ceil(math.abs(y2-y1)) + end + if g.intersectScissor then g.intersectScissor(x,y,w,h) + else g.setScissor(x,y,w,h)end +end +return M diff --git a/src/battle/Gen2BgPictures.lua b/src/battle/Gen2BgPictures.lua new file mode 100644 index 000000000..5b94dd5f2 --- /dev/null +++ b/src/battle/Gen2BgPictures.lua @@ -0,0 +1,125 @@ +-- Gen1Recomp-derived picture/register integration. GPLv3 plus Additional +-- Terms, compat/gen1-gsc/LICENSE.MD. See pinned provenance.json. +-- The existing native screen still owns images, battlers and all clocks. +local Version=require('src.core.GameVersion') +local Palette=require('src.render.Gen2PaletteDraw') +local Chrome=require('src.battle.Gen2BgPaint') +local M={} +local SIZES={[0]=6,[1]=4,[2]=2,[3]=7,[4]=5,[5]=3} + +-- Per-mon CopyPals first, whole-BG BGP second. Compose indices before the +-- existing image owner bakes them; never guess shades from finished RGB. +function M.paletteByte(monByte,bgByte) + local mon=Palette.bgpShades(monByte) + local bg=Palette.bgpShades(bgByte) + local out=0 + for i=0,3 do out=out+mon[bg[i+1]+1]*4^i end + return out +end +local function withPalette(screen,side,body) + if screen._gen2PicPaletteSide==side then return body()end + local state=screen:animPicState(side) + local previous=Palette.bgp + local owner=screen._gen2PicPaletteSide + screen._gen2PicPaletteSide=side + Palette.setBgp(M.paletteByte(state and state.shade,previous)) + local ok,why=pcall(body) + Palette.setBgp(previous) + screen._gen2PicPaletteSide=owner + if not ok then error(why,0)end +end +function M.install(Screen) + -- Compose the native picture observation with the real BG owner. A later + -- public method override remains visible to the actual picture drawer. + local read=Screen.animPicState + function Screen:animPicState(side) + local state=read and read(self,side) + local bg=self.anim and self.anim.bg + if not bg then return state end + state=state or {} + state.hidden=state.hidden or bg.hidden[side] + state.size,state.shade=bg.picSize[side],bg.monShade[side] + state.slide,state.lifted=bg.slide[side]or 0,bg.liftedRows[side] + return state + end + local draw=Screen.drawBattlerPic + function Screen:drawBattlerPic(b,x,y,scale) + if not Version.isGen2() or not(self.anim and self.anim.bg) then + return draw(self,b,x,y,scale) + end + local side=b.isPlayer and 'player'or'enemy' + local back=b.isPlayer + if self._gen2PicBack~=nil then back=self._gen2PicBack end + local state=self:animPicState(side) + if state and state.hidden then return end + if self.liftedPass and not(state and state.lifted)then return end + if not self.liftedPass then x=x+(state and state.slide or 0)end + local size=state and SIZES[state.size] + if size then + local image=self:battlerPic(b) + if image then + local factor=size/(back and 6 or 7) + x=x+math.floor(image:getWidth()*scale*(1-factor)/2) + y=y+math.floor(image:getHeight()*scale*(1-factor)) + scale=scale*factor + end + end + local function paint()withPalette(self,side,function()draw(self,b,x,y,scale)end)end + local lifted=state and state.lifted + if not lifted then return paint()end + local bandY=(back and 48 or 0)+lifted[1]*8 + local bandH=lifted[2]*8 + local function band(top,height) + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + Chrome.clipTo(0,top,160,height) + paint() + end) + g.pop() + if not ok then error(why,0)end + end + if self.liftedPass then band(bandY,bandH) + else + if bandY>0 then band(0,bandY)end + local below=144-bandY-bandH + if below>0 then band(bandY+bandH,below)end + end + end + local drawPic=Screen.drawPic + if drawPic then + function Screen:drawPic(mon,back) + if not Version.isGen2() or not(self.anim and self.anim.bg)then + return drawPic(self,mon,back) + end + local side=self.player and self.player.mon==mon and 'player' + or self.enemy and self.enemy.mon==mon and 'enemy' + or (back and 'player'or'enemy') + return withPalette(self,side,function()drawPic(self,mon,back)end) + end + end + function Screen:drawLiftedRows(width) + local bg=self.anim and self.anim.bg + local rows=bg and bg.liftedRows + if not(rows and(rows.player or rows.enemy))then return end + local before=self.liftedPass + self.liftedPass=true + local previous=Palette.setBgp(bg.bgp) + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + local slide=(self.introSlide or 0)*require('src.core.Timing').BATTLE_SLIDE_PX_PER_FRAME + if width and width>160 then + require('src.battle.WideBattle').drawPicRegions(self,slide) + else + if rows.enemy then self:drawPicsLayer(slide,0,0,'enemy',true)end + if rows.player then self:drawPicsLayer(slide,0,0,'player',true)end + end + end) + g.pop() + Palette.setBgp(previous) + self.liftedPass=before + if not ok then error(why,0)end + end +end +return M diff --git a/src/battle/Gen2BgView.lua b/src/battle/Gen2BgView.lua new file mode 100644 index 000000000..7d835ea37 --- /dev/null +++ b/src/battle/Gen2BgView.lua @@ -0,0 +1,364 @@ +-- Gen1Recomp-derived BG presenter, pinned at535a5ec0. GPLv3 plus +-- Additional Terms: compat/gen1-gsc/LICENSE.MD and provenance.json. +-- Native integration retains the real BG scanline pipeline and uses this +-- engine's palette and pixel-canvas owners. OAM is composited afterwards. +local PixelCanvas=require('src.render.PixelCanvas') +local Chrome=require('src.battle.Gen2BgPaint') +local GbcPalette=require('src.render.Gen2PaletteDraw') +local BgView={} +local SCREEN_W,SCREEN_H=160,144 +local function signed(value) + value=(value or 0)%256 + return value<0x80 and value or value-256 +end +local function needsCanvas(runner) + local bg = runner.bg + -- engine/battle_anims/bg_effects.asm:448-465: a lifted battler row stays + -- out of the BG until the next pic redraw, so those frames stay baked too. + local lifted = bg.liftedRows + if lifted and (lifted.player or lifted.enemy) then return true end + if bg.scx ~= 0 or bg.scy ~= 0 then return true end + if not bg.lcdc then return false end + if bg.lyEnd <= bg.lyStart then return false end + for row = bg.lyStart, math.min(bg.lyEnd, SCREEN_H) - 1 do + if (bg.lyBackup[row] or 0) ~= 0 then return true end + end + return false +end + +-- The battle background is BG colour 0 everywhere the two pic boxes and the +function BgView:fillBackground(palByte) + local SCREEN_W=self.bgWidth or 160 + if self.bgTransparent then return end + local previousBgp = GbcPalette.setBgp(palByte) + local ok, err = pcall(Chrome.paletteFill, 0, 0, SCREEN_W, SCREEN_H) + GbcPalette.setBgp(previousBgp) + if not ok then error(err, 0) end +end + +-- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn +-- CLIPPED to the 160-pixel screen rather than allowed to hang over the edge: +-- the cart's BG map wraps, so a scrolled scanline never spills past the LCD. +function BgView:blitRowAt(srcRow, destRow, dx) + local SCREEN_W=self.bgWidth or 160 + local canvas = self.canvas + if not self.blitQuad then + self.blitQuad = love.graphics.newQuad(0, 0, SCREEN_W, 1, SCREEN_W, SCREEN_H) + end + local srcX, width, destX = 0, SCREEN_W, dx + if dx > 0 then + width = SCREEN_W - dx + elseif dx < 0 then + srcX, width, destX = -dx, SCREEN_W + dx, 0 + end + if width <= 0 then return end + self.blitQuad:setViewport(srcX, srcRow, width, 1, SCREEN_W, SCREEN_H) + love.graphics.draw(canvas, self.blitQuad, destX, destRow) +end + +function BgView:blitRow(row, dx, dy) + self:blitRowAt(row, row + dy, dx) +end + +-- Draw the battle panel into the blit canvas, optionally with an rBGP byte +-- folded into every palette on the way in. +-- +-- The byte is set on GbcPalette rather than applied afterwards, which is what +-- makes it exact: the panel is still being DRAWN, so its palettes can take +-- CopyPals' permutation before they ever reach the shader. That is bit for bit +-- what DmgToCgbBGPals does, and it is why nothing here has to guess which +-- palette entry produced a finished pixel. +-- +-- pcall so a drawBg that throws cannot leave the byte standing on GbcPalette +-- for the rest of the frame. +function BgView:bake(drawBg, palByte) + local SCREEN_W=self.bgWidth or 160 + local G = love.graphics + if not self.canvas then + self.canvas = PixelCanvas.new(SCREEN_W, SCREEN_H) + self.canvas:setFilter("nearest", "nearest") + end + local previousBgp = GbcPalette.setBgp(palByte) + G.push('all') + local ok, err = pcall(function() + G.setCanvas(self.canvas) + G.setScissor() + G.clear(0,0,0,0) + G.origin() + drawBg() + end) + G.pop() + GbcPalette.setBgp(previousBgp) + if not ok then error(err,0) end +end + +-- The rBGP window's scanlines grouped by the byte they hold, identity first. +-- +-- `.SetLYOverridesBackup` writes one value on every SECOND row and leaves the +-- rest on whatever ClearLYOverrides put there, so a beta send-out frame holds +-- two bytes and FadeMonsToBlackRepeating's DMG path holds two bands of one +-- each. Grouping means the panel is re-baked twice a frame rather than 144 +-- times, and the grouping is by VALUE so a table that happens to repeat costs +-- nothing extra. +local function bgpBands(bg) + local base = bg.bgp or GbcPalette.BGP_IDENTITY + local order, bands = {}, {} + for row = 0, SCREEN_H - 1 do + -- home/lcd.asm:12 + local inWindow = row > bg.lyStart and row <= bg.lyEnd + -- Outside the window the register still reads whatever wBGP holds. + local byte = inWindow and (bg.lyBackup[row - 1] or base) or base + local band = bands[byte] + if not band then + band = { byte = byte, rows = {} } + bands[byte] = band + order[#order + 1] = band + end + band.rows[#band.rows + 1] = row + end + -- The base band first so the fillBackground below it happens before any blit + -- and the common band is the one drawn from the first bake. + table.sort(order, function(a, b) + if a.byte == b.byte then return false end + if a.byte == base then return true end + if b.byte == base then return false end + return a.rows[1] < b.rows[1] + end) + return order +end +BgView.bgpBands = bgpBands + +-- engine/battle_anims/bg_effects.asm:2638 +-- +-- One scanline's source row and x shift, or nil when the row shows nothing. +local function scanline(bg, scy, row) + local dx, src = 0, row + scy + -- home/lcd.asm:12 + local inWindow = bg.lcdc and bg.lcdc ~= "BGP" + and row > bg.lyStart and row <= bg.lyEnd + local byte = inWindow and (bg.lyBackup[row - 1] or 0) or 0 + if inWindow then + local value = signed(byte) + if bg.lcdc == "SCX" then dx = -value else src = src + value end + -- home/lcd.asm:3 + if byte ~= 0x90 and scy == 0 then + if src < 0 then src = 0 elseif src >= SCREEN_H then src = SCREEN_H - 1 end + end + end + if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then + return src, dx + end + return nil +end + +function BgView.scanlines(bg) + local lines = {} + local scy = signed(bg.scy) + for row = 0, SCREEN_H - 1 do + local src, dx = scanline(bg, scy, row) + if src then + lines[#lines + 1] = { src = src, dest = row, dx = dx } + end + end + return lines +end + +-- Runs `drawBg` (the battle panel) and puts it on screen through the +-- animation's BG registers; skips the canvas when nothing needs one. +function BgView:present(runner, drawBg, battle) + local SCREEN_W=self.bgWidth or 160 + if not (love and love.graphics) then return end + local bg = runner.bg + -- engine/battle_anims/anim_commands.asm:1293 + local byte = bg.bgp ~= GbcPalette.BGP_IDENTITY and bg.bgp or nil + if not byte and not needsCanvas(runner) then + drawBg() + return + end + local G = love.graphics + + -- hLCDCPointer can also aim at rBGP, in which case each scanline gets its own + -- PALETTE rather than its own scroll (the beta send-out effects, and + -- FadeMonsToBlackRepeating on the DMG path). Nothing scrolls on that path, + -- so the whole of it is: bake the panel once per distinct byte and blit that + -- byte's rows out of it. + if bg.lcdc == "BGP" and GbcPalette.available() then + local baseX, baseY = -signed(bg.scx), -signed(bg.scy) + local bands = bgpBands(bg) + local filled = false + for _, band in ipairs(bands) do + self:bake(drawBg, band.byte) + G.setShader() + if not filled then + self:fillBackground(band.byte) + filled = true + end + G.setColor(1, 1, 1, 1) + for _, row in ipairs(band.rows) do + self:blitRow(row, baseX, baseY) + end + end + return + end + + self:bake(drawBg, byte) + G.setShader() + + -- A shifted scanline exposes the blank tile beside the pic boxes; without + -- this the exposed strip is the canvas's own transparency. + self:fillBackground(byte) + G.setColor(1, 1, 1, 1) + -- hSCX / hSCY move the whole background; the per-scanline overrides only + -- apply inside the effect's own window. + local baseX = -signed(bg.scx) + -- BgView.scanlines row by row, without a table per scanline. + local scy = signed(bg.scy) + for row = 0, SCREEN_H - 1 do + local src, dx = scanline(bg, scy, row) + if src then self:blitRowAt(src, row, baseX + dx) end + end + -- Shaderless boot: the panel is raw grayscale, so there are no palettes to + -- permute and the entry's BRIGHTNESS is the only thing left to reproduce. + if bg.lcdc == "BGP" then + for row = bg.lyStart + 1, math.min(bg.lyEnd, SCREEN_H - 1) do + local veil = BgView.palVeil(bg.lyBackup[row - 1]) + if veil ~= 0 then + local shade = veil > 0 and 0 or 1 + G.setColor(shade, shade, shade, math.min(1, math.abs(veil))) + G.rectangle("fill", 0, row, SCREEN_W, 1) + end + end + G.setColor(1, 1, 1, 1) + end +end + +-- A DMG palette byte's mean shade against the identity %11100100, signed: +-- +1 is solid black ($ff), -1 solid white ($00), 0 the identity. The +-- approximation, kept for the shaderless path in `present` above. +function BgView.palVeil(palByte) + if not palByte then return 0 end + local sum = 0 + for index = 0, 3 do + sum = sum + math.floor(palByte / (4 ^ index)) % 4 + end + return (sum - 6) / 6 +end + +-------------------------------------------------------------------------- +-- The battle intro slide (engine/battle/sliding_intro.asm) +-------------------------------------------------------------------------- + +-- BattleIntroSlidingPics runs 72 frames. It holds three SCX values at once: +-- scanlines 0-$3f take `c`, which starts at $90 and falls by 2 a frame, so +-- the enemy's half enters from the RIGHT; $40-$5f take `b`, which starts at +-- $70 and RISES by 2, wrapping to 0 on the last frame, so the player's half +-- enters from the LEFT; everything from $60 down is already in place. +BgView.SLIDE_FRAMES = 72 + +function BgView.slideOffsets(frame) + local step = math.max(0, math.min(BgView.SLIDE_FRAMES, frame)) + local top = (0x90 - step * 2) % 256 + local middle = (0x70 + step * 2) % 256 + return top, middle +end + +-- The pixels the cart's OAM back-pic copy still has to travel at `frame`: +-- CopyBackpic parks 18 sprites just off the RIGHT edge (x = 168 in OAM +-- terms) and the slide's `.subfunction1` walks every one left 2px a frame, +-- so the whole pic crosses 144px to its resting column in the 72 frames. +function BgView.slideBackpicOffset(frame) + local step = math.max(0, math.min(BgView.SLIDE_FRAMES, frame)) + return (BgView.SLIDE_FRAMES - step) * 2 +end + +-- The same band-at-a-time blit `present` uses, with the intro's own offsets. +-- +-- `drawBackpic(offset)`, when given, draws the player's back pic OVER the +-- bands the way the cart's OAM copy rides over them: InitBattleDisplay clears +-- the pic's top rows out of the BG before the slide (the hlcoord 1, 5 +-- ClearBox) precisely because the pic straddles the $40 scanline where the +-- two bands part ways -- baked into the bands it tears in half there, its top +-- rows riding the enemy's offset and its bottom rows the player's. The +-- caller must leave the pic OUT of drawBg and hand it here instead. +function BgView:presentSlide(frame, drawBg, drawBackpic) + local SCREEN_W=self.bgWidth or 160 + if not (love and love.graphics) then return end + local top, middle = BgView.slideOffsets(frame) + if top == 0 and middle == 0 then + drawBg() + if drawBackpic then drawBackpic(0) end + return + end + local G = love.graphics + self:bake(drawBg, nil) + G.setShader() + self:fillBackground() + G.setColor(1, 1, 1, 1) + for row = 0, SCREEN_H - 1 do + local scx = 0 + if row < 0x40 then + scx = top + elseif row < 0x60 then + scx = middle + end + -- SCX is unsigned and the map wraps at 256, so a value over half the + -- screen reads as "coming in from the other side" rather than as a jump. + local dx = -scx + if scx > 128 then dx = 256 - scx end + self:blitRow(row, dx, 0) + end + if drawBackpic then + drawBackpic(BgView.slideBackpicOffset(frame)) + end +end + +-- A DMG palette byte read as a shade remap, so a BG effect that fades a mon +-- through $f8/$fc can be applied to that mon's four CGB colours. The same +-- CopyPals permutation `present` now folds into the whole panel, which is why +-- it lives in GbcPalette and this is a name for it rather than a second copy. +function BgView.shadeColors(colors, palByte) + return GbcPalette.remap(colors, palByte) +end + +BgView.SCREEN_W = SCREEN_W +BgView.SCREEN_H = SCREEN_H +BgView.needsCanvas = needsCanvas + +function BgView:setBgSize(width) + width=width or 160 + assert(type(width)=='number' and width>0 and width%1==0,'invalid native battle surface width') + if self.bgWidth==width then return end + self:releaseBg() + self.bgWidth=width +end +function BgView:releaseBg() + if self.canvas and self.canvas.release then self.canvas:release() end + self.canvas,self.blitQuad=nil,nil +end + +-- Public draw seams restore the caller's graphics owner; no animation time +-- runs here. The real native scene supplies its transparency and surface. +local present=BgView.present +function BgView:present(runner,drawBg,battle,width) + if not(love and love.graphics)then return end + self:setBgSize(width) + local transparent=self.bgTransparent + self.bgTransparent=battle and type(battle.worldBackdrop)=='function' and battle:worldBackdrop() or false + local g=love.graphics + g.push('all') + local ok,why=pcall(present,self,runner,drawBg,battle) + g.pop() + self.bgTransparent=transparent + if not ok then error(why,0) end +end +local presentSlide=BgView.presentSlide +function BgView:presentSlide(frame,drawBg,drawBackpic,width) + if not(love and love.graphics)then return end + self:setBgSize(width) + local g=love.graphics + g.push('all') + local ok,why=pcall(presentSlide,self,frame,drawBg,drawBackpic) + g.pop() + if not ok then error(why,0) end +end +return BgView diff --git a/src/battle/Gen2MovePresentation.lua b/src/battle/Gen2MovePresentation.lua new file mode 100644 index 000000000..3a66fcf76 --- /dev/null +++ b/src/battle/Gen2MovePresentation.lua @@ -0,0 +1,67 @@ +-- Native Gen2 presentation entry points over the existing battle queue/VM. +-- Public side/after-animation names follow Gen1Recomp's Gen2 BattleState; +-- battle decisions, damage, PP, queue consumption and time stay native. +local Version=require('src.core.GameVersion') +local View=require('src.battle.Gen2AnimView') +local Data=require('src.battle.Gen2AnimData') +local M={} + +function M.start(screen,id,isPlayer,opts) + local player=screen.animPlayer + if not (Version.isGen2() and player and Data.isGen2(player.anims) + and player:scriptFor(id)) then return false end + if player:start(id,isPlayer,opts)==false then return false end + screen.animPlaying=true + screen.animName,screen.animAttackerIsPlayer=id,isPlayer + screen:resetPicFx() + player.presentation=View.runner(player) + screen.anim=player.presentation + for _,event in ipairs(player:pollEffects())do screen:applyAnimEffect(event)end + return true +end + +function M.install(Screen) + function Screen:turnFor(side) return side=='enemy' and 1 or 0 end + function Screen:afterAnimFor(side,kind) + if kind=='statdown'then + return side=='player'and 'ANIM_ENEMY_STAT_DOWN'or 'ANIM_WOBBLE' + end + if kind=='damage'then + return side=='player'and 'ANIM_ENEMY_DAMAGE'or 'ANIM_PLAYER_DAMAGE' + end + end + function Screen:playHitSound(effectiveness) + if not effectiveness or effectiveness==0 then return end + local name=effectiveness>10 and 'Super_Effective' + or effectiveness<10 and 'Not_Very_Effective'or 'Damage' + require('src.core.Sound').play(self.data,name) + end + function Screen:animForMove(moveId,side,param,effectiveness,afterAnim) + local player=self.animPlayer + if not (Version.isGen2() and self:animationsOn() and player + and Data.isGen2(player.anims) and player:scriptFor(moveId)) then return false end + local started=M.start(self,moveId,side~='enemy',{param=param or 0}) + if started then + local name=self:afterAnimFor(side,afterAnim) + self.pendingAfterAnim=name and {name=name,side=side, + effectiveness=afterAnim=='damage'and effectiveness or nil}or nil + self.afterAnimPlayed=nil + end + return started + end + function Screen:animForId(id,side,param) + return M.start(self,id,side~='enemy',{param=param or 0}) + end + function Screen:startPendingAfterAnim() + local pending=self.pendingAfterAnim + if not pending then return false end + self.pendingAfterAnim=nil + if self:animForId(pending.name,pending.side)then + self:playHitSound(pending.effectiveness) + self.afterAnimPlayed=true + return true + end + return false + end +end +return M diff --git a/src/battle/Gen2PictureArt.lua b/src/battle/Gen2PictureArt.lua new file mode 100644 index 000000000..40aa9e4e9 --- /dev/null +++ b/src/battle/Gen2PictureArt.lua @@ -0,0 +1,84 @@ +-- Native indexed picture replacements. Full six/seven-tile boxes compose +-- with the existing palette, resize, slide and lifted-row draw owners. +local Version=require('src.core.GameVersion') +local Pictures=require('src.battle.Gen2PicturePresentation') +local M={} +function M.back(screen,b) + if screen._gen2PicBack~=nil then return screen._gen2PicBack end + return b.isPlayer +end +local function record(screen) + local anims=screen.data and screen.data.battle_anims + local art=anims and anims.pictureArt + return art and art.version==1 and art or nil +end +function M.replacement(screen,b,back) + if not(Version.isGen2() and b)then return end + -- Keep the native faint picture owner; a combat substitute cannot replace + -- the Pokemon while that owner's cropped sink is running. + if b.fainted or screen:fxFaintActive(b)then return end + local art=record(screen) + local kind=Pictures.override(screen,b) + local row=art and type(kind)=='string' and art[kind] + if not row then return end + if back==nil then back=M.back(screen,b)end + local path=row[back and 'player'or'enemy'] + local images=screen._gen2PictureImages + if not(type(path)=='string' and images)then return end + local pal=images.palette(screen.data,b.mon.species, + require('src.pokemon.Pokemon').isShiny(b.mon)) + return images.load(path,pal,false),kind +end +function M.install(Screen,images) + Screen._gen2PictureImages=images + function Screen:gen2ObjectPictureSource(side) + if not Version.isGen2()then return end + local b=self[side] + if not b then return end + local art=record(self) + local kind=Pictures.override(self,b) + local row=art and type(kind)=='string'and art[kind] + local path=row and row[side] + if path then return {path=path}end + local image=Pictures.sprite(self,b) + local meta=images.source(image) + if meta then return {path=meta.path,crop=meta.crop,trueColor=meta.trueColor}end + end + local sprite=Screen.pictureSprite + function Screen:pictureSprite(b) + return M.replacement(self,b) or sprite(self,b) + end + local pic=Screen.battlerPic + function Screen:battlerPic(b) + local replacement=M.replacement(self,b) + if replacement then return self:picImage(replacement)end + return pic(self,b) + end + local path=Screen.pictureScalePath + function Screen:pictureScalePath(b,value) + if M.replacement(self,b)then + return images.path(Pictures.sprite(self,b)) or value + end + return path(self,b,value) + end + local doll=Screen.substituteDoll + function Screen:substituteDoll(back) + if not Version.isGen2()then return doll and doll(self,back)end + local art=record(self) + local path=art and art.substitute and art.substitute.sheet + if not path then return end + -- Public source callers own their palette operation. Return indexed art, + -- not an already coloured/register-remapped capture. + local image=images.load(path,nil,false) + if not image then return end + local cached=self._gen2DollQuads + if not(cached and cached.image==image)then + local w,h=image:getDimensions() + cached={image=image,front=love.graphics.newQuad(0,0,16,16,w,h), + back=love.graphics.newQuad(0,16,16,16,w,h)} + self._gen2DollQuads=cached + end + return image,back and cached.back or cached.front + end +end +return M diff --git a/src/battle/Gen2PicturePresentation.lua b/src/battle/Gen2PicturePresentation.lua new file mode 100644 index 000000000..8d6a0e2b6 --- /dev/null +++ b/src/battle/Gen2PicturePresentation.lua @@ -0,0 +1,166 @@ +-- Picture identity follows the existing GSC animation VM, not the earlier +-- native damage/effect calculation. No combat value or saved mon is delayed. +local Version=require('src.core.GameVersion') +local Data=require('src.battle.Gen2AnimData') +local M={} + +local function side(b) return b.isPlayer and 'player' or 'enemy' end +local function current(screen,p) + return screen[side(p.b)]==p.b and p.b.mon==p.mon +end +local function commit(p) + if p.kind=='transform' then + p.b.species=p.species + p.b.picAnim=p.picAnim + end +end +local function remove(screen,index,apply) + local p=table.remove(screen._gen2PictureChanges,index) + if apply and current(screen,p) then commit(p) end +end +local function pending(screen,b,kind) + for _,p in ipairs(screen._gen2PictureChanges or {})do + if p.b==b and p.mon==b.mon and (not kind or p.kind==kind)then return p end + end +end +local function mark(screen,b,kind) + local row=screen.moveAnimRow + local player=screen.animPlayer + if not (Version.isGen2() and row and screen:animationsOn() and player and Data.isGen2(player.anims) + and player:scriptFor(row.anim))then return end + local queued=false + for _,item in ipairs(screen.queue or {})do if item==row then queued=true;break end end + if not queued then return end + local p={b=b,mon=b.mon,row=row,kind=kind} + screen._gen2PictureChanges=screen._gen2PictureChanges or {} + table.insert(screen._gen2PictureChanges,p) + return p +end + +function M.transform(screen,b,target) + local function resolved(who) + local species=who.species or who.mon.species + for _,p in ipairs(screen._gen2PictureChanges or {})do + if p.b==who and p.mon==who.mon and p.kind=='transform'then species=p.species end + end + return species + end + local species,before=resolved(target),resolved(b) + -- Front entrance frames belong to this battler, never the target's live + -- runner. A back picture has no front entrance animation to borrow. + local animation=not b.isPlayer and require('src.pokemon.PicAnim').new(screen.data,species) or nil + local p=mark(screen,b,'transform') + if p then + p.beforeSprite,p.beforeSpecies=b.sprite,before + p.species,p.picAnim=species,animation + end + b.sprite=screen:speciesSprite(species,b.isPlayer) or b.sprite + if not p then commit({kind='transform',b=b,species=species,picAnim=animation})end +end + +function M.substitute(screen,b) + if Version.isGen2() then mark(screen,b,'substitute') end +end + +-- Native row admission binds the one actual VM (or selects the normal +-- disabled/missing-data fallback), never a parallel animation owner. +function M.admitted(screen,row,runner) + local i=1 + while i<=#(screen._gen2PictureChanges or {})do + local p=screen._gen2PictureChanges[i] + if p.row==row then + if runner then p.runner=runner;i=i+1 else remove(screen,i,true)end + else i=i+1 end + end +end + +-- Called after the native VM tick, before an after-animation replaces it. +-- Drawing only reads; it cannot advance this owner. +function M.sync(screen) + local i=1 + while i<=#(screen._gen2PictureChanges or {}) do + local p=screen._gen2PictureChanges[i] + local discard,apply=false,true + if not current(screen,p)then discard,apply=true,false + elseif p.runner then + local value=p.runner.picOverride[side(p.b)] + discard=p.runner~=screen.animPlayer.runtime or p.runner.stopped or value==p.kind + else + local queued=false + for _,row in ipairs(screen.queue or {})do if row==p.row then queued=true;break end end + discard=not queued + end + if discard then remove(screen,i,apply)else i=i+1 end + end +end + +function M.override(screen,b) + local overrides=screen.anim and screen.anim.picOverride + local value=overrides and overrides[side(b)] + -- false is an explicit dropsub, distinct from an unmodified tilemap. + if value~=nil then return value end + if pending(screen,b,'substitute')then return false end + return b.substituteHP and 'substitute' or nil +end + +function M.species(screen,b) + if not Version.isGen2()then return b.species or b.mon.species end + local p=pending(screen,b,'transform') + if p then return p.beforeSpecies end + local value=M.override(screen,b) + if type(value)=='table' and value.kind=='beatup' then + -- The wire parameter is wCurPartySpecies, not an index into the party. + -- Keep the pinned VM's public `slot` field for compatibility. + local catalog=screen.data.pokemon + local cached=screen._gen2BeatUpIdentity + if not(cached and cached.value==value and cached.catalog==catalog and cached.index==value.slot)then + cached={value=value,catalog=catalog,index=value.slot} + for name,def in pairs(catalog or {})do + if type(def)=='table' and def.index==value.slot then cached.species=name;break end + end + screen._gen2BeatUpIdentity=cached + end + if cached.species then return cached.species end + end + return b.species or b.mon.species +end + +function M.sprite(screen,b) + if not Version.isGen2()then return b.sprite end + local p=pending(screen,b,'transform') + if p then return p.beforeSprite end + local species=M.species(screen,b) + if species~=(b.species or b.mon.species)then + return screen:speciesSprite(species,b.isPlayer) or b.sprite + end + return b.sprite +end + +function M.install(Screen) + local sprite=Screen.pictureSprite + function Screen:pictureSprite(b) + if Version.isGen2()then return M.sprite(self,b)end + return sprite(self,b) + end + local pic=Screen.battlerPic + function Screen:battlerPic(b) + if Version.isGen2() and (pending(self,b,'transform') or M.sprite(self,b)~=b.sprite)then + return self:pictureStill(b) + end + return pic(self,b) + end + local visible=Screen.substituteVisible + function Screen:substituteVisible(b) + if Version.isGen2() and b then return M.override(self,b)=='substitute'end + return visible(self,b) + end + local read=Screen.animPicState + function Screen:animPicState(which) + local state=read and read(self,which) + if Version.isGen2() and self[which]then + state=state or {};state.pic=M.override(self,self[which]) + end + return state + end +end +return M diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 5772141ae..783c34151 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -360,6 +360,7 @@ MoveEffects.primary = { return { Strings("Too weak to make\na SUBSTITUTE!") } end user.mon.hp = user.mon.hp - cost + if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').substitute(battle,user) end user.substituteHP = cost + 1 -- _SubstituteText return { Strings("It created a\nSUBSTITUTE!") } @@ -386,7 +387,9 @@ MoveEffects.primary = { -- mosaic and HandleSpeciesGfxDataChange performs it at callback 46. -- Gen3MoveAnim owns that visual midpoint, so changing the FireRed sprite -- here would expose the copied species for the first 45 callbacks. - if GameVersion.get() ~= "firered" then + if GameVersion.isGen2() then + require('src.battle.Gen2PicturePresentation').transform(battle,user,target) + elseif GameVersion.get() ~= "firered" then user.sprite = battle:speciesSprite(target.mon.species, user.isPlayer) or user.sprite -- Black / White draw a live animation over `sprite`, so their picture diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index b8a4a4ca5..71b4bcafc 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -78,9 +78,10 @@ local function inRegion(x, y, w, h, dx, dy, fn) g.setScissor(x, y, w, h) g.push() g.translate(dx, dy) - fn() + local ok,why=pcall(fn) g.pop() restoreScissor(saved) + if not ok then error(why,0)end end local function levelAt(battle, battler, x, y) @@ -430,10 +431,12 @@ end local function currentAnimationSprites(battle) if battle.animPlaying and battle.animPlayer then + if battle.animPlayer.oam then return battle.animPlayer:oam() end local step = battle.animPlayer.steps[battle.animPlayer.stepIndex] return step and step.sprites end if battle.lockedBall and battle.animPlayer then + if battle.animPlayer.oam then return battle.animPlayer:oam(battle.lockedBall) end return battle.lockedBall end end @@ -447,7 +450,22 @@ local function drawAnimationLayer(battle) end -- The whole 304x144 composition for one frame. -function WideBattle.draw(battle) +-- Native picture regions are shared by the BG and lifted-OBJ passes. +function WideBattle.drawPicRegions(battle,slide,sx,sy) + sx,sy=sx or 0,sy or 0 + local previous=battle.wideRegion + battle.wideRegion = true + local ok,why=pcall(function() + inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy, + function() battle:drawPicsLayer(slide, 0, 0, "player", true) end) + inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy, + function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end) + end) + battle.wideRegion = previous + if not ok then error(why,0)end +end + +local function drawPanel(battle,withObjects) local g = love.graphics -- The field is the display mode's paper. Under a forced-mono mode the -- whole surface is remapped downstream (WideBattle.zones), so the field @@ -459,7 +477,9 @@ function WideBattle.draw(battle) else g.setColor(PaletteFX.paperShade(battle.data)) end - g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) + if not(type(battle.worldBackdrop)=='function'and battle:worldBackdrop())then + g.rectangle("fill",0,0,WideBattle.WIDTH,WideBattle.HEIGHT) + end -- AskName clears the field the same way the classic layout does if battle.blankForAskName then return end @@ -480,12 +500,7 @@ function WideBattle.draw(battle) -- each region's clip with its contents, so a pic pushed toward a region -- edge is not sheared off it (a vertical shake used to clip the player's -- feet at FIELD_BOTTOM). - battle.wideRegion = true - inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy, - function() battle:drawPicsLayer(slide, 0, 0, "player", true) end) - inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy, - function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end) - battle.wideRegion = nil + WideBattle.drawPicRegions(battle,slide,sx,sy) -- A battle sets rWY to 0 (engine/battle/core.asm), so the window the -- shakes move IS the whole screen: PredefShakeScreenHorizontally, @@ -503,9 +518,20 @@ function WideBattle.draw(battle) g.pop() end shaken(function() drawHUDs(battle, slide) end) - drawAnimationLayer(battle) + if withObjects then drawAnimationLayer(battle) end shaken(function() drawTextArea(battle) end) +end + +function WideBattle.draw(battle) + if require('src.core.GameVersion').isGen2() then + return battle:drawScene(function() + battle:drawSceneBody(function()drawPanel(battle,false)end, + function()drawAnimationLayer(battle)end,WideBattle.WIDTH,WideBattle.HEIGHT) + end) + end + drawPanel(battle,true) + local g,fx=love.graphics,battle.fx if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then g.setColor(1, 1, 1, 0.85) g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) diff --git a/src/battle/gen2/AnimObjects.lua b/src/battle/gen2/AnimObjects.lua new file mode 100644 index 000000000..f97ac421e --- /dev/null +++ b/src/battle/gen2/AnimObjects.lua @@ -0,0 +1,2023 @@ +-- Gen1Recomp-derived runtime: GPLv3 plus Additional Terms. +-- See compat/gen1-gsc/LICENSE.MD and pinned provenance.json. +-- The Gen 2 battle-animation OBJECT runtime. +-- +-- pokegold engine/battle_anims/core.asm (the struct pool and the OAM writer), +-- engine/battle_anims/helpers.asm (GetBattleAnimFrame) and +-- engine/battle_anims/functions.asm (the 80 per-frame functions). The DATA +-- these run on -- objects, framesets, OAM sets, GFX sheets -- is extracted +-- into `data/generated/battle_anims.lua`; nothing here is transcribed by hand. +-- +-- Ten structs, and one of them is picked by QueueBattleAnimation the same way +-- _InitSpriteAnimStruct picks a sprite-anim slot: the first with INDEX == 0. +-- Every field is a byte and wraps, and several functions rely on that: an +-- object walks its Y offset down past 0 into $ff.. and reads the result back +-- as a negative pixel offset. +-- +-- Coordinates are hardware OAM coordinates: an OBJ at struct (x, y) draws at +-- (x - 8, y - 16) on the 160x144 screen. `xOffset`/`yOffset` are signed bytes +-- added on top, which is what every sine-driven function writes. +-- +-- Two traps this file exists to get right: +-- +-- * ASM fallthrough is not the same as a jumptable branch. A dozen of these +-- functions end their `.zero` case by dropping straight into `.one` on the +-- SAME frame, so an object that only ran its init would be a frame late for +-- the rest of its life. Every one of those is written out here. +-- * `ld a, [hl]` followed by `inc [hl]` leaves `a` holding the value from +-- BEFORE the increment. Dizzy's frameset flip and Perish Song's descent +-- both read the pre-increment value, and using the new one desynchronises +-- the animation from its own frameset. +-- +-- Love-free on purpose: src/ui/gen2/BattleAnimView.lua turns the OAM list into +-- draw calls, and the tests step whole animations with no window at all. + +local bit = require("bit") +-- The sine table is engine/math/sine.asm's `sine_table 32`, shared with the +-- overworld/intro sprite anims: BattleAnim_Sine is `calc_sine_wave +-- BattleAnimSineWave`, the same macro over the same 32-entry quarter wave. +local SpriteAnims = require("src.battle.Gen2AnimMath") + +local AnimObjects = {} + +local NUM_STRUCTS = 10 -- NUM_BATTLE_ANIM_STRUCTS +-- wShadowOAM is 40 objects; BattleAnimOAMUpdate returns carry once it is full +-- and BattleAnim_UpdateOAM_All stops walking the structs. +local OAM_LIMIT = 40 +local EMPTY = {} + +local OAM_PRIO, OAM_YFLIP, OAM_XFLIP = 0x80, 0x40, 0x20 +local OAM_FLAG_MASK = 0xe0 +local OAM_PAL1 = 0x10 +-- BATTLEANIMSTRUCT_OAMFLAGS_FIX_COORDS_F: bit 0 of the object's flags byte +-- means "mirror this object onto the other battler's side when the enemy is +-- the one attacking". +local FIX_COORDS = 0x01 + +-------------------------------------------------------------------------- +-- Byte arithmetic +-------------------------------------------------------------------------- + +local function u8(value) return value % 256 end + +-- The byte read as a signed value, which is what every `bit 7, a` test and +-- every coordinate add is really doing. +local function s8(value) + value = value % 256 + return value < 0x80 and value or value - 256 +end + +-- `sra a`: arithmetic shift right, sign preserved. +local function sra(value) return u8(math.floor(s8(value) / 2)) end + +-- `swap a` +local function swap(value) + value = u8(value) + return bit.bor(bit.rshift(value, 4), bit.band(bit.lshift(value, 4), 0xf0)) +end + +-- `rlca` +local function rlca(value) + value = u8(value) + return u8(bit.lshift(value, 1) + bit.rshift(value, 7)) +end + +local sine = SpriteAnims.sine +local cosine = SpriteAnims.cosine + +-------------------------------------------------------------------------- +-- The struct pool +-------------------------------------------------------------------------- + +local Pool = {} +Pool.__index = Pool + +local function newStruct() + return { + index = 0, oamFlags = 0, fixY = 0, framesetId = 0, func = 0, palette = 0, + tileId = 0, x = 0, y = 0, xOffset = 0, yOffset = 0, param = 0, + duration = 0, frame = 0xff, jt = 0, var1 = 0, var2 = 0, + } +end + +-- `data` is the cache's battle_anims.lua table; `constants` the cache's +-- constants.lua (for the ordered name lists an id indexes into). +-- +-- `env` is what the battle screen owns and this pool only reads: +-- env.battleTurn hBattleTurn: 0 while the player is attacking +-- env.animId wFXAnimID, the move whose script is running (KINESIS, +-- SOFTBOILED and MILK_DRINK get their own Y nudge) +-- env.ballPalette the PAL_BATTLE_OB_* name GetBallAnimPal resolves for +-- wCurItem, or nil outside a ball throw +-- env.sgb hSGB, which only Sky Attack's palette cycle reads +function AnimObjects.new(data, constants, env) + local self = setmetatable({}, Pool) + self.data = data or {} + self.env = env or {} + self.structs = {} + for slot = 1, NUM_STRUCTS do self.structs[slot] = newStruct() end + self.lastIndex = 0 -- wLastAnimObjectIndex + self.oam = {} + + constants = constants or {} + self.objectOrder = constants.battleAnimObjectOrder or {} + self.framesetOrder = constants.battleAnimFramesetOrder or {} + -- Name -> numeric id, because several functions do frameset ARITHMETIC + -- (`ld a, BATTLE_ANIM_FRAMESET_SOUND_1; add [hl]`) while the extractor + -- writes names. Keeping the struct's FRAMESET_ID numeric is what makes + -- those adds mean the same thing they do on the cart. + self.framesetIds = {} + for index, name in ipairs(self.framesetOrder) do + self.framesetIds[name] = index - 1 + end + -- engine/battle_anims/functions.asm:1158 + self.hram = nil + self.obp0 = nil + return self +end + +function Pool:clear() + for slot = 1, NUM_STRUCTS do self.structs[slot] = newStruct() end + self.lastIndex = 0 + self.oam = {} + self.obp0 = nil +end + +-- BattleAnimCmd_ClearObjs. The cart's loop clears $a0 bytes from +-- wActiveAnimObjects and BATTLEANIMSTRUCT_LENGTH is $18, so it reaches six +-- whole structs plus the first sixteen bytes of the seventh -- enough to zero +-- that one's INDEX, and no further. Structs 8-10 keep running: that is the +-- documented bug (docs/bugs_and_glitches.md), and an animation that spawns +-- more than seven objects visibly depends on it. +function Pool:clearObjs() + for slot = 1, 7 do self.structs[slot] = newStruct() end +end + +function Pool:framesetId(name) + local id = self.framesetIds[name] + if not id then error("unknown battle anim frameset: " .. tostring(name)) end + return id +end + +-- ReinitBattleAnimFrameset: swap framesets and restart the frame walk. +local function reinit(st, framesetId) + st.framesetId = u8(framesetId) + st.duration = 0 + st.frame = 0xff -- `ld [hl], -1` +end + +local function deinit(st) st.index = 0 end + +-- InitBattleAnimation. The object row's six bytes land in the struct in +-- order; the seventh field, TILEID, comes from the tile dict instead +-- (GetBattleAnimTileOffset), because where a sheet ended up in VRAM is a +-- property of the running script and not of the object. +function Pool:queue(objectId, x, y, param, tileOffsetFor) + local name = objectId + if type(objectId) == "number" then + name = self.objectOrder[objectId + 1] or objectId + end + local object = (self.data.objects or {})[name] + if not object then return nil end + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index == 0 then + self.lastIndex = u8(self.lastIndex + 1) + st.index = self.lastIndex + st.oamFlags = object.flags or 0 + st.fixY = object.fixY or 0 + st.framesetId = self.framesetIds[object.frameset] or 0 + st.func = object.func or 0 + st.palette = object.palette or 0 + st.tileId = tileOffsetFor and tileOffsetFor(object.gfx) or 0 + st.x, st.y = u8(x), u8(y) + st.xOffset, st.yOffset = 0, 0 + st.param = u8(param or 0) + st.duration = 0 + st.frame = 0xff + st.jt, st.var1, st.var2 = 0, 0, 0 + st.objectId = name + return st + end + end + -- QueueBattleAnimation returns carry when all ten are busy; the script does + -- not look, and neither does anything here. + return nil +end + +function Pool:findByIndex(value) + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index == value then return st end + end + return nil +end + +function Pool:activeCount() + local count = 0 + for slot = 1, NUM_STRUCTS do + if self.structs[slot].index ~= 0 then count = count + 1 end + end + return count +end + +-------------------------------------------------------------------------- +-- GetBattleAnimFrame (engine/battle_anims/helpers.asm) +-------------------------------------------------------------------------- + +-- What a frameset row yields: the OAM set name, or the "wait" / "delete" +-- pseudo-commands BattleAnimOAMUpdate acts on, plus the frame's flip flags. +local function frameYield(row) + local kind = row[1] + if kind == "wait" then return "wait", 0 end + if kind == "delete" then return "delete", 0 end + return row[2], row[4] or 0 +end + +-- `oamwait n` is not skipped here: GetBattleAnimFrame stores n as the struct's +-- duration and hands the command itself back, so the struct genuinely spends +-- n frames drawing nothing. Only BattleAnimOAMUpdate knows what to do with it. +function Pool:getFrame(st) + local frames = (self.data.framesets or {})[self.framesetOrder[st.framesetId + 1]] + if not frames then return nil, 0 end + for _ = 1, 64 do + if st.duration ~= 0 then + st.duration = st.duration - 1 + local row = frames[st.frame + 1] + if not row then return nil, 0 end + return frameYield(row) + end + st.frame = u8(st.frame + 1) + local row = frames[st.frame + 1] + if not row then return nil, 0 end + local kind = row[1] + if kind == "restart" then + st.duration = 0 + st.frame = 0xff + elseif kind == "end" then + -- Step back two so the next pass lands on the frame before this one and + -- then holds it forever. + st.duration = 0 + st.frame = u8(st.frame - 2) + elseif kind == "delete" then + -- `oamdelete` carries no argument; the cart reads the next byte as a + -- duration anyway and then throws the whole struct away, so it does not + -- matter what lands here. + st.duration = 0 + return "delete", 0 + elseif kind == "wait" then + st.duration = u8(row[2]) + return "wait", 0 + else + st.duration = u8(row[3]) + return frameYield(row) + end + end + error("battle anim frameset never yields a frame: " + .. tostring(self.framesetOrder[st.framesetId + 1])) +end + +-------------------------------------------------------------------------- +-- BattleAnimOAMUpdate (engine/battle_anims/core.asm) +-------------------------------------------------------------------------- + +-- AddOrSubtractY / AddOrSubtractX: a flipped entry mirrors around its own +-- 8-pixel cell, which is `-(offset + 8)`. +local function mirror(value, flip) + if not flip then return value end + return u8(-(u8(value) + 8)) +end + +-- InitBattleAnimBuffer. On the enemy's turn the whole object is reflected +-- onto the other side of the field -- but only if its OAMFLAGS ask for it. +function Pool:initBuffer(st) + local buf = { + oamFlags = bit.band(st.oamFlags, OAM_PRIO), + palette = st.palette, + tileId = st.tileId, + x = st.x, y = st.y, + xOffset = st.xOffset, yOffset = st.yOffset, + } + if (self.env.battleTurn or 0) == 0 then return buf end + buf.oamFlags = st.oamFlags + if bit.band(st.oamFlags, FIX_COORDS) == 0 then return buf end + -- x' = (-10 tiles + 4) - x: reflected about the middle of the field. + buf.x = u8((-10 * 8 + 4) - st.x) + if st.fixY == 0xff then + buf.y = u8(5 * 8 + st.y) + else + local y = u8(st.fixY - st.y) + local animId = self.env.animId + -- The three self-targeting animations whose object sits one tile higher + -- on the enemy's side. + if animId == "KINESIS" or animId == "SOFTBOILED" or animId == "MILK_DRINK" then + y = u8(y - 8) + end + buf.y = y + end + buf.xOffset = u8(-st.xOffset) + return buf +end + +-- One struct's OAM entries appended to self.oam. Returns true once the +-- 40-object shadow OAM is full, which is the carry the caller stops on. +function Pool:updateOam(st) + local buf = self:initBuffer(st) + local oamsetName, frameFlags = self:getFrame(st) + if oamsetName == "wait" or oamsetName == nil then return false end + if oamsetName == "delete" then + deinit(st) + return false + end + buf.oamFlags = bit.band(bit.bxor(frameFlags, buf.oamFlags), OAM_FLAG_MASK) + local set = (self.data.oamsets or {})[oamsetName] + if not set then return false end + local tileId = u8(buf.tileId + (set.vtile or 0)) + local yFlip = bit.band(buf.oamFlags, OAM_YFLIP) ~= 0 + local xFlip = bit.band(buf.oamFlags, OAM_XFLIP) ~= 0 + local oam = self.oam + local pool = self.oamPool + for _, entry in ipairs(set.sprites or EMPTY) do + local n = #oam + if n >= OAM_LIMIT then return true end + -- GetSpriteOAMAttr: the frame's flip/priority flags toggle the entry's; + -- OAM_PAL1 passes through from the entry, and the palette slot comes from + -- the struct. + local attr = bit.band(bit.bxor(entry.attr or 0, buf.oamFlags), OAM_FLAG_MASK) + attr = attr + bit.band(entry.attr or 0, OAM_PAL1) + -- Shadow OAM slots are recycled frame to frame (playFrame), the way the + -- cart rewrites wShadowOAM in place: the 40 tables are made once. + local out = pool and pool[n + 1] + if not out then + out = {} + if pool then pool[n + 1] = out end + end + out.y = u8(buf.y + buf.yOffset + mirror(entry.y or 0, yFlip)) + out.x = u8(buf.x + buf.xOffset + mirror(entry.x or 0, xFlip)) + -- BATTLEANIM_BASE_TILE is added here on the cart and subtracted again by + -- every sheet lookup, so the port keeps tiles in sheet-relative space. + out.tile = u8(tileId + (entry.tile or 0)) + out.attr = attr + out.palette = buf.palette + oam[n + 1] = out + end + return false +end + +-- BattleAnim_UpdateOAM_All: run every live struct's function, then let it +-- write its OAM entries. A struct that deinitialises itself inside its +-- function still draws this frame, because the ASM calls BattleAnimOAMUpdate +-- unconditionally. +-- +-- The OAM list and its entries are reused from frame to frame: what a frame +-- returns is valid until the next playFrame, which is all the draw needs. +function Pool:playFrame() + local oam = self.oamList + if not oam then + oam = {} + self.oamList = oam + self.oamPool = {} + end + for i = #oam, 1, -1 do oam[i] = nil end + self.oam = oam + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index ~= 0 then + local fn = AnimObjects.FUNCTIONS[st.func] + if fn then fn(self, st) end + if self:updateOam(st) then break end + end + end + return self.oam +end + +-------------------------------------------------------------------------- +-- engine/battle_anims/functions.asm +-------------------------------------------------------------------------- + +local function incJt(st) st.jt = u8(st.jt + 1) end + +-- BattleAnim_StepToTarget: inches the object toward the opponent's side, half +-- as far vertically as horizontally. The `dec [hl]` loop runs BEFORE `dec e` +-- is tested, so a vertical step of 0 walks the Y coordinate 256 times -- right +-- back where it started, which is the point. +local function stepToTarget(st, speed) + local e = bit.band(speed, 0xf) + st.x = u8(st.x + e) + local steps = bit.rshift(e, 1) + st.y = u8(st.y - (steps == 0 and 256 or steps)) +end + +-- BattleAnim_StepCircle: circular movement whose height is a quarter of its +-- width. +local function stepCircle(st, angle, radius) + st.yOffset = sra(sra(sine(angle, radius))) + st.xOffset = cosine(angle, radius) +end + +-- A 16-bit accumulator spread over two byte fields, which is how every +-- sub-pixel movement here is done: the HIGH byte is the pixel coordinate and +-- the LOW byte the fraction. +local function add16(high, low, delta) + local value = (u8(high) * 256 + u8(low) + delta) % 0x10000 + return math.floor(value / 256), value % 256 +end + +local F = {} + +F.BATTLE_ANIM_FUNC_NULL = function(_, st) + -- anim_incobj is what walks this one to `.one`, which deletes the object. + if st.jt ~= 0 then deinit(st) end +end + +-- BattleAnimFunction_ThrowFromUserToTarget: right 2 and up 1 a frame, with the +-- object's PARAM as the amplitude of a sine on the Y offset. Returns true for +-- "still going", which is the carry the AndDisappear wrapper reads. +local function throwToTarget(st) + if st.x >= 0x88 then return false end + st.x = u8(st.x + 2) + st.y = u8(st.y - 1) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, st.param) + return true +end + +F.BATTLE_ANIM_FUNC_THROW_TO_TARGET = function(_, st) throwToTarget(st) end + +F.BATTLE_ANIM_FUNC_THROW_TO_TARGET_DISAPPEAR = function(_, st) + if not throwToTarget(st) then deinit(st) end +end + +F.BATTLE_ANIM_FUNC_WAVE_TO_TARGET = function(_, st) + if st.x >= 0x88 then + deinit(st) + return + end + st.x = u8(st.x + 2) + st.y = u8(st.y - 1) + local angle = st.var1 + st.var1 = u8(st.var1 + 4) + st.yOffset = sine(angle, 0x10) + -- The X offset is the cosine divided by sixteen, so the wave is much + -- flatter across than it is up. + st.xOffset = sra(sra(sra(sra(cosine(angle, 0x10))))) +end + +F.BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE = function(_, st) + if st.jt == 0 then + incJt(st) + -- Bit 7 of PARAM starts the object half a turn round; the rest is the + -- radius, so the flag has to come off before it is used as one. + st.var1 = bit.band(st.param, 0x80) ~= 0 and 0x20 or 0 + st.param = bit.band(st.param, 0x7f) + end + local angle = st.var1 + st.yOffset = sine(angle, st.param) + st.xOffset = cosine(angle, st.param) + st.var1 = u8(st.var1 + 1) +end + +F.BATTLE_ANIM_FUNC_USER_TO_TARGET = function(_, st) + if st.jt ~= 0 then + deinit(st) + return + end + if st.x >= 0x84 then return end + stepToTarget(st, st.param) +end + +F.BATTLE_ANIM_FUNC_USER_TO_TARGET_DISAPPEAR = function(_, st) + if st.x >= 0x84 then + deinit(st) + return + end + stepToTarget(st, st.param) +end + +-- GetBallAnimPal: the thrown ball wears the colour of the ball being thrown +-- (data/battle_anims/ball_colors.asm). The battle screen resolves that for +-- wCurItem and hands it over as env.ballPalette. +local function ballPal(self, st) + if self.env.ballPalette then st.palette = self.env.ballPalette end +end + +-- .four: the ball bounces on a shrinking sine while VAR2 steps down by four; +-- when it reaches zero the ball opens. +local function pokeballBounce(self, st) + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 - 1) + if bit.band(st.var1, 0x1f) ~= 0 then return end + -- `ld [hl], a` after the mask: VAR1 is zeroed, not just left on a boundary, + -- so every bounce starts from the same phase. + st.var1 = 0 + st.var2 = u8(st.var2 - 4) + if st.var2 ~= 0 then return end + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_4")) + incJt(st) +end + +-- .eight/.ten: the same sine, but every $10 steps the jumptable advances, so +-- the caught / broke-free branch is only ever reached on a shake boundary. +local function pokeballWobble(_, st) + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 - 1) + if bit.band(st.var1, 0x1f) == 0 then + deinit(st) + return + end + if bit.band(st.var1, 0xf) ~= 0 then return end + incJt(st) +end + +F.BATTLE_ANIM_FUNC_POKEBALL = function(self, st) + local jt = st.jt + if jt == 0 then + ballPal(self, st) + incJt(st) + elseif jt == 1 then + if throwToTarget(st) then return end + -- The arc's Y offset is folded into the coordinate before the ball + -- switches to its opening frameset, so it lands where it fell. + st.y = u8(st.y + st.yOffset) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_3")) + incJt(st) + elseif jt == 3 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_1")) + st.var1, st.var2 = 0, 0x10 + pokeballBounce(self, st) -- .three falls into .four + elseif jt == 4 then + pokeballBounce(self, st) + elseif jt == 6 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_5")) + st.jt = u8(st.jt - 1) + elseif jt == 7 then + ballPal(self, st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_2")) + incJt(st) + st.var2 = 0x20 + pokeballWobble(self, st) -- .seven falls into .eight + elseif jt == 8 or jt == 10 then + pokeballWobble(self, st) + elseif jt == 11 then + deinit(st) + end +end + +local function pokeballBlockedFall(_, st) + if st.y >= 0x80 then + deinit(st) + return + end + st.y = u8(st.y + 4) + st.x = u8(st.x - 2) +end + +F.BATTLE_ANIM_FUNC_POKEBALL_BLOCKED = function(self, st) + local jt = st.jt + if jt == 0 then + ballPal(self, st) + incJt(st) + elseif jt == 1 then + if st.x < 0x70 then + throwToTarget(st) + return + end + incJt(st) + pokeballBlockedFall(self, st) -- .next falls into .two + elseif jt == 2 then + pokeballBlockedFall(self, st) + end +end + +F.BATTLE_ANIM_FUNC_EMBER = function(self, st) + local jt = st.jt + if jt == 0 then + -- The upper nybble of PARAM picks which branch this object runs. + st.jt = bit.band(swap(st.param), 0xf) + elseif jt == 1 then + if st.x >= 0x88 then return end + stepToTarget(st, st.param) + elseif jt == 2 then + deinit(st) + elseif jt == 3 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_FLAMETHROWER")) + end +end + +F.BATTLE_ANIM_FUNC_DROP = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1, st.var2 = 0x30, 0x48 + end + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x3f) ~= 0 then return end + st.var1 = 0x20 + -- Each bounce loses PARAM off the amplitude; once that would go to zero or + -- below, the object is done. + local left = st.var2 - st.param + if left <= 0 then + deinit(st) + return + end + st.var2 = left +end + +F.BATTLE_ANIM_FUNC_USER_TO_TARGET_SPIN = function(_, st) + -- .SetCoords: the lower nybble of PARAM is the horizontal step, half of it + -- the vertical one. + local function setCoords() + local e = bit.band(st.param, 0xf) + st.x = u8(st.x + e) + local steps = bit.rshift(e, 1) + st.y = u8(st.y - (steps == 0 and 256 or steps)) + end + -- .two: a circle whose top is flattened -- the cosine is pulled down by its + -- own radius and halved. + local function orbit() + if st.var1 < 0x40 then + st.yOffset = sra(u8(cosine(st.var1, 0x18) - 0x18)) + st.xOffset = sine(st.var1, 0x18) + st.var1 = u8(st.var1 + bit.band(st.param, 0xf)) + return + end + -- .loop_back: the upper nybble is a lap counter. + local laps = bit.band(st.param, 0xf0) + if laps == 0 then + incJt(st) -- .finish falls into .three + if st.x >= 0xb0 then + deinit(st) + else + setCoords() + end + return + end + st.param = bit.band(st.param, 0xf) + (laps - 0x10) + st.jt = u8(st.jt - 1) + end + local jt = st.jt + if jt == 0 then + if st.x < 0x80 then + setCoords() + return + end + -- .next -> .one -> .two, all on this frame. + incJt(st) + incJt(st) + st.var1 = 0 + orbit() + elseif jt == 1 then + incJt(st) + st.var1 = 0 + orbit() + elseif jt == 2 then + orbit() + elseif jt == 3 then + if st.x >= 0xb0 then + deinit(st) + return + end + setCoords() + end +end + +F.BATTLE_ANIM_FUNC_SHAKE = function(_, st) + -- .done_one: hold for the upper nybble of PARAM, then jump to the other side. + local function flip() + st.var1 = bit.band(swap(st.param), 0xf) + st.xOffset = u8(-st.xOffset) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0 + st.xOffset = bit.band(st.param, 0xf) + flip() -- .zero falls into .one, and VAR1 is 0, so .done_one runs at once + elseif jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + return + end + flip() + elseif jt == 2 then + deinit(st) + end +end + +F.BATTLE_ANIM_FUNC_FIRE_BLAST = function(self, st) + -- .eight: the travelling flame spirals once it arrives. + local function spin() + local angle = st.var1 + st.yOffset = sine(angle, 0x10) + st.xOffset = cosine(angle, 0x10) + st.var1 = u8(st.var1 + 1) + end + -- .seven: straight across, then hand over to the spiral. + local function travel() + if st.x < 0x88 then + st.x = u8(st.x + 2) + st.y = u8(st.y - 1) + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EMBER")) + spin() -- .set_up_eight falls into .eight + end + local jt = st.jt + if jt == 0 then + -- PARAM picks the branch outright: 7 is the flame that travels, and the + -- rest are the five arms of the blast, which only drift. + st.jt = st.param + if st.param == 7 then + travel() + else + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_BURNED")) + end + elseif jt == 1 then + st.yOffset = u8(st.yOffset - 1) + elseif jt == 2 then + st.xOffset = u8(st.xOffset - 1) + elseif jt == 3 then + st.xOffset = u8(st.xOffset + 1) + elseif jt == 4 then + st.yOffset = u8(st.yOffset + 1) + st.xOffset = u8(st.xOffset - 1) + elseif jt == 5 then + st.yOffset = u8(st.yOffset + 1) + st.xOffset = u8(st.xOffset + 1) + elseif jt == 7 then + travel() + elseif jt == 8 then + spin() + elseif jt == 9 then + deinit(st) + end +end + +-- BattleAnim_ScatterHorizontal: a 16-bit per-frame X step picked from the +-- object's PARAM, so a screenful of leaves fans out instead of moving as one. +local function scatterHorizontal(st) + local param = st.param + if bit.band(param, 0x80) == 0 then + if param >= 0x20 then return 0x100 end + if param >= 0x18 then return 0x180 end + return 0x200 + end + local masked = bit.band(param, 0x3f) + if masked >= 0x20 then return -0x100 end + if masked >= 0x18 then return -0x180 end + return -0x200 +end + +F.BATTLE_ANIM_FUNC_RAZOR_LEAF = function(self, st) + local function arcStep() + local radius = bit.band(st.param, 0x3f) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, radius) + st.x, st.var2 = add16(st.x, st.var2, scatterHorizontal(st)) + end + local function arcOrLand() + if st.var1 >= 0x30 then + arcStep() + return + end + incJt(st) + st.var1, st.var2 = 0, 0 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_RAZOR_LEAF_2")) + -- Bit 6 starts the second frameset six frames in, which is the leaf + -- already half-turned. + if bit.band(st.param, 0x40) ~= 0 then st.frame = 5 end + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0x40 + arcOrLand() -- .zero falls into .one + elseif jt == 1 then + arcOrLand() + elseif jt == 2 then + if st.yOffset == 0x20 then + deinit(st) + return + end + st.xOffset = sine(st.var1, 0x10) + if bit.band(st.param, 0x40) ~= 0 then + st.var1 = u8(st.var1 - 1) + else + st.var1 = u8(st.var1 + 1) + end + st.yOffset, st.var2 = add16(st.yOffset, st.var2, 0x80) + elseif jt == 3 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_RAZOR_LEAF_1")) + st.oamFlags = bit.band(st.oamFlags, 0xff - OAM_XFLIP) + incJt(st) + elseif jt >= 4 and jt <= 7 then + incJt(st) + elseif jt == 8 then + if st.x >= 0xc0 then return end + stepToTarget(st, 8) + end +end + +F.BATTLE_ANIM_FUNC_ROCK_SMASH = function(self, st) + if st.jt == 0 then + -- Bit 6 picks between the two rock framesets. + st.framesetId = u8(rlca(rlca(bit.band(st.param, 0x40))) + + self:framesetId("BATTLE_ANIM_FRAMESET_BIG_ROCK")) + incJt(st) + st.var1 = 0x40 + end + if st.var1 < 0x30 then + deinit(st) + return + end + local radius = bit.band(st.param, 0x3f) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, radius) + st.x, st.var2 = add16(st.x, st.var2, scatterHorizontal(st)) +end + +F.BATTLE_ANIM_FUNC_BUBBLE = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0xc + jt = 1 + end + if jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + stepToTarget(st, st.param) + return + end + incJt(st) + st.var1 = 0 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_PULSING_BUBBLE")) + return + end + if jt ~= 2 then return end + if st.x < 0x98 then + st.x, st.var1 = add16(st.x, st.var1, 0x60) + end + if st.y < 0x20 then return end + -- The upper nybble of PARAM is a per-frame rise; `ld d, $ff` is what makes + -- it a NEGATIVE 16-bit step. + st.y, st.var2 = add16(st.y, st.var2, bit.band(st.param, 0xf0) - 0x100) +end + +-- engine/battle_anims/functions.asm:1148 +F.BATTLE_ANIM_FUNC_SURF = function(self, st) + local hram = self.hram + local jt = st.jt + if jt == 0 then + incJt(st) + if hram then + hram.lcdc, hram.lyStart, hram.lyEnd = "SCY", 0x58, 0x5e + end + return + end + if jt == 1 then + if st.y < st.param then + incJt(st) + if hram then hram.lyStart = 0 end + return + end + st.y = u8(st.y - 1) + st.yOffset = sine(st.var1, 0x10) + local top = st.yOffset + st.y - 0x10 + -- `ret c`: the wave stops climbing entirely on the frames the subtraction + -- underflows, offsets and all. + if top < 0 then return end + if hram then hram.lyStart = u8(top) end + st.xOffset = bit.band(st.xOffset + 1, 7) + st.var1 = u8(st.var1 + 2) + return + end + if jt == 3 then + if st.y >= 0x70 then + if hram then + hram.lcdc, hram.lyStart, hram.lyEnd = nil, 0, 0 + end + deinit(st) + return + end + st.y = u8(st.y + 2) + local top = st.y - 0x10 + if top < 0 then return end + if hram then hram.lyStart = u8(top) end + return + end + if jt == 4 then deinit(st) end +end + +F.BATTLE_ANIM_FUNC_SING = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1") + st.param) + end + if st.x >= 0xb8 then + deinit(st) + return + end + stepToTarget(st, 2) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, 8) +end + +F.BATTLE_ANIM_FUNC_WATER_GUN = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + jt = 1 + end + if jt == 1 then + if st.y >= 0x30 then + stepToTarget(st, 2) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, 8) + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_WATER_GUN_2")) + st.yOffset = 0 + st.y = 0x30 + -- Everything but FIX_COORDS is dropped, so the splash never flips. + st.oamFlags = bit.band(st.oamFlags, FIX_COORDS) + jt = 2 + end + if jt ~= 2 then return end + if st.yOffset >= 0x18 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_WATER_GUN_3")) + return + end + st.yOffset = u8(st.yOffset + 1) +end + +F.BATTLE_ANIM_FUNC_POWDER = function(_, st) + if st.yOffset >= 0x38 then + deinit(st) + return + end + st.yOffset, st.var1 = add16(st.yOffset, st.var1, 0x80) + -- Shakes sixteen pixels either side by toggling one bit. + st.xOffset = bit.bxor(st.xOffset, 0x10) +end + +F.BATTLE_ANIM_FUNC_RECOVER = function(_, st) + if st.jt == 0 then + incJt(st) + st.var2 = bit.band(st.param, 0xf0) -- radius + st.var1 = u8(bit.band(st.param, 0xf) * 8) -- starting angle + st.param = 1 -- reused as an every-other-frame toggle + end + if st.var2 == 0 then + deinit(st) + return + end + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.yOffset = sine(angle, st.var2) + st.xOffset = cosine(angle, st.var2) + st.param = bit.bxor(st.param, 1) + if st.param == 0 then return end + st.var2 = u8(st.var2 - 1) +end + +F.BATTLE_ANIM_FUNC_THUNDER_WAVE = function(self, st) + if st.jt == 1 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_THUNDER_WAVE_EXTRA")) + elseif st.jt == 3 then + deinit(st) + end +end + +-- Clamp/Encore: two halves clap together, twice. The frameset the object +-- switches to is the base or the base + 1 (CLAMP_FLIPPED / ENCORE_HAND_ +-- FLIPPED), picked by the SIGN of the sine, so both halves close together. +F.BATTLE_ANIM_FUNC_CLAMP_ENCORE = function(self, st) + local function step() + local value = sine(st.var1, st.param) + st.xOffset = value + reinit(st, bit.band(value, 0x80) ~= 0 and st.var2 or u8(st.var2 + 1)) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x1f) ~= 0 then return end + incJt(st) -- falls into .two, which is a bare IncAnonJumptableIndex + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var2 = st.framesetId + st.var1 = bit.band(st.param, 0x80) ~= 0 and 0x30 or 0x10 + st.param = bit.band(st.param, 0x7f) + step() + elseif jt == 1 then + step() + elseif jt >= 2 and jt <= 5 then + incJt(st) + elseif jt == 6 then + st.jt = 1 + end +end + +F.BATTLE_ANIM_FUNC_BITE = function(self, st) + local function step() + local value = sine(st.var1, st.param) + st.yOffset = value + reinit(st, bit.band(value, 0x80) ~= 0 + and self:framesetId("BATTLE_ANIM_FRAMESET_BITE_1") + or self:framesetId("BATTLE_ANIM_FRAMESET_BITE_2")) + st.var1 = u8(st.var1 + 2) + if bit.band(st.var1, 0x1f) ~= 0 then return end + incJt(st) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = bit.band(st.param, 0x80) ~= 0 and 0x30 or 0x10 + st.param = bit.band(st.param, 0x7f) + step() + elseif jt == 1 then + step() + elseif jt >= 2 and jt <= 5 then + incJt(st) + elseif jt == 6 then + st.jt = 1 + end +end + +F.BATTLE_ANIM_FUNC_SOLAR_BEAM = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1, st.var2 = 0x28, 0 + end + local angle = st.param + st.yOffset = sine(angle, st.var1) + st.xOffset = cosine(angle, st.var1) + if st.var1 == 0 then + deinit(st) + return + end + -- The radius is a 16-bit value shrinking half a pixel a frame. + st.var1, st.var2 = add16(st.var1, st.var2, -0x80) +end + +-- The gust's radius comes from a nine-entry table, so the whirl pulses rather +-- than turning at a constant width. +local GUST_OFFSETS = { [0] = 8, [1] = 6, [2] = 5, [3] = 4, [4] = 5, [5] = 6, + [6] = 8, [7] = 12, [8] = 16 } + +F.BATTLE_ANIM_FUNC_GUST = function(_, st) + local function wobble() + local radius = GUST_OFFSETS[st.var2] or 8 + local angle = st.var1 + -- Height is a sixteenth of the width, plus PARAM's own drift. + st.yOffset = u8(sra(sra(sra(sra(sine(angle, radius))))) + st.param) + st.xOffset = cosine(angle, radius) + st.var1 = u8(st.var1 - 8) + -- PARAM counts DOWN from 0 through $ff; once it drops below $c2 the whirl + -- settles back to the middle. + if st.param ~= 0 and st.param < 0xc2 then + st.var2, st.param, st.xOffset, st.yOffset = 0, 0, 0, 0 + return + end + st.param = u8(st.param - 1) + if bit.band(st.param, 7) ~= 0 then return end + st.var2 = u8(st.var2 + 1) + end + local function move() + wobble() + st.x = u8(st.x + 1) + if bit.band(st.x, 1) ~= 0 then return end + st.y = u8(st.y - 1) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.param = 0 + wobble() -- .zero falls into .one + elseif jt == 1 or jt == 3 then + wobble() + elseif jt == 2 then + if st.x < 0x88 then + move() + else + incJt(st) + end + elseif jt == 4 then + if st.x < 0xb8 then + move() + else + deinit(st) + end + end +end + +F.BATTLE_ANIM_FUNC_ABSORB = function(_, st) + if st.x < 0x30 then + deinit(st) + return + end + local e = bit.band(st.param, 0xf) + st.x = u8(st.x - e) + local steps = bit.rshift(e, 1) + st.y = u8(st.y + (steps == 0 and 256 or steps)) +end + +F.BATTLE_ANIM_FUNC_WRAP = function(_, st) + -- anim_incobj walks the frameset one step along the BIND_1..4 run. + if st.jt ~= 1 then return end + reinit(st, u8(st.framesetId + 1)) + incJt(st) + st.var1 = 8 +end + +-- BattleAnim_StepThrownToTarget: a parabola whose horizontal step is PARAM's +-- two nybbles read as a 16-bit fixed-point number -- the LOW nybble is the +-- fraction and the HIGH nybble the whole pixels, which is the reverse of how +-- the macro's argument reads. +local function stepThrownToTarget(st) + st.var2 = u8(st.var2 - 1) + st.yOffset = sine(st.var2, 0x20) + st.fixY = u8(st.fixY + 2) + local step = bit.rshift(bit.band(st.param, 0xf0), 4) * 256 + + swap(bit.band(st.param, 0xf)) + st.x, st.var1 = add16(st.x, st.var1, step) + if bit.band(st.var2, 1) ~= 0 then return end + st.y = u8(st.y - 1) +end + +F.BATTLE_ANIM_FUNC_LEECH_SEED = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var2 = 0x40 + elseif jt == 1 then + if st.var2 >= 0x20 then + stepThrownToTarget(st) + return + end + st.var2 = 0x40 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_LEECH_SEED_2")) + incJt(st) + elseif jt == 2 then + if st.var2 ~= 0 then + st.var2 = st.var2 - 1 + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_LEECH_SEED_3")) + end +end + +F.BATTLE_ANIM_FUNC_SPIKES = function(_, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var2 = 0x40 + elseif jt == 1 then + if st.var2 >= 0x20 then + stepThrownToTarget(st) + return + end + incJt(st) + end +end + +F.BATTLE_ANIM_FUNC_RAZOR_WIND = function(self, st) + F.BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE(self, st) + -- Fifteen extra steps a frame, so the object races round the circle. + st.var1 = u8(st.var1 + 0xf) +end + +local function kickRoll(_, st) + if st.x >= 0x98 then return end + st.x = u8(st.x + 2) + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.yOffset = sine(angle, 8) +end + +F.BATTLE_ANIM_FUNC_KICK = function(_, st) + local jt = st.jt + if jt == 1 then + if st.y < 0x30 then + st.y = u8(st.y + 4) + return + end + st.jt = 0 + elseif jt == 2 then + if st.x >= 0x98 then return end + st.x = u8(st.x + 2) + -- The kick pins itself to the target's side and holds one frame. + st.oamFlags = bit.bor(st.oamFlags, FIX_COORDS) + st.fixY = 0x90 + st.frame = 0 + st.duration = 2 + st.y = u8(st.y - 1) + elseif jt == 3 then + incJt(st) + st.var1 = 0x2c + st.frame = 0 + st.duration = 0x80 + kickRoll(_, st) -- .three falls into .four + elseif jt == 4 then + kickRoll(_, st) + end +end + +F.BATTLE_ANIM_FUNC_EGG = function(self, st) + -- .EggVerticalWaveMotion, shared by both openings. + local function wave() + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x3f) ~= 0 then return end + st.var1 = 0x20 + st.var2 = u8(st.var2 - 8) + if st.var2 ~= 0 then return end + st.var1, st.var2 = 0, 0 + incJt(st) + end + -- .egg_bomb_step: the egg drifts up half a pixel a frame while it travels. + local function step() + st.x = u8(st.x + 1) + st.y, st.var1 = add16(st.y, st.var1, -0x80) + end + local jt = st.jt + if jt == 0 then + -- The object starts here and then jumps to whichever branch PARAM names, + -- which is how one object serves both Egg Bomb and Softboiled. + st.var1, st.var2 = 0x28, 0x10 + st.jt = st.param + elseif jt == 1 then + if st.x < 0x40 then st.x = u8(st.x + 1) end + wave() + elseif jt == 2 then + if st.x >= 0x88 then + incJt(st) + incJt(st) -- .egg_bomb_done skips straight to .four + return + end + if bit.band(st.x, 0xf) ~= 0 then + step() + return + end + st.var2 = 0x10 + incJt(st) + elseif jt == 3 then + if st.var2 ~= 0 then + st.var2 = st.var2 - 1 + return + end + st.jt = u8(st.jt - 1) + step() + elseif jt == 5 then + deinit(st) + elseif jt == 6 then + if st.x < 0x4b then st.x = u8(st.x + 1) end + wave() + elseif jt == 7 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EGG_WOBBLE")) + incJt(st) + elseif jt == 8 then + local angle = st.var1 + st.var1 = u8(st.var1 + 2) + st.xOffset = sine(angle, 2) + elseif jt == 9 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EGG_CRACKED_BOTTOM")) + st.yOffset = 4 + incJt(st) + elseif jt == 11 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EGG_CRACKED_TOP")) + incJt(st) + st.var1 = 0x40 + elseif jt == 12 then + st.yOffset = sine(st.var1, 0x20) + if st.var1 < 0x30 then + incJt(st) + return + end + st.var1 = u8(st.var1 - 1) + end +end + +F.BATTLE_ANIM_FUNC_MOVE_UP = function(_, st) + -- Runs while the offset is 0 or already past $d8 going negative; anything + -- in between is "far enough up" and ends the object. + if st.yOffset ~= 0 and st.yOffset < 0xd8 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset - st.param) +end + +F.BATTLE_ANIM_FUNC_SOUND = function(self, st) + local function motion() + local angle = st.var2 + st.var2 = u8(st.var2 + 2) + local value = sine(angle, 0x10) + st.xOffset = value + if st.param == 0 then + st.yOffset = u8(-value) + elseif st.param ~= 1 then + st.yOffset = value + end + -- PARAM 1 leaves the Y offset alone: that is the flat sideways wave. + end + if st.jt == 0 then + if (self.env.battleTurn or 0) ~= 0 then + -- `xor $ff; add $3` is 2 - param: the enemy's three angles are the + -- player's three mirrored, 0 <-> 2. + st.param = u8(2 - st.param) + end + incJt(st) + st.var1 = 8 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_SOUND_1") + st.param) + return + end + if st.var1 == 0 then + deinit(st) + return + end + st.var1 = st.var1 - 1 + motion() +end + +F.BATTLE_ANIM_FUNC_CONFUSE_RAY = function(self, st) + if st.jt == 0 then + incJt(st) + st.var2 = bit.band(st.param, 0x3f) + -- Bit 7 becomes both the frameset offset and, once swapped, the radius. + st.param = rlca(bit.band(st.param, 0x80)) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_CONFUSE_RAY_1") + st.param) + return + end + local radius = swap(st.param) + local angle = st.var2 + st.var2 = u8(st.var2 + 1) + st.yOffset = sine(angle, radius) + st.xOffset = cosine(angle, radius) + if st.x >= 0x80 then return end + -- Both tests read the NEW VAR2, and the second `and $1` is applied to what + -- the first `and $3` left, not to the register again. + local phase = bit.band(st.var2, 3) + if phase == 0 then st.y = u8(st.y - 1) end + if bit.band(phase, 1) ~= 0 then return end + st.x = u8(st.x + 1) +end + +F.BATTLE_ANIM_FUNC_DIZZY = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1 = st.framesetId + reinit(st, u8(st.var1 + rlca(bit.band(st.param, 0x80)))) + st.param = bit.band(st.param, 0x7f) + end + local angle = st.param + st.yOffset = sra(sra(sine(angle, 0x10))) + st.xOffset = cosine(angle, 0x10) + -- `ld a, [hl]` then `inc [hl]`: the frameset flip tests the PRE-increment + -- angle, so the two chick frames swap on the same beat as the circle. + st.param = u8(st.param + 1) + local phase = bit.band(angle, 0x3f) + if phase == 0 then + reinit(st, st.var1) + elseif bit.band(phase, 0x1f) == 0 then + reinit(st, u8(st.var1 + 1)) + end +end + +-- Hardcoded Y offsets, one per PARAM. +local AMNESIA_OFFSETS = { [0] = 0xec, [1] = 0xf8, [2] = 0x00 } + +F.BATTLE_ANIM_FUNC_AMNESIA = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_AMNESIA_1") + st.param) + st.yOffset = AMNESIA_OFFSETS[st.param] or 0 + elseif st.jt == 2 then + -- anim_incobj forces the object to deinit; Present is what uses it. + deinit(st) + end +end + +F.BATTLE_ANIM_FUNC_FLOAT_UP = function(_, st) + local angle = st.var1 + st.var1 = u8(st.var1 + 2) + st.xOffset = sine(angle, 4) + -- `lb hl, -1, $a0` is the 16-bit constant $ffa0: up 3/8 of a pixel a frame. + st.yOffset, st.var2 = add16(st.yOffset, st.var2, -0x60) +end + +F.BATTLE_ANIM_FUNC_DIG = function(_, st) + local angle = st.var1 + st.var1 = u8(st.var1 - 2) + st.yOffset = sine(angle, 0x10) + st.x = u8(st.x + 1) +end + +F.BATTLE_ANIM_FUNC_STRING = function(self, st) + if st.jt ~= 0 then return end + incJt(st) + -- PARAM 0 is the one that flips on the enemy's turn. + if st.param == 0 then st.oamFlags = bit.bor(st.oamFlags, OAM_YFLIP) end + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_STRING_SHOT_1") + st.param) +end + +F.BATTLE_ANIM_FUNC_PARALYZED = function(self, st) + if st.jt == 0 then + incJt(st) + st.var1 = 0 + local param = st.param + -- Bits 4-6 become the hold time; bit 7 flips the object, and the low + -- nybble is how far it jitters. + st.param = bit.band(swap(bit.band(param, 0x70)), 0xf) + if bit.band(param, 0x80) == 0 then + st.xOffset = bit.band(param, 0xf) + else + st.xOffset = u8(-bit.band(param, 0xf)) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_PARALYZED_FLIPPED")) + end + return + end + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + return + end + st.var1 = st.param + st.xOffset = u8(-st.xOffset) +end + +-- A shared descent: a circle whose height is an eighth of its width, sinking +-- one pixel every few frames until it is $28 down. Spiral Descent checks +-- every eight frames, Petal Dance every four. +local function spiralDescent(st, mask) + local angle = st.var1 + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.xOffset = cosine(angle, 0x18) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, mask) ~= 0 then return end + if st.var2 >= 0x28 then + deinit(st) + return + end + st.var2 = u8(st.var2 + 1) +end + +F.BATTLE_ANIM_FUNC_SPIRAL_DESCENT = function(_, st) spiralDescent(st, 7) end +F.BATTLE_ANIM_FUNC_PETAL_DANCE = function(_, st) spiralDescent(st, 3) end + +F.BATTLE_ANIM_FUNC_POISON_GAS = function(_, st) + if st.jt ~= 0 then + spiralDescent(st, 7) + return + end + if st.x >= 0x84 then + incJt(st) + return + end + st.x = u8(st.x + 1) + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.xOffset = cosine(angle, 0x18) + if bit.band(st.x, 1) ~= 0 then return end + st.y = u8(st.y - 1) +end + +F.BATTLE_ANIM_FUNC_SMOKE_FLAME_WHEEL = function(_, st) + local angle = st.param + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.xOffset = cosine(angle, 0x18) + st.param = u8(st.param + 2) + if bit.band(st.param, 7) ~= 0 then return end + if st.var2 == 0xe8 then + deinit(st) + return + end + st.var2 = u8(st.var2 - 1) +end + +F.BATTLE_ANIM_FUNC_SACRED_FIRE = function(_, st) + local angle = st.param + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.xOffset = cosine(angle, 0x18) + st.param = u8(st.param + 2) + if bit.band(st.param, 3) ~= 0 then return end + if st.var2 == 0xd0 then + deinit(st) + return + end + st.var2 = u8(st.var2 - 2) +end + +F.BATTLE_ANIM_FUNC_PRESENT_SMOKESCREEN = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1, st.var2 = 0x34, 0x10 + elseif st.jt == 2 then + deinit(st) + return + end + if st.jt ~= 1 then return end + if st.x >= 0x6c then return end + stepToTarget(st, 2) + local value = sine(st.var1, st.var2) + -- Only the upper half of the bounce shows: a positive sine is negated, so + -- the puff always sits above its line. + if bit.band(value, 0x80) == 0 then value = u8(-value) end + st.yOffset = value + st.var1 = u8(st.var1 - 4) + -- The halving below is unreachable on the cart: `and $1f` can never leave + -- $20, so Present's puff keeps its height the whole way across. +end + +F.BATTLE_ANIM_FUNC_HORN = function(_, st) + local function spin() + local value = sine(st.var2, 8) + st.xOffset = value + st.y = u8(st.var1 - sra(value)) + st.var2 = u8(st.var2 + 8) + end + local jt = st.jt + if jt == 0 then + st.jt = st.param + st.var1 = st.y + elseif jt == 1 then + if st.x >= 0x58 then return end + stepToTarget(st, 2) + elseif jt == 2 then + if st.var2 >= 0x20 then + deinit(st) + return + end + spin() + elseif jt == 3 then + spin() + end +end + +F.BATTLE_ANIM_FUNC_NEEDLE = function(_, st) + local function line() + if st.x >= 0x84 then + deinit(st) + return + end + stepToTarget(st, st.param) + end + local jt = st.jt + if jt == 0 then + -- The upper nybble of PARAM picks straight line or arc. + st.jt = bit.band(swap(st.param), 0xf) + elseif jt == 1 then + line() + elseif jt == 2 then + local value = sine(st.var1, 0x10) + -- Only the negative half is written, so the needle arcs upward only. + if bit.band(value, 0x80) ~= 0 then st.yOffset = value end + st.var1 = u8(st.var1 - 4) + line() -- .two falls into .one + end +end + +F.BATTLE_ANIM_FUNC_THIEF_PAYDAY = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1 = 0x28 + st.var2 = u8(st.y - 0x28) + end + st.yOffset = sine(st.var1, st.var2) + -- PARAM is a MASK, so the coin only drifts left on the frames that clear it. + if bit.band(st.var1, st.param) == 0 then st.x = u8(st.x - 1) end + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x3f) ~= 0 then return end + st.var1 = 0x20 + st.var2 = bit.rshift(st.var2, 1) +end + +F.BATTLE_ANIM_FUNC_ABSORB_CIRCLE = function(_, st) + local angle = st.param + st.yOffset = sine(angle, st.var1) + st.xOffset = cosine(angle, st.var1) + st.param = u8(st.param + 1) + if bit.band(st.param, 1) == 0 then st.x = u8(st.x - 1) end + if bit.band(st.param, 3) == 0 then st.y = u8(st.y + 1) end + if st.x >= 0x5a then + st.var1 = u8(st.var1 + 1) + return + end + if st.var1 == 0 then + deinit(st) + return + end + st.var1 = u8(st.var1 - 1) +end + +F.BATTLE_ANIM_FUNC_CONVERSION = function(_, st) + local angle = st.param + st.param = u8(st.param + 1) + st.yOffset = sine(angle, st.var1) + st.xOffset = cosine(angle, st.var1) + local age = st.var2 + st.var2 = u8(st.var2 + 1) + if age < 0x40 then + st.var1 = u8(st.var1 + 1) + return + end + local radius = st.var1 + st.var1 = u8(st.var1 - 1) + if radius ~= 0 then return end + deinit(st) +end + +F.BATTLE_ANIM_FUNC_BONEMERANG = function(_, st) + if st.jt == 0 then + incJt(st) + st.var2 = st.y + end + st.y = u8(st.var2 + sine(st.param, 0x30)) + -- Eight steps of phase between the two axes is what bends the throw into a + -- boomerang instead of a circle. + st.xOffset = cosine(st.param + 8, 0x30) + st.param = u8(st.param + 1) +end + +F.BATTLE_ANIM_FUNC_SHINY = function(_, st) + if st.jt ~= 0 then return end + incJt(st) + st.yOffset = sine(st.param, 0x10) + st.xOffset = cosine(st.param, 0x10) + st.var2 = 0xf +end + +-- Sky Attack pulses OBP0 rather than moving anything, so the palette write is +-- what the view has to see. +local SKY_ATTACK_GBC = { [0] = 0xff, [1] = 0xaa, [2] = 0x55, [3] = 0xaa } +local SKY_ATTACK_SGB = { [0] = 0xff, [1] = 0xff, [2] = 0x00, [3] = 0x00 } + +F.BATTLE_ANIM_FUNC_SKY_ATTACK = function(self, st) + local function cyclePalette() + local phase = bit.band(st.var2, 7) + st.var2 = u8(st.var2 + 1) + local pals = self.env.sgb and SKY_ATTACK_SGB or SKY_ATTACK_GBC + self.obp0 = bit.band(pals[bit.rshift(phase, 1)] or 0xff, st.var1) + if self.hram then self.hram.obp0 = self.obp0 end + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = (self.env.battleTurn or 0) == 0 and 0xf0 or 0xcc + elseif jt == 1 then + cyclePalette() + elseif jt == 2 then + cyclePalette() + if st.x >= 0x84 then return end + stepToTarget(st, 4) + elseif jt == 3 then + cyclePalette() + if st.x >= 0xd0 then + deinit(st) + return + end + stepToTarget(st, 4) + end +end + +F.BATTLE_ANIM_FUNC_GROWTH_SWORDS_DANCE = function(_, st) + local angle = st.param + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.param = u8(st.param + 1) + st.xOffset = cosine(angle, 0x18) + st.var2 = u8(st.var2 - 2) +end + +F.BATTLE_ANIM_FUNC_STRENGTH_SEISMIC_TOSS = function(_, st) + local jt = st.jt + if jt == 0 then + if st.yOffset == 0xe0 then + incJt(st) + st.var1 = 2 + return + end + st.yOffset, st.var1 = add16(st.yOffset, st.var1, -0x80) + elseif jt == 1 then + if st.var2 ~= 0 then + st.var2 = st.var2 - 1 + return + end + -- Shakes by negating the accumulated step and folding it into the offset + -- every four frames. + st.var2 = 4 + st.var1 = u8(-st.var1) + st.yOffset = u8(st.yOffset + st.var1) + elseif jt == 2 then + if st.x >= 0x84 then + deinit(st) + return + end + stepToTarget(st, 4) + end +end + +F.BATTLE_ANIM_FUNC_SPEED_LINE = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_SPEED_LINE_1") + + bit.band(st.param, 0x7f)) + end + if bit.band(st.param, 0x80) ~= 0 then + st.xOffset = u8(st.xOffset - 1) + else + st.xOffset = u8(st.xOffset + 1) + end +end + +F.BATTLE_ANIM_FUNC_SLUDGE = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0xc + elseif jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE_BURST")) + st.yOffset = u8(st.yOffset - 1) -- .done falls into .two + elseif jt == 2 then + st.yOffset = u8(st.yOffset - 1) + end +end + +F.BATTLE_ANIM_FUNC_METRONOME_HAND = function(_, st) + local angle = st.var1 + st.var1 = u8(st.var1 + 2) + st.yOffset = sine(angle, 2) + st.xOffset = cosine(angle, 8) +end + +F.BATTLE_ANIM_FUNC_METRONOME_SPARKLE_SKETCH = function(_, st) + if st.yOffset >= 0x20 then + deinit(st) + return + end + st.xOffset = cosine(st.param, 8) + st.param = u8(st.param + 2) + if bit.band(st.param, 7) ~= 0 then return end + st.yOffset = u8(st.yOffset + 1) +end + +F.BATTLE_ANIM_FUNC_AGILITY = function(_, st) + -- anim_incobj is what makes it disappear. + if st.jt ~= 0 then + deinit(st) + return + end + st.x = u8(st.x + st.param) +end + +F.BATTLE_ANIM_FUNC_SAFEGUARD_PROTECT = function(_, st) + local angle = st.param + st.yOffset = sine(angle, 0x18) + st.xOffset = sra(cosine(angle, 0x18)) + st.param = u8(st.param + 1) +end + +F.BATTLE_ANIM_FUNC_LOCK_ON_MIND_READER = function(_, st) + -- .two: hold for $10 frames, then go. + local function hold() + local left = st.var1 + st.var1 = u8(st.var1 - 1) + if left ~= 0 then return end + deinit(st) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0x28 + -- The low nybble walks along the four LOCK_ON / MIND_READER framesets, + -- and the object's own frameset id is the base of that run. + reinit(st, u8(st.framesetId + bit.band(st.param, 0xf))) + st.param = bit.bor(bit.band(st.param, 0xf0), 8) + jt = 1 + end + if jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + -- The radius is the distance still to run, so the ring converges. + local radius = u8(st.var1 + 8) + st.yOffset = sine(st.param, radius) + st.xOffset = cosine(st.param, radius) + return + end + st.var1 = 0x10 + incJt(st) + hold() -- .done falls into .two + return + end + if jt == 2 then hold() end +end + +F.BATTLE_ANIM_FUNC_HEAL_BELL_NOTES = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1") + st.param) + end + if st.yOffset >= 0x38 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset + 1) + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.xOffset = cosine(angle, 0x18) + -- Tests the Y COORDINATE, not the counter: the note drifts left on the + -- frames its row happens to be even. + if bit.band(st.y, 1) ~= 0 then return end + st.x = u8(st.x - 1) +end + +F.BATTLE_ANIM_FUNC_BATON_PASS = function(_, st) + if st.param == 0 then return end + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + local value = sine(angle, st.param) + if bit.band(value, 0x80) == 0 then value = u8(-value) end + st.yOffset = value + if bit.band(st.var1, 0x1f) ~= 0 then return end + -- Each bounce is half the last. + st.param = bit.rshift(st.param, 1) +end + +F.BATTLE_ANIM_FUNC_ENCORE_BELLY_DRUM = function(_, st) + if st.var1 >= 0x10 then + deinit(st) + return + end + local radius = st.var1 + st.var1 = u8(st.var1 + 2) + st.yOffset = sine(st.param, radius) + st.xOffset = cosine(st.param, radius) +end + +F.BATTLE_ANIM_FUNC_SWAGGER_MORNING_SUN = function(_, st) + -- The top two bits of PARAM are the speed and the low six the angle; the + -- amplitude is VAR1 as it was BEFORE this frame's speed was added. + local radius = st.var1 + st.var1 = u8(st.var1 + rlca(rlca(bit.band(st.param, 0xc0)))) + local angle = bit.band(st.param, 0x3f) + st.yOffset = sine(angle, radius) + st.xOffset = cosine(angle, radius) +end + +F.BATTLE_ANIM_FUNC_HIDDEN_POWER = function(_, st) + -- .two: the ring expands eight pixels a frame and then vanishes. + local function expand() + if st.var1 >= 0x80 then + deinit(st) + return + end + local radius = st.var1 + st.var1 = u8(st.var1 + 8) + stepCircle(st, st.param, radius) + end + local jt = st.jt + if jt == 0 then + local angle = st.param + st.param = u8(st.param + 1) + stepCircle(st, angle, 0x18) + elseif jt == 1 then + incJt(st) + st.var1 = 0x18 + expand() -- .one falls into .two + elseif jt == 2 then + expand() + end +end + +F.BATTLE_ANIM_FUNC_CURSE = function(_, st) + if st.jt ~= 1 then return end + if st.x < 0x30 then + deinit(st) + return + end + st.x = u8(st.x - 2) + st.y = u8(st.y + 2) +end + +F.BATTLE_ANIM_FUNC_PERISH_SONG = function(_, st) + local angle = st.param + st.param = u8(st.param + 2) + -- VAR1 is both the sink and the counter: the sine is added to it and then + -- it is stepped, so the ring drifts down as it turns. + st.yOffset = u8(sra(sra(sine(angle, 0x50))) + st.var1) + st.var1 = u8(st.var1 + 1) + st.xOffset = cosine(angle, 0x50) +end + +F.BATTLE_ANIM_FUNC_RAPID_SPIN = function(_, st) + if st.yOffset == 0xd0 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset - 4) +end + +F.BATTLE_ANIM_FUNC_BETA_PURSUIT = function(_, st) + local jt = st.jt + if jt == 0 then + if st.param ~= 0 then + incJt(st) + incJt(st) + return + end + incJt(st) + st.yOffset = 0xec + elseif jt == 1 then + if st.yOffset == 4 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset + 4) + elseif jt == 2 then + if st.yOffset == 0xd8 then return end + st.yOffset = u8(st.yOffset - 4) + elseif jt == 3 then + deinit(st) + end +end + +F.BATTLE_ANIM_FUNC_RAIN_SANDSTORM = function(_, st) + -- The Y offset wraps at $70, which is what makes a single object read as a + -- continuous fall of rain. + local function fall(step) + local y = st.yOffset + 4 + st.yOffset = (y < 0x70) and y or 0 + st.xOffset = u8(st.xOffset + step) + end + local jt = st.jt + if jt == 0 then + st.jt = u8(st.param + 1) -- .zero sets the index from PARAM and then incs + elseif jt == 1 then + fall(2) + elseif jt == 2 then + fall(8) + elseif jt == 3 then + fall(4) + end +end + +F.BATTLE_ANIM_FUNC_BATTLE_ANIM_OBJ_B0 = function(_, st) + -- Unused on the cart (nothing names BATTLE_ANIM_OBJ_B0), transcribed + -- because the jumptable slot is real: PARAM's nybbles become a 16-bit step + -- over XCOORD:VAR1, the high nybble duplicated into both halves of the + -- whole-pixel byte. + local high = bit.band(st.param, 0xf0) + high = bit.bor(high, bit.rshift(high, 4)) + st.x, st.var1 = add16(st.x, st.var1, + high * 256 + swap(bit.band(st.param, 0xf))) +end + +F.BATTLE_ANIM_FUNC_PSYCH_UP = function(_, st) + local angle = st.param + st.param = u8(st.param + 1) + stepCircle(st, angle, 0x18) +end + +F.BATTLE_ANIM_FUNC_COTTON = function(_, st) + local phase = st.var2 + st.var2 = u8(st.var2 + 1) + stepCircle(st, u8(bit.rshift(phase, 1) + st.param), 0x18) +end + +F.BATTLE_ANIM_FUNC_ANCIENT_POWER = function(_, st) + if st.var1 >= 0x20 then + deinit(st) + return + end + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.yOffset = u8(-sine(angle, st.param)) +end + +AnimObjects.FUNCTIONS = F +AnimObjects.NUM_STRUCTS = NUM_STRUCTS +AnimObjects.OAM_LIMIT = OAM_LIMIT +AnimObjects.OAM_XFLIP = OAM_XFLIP +AnimObjects.OAM_YFLIP = OAM_YFLIP +AnimObjects.OAM_PRIO = OAM_PRIO +AnimObjects.OAM_PAL1 = OAM_PAL1 +AnimObjects.FIX_COORDS = FIX_COORDS +AnimObjects.sine, AnimObjects.cosine = sine, cosine +AnimObjects.u8, AnimObjects.s8, AnimObjects.sra = u8, s8, sra +AnimObjects.swap, AnimObjects.rlca = swap, rlca + +return AnimObjects diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua new file mode 100644 index 000000000..800f01964 --- /dev/null +++ b/src/battle/gen2/AnimRunner.lua @@ -0,0 +1,500 @@ +-- Gen1Recomp-derived runtime: GPLv3 plus Additional Terms. +-- See compat/gen1-gsc/LICENSE.MD and pinned provenance.json. +-- The Gen 2 battle-animation command interpreter. +-- +-- pokegold engine/battle_anims/anim_commands.asm: RunBattleAnimScript's frame +-- loop and the 48-entry BattleAnimCommands jumptable it dispatches through. +-- The scripts themselves are already disassembled into the cache by +-- RomExtractorGen2 (`data/generated/battle_anims.lua`), keyed by their ROM +-- address because that is what a branch names. +-- +-- One frame of an animation is exactly three things, in this order: +-- +-- RunBattleAnimCommand run script bytes until one asks to wait +-- ExecuteBGEffects one pass over the five BG-effect structs +-- BattleAnim_UpdateOAM_All one pass over the ten object structs +-- +-- so `step()` here is one 60 Hz frame and nothing else needs a clock. The +-- animation is over when a `ret` runs outside a subroutine, which is what +-- BATTLEANIM_STOP_F means. +-- +-- Two things about the script format that are easy to get wrong and are +-- already handled by the extractor, repeated here because this is where they +-- bite: anything under $d0 is `anim_wait ` and carries no arguments, and a +-- branch's target is the LAST two bytes of the command, which the extractor +-- has already rewritten into a pool key. +-- +-- Love-free: sound and cries go out through the `hooks` table the battle +-- screen supplies, so a test can step a whole animation and assert what it +-- asked to play. + +local bit = require("bit") +local AnimObjects = require("src.battle.gen2.AnimObjects") +local BgEffects = require("src.battle.gen2.BgEffects") + +local AnimRunner = {} +local Runner = {} +Runner.__index = Runner + +-- wBattleAnimTileDict is five {gfx id, tile id} pairs. +local NUM_TILEDICT_ENTRIES = 5 +-- BATTLEANIM_BASE_TILE is 7*7; the sheets share the tiles above it up to +-- vTiles1, so the running allocator stops at 128 - 49. +local MAX_ANIM_TILES = 128 - 49 + +-- BattleAnimCmd_BattlerGFX_*: the battlers' own pic tiles are registered in +-- the dict at fixed ids rather than loaded from AnimObjGFX. These are the +-- ASM's `($80 - 6 - 7) - BATTLEANIM_BASE_TILE` and friends. +local BATTLER_TILES = { + oneRow = { player = (0x80 - 6 - 7) - 49, enemy = (0x80 - 6) - 49 }, + twoRow = { player = (0x80 - 6 * 2 - 7 * 2) - 49, enemy = (0x80 - 6 * 2) - 49 }, +} + +-- BattleAnimCmd_Cry's .CryData: a pitch and a length added to the mon's own +-- cry, indexed by the command's argument masked to NUM_NOISE_CHANS. +local CRY_DATA = { + [0] = { pitch = 0x0000, length = 0x00c0 }, + [1] = { pitch = 0x0000, length = 0x0040 }, + [2] = { pitch = 0x0000, length = 0x0000 }, + [3] = { pitch = 0x0000, length = 0x0000 }, +} + +-- BattleAnimCmd_Sound's .GetPanning, indexed by the cry-track pair. +local PANNING = { [0] = 0xf0, [1] = 0x0f, [2] = 0xf0, [3] = 0x0f } + +-------------------------------------------------------------------------- + +-- opts: +-- data the cache's battle_anims.lua +-- constants the cache's constants.lua +-- battleTurn hBattleTurn -- 0 while the player is attacking +-- param wBattleAnimParam, which the effect layer sets (hit count, +-- stat direction, the Beat Up party slot...) +-- animId the move (or ANIM_* id) whose script this is +-- hooks { sound(name, panning, duration), cry(side, pitch, length) } +-- ballPalette the PAL_BATTLE_OB_* name for the ball being thrown +function AnimRunner.new(opts) + opts = opts or {} + local self = setmetatable({}, Runner) + self.data = opts.data or {} + self.constants = opts.constants or {} + self.hooks = opts.hooks or {} + -- Shared by both pools; the object functions and the BG effects read the + -- same hBattleTurn. + self.env = { + battleTurn = opts.battleTurn or 0, + animId = opts.animId, + ballPalette = opts.ballPalette, + sgb = opts.sgb, + flying = opts.flying or {}, + } + self.objects = AnimObjects.new(self.data, self.constants, self.env) + self.bg = BgEffects.new(self.constants, self.env) + -- engine/battle_anims/functions.asm:1158 + self.objects.hram = self.bg + self.animId = opts.animId -- wFXAnimID + self.gfxOrder = self.constants.battleAnimGfxOrder or {} + self.sfxOrder = opts.sfxOrder or {} + + self.param = opts.param or 0 -- wBattleAnimParam + self.var = 0 -- wBattleAnimVar + self.delay = 0 -- wBattleAnimDelay + self.loops = 0 -- wBattleAnimLoops + self.inSubroutine = false + self.inLoop = false + self.stopped = false + self.keepSprites = false + self.frames = 0 + -- wBattleAnimTileDict, and the sheets it points at. + self.tileDict = {} + self.loaded = {} + -- Set by the substitute / minimize / transform commands, for the view. + self.picOverride = { player = nil, enemy = nil } + self.actorPic = {} + self.address = nil + self.parent = nil + return self +end + +-- ClearBattleAnims: the whole animation block, then the entry point. +function Runner:start(scriptKey) + self.objects:clear() + self.bg:reset() + self.var, self.delay, self.loops = 0, 0, 0 + self.inSubroutine, self.inLoop, self.stopped = false, false, false + self.keepSprites = false + self.frames = 0 + self.tileDict = {} + self.loaded = {} + self.picOverride = { player = nil, enemy = nil } + self.actorPic = {} + self.address = scriptKey and { key = scriptKey, index = 1 } or nil + self.parent = nil + return self +end + +-- The script for a move, or nil when the cache has none (which is what an +-- unextracted or modded move looks like). +function AnimRunner.scriptForMove(data, moveId) + local moves = (data or {}).moves or {} + return moves[moveId] +end + +function Runner:scriptRows(key) + return (self.data.scripts or {})[key] +end + +-- GetBattleAnimByte, one decoded row at a time. +function Runner:fetch() + local at = self.address + if not at then return nil end + local rows = self:scriptRows(at.key) + if not rows then return nil end + local row = rows[at.index] + if not row then return nil end + at.index = at.index + 1 + return row +end + +-- The three "skip the branch target" tails: a conditional that does not take +-- its branch steps the address past the two address bytes, which in a decoded +-- row list is simply "carry on". +function Runner:jumpTo(key) + self.address = { key = key, index = 1 } +end + +-------------------------------------------------------------------------- +-- The tile dict +-------------------------------------------------------------------------- + +-- GetBattleAnimTileOffset: the dict is scanned for the gfx id and its tile +-- returned; a miss is 0, which is why an object whose sheet the script never +-- loaded draws whatever happens to sit at the base tile. +function Runner:tileOffsetFor(gfxId) + for i = 1, NUM_TILEDICT_ENTRIES do + local entry = self.tileDict[i] + if entry and entry.gfx == gfxId then return entry.tile end + end + return 0 +end + +-- BattleAnimCmd_1GFX..5GFX. The running tile id restarts at 0 for every +-- command and each sheet is laid down after the last, so two animations that +-- load different sheet counts do not agree about where anything is -- which +-- is exactly why the dict exists. Entries past the count are NOT cleared. +function Runner:loadGfx(names) + local tile = 0 + for slot, gfxId in ipairs(names) do + if tile >= MAX_ANIM_TILES then break end + local name = gfxId + if type(gfxId) == "number" then + name = self.gfxOrder[gfxId + 1] or gfxId + end + self.tileDict[slot] = { gfx = name, tile = tile } + local sheet = (self.data.gfx or {})[name] + self.loaded[#self.loaded + 1] = { + gfx = name, tile = tile, tiles = (sheet and sheet.tiles) or 0, + } + tile = tile + ((sheet and sheet.tiles) or 0) + end +end + +-- engine/battle_anims/anim_commands.asm:755. The jumptable crosses the macro +-- names: $d9 (anim_battlergfx_2row) dispatches to _1Row (#1401) +function Runner:loadBattlerGfx(rows) + local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow + local slot = 1 + while slot <= NUM_TILEDICT_ENTRIES and self.tileDict[slot] do + slot = slot + 1 + end + if slot + 1 > NUM_TILEDICT_ENTRIES then return end + self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player } + self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy } + self.loaded[#self.loaded + 1] = + { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 7, + battler = "enemy", rows = rows } + self.loaded[#self.loaded + 1] = + { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6, + battler = "player", rows = rows } + if self.hooks.battlerGfx then + self.hooks.battlerGfx(self,self.loaded[#self.loaded-1],self.loaded[#self.loaded]) + end +end + +-------------------------------------------------------------------------- +-- BattleAnimCommands +-------------------------------------------------------------------------- + +local C = {} + +C.obj = function(self, row) + self.objects:queue(row[2], row[3], row[4], row[5], function(gfx) + return self:tileOffsetFor(gfx) + end) +end + +for count = 1, 5 do + C[count .. "gfx"] = function(self, row) + local names = {} + for i = 1, count do names[i] = row[i + 1] end + self:loadGfx(names) + end +end + +C.incobj = function(self, row) + local st = self.objects:findByIndex(row[2]) + if st then st.jt = AnimObjects.u8(st.jt + 1) end +end + +C.setobj = function(self, row) + local st = self.objects:findByIndex(row[2]) + if st then st.jt = AnimObjects.u8(row[3]) end +end + +C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end + +-- anim_commands.asm:317: $d9 (anim_battlergfx_2row) dispatches to _1Row +C.battlergfx_1row = function(self) self:loadBattlerGfx(2) end +C.battlergfx_2row = function(self) self:loadBattlerGfx(1) end + +-- GetPokeBallWobble's answer, which the ball's own script then branches on. +C.checkpokeball = function(self) + self.var = self.hooks.pokeballWobble and self.hooks.pokeballWobble() or 0 +end + +-- The commands that swap a battler's pic out for something else. The port +-- records which, and the view draws it. +-- +-- hBattleTurn selects the actor's own 6x6 back or 7x7 front slot. GS writes +-- Transform directly to BG; Crystal stages it in vTiles0 and copies it at +-- updateactorpic. Keep that distinction at the command owner. +C.transform = function(self) + -- BattleAnimCmd_Transform: .player loads wTempEnemyMonSpecies into the + -- backpic slot, i.e. the player's sprite becomes what it transformed into. + local side = self.env.battleTurn == 0 and "player" or "enemy" + local art=self.data.pictureArt + if art and art.stagedActorPic then self.actorPic[side]='transform' + else self.picOverride[side] = "transform" end +end + +C.raisesub = function(self) + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = "substitute" +end + +C.dropsub = function(self) + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = false +end + +-- BattleAnimCmd_MinimizeOpp / GetMinimizePic: despite the name it shrinks the +-- ATTACKER, because .player (turn 0) requests the 6x6 block at tile $31. The +-- other minimize opcode, $e9, is one of the dummies below. +C.minimizeopp = function(self) + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = "minimize" +end + +C.beatup = function(self) + -- wBattleAnimParam is the species byte; `slot` is the retained public key. + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = { kind = "beatup", slot = self.param } +end + +C.resetobp0 = function(self) + self.bg.obp0 = self.env.sgb and 0xf0 or 0xe0 +end + +C.sound = function(self, row) + local packed = row[2] or 0 + -- The first byte is BOTH the duration (its top six bits) and the cry-track + -- pair (its bottom two), which is why the same value reads twice here. + local duration = bit.rshift(packed, 2) + local tracks = bit.band(packed, 3) + if self.env.battleTurn ~= 0 then tracks = bit.bxor(tracks, 1) end + local id = row[3] or 0 + local name = self.sfxOrder[id + 1] + if self.hooks.sound then + self.hooks.sound(name, PANNING[tracks] or 0xff, duration, id) + end +end + +C.cry = function(self, row) + local slot = bit.band(row[2] or 0, 3) + local entry = CRY_DATA[slot] or CRY_DATA[0] + local side = self.env.battleTurn == 0 and "player" or "enemy" + if self.hooks.cry then self.hooks.cry(side, entry.pitch, entry.length) end +end + +C.clearobjs = function(self) self.objects:clearObjs() end + +C.oamon = function() end +C.oamoff = function() end +C.updateactorpic = function(self) + local side=self.env.battleTurn==0 and 'player'or'enemy' + local art=self.data.pictureArt + if art and art.stagedActorPic and self.actorPic[side]~=nil then + self.picOverride[side]=self.actorPic[side] + end +end +-- GS $e8/$e9 are ret. Crystal's $e9 fills vTiles0 without repainting the +-- actor; $e8 copies those staged tiles into its BG slot after its wait in +-- the actual Minimize program. The ROM importer declares that capability. +C.minimize = function(self) + local art=self.data.pictureArt + if art and art.stagedMinimize then + self.actorPic[self.env.battleTurn==0 and 'player'or'enemy']='minimize' + end +end +C.unknown_e7 = function() end +C.unknown_ea = function() end +C.unknown_eb = function() end +C.unknown_ec = function() end +C.unknown_ed = function() end +C.unknown_f5 = function() end +C.unknown_f6 = function() end +C.unknown_f7 = function() end + +C.keepsprites = function(self) self.keepSprites = true end + +C.bgp = function(self, row) self.bg.bgp = row[2] end +C.obp0 = function(self, row) self.bg.obp0 = row[2] end +C.obp1 = function(self, row) self.bg.obp1 = row[2] end + +C.bgeffect = function(self, row) + self.bg:queue(row[2], row[3], row[4], row[5]) +end + +C.setvar = function(self, row) self.var = AnimObjects.u8(row[2]) end +C.incvar = function(self) self.var = AnimObjects.u8(self.var + 1) end + +C.if_var_equal = function(self, row) + if row[2] == self.var then self:jumpTo(row[3]) end +end + +C.if_param_equal = function(self, row) + if row[2] == self.param then self:jumpTo(row[3]) end +end + +C.if_param_and = function(self, row) + if bit.band(self.param, row[2] or 0) ~= 0 then self:jumpTo(row[3]) end +end + +-- The one conditional that CONSUMES what it tests: each pass decrements +-- wBattleAnimParam, so `anim_jumpuntil` runs its block param times. +C.jumpuntil = function(self, row) + if self.param == 0 then return end + self.param = AnimObjects.u8(self.param - 1) + self:jumpTo(row[2]) +end + +C.jump = function(self, row) self:jumpTo(row[2]) end + +C.loop = function(self, row) + local count = row[2] or 0 + if not self.inLoop then + -- A count of 0 loops forever and never claims the loop flag. + if count ~= 0 then + self.inLoop = true + self.loops = AnimObjects.u8(count - 1) + end + self:jumpTo(row[3]) + return + end + if self.loops == 0 then + self.inLoop = false + return -- falls through past the target + end + self.loops = self.loops - 1 + self:jumpTo(row[3]) +end + +C.call = function(self, row) + self.parent = { key = self.address.key, index = self.address.index } + self.inSubroutine = true + self:jumpTo(row[2]) +end + +C.ret = function(self) + self.inSubroutine = false + self.address = self.parent + and { key = self.parent.key, index = self.parent.index } or nil +end + +-------------------------------------------------------------------------- + +-- RunBattleAnimCommand: burn the delay, otherwise run script rows until one +-- of them asks to wait or the animation ends. +function Runner:runCommands() + if self.delay ~= 0 then + self.delay = self.delay - 1 + return + end + for _ = 1, 512 do + local row = self:fetch() + if not row then + self.stopped = true + return + end + local cmd = row[1] + if cmd == "ret" then + -- A `ret` outside a subroutine is what ends the whole animation. + if not self.inSubroutine then + self.stopped = true + return + end + C.ret(self, row) + elseif cmd == "wait" then + self.delay = row[2] or 0 + return + else + local fn = C[cmd] + if fn then fn(self, row) end + end + end + -- A script that never waits would hang the battle; stopping is the only + -- honest thing to do with one. + self.stopped = true +end + +-- One frame. Returns false once the animation is over. +function Runner:step() + if self.stopped then return false end + self.frames = self.frames + 1 + self:runCommands() + self.bg:playFrame() + -- A BG effect can ask for an object (the battler-pic ones do), and it has + -- to land before the object pass or it would be a frame late. + for _, spawn in ipairs(self.bg:takeSpawns()) do + self.objects:queue(spawn.object, spawn.x, spawn.y, spawn.param, + function(gfx) return self:tileOffsetFor(gfx) end) + end + self.objects:playFrame() + -- Rollout hands the shake to the first object's Y offset. + if self.bg.rolloutYOffset then + local first = self.objects.structs[1] + if first and first.index ~= 0 then first.yOffset = self.bg.rolloutYOffset end + end + if self.stopped then + -- engine/battle_anims/anim_commands.asm:213 + if not self.keepSprites then + self.objects.oam = {} + else + for _, obj in ipairs(self.objects.oam) do + obj.palette = "PAL_BATTLE_OB_ENEMY" + end + end + return false + end + return true +end + +function Runner:oam() return self.objects.oam end +function Runner:done() return self.stopped end + +AnimRunner.COMMANDS = C +AnimRunner.NUM_TILEDICT_ENTRIES = NUM_TILEDICT_ENTRIES +AnimRunner.MAX_ANIM_TILES = MAX_ANIM_TILES +AnimRunner.BATTLER_TILES = BATTLER_TILES + +return AnimRunner diff --git a/src/battle/gen2/BgEffects.lua b/src/battle/gen2/BgEffects.lua new file mode 100644 index 000000000..be080a185 --- /dev/null +++ b/src/battle/gen2/BgEffects.lua @@ -0,0 +1,1377 @@ +-- Gen1Recomp-derived code: GPLv3 plus Additional Terms; see +-- compat/gen1-gsc/LICENSE.MD and provenance.json. Existing rights retained. +-- Gen1Recomp source: src/battle/gen2/BgEffects.lua at535a5ec0. +-- Its existing copyrights/licences remain effective (compat/gen1-gsc/LICENSE.MD). +-- Native dependency integration: Roxas2712, under the same applicable terms. +-- The Gen 2 battle-animation BACKGROUND effects. +-- +-- pokegold engine/battle_anims/bg_effects.asm: five concurrent structs, each +-- of {function, jumptable index, battle turn, param}, and a jumptable of 53 +-- effects that between them do all the shaking, flashing and sinking a Gen 2 +-- battle animation is made of. Objects (src/battle/gen2/AnimObjects.lua) are +-- OBJs; these are the BG layer. +-- +-- The one mechanism worth understanding before reading any of it: almost +-- nothing here moves a sprite. It writes wLYOverridesBackup, a per-scanline +-- value the LCD STAT interrupt copies into rSCX, rSCY or rBGP as the beam +-- passes. BattleBGEffect_SetLCDStatCustoms1 picks the window -- scanlines +-- $00-$36 for the enemy's pic, $2f-$5e for the player's -- so "shake the +-- attacker" is "write the same SCX to every scanline the attacker occupies". +-- +-- The port keeps that literally: `lyBackup` is 144 bytes, `lcdc` names the +-- register they land in, and src/ui/gen2/BattleAnimView.lua draws the BG layer +-- one scanline band at a time. Modelling it as "move the mon pic" instead +-- would work for Tackle and then fall apart on Withdraw and Dig, which push a +-- GROWING number of rows off and leave the rest where they are. +-- +-- Love-free, like AnimObjects. + +local bit = require("bit") +-- Arithmetic shared with the reference object engine; the fork keeps its +-- existing OAM owner instead of starting a second object pool. +local AnimObjects = require("src.battle.Gen2AnimMath") + +local u8, sra = AnimObjects.u8, AnimObjects.sra +local swap = AnimObjects.swap +local sine, cosine = AnimObjects.sine, AnimObjects.cosine + +local BgEffects = {} + +local NUM_EFFECTS = 5 -- NUM_BG_EFFECTS +local SCREEN_ROWS = 0x90 -- wLYOverridesBackup is $91 bytes + +-- `dc a, b, c, d` packs four 2-bit shades into a DMG palette byte, high pair +-- first: `dc 3, 2, 1, 0` is %11100100 = $e4, the identity ramp. +local function dc(a, b, c, d) return a * 64 + b * 16 + c * 4 + d end + +local NORMAL_PAL = dc(3, 2, 1, 0) + +-------------------------------------------------------------------------- + +local Pool = {} +Pool.__index = Pool + +local function newEffect() + return { func = nil, jt = 0, turn = 0, param = 0, side = nil } +end + +-- `env` is shared with the object pool: env.battleTurn is hBattleTurn, and +-- env.flying tells BGEffect_CheckFlyDigStatus whether the battler in question +-- is mid-Fly or mid-Dig (in which case ShowMon and the battler-pic objects +-- decline to draw a mon that is not on the field). +function BgEffects.new(constants, env) + local self = setmetatable({}, Pool) + self.env = env or {} + self.effects = {} + for slot = 1, NUM_EFFECTS do self.effects[slot] = newEffect() end + self.order = (constants or {}).battleBgEffectOrder or require("src.battle.Gen2BgEffectIds").forVersion() + self:reset() + return self +end + +function Pool:reset() + for slot = 1, NUM_EFFECTS do self.effects[slot] = newEffect() end + -- hSCX / hSCY: a whole-screen scroll, which is what the screen shakes use. + self.scx, self.scy = 0, 0 + -- hLCDCPointer plus its window, and the per-scanline values themselves. + self.lcdc, self.lyStart, self.lyEnd = nil, 0, 0 + self.lyBackup = {} + for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end + -- wBGP / wOBP0 / wOBP1, as DMG palette bytes. + self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL + -- Per-battler state the CGB paths write instead of touching wBGP: shade + -- byte, hidden flag, lifted tile rows, and which BG square it is drawn at. + self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL } + self.hidden = { player = false, enemy = false } + self.liftedRows = { player = nil, enemy = nil } + self.picSize = { player = nil, enemy = nil } + self.slide = { player = 0, enemy = 0 } + -- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the + -- overrides. nil until InitSurfWaves lays one down. + self.surfWave = nil + -- The objects a BG effect asks the object pool to spawn, drained by the + -- runner after each frame. + self.spawns = {} +end + +function Pool:activeCount() + local count = 0 + for slot = 1, NUM_EFFECTS do + if self.effects[slot].func then count = count + 1 end + end + return count +end + +-- QueueBGEffect: first free struct wins; a full pool silently drops the +-- request, which is exactly what the carry return means to the caller. +function Pool:queue(effectId, jumptableIndex, turn, param) + local name = effectId + if type(effectId) == "number" then + name = self.order[effectId + 1] or effectId + end + for slot = 1, NUM_EFFECTS do + local st = self.effects[slot] + if not st.func then + st.func = name + st.jt = u8(jumptableIndex or 0) + st.turn = u8(turn or 0) + st.param = u8(param or 0) + st.side = nil + return st + end + end + return nil +end + +-- BattleAnimCmd_IncBGEffect: bump the jumptable index of the first struct +-- running this effect. +function Pool:incEffect(effectId) + local name = effectId + if type(effectId) == "number" then + name = self.order[effectId + 1] or effectId + end + for slot = 1, NUM_EFFECTS do + local st = self.effects[slot] + if st.func == name then + st.jt = u8(st.jt + 1) + return st + end + end + return nil +end + +-------------------------------------------------------------------------- +-- Shared helpers +-------------------------------------------------------------------------- + +local function endEffect(st) st.func = nil end +local function incJt(st) st.jt = u8(st.jt + 1) end + +-- BGEffect_CheckBattleTurn: non-zero means "the side this effect is aimed at +-- is the player's". A struct's `turn` is BG_EFFECT_USER / BG_EFFECT_TARGET, +-- so the same effect id follows whichever battler is attacking. +function Pool:playerSide(st) + return bit.bxor(bit.band(self.env.battleTurn or 0, 1), st.turn) ~= 0 +end + +function Pool:sideKey(st) + return self:playerSide(st) and "player" or "enemy" +end + +-- BGEffect_CheckFlyDigStatus: zero means "on the field". +function Pool:flyDig(st) + local flying = self.env.flying or {} + return flying[self:sideKey(st)] and true or false +end + +function Pool:clearLYOverrides(value) + value = value or 0 + for row = 0, SCREEN_ROWS do self.lyBackup[row] = value end +end + +-- BattleBGEffect_SetLCDStatCustoms1: the window is the attacker's pic rows. +function Pool:setLCDStatCustoms1(register, st) + self.lcdc = register + if self:playerSide(st) then + self.lyStart, self.lyEnd = 0x2f, 0x5e + else + self.lyStart, self.lyEnd = 0x00, 0x36 + end +end + +-- engine/battle_anims/bg_effects.asm:2671 +function Pool:setLCDStatCustoms2(register, st) + self.lcdc = register + if self:playerSide(st) then + self.lyStart, self.lyEnd = 0x2d, 0x5e + else + self.lyStart, self.lyEnd = 0x00, 0x36 + end +end + +function Pool:resetLCDStatCustom(st) + self.lyStart, self.lyEnd = 0, 0 + self:clearLYOverrides(0) + self.lcdc = nil + endEffect(st) +end + +function Pool:resetVideoHRAM() + self.lcdc = nil + self.bgp, self.obp1 = NORMAL_PAL, NORMAL_PAL + self.lyStart, self.lyEnd = 0, 0 + self:clearLYOverrides(0) +end + +-- BGEffect_FillLYOverridesBackup: the same value on every scanline in the +-- window. `dec d; jr nz` after the first store, so a zero-width window would +-- run 256 times; the port refuses instead of wrapping the array. +function Pool:fillLY(value) + local count = u8(self.lyEnd - self.lyStart) + if count == 0 then count = 256 end + for i = 0, count - 1 do + local row = self.lyStart + i + if row > SCREEN_ROWS then break end + self.lyBackup[row] = u8(value) + end +end + +-- BGEffect_DisplaceLYOverridesBackup: the first `a` scanlines of the window +-- are scrolled to a blank part of the map ($90) and the rest are pushed down +-- by a + 1. That is what makes Withdraw and Dig look like the mon sinking +-- rather than sliding. +function Pool:displaceLY(a) + a = u8(a) + local span = u8(self.lyEnd - self.lyStart) + local rest = u8(span - a) + local row = self.lyStart + for _ = 1, (a == 0 and 256 or a) do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = 0x90 + row = row + 1 + end + local pushed = u8(0xff - a) + for _ = 1, (rest == 0 and 256 or rest) do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = pushed + row = row + 1 + end +end + +-- DeformScreen: a standing sine wave down the window. It walks the FIRST +-- $80 entries of wLYOverridesBackup by their low address byte and writes only +-- the ones inside the window -- `cp c / jr nc` skips while lyStart >= c and +-- `cp c / jr c` skips once lyEnd < c, so the row written is strictly +-- lyStart < row <= lyEnd -- but the phase advances on EVERY iteration, window +-- or not. So where the window sits decides which part of the wave lands on +-- it, and two effects with the same amplitude and offset but different +-- windows do not look alike. +-- +-- `lb de, d, e` puts the AMPLITUDE in d and the phase step in e. +function Pool:deformScreen(amplitude, offset) + local progress = 0 + for row = 0, 0x7f do + if self.lyStart < row and row <= self.lyEnd and row <= SCREEN_ROWS then + self.lyBackup[row] = sine(progress, amplitude) + end + progress = u8(progress + offset) + end +end + +-- InitSurfWaves: the same wave, into the $40-byte wSurfWaveBGEffect ring +-- rather than the overrides themselves. Surf rotates that ring a step a frame +-- and copies it out, which is what makes the water ROLL instead of standing +-- still the way DeformScreen's does. +Pool.SURF_WAVE_LENGTH = 0x40 + +function Pool:initSurfWaves(amplitude, offset) + local progress = 0 + self.surfWave = {} + for index = 0, Pool.SURF_WAVE_LENGTH - 1 do + self.surfWave[index] = sine(progress, amplitude) + progress = u8(progress + offset) + end +end + +-- BattleBGEffect_Surf's `.RotatewSurfWaveBGEffect`: rotate the ring left one, +-- then paint scanlines $00-$5e from it -- zero at and below lyStart, the ring +-- (wrapping every $40 rows) above it. The ring index advances on every +-- scanline including the zeroed ones, so the wave keeps its phase across the +-- boundary. +function Pool:rotateSurfWave() + local wave = self.surfWave + if not wave then return end + local first = wave[0] + for index = 0, Pool.SURF_WAVE_LENGTH - 2 do + wave[index] = wave[index + 1] + end + wave[Pool.SURF_WAVE_LENGTH - 1] = first + local ring = 0 + for row = 0, 0x5e do + local value = 0 + if self.lyStart < row then value = wave[ring] end + if row <= SCREEN_ROWS then self.lyBackup[row] = u8(value) end + ring = bit.band(ring + 1, Pool.SURF_WAVE_LENGTH - 1) + end +end + +-- DeformWater: `count` PAIRS of scanlines either side of a centre at +-- lyStart + `progress`, each pair taking the next step of a sine whose angle +-- climbs by 4 a pair. Both walkers start on the centre row, so it is written +-- twice and the figure is symmetric about it. The two bounds checks are not +-- the same test: the downward walker stops once lyEnd < its row, the upward +-- one once lyStart >= its row. +function Pool:deformWater(count, amplitude, offset, progress) + local down = self.lyStart + (progress or 0) + local up = down + local angle = u8(offset) + for _ = 1, u8(count) do + local value = sine(angle, amplitude) + if self.lyEnd >= down then + if down >= 0 and down <= SCREEN_ROWS then self.lyBackup[down] = value end + down = down + 1 + end + if self.lyStart < up then + if up >= 0 and up <= SCREEN_ROWS then self.lyBackup[up] = value end + up = up - 1 + end + angle = u8(angle + 4) + end +end + +-- BattleBGEffect_WavyScreenFX: rotate the window's overrides up one row, the +-- old top row wrapping around to the bottom. Every wobble effect is +-- DeformScreen once to lay the wave down and then this, once a frame, to make +-- it travel. +function Pool:wavyScreenFX() + local span = u8(self.lyEnd - self.lyStart) + if span == 0 then return end + local first = self.lyBackup[self.lyStart] or 0 + for i = 0, span - 1 do + local row = self.lyStart + i + if row > SCREEN_ROWS then break end + self.lyBackup[row] = self.lyBackup[row + 1] or 0 + end + local last = self.lyStart + span + if last <= SCREEN_ROWS then self.lyBackup[last] = first end +end + +-- BattleBGEffect_GetFirstDMGPal / GetNextDMGPal walking a `dc` list. +-- $ff ends the effect (returns nil); $fe restarts the list from the top. +local function nextPal(st, pals) + local index = st.param + st.param = u8(st.param + 1) + local value = pals[index + 1] + if value == nil or value == 0xff then return nil end + if value == 0xfe then + -- Rewind and hand back the list's first entry. + st.param = 0 + value = pals[1] + end + return value +end + +-- BattleBGEffect_GetNthDMGPal: JT doubles as a per-step frame counter, and it +-- is reloaded from the struct's `turn` -- so the SAME field is the flash speed +-- here and the battler side everywhere else. +local function nthPal(st, pals) + if st.jt ~= 0 then + st.jt = st.jt - 1 + local index = st.param + local value = pals[index + 1] + if value == nil or value == 0xff then return nil end + if value == 0xfe then + st.param = 0 + value = pals[1] + end + return value + end + st.jt = st.turn + return nextPal(st, pals) +end + +-------------------------------------------------------------------------- +-- The effects (BattleBGEffects jumptable) +-------------------------------------------------------------------------- + +local E = {} + +E.BATTLE_BG_EFFECT_END = function(_, st) endEffect(st) end + +-- BattleBGEffect_FlashContinue: `turn` is the flash duration, `param` the +-- number of flashes left, and the two palettes alternate. +local function flash(self, st, pals) + if st.jt ~= 0 then + st.jt = st.jt - 1 + return + end + st.jt = st.turn + if st.param == 0 then + endEffect(st) + return + end + st.param = u8(st.param - 1) + self.bgp = pals[bit.band(st.param, 1) + 1] +end + +E.BATTLE_BG_EFFECT_FLASH_INVERTED = function(self, st) + flash(self, st, { dc(3, 2, 1, 0), dc(0, 1, 2, 3) }) +end + +E.BATTLE_BG_EFFECT_FLASH_WHITE = function(self, st) + flash(self, st, { dc(3, 2, 1, 0), dc(0, 0, 0, 0) }) +end + +local WHITE_HUES = { dc(3, 2, 1, 0), dc(3, 2, 0, 0), dc(3, 1, 0, 0), 0xff } +local BLACK_HUES = { dc(3, 2, 1, 0), dc(3, 3, 1, 0), dc(3, 3, 2, 0), 0xff } +local ALTERNATE_HUES = { + dc(3, 2, 1, 0), dc(3, 3, 2, 0), dc(3, 3, 3, 0), dc(3, 3, 2, 0), + dc(3, 2, 1, 0), dc(2, 1, 0, 0), dc(1, 0, 0, 0), dc(2, 1, 0, 0), 0xfe, +} + +E.BATTLE_BG_EFFECT_WHITE_HUES = function(self, st) + local value = nthPal(st, WHITE_HUES) + if not value then + endEffect(st) + return + end + self.bgp = value +end + +E.BATTLE_BG_EFFECT_BLACK_HUES = function(self, st) + local value = nthPal(st, BLACK_HUES) + if not value then + endEffect(st) + return + end + self.bgp = value +end + +E.BATTLE_BG_EFFECT_ALTERNATE_HUES = function(self, st) + local value = nthPal(st, ALTERNATE_HUES) + if not value then + endEffect(st) + return + end + self.bgp, self.obp1 = value, value +end + +local OB_GRAY_YELLOW = { dc(3, 2, 1, 0), dc(2, 1, 0, 0), 0xfe } +local OB_MID_GRAY_YELLOW = { dc(3, 2, 1, 0), dc(3, 1, 2, 0), 0xfe } +local BG_INVERTED = { dc(0, 1, 2, 3), dc(1, 2, 0, 3), dc(2, 0, 1, 3), 0xfe } + +E.BATTLE_BG_EFFECT_CYCLE_OBPALS_GRAY_AND_YELLOW = function(self, st) + local value = nthPal(st, OB_GRAY_YELLOW) + if value then self.obp0 = value end +end + +E.BATTLE_BG_EFFECT_CYCLE_MID_OBPALS_GRAY_AND_YELLOW = function(self, st) + local value = nthPal(st, OB_MID_GRAY_YELLOW) + if value then self.obp0 = value end +end + +E.BATTLE_BG_EFFECT_CYCLE_BGPALS_INVERTED = function(self, st) + local value = nthPal(st, BG_INVERTED) + if value then self.bgp = value end +end + +-- engine/battle_anims/bg_effects.asm:343 +E.BATTLE_BG_EFFECT_HIDE_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self.hidden[self:sideKey(st)] = true + elseif jt >= 1 and jt <= 3 then + incJt(st) + elseif jt == 4 then + endEffect(st) + end +end + +-- BattleBGEffect_RunPicResizeScript: rows of {size, base tile, coord slot}, +-- $ff ending, $fe clearing a box, $fd skipping the draw. The sizes are the +-- six BG squares (6x6, 4x4, 2x2 for the player; 7x7, 5x5, 3x3 for the enemy), +-- which is how a mon grows into or shrinks out of the field. Only the size +-- matters to this port -- the tile ids and the coord slot are the same pic in +-- the same box -- so the script is followed for its TIMING and its scale. +local PIC_RESIZE = { + -- BattleBGEffect_ShowMon + showPlayer = { 0 }, + showEnemy = { 3 }, + -- BattleBGEffect_EnterMon + enterPlayer = { 2, 1, 0 }, + enterEnemy = { 5, 4, 3 }, + -- BattleBGEffect_ReturnMon: each step is preceded by a box clear, which is + -- the -2 row, and the last -3 row leaves the field empty. + returnPlayer = { 0, 1, 2, false }, + returnEnemy = { 3, 4, 5, false }, +} + +local function runPicResize(self, st, script) + local side = self:sideKey(st) + local jt = st.jt + if jt == 0 then + local step = script[st.param + 1] + st.param = u8(st.param + 1) + if step == nil then + self.picSize[side] = nil + endEffect(st) + return + end + if step == false then + self.picSize[side] = nil + self.hidden[side] = true + else + self.picSize[side] = step + self.hidden[side] = false + end + self.liftedRows[side] = nil + incJt(st) + elseif jt >= 1 and jt <= 2 then + incJt(st) + elseif jt == 3 then + st.jt = 0 + elseif jt == 4 then + self.picSize[side] = nil + endEffect(st) + end +end + +E.BATTLE_BG_EFFECT_SHOW_MON = function(self, st) + if self:flyDig(st) then + endEffect(st) + return + end + runPicResize(self, st, self:playerSide(st) + and PIC_RESIZE.showPlayer or PIC_RESIZE.showEnemy) +end + +E.BATTLE_BG_EFFECT_ENTER_MON = function(self, st) + runPicResize(self, st, self:playerSide(st) + and PIC_RESIZE.enterPlayer or PIC_RESIZE.enterEnemy) +end + +E.BATTLE_BG_EFFECT_RETURN_MON = function(self, st) + runPicResize(self, st, self:playerSide(st) + and PIC_RESIZE.returnPlayer or PIC_RESIZE.returnEnemy) +end + +-- BattleBGEffect_RemoveMon slides the pic's tilemap one column a frame +-- towards the edge it came from, eight or nine columns' worth. +E.BATTLE_BG_EFFECT_REMOVE_MON = function(self, st) + local side = self:sideKey(st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.param = self:playerSide(st) and 9 or 8 + elseif jt == 1 then + self.slide[side] = self.slide[side] + (self:playerSide(st) and -8 or 8) + incJt(st) + st.param = u8(st.param - 1) + elseif jt == 2 or jt == 3 then + incJt(st) + elseif jt == 4 then + if st.param == 0 then + self.slide[side] = 0 + self.hidden[side] = true + endEffect(st) + return + end + st.jt = 1 + end +end + +-- The two battler-pic objects: the animation borrows the mon's own tiles as +-- an OBJ so it can be moved without touching the tilemap. +local function battlerObj(self, st, objectPlayer, objectEnemy, rows) + local jt = st.jt + if jt == 0 then + if self:flyDig(st) then + endEffect(st) + return + end + incJt(st) + local player = self:playerSide(st) + self.spawns[#self.spawns + 1] = { + object = player and objectPlayer or objectEnemy, + x = player and (6 * 8) or (16 * 8 + 4), + y = 8 * 8, + param = 0, + } + elseif jt == 1 then + incJt(st) + -- engine/battle_anims/bg_effects.asm:448-465: the rows the OBJ now covers + -- come out of the tilemap, and .five never puts them back. + self.liftedRows[self:sideKey(st)] = rows[self:sideKey(st)] + elseif jt >= 2 and jt <= 4 then + incJt(st) + elseif jt == 5 then + endEffect(st) + end +end + +E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st) + battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", + { player = { 0, 1 }, enemy = { 6, 1 } }) +end + +E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st) + battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", + { player = { 0, 2 }, enemy = { 5, 2 } }) +end + +-- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler +-- (the struct's side) rather than to the whole background, which is what the +-- per-mon fades want; the port keeps that and leaves wBGP alone. +local function rapidCyclePals(self, st, pals) + local jt = st.jt + if jt == 0 then + -- engine/battle_anims/bg_effects.asm:2480-2494 + incJt(st) + st.side = self:sideKey(st) + st.turn = st.param + st.param = 0 + return + end + local side = st.side or self:sideKey(st) + if jt == 1 then + if bit.band(st.turn, 0xf) ~= 0 then + st.turn = u8(st.turn - 1) + return + end + -- The low nybble is reloaded from the high one, which is the step delay. + st.turn = bit.bor(swap(st.turn), st.turn) + local value = nextPal(st, pals) + if value == nil then + -- engine/battle_anims/bg_effects.asm:2515-2519 + st.param = u8(st.param - 1) + return + end + self.monShade[side] = value + return + end + self.monShade[side] = NORMAL_PAL + endEffect(st) +end + +local RAPID_PALS = { + BATTLE_BG_EFFECT_RAPID_FLASH = { 0xe4, 0x6c, 0xfe }, + BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT = { 0xe4, 0x90, 0x40, 0xff }, + BATTLE_BG_EFFECT_FADE_MON_TO_BLACK = { 0xe4, 0xf8, 0xfc, 0xff }, + BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT_REPEATING = { 0xe4, 0x90, 0x40, 0x90, 0xfe }, + BATTLE_BG_EFFECT_FADE_MON_TO_BLACK_REPEATING = { 0xe4, 0xf8, 0xfc, 0xf8, 0xfe }, + BATTLE_BG_EFFECT_CYCLE_MON_LIGHT_DARK_REPEATING = + { 0xe4, 0xf8, 0xfc, 0xf8, 0xe4, 0x90, 0x40, 0x90, 0xfe }, + BATTLE_BG_EFFECT_FLASH_MON_REPEATING = { 0xe4, 0xfc, 0xe4, 0x00, 0xfe }, + BATTLE_BG_EFFECT_FADE_MON_TO_WHITE_WAIT_FADE_BACK = { + 0xe4, 0x90, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x40, 0x90, 0xe4, 0xff, + }, + BATTLE_BG_EFFECT_FADE_MON_FROM_WHITE = { 0x00, 0x40, 0x90, 0xe4, 0xff }, +} + +for name, pals in pairs(RAPID_PALS) do + E[name] = function(self, st) rapidCyclePals(self, st, pals) end +end + +-- BattleBGEffect_FadeMonsToBlackRepeating fades BOTH battlers, on opposite +-- halves of the same four-step ramp. +local FADE_BOTH = { 0xe4, 0xe4, 0xf8, 0x90, 0xfc, 0x40, 0xf8, 0x90 } + +E.BATTLE_BG_EFFECT_FADE_MONS_TO_BLACK_REPEATING = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.param = 0 + return + end + if jt == 1 then + local age = st.param + st.param = u8(st.param + 1) + if bit.band(age, 7) ~= 0 then return end + -- Bits 3-4 pick the pair, doubled into a row index. + local index = bit.rshift(bit.band(age, 0x18), 3) * 2 + local first = FADE_BOTH[index + 1] or NORMAL_PAL + local second = FADE_BOTH[index + 2] or NORMAL_PAL + if self:playerSide(st) then + self.monShade.player, self.monShade.enemy = first, second + else + self.monShade.enemy, self.monShade.player = first, second + end + return + end + self.monShade.player, self.monShade.enemy = NORMAL_PAL, NORMAL_PAL + endEffect(st) +end + +-- BattleBGEffects_GetShakeAmount. JT is the total frame count, PARAM's low +-- nybble the countdown to the next flip (reloaded from its high nybble) and +-- `turn` the amplitude, negated on every flip. Returns nil once it is done. +local function shakeAmount(self, st) + if st.jt == 0 then + endEffect(st) + return nil + end + st.jt = st.jt - 1 + if bit.band(st.param, 0xf) ~= 0 then + st.param = u8(st.param - 1) + return st.turn + end + st.param = bit.bor(swap(st.param), st.param) + st.turn = u8(-st.turn) + return st.turn +end + +E.BATTLE_BG_EFFECT_SHAKE_SCREEN_X = function(self, st) + self.scx = shakeAmount(self, st) or 0 +end + +E.BATTLE_BG_EFFECT_SHAKE_SCREEN_Y = function(self, st) + self.scy = shakeAmount(self, st) or 0 +end + +-- Rollout shakes vertically and hands the negated amount to the first anim +-- object's Y offset, so the boulder rides the shake instead of floating over +-- it. The cart's extra DelayFrame here is what makes Rollout's animation run +-- at half speed; RunBattleAnimScript skips its own frame delay to compensate. +E.BATTLE_BG_EFFECT_ROLLOUT = function(self, st) + local amount = shakeAmount(self, st) + if amount == nil or bit.band(amount, 0x80) ~= 0 then amount = 0 end + self.scy = amount + self.rolloutYOffset = u8(-amount) +end + +E.BATTLE_BG_EFFECT_WOBBLE_SCREEN = function(self, st) + if st.param >= 0x40 then + self.scx = 0 + return + end + self.scx = sine(st.param, 6) + st.param = u8(st.param + 2) +end + +-- Withdraw: a growing number of the attacker's scanlines are pushed off, so +-- the mon appears to pull into its shell. PARAM's low six bits are how far +-- to go and its top two the step. +E.BATTLE_BG_EFFECT_WITHDRAW = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 1 + elseif jt == 1 then + local limit = bit.band(st.param, 0x3f) + if st.turn >= limit then return end + self:displaceLY(st.turn) + local step = bit.band(bit.rshift(st.param, 6), 3) + st.turn = u8(st.turn + step) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Dig: the same displacement, but it pauses and then eats the pic two +-- scanlines at a time until the whole window is gone. +E.BATTLE_BG_EFFECT_DIG = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 2 + st.param = 0 + elseif jt == 1 then + if st.param ~= 0 then + st.param = u8(st.param - 1) + return + end + st.param = 0x10 + incJt(st) + elseif jt == 2 then + local span = u8(self.lyEnd - self.lyStart) - 1 + if span < st.turn then return end + -- Every eighth scanline the effect steps back a state, which is the + -- pause between digs. + if bit.band(st.turn, 7) == 0 then st.jt = u8(st.jt - 1) end + self:displaceLY(st.turn) + st.turn = u8(st.turn + 2) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +-- Tackle: the attacker's rows slide eight pixels towards the target and back. +-- `turn` is the signed step and `param` the distance travelled so far. +local function tackleMoveForward(self, st) + if st.param == u8(-8) or st.param == 8 then incJt(st) end + self:fillLY(st.param) + st.param = u8(st.param + st.turn) +end + +local function tackleReturn(self, st) + if st.param == 0 then incJt(st) end + self:fillLY(st.param) + st.param = u8(st.param + u8(-st.turn)) +end + +local function tackleInit(self, st, backwards) + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + -- SCX scrolls the BACKGROUND, so a negative value moves the mon RIGHT: the + -- player's back pic steps towards the enemy on -2, not +2. + local forward = self:playerSide(st) and u8(-2) or 2 + if backwards then forward = self:playerSide(st) and 2 or u8(-2) end + st.param = 0 + st.turn = forward +end + +E.BATTLE_BG_EFFECT_TACKLE = function(self, st) + local jt = st.jt + if jt == 0 then + tackleInit(self, st, false) + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 2 then + tackleReturn(self, st) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_VITAL_THROW = function(self, st) + local jt = st.jt + if jt == 0 then + tackleInit(self, st, true) + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 3 then + tackleReturn(self, st) + elseif jt == 4 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_BETA_PURSUIT = function(self, st) + local jt = st.jt + if jt == 0 then + tackleInit(self, st, true) + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 2 then + tackleReturn(self, st) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +-- engine/battle_anims/bg_effects.asm:1444 +E.BATTLE_BG_EFFECT_BODY_SLAM = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms2("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.param = 0 + st.turn = self:playerSide(st) and u8(-2) or 2 + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 2 then + tackleReturn(self, st) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_WOBBLE_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.param = 0 + elseif jt == 1 then + self:fillLY(sine(st.param, 8)) + st.param = u8(st.param + 4) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Always the player's rows, and the window is written directly rather than +-- through SetLCDStatCustoms1 -- this is the wobble the player's own mon does +-- when it is confused, whoever is attacking. +E.BATTLE_BG_EFFECT_WOBBLE_PLAYER = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self.lcdc = "SCX" + self.lyStart, self.lyEnd = 0, 0x37 + st.param = 0 + elseif jt == 1 then + if st.param >= 0x40 then + self:resetLCDStatCustom(st) + return + end + self:fillLY(sine(st.param, 6)) + st.param = u8(st.param + 2) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Two sines an octave apart, which is what makes Flail read as thrashing +-- rather than swaying. +E.BATTLE_BG_EFFECT_FLAIL = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn, st.param = 0, 0 + elseif jt == 1 then + local wide = sine(st.param, 6) + local narrow = sine(st.turn, 2) + self:fillLY(u8(wide + narrow)) + st.turn = u8(st.turn + 8) + st.param = u8(st.param + 2) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_VIBRATE_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 1 + st.param = 0x20 + elseif jt == 1 then + if st.param == 0 then + self:resetLCDStatCustom(st) + return + end + st.param = u8(st.param - 1) + -- Flips on the even frames only, so it buzzes at 30 Hz rather than 60. + if bit.band(st.param, 1) ~= 0 then return end + st.turn = u8(-st.turn) + self:fillLY(st.turn) + end +end + +-- BounceDown: the attacker drops in on a cosine and settles, using the same +-- scanline displacement Withdraw does. +E.BATTLE_BG_EFFECT_BOUNCE_DOWN = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms2("SCY", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 1 + st.param = 0x20 + elseif jt == 1 then + if st.turn >= 0x38 then return end + local height = u8(cosine(st.param, 0x10) + 0x10) + self:displaceLY(u8(st.turn + height)) + st.param = u8(st.param + 2) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-------------------------------------------------------------------------- +-- The screen-wide deformations +-------------------------------------------------------------------------- +-- +-- These thirteen are the ones that write a DIFFERENT value to every scanline +-- rather than the same one to a band, so they all sit on DeformScreen, +-- DeformWater or the surf ring above. The shape of each is the ASM's; what +-- the port cannot reproduce is the CGB writing rSCX mid-frame at sub-pixel +-- timing, and none of these depend on that -- they depend on the ARRAY, which +-- is modelled exactly. + +-- Surf. `.zero` lays a 2-amplitude wave into the ring and falls through to +-- `.one` on the same frame (ASM fallthrough, not a jumptable branch), and +-- `.one` waits for hLCDCPointer: engine/battle_anims/functions.asm:1158. +E.BATTLE_BG_EFFECT_SURF = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:initSurfWaves(2, 2) + jt = 1 + end + if jt == 1 then + if not self.lcdc then return end + self:rotateSurfWave() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Whirlpool: the wave covers the WHOLE screen ($00-$5e) rather than one +-- battler's rows, and it scrolls vertically (rSCY), so the water rolls +-- top to bottom behind both mons. +E.BATTLE_BG_EFFECT_WHIRLPOOL = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self.lcdc = "SCY" + self.lyStart, self.lyEnd = 0, 0x5e + self:deformScreen(2, 2) + elseif jt == 1 then + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- The three water effects, which the Surf animation drives as a set: START +-- opens the window and ends itself immediately, WATER does the work, END puts +-- the registers back. +E.BATTLE_BG_EFFECT_START_WATER = function(self, st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + endEffect(st) +end + +-- WATER is the one effect whose three struct fields are all something else: +-- PARAM is the sine phase (climbing 4 a frame), BATTLE_TURN is a frame +-- counter that doubles as the amplitude, and JT_INDEX is the Y position the +-- deformation is centred on. +-- +-- `ld a, [hl]` then `inc [hl]` twice leaves `a` holding the PRE-increment +-- turn, and that is the count DeformWater is called with -- so the figure +-- grows two scanlines a frame from nothing until the counter passes $20. +E.BATTLE_BG_EFFECT_WATER = function(self, st) + local offset = st.param + st.param = u8(st.param + 4) + -- (0xff XOR the high nibble) + 4: the amplitude SHRINKS as the counter + -- climbs, so the wave is widest when it first appears. + local amplitude = u8(bit.bxor(bit.rshift(bit.band(st.turn, 0xf0), 4), 0xff) + 4) + local progress = st.jt + local count = st.turn + if count >= 0x20 then + self:clearLYOverrides(0) + endEffect(st) + return + end + st.turn = u8(st.turn + 2) + self:deformWater(count, amplitude, offset, progress) +end + +E.BATTLE_BG_EFFECT_END_WATER = function(self, st) + self:resetLCDStatCustom(st) +end + +-- Psychic is hardcoded to the whole screen ($00-$5f) whichever side used it, +-- and only travels every FOURTH frame (`and $3 / ret nz`), which is what makes +-- it a slow ripple rather than Teleport's shimmer. +E.BATTLE_BG_EFFECT_PSYCHIC = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self.lcdc = "SCX" + self.lyStart, self.lyEnd = 0, 0x5f + self:deformScreen(6, 5) + st.param = 0 + elseif jt == 1 then + local counter = st.param + st.param = u8(st.param + 1) + if bit.band(counter, 3) ~= 0 then return end + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Teleport: the same wave as Psychic but only over the user's own rows, and +-- travelling every frame. +E.BATTLE_BG_EFFECT_TELEPORT = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self:deformScreen(6, 5) + elseif jt == 1 then + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Night Shade takes its phase step from the struct's PARAM, so the same +-- effect id gives a long slow roll or a tight ripple depending on what the +-- script queued it with. +E.BATTLE_BG_EFFECT_NIGHT_SHADE = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self:deformScreen(2, st.param) + elseif jt == 1 then + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Double Team's afterimage: alternate scanlines are pushed +n and -n, so the +-- pic reads as two copies of itself a few pixels apart. `.UpdateLYOverrides` +-- writes the pair (e, -e) down the window and, on an odd-height window, +-- repeats `e` on the last row -- `srl a` leaves the odd bit in carry and +-- `ret nc` is what skips that store on an even one. +local function doubleTeamOverrides(self, value) + local e = u8(value) + local d = u8(-e) + local span = u8(self.lyEnd - self.lyStart) + local pairs_ = bit.rshift(span, 1) + local odd = bit.band(span, 1) ~= 0 + local row = self.lyStart + for _ = 1, pairs_ do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = e + row = row + 1 + if row > SCREEN_ROWS then return end + self.lyBackup[row] = d + row = row + 1 + end + if odd and row <= SCREEN_ROWS then self.lyBackup[row] = e end +end + +E.BATTLE_BG_EFFECT_DOUBLE_TEAM = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 0 + elseif jt == 1 then + -- Split apart, a pixel a frame, to $10. + if st.param >= 0x10 then + incJt(st) + return + end + local value = st.param + st.param = u8(st.param + 1) + doubleTeamOverrides(self, value) + elseif jt == 2 then + -- Hold, wobbling about the current separation. This state never advances + -- itself; the script's own `incbgeffect` is what moves it on. + local wobble = u8(sine(st.turn, 2) + st.param) + doubleTeamOverrides(self, wobble) + st.turn = u8(st.turn + 4) + elseif jt == 3 then + -- Come back together. The test is `cp $ff`, so a PARAM that started at 0 + -- underflows to $ff and stops there rather than at zero. + if st.param == 0xff then + incJt(st) + return + end + local value = st.param + st.param = u8(st.param - 1) + doubleTeamOverrides(self, value) + elseif jt == 5 then + self:resetLCDStatCustom(st) + end + -- jt 4 is a bare `ret`: the gap the script sits in between the two halves. +end + +-- Acid Armor: the wave is laid down once and then the whole window is scrolled +-- DOWN one scanline a frame, with a blank row ($90) fed in at the top -- so +-- the mon melts into the floor instead of wobbling in place. The two +-- fix-ups at the bottom clear the last two rows once their values are large +-- enough to be showing the pic's own bottom edge. +E.BATTLE_BG_EFFECT_ACID_ARMOR = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self:deformScreen(2, st.param) + self.lyBackup[self.lyEnd] = 0 + self.lyBackup[self.lyEnd - 1] = 0 + elseif jt == 1 then + for row = self.lyEnd, self.lyStart + 1, -1 do + self.lyBackup[row] = self.lyBackup[row - 1] or 0 + end + self.lyBackup[self.lyStart] = 0x90 + local last = self.lyBackup[self.lyEnd] or 0 + if last >= 1 and last ~= 0x90 then self.lyBackup[self.lyEnd] = 0 end + local penultimate = self.lyBackup[self.lyEnd - 1] or 0 + if penultimate >= 2 and penultimate ~= 0x90 then + self.lyBackup[self.lyEnd - 1] = 0 + end + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Wave Deform: the amplitude ramps up to $20 in state 1 and back down to 0 in +-- state 2, at a fixed phase step of 4. Neither ramp advances the state on its +-- own -- the script does -- so how far it gets is the script's business. +E.BATTLE_BG_EFFECT_WAVE_DEFORM_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + elseif jt == 1 then + if st.param >= 0x20 then return end + local amplitude = st.param + st.param = u8(st.param + 1) + self:deformScreen(amplitude, 4) + elseif jt == 2 then + if st.param == 0 then + self:resetLCDStatCustom(st) + return + end + local amplitude = st.param + st.param = u8(st.param - 1) + self:deformScreen(amplitude, 4) + end +end + +-- The two beta send-outs are `; unused` on the cart -- nothing queues them -- +-- but they are in the jumptable, so a mod or a hand-written script can, and +-- an unimplemented entry would sit in the pool forever. +-- +-- MON1 writes rBGP per scanline rather than a scroll register: every other +-- row of the window steps through $00 (all white), $40, $90 and $e4 (normal), +-- eight frames apart, so the pic fades in through a venetian blind. +local BETA_SEND_OUT_PALS = { 0x00, 0x40, 0x90, 0xe4 } + +-- `.SetLYOverridesBackup`: every SECOND scanline, (lyEnd - lyStart) / 2 times. +local function betaBlind(self, value) + local count = bit.rshift(u8(self.lyEnd - self.lyStart), 1) + local row = self.lyStart + for _ = 1, count do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = u8(value) + row = row + 2 + end +end + +-- `.GetLYOverride`: PARAM counts up and its top bits index the palette list, +-- so each entry is held eight frames. Past the end it returns nil, which is +-- the `cp $ff` the caller branches on. +local function betaPal(st) + local index = bit.rshift(st.param, 3) + st.param = u8(st.param + 1) + return BETA_SEND_OUT_PALS[index + 1] +end + +E.BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1 = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0xe4) + self:setLCDStatCustoms1("BGP", st) + self.lyEnd = u8(self.lyEnd + 1) + for row = self.lyStart, self.lyEnd - 1 do + if row > SCREEN_ROWS then break end + self.lyBackup[row] = 0 + end + st.param = 0 + -- `.zero` falls into `.one`, which is a bare ret. + elseif jt == 2 then + local value = betaPal(st) + if value then + betaBlind(self, value) + return + end + st.param = 0 + self.lyStart = u8(self.lyStart + 1) + incJt(st) + elseif jt == 3 then + local value = betaPal(st) + if not value then + incJt(st) + return + end + betaBlind(self, value) + -- The second pass also fills the row the blind skipped at the bottom. + self.lyBackup[self.lyEnd - 1] = u8(value) + elseif jt == 5 then + self:resetVideoHRAM() + endEffect(st) + end + -- jt 1 and 4 are bare rets. +end + +-- MON2 is a plain DeformScreen whose amplitude and phase step are the SAME +-- value, counted down from $40 in eighths -- so the wobble starts at 8 and +-- unwinds to nothing. +E.BATTLE_BG_EFFECT_BETA_SEND_OUT_MON2 = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + st.turn = 0x40 + elseif jt == 1 then + if st.turn == 0 then + self:resetLCDStatCustom(st) + return + end + local value = st.turn + st.turn = u8(st.turn - 1) + -- `ld a, [hl] / dec [hl] / srl a x3`: the PRE-decrement value, shifted. + local amount = bit.band(bit.rshift(value, 3), 0x0f) + self:deformScreen(amount, amount) + end +end + +-- Nothing is left unmodelled. The name stays so a caller (and the tests) can +-- still ask, and so the answer is checkable rather than a claim in a comment. +local UNMODELLED = {} +local UNMODELLED_SET = {} +for _, name in ipairs(UNMODELLED) do UNMODELLED_SET[name] = true end + +local DROPPED = {} +BgEffects.DROPPED = DROPPED +BgEffects.strict = os.getenv("POKEPORT_DEV") == "1" + +local function dropUnknown(st) + local name = st.func + endEffect(st) + if UNMODELLED_SET[name] then return end + if not DROPPED[name] then + DROPPED[name] = true + DROPPED[#DROPPED + 1] = name + end + if BgEffects.strict then + error("BgEffects: no entry for " .. tostring(name)) + end +end + +-------------------------------------------------------------------------- + +-- ExecuteBGEffects: one pass over the five structs. +function Pool:playFrame() + for slot = 1, NUM_EFFECTS do + local st = self.effects[slot] + if st.func then + local fn = E[st.func] + if fn then + fn(self, st) + else + -- An id with no entry would otherwise sit in the pool forever and + -- keep the animation from ending. + dropUnknown(st) + end + end + end +end + +function Pool:takeSpawns() + local spawns = self.spawns + self.spawns = {} + return spawns +end + +BgEffects.EFFECTS = E +BgEffects.NUM_EFFECTS = NUM_EFFECTS +BgEffects.NORMAL_PAL = NORMAL_PAL +BgEffects.SCREEN_ROWS = SCREEN_ROWS +BgEffects.UNMODELLED = UNMODELLED + +return BgEffects diff --git a/src/core/Data.lua b/src/core/Data.lua index 02984c906..e5350464e 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -691,6 +691,9 @@ local function gen2PaletteRows(tileset) end function Data:publishGen2Palettes() + if require('src.core.GameVersion').isGen2()then + for _,def in pairs(self.maps or{})do require('src.render.Gen2PaletteContext').publish(def)end + end local tilesets = self.tilesets if type(tilesets) ~= "table" then return false end @@ -741,6 +744,7 @@ function Data:publishGen2Palettes() self.gen2Palettes = setmetatable({}, { __index = function(_, id) return gen2PaletteRows(tilesets[id]) end, }) + require('src.render.Gen2PaletteContext').bind(self.gen2Palettes,self) return true end diff --git a/src/core/Game.lua b/src/core/Game.lua index fc5db6c50..e70c2262d 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1013,6 +1013,13 @@ function Game:_draw() -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's local drawFrom = Game.drawBaseInStack(self.stack, base) + local ScreenSpaceUI=require('src.render.ScreenSpaceUI') + local scene=self.stack + local windowUI,windowUIIndex=ScreenSpaceUI.select(self.stack,scene,drawFrom) + local windowUIOverlay=false + local nativeWindowOverlays=windowUI and require('src.core.GameVersion').isGen2() + local nativeWindowStack=not windowUI and require('src.core.GameVersion').isGen2() + and ScreenSpaceUI.hasNativeStack(self.stack,scene,drawFrom,self.overworld) local worldDrawn = self.stack.states[drawFrom] == self.overworld -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred @@ -1095,7 +1102,13 @@ function Game:_draw() local ownsSurface = wideState or (native ~= nil and state == native) or Game.wantsThisSurface(state) - if state and state.draw then + if nativeWindowOverlays and windowUIIndex and i>windowUIIndex then + if state and state.draw then windowUIOverlay=true end + elseif nativeWindowStack and state~=self.overworld then + -- Native controllers draw once on the full output surface below. + elseif state==windowUI then + love.graphics.clear(0,0,0,0) + elseif state and state.draw then -- ...and it is centred by ITS OWN size, not by the Game Boy's (see -- Game.homeOffset). classicOffset still gates it: a surface that is -- the Game Boy's centres nothing, which is every frame outside a wide @@ -1117,7 +1130,7 @@ function Game:_draw() end local baseState = self.stack.states[drawFrom] local SuperSample = require("src.render.SuperSample") - if not worldDrawn and baseState and require("src.core.GameVersion").isNds() + if not windowUI and not worldDrawn and baseState and require("src.core.GameVersion").isNds() and SuperSample.singleScreen(self) and not SuperSample.active then SuperSample.draw(self, self.stack:top(), drawStates) else @@ -1171,7 +1184,11 @@ function Game:_draw() end -- 14's render.zones: weather/lighting overlays and custom colorization -- recolor or add zones before the blit - if ModRuntime.wantsHook("render.zones") then + if require("src.core.GameVersion").isGen2() then + Renderer.gen2FrameManaged = true + Renderer.gen2FrameGame = self + Renderer.gen2FrameZones = require("src.render.Gen2Presentation").zones(self) + elseif ModRuntime.wantsHook("render.zones") then zones = ModRuntime.call("render.zones", sameZones, self, zones) end -- Keyed to whether the map actually DREW, not to whether it is the clear's @@ -1183,7 +1200,20 @@ function Game:_draw() if worldDrawn and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end + if Renderer.gen2FrameManaged and (windowUI or nativeWindowStack) then + Renderer.gen2WindowDraw=function(viewport) + if windowUI then + ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,false) + if windowUIOverlay then ScreenSpaceUI.drawNativeStack(self.stack,scene,windowUIIndex+1, + self.overworld,Renderer,viewport,false)end + else ScreenSpaceUI.drawNativeStack(self.stack,scene,drawFrom,self.overworld, + Renderer,viewport,not worldDrawn)end + end + end local viewport = Renderer:endFrame(zones, worldZones) + if windowUI and not Renderer.gen2FrameManaged then + ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,windowUIOverlay) + end -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile -- controls. It never becomes an updating game state. @@ -1408,7 +1438,7 @@ function Game:keypressed(key) or ow.engaging or ow.emote))) if not busy then local PaletteFX = require("src.render.PaletteFX") - self.save.options.colors = PaletteFX.cycleMode() + PaletteFX.writeColorOption(self.save.options, PaletteFX.cycleMode()) self:writeOptions() end return diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..ff6316b05 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1082,7 +1082,14 @@ GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal", GameVersion.current = "red" function GameVersion.set(id) + local previous=GameVersion.current GameVersion.current = GameVersion.VERSIONS[id] and id or "red" + if previous~=GameVersion.current then + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end + end return GameVersion.current end diff --git a/src/core/Gen2Clock.lua b/src/core/Gen2Clock.lua new file mode 100644 index 000000000..dc7d75d17 --- /dev/null +++ b/src/core/Gen2Clock.lua @@ -0,0 +1,128 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +-- Shared native GSC RTC reads. The public Clock spelling follows the +-- reference contract; no private mod implementation or clock is embedded. +local Clock = { MINUTES_PER_DAY = 1440, DAYS = 7, DEFAULT_HOUR = 10, DEFAULT_MINUTE = 0 } +local Strings = require("src.core.Strings") +Clock.DAY_NAMES = { "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY" } +local observed = setmetatable({}, { __mode = "k" }) +local function integer(value) + local n = tonumber(value) + if n and n == n and n ~= math.huge and n ~= -math.huge then return math.floor(n) end +end +local function rtc(save) + return type(save) == "table" and type(save.rtc) == "table" and save.rtc or nil +end +function Clock.hostMinutes(now) + return ((integer(os.date("%H", now)) or 0) * 60 + (integer(os.date("%M", now)) or 0)) % 1440 +end +function Clock.hostWeekday(now) + return (integer(os.date("%w", now)) or 0) % 7 +end +-- Newly set clocks advance by elapsed RTC seconds, including while closed. +-- The old host-local offsets remain readable without migrating a save. Host +-- DST/time-zone changes must not silently change an explicitly set game clock. +local function anchored(state, now) + if not state then return nil end + local at, minute, day = integer(state.anchorTime), integer(state.anchorMinute), integer(state.anchorDay) + if not at or not minute or minute < 0 or minute >= 1440 or not day or day < 0 or day >= 7 then return nil end + now = integer(now or os.time()) + if not now then return nil end + return minute + math.floor((now - at) / 60), day +end +function Clock.minutes(save, now) + local state = rtc(save) + local total = anchored(state, now) + if total then return total % 1440 end + return (Clock.hostMinutes(now) + (state and integer(state.startMinute) or 0)) % 1440 +end +function Clock.hour(save, now) return math.floor(Clock.minutes(save, now) / 60) end +function Clock.minute(save, now) return Clock.minutes(save, now) % 60 end +function Clock.isSet(save) + local state = rtc(save) + return state ~= nil and integer(state.startMinute) ~= nil +end +function Clock.setTime(save, hour, minute) + if type(save) ~= "table" then return false end + hour, minute = integer(hour or 0), integer(minute or 0) + if not hour or not minute then return false end + local now = os.time() + local day, date = Clock.weekday(save, now), Clock.dateKey(save, now) + local wanted = (hour % 24) * 60 + minute % 60 + save.rtc = rtc(save) or {} + save.rtc.startMinute = (wanted - Clock.hostMinutes(now)) % 1440 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 + save.g2DayOffset = save.rtc.startDay + save.rtc.anchorTime, save.rtc.anchorMinute = now, wanted + save.rtc.anchorDay, save.rtc.anchorDate = day, date + return true +end +local function note(save, day, reason, now) + if type(save) ~= "table" then return day end + local Runtime = require("src.mods.Runtime") + if not require("src.core.GameVersion").isGen2() or not Runtime.wants("clock.day_changed") then + observed[save] = nil + return day + end + local previous, date = observed[save], Clock.dateKey(save, now) + observed[save] = { day=day, date=date } -- Commit before dispatch; reentrant reads are silent. + if previous ~= nil and (previous.day ~= day or previous.date ~= date) then + Runtime.emit("clock.day_changed", { day = day, previous = previous.day, reason = reason }) + end + return day +end +function Clock.weekday(save, now) + local state = rtc(save) + local total, day = anchored(state, now) + if total then return note(save, (day + math.floor(total / 1440)) % 7, "rollover", now) end + local offset = state and integer(state.startDay) + if offset == nil then offset = type(save) == "table" and integer(save.g2DayOffset) or 0 end + return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover", now) +end +function Clock.setWeekday(save, day) + day = integer(day) + if type(save) ~= "table" or not day then return false end + local now = os.time() + save.rtc = rtc(save) or {} + day = day % 7 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 + save.rtc.dayOfWeek = day + local total = anchored(save.rtc, now) + if total then save.rtc.anchorDay = (day - math.floor(total / 1440)) % 7 end + -- Keep the existing native field in sync for older native consumers/saves. + save.g2DayOffset = save.rtc.startDay + note(save, day, "set", now) + return true +end +-- Calendar identity for daily/weekly timers. Only date arithmetic uses the +-- host calendar library; elapsed game days come from the same RTC as minutes +-- and weekday. Noon normalization avoids missing/duplicated local DST hours. +function Clock.calendarDate(save, now) + local state = rtc(save) + local total = anchored(state, now) + local year, month, day + if total and type(state.anchorDate) == "string" then + year, month, day = state.anchorDate:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$") + year, month, day = tonumber(year), tonumber(month), tonumber(day) + end + if year and month >= 1 and month <= 12 and day >= 1 and day <= 31 then + local ok, stamp = pcall(os.time, { year=year, month=month, + day=day + math.floor(total / 1440), hour=12, min=0, sec=0, isdst=nil }) + if ok and stamp then return os.date("*t", stamp) end + end + return os.date("*t", now) +end +function Clock.dateKey(save, now) + local date = Clock.calendarDate(save, now) + return ("%04d-%02d-%02d"):format(date.year, date.month, date.day) +end +function Clock.weekdayName(day) + local name = Clock.DAY_NAMES[day] + return name and Strings(name) +end +function Clock.daytimeLabel(hour) + hour = integer(hour) or Clock.hour() + hour = hour % 24 + return Strings(hour < 4 and "NITE" or hour < 10 and "MORN" or hour < 18 and "DAY" or "NITE") +end +function Clock.resetObservations() observed = setmetatable({}, { __mode = "k" }) end +return Clock diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..d651dc3c7 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -34,7 +34,6 @@ -- party follower -- src.ui.gen2.Chrome no shared window-chrome module -- src.ui.gen2.SaveMenu the save flow is part of StartMenu here --- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view -- src.battle.UIVisibility no equivalent -- src.ui.PartyPanel no equivalent -- src.battle.gen2.Battle AMBIGUOUS, which is the same as absent here. @@ -62,11 +61,14 @@ ModCompat.ALIASES = { ["src.world.gen2.Player"] = "src.world.Player", ["src.world.gen2.World"] = "src.world.OverworldController", ["src.world.gen2.Map"] = "src.world.Map", + ["src.ui.gen2.BattleAnimView"] = "src.battle.Gen2AnimView", ["src.world.gen2.Permissions"] = "src.world.Collision", ["src.world.gen2.Palettes"] = "src.render.PaletteFX", -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.core.gen2.Clock"] = "src.core.Gen2Clock", + ["src.ui.gen2.InitClock"] = "src.ui.Gen2InitClock", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", @@ -97,7 +99,8 @@ ModCompat.ALIASES = { -- the Gold port splits its Game class per generation; this one does not ["src.core.Game2"] = "src.core.Game", - ["src.render.GbcPalette"] = "src.render.GBCFX", + -- Indexed image palettes and LCD screen effects are different contracts. + ["src.render.GbcPalette"] = "src.render.Gen2PaletteDraw", } local seen = {} diff --git a/src/import/Gen2BattlePictureArt.lua b/src/import/Gen2BattlePictureArt.lua new file mode 100644 index 000000000..727e5a239 --- /dev/null +++ b/src/import/Gen2BattlePictureArt.lua @@ -0,0 +1,54 @@ +-- GSC GetSubstitutePic / GetMinimizePic, decoded from the mounted ROM. +-- Scratch tiles are column-major on the cartridge; PNG tiles are row-major. +-- No ROM image bytes are shipped with the engine. +local Writer=require('src.import.ImageWriter') +local M={} +local function bytes(e,name,count) + local p=e:symbol(name) + if not p or not e.rom then return end + local ok,raw=pcall(e.rom.bytes,e.rom,p.bank,p.address,count) + if not ok or type(raw)~='table' or #raw~=count then return end + for _,value in ipairs(raw)do + if type(value)~='number' or value%1~=0 or value<0 or value>255 then return end + end + return raw +end +local function box(raw,n,placements) + local out={} + for i=1,n*n*16 do out[i]=0 end + for _,p in ipairs(placements)do + local dest=(p[2]*n+p[1])*16 + for j=1,16 do out[dest+j]=raw[p[3]*16+j]end + end + return Writer.decode2bpp(out,n*8,n*8,true) +end +function M.extract(e) + local doll=bytes(e,'MonsterSpriteGFX',8*16) + local mini=bytes(e,'MinimizePic',16) + local staged=e:symbol('BattleAnimCmd_UpdateActorPic')~=nil + if not(doll or mini or staged)then return end + local record={version=1,stagedActorPic=staged} + if doll then + local front='battle/gen2_substitute_front.png' + local back='battle/gen2_substitute_back.png' + local sheet='battle/gen2_substitute_sheet.png' + e:saveImage(box(doll,7,{{2,5,0},{3,5,1},{2,6,2},{3,6,3}}),front) + e:saveImage(box(doll,6,{{2,4,4},{3,4,5},{2,5,6},{3,5,7}}),back) + e:saveImage(Writer.decode2bpp(doll,16,32,true),sheet) + record.substitute={enemy='assets/generated/'..front, + player='assets/generated/'..back,sheet='assets/generated/'..sheet} + end + if mini then + local front='battle/gen2_minimize_front.png' + local back='battle/gen2_minimize_back.png' + e:saveImage(box(mini,7,{{3,5,0}}),front) + e:saveImage(box(mini,6,{{3,4,0}}),back) + record.minimize={enemy='assets/generated/'..front,player='assets/generated/'..back} + -- GS $e8/$e9 are ret. Crystal stages into vTiles0 before copying to the + -- actor's BG slot; positive symbols also admit supported renamed editions. + record.stagedMinimize=e:symbol('BattleAnimCmd_Minimize')~=nil + and e:symbol('BattleAnimCmd_UpdateActorPic')~=nil + end + return record +end +return M diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..9f9376ec0 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -14787,11 +14787,13 @@ GEN2_ANIM.BY_HANDLER = { IncObj = { "incobj", 1 }, SetObj = { "setobj", 2 }, IncBGEffect = { "incbgeffect", 1 }, - BattlerGFX_1Row = { "battlergfx_1row" }, - BattlerGFX_2Row = { "battlergfx_2row" }, + -- Macro names cross the cartridge handler names: D9's 2row macro + -- dispatches to _1Row. Preserve the decoded macro contract. + BattlerGFX_1Row = { "battlergfx_2row" }, + BattlerGFX_2Row = { "battlergfx_1row" }, -- Prism's names for the same two slots - EnemyFeetObj = { "battlergfx_1row" }, - PlayerHeadObj = { "battlergfx_2row" }, + EnemyFeetObj = { "battlergfx_2row" }, + PlayerHeadObj = { "battlergfx_1row" }, CheckPokeball = { "checkpokeball" }, Transform = { "transform" }, RaiseSub = { "raisesub" }, @@ -14959,7 +14961,8 @@ function RomExtractorGen2:gen2AnimScript(bank, entry) end end if not labels[entry] then return nil end - return { entry = labels[entry], code = code } + return { entry = labels[entry], code = code, bank=bank, + entryAddress=entry, addresses=addresses } end function RomExtractorGen2:gen2AnimObjects() @@ -15110,7 +15113,7 @@ end -- Name each object `function` slot from DoBattleAnimFrame's jumptable. The -- routines themselves are hand-written asm and cannot be ported as data, but --- the names tell the player which motion to approximate. +-- the names resolve the imported IDs to the genuine GSC object routines. function RomExtractorGen2:gen2AnimFunctions() local out = {} -- Resolved from the routine that dispatches through it, never hardcoded: @@ -15149,7 +15152,7 @@ function RomExtractorGen2:gen2BattleAnims() local ok, first = pcall(self.rom.word, self.rom, sym.bank, sym.address) if ok then local count = math.floor((first - sym.address) / 2) - for id = 1, count - 1 do + for id = 0, count - 1 do pcall(function() local entry = self.rom:word(sym.bank, sym.address + id * 2) scripts[id] = self:gen2AnimScript(sym.bank, entry) @@ -15161,8 +15164,11 @@ function RomExtractorGen2:gen2BattleAnims() for index, move in ipairs(order) do if scripts[index] then moveAnims[move] = index end end - return { + local imported = { gen2 = true, + bank = sym.bank, + commandFormat = "gsc-macro-v1", + pictureArt = require('src.import.Gen2BattlePictureArt').extract(self), scripts = scripts, moveAnims = moveAnims, objects = self:gen2AnimObjects(), @@ -15175,8 +15181,10 @@ function RomExtractorGen2:gen2BattleAnims() -- (constants/move_constants.asm: ANIM_THROW_POKE_BALL $100, -- ANIM_SEND_OUT_MON $101, ANIM_IN_SANDSTORM $10b) misc = { throwPokeBall = 256, sendOutMon = 257, inSandstorm = 267 }, + ids = require('src.battle.Gen2AnimationIds').install({gen2=true,scripts=scripts}).ids, move = {}, effects = {}, } + return require('src.battle.Gen2AnimCatalog').compile(imported,self:constants(),self.version) end -- Sequences the ROM never emits but hand-written port text does. diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index bbd959cfd..8bbfb1772 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1323,6 +1323,10 @@ local function markerFor(version) if version == "red" or version == "blue" or version == "yellow" then revision = "gen1-trade-art-v1:" end + -- Reimport generated animation commands; keep saves and unrelated caches. + if GameVersion.generation(version)==2 then + revision=revision..'gen2-anim-object-v'..require('src.battle.Gen2AnimData').CACHE_REVISION..':' + end return CACHE_FORMAT .. revision .. GameVersion.info(version).sha1 end @@ -5705,7 +5709,11 @@ function RomImporter:draw() if link.url==DONATE_URL then paypalLink=link else footerLinks[#footerLinks+1]=link end end - local creditH = self.hintFont:getHeight() + local creditText = "Created by " .. credit.by + .. "\nBased on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC" + .. "\n(https://github.com/bryanthaboi/gen1recomp)" + local _, creditLines = self.hintFont:getWrap(creditText, appW) + local creditH = math.max(1, #creditLines) * self.hintFont:getHeight() local donateH = self.hintFont:getHeight() + 10 * s local chipsH = (#footerLinks > 0) and (8 * s + donateH) or 0 local footerH = 10 * s + bcgDH + 6 * s + warningH + 8 * s + creditH + chipsH + 12 * s @@ -6240,7 +6248,7 @@ function RomImporter:draw() love.graphics.setFont(self.hintFont) local creditY = warningY + warningH + 8 * s col(PAL.warning) - love.graphics.printf("Created by " .. credit.by, appX, creditY, appW, "center") + love.graphics.printf(creditText, appX, creditY, appW, "center") self.donateButtons = {} self.donateButton = nil if #footerLinks > 0 then diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..5aa2d364e 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1310,6 +1310,10 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the @@ -1653,6 +1657,10 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 321387210..e9506843f 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -89,7 +89,7 @@ Evolution.METHODS = { return false end if not evo.timeOfDay then return true end - local night = Evolution.isNight() + local night = Evolution.isNight(game.save) if evo.timeOfDay == "NITE" then return night end return not night -- TR_MORNDAY: morn or day end, @@ -223,10 +223,11 @@ Evolution.HAPPINESS_TO_EVOLVE = 220 Evolution.BASE_HAPPINESS = 70 -- GSC time-of-day bands (constants/time_constants.asm): MORN 4-9, DAY --- 10-17, NITE 18-3. The port has no in-game clock, so the wall clock is --- what GSC's RTC would have been. -function Evolution.isNight() - local hour = tonumber(os.date("%H")) or 12 +-- 10-17, NITE 18-3. GSC reads the shared saved RTC offset; other editions +-- retain their existing host-clock behavior. +function Evolution.isNight(save) + local hour = require("src.core.GameVersion").isGen2() + and require("src.core.Gen2Clock").hour(save) or tonumber(os.date("%H")) or 12 return hour >= 18 or hour < 4 end diff --git a/src/render/Gen2PaletteContext.lua b/src/render/Gen2PaletteContext.lua new file mode 100644 index 000000000..d5631b5f4 --- /dev/null +++ b/src/render/Gen2PaletteContext.lua @@ -0,0 +1,74 @@ +-- Native special-map/roof selection adapted from TileRenderer.lua: +-- Copyright (c) 2026 Cedric. All rights reserved. See LICENSE.md. +-- Additional context/view integration (c) 2026 Roxas2712. +-- See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md; existing upstream rights remain unchanged. +local M={} +local names={[0]='PALETTE_AUTO','PALETTE_DAY','PALETTE_NITE','PALETTE_MORN','PALETTE_DARK', + 'PALETTE_AUTO','PALETTE_AUTO','PALETTE_AUTO'} +local ids={PALETTE_AUTO=0,PALETTE_DAY=1,PALETTE_NITE=2,PALETTE_MORN=3,PALETTE_DARK=4} +local views=setmetatable({},{__mode='k'}) +function M.id(def) + if type(def)~='table'then return nil end + local authored=rawget(def,'palette') + if ids[authored]~=nil then return ids[authored]end + local n=tonumber(def.mapPalette) + return n and n%1==0 and n>=0 and n<=7 and n or nil +end +function M.publish(def) + if type(def)~='table'or rawget(def,'palette')~=nil or getmetatable(def)~=nil then return false end + setmetatable(def,{__index=function(t,key) + if key=='palette'then return names[M.id(t)]end + end}) + return true +end +function M.bind(view,data)views[view]={data=data,tilesets=data.tilesets}end +function M.owner(data) + if type(data)~='table'then return nil end + local binding=views[data] + if binding then + local owner=binding.data + return owner.gen2Palettes==data and owner.tilesets==binding.tilesets and owner or nil,true + end + return type(data.tilesets)=='table'and data or nil,false +end +function M.special(data,def,tileset,row) + if row=='DARK'then return nil end + local special=data and data.field and data.field.gen2MapPalettes + if type(def)~='table'or type(special)~='table'or type(special.rules)~='table'then return nil end + for _,rule in ipairs(special.rules)do + local hit + if rule.kind=='map'then hit=rule.group==def.group and rule.number==def.number + elseif rule.kind=='landmark'then hit=rule.landmark==def.landmark + elseif rule.kind=='tileset'then hit=rule.tileset==tileset end + if hit and rule.landmarkMin then hit=type(def.landmark)=='number'and def.landmark>=rule.landmarkMin end + if hit and rule.landmarkEq then hit=def.landmark==rule.landmarkEq end + if hit then + local set=special.sets and special.sets[rule.set] + local rows=set and(set.ALL or set[row]or set.DAY) + if rows and #rows>0 then return '#pal:'..tostring(rule.set),rows end + return nil -- The first matching rule owns the decision, even if unavailable. + end + end +end +function M.roof(data,def,tileset,row) + local roofs=data and data.field and data.field.gen2Roofs + if type(roofs)~='table'or type(tileset)~='table'or + type(roofs.tilesets)~='table'or not roofs.tilesets[tileset.id]then return nil end + local group=def and def.group + local id=group~=nil and type(roofs.byGroup)=='table'and roofs.byGroup[group]or nil + if id==nil then return nil end + local image=type(roofs.images)=='table'and roofs.images[id]or nil + if not image then return nil end + local byTod=type(roofs.colors)=='table'and roofs.colors[group]or nil + local pair=byTod and(byTod[row]or byTod.DAY)or nil + return{image=image,pair=pair,slot=roofs.slot or 10,count=roofs.count or 9,palIndex=roofs.palIndex or 6, + variants=tileset.tileVariants,key='#roof:'..tostring(id)..'.'..tostring(group)} +end +function M.withRoof(rows,roof) + if not(roof and roof.pair)then return rows end + local out={};for i=1,#rows do out[i]=rows[i]end + local slot=roof.palIndex+1;local row=out[slot] + if row then out[slot]={row[1],roof.pair[1],roof.pair[2],row[4]}end + return out +end +return M diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua new file mode 100644 index 000000000..f2727e194 --- /dev/null +++ b/src/render/Gen2PaletteDraw.lua @@ -0,0 +1,284 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Indexed image drawing, separate from the LCD post-process in GBCFX. +-- Palette resolution uses the native display/register state in PaletteFX. +local Palettes = require("src.render.PaletteFX") +local Version = require("src.core.GameVersion") +local M = {} +-- Display state belongs to the same owner as the native map/OBJ/pic bakes. +setmetatable(M, {__index = function(_, key) + if key == "mode" then + if Version.isGen2() and Palettes.mode == "custom" then return "custom" end + if Palettes.mode == "og" or Palettes.mode == "og_inv" then return "dmg" end + return Palettes.mode == "classic" and "classic" or "gbc" + end + if key == "bgp" then return Palettes.bgp() end + if key == "customRamp" then return Palettes.customRamp() end +end, __newindex = function(self, key, value) + if key == "bgp" then Palettes.setBgp(value) + elseif key == "mode" then M.setMode(value) + elseif key == "customRamp" then Palettes.setCustomRamp(value) + else rawset(self, key, value) end +end}) +local compiler, shaders, failed = nil, {}, {} +local sent = setmetatable({}, {__mode = "k"}) +local names = {"pal0", "pal1", "pal2", "pal3"} +local source = [[ +extern vec3 pal0; extern vec3 pal1; extern vec3 pal2; extern vec3 pal3; +vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { + vec4 pixel = Texel(image, uv); + float shade = floor((1.0 - pixel.r) * 3.0 + 0.5); + vec3 rgb = shade < 0.5 ? pal0 : (shade < 1.5 ? pal1 : (shade < 2.5 ? pal2 : pal3)); + return vec4(rgb, ALPHA) * tint; +} +]] + +-- A composited CGB frame has lost its palette indices. Match only colours +-- present in its BG/OBJ tables; unrelated RGB art must keep its own colour. +-- Constant loop bounds also work on GLSL ES uniform-array implementations. +M.REMAP_MAX = 64 +M.REMAP_TOLERANCE = (3 / 255) ^ 2 +local remapSource = [[ +extern int remapCount; +extern float remapTol; +extern vec3 remapSrc[64]; +extern vec3 remapDst[64]; +vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { + vec4 pixel = Texel(image, uv); + vec3 rgb = pixel.rgb; + float closest = remapTol; + for (int i = 0; i < 64; i++) { + if (i >= remapCount) break; + vec3 delta = pixel.rgb - remapSrc[i]; + float distanceSquared = dot(delta, delta); + if (distanceSquared < closest) { + closest = distanceSquared; + rgb = remapDst[i]; + } + } + return vec4(rgb, pixel.a) * tint; +} +]] + +local function graphics() return love and love.graphics end +local function compile(key, code, keyed) + local g = graphics() + local newShader = g and g.newShader + if type(newShader) ~= "function" then return nil end + if compiler ~= newShader then + compiler, shaders, failed = newShader, {}, {} + end + if failed[key] then return nil end + if not shaders[key] then + if key ~= "remap" then + local alpha = keyed and "(shade < 0.5 ? 0.0 : pixel.a)" or "pixel.a" + code = (code:gsub("ALPHA", alpha)) + end + local ok, value = pcall(newShader, code) + if not ok or not value then failed[key] = true; return nil end + shaders[key] = value + end + return shaders[key] +end +local function shader(keyed) + return compile(keyed and "keyed" or "normal", source, keyed) +end +function M.shader() return shader(false) end +function M.keyedShader() return shader(true) end +function M.remapShader() return compile("remap", remapSource) end +function M.available() return M.shader() ~= nil end + +local function copy(colors) + if type(colors) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + local color = colors[i] + if type(color) ~= "table" then return nil end + local row = {} + for c = 1, 3 do + local n = color[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + row[c] = n + end + out[i] = row + end + return out +end +M.BGP_IDENTITY = Palettes.BGP_IDENTITY +M.bgpShades = Palettes.bgpShades +M.remap = Palettes.remap +M.setBgp = Palettes.setBgp +M.MODES = {"gbc", "dmg", "classic"} +M.MODE_LABELS = {gbc="GEN 2", dmg="DMG", classic="CLASSIC", custom="CUSTOM"} +M.CUSTOM_MODE = "custom" +M.setCustomRamp = Palettes.setCustomRamp +M.presentColors = Palettes.presentColors +function M.setMode(mode) + if mode~="gbc" and mode~="dmg" and mode~="classic" and mode~="custom" then mode="gbc" end + Palettes.setMode(mode) + return M.mode +end +function M.cycle(delta) Palettes.cycleMode(delta); return M.mode end +function M.applyOptions(opts) Palettes.applyOptions(opts); return M.mode end +function M.modeLabel(mode) return M.MODE_LABELS[mode or M.mode] or "GEN 2" end +function M.resolve(colors) return Palettes.displayColors(colors) end +function M.color(colors, index) + local palette = Palettes.effectiveColors(colors or Palettes.GRAYS) + return palette and palette[index] or nil +end + +local function upload(sh, colors) + -- Public shader objects can be edited externally. Upload every public call; + -- a cached last-send comparison could otherwise trust foreign uniforms. + sent[sh] = nil + for i = 1, 4 do + local row = colors[i] + sh:send(names[i], {row[1]/255, row[2]/255, row[3]/255}) + end + sent[sh] = colors +end +local function use(colors, keyed, raw) + local g = graphics() + if not (g and g.setShader) then return false end + colors = copy(raw and colors or Palettes.effectiveColors(colors)) + if not colors then return false end + local sh = shader(keyed) + if not sh then return false end + upload(sh, colors) + g.setShader(sh) + return true +end +function M.use(colors) return use(colors, false, false) end +function M.useRaw(colors) return use(colors, false, true) end +function M.useKeyed(colors) return use(colors, true, false) end +function M.clear() + local g = graphics() + if g and g.setShader then g.setShader() end +end + +-- The first BG slot wins when the same RGB value has different meanings. +-- Report those collisions rather than pretending an RGB frame retains its +-- original indices. OBJ rows normally map to themselves, protecting sprites. +function M.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) + local src, dst, seen, ambiguous = {}, {}, {}, 0 + local function key(c) + return math.floor(c[1]) .. "," .. math.floor(c[2]) .. "," .. math.floor(c[3]) + end + local function add(colors, mapped) + colors, mapped = copy(colors), copy(mapped) + if not (colors and mapped) then return end + for i = 1, 4 do + local from, to = colors[i], mapped[i] + local k = key(from) + local at = seen[k] + if at then + if key(dst[at]) ~= key(to) then ambiguous = ambiguous + 1 end + elseif #src < M.REMAP_MAX then + src[#src + 1], dst[#dst + 1] = from, to + seen[k] = #src + end + end + end + local forced = ramp and M.resolve(copy(ramp)) + local forcedBg = forced and M.remap(forced, byte) + for _, colors in ipairs(bgPalettes or {}) do + local resolved = M.resolve(copy(colors)) + add(resolved, forcedBg or M.remap(resolved, byte)) + end + if forced then add(forced, forcedBg) end + for i, colors in ipairs(objPalettes or {}) do + local resolved = M.resolve(copy(colors)) + add(resolved, forced and objRamped and objRamped[i] and forced or resolved) + end + local count = #src + for i = count + 1, M.REMAP_MAX do + src[i] = src[1] and {unpack(src[1])} or {0, 0, 0} + dst[i] = dst[1] and {unpack(dst[1])} or {0, 0, 0} + end + return src, dst, count, ambiguous +end +function M.remapUniforms(bgPalettes, byte, objPalettes, ramp, objRamped) + local src, dst, count, ambiguous = M.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) + if count == 0 then return nil end + for i = 1, M.REMAP_MAX do + for c = 1, 3 do src[i][c], dst[i][c] = src[i][c]/255, dst[i][c]/255 end + end + return {src=src, dst=dst, count=count, ambiguous=ambiguous} +end +local remapSent = setmetatable({}, {__mode = "k"}) +local function uploadRemap(sh, uniforms) + remapSent[sh] = nil + sh:send("remapCount", uniforms.count) + sh:send("remapTol", M.REMAP_TOLERANCE) + sh:send("remapSrc", unpack(uniforms.src)) + sh:send("remapDst", unpack(uniforms.dst)) + remapSent[sh] = uniforms +end +local function copyUniforms(uniforms) + if type(uniforms) ~= "table" or type(uniforms.count) ~= "number" + or uniforms.count < 1 or uniforms.count > M.REMAP_MAX + or uniforms.count % 1 ~= 0 then return nil end + local result = {count=uniforms.count, ambiguous=uniforms.ambiguous, src={}, dst={}} + for _, name in ipairs({"src", "dst"}) do + if type(uniforms[name]) ~= "table" then return nil end + for i = 1, M.REMAP_MAX do + local row = uniforms[name][i] + if type(row) ~= "table" then return nil end + result[name][i] = {} + for c = 1, 3 do + local n = row[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 1 then return nil end + result[name][i][c] = n + end + end + end + return result +end +function M.useRemapUniforms(uniforms) + local g = graphics() + if not (g and g.setShader) then return false end + local sh = M.remapShader() + uniforms = copyUniforms(uniforms) + if not (sh and uniforms) then return false end + local previous = remapSent[sh] + if not pcall(uploadRemap, sh, uniforms) then + if previous then pcall(uploadRemap, sh, previous) end + failed.remap, shaders.remap = true, nil + return false + end + g.setShader(sh) + return true, uniforms.ambiguous +end +function M.useRemap(bgPalettes, byte, objPalettes, ramp, objRamped) + return M.useRemapUniforms(M.remapUniforms(bgPalettes, byte, objPalettes, ramp, objRamped)) +end + +local function scoped(colors, body, apply) + local g = graphics() + local previous = g and g.getShader and g.getShader() or nil + -- Restoring only the shader object is insufficient: nested calls reuse it + -- and change its uniforms. Save the palette values as well as its identity. + local previousColors = previous and sent[previous] + local previousRemap = previous and remapSent[previous] + local ok, applied = pcall(function() + local active = apply(colors) + body() + return active + end) + local restored, why = pcall(function() + if previousColors then upload(previous, previousColors) end + if previousRemap then uploadRemap(previous, previousRemap) end + if g and g.setShader then g.setShader(previous) end + end) + if not ok then error(applied, 0) end + if not restored then error(why, 0) end + return applied +end +function M.with(colors, body) return scoped(colors, body, M.use) end +function M.withRaw(colors, body) return scoped(colors, body, M.useRaw) end +function M.keyedWith(colors, body) return scoped(colors, body, M.useKeyed) end +-- Graphics push/pop retains a shader object, not its mutable uniforms. An +-- OAM pass selects several palettes itself, so preserve the outer indexed +-- palette without imposing an extra palette on the first object. +function M.preserving(body) return scoped(nil, body, function()return false end) end +return M diff --git a/src/render/Gen2PokemonPalettes.lua b/src/render/Gen2PokemonPalettes.lua new file mode 100644 index 000000000..576e29589 --- /dev/null +++ b/src/render/Gen2PokemonPalettes.lua @@ -0,0 +1,61 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Public Gen2 colour reads over the native palette pack. No duplicate +-- species catalogue, ROM colours or private renderer implementation. +local Context = require("src.render.Gen2PaletteContext") +local M = {} + +-- Return the native source table without allocation for the built-in renderer. +-- Public consumers receive detached RGB values through colors() below. +function M.native(data, species, shiny) + local owner = Context.owner(data) + local pokemon = owner and owner.pokemon + local def = type(pokemon) == "table" and pokemon[species] + local pack = owner and owner.palettes + local palettes = type(pack) == "table" and pack.palettes + if type(def) ~= "table" or type(palettes) ~= "table" then return nil end + if shiny and def.shinyPalette and palettes[def.shinyPalette] then + return palettes[def.shinyPalette] + end + if def.palette and palettes[def.palette] then return palettes[def.palette] end + local names = pack.pokemon + local name = type(names) == "table" and (names[def.id or species] or names[species]) + return name and palettes[name] or nil +end + +local function color(value) + if type(value) ~= "table" then return nil end + local rgb = {} + for i = 1, 3 do + local n = value[i] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + rgb[i] = n + end + return rgb +end +local function detached(palette) + if type(palette) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + out[i] = color(palette[i]) + if not out[i] then return nil end + end + return out +end + +function M.colors(data, species, shiny) + if type(data) ~= "table" or type(species) ~= "string" then return nil end + local owner, bound = Context.owner(data) + if owner then return detached(M.native(data, species, shiny)) end + if bound then return nil end -- Never revive a retired dataset view. + -- Reference palette-only records carry the two ROM middle colours, rather + -- than native names into a four-colour pack. Preserve that public input. + local rows = data.pokemon + local entry = type(rows) == "table" and rows[species] + if type(entry) ~= "table" then return nil end + local pair = shiny and entry.shiny or entry.normal + if type(pair) ~= "table" then return nil end + local first, second = color(pair[1]), color(pair[2]) + if not first or not second then return nil end + return { {255,255,255}, first, second, {0,0,0} } +end +return M diff --git a/src/render/Gen2Presentation.lua b/src/render/Gen2Presentation.lua new file mode 100644 index 000000000..017a877da --- /dev/null +++ b/src/render/Gen2Presentation.lua @@ -0,0 +1,79 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Gen2 display zones belong to the completed colour frame, after indexed +-- native BG/OBJ/UI bakes and before present/output effects. In Classic those +-- bakes are grayscale and this pass supplies the green ramp exactly once. +local P = require("src.render.PaletteFX") +local Gbc = require("src.render.Gen2PaletteDraw") +local Runtime = require("src.mods.Runtime") +local M = {} +local target +local function identity(_, zones) return zones end +function M.zones(game) + local colors = P.presentColors() + local zones = colors and {{x=0,y=0,w=160,h=144,colors=colors}} or nil + if Runtime.wantsHook("render.zones") then + zones = Runtime.call("render.zones", identity, game, zones) + end + return zones +end +local function finite(n) return type(n)=="number" and n==n and math.abs(n)x1 and y2>y1 then + g.setScissor(x1,y1,x2-x1,y2-y1) + if zone.colors==false then g.setShader();g.draw(source,0,0) + else Gbc.withRaw(zone.colors,function()g.draw(source,0,0)end) end + end + end + end,debug.traceback) + g.pop() + if not ok then error(why,0) end + end) +end +function M.apply(source, zones, w, h) + if not (type(zones)=="table" and zones[1]) then return source end + local g=love.graphics + if not (g.newCanvas and Gbc.available()) then return source end + local dpi=source.getDPIScale and source:getDPIScale() or 1 + if not target or target:getWidth()~=w or target:getHeight()~=h + or (target.getDPIScale and target:getDPIScale()~=dpi) then + if target then target:release() end + target=g.newCanvas(w,h,{format="normal",dpiscale=dpi,msaa=0}) + target:setFilter("nearest","nearest") + end + local previous={g.getCanvas()} + Gbc.withRaw(nil,function() + g.push("all") + local ok,why=xpcall(function() + g.setCanvas(target);g.origin();g.setShader();g.setScissor() + g.setColorMask(true,true,true,true);g.setStencilTest();g.setDepthMode() + g.setBlendMode("alpha","premultiplied");g.clear(0,0,0,1) + M.blit(source,zones,w,h) + end,debug.traceback) + g.setCanvas(unpack(previous));g.pop() + if not ok then error(why,0) end + end) + return target +end +function M.reset() + if target then target:release();target=nil end +end +return M diff --git a/src/render/Palette.lua b/src/render/Palette.lua new file mode 100644 index 000000000..6982156b2 --- /dev/null +++ b/src/render/Palette.lua @@ -0,0 +1,136 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Palette IDs and category vocabulary follow Gen1Recomp's public protocol. +-- An optional authored data.gb_palettes library supplies named ramps; this +-- module contains no cartridge palette catalogue or private renderer data. +local P = {} +P.MONO_COLOURS = { + {"GREEN",155,188,15}, {"LIME",168,216,56}, {"OLIVE",166,172,132}, + {"AMBER",255,176,0}, {"ORANGE",255,128,48}, {"RED",255,80,80}, + {"ROSE",255,144,176}, {"MAGENTA",240,96,208}, {"PURPLE",176,112,240}, + {"INDIGO",128,128,240}, {"BLUE",80,160,255}, {"CYAN",64,224,224}, + {"TEAL",64,192,160}, {"MINT",144,224,176}, {"SAND",224,200,144}, + {"BROWN",192,144,80}, {"WHITE",255,255,255}, {"SILVER",208,216,224}, +} +P.GREY_CHROMA, P.HUE_ARC = 12, 50 +P.CATEGORIES = {{key="full",label="FULL COLOUR"},{key="single",label="SINGLE COLOUR"},{key="grey",label="GREYSCALE"}} +local library, index, buckets, lastId, lastRamp +function P.packs() + if library == nil then + local ok, value = pcall(require, "data.gb_palettes") + library = ok and type(value) == "table" and value or false + end + return library or {} +end +function P.packId(group, name) return "p:" .. group .. "/" .. name end +function P.monoId(r,g,b) return ("m:%02x%02x%02x"):format(r,g,b) end +function P.monoRamp(r,g,b) + local out = {} + for i, scale in ipairs({1,0.72,0.38,0.12}) do + out[i] = {math.floor(r*scale+0.5),math.floor(g*scale+0.5),math.floor(b*scale+0.5)} + end + return out +end +function P.unhex(text) + if type(text) ~= "string" or #text ~= 24 or text:find("[^%x]") then return nil end + local out = {} + for i=1,4 do + out[i] = {} + for c=1,3 do local at=(i-1)*6+(c-1)*2+1;out[i][c]=tonumber(text:sub(at,at+1),16) end + end + return out +end +function P.hueChroma(color) + local r,g,b=color[1] or 0,color[2] or 0,color[3] or 0 + local hi,lo=math.max(r,g,b),math.min(r,g,b) + local c=hi-lo + if c<=0 then return 0,0 end + local h + if hi==r then h=((g-b)/c)%6 elseif hi==g then h=(b-r)/c+2 else h=(r-g)/c+4 end + return h*60,c +end +function P.hueArc(hues) + local span=360 + for _,start in ipairs(hues) do + local width=0 + for _,hue in ipairs(hues) do width=math.max(width,(hue-start)%360) end + span=math.min(span,width) + end + return span +end +function P.categoryOf(ramp) + if type(ramp)~="table" or not ramp[4] then return nil end + local hues={} + for i=1,4 do + local h,c=P.hueChroma(ramp[i]) + if c>P.GREY_CHROMA then hues[#hues+1]=h end + end + if #hues==0 then return "grey" end + return P.hueArc(hues)<=P.HUE_ARC and "single" or "full" +end +local function ensureIndex() + if index then return end + index,buckets={},{full={},single={},grey={}} + for _,pack in ipairs(P.packs()) do + for _,entry in ipairs(pack.palettes or {}) do + if type(entry[1])=="string" then + local id=P.packId(pack.name,entry[1]);index[id]=entry + local ramp=P.unhex(entry[2]);local category=ramp and P.categoryOf(ramp) + if category then + local bucket=buckets[category] + bucket[#bucket+1]={name=entry[1],pack=pack.name,id=id} + end + end + end + end +end +function P.findPack(group,name) ensureIndex();return index[P.packId(group,name)] end +function P.category(key) + ensureIndex() + for _,category in ipairs(P.CATEGORIES) do + if category.key==key then return {key=key,name=category.label,palettes=buckets[key]} end + end +end +function P.categories() + local out={} + for _,category in ipairs(P.CATEGORIES) do out[#out+1]=P.category(category.key) end + return out +end +function P.locate(id) + if type(id)~="string" or id=="" then return nil end + ensureIndex() + for _,category in ipairs(P.CATEGORIES) do + for i,palette in ipairs(buckets[category.key]) do if palette.id==id then return category.key,i end end + end +end +function P.label(id) + if type(id)~="string" or id=="" then return nil end + local hex=id:match("^m:(%x+)$") + if hex then + local r,g,b=tonumber(hex:sub(1,2),16),tonumber(hex:sub(3,4),16),tonumber(hex:sub(5,6),16) + for _,color in ipairs(P.MONO_COLOURS) do if r==color[2] and g==color[3] and b==color[4] then return color[1] end end + return "MONO" + end + return id:match("^p:.*/([^/]+)$") or id +end +function P.ramp(id) + if type(id)~="string" or id=="" then return nil end + if id==lastId then return lastRamp end + local ramp + local hex=id:match("^m:(%x%x%x%x%x%x)$") + if hex then ramp=P.monoRamp(tonumber(hex:sub(1,2),16),tonumber(hex:sub(3,4),16),tonumber(hex:sub(5,6),16)) + else + local group,name=id:match("^p:(.*)/([^/]+)$") + local entry=group and P.findPack(group,name) + local raw=entry and P.unhex(entry[2]) + if raw then ramp={raw[4],raw[3],raw[2],raw[1]} end + end + lastId,lastRamp=id,ramp + return ramp +end +function P.swatch(id) + local ramp=P.ramp(id) + if ramp then return {ramp[4],ramp[3],ramp[2],ramp[1]} end +end +function P.invalidate() index,buckets,lastId,lastRamp=nil,nil,nil,nil end +return P diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 88984d94b..5cea28f01 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -44,6 +44,31 @@ PaletteFX.MODE_LABELS = { } PaletteFX.mode = "gbc" +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +PaletteFX.GEN2_MODES = {"gbc", "og", "classic"} +local customRamp, customKey = nil, "" +function PaletteFX.customRamp() return customRamp end +function PaletteFX.displayKey() return tostring(PaletteFX.mode) .. customKey end +function PaletteFX.colorModes() + return GameVersion.isGen2() and PaletteFX.GEN2_MODES or PaletteFX.MODES +end +local function rampKey(ramp) + if ramp == nil then return "" end + if type(ramp) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + local row = ramp[i] + if type(row) ~= "table" then return nil end + for c = 1, 3 do + local n = row[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + out[#out + 1] = ("%.17g"):format(n) + end + end + return "#ramp:" .. table.concat(out, ",") +end + -- ------- dark-cave state (wMapPalOffset) -- -- Unlike the per-frame shadeMap further down, this outlives a frame: ADVANCED @@ -421,6 +446,7 @@ local GEN2_SHADE_MODES = { } function PaletteFX.usesGen2ObjPal(mode) + if GameVersion.isGen2() then return true end mode = mode or PaletteFX.mode return not GEN2_SHADE_MODES[mode] end @@ -467,6 +493,10 @@ local gen2ClockTod = "DAY" -- what the clock alone says local gen2MapPalette = 0 -- PALETTE_* from the current map's header -- OverworldState:timeOfDay answers "MORNING"/"NITE"; a mod's world.tod hook may -- answer "NIGHT". Normalise to the four row names the importer writes. +-- Public ROM row order, shared with native Gen2 atlas consumers. +PaletteFX.DAYTIMES = { "MORN", "DAY", "NITE", "DARK" } +PaletteFX.DAYTIME_ID = { MORN = 1, DAY = 2, NITE = 3, DARK = 4 } + local GEN2_TOD_ALIAS = { MORN = "MORN", MORNING = "MORN", DAY = "DAY", DAYTIME = "DAY", NITE = "NITE", NIGHT = "NITE", EVE = "NITE", EVENING = "NITE", @@ -485,6 +515,29 @@ local GEN2_TOD_ALIAS = { local GEN2_MAP_PALETTE_ROW = { [1] = "DAY", [2] = "NITE", [3] = "MORN", [4] = "NITE", } +local gen2FlashUsed=false +local gen2CurrentDef +function PaletteFX.clearGen2Context()gen2CurrentDef=nil;gen2FlashUsed=false end +function PaletteFX.bindGen2Context(def,flashUsed) + gen2CurrentDef=type(def)=='table'and def or nil;gen2FlashUsed=flashUsed==true +end +function PaletteFX.setGen2FlashUsed(on)gen2FlashUsed=on==true end +function PaletteFX.effectiveGen2Tod()return darkWorld and 'DARK'or gen2Tod end +-- AUTO neighbours share the entered AUTO scene's presentation override. +-- A pinned current room/cave has no outdoor clock presentation to share: +-- neighbouring AUTO maps read the saved RTC instead of its unused hook row. +local function gen2NeighbourClock() + local id=require('src.render.Gen2PaletteContext').id(gen2CurrentDef) + if GEN2_MAP_PALETTE_ROW[id] then return PaletteFX.clockDaytime() end + return gen2ClockTod +end +function PaletteFX.gen2RowFor(def) + if def~=nil and def==gen2CurrentDef then return PaletteFX.effectiveGen2Tod()end + local id=require('src.render.Gen2PaletteContext').id(def) + if id==nil then return PaletteFX.effectiveGen2Tod() end + if id==4 then return gen2FlashUsed and 'NITE'or 'DARK'end + return GEN2_MAP_PALETTE_ROW[id]or gen2NeighbourClock() +end local function gen2RecomputeTod() local key = GEN2_MAP_PALETTE_ROW[gen2MapPalette] or gen2ClockTod @@ -502,7 +555,8 @@ end -- Called on every map change with the map's PALETTE_* byte (0 = AUTO). -- Same contract as setGen2Tod: true means the atlases are stale. -function PaletteFX.setGen2MapPalette(palette) +function PaletteFX.setGen2MapPalette(palette, mapDef) + gen2CurrentDef=type(mapDef)=='table'and mapDef or nil local value = tonumber(palette) or 0 if value < 0 or value > 7 then value = 0 end -- PALETTE_5..7 are unused slots that ReplaceTimeOfDayPals maps to $E4, the @@ -535,6 +589,22 @@ function PaletteFX.gen2Tod() return gen2Tod end -- bgSet fetches it (LoadMapPals). Neither reads or writes the live state, so -- calling them cannot disturb what the 2D atlas was baked with. +-- Public RTC-only query used by Gen2 scene renderers (including train rides). +-- It must not inherit the live map override, darkness, or atlas bake state. +function PaletteFX.clockDaytime(hour) + hour = tonumber(hour) + if not hour and GameVersion.isGen2() then + local game = package.loaded["src.core.Game"] + hour = require("src.core.Gen2Clock").hour(game and game.save) + end + if not hour then hour = tonumber(os.date("%H")) end + hour = math.floor(hour or 12) % 24 + if hour < 4 then return "NITE" end + if hour < 10 then return "MORN" end + if hour < 18 then return "DAY" end + return "NITE" +end + -- Which of MORN / DAY / NITE / DARK a map takes. -- -- mapDef a record out of data.maps; its `mapPalette` is map header @@ -546,6 +616,11 @@ function PaletteFX.gen2Tod() return gen2Tod end -- a caller that has no flash flag to offer gets the engine's own -- darkWorld state, which already folds in save.flashLit. function PaletteFX.daytimeFor(mapDef, hour, flashUsed) + local id=require('src.render.Gen2PaletteContext').id(mapDef) + if id~=nil then + if id==4 then return flashUsed==true and 'NITE'or 'DARK'end + return GEN2_MAP_PALETTE_ROW[id]or(hour==nil and gen2CurrentDef and gen2NeighbourClock())or PaletteFX.clockDaytime(hour) + end -- Darkness wins outright and the clock stops mattering, which is what -- .NeedsFlash does: an unlit cave is the DARKNESS row at noon. if flashUsed ~= true and darkWorld then return "DARK" end @@ -559,13 +634,7 @@ function PaletteFX.daytimeFor(mapDef, hour, flashUsed) if override then return override end -- AUTO: GetTimeOfDay's own table (5:$4032 .TimeOfDayTable). - hour = tonumber(hour) - if not hour then hour = tonumber(os.date("%H")) end - hour = math.floor(hour or 12) % 24 - if hour < 4 then return "NITE" end - if hour < 10 then return "MORN" end - if hour < 18 then return "DAY" end - return "NITE" + return PaletteFX.clockDaytime(hour) end -- The eight BG palettes a map wears on a given row. @@ -596,9 +665,18 @@ function PaletteFX.bgSet(data, mapDef, daytime) end if type(rows) ~= "table" then return nil end - local row = GEN2_TOD_ALIAS[tostring(daytime or ""):upper()] or gen2Tod + local row = GEN2_TOD_ALIAS[tostring(daytime or ""):upper()] or PaletteFX.gen2RowFor(mapDef) local set = rows[row] or rows.DAY if type(set) ~= "table" or set[1] == nil then return nil end + local context=require('src.render.Gen2PaletteContext') + local owner,bound=context.owner(data) + if bound and not owner then return nil end + if owner then + local _,special=context.special(owner,mapDef,tilesetId,row) + if special then set=special end + local tileset=owner.tilesets and owner.tilesets[tilesetId] + set=context.withRoof(set,context.roof(owner,mapDef,tileset,row)) + end return set, row end @@ -787,6 +865,10 @@ function PaletteFX.monPal(data, species, transformed, shiny) return p.palettes.GRAYMON or (data and data.palettes and data.palettes.palettes.GRAYMON) end + if GameVersion.isGen2() then + local native = require("src.render.Gen2PokemonPalettes").native(data, species, shiny) + if native then return native end + end local def = data and data.pokemon and data.pokemon[species] if shiny and def and def.shinyPalette then local pal = p.palettes[def.shinyPalette] @@ -828,6 +910,12 @@ function PaletteFX.monPal(data, species, transformed, shiny) return nil end +-- Legacy external Gen2 consumers ask the published palette view directly. +-- Resolve the same native row as monPal and return fresh RGB values. +function PaletteFX.monColors(data, species, shiny) + return require("src.render.Gen2PokemonPalettes").colors(data, species, shiny) +end + -- palette name a species currently resolves to (for image-cache keys) function PaletteFX.monPalName(data, species, transformed, shiny) if transformed then return "GRAYMON" end @@ -1067,54 +1155,124 @@ function PaletteFX.shadeMap() return shadeMap end -function PaletteFX.setMode(mode) - local prev = PaletteFX.mode - local ok = false - for _, m in ipairs(PaletteFX.MODES) do - if m == mode then - PaletteFX.mode = mode - ok = true - break +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Public byte-register access and native frame drawing share one state. +-- Renderer.beginFrame's existing reset therefore also clears a mod's BGP; +-- a byte cannot silently survive a menu, map or battle frame. +PaletteFX.BGP_IDENTITY = 0xe4 +function PaletteFX.bgpShades(byte) + byte = byte or PaletteFX.BGP_IDENTITY + local out = {} + for i = 0, 3 do out[i + 1] = math.floor(byte / 4 ^ i) % 4 end + return out +end +function PaletteFX.bgp() + if not shadeMap then return nil end + local byte = 0 + for i = 0, 3 do byte = byte + shadeMap[i] * 4 ^ i end + return byte ~= PaletteFX.BGP_IDENTITY and byte or nil +end +function PaletteFX.setBgp(byte) + local previous = PaletteFX.bgp() + if byte == nil or byte == PaletteFX.BGP_IDENTITY then + PaletteFX.setShadeMap(nil) + else + local shades = PaletteFX.bgpShades(byte) + PaletteFX.setShadeMap({[0]=shades[1], shades[2], shades[3], shades[4]}) + end + return previous +end +function PaletteFX.remap(colors, byte) + if not colors or not byte or byte == PaletteFX.BGP_IDENTITY then return colors end + local shades, out = PaletteFX.bgpShades(byte), {} + for i = 1, 4 do out[i] = colors[shades[i] + 1] or colors[4] end + return out +end + +local function invalidateDisplay() + -- Register native producers before the shared flush. External producers + -- subscribe to the same Assets.register bus; private cache calls alone + -- leave their atlases stale when a second ramp keeps the same mode ID. + for _, name in ipairs({"src.battle.BattleState","src.render.SpriteRenderer", + "src.render.TileRenderer","src.world.MapLoader"}) do + pcall(require, name) + end + require("src.render.Assets").invalidate() + pcall(function() + local Game = require("src.core.Game") + if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then + Game.overworld:reloadMap(Game.overworld.map.id, "colors") end + end) +end +local function normalizeMode(mode) + if GameVersion.isGen2() then + if mode == "dmg" then return "og" end + if mode == "custom" then return mode end end - if not ok then PaletteFX.mode = "gbc" end - -- battle pics and overworld sprites bake the active pack into ImageData; - -- drop those caches when the pack (or any COLORS mode) changes so the - -- next draw re-tints - if prev ~= PaletteFX.mode then - pcall(function() require("src.battle.BattleState").invalidate() end) - pcall(function() require("src.render.SpriteRenderer").invalidate() end) - -- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once - -- per loaded map, so a mode toggle needs every cached Map/TileRenderer - -- dropped and the currently-visible one rebuilt in place -- otherwise - -- the on-screen map keeps its stale (wrong-mode) atlas until the next - -- map transition happens to reload it. - pcall(function() - require("src.world.MapLoader").invalidateAll() - local Game = require("src.core.Game") - if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then - Game.overworld:reloadMap(Game.overworld.map.id, "colors") - end - end) + for _, m in ipairs(PaletteFX.MODES) do + if m == mode then return mode end end + return "gbc" +end +-- Set a complete selection atomically: applying persisted mode + ramp must +-- rebuild the entered map once, never briefly in the previous custom ramp. +function PaletteFX.setDisplay(mode, ramp) + local key = rampKey(ramp) + if key == nil then ramp, key = nil, "" end + mode = normalizeMode(mode) + local changed = mode ~= PaletteFX.mode or ramp ~= customRamp or key ~= customKey + PaletteFX.mode, customRamp, customKey = mode, ramp, key + if changed then invalidateDisplay() end + return mode +end +function PaletteFX.setMode(mode) + return PaletteFX.setDisplay(mode, customRamp) +end +function PaletteFX.setCustomRamp(ramp) + PaletteFX.setDisplay(PaletteFX.mode, ramp) + return customRamp end -function PaletteFX.cycleMode() +function PaletteFX.cycleMode(delta) local cur = PaletteFX.mode or "gbc" + if GameVersion.isGen2() and cur == "og_inv" then cur = "og" end + local modes = PaletteFX.colorModes() local idx = 1 - for i, m in ipairs(PaletteFX.MODES) do + for i, m in ipairs(modes) do if m == cur then idx = i; break end end - PaletteFX.setMode(PaletteFX.MODES[idx % #PaletteFX.MODES + 1]) + local nextMode = modes[(idx - 1 + (delta or 1)) % #modes + 1] + local ramp = customRamp + if GameVersion.isGen2() then ramp = nil end + PaletteFX.setDisplay(nextMode, ramp) return PaletteFX.mode end function PaletteFX.applyOptions(opts) - PaletteFX.setMode(opts and opts.colors or "gbc") + if GameVersion.isGen2() then + local ramp = opts and opts.palette and require("src.render.Palette").ramp(opts.palette) + return PaletteFX.setDisplay(opts and (opts.color or opts.colors) or "gbc", ramp) + end + return PaletteFX.setMode(opts and opts.colors or "gbc") +end +function PaletteFX.writeColorOption(opts, mode) + opts.colors = mode + if GameVersion.isGen2() then + opts.color = mode == "og" and "dmg" or mode + opts.palette = nil + end +end +function PaletteFX.presentColors() + if GameVersion.isGen2() and PaletteFX.mode == "classic" then return PaletteFX.CLASSIC end end function PaletteFX.modeLabel(mode) mode = mode or PaletteFX.mode + if GameVersion.isGen2() then + return ({gbc="GEN 2", og="DMG", dmg="DMG", classic="CLASSIC", custom="CUSTOM"})[mode] or "GEN 2" + end -- The GBC hardware mode wears the running game's name: OG RED / OG BLUE -- (boot-ROM auto-palette) or OG YELLOW (pokeyellow CGBBasePalettes). if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end @@ -1155,27 +1313,48 @@ function PaletteFX.paperShade(data) return c[1] / 255, c[2] / 255, c[3] / 255 end -function PaletteFX.effectiveColors(c) +function PaletteFX.displayColors(c) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c - if gen2Native() then + if GameVersion.isGen3() then mode = "gbc" end - if mode == "og" then + if GameVersion.isGen2() and customRamp then return customRamp end + if mode == "og" or (GameVersion.isGen2() and mode == "custom") then out = PaletteFX.GRAYS elseif mode == "og_inv" then out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) elseif mode == "classic" then - out = PaletteFX.CLASSIC + out = GameVersion.isGen2() and PaletteFX.GRAYS or PaletteFX.CLASSIC elseif mode == "gbc_inv" then out = PaletteFX.permute(c, INV_MAP) end + return out +end +-- Static native BG/OBJ bakes resolve the display without a transient BGP. +-- DARK rows lose their darkness if replaced by a mono/custom ramp; fold the +-- world shift explicitly only for such replacements. Menus stay independent. +function PaletteFX.gen2BakeColors(c, dark) + local out = PaletteFX.displayColors(c) + local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" or PaletteFX.mode == "classic" or PaletteFX.mode == "custom" + if GameVersion.isGen2() and dark and (mono or customRamp) then + out = PaletteFX.permute(out, PaletteFX.DARK_BGP) + end + return out +end +function PaletteFX.effectiveColors(c, invert) + local out = PaletteFX.displayColors(c) + if not out then return nil end -- The shade map goes on LAST: rBGP is a hardware register write, so it -- composes on top of whatever colors the display mode settled on -- a dark -- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too -- (#322). permute() is the identity (and returns `out` itself, which the -- mod-graphics parity check leans on) while nothing has armed one. + -- An inverted font page reverses its settled display ramp before the + -- hardware shade register permutes it. Keep this on the native path so + -- Gen2 menu chrome cannot carry a second palette mode/register state. + if invert then out = { out[4], out[3], out[2], out[1] } end return PaletteFX.permute(out, shadeMap) end diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 4d8143548..4b8336df7 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +local Gen2Presentation = require("src.render.Gen2Presentation") -- RESOLVED ONCE, NOT EVERY FRAME. These three were `require`d from inside -- beginFrame and endFrame -- three string hashes into package.loaded on every @@ -109,6 +110,7 @@ local function displayMetrics() end function Renderer:init() + Gen2Presentation.reset() -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- (#208). Every canvas below is sized in framebuffer pixels for the same -- reason -- worldViewSize() already works in drawable pixels. @@ -320,6 +322,8 @@ end -- transparent: the world pass shows through (UI pass draws overlays only) function Renderer:beginFrame(transparent) + self.gen2FrameManaged, self.gen2FrameGame, self.gen2FrameZones = nil, nil, nil + self.gen2WindowDraw = nil -- a frame that ends early -- a state that returns before endFrame, an error -- caught upstream -- must not leak a queued native-resolution draw into the -- next frame's composite @@ -715,6 +719,10 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, love.graphics.setShader() end +function Renderer:blitZones(canvas,zones,w,h) + return Gen2Presentation.blit(canvas,zones,w,h) +end + -- THE BARS, FILLED WITH THE EDGE THEY FRAME. -- -- Asked for directly: "for all gen3 menus take the outside border 1px and @@ -892,7 +900,7 @@ function Renderer:endFrame(zones, worldZones) -- stacked Game Boy screens, or driving one onto a second physical display. -- The mod returns true to take over the whole window; anything else (or no -- mod wrapping the hook) falls through to the normal composite below. - if Runtime.wantsHook("render.compose") then + if not self.gen2FrameManaged and Runtime.wantsHook("render.compose") then local ctx = { renderer = self, worldCanvas = self.worldCanvas, uiCanvas = self.canvas, @@ -949,6 +957,9 @@ function Renderer:endFrame(zones, worldZones) and self.battleWipe.screenDraw local needPresent = GBCFX.active() or Pipelines.wantsPresent() or battleNeedsSource + or (self.gen2FrameManaged and (self.gen2WindowDraw + or (type(self.gen2FrameZones)=="table" and self.gen2FrameZones[1]) + or Runtime.wantsHook("render.compose"))) local present = nil if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww @@ -1389,6 +1400,10 @@ function Renderer:endFrame(zones, worldZones) -- over the finished composite rather than under the UI blit. On hardware -- it is the tilemap being overwritten -- there is nothing it does not cover. local battleSource = nil + if self.gen2FrameManaged and self.gen2WindowDraw then + self.gen2WindowDraw({width=ww,height=wh,gameX=ox,gameY=oy,gameWidth=vpw,gameHeight=vph,scale=Sp,dpiX=dpiX,dpiY=dpiY}) + end + if battleNeedsSource and present then -- Snapshot the untouched finished frame into a second screen-sized canvas. -- `present` is the current render target here, so sampling it directly in @@ -1432,21 +1447,25 @@ function Renderer:endFrame(zones, worldZones) if present then love.graphics.setCanvas() - -- Post-process pipelines run over the finished composite -- world, UI - -- and all -- and before GBC FX, so a blur or colour grade is what the - -- LCD grid is then drawn over rather than something that smears the - -- grid itself. Each pass hands back a canvas; with none registered - -- this returns `present` unchanged and the frame is byte-identical. - local composed = Pipelines.present(present, - { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present - if GBCFX.active() then - -- shader grid/shadow math is in framebuffer pixels - GBCFX.present(composed, Sp) - else - -- the present canvas only existed for the post-process, so put the - -- result on the screen at the same 1:1 unit mapping it was built at - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(composed, 0, 0) + local compositionHandled=false + if self.gen2FrameManaged and Runtime.wantsHook("render.compose") then + local ctx={renderer=self,game=self.gen2FrameGame,worldCanvas=present,uiCanvas=present,sceneCanvas=present, + worldActive=self.worldActive and true or false,generation=2,zones=self.gen2FrameZones,worldZones=nil, + ww=ww,wh=wh,pw=pw,ph=ph,ox=ox,oy=oy,vpw=vpw,vph=vph,uiw=uiw,uih=uih, + scale=Sp,Sx=Sx,Sy=Sy,dpiX=dpiX,dpiY=dpiY,secondScreen=require("src.render.SecondScreen")} + love.graphics.push("all") + local ok,value=xpcall(function() + return Runtime.call("render.compose",function()return false end,self,ctx)==true + end,debug.traceback) + love.graphics.pop() + if not ok then error(value,0)end + compositionHandled=value + end + if not compositionHandled then + local source=self.gen2FrameManaged and Gen2Presentation.apply(present,self.gen2FrameZones,ww,wh)or present + local composed=Pipelines.present(source,{width=ww,height=wh,scale=Sp,dpi=dpiY,dpiX=dpiX,dpiY=dpiY})or source + if GBCFX.active()then GBCFX.present(composed,Sp) + else love.graphics.setColor(1,1,1,1);love.graphics.draw(composed,0,0)end end end self.worldActive = false diff --git a/src/render/ScreenSpaceUI.lua b/src/render/ScreenSpaceUI.lua new file mode 100644 index 000000000..0f04a3bc7 --- /dev/null +++ b/src/render/ScreenSpaceUI.lua @@ -0,0 +1,93 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Optional window-resolution UI. Simulation/input remain native; only an +-- explicitly advertised drawer leaves the cartridge-sized UI canvas. +local M = {} +local function visible(stack, state) + return not stack.renderVisible or stack:renderVisible(state) +end +function M.select(stack, scene, first) + for i = #scene.states, first, -1 do + local state = scene.states[i] + if visible(stack, state) and type(state.drawsWidescreen) == 'function' + and type(state.drawWidescreen) == 'function' and state:drawsWidescreen() then + return state, i + end + end +end +function M.classic(draw, renderer, width, height) + local G = love.graphics + local uw, uh = renderer:uiSize() + local p = renderer.uiPresentation + if not p then + local scale = math.max(1, math.floor(math.min(width / uw, height / uh))) + p = { x=(width-uw*scale)/2, y=(height-uh*scale)/2, scaleX=scale, scaleY=scale } + end + G.push('all') + G.origin() + G.translate(p.x+(uw-renderer.WIDTH)*p.scaleX/2, p.y+(uh-renderer.HEIGHT)*p.scaleY/2) + G.scale(p.scaleX,p.scaleY) + local ok, err = pcall(draw) + G.pop() + if not ok then error(err,0) end +end +-- Gen2's reference renderer paints native overlays on its full-resolution +-- scene, under a cartridge-grid transform. A drawer that resets origin() +-- therefore gets real window pixels, not a 160x144 intermediate texture. +function M.hasNativeStack(stack, scene, first, world) + for i=first,#scene.states do + local state=scene.states[i] + if state and state~=world and visible(stack,state) and type(state.draw)=='function' then return true end + end + return false +end +function M.drawNativeStack(stack, scene, first, world, renderer, viewport, opaque) + local G=love.graphics + G.push('all');G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok,err=pcall(function() + if opaque then G.rectangle('fill',0,0,viewport.width,viewport.height) end + M.classic(function() + for i=first,#scene.states do + local state=scene.states[i] + if state and state~=world and visible(stack,state) and type(state.draw)=='function' then + G.push('all') + -- Declared larger native surfaces retain their own origin. Plain + -- GB states stay centred in a parent's expanded surface. + if type(state.uiSize)=='function' then + local sized,w,h=pcall(state.uiSize,state) + if sized and type(w)=='number' and w>0 and type(h)=='number' and h>0 then + G.translate((renderer.WIDTH-w)/2,(renderer.HEIGHT-h)/2) + end + end + local drawn,why=pcall(state.draw,state) + G.pop() + if not drawn then error(why,0) end + end + end + end,renderer,viewport.width,viewport.height) + end) + G.pop() + if not ok then error(err,0) end +end +function M.draw(owner, renderer, viewport, zones, overlay) + local G = love.graphics + G.push('all') + G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok, err = pcall(owner.drawWidescreen, owner, viewport.width, viewport.height) + G.pop() + if not ok then error(err,0) end + -- The classic canvas contains only states above this owner. Reapply it + -- after the high-resolution drawer so a native textbox keeps its parent. + if overlay then + local p = renderer.uiPresentation + if not p then + p = {x=viewport.gameX,y=viewport.gameY,scaleX=viewport.scale/(viewport.dpiX or 1),scaleY=viewport.scale/(viewport.dpiY or 1)} + end + G.push('all');G.origin();G.setShader();G.setColor(1,1,1,1);G.setBlendMode('alpha','premultiplied') + local overlayOK, overlayError = pcall(renderer.blitCanvas,renderer,renderer.canvas,p.scaleX,p.scaleY,zones,p.scaleX,p.scaleY, + p.x,p.y,0,0,viewport.width,viewport.height,viewport.dpiX or 1,viewport.dpiY or 1) + G.pop() + if not overlayOK then error(overlayError,0) end + end +end +return M diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index f22e1907b..bd36be9f2 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -5,6 +5,7 @@ local Assets = require("src.render.Assets") local PaletteFX = require("src.render.PaletteFX") +local GameVersion = require("src.core.GameVersion") local SpriteRenderer = {} SpriteRenderer.__index = SpriteRenderer @@ -46,6 +47,10 @@ end local obpCache = {} local function getObpImage(path, colors, group) + if GameVersion.isGen2() then + colors = PaletteFX.gen2BakeColors(colors, PaletteFX.darkWorld()) + group = tostring(group) .. "#" .. PaletteFX.displayKey() .. PaletteFX.darkKey() + end local key = path .. "#obp" .. group if not obpCache[key] then local img diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 5f7a007a5..30429842d 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -568,19 +568,27 @@ end -- palColorsByTod is what the Gen2 importer now writes; palColors alone is the -- older single-row shape, kept working so a dataset extracted before this -- change still renders instead of falling back to grey. -local function gen2PalColors(tileset) +local function gen2PalColors(tileset, row) local byTod = tileset.palColorsByTod if type(byTod) ~= "table" then return tileset.palColors end - if PaletteFX.darkWorld() then return byTod.DARK or tileset.palColors end - return byTod[PaletteFX.gen2Tod()] or byTod.DAY or tileset.palColors + row=row or PaletteFX.effectiveGen2Tod() + if row=='DARK'then return byTod.DARK or tileset.palColors end + return byTod[row] or byTod.DAY or tileset.palColors +end + +local function gen2DisplayRows(rows, row) + if not (rows and require("src.core.GameVersion").isGen2()) then return rows end + local out = {} + for i, colors in ipairs(rows) do out[i] = PaletteFX.gen2BakeColors(colors, row == "DARK") end + return out end -- One atlas per (tileset image, COLORS mode, time of day). Leaving the mode -- out of this key is why switching COLORS did nothing out in the Gen2 -- overworld: the first bake won and every later mode kept being handed it. -local function gen2AtlasKey(imagePath, roof, special) - return imagePath .. "#gen2pal:" .. tostring(PaletteFX.mode) .. ":" - .. (PaletteFX.darkWorld() and "DARK" or tostring(PaletteFX.gen2Tod())) +local function gen2AtlasKey(imagePath, roof, special, row) + return imagePath .. "#gen2pal:" .. PaletteFX.displayKey() .. ":" + .. tostring(row or PaletteFX.effectiveGen2Tod()) -- TWO MAPS ON ONE TILESET ARE TWO DIFFERENT PICTURES once the roof is in -- it. Violet and Azalea share TilesetJohto1 and wear different roofs, so -- leaving the roof out of the key would hand the second town the first @@ -601,36 +609,10 @@ end -- -- Skipped outright while the world is dark, which keeps the ROM's ordering: -- the darkness row is the first row in that table and beats every other. -local function gen2SpecialPalette(map, data) - if PaletteFX.darkWorld() then return nil end - local special = data and data.field and data.field.gen2MapPalettes - if type(special) ~= "table" or type(special.rules) ~= "table" then return nil end - local def = data.maps and map.id and data.maps[map.id] - if type(def) ~= "table" then return nil end - local tileset = map.tileset and map.tileset.id - for _, rule in ipairs(special.rules) do - local hit - if rule.kind == "map" then - hit = rule.group == def.group and rule.number == def.number - elseif rule.kind == "landmark" then - hit = rule.landmark == def.landmark - elseif rule.kind == "tileset" then - hit = rule.tileset == tileset - end - -- RegionCheck, as PokeCenterSpecialCase applies it - if hit and rule.landmarkMin then - hit = type(def.landmark) == "number" and def.landmark >= rule.landmarkMin - end - if hit and rule.landmarkEq then hit = def.landmark == rule.landmarkEq end - if hit then - local set = special.sets and special.sets[rule.set] - local rows = set and (set.ALL - or set[PaletteFX.gen2Tod()] or set.DAY) - if rows and #rows > 0 then return "#pal:" .. tostring(rule.set), rows end - return nil - end - end - return nil +local function gen2SpecialPalette(map, data, row) + local def=data.maps and map.id and data.maps[map.id] or map.def + return require('src.render.Gen2PaletteContext').special(data,def, + map.tileset and map.tileset.id,row or PaletteFX.effectiveGen2Tod()) end -- THE ROOF A MAP WEARS, or nil when it wears none. @@ -639,33 +621,10 @@ end -- tileset (see RomExtractorGen2:gen2Roofs for the cartridge routines). The -- art is a nine-tile strip on disk and the colour is a PAIR -- colours 1 and 2 -- of the roof palette row, which is all LoadMapPals ever writes. -local function gen2RoofFor(map, data) - local roofs = data and data.field and data.field.gen2Roofs - if type(roofs) ~= "table" then return nil end - local tileset = map and map.tileset - if not (tileset and type(roofs.tilesets) == "table" - and roofs.tilesets[tileset.id]) then - return nil - end - local def = data.maps and map.id and data.maps[map.id] - local group = (def and def.group) or map.group - local id = group ~= nil and type(roofs.byGroup) == "table" - and roofs.byGroup[group] or nil - if id == nil then return nil end - local image = type(roofs.images) == "table" and roofs.images[id] or nil - if not image then return nil end - local byTod = type(roofs.colors) == "table" and roofs.colors[group] or nil - local pair = byTod and (PaletteFX.darkWorld() and byTod.DARK - or byTod[PaletteFX.gen2Tod()] or byTod.DAY) or nil - return { - image = image, - pair = pair, - slot = roofs.slot or 10, - count = roofs.count or 9, - palIndex = roofs.palIndex or 6, - variants = tileset.tileVariants, - key = "#roof:" .. tostring(id) .. "." .. tostring(group), - } +local function gen2RoofFor(map, data, row) + local def=data.maps and map.id and data.maps[map.id] or map.def or map + return require('src.render.Gen2PaletteContext').roof(data,def,map.tileset, + row or PaletteFX.effectiveGen2Tod()) end local gen2AtlasCache = {} @@ -792,6 +751,7 @@ function TileRenderer.gen2AtlasFor(tileset) if not (tileset.palMap and #tileset.palMap > 0) then return nil end if not PaletteFX.usesGen2BgPal() then return nil end local colors = gen2PalColors(tileset) + colors = gen2DisplayRows(colors, PaletteFX.effectiveGen2Tod()) if not (colors and #colors > 0) then return nil end local perRow = tileset.tilesPerRow if not perRow or perRow < 1 then return nil end @@ -1368,14 +1328,17 @@ function TileRenderer.new(map, data) -- "show me the hardware", and the DMG/SGB-flavoured modes have to fall -- through to the raw sheet and their own shade treatment. Unconditional, it -- made COLORS a no-op for every Gen2 tile on screen. - local gen2Colors = PaletteFX.usesGen2BgPal() and gen2PalColors(map.tileset) + local gen2Def=data and data.maps and map.id and data.maps[map.id] or map.def + local gen2Enabled=PaletteFX.usesGen2BgPal() + local gen2Row=gen2Enabled and PaletteFX.gen2RowFor(gen2Def) + local gen2Colors = gen2Enabled and gen2PalColors(map.tileset,gen2Row) -- ...and this map's own seven, where the cartridge gives it seven of its -- own. Before the roof swap below, because the ROM does it in that order: -- LoadMapPals picks the palettes, special or environment, and only then -- writes the map group's roof pair into whichever set it ended up with. local specialKey if gen2Colors then - local key, rows = gen2SpecialPalette(map, data) + local key, rows = gen2SpecialPalette(map, data,gen2Row) if rows then gen2Colors, specialKey = rows, key end end if not self.gbcAtlas and map.tileset.palMap and #map.tileset.palMap > 0 @@ -1386,18 +1349,10 @@ function TileRenderer.new(map, data) -- list with just that pair replaced is the whole of it. A copy, because -- `gen2Colors` is the tileset def's own table and two groups sharing a -- tileset would otherwise overwrite each other's roof. - local roof = gen2RoofFor(map, data) - if roof and roof.pair then - local swapped = {} - for i = 1, #gen2Colors do swapped[i] = gen2Colors[i] end - local row = swapped[roof.palIndex + 1] - if row then - swapped[roof.palIndex + 1] = - { row[1], roof.pair[1], roof.pair[2], row[4] } - end - gen2Colors = swapped - end - local key = gen2AtlasKey(map.tileset.image, roof, specialKey) + local roof = gen2RoofFor(map, data,gen2Row) + gen2Colors=require('src.render.Gen2PaletteContext').withRoof(gen2Colors,roof) + gen2Colors=gen2DisplayRows(gen2Colors,gen2Row) + local key = gen2AtlasKey(map.tileset.image, roof, specialKey,gen2Row) local gen2img = getGen2Atlas(key, map.tileset.image, map.tileset.tilesPerRow, map.tileset.palMap, gen2Colors, TileRenderer.borrowStart(map.tileset), roof) diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..4de6372fc 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2628,12 +2628,9 @@ function Commands.g2_readvar(ctx, var) elseif var == 6 then ctx.g2Var = countKeys(ctx.save.pokedex and ctx.save.pokedex.seen) elseif var == 10 then - ctx.g2Var = tonumber(os.date("%H")) or 0 + ctx.g2Var = require("src.core.Gen2Clock").hour(ctx.save) elseif var == 11 then - -- g2DayOffset is what mom's SetDayOfWeek picker answered, as a shift off - -- the host day, so Arthur still shows up on a real Thursday. - ctx.g2Var = ((tonumber(os.date("%w")) or 0) - + (ctx.save.g2DayOffset or 0)) % 7 + ctx.g2Var = require("src.core.Gen2Clock").weekday(ctx.save) elseif var == 16 then -- BoxFreeSpace counts only the current box, not all available PC slots. local Boxes = require("src.pokemon.Boxes") @@ -2882,44 +2879,21 @@ function Commands.g2_clear_dst(ctx) Commands.show_text(ctx, "InitialClearDSTFlag.Text") end --- The ROM spins these with the d-pad on a graphical clock-set background; --- a list menu is the port's equivalent and needs no ROM tiles. The names --- are engine text, not ROM strings: SetDayOfWeek.WeekdayStrings is raw --- charmap data, and the extractor only harvests symbols matching "Text". -local WEEKDAYS = { - "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", -} - --- `special SetDayOfWeek` ($25), the day picker in mom's clock talk. The GB --- has to ask because its RTC has no idea what day it started on; here --- readvar 11 reads the host clock, which already knows. So the answer is --- kept as an OFFSET from the host day rather than as the day itself: the --- cursor opens on today, a truthful answer stores 0 and changes nothing, and --- either way the day still rolls over at real midnight instead of freezing. +-- Native SetDayOfWeek shares the clock-setting screen and RTC owner with +-- the introduction. The script remains suspended through both selection +-- and confirmation; declining does not write the save or resume the script. function Commands.g2_set_day_of_week(ctx) local game, runner = ctx.game, ctx.runner - local host = tonumber(os.date("%w")) or 0 - local picked - repeat - Commands.show_text(ctx, "SetDayOfWeek.OakTimeWhatDayIsItText") - local items = {} - for index, day in ipairs(WEEKDAYS) do - items[index] = { - label = Strings(day), - onSelect = function() picked = index - 1 runner:resume() end, - } - end - -- no cancel: the ROM's loop watches A only, and onCancel is the only - -- other path back to runner:resume() - local menu = require("src.ui.Menu").new(game, items, { cancelable = false }) - menu.index = host + 1 - menu:clampScroll() - game.stack:push(menu) - runner:yield() - Commands.ask(ctx, Strings(WEEKDAYS[picked + 1]) - .. resolvedText(ctx, "SetDayOfWeek.OakTimeIsItText")) - until ctx.lastCheck - ctx.save.g2DayOffset = (picked - host) % 7 + local screen + screen = require("src.ui.Gen2InitClock").new(game, { mode="day", save=ctx.save, + onDone=function() + if game.stack:top() == screen then game.stack:pop() end + ctx.lastCheck = true + runner:resume() + end, + }) + game.stack:push(screen) + runner:yield() end -- UnownPuzzle (SpecialsPointers row 41). The chamber scripts run diff --git a/src/script/Gen2Daily.lua b/src/script/Gen2Daily.lua index 96e54c666..36c041447 100644 --- a/src/script/Gen2Daily.lua +++ b/src/script/Gen2Daily.lua @@ -18,6 +18,9 @@ local Gen2Flags = require("src.script.Gen2Flags") local Gen2Daily = {} +-- Native RTC/calendar integration by Roxas2712; see contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +local Clock = require("src.core.Gen2Clock") local ENGINE_LUCKY_NUMBER_SHOW = 77 local GameVersion = require("src.core.GameVersion") @@ -39,19 +42,15 @@ local INITIAL_HIDDEN_EVENTS = { 1915, -- EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL } -local function todayKey(now) - return os.date("%Y-%m-%d", now) -end - local function nextFriday(save, now) - local date = os.date("*t", now) - local weekday = (date.wday - 1 + (tonumber(save.g2DayOffset) or 0)) % 7 + local date = Clock.calendarDate(save, now) + local weekday = Clock.weekday(save, now) local days = (5 - weekday) % 7 -- Match the weekday chosen at Mom's clock. if days == 0 then days = 7 end -- Calendar arithmetic, rather than seconds, also handles daylight saving. date.day, date.hour, date.min, date.sec = date.day + days, 12, 0, 0 date.isdst = nil - return todayKey(os.time(date)) + return os.date("%Y-%m-%d", os.time(date)) end function Gen2Daily.lotteryExpired(save, now) @@ -59,7 +58,7 @@ function Gen2Daily.lotteryExpired(save, now) -- Old saves already have a drawn ID; retain it when adding the timer. save.g2LuckyNextFriday = nextFriday(save, now) end - return todayKey(now) >= save.g2LuckyNextFriday + return Clock.dateKey(save, now) >= save.g2LuckyNextFriday end function Gen2Daily.resetLottery(save, now) @@ -121,7 +120,7 @@ function Gen2Daily.poll(save, now) Gen2Daily.ensureRoam(save, nil) if not save then return end Gen2Daily.seedInitialObjectFlags(save) - local today = todayKey(now) + local today = Clock.dateKey(save, now) if save.g2DailyDay == today then return end save.g2DailyDay = today Gen2Daily.onNewDay(save) diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua new file mode 100644 index 000000000..bf37c8436 --- /dev/null +++ b/src/ui/Gen2InitClock.lua @@ -0,0 +1,185 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +-- Native clock/day selection over the shared saved RTC and native widgets. +-- Dialogue comes from the active ROM cache; this layout is engine-owned. +local Clock = require("src.core.Gen2Clock") +local Font = require("src.render.Font") +local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") +local InitClock = {} +InitClock.__index = InitClock +InitClock.isOpaque = true +local TEXT = { + intro=Strings.source("SET THE CLOCK"), hour=Strings.source("CHOOSE HOUR"), + minute=Strings.source("CHOOSE MINUTE"), day=Strings.source("CHOOSE DAY"), + confirmHour=Strings.source("CONFIRM HOUR"), confirmMinute=Strings.source("CONFIRM MINUTE"), + questionMark=Strings.source("?"), response=Strings.source("\nCLOCK SET"), +} +local function value(n, fallback, limit) + n = tonumber(n) + if not n or n ~= n or n == math.huge or n == -math.huge then return fallback end + return math.floor(n) % limit +end +local function text(self, key, fallback) + local bank = self.game and self.game.data and self.game.data.text or {} + local found = bank[key] or bank["_" .. key:match("[^.]+$")] + if type(found) == "string" and found ~= "" then return found end + return Strings(fallback) +end +function InitClock.hourString(hour) + hour = value(hour, 0, 24) + local shown = hour % 12 + return ("%s %d"):format(Clock.daytimeLabel(hour), shown == 0 and 12 or shown) +end +function InitClock.oclockString(hour) return Strings("%s o'clock", InitClock.hourString(hour)) end +function InitClock.timeString(hour, minute) + return ("%s:%02d"):format(InitClock.hourString(hour), value(minute, 0, 60)) +end +function InitClock.responseKey(hour) + hour = value(hour, 0, 24) + if hour < 4 or hour >= 18 then return "soDark" end + return hour <= 10 and "overslept" or "yikes" +end +function InitClock.new(game, opts) + opts = opts or {} + local day = opts.mode == "day" + return setmetatable({ game=game, save=opts.save or game.save, + mode=day and "day" or "clock", phase=day and "day" or "intro", + isOpaque=not day, onDone=opts.onDone, autoConfirm=opts.autoConfirm == true, + fades=opts.fades == true and not day and not opts.autoConfirm, + hour=value(opts.hour, Clock.DEFAULT_HOUR, 24), + minute=value(opts.minute, Clock.DEFAULT_MINUTE, 60), day=value(opts.day, 0, 7), + }, InitClock) +end +function InitClock:wantsFillScale() return self.mode ~= "day" end +function InitClock:question() + if self.phase == "intro" then return text(self, "OakTimeWokeUpText", TEXT.intro) end + if self.phase == "hour" then return text(self, "OakTimeWhatTimeIsItText", TEXT.hour) end + if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", TEXT.minute) end + if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", TEXT.day) end + if self.phase == "confirm-hour" then + local lines = { text(self, "OakTimeWhatHoursText", TEXT.confirmHour), + InitClock.oclockString(self.hour) + .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", TEXT.questionMark) } + return table.concat(lines, "\n") + end + if self.phase == "confirm-minute" then + local lines = { text(self, "OakTimeWhoaMinutesText", TEXT.confirmMinute), + Strings("%d min.", self.minute) + .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", TEXT.questionMark) } + return table.concat(lines, "\n") + end + if self.phase == "confirm-day" then + return Clock.weekdayName(self.day + 1) + .. text(self, "SetDayOfWeek.OakTimeIsItText", TEXT.questionMark) + end + local response = { soDark="OakTimeSoDarkText", overslept="OakTimeOversleptText", yikes="OakTimeYikesText" } + if self.phase == "response" then + return InitClock.timeString(self.hour, self.minute) + .. text(self, "OakText_ResponseToSetTime." .. response[InitClock.responseKey(self.hour)], TEXT.response) + end + return "" +end +function InitClock:display() + if self.phase == "hour" then return InitClock.oclockString(self.hour) end + if self.phase == "minute" then return Strings("%d min.", self.minute) end + if self.phase == "day" then return Clock.weekdayName(self.day + 1) end +end +function InitClock:confirming() return self.phase:match("^confirm%-") ~= nil end +function InitClock:showPhase() + if self.autoConfirm or self.done then return end + if self.phase == "intro" or self.phase == "response" then + self.game.stack:push(TextBox.new(self.game, self:question(), function() self:accept() end)) + elseif self:confirming() then + self.game.stack:push(TextBox.new(self.game, self:question(), nil, { + choice=function(yes) if yes then self:accept() else self:decline() end end, + })) + end +end +function InitClock:enter() + if self.fades then self.fade = { out=false, frame=0 } else self:showPhase() end +end +function InitClock:complete() + if self.done then return end + self.done = true + if self.onDone then + if self.mode == "day" then self.onDone(self.day) else self.onDone(self.hour, self.minute) end + elseif self.game.stack:top() == self then + self.game.stack:pop() + end +end +function InitClock:finish() + if self.done or self.finishing then return end + local ok + if self.mode == "day" then ok = Clock.setWeekday(self.save, self.day) + else ok = Clock.setTime(self.save, self.hour, self.minute) end + if not ok then return end + self.finishing = true + if self.fades then self.fade = { out=true, frame=0 } else self:complete() end +end +function InitClock:accept() + if self.done or self.finishing then return end + local nextPhase = { intro="hour", hour="confirm-hour", ["confirm-hour"]="minute", + minute="confirm-minute", ["confirm-minute"]="response", day="confirm-day" } + local next = nextPhase[self.phase] + if next then self.phase=next;self:showPhase() else self:finish() end +end +function InitClock:decline() + if self.done or self.finishing then return end + local picker = self.phase:match("^confirm%-(.+)$") + if picker then self.phase=picker;self:showPhase() end +end +function InitClock:value() + if self.phase == "hour" then return self.hour, 23 end + if self.phase == "minute" then return self.minute, 59 end + return self.day, 6 +end +function InitClock:step(delta) + local current, last = self:value() + local key = self.phase + if key == "hour" or key == "minute" or key == "day" then self[key]=(current+delta)%(last+1) end +end +function InitClock:update() + if self.done then return end + if self.fade then + local fade = self.fade + fade.frame = fade.frame + 1 + if fade.frame >= 8 then + self.fade = nil + if fade.out then self:complete() else self:showPhase() end + end + return + end + if self.finishing then return end + if self.autoConfirm then self:accept();return end + local input = self.game.input + if input:wasPressed("up") then self:step(1) + elseif input:wasPressed("down") then self:step(-1) + elseif input:wasPressed("a") then self:accept() end +end +function InitClock:sgbPalettes() + local P = require("src.render.PaletteFX") + if self.isOpaque then return { P.trueColorZone(0, 0, 19, 17) } end + return { P.trueColorZone(1, 4, 18, 11), P.trueColorZone(0, 12, 19, 17) } +end +function InitClock:draw() + local G = love.graphics + G.setColor(1, 1, 1, 1) + if self.isOpaque then G.rectangle("fill", 0, 0, 160, 144) end + Font.drawBox(1, 4, 18, 8) + local picker = self:display() + local shown = picker or InitClock.timeString(self.hour, self.minute) + Font.draw(shown, 16, 48) + if picker then + Font.draw(Strings("UP/DOWN: CHANGE"), 16, 64) + Font.draw(Strings("A: CONFIRM"), 16, 80) + Font.drawBox(0, 12, 20, 6) + Font.draw(self:question(), 8, 105) + end + if self.fade then + local alpha = self.fade.frame / 8 + G.setColor(0, 0, 0, self.fade.out and alpha or 1-alpha) + G.rectangle("fill", 0, 0, 160, 144) + end + G.setColor(1, 1, 1, 1) +end +return InitClock diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index ed0f11d16..87fda58a2 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -281,7 +281,7 @@ end -- over a Gen2 save. The keys are the ROM's own text labels, so textOr finds -- them in data.text instead of falling through. function OakSpeech.gen2Steps() - return { + local steps = { { id = "oak_welcome", kind = "say", @@ -384,6 +384,11 @@ function OakSpeech.gen2Steps() kind = "shrink", }, } + local version = GameVersion.get() + if version == "gold" or version == "silver" or version == "crystal" then + table.insert(steps, 1, { id="init_clock", kind="initclock" }) + end + return steps end -- list helpers for intro.oak_speech.build wrappers (also on ModUI) @@ -489,10 +494,16 @@ function OakSpeech:buildSteps() end function OakSpeech:enter() - -- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48) - Music.play(self.game.data, self.cfg.music or "Music_Routes2") + local version = GameVersion.get() + local clockIntro = version == "gold" or version == "silver" or version == "crystal" + -- Other introductions retain their original music-before-build ordering. + if not clockIntro then Music.play(self.game.data, self.cfg.music or "Music_Routes2") end self.answers = {} self.steps = self:buildSteps() + -- The retail clock comes before the professor and his music. + if clockIntro and not (self.steps[1] and self.steps[1].kind == "initclock") then + Music.play(self.game.data, self.cfg.music or "Music_Routes2") + end if Runtime.wants("intro.oak_speech.started") then Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps }) end @@ -660,7 +671,17 @@ end function OakSpeech:runStep(step) local kind = step.kind or "say" - if kind == "say" then + if kind == "initclock" then + local screen + screen = require("src.ui.Screens").push(self.game, "Gen2InitClock", { + save=self.game.save, fades=true, + onDone=function() + if self.game.stack:top() == screen then self.game.stack:pop() end + Music.play(self.game.data, self.cfg.music or "Music_Routes2") + self:advance() + end, + }) + elseif kind == "say" then self:applyPic(step) self:afterReveal(step, function() self:runCry(step) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d6d1f500d..92c9de220 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -104,8 +104,8 @@ local function stepVolume(v, dir) end local function colorIndex(opts) - local cur = opts.colors or "gbc" - for i, m in ipairs(PaletteFX.MODES) do + local cur = GameVersion.isGen2() and PaletteFX.mode or opts.colors or "gbc" + for i, m in ipairs(PaletteFX.colorModes()) do if m == cur then return i end end return 1 @@ -370,14 +370,17 @@ local function buildRows(game) end }, { id = "colors", label = Strings("COLORS"), value = function(g) - return PaletteFX.modeLabel(g.save.options.colors or "gbc") + local mode = GameVersion.isGen2() and PaletteFX.mode or g.save.options.colors or "gbc" + return PaletteFX.modeLabel(mode) end, step = function(g, dir) local o = g.save.options local i = colorIndex(o) - i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1 - o.colors = PaletteFX.MODES[i] - PaletteFX.setMode(o.colors) + local modes = PaletteFX.colorModes() + i = wrapIndex(i - 1 + dir, #modes) + 1 + PaletteFX.writeColorOption(o, modes[i]) + if GameVersion.isGen2() then PaletteFX.setDisplay(o.colors,nil) + else PaletteFX.setMode(o.colors) end return true end }, { id = "tilt", label = Strings("TILT"), diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..a87e3392d 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -370,6 +370,25 @@ function PokegearMenu:current() return self.cards[self.index] end +-- Public clock presentation, shared by the native card and source renderers. +-- Keep the native save/world as the owner; weekday is one-based for UI lists. +function PokegearMenu:clockParts() + if type(self.clock) == "table" then + return self.clock.hour or 0, self.clock.minute or 0, self.clock.weekday or 1 + end + local game = self.game + local world = game and (game.overworld or game.world) + if world and type(world.hour) == "function" and type(world.minute) == "function" + and type(world.weekday) == "function" then + return world:hour(), world:minute(), world:weekday() % 7 + 1 + end + local save = game and game.save or self.save + local Clock = require("src.core.Gen2Clock") + local minutes = Clock.minutes(save) + return math.floor(minutes / 60), minutes % 60, Clock.weekday(save) + 1 +end + + -- The registered phone numbers, newest id last. -- -- Gen2 keeps two halves of this and the card used to read neither: which ids @@ -689,11 +708,8 @@ function PokegearMenu:drawClock() self:drawStrip() self:text("SWITCH", 13, 1) - local save = self.game.save - local t = math.floor(require("src.core.SaveData").playSeconds(save)) - local hour = math.floor(t / 3600) % 24 - local minute = math.floor(t / 60) % 60 - local day = DAYS[(math.floor(t / 86400) % 7) + 1] + local hour, minute, weekday = self:clockParts() + local day = require("src.core.Gen2Clock").weekdayName(weekday) local display = hour % 12 if display == 0 then display = 12 end self:text(day, 6, 6) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a17b2dd6e..891fb63d4 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -28,6 +28,7 @@ local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local ScriptRunner = require("src.script.ScriptRunner") local GameVersion = require("src.core.GameVersion") +local Gen2Clock = require("src.core.Gen2Clock") local Tilt = require("src.render.Tilt") local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") @@ -67,6 +68,13 @@ end local OverworldState = { isOpaque = true, isOverworld = true } local Game -- set on enter (avoids circular require at load time) +-- Read the active native bake/Flash fields rather than copying another world. +setmetatable(OverworldState,{__index=function(world,key) + if(key=='daytime'or key=='flashUsed')and GameVersion.isGen2()and world.map then + if key=='daytime'then return PaletteFX.effectiveGen2Tod()end + return Game and Game.save and Game.save.flashLit==true or false + end +end}) -- AN EMPTY PLOT DRAWS NOTHING, asked once per entity per frame. Written -- with `pcall(function() ... require(...) ... end)` this was a closure, a @@ -167,6 +175,8 @@ local function isDarkMap(mapId) -- ReplaceTimeOfDayPals sends down .NeedsFlash. Reading Gen1's pokered -- list here matched nothing, so Dark Cave and the Whirl Islands were lit. local def = Game.data.maps[mapId] + local palette=require('src.render.Gen2PaletteContext').id(def) + if palette~=nil then return palette==4 end local dark = Game.data.field.gen2DarkMaps if (def and dark and dark[def.label]) == true then return true end -- Fallback when gen2DarkMaps was not extracted (stale cache): maps @@ -283,7 +293,7 @@ local function objectTodBit() local ow = Game and Game.overworld local bit = ow and tonumber(ow.objectTodBit) if bit then return bit end - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) if period and tonumber(period.bit) then return tonumber(period.bit) end return TOD_BITS[objectTimeOfDay()] or TOD_BITS.DAY end @@ -1226,7 +1236,8 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- and the Ruins of Alph chambers taking the NITE row after 6pm. if GameVersion.isGen2() then local paletteDef = Game.data.maps[mapId] - if PaletteFX.setGen2MapPalette(paletteDef and paletteDef.mapPalette or 0) + PaletteFX.setGen2FlashUsed(Game.save.flashLit == true) + if PaletteFX.setGen2MapPalette(require('src.render.Gen2PaletteContext').id(paletteDef) or 0,paletteDef) and PaletteFX.usesGen2BgPal() then MapLoader.invalidateAll() end @@ -2391,7 +2402,7 @@ local function sameTod(tod) return tod end -- < 4 NITE, < 10 MORN, < 18 DAY, else NITE. Gen 1 has no clock at all, so -- only Gen 2 consults the host one. local function clockTimeOfDay() - local hour = tonumber(os.date("%H")) or 12 + local hour = Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) if hour < 4 then return "NITE" end if hour < 10 then return "MORNING" end if hour < 18 then return "DAY" end @@ -2404,7 +2415,7 @@ OverworldState.clockTimeOfDay = clockTimeOfDay -- The cartridge's MAPOBJECT_TIMEOFDAY bit for the hour on the clock right now. function OverworldState.clockTimeOfDayBit() - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) return (period and tonumber(period.bit)) or TOD_BITS.DAY end @@ -2418,6 +2429,9 @@ end -- modes that do not use the ROM's palettes at all. function OverworldState:syncGen2Tod(tod) local PaletteFX = require("src.render.PaletteFX") + if GameVersion.isGen2() then + PaletteFX.bindGen2Context(self.map and self.map.def,Game and Game.save and Game.save.flashLit == true) + end if not PaletteFX.setGen2Tod(tod) then return end if not (GameVersion.isGen2() and PaletteFX.usesGen2BgPal()) then return end pcall(function() @@ -2427,6 +2441,7 @@ function OverworldState:syncGen2Tod(tod) end function OverworldState:timeOfDay() + if GameVersion.isGen2() then Gen2Clock.weekday(Game and Game.save) end local tod = GameVersion.isGen2() and clockTimeOfDay() or (self.tod or "DAY") if not Runtime.wantsHook("world.tod") then if tod ~= self.tod then @@ -2463,9 +2478,17 @@ function OverworldState:timeOfDay() return self.tod end +-- Shared native saved RTC views; no second clock state on World. +function OverworldState.hour() return Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.minute() return Gen2Clock.minute(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.weekday() return Gen2Clock.weekday(GameVersion.isGen2() and Game and Game.save or nil) end + function OverworldState:paletteNameFor(map) local palettes = FieldDefaults.field(Game.data, "palettes") - local name = map.def.palette or paletteLookup(palettes, map.id, map.def.tileset) + -- Gen2's PALETTE_* identity is not a Gen1/SGB colour-pack name. + local authored = map.def.palette + if GameVersion.isGen2() and type(authored)=='string'and authored:match('^PALETTE_')then authored=nil end + local name = authored or paletteLookup(palettes, map.id, map.def.tileset) if not name then -- Interiors inherit the outdoor map they sit in. Before the player has -- been outdoors at all, that is wLastMap's zero-fill -- map 0, @@ -2593,6 +2616,7 @@ end function OverworldState:setDark(on) on = on and true or false self.dark = on + if GameVersion.isGen2() then PaletteFX.setGen2FlashUsed(Game and Game.save and Game.save.flashLit == true) end if PaletteFX.setDarkWorld(on) and PaletteFX.bakesDarkness() and self.map then MapLoader.invalidateAll() self:reloadMap(self.map.id, "dark") diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua new file mode 100644 index 000000000..feede9939 --- /dev/null +++ b/tests/engine/gen1_source_credit_footer.lua @@ -0,0 +1,90 @@ +-- Native launcher draw and scroll owners; only the graphics device is stubbed. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local run=T.sdk.loadNone({data=T.fixtures.fresh(),fs=T.sdk.memfs({})}) +love.system=love.system or {} +love.system.getOS=function()return 'Unknown'end +local Importer=require('src.import.RomImporter') +local g=love.graphics +local dimensions,printf,setFont=g.getDimensions,g.printf,g.setFont +local widthFn,heightFn,safeArea=g.getWidth,g.getHeight,love.window.getSafeArea +local mesh,shader,image,newFont=g.newMesh,g.newShader,g.newImage,g.newFont +local oldImage=love.image +local mouseDown=love.mouse.isDown +love.mouse.isDown=function()return false end +local function noop()end +local ellipse,arc,polygon=g.ellipse,g.arc,g.polygon +g.ellipse,g.arc,g.polygon=noop,noop,noop +local stencil,stencilTest=g.stencil,g.setStencilTest +g.stencil=function(fn)fn()end +g.setStencilTest=noop +g.newMesh=function()return {release=noop,setVertices=noop}end +g.newShader=function()return {send=noop,release=noop}end +love.image={newImageData=function(w,h)return {w=w,h=h,setPixel=noop}end} +g.newImage=function(source) + local obj=type(source)=='table'and {w=source.w,h=source.h}or image(source) + obj.setWrap,obj.setFilter,obj.setMipmapFilter=noop,noop,noop + obj.getDimensions=function(self)return self.w,self.h end + return obj +end +g.newFont=function(a,b) + local font=newFont(type(a)=='number'and a or b) + function font:getWrap(text,limit) + local lines={} + for line in (tostring(text)..'\n'):gmatch('(.-)\n')do + local current='' + for word in line:gmatch('%S+')do + local nextLine=current==''and word or current..' '..word + if current~=''and self:getWidth(nextLine)>limit then lines[#lines+1]=current;current=word + else current=nextLine end + end + lines[#lines+1]=current + end + return limit,lines + end + return font +end +local width,height=960,720 +g.getDimensions=function()return width,height end +g.getWidth=function()return width end +g.getHeight=function()return height end +love.window.getSafeArea=function()return 0,0,width,height end +local printed,currentFont={},nil +g.setFont=function(font)currentFont=font;return setFont(font)end +g.printf=function(text,x,y,w,...) + if type(text)=='string'and text:find('Based on the Pokemon Gen 1',1,true)then + printed[#printed+1]={text=text,x=x,y=y,width=w,font=currentFont} + end + return printf(text,x,y,w,...) +end +local ui=Importer.new(function()end) +for _,size in ipairs({{960,720},{480,360},{1280,960}})do + width,height=size[1],size[2] + for _,tab in ipairs({'red','gold','crystal','emerald'})do + ui.tab=tab;printed={} + local ok,why=pcall(ui.draw,ui) + T.check(ok,'actual native launcher still draws with wrapped attribution: '..tostring(why)) + local line=printed[#printed] + T.check(line~=nil,'native launcher renders the original project credit') + if line then + T.check(line.text:find('by BOIS CLUB GAMES, LLC',1,true)~=nil,'credit retains original rights holder') + T.check(line.text:find('https://github.com/bryanthaboi/gen1recomp',1,true)~=nil,'credit retains source project link') + local _,lines=line.font:getWrap(line.text,line.width) + local bottom=line.y+math.max(1,#lines)*line.font:getHeight() + for _,button in ipairs(ui.donateButtons or {})do + if not button.pinned then + T.check(button.y>=bottom,'native footer reserves the full wrapped credit before its donation controls') + end + end + end + end +end +g.getDimensions,g.printf,g.setFont=dimensions,printf,setFont +g.getWidth,g.getHeight,love.window.getSafeArea=widthFn,heightFn,safeArea +g.newMesh,g.newShader,g.newImage,g.newFont=mesh,shader,image,newFont +love.image=oldImage +love.mouse.isDown=mouseDown +g.stencil,g.setStencilTest=stencil,stencilTest +g.ellipse,g.arc,g.polygon=ellipse,arc,polygon +run.release() +T.finish('Gen1 source attribution through native launcher footer') diff --git a/tests/engine/gen2_animation_catalog.lua b/tests/engine/gen2_animation_catalog.lua new file mode 100644 index 000000000..d2646ad4f --- /dev/null +++ b/tests/engine/gen2_animation_catalog.lua @@ -0,0 +1,112 @@ +-- Actual synthetic ROM bytes -> complete native pool -> actual shared VM. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local E=require('src.import.RomExtractorGen2') +local Data=require('src.battle.Gen2AnimData') +local Catalog=require('src.battle.Gen2AnimCatalog') +local Runtime=require('src.battle.gen2.AnimRunner') +local Player=require('src.battle.Gen2AnimPlayer') +local V=require('src.core.GameVersion') +local previous=V.get() +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition) + local constants={moveOrder={'PROBE','OTHER','ALIAS'}} + local e=E.new(nil,edition,{symbols={BattleAnimations={1,0x4000}}}) + e.constants=function()return constants end + local pointers={[0x4000]=0x4008,[0x4002]=0x5000,[0x4004]=0x5010,[0x4006]=0x5000} + local bytes={ + [0x4008]=0xff, + [0x5000]=0xfe,[0x5001]=0x00,[0x5002]=0x51,[0x5003]=3,[0x5004]=0xff, + [0x5010]=1,[0x5011]=0xff, + [0x5100]=2,[0x5101]=0xff, + } + e.rom={word=function(_,bank,pc)T.eq(bank,1);return assert(pointers[pc])end, + byte=function(_,bank,pc)T.eq(bank,1);return assert(bytes[pc])end} + local c=e:gen2BattleAnims() + T.eq(Data.isNative(c),true,'actual ROM importer publishes native interpreter schema') + T.eq(c.bank,1);T.eq(c.gameVersion,edition) + T.eq(c.source,'ROM:BattleAnimations + data/battle_anims/*','ROM provenance remains explicit') + T.eq(c.moves.PROBE,'5000');T.eq(c.moves.OTHER,'5010');T.eq(c.moves.ALIAS,'5000') + T.eq(c.indices[0],'4008','cartridge dummy entry is part of the complete pool') + T.same(c.scriptOrder,{'4008','5000','5100','5010'},'pool includes call-only blocks and unselected moves once') + T.same(c.scripts['5000'],{{'call','5100'},{'wait',3},{'ret'}}) + T.same(c.scripts['5100'],{{'wait',2},{'ret'}}) + T.same(c.scripts['5010'],{{'wait',1},{'ret'}}) + T.eq(c.scripts['5000'],c.scripts[c.moves.ALIAS],'same ROM address has one real command owner') + T.eq(Runtime.scriptForMove(c,'PROBE'),'5000','standalone native API uses the actual imported dataset') + T.eq(Runtime.scriptForMove(c,'ABSENT'),nil,'absent move gets no invented animation') + local view,orders=Data.tables(c,constants) + T.eq(view,c,'native data does not get copied into a selected-program facade') + local bodySlam=false + for _,name in ipairs(orders.battleBgEffectOrder)do if name=='BATTLE_BG_EFFECT_BODY_SLAM'then bodySlam=true end end + T.eq(bodySlam,edition=='crystal','import edition owns numeric BG-function order') + local p=Player.new({battle_anims=c,constants=constants}) + T.eq(p.runtime.data,c,'idle native player already exposes all imported programs') + T.check(p:start('PROBE',true));local first=p.runtime + T.eq(first.data,c);T.eq(p.view.data,c,'the real OAM view reads the same complete native pool') + T.eq(first.data.scripts['5010'],c.scripts['5010'],'unselected program stays available during another move') + local steps=0 + repeat p:update();steps=steps+1;T.check(steps<20)until p:isDone() + T.eq(p.frames,8,'real call, full waits, parent resume and ret preserve cartridge time') + T.eq(p:scriptFor('ALIAS'),p:scriptFor('PROBE')) + T.check(p:start('OTHER',false));T.eq(p.runtime.data,c,'next move does not replace the shared data owner') + repeat p:update()until p:isDone() + T.eq(p.frames,3);T.eq(first.frames,8,'retained runner keeps its original native clock') + -- Native clients can edit an actual canonical program. The next start sees + -- that same owner; it must not silently rebuild it from stale legacy data. + c.scripts['5010'][1]={'wait',4} + T.check(p:start('OTHER',false)) + repeat p:update()until p:isDone() + T.eq(p.frames,6,'actual native source-program edit reaches the genuine VM') + p:release() + local source=require('src.import.LuaWriter').encodeSplit(c) + local restored=assert(loadstring(source))() + T.same(restored,c,'actual generated-cache writer preserves the complete native schema') + T.eq(Data.tables(restored,constants),restored,'restored data remains the native owner') + local fixtures=T.fixtures.fresh();fixtures.constants.gen=2;fixtures.battle_anims=restored + require('src.render.Font').load(fixtures) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(fixtures,'FIXMON_A',20)} + local screen=require('src.battle.BattleState').newWild({data=fixtures,save=save, + stack={top=function()end}},'FIXMON_C',15) + restored.moves.ADDED='5010' + T.eq(restored.moveAnims.ADDED,nil,'new native registration does not need a stale legacy index map') + T.check(screen:animForMove('ADDED','player'),'actual native screen admits the canonical source registration') + T.eq(screen.animPlayer.runtime.data,restored,'real screen, public data and VM share the restored owner') + T.eq(screen.anims,restored,'real screen exposes the complete native dataset') + screen.animPlayer:release() + -- Gen1Recomp's native GSC schema has generation=2 and these real tables, + -- without the old fork's private gen2/format/moveAnims/index markers. + restored.gen2,restored.format,restored.moveAnims,restored.indices,restored.misc=nil,nil,nil,nil,nil + T.eq(Data.isNative(restored),true,'source-native data needs no fork-only format flag') + local native=require('src.battle.BattleState').newWild({data=fixtures,save=save, + stack={top=function()end}},'FIXMON_C',15) + T.check(native:animForMove('ADDED','player'),'real screen accepts the original source-native schema') + T.eq(native.animPlayer.runtime.data,restored) + T.eq(Runtime.scriptForMove(restored,'PROBE'),'5000') + native.animPlayer:release() + local missing=E.new(nil,edition,{symbols={}}):gen2BattleAnims() + T.eq(Data.isNative(missing),false,'missing ROM animation table remains an explicit unsupported result') +end +do + local c=Catalog.compile({gen2=true,scripts={[1]={entry=1,code={ + {op='ifparamequal',args={5,0,80},to=3},{op='ret'},{op='delay',frames=2},{op='ret'}}}}, + moveAnims={PROBE=1},objects={},framesets={},oam={},gfx={}},nil,'crystal') + T.eq(c.moves.PROBE,'program:1:1','non-ROM registration has an explicit program namespace') + T.eq(c.source,'Registered GSC animation programs','registered programs do not claim cartridge provenance') + T.same(c.scripts['program:1:1'],{{'if_param_equal',5,'program:1:3'},{'ret'}}, + 'canonical branch has one target key, not leftover cartridge pointer bytes') + T.same(c.scripts['program:1:3'],{{'wait',2},{'ret'}}) + local loop=Catalog.compile({scripts={[1]={entry=1,addresses={0x5000},code={{op='jump',to=1}}}}, + moveAnims={PROBE=1}},nil,'gold') + T.same(loop.scripts['5000'],{{'jump','5000'}},'self branch neither recurses forever nor duplicates the pool') + T.same(loop.scriptOrder,{'5000'}) + T.raises(function()Catalog.compile({scripts={ + [1]={entry=1,addresses={0x5000,0x5001},code={{op='delay',frames=1},{op='ret'}}}, + [2]={entry=1,addresses={0x5000,0x5001},code={{op='delay',frames=2},{op='ret'}}}}},nil,'gold')end, + 'conflicting GSC program','different bytes cannot silently share a ROM program owner') + T.same(Data.row({op='call',args={0,80},to=1},'5000'),{'call','5000'}) + T.same(Data.row({op='loop',args={3,0,80},to=1},'5000'),{'loop',3,'5000'}) +end +V.set(previous) +T.finish('complete native GSC ROM animation pool, source API, actual VM and shared ownership') diff --git a/tests/engine/gen2_animation_identity.lua b/tests/engine/gen2_animation_identity.lua new file mode 100644 index 000000000..ff6ad4d09 --- /dev/null +++ b/tests/engine/gen2_animation_identity.lua @@ -0,0 +1,51 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Player=require('src.battle.Gen2AnimPlayer') +local View=require('src.battle.Gen2AnimView') +local previous=Version.get() +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data={battle_anims={gen2=true,moveAnims={FIX_MOVE=1},misc={throwPokeBall=0x100,sendOutMon=0x101}, + scripts={[1]={entry=1,code={{op='delay',frames=1},{op='ret'}}}, + [0x100]={entry=1,code={{op='delay',frames=2},{op='ret'}}}, + [0x101]={entry=1,code={{op='delay',frames=3},{op='ret'}}}}}} + for _,id in ipairs({'TOSS_ANIM','GREATTOSS_ANIM','ULTRATOSS_ANIM','BLOCKBALL_ANIM','POOF_ANIM','SHINY_ANIM','FIX_MOVE'})do + local p=Player.new(data);p:start(id,true,{ballIndex=4}) + local projections=0;local project=p.oam + p.oam=function(self,...)projections=projections+1;return project(self,...)end + local r=View.runner(p);p.presentation=r + local expected=id=='FIX_MOVE'and id or(id=='POOF_ANIM'or id=='SHINY_ANIM')and'ANIM_SEND_OUT_MON'or'ANIM_THROW_POKE_BALL' + T.eq(r.animId,expected,'observation identifies the actual native ROM program') + T.eq(p.moveId,id,'native queue/ball parameter identity is preserved') + T.eq(p.enemySide,id=='POOF_ANIM'or id=='SHINY_ANIM','native send-out side correction is preserved') + if id=='SHINY_ANIM'then T.eq(p.vars.param,1,'native shiny parameter is preserved') + elseif id=='BLOCKBALL_ANIM'then T.eq(p.vars.param,0,'native trainer block parameter is preserved') + elseif id~='POOF_ANIM'and id~='FIX_MOVE'then T.eq(p.vars.param,4,'native ball item parameter is preserved')end + for _=1,100 do + local name,frames,var,stopped,kept=r.animId,r.frames,r.var,r.stopped,r.keepSprites + end + T.eq(r.frames,p.frames,'repeated scalar reads observe native time') + T.eq(projections,0,'scalar queries never project OAM') + T.eq(#r.objects.structs,10,'real object pool retains all ten hardware slots') + T.eq(r.objects:activeCount(),0,'idle hardware slots are empty') + T.eq(r,p.runtime,'public runner is the actual native interpreter') + T.eq(projections,0,'object metadata queries never project OAM') + r:oam();T.eq(projections,0,'real OAM is already emitted by the VM, with no draw-time projection') + local before=p.frames;p:update();T.eq(r.frames,p.frames,'live frame observation advances with VM') + T.eq(p.frames,before+1,'observation does not advance the clock') + p:start('FIX_MOVE',false) + T.eq(r.animId,expected,'retired observation preserves original resolved program') + T.eq(projections,0,'old runner keeps its own OAM without retirement projection') + r.retire();T.eq(projections,0,'repeated retirement does not rebuild sprites') + end + local p=Player.new(data) + data.battle_anims.moveAnims.TOSS_ANIM=1 + p:start('TOSS_ANIM',true) + T.eq(View.runner(p).animId,'TOSS_ANIM','different mod script cannot acquire native ball identity') + data.battle_anims.scripts[0x100]=nil + p:start('FIX_MOVE',true) + T.eq(View.runner(p).animId,'FIX_MOVE','absent special programs never acquire invented identities') +end +Version.set(previous) +T.finish('native Gen2 animation identity and lazy observation') diff --git a/tests/engine/gen2_animation_import_commands.lua b/tests/engine/gen2_animation_import_commands.lua new file mode 100644 index 000000000..f26c2fd06 --- /dev/null +++ b/tests/engine/gen2_animation_import_commands.lua @@ -0,0 +1,64 @@ +-- Real ROM-handler dispatch and script decoder, using synthetic device bytes. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local E=require('src.import.RomExtractorGen2') +local Player=require('src.battle.Gen2AnimPlayer') +local Importer=require('src.import.RomImporter') +local Version=require('src.core.GameVersion') +local previous=Version.get() +-- Older engine bases have the same real readiness gate without the newer +-- public cache descriptor. Seed its genuine marker, never a guessed format. +local markerFor +for i=1,100 do + local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end + if not name then break end +end +assert(markerFor) +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local e=E.new(nil,edition,{symbols={BattleAnimCommands={1,0x4000}, + BattleAnimCmd_BattlerGFX_1Row={1,0x4100}, + BattleAnimCmd_BattlerGFX_2Row={1,0x4120}}}) + e.rom={word=function(_,bank,address) + T.eq(bank,1,'decoder reads actual command-table bank') + if address==0x4012 then return 0x4100 end + if address==0x4014 then return 0x4120 end + return 0 + end,byte=function(_,bank,address) + return assert(({[0x5000]=0xd9,[0x5001]=0xda,[0x5002]=0xff})[address]) + end} + local script=e:gen2AnimScript(1,0x5000) + T.eq(script.code[1].op,'battlergfx_2row','D9 handler retains its crossed macro name') + T.eq(script.code[2].op,'battlergfx_1row','DA handler retains its crossed macro name') + local p=Player.new({battle_anims={gen2=true,moveAnims={PROBE=1},scripts={[1]=script}}}) + T.check(p:start('PROBE',true));p:update() + local r=p.runtime + T.eq(r.tileDict[1].tile,66,'D9 loads real one-row player-head tile base') + T.eq(r.tileDict[2].tile,73,'D9 loads real one-row enemy-foot tile base') + T.eq(r.tileDict[3].tile,53,'DA loads real two-row player-head tile base') + T.eq(r.tileDict[4].tile,67,'DA loads real two-row enemy-foot tile base') + T.eq(r.loaded[1].rows,1,'actual loaded descriptor has one row') + T.eq(r.loaded[3].rows,2,'actual loaded descriptor has two rows') + p:release() +end +for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local marker=markerFor(edition) + T.check(marker:find('gen2%-anim%-object%-v3:')~=nil,'generated animation cache has a new contract') + Version.set(edition) + for _,old in ipairs({marker:gsub('gen2%-anim%-object%-v3:',''), + (marker:gsub('gen2%-anim%-object%-v3:','gen2-anim-object-v1:')), + (marker:gsub('gen2%-anim%-object%-v3:','gen2-anim-object-v2:'))})do + love.filesystem.write(Version.cachePrefix(edition)..'rom-cache.complete',old) + local report=Importer.readyReport(edition) + T.eq(report.ok,false,'old commands or missing-picture cache must be rebuilt') + T.check(report.why and report.why:find('another version',1,true)~=nil, + 'real readiness gate refuses old format before checking file gaps') + end +end +for _,edition in ipairs({'red','emerald','platinum'})do + T.check(not markerFor(edition):find('gen2%-anim%-object'), + 'unrelated cartridge cache contract is retained') +end +Version.set(previous) +T.finish('native GSC crossed battler-row handler import and legacy-cache refusal') diff --git a/tests/engine/gen2_animation_objects.lua b/tests/engine/gen2_animation_objects.lua new file mode 100644 index 000000000..1c492296f --- /dev/null +++ b/tests/engine/gen2_animation_objects.lua @@ -0,0 +1,107 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Player=require('src.battle.Gen2AnimPlayer') +local View=require('src.battle.Gen2AnimView') +local Battle=require('src.battle.BattleState') +local Wide=require('src.battle.WideBattle') +local oldVersion=Version.get() +local function fixture() + return {battle_anims={gen2=true,moveAnims={FIX_MOVE=1},misc={throwPokeBall=1}, + scripts={[1]={entry=1,code={{op='gfx',ids={2}},{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, + objects={[0]={oamFlags=0,fixY=255,frameset=0,fn=0,palette=4,gfx=2}},functions={[0]='Null'}, + framesets={[0]={frames={{oam=0,duration=20}},loop='hold'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0},{x=8,y=0,tile=1,attr=32}}}}, + gfx={[2]={tiles=2,cols=2,image='fixture.png'}},palettes={[2]={{255,255,255},{200,100,50},{100,30,10},{0,0,0}}}}} +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data=fixture();local p=Player.new(data) + T.eq(p:isDone(),true,'new native player has a terminal idle state') + T.eq(getmetatable(p.view),View,'VM owns the actual public drawing class') + T.eq(p:start('FIX_MOVE',true,{param=17}),true,'native bytecode starts normally') + T.eq(p.vars.param,17,'native script receives its caller parameter') + p:update() + local native=p.objects.structs[1] + local rows=p:oam() + T.eq(#rows,2,'actual frame OAM contains both object tiles') + T.same(rows[1],{x=40,y=40,tile=0,attr=0,palette='PAL_BATTLE_OB_RED'},'first native sprite projection') + T.same(rows[2],{x=48,y=40,tile=1,attr=32,palette='PAL_BATTLE_OB_RED'},'second native sprite retains tile and flip') + local runner=View.runner(p) + T.eq(runner.frames,1,'runner observes actual update clock') + T.eq(runner.objects.structs[1].index,native.index,'runner observes actual native object identity') + T.eq(runner.objects.structs[1].jt,native.jt,'runner observes actual object state machine') + local age,frame=native.duration,p.frames + runner:oam();runner:oam() + T.eq(native.duration,age,'OAM reads cannot tick object motion') + T.eq(p.frames,frame,'OAM reads cannot tick script time') + p:update();T.eq(runner.frames,2,'delay frames count on actual native timeline') + p.runtime.data.framesets[p.objects.framesetOrder[1]][1][4]=0x20;p:update() + rows=p:oam() + T.eq(rows[1].x,32,'object X flip negates offset and eight-pixel cell width') + T.eq(rows[1].attr,32,'object X flip reaches sprite attributes') + T.eq(rows[2].attr,0,'object flip XORs a sprite already flipped') + p.runtime.data.framesets[p.objects.framesetOrder[1]][1][4]=0x60;p:update() + T.eq(p:oam()[1].y,32,'object Y flip negates native row offset') + T.eq(runner.objects,p.objects,'public runner exposes the real mutable struct pool') + p.vars.var,p.runtime.keepSprites=1,true + T.eq(runner.var,1,'capture result observation uses VM variable') + T.eq(runner.keepSprites,true,'capture retention observation uses VM flag') + p.presentation=runner + local ticks=runner.frames + p:start('FIX_MOVE',false) + T.eq(runner.frames,ticks,'restarting VM cannot rebind previous animation observation') + T.eq(runner:oam()[1].palette,'PAL_BATTLE_OB_RED','retired OAM retains its own actual palette') + T.eq(p.frames,0,'new animation starts with its own actual clock') + p:update() + local g=love.graphics;local draw=g.draw;local calls={} + g.draw=function(...)calls[#calls+1]={...}end + local shader={};g.setShader(shader);g.setColor(.2,.3,.4,.5) + p:draw() + T.eq(#calls,2,'native VM draws through the actual object-view owner') + T.eq(g.getShader(),shader,'object pass restores caller shader') + T.same({g.getColor()},{.2,.3,.4,.5},'object pass restores caller tint') + g.draw=function()error('actual object draw failure')end + T.raises(function()p:draw()end,'actual object draw failure','render error remains observable') + T.eq(g.getShader(),shader,'failed object pass restores caller state') + g.draw=draw;g.setShader();g.setColor(1,1,1,1) + p:release() +end +require('src.core.ModCompat').install() +T.eq(require('src.ui.gen2.BattleAnimView'),View,'compat spelling resolves actual native renderer class') +do + Version.set('crystal') + local data=T.fixtures.fresh();data.constants.gen=2 + data.battle_anims=fixture().battle_anims + data.items.ITEM_FIX_BALL={id='ITEM_FIX_BALL',key='POKE_BALL',index=5} + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + T.eq(s.animView,s.animPlayer.view,'screen exposes the same actual drawing owner') + local item + s.ballPalette=function(self,id) item=id;return 'TEST_QUEUED_BALL'end + s.queue={{anim='TOSS_ANIM',attackerIsPlayer=true,ball='ITEM_FIX_BALL',caught=true,shakes=3,animDelayed=true}} + s.current,s.waitFrames=nil,nil + s:updateQueue() + T.eq(item,'POKE_BALL','actual queued throw calls public palette seam with source item spelling') + T.eq(s.animPlayer.opts.ballPalette,'TEST_QUEUED_BALL','native bytecode owns the selected thrown palette') + T.eq(s.animPlayer.opts.ball,'ITEM_FIX_BALL','native inventory item remains unchanged') + T.eq(s.animPlayer.battle,s.battle,'renderer sees live native battle model') + local PaletteFX=require('src.render.PaletteFX') + local nativePal=PaletteFX.monPal + local observed + PaletteFX.monPal=function(_,species)observed=species;return PaletteFX.GRAYS end + s.animView:objPalette('PAL_BATTLE_OB_ENEMY',s) + T.eq(observed,s.enemy.mon.species,'native battler record resolves its actual Pokemon palette') + s.animView:objPalette('PAL_BATTLE_OB_PLAYER',{player=s.player.mon}) + T.eq(observed,s.player.mon.species,'direct native Pokemon model resolves the same palette owner') + PaletteFX.monPal=nativePal + s.animPlayer:update() + local layer=s.drawAnimLayer;local layers=0 + s.drawAnimLayer=function(self,...)layers=layers+1;return layer(self,...)end + Wide.draw(s) + T.eq(layers,1,'wide native composition draws actual Gen2 OAM without Gen1 frame blocks') +end +Version.set(oldVersion) +T.finish('native Gen2 animation object owner, observation and queued ball palette') diff --git a/tests/engine/gen2_battle_scene.lua b/tests/engine/gen2_battle_scene.lua new file mode 100644 index 000000000..5cf2d0ae4 --- /dev/null +++ b/tests/engine/gen2_battle_scene.lua @@ -0,0 +1,65 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Wide=require('src.battle.WideBattle') +local Runtime=require('src.mods.Runtime') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local before=Version.get() +local wants,call=Runtime.wantsHook,Runtime.call +local overlays,expected=0 +Runtime.wantsHook=function(name)if name=='battle.overlay'then return true end;return wants(name)end +Runtime.call=function(name,vanilla,screen,...) + if name=='battle.overlay'then overlays=overlays+1;T.eq(screen,expected,'overlay receives actual native screen');return vanilla(screen)end + return call(name,vanilla,screen,...) +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data=T.fixtures.fresh();require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + expected=s + local layers={};local scene,body=s.drawScene,s.drawSceneBody + s.drawScene=function(self,...)layers[#layers+1]='scene';return scene(self,...)end + s.drawSceneBody=function(self,...)layers[#layers+1]='body';return body(self,...)end + s.drawPicsLayer=function()layers[#layers+1]='pics'end + s.drawHUDs=function()layers[#layers+1]='hud'end + s.drawTextArea=function()layers[#layers+1]='text'end + s.drawAnimLayer=function()layers[#layers+1]='objects'end + s.colorMode=function()return false end + local count=overlays;s:draw() + T.same(layers,{'scene','body','pics','hud','text','objects'},'actual classic draw reaches native scene/BG/OAM owners once') + T.eq(overlays,count+1,'actual scene emits one final overlay') + layers={};count=overlays + s:drawSceneBody(function()layers[#layers+1]='custom'end) + T.same(layers,{'body','custom','objects'},'replacement panel retains native object pass') + T.eq(overlays,count,'scene body does not emit duplicate final overlay') + layers={};s:drawScene(function()s:drawSceneBody(function()layers[#layers+1]='supplied'end)end) + T.same(layers,{'scene','body','supplied','objects'},'supplied scene body stays inside actual outer owner') + layers={};s.blankForAskName=true;s:drawSceneBody() + T.same(layers,{'body'},'native naming blank suppresses battle pictures and objects') + s.blankForAskName=nil + local g=love.graphics;local sentinel={};g.setShader(sentinel);g.setColor(.2,.3,.4,.5) + T.raises(function()s:drawSceneBody(function()g.setShader();g.setColor(0,0,0,1);error('panel draw failed')end)end,'panel draw failed','native scene propagates panel error') + T.eq(g.getShader(),sentinel,'failed panel restores graphics owner') + T.same({g.getColor()},{.2,.3,.4,.5},'failed panel restores caller tint') + g.setShader();g.setColor(1,1,1,1) + local nativeBody=s.drawSceneBody;local widths={};layers={};count=overlays + s.drawSceneBody=function(self,panel,objects,w,h) + widths={w,h};return nativeBody(self,panel,objects,w,h) + end + Wide.draw(s) + T.same(widths,{304,144},'actual native widescreen enters same composition owner with real surface') + T.eq(layers[1],'scene','wide layout enters native outer scene') + T.eq(layers[2],'body','wide layout enters native BG/OAM scene') + T.eq(overlays,count+1,'wide layout emits one final overlay') + -- Red keeps its existing layout route. Presence of a Gen2 method must not + -- redirect other games simply because they share the native screen class. + Version.set('red');layers={};s:drawClassic() + T.check(layers[1]~='scene'and layers[1]~='body','Gen1 classic route remains native original route') + layers={};Wide.draw(s) + T.check(layers[1]~='scene'and layers[1]~='body','Gen1 wide route remains native original route') +end +Runtime.wantsHook,Runtime.call=wants,call;Version.set(before) +T.finish('native Gen2 classic/wide scene owner, panel replacement and layer lifecycle') diff --git a/tests/engine/gen2_battler_rows.lua b/tests/engine/gen2_battler_rows.lua new file mode 100644 index 000000000..8b445c18e --- /dev/null +++ b/tests/engine/gen2_battler_rows.lua @@ -0,0 +1,219 @@ +-- Actual native command -> copied picture cells -> emitted OBJ draw calls. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local F=dofile('tests/gen2_picture_art/fixture.lua') +local V=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Assets=require('src.render.Assets') +local P=require('src.render.Gen2PaletteDraw') +local Rows=require('src.battle.Gen2BattlerRows') +local oldVersion=V.get() +local g=love.graphics +local oldData,oldImage,oldNew,oldDraw,oldShader=Assets.imageData,love.image,g.newImage,g.draw,g.newShader +local sources,draws,decodes,uploads={},{},0,0 +love.image={newImageData=F.image} +local function picture(w,h,seed) + local image=F.image(w,h) + image:mapPixel(function(x,y) + local shade=(math.floor(x/8)*3+math.floor(y/8)*2+x+2*y+seed)%4 + return (3-shade)/3,(3-shade)/3,(3-shade)/3,1 + end) + return image +end +Assets.imageData=function(path) + decodes=decodes+1 + if not sources[path]then sources[path]=picture(path:find('/back/',1,true)and 48 or 40, + path:find('/back/',1,true)and 48 or 40,0)end + return sources[path]:clone() +end +g.newImage=function(data) + if type(data)=='table'and data.clone then + uploads=uploads+1 + local image=data:clone() + function image:release()self.released=true end + return image + end + return oldNew(data) +end +g.newShader=function()return {values={},send=function(self,k,v)self.values[k]=v end}end +g.draw=function(image,quad,x,y,angle,sx,sy) + local shader=g.getShader();local colors={} + if shader and shader.values then for k,v in pairs(shader.values)do colors[k]=v end end + draws[#draws+1]={image=image,quad=quad,x=x,y=y,sx=sx,sy=sy,colors=colors} +end +local function make(edition,rows,attacker) + V.set(edition);Assets.invalidate();Battle.invalidate();sources={} + local data=T.fixtures.fresh();data.constants.gen=2 + for name,def in pairs(data.pokemon)do + def.spriteFront='qa/front/'..name..'.png' + def.spriteBack='qa/back/'..name..'.png' + end + data.constants.battleAnimGfxOrder={'BATTLE_ANIM_GFX_PLAYERHEAD','BATTLE_ANIM_GFX_ENEMYFEET'} + data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={[1]={entry=1,code={ + {op=rows==2 and 'battlergfx_1row'or'battlergfx_2row'}, + {op='obj',args={0,64,80,0}},{op='obj',args={1,88,88,0}}, + {op='delay',frames=100},{op='ret'}}}}, + functions={[0]='MoveFromUserToTarget'},objects={},framesets={},oam={}} + for id=0,1 do + data.battle_anims.objects[id]={oamFlags=1,fixY=255,fn=0,frameset=id,palette=id,gfx=id} + data.battle_anims.framesets[id]={frames={{oam=id,duration=1}},loop='restart'} + local sprites={} + for tile=0,(id==0 and 7 or 6)*rows-1 do + sprites[#sprites+1]={x=0,y=0,tile=tile,attr=(tile%4)*32} + end + data.battle_anims.oam[id]={tile=0,sprites=sprites} + end + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s:animForMove('PROBE',attacker) + local p=s.animPlayer + T.eq(p.screen,s,'existing native player owns the real picture source') + p:update() + return s,p +end +for _,edition in ipairs{'gold','silver','crystal'}do + for _,rows in ipairs{1,2}do for _,attacker in ipairs{'player','enemy'}do + local s,p=make(edition,rows,attacker) + local runner=p.runtime + T.eq(#runner.loaded,2) + for _,entry in ipairs(runner.loaded)do + local snapshot=p.view.battlerRows[entry] + T.check(snapshot~=nil,'real battlergfx command uploads its native battler rows') + local back=entry.battler=='player' + local n=back and 48 or 56 + T.same({snapshot.image:getDimensions()},{n,rows*8}) + local raw=sources[s:gen2ObjectPictureSource(entry.battler).path] + for y=0,rows*8-1 do for x=0,n-1 do + local px,py=x,back and y or 56-rows*8+y + if not back then px=px-8;py=py-(56-raw.h)end + local expected={0,0,0,0} + if px>=0 and py>=0 and px.83 then expected[4]=0 end + end + T.same({snapshot.image:getPixel(x,y)},expected,'OBJ pixels follow native BG-to-OBJ copy and colour-zero key') + end end + end + local before,frame,random=decodes,p.frames,s.rng + local images=uploads + draws={} + local outerColors={{251,239,227},{193,151,109},{83,61,47},{17,11,5}} + P.useRaw(outerColors);local outside=g.getShader();P.setBgp(0) + p:draw();local once=draws + T.eq(#once,13*rows,'both real picture strips reach the native OAM renderer') + T.eq(g.getShader(),outside,'caller shader restored') + for i=1,4 do + T.same(outside.values['pal'..(i-1)],{outerColors[i][1]/255,outerColors[i][2]/255,outerColors[i][3]/255}, + 'caller palette uniforms survive reuse of the same indexed shader') + end + local oam=runner:oam() + for index,draw in ipairs(once)do + local obj=oam[index] + local entry=runner.loaded[index<=7*rows and 1 or 2] + local localTile=obj.tile-entry.tile + T.same({draw.quad:getViewport()},{math.floor(localTile/rows)*8,(localTile%rows)*8,8,8}, + 'copied strip is addressed column-major, not as a regular OBJ atlas') + local sx=math.floor(obj.attr/32)%2==1 and -1 or 1 + local sy=math.floor(obj.attr/64)%2==1 and -1 or 1 + T.same({draw.x,draw.y,draw.sx,draw.sy},{obj.x-8+(sx<0 and 8 or 0),obj.y-16+(sy<0 and 8 or 0),sx,sy}, + 'actual native OAM retains both flips and attacker mirroring') + local colors=p.view:objPalette(obj.palette,p.battle) + local expected=P.resolve(colors) + for i=1,4 do T.same(draw.colors['pal'..(i-1)],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255}, + 'OBJ colours retain the real battler palette and ignore BGP')end + end + -- Mutating the picture provider cannot repaint already copied OBJ VRAM. + local source=s:gen2ObjectPictureSource('enemy') + sources[source.path]=picture(40,40,1) + for _=1,4 do p:draw()end + T.eq(decodes,before,'drawing does no image decode or GPU readback') + T.eq(uploads,images,'drawing does not upload images again') + T.eq(p.frames,frame,'drawing never runs the VM') + T.eq(s.rng,random) + local first=runner.loaded[1];local snapshot=p.view.battlerRows[first] + p:start('PROBE',attacker=='player');p:update() + T.check(p.runtime~=runner,'restart still creates the existing distinct runner') + T.eq(p.view.battlerRows[first],snapshot,'retained old runner keeps its own copied art') + local retained=runner:oam();retained.runner=runner;draws={};p:drawSprites(retained) + T.eq(#draws,13*rows,'retained real OAM draws without rebinding to the current runner') + T.eq(draws[1].image,snapshot.image) + local draw=g.draw;g.draw=function()g.setShader({});error('row draw failure')end + T.raises(function()p:drawSprites(retained)end,'row draw failure') + T.eq(g.getShader(),outside,'failed drawing also restores caller graphics state') + for i=1,4 do + T.same(outside.values['pal'..(i-1)],{outerColors[i][1]/255,outerColors[i][2]/255,outerColors[i][3]/255}, + 'failed drawing restores caller palette uniforms as well as shader identity') + end + g.draw=draw + p:release();T.eq(snapshot.image.released,true,'view retirement releases retained snapshot images') + P.setBgp(nil);g.setShader() + end end +end +-- Smaller 6x6 fronts still start one TILE from the left, as PadFrontpic does. +for _,n in ipairs{40,48,56}do + sources['sized-'..n]=picture(n,n,0);Assets.invalidate() + local row=Rows.snapshot({path='sized-'..n},{battler='enemy',rows=1}) + local left=n<56 and 8 or 0 + for x=0,55 do + local expected={0,0,0,0} + if x>=left and x.83 then expected[4]=0 end + end + T.same({row.image:getPixel(x,7)},expected,'declared native 5/6/7-tile front padding') + end +end +-- Custom RGB art opts out of indexed colour-zero removal; crop keeps the +-- selected Crystal frame instead of sampling an entire animation strip. +Assets.invalidate();sources.rgb=F.image(96,48) +sources.rgb:mapPixel(function(x)return x<48 and 0 or 1,.4,.2,.75 end) +local rgb=Rows.snapshot({path='rgb',trueColor=true,crop={index=2,width=48}},{battler='player',rows=2}) +T.same({rgb.image:getPixel(0,0)},{1,.4,.2,.75});T.eq(rgb.trueColor,true) +T.eq(Rows.quad(rgb,12),nil,'out-of-range OAM cells are not invented') +do + local s,p=make('crystal',2,'player') + local original=s:gen2ObjectPictureSource('player').path + local row={anim='PROBE'};s.moveAnimRow=row;s.queue={row} + require('src.battle.Gen2PicturePresentation').transform(s,s.player,s.enemy) + T.eq(s:gen2ObjectPictureSource('player').path,original, + 'queued Transform reads the pinned old picture, not its future resolved sprite') + s:animForMove('PROBE','player');p:update() + local entry=p.runtime.loaded[2] + T.check(p.view.battlerRows[entry]~=nil,'pending picture snapshot still uses actual command') + local originalProvider=s.gen2ObjectPictureSource + sources['rgb-live']=F.image(96,48) + sources['rgb-live']:mapPixel(function(x)return x<48 and 0 or 1,.4,.2,.75 end) + function s:gen2ObjectPictureSource(side) + if side=='player'then return {path='rgb-live',trueColor=true,crop={index=2,width=48}}end + return originalProvider(self,side) + end + s:animForMove('PROBE','player');p:update();draws={};p:draw() + T.eq(#draws,26) + for i=15,26 do + T.same(draws[i].colors,{},'actual custom RGB OAM clears preceding indexed shader') + T.same({draws[i].image:getPixel(0,0)},{1,.4,.2,.75},'actual OBJ copy retains selected RGB frame') + end + s.gen2ObjectPictureSource=originalProvider + -- A real raisesub command precedes the copy, and therefore the doll is + -- the current BG source. A later dropsub cannot alter these copied cells. + local code=p.anims.scripts[1].code + table.insert(code,1,{op='raisesub'}) + p.anims.pictureArt={version=1,substitute={player='qa-doll-back',enemy='qa-doll-front'}} + sources['qa-doll-back']=picture(48,48,2);sources['qa-doll-front']=picture(56,56,1) + s:animForMove('PROBE','player');p:update() + T.eq(s:gen2ObjectPictureSource('player').path,'qa-doll-back','source follows real VM replacement state') + local copied=p.view.battlerRows[p.runtime.loaded[2]].image + local expected={sources['qa-doll-back']:getPixel(0,0)} + if expected[1]>.83 then expected[4]=0 end + T.same({copied:getPixel(0,0)},expected,'real native copy uses replacement pixels') + p.runtime.picOverride.player=false + T.eq(s:gen2ObjectPictureSource('player').path,original,'drop reveals native pinned Pokemon source') + T.same({copied:getPixel(0,0)},expected,'later visible picture change cannot mutate earlier OBJ copy') + p:release() +end +Assets.imageData,love.image,g.newImage,g.draw,g.newShader=oldData,oldImage,oldNew,oldDraw,oldShader +Assets.invalidate();V.set(oldVersion) +T.finish('native GSC battler-row copy pixels, actual OAM, palettes, snapshots and lifetime') diff --git a/tests/engine/gen2_bg_object_palette.lua b/tests/engine/gen2_bg_object_palette.lua new file mode 100644 index 000000000..d034303f1 --- /dev/null +++ b/tests/engine/gen2_bg_object_palette.lua @@ -0,0 +1,51 @@ +-- Real native OBP command -> OAM -> indexed shader uniforms. Device records +-- observe the native draw without replacing animation or palette owners. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Player=require('src.battle.Gen2AnimPlayer') +local Palette=require('src.render.Gen2PaletteDraw') +local Version=require('src.core.GameVersion') +local g=love.graphics +local shader,draw=g.newShader,g.draw +local previous=Version.get() +g.newShader=function() + return {values={},send=function(self,name,value)self.values[name]=value end} +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} + local B={{245,239,227},{31,97,193},{167,59,131},{7,13,19}} + local C={{221,231,241},{71,81,91},{101,111,121},{11,21,31}} + local data={battle_anims={gen2=true,moveAnims={PROBE=1}, + scripts={[1]={entry=1,code={{op='gfx',ids={1}},{op='obp0',args={0x1b}}, + {op='obj',args={0,64,48,0}},{op='obj',args={1,72,48,0}}, + {op='obj',args={2,80,48,0}},{op='delay',frames=2},{op='ret'}}}}, + objects={},functions={[0]='Null'},framesets={[0]={frames={{oam=0,duration=8}},loop='hold'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}}, + gfx={[1]={tiles=1,cols=1,image='native-obj'}},palettes={[0]=A,[1]=B,[2]=C}}} + for id=0,2 do data.battle_anims.objects[id]={fn=0,frameset=0,palette=id+2,gfx=1}end + local p=Player.new(data);T.check(p:start('PROBE',true),'native object-palette program starts') + p:update();T.eq(p.bg.obp0,0x1b,'native command owns the actual OBP0 register') + local records={} + g.draw=function() + local active=g.getShader();local row={} + for i=0,3 do row[i+1]={unpack(active.values['pal'..i])}end + records[#records+1]=row + end + local external={};g.setShader(external) + Palette.setBgp(0x00) -- the already-coloured BG register must not fade OBJs + local frame=p.frames;p:draw() + T.eq(#records,3,'all three actual native OAM objects are drawn') + for index,colors in ipairs({A,B,C})do + local expected=Palette.remap(Palette.resolve(colors),index<=2 and 0x1b or nil) + for i=1,4 do + T.same(records[index][i],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255}, + 'only native gray/yellow OBJ slots consume OBP0, with exact RGB uniforms') + end + end + T.eq(p.frames,frame,'OBJ palette drawing never advances the VM') + T.eq(g.getShader(),external,'OBJ pass restores caller shader') + Palette.setBgp(nil);g.setShader();p:release() +end +g.newShader,g.draw=shader,draw;Version.set(previous) +T.finish('native GSC OBP command, OAM palette selection and exact shader uniforms') diff --git a/tests/engine/gen2_bg_presenter.lua b/tests/engine/gen2_bg_presenter.lua new file mode 100644 index 000000000..147e85ea7 --- /dev/null +++ b/tests/engine/gen2_bg_presenter.lua @@ -0,0 +1,166 @@ +-- Actual native VM -> scene -> BG presenter. Older upstream uses its +-- native battler draw where the separate public portrait API is absent. Only graphics device calls +-- are recorded. These checks do not claim rendered GPU pixel acceptance. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local View=require('src.battle.Gen2AnimView') +local Bg=require('src.battle.gen2.BgEffects') +local Palette=require('src.render.Gen2PaletteDraw') +local Pictures=require('src.battle.Gen2BgPictures') +local Battle=require('src.battle.BattleState') +local Wide=require('src.battle.WideBattle') +local Pokemon=require('src.pokemon.Pokemon') +local g=love.graphics +local draw,newCanvas,rectangle=g.draw,g.newCanvas,g.rectangle +local calls,rects,allocations,released={},{},0,0 +local previous=Version.get() +g.draw=function(image,quad,x,y,...) + calls[#calls+1]={image=image,quad=quad,x=x,y=y,shader=g.getShader(),byte=Palette.bgp, + viewport=type(quad)=='table'and quad.getViewport and {quad:getViewport()}or nil} +end +g.rectangle=function(...)rects[#rects+1]={...};return rectangle(...)end +g.newCanvas=function(...) + allocations=allocations+1 + local canvas=newCanvas(...) + canvas.release=function()released=released+1 end + return canvas +end +local function native(edition,effect) + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={ + [1]={entry=1,code={{op='bgeffect',args={effect or 'BATTLE_BG_EFFECT_BODY_SLAM',0,1,0}}, + {op='delay',frames=16},{op='ret'}}}},objects={},framesets={},oam={},gfx={}} + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.introSlide=0;s.showPlayerBack=false;s.showEnemyTrainer=false + s.sendingOut=false;s.enemySendingOut=false;s.enemyHidden=false + T.check(s:animForId('PROBE','player'),'actual native screen starts its existing animation player') + for _=1,3 do s.animPlayer:update()end + return s +end +for _,edition in ipairs({'gold','silver','crystal'})do + local s=native(edition) + for _,width in ipairs({160,304,160})do + calls,rects={},{} + local external={};g.setShader(external);g.setColor(.2,.3,.4,.5) + local frame=s.anim.frames + if width==304 then Wide.draw(s)else s:drawSceneBody()end + local blits={} + for _,row in ipairs(calls)do + if row.image==s.animView.canvas then blits[#blits+1]=row end + end + T.eq(#blits,144,'native composition blits one actual background row per LCD row') + T.eq(s.animView.canvas:getWidth(),width,'presenter uses the actual classic or wide surface') + T.eq(blits[49].x,2,'native BODY_SLAM shifts the selected LCD band by signed two pixels') + T.eq(blits[49].viewport[3],width-2,'shifted row clips to its own surface width') + T.eq(blits[1].x,0,'row above the LCD window remains fixed') + T.eq(blits[144].x,0,'text rows remain outside the BODY_SLAM window') + for _,row in ipairs(blits)do T.eq(row.shader,nil,'already-coloured canvas rows bypass a caller shader')end + T.eq(s.anim.frames,frame,'scene rendering never advances the native VM') + T.eq(g.getShader(),external,'native composition restores caller shader') + if width==304 then + T.same({g.getColor()},{1,1,1,1},'outer native scene sets neutral tint for final overlays') + else + T.same({g.getColor()},{.2,.3,.4,.5},'native scene body restores caller tint') + end + end + s.animView:release();s.animPlayer:release() +end +-- Paint the actual native battler under picture-size, hide, shade and lifted +-- states. These are graphics-device records, not fabricated gameplay saves. +for _,edition in ipairs({'gold','silver','crystal'})do + local s=native(edition,'BATTLE_BG_EFFECT_ENTER_MON') + local b=s.player + local image=s:battlerPic(b);local w,h=image:getDimensions() + calls={} + s:drawBattlerPic(b,10,20,1) + local row=calls[#calls] + T.check(row~=nil,'native entry still paints its actual battler') + T.eq(row.quad,10+math.floor(w*(1-1/3)/2),'entry picture compensation stays on integer native pixels') + T.eq(row.x,20+math.floor(h*(1-1/3)),'entry keeps its bottom edge pinned to the native box') + local bg=s.anim.bg;bg.picSize.player=nil + local read=s.animPicState + s.animPicState=function(self,side) + local state=read(self,side);state.slide=3;return state + end + calls={};s:drawBattlerPic(b,10,20,1) + T.eq(calls[#calls].quad,13,'public picture-state slide override reaches the actual native draw') + s.animPicState=read + bg.monShade.player=0xf8;bg.monShade.enemy=0xfc + Palette.setBgp(0x1b);calls={} + if s.drawPic then s:drawPic(b.mon,false)else s:drawBattlerPic(b,10,20,1)end + T.check(#calls>0,'front capture of the actual player remains drawable') + for _,paint in ipairs(calls)do + T.eq(paint.byte,Pictures.paletteByte(0xf8,0x1b),'player front capture uses player shade and composes it once') + end + T.eq(Palette.bgp,0x1b,'picture capture restores the surrounding BG register') + Palette.setBgp(nil) + bg.hidden.player=true;calls={} + s:drawBattlerPic(b,10,20,1) + T.eq(#calls,0,'actual BG hide state suppresses the native battler draw') + bg.hidden.player=false;bg.liftedRows.player={0,1} + local scissor=g.setScissor;local clips={} + g.setScissor=function(...)clips[#clips+1]={...};return scissor(...)end + calls={};s:drawBattlerPic(b,10,20,1) + T.same(clips[1],{0,0,160,48},'native BG paints only above the lifted player row') + T.same(clips[2],{0,56,160,88},'native BG paints only below the lifted player row') + T.eq(#calls,2,'lifted row is excluded from the actual BG picture pass') + clips={};calls={};s.liftedPass=true;s:drawBattlerPic(b,10,20,1);s.liftedPass=nil + T.same(clips[1],{0,48,160,8},'lifted OBJ pass selects the original player tile row') + T.eq(#calls,1,'lifted row is restored exactly once outside BG distortion') + g.setScissor=scissor + local external={};g.setShader(external);Palette.setBgp(0x1b) + local record=g.draw;g.draw=function()error('native portrait failure',0)end + T.raises(function() + if s.drawPic then s:drawPic(b.mon,false)else s:drawBattlerPic(b,10,20,1)end + end,'native portrait failure','picture error remains visible') + T.eq(Palette.bgp,0x1b,'failed portrait restores its BG palette owner') + T.eq(s._gen2PicPaletteSide,nil,'failed portrait retires its per-side palette context') + T.eq(g.getShader(),external,'failed portrait restores caller shader') + g.draw=record;g.setShader();Palette.setBgp(nil);s.animPlayer:release() +end +T.check(released>=9,'changing surface and releasing native views retires owned canvases') +Version.set('crystal') +do + local v=View.new() + local pool=Bg.new({},{});pool.scx=2 + local screen={worldBackdrop=function()return true end} + calls,rects={},{} + local seen=0 + v:present({bg=pool},function()seen=seen+1 end,screen) + T.eq(seen,1,'transparent scene still draws its actual supplied panel') + T.eq(#rects,0,'exposed scanlines do not paint paper over a live world') + local external={};local target={};g.setShader(external);g.setCanvas(target) + g.setColor(.2,.4,.6,.8);Palette.setBgp(0x1b) + T.raises(function()v:present({bg=pool},function() + g.setShader();g.setColor(1,0,0,1);error('native panel failure',0) + end)end,'native panel failure','actual panel failure remains visible') + T.eq(g.getShader(),external,'failed BG bake restores caller shader') + T.eq(g.getCanvas(),target,'failed BG bake restores caller canvas') + T.same({g.getColor()},{.2,.4,.6,.8},'failed BG bake restores caller tint') + T.eq(Palette.bgp,0x1b,'failed BG bake restores native palette register') + g.setCanvas();g.setShader();Palette.setBgp(nil) + local bg=Bg.new({},{});bg.lcdc='BGP';bg.lyStart=0;bg.lyEnd=4 + for i=0,3 do bg.lyBackup[i]=0xff end + local bands=View.bgpBands(bg) + T.eq(#bands,2,'LCD palette bands are grouped by their actual byte') + T.eq(bands[1].byte,0xe4,'identity palette band is baked first') + T.eq(bands[2].rows[1],1,'LCD applies the prior backup byte starting one row after LY start') + T.eq(bands[2].rows[4],4,'LCD window includes its final scanline') + v:release() +end +for _,monByte in ipairs({0xe4,0x00,0xff,0xf8,0xfc,0x1b})do + for _,bgByte in ipairs({0xe4,0x00,0xff,0x1b})do + local colors={{0},{1},{2},{3}} + local expected=Palette.remap(Palette.remap(colors,monByte),bgByte) + local actual=Palette.bgpShades(Pictures.paletteByte(monByte,bgByte)) + for i=1,4 do T.eq(actual[i],expected[i][1],'native mon and BG palette composition retains index ordering')end + end +end +g.draw,g.newCanvas,g.rectangle=draw,newCanvas,rectangle +g.setShader();g.setCanvas();g.setColor(1,1,1,1);Palette.setBgp(nil);Version.set(previous) +T.finish('native GSC BG row presentation, wide composition, transparency and state restoration') diff --git a/tests/engine/gen2_bg_sdk.lua b/tests/engine/gen2_bg_sdk.lua new file mode 100644 index 000000000..845f97eee --- /dev/null +++ b/tests/engine/gen2_bg_sdk.lua @@ -0,0 +1,35 @@ +-- Native BG API first import from a real SDK entry, with no ROM or renderer. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local before=Version.get() +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + local run=T.sdk.loadMods({'mods/bg_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/bg_probe/manifest.json']=[[{"id":"bg_probe","name":"BG probe","version":"1.0.0","api":2,"entry":"main.lua"}]], + ['mods/bg_probe/main.lua']=[[ + local mod=... + local Bg=require('src.battle.gen2.BgEffects') + local pool=Bg.new({}, {battleTurn=0}) + mod.exports.pool=pool + mod.exports.enter=function() + pool:queue('BATTLE_BG_EFFECT_ENTER_MON',0,1,0) + pool:playFrame() + return pool.picSize.player + end + ]], + })}) + T.eq(#run.errors,0,'actual SDK entry can import the real engine BG module') + local api=run.loader.exports.bg_probe + T.check(api and api.pool~=nil,'native BG owner reaches the unmodified SDK export path') + if api then + T.eq(api.enter(),2,'SDK consumer runs the actual native entry state machine') + local row=api.pool:queue(0x25,0,1,0) + T.eq(row.func,edition=='crystal'and'BATTLE_BG_EFFECT_BODY_SLAM'or'BATTLE_BG_EFFECT_WOBBLE_MON', + 'actual native host edition resolves raw BG ids inside a mod entry') + end + require('src.mods.Loader').teardown();run.release() +end +Version.set(before) +T.finish('GSC background state machines through actual SDK module lookup') diff --git a/tests/engine/gen2_clock.lua b/tests/engine/gen2_clock.lua new file mode 100644 index 000000000..ecfe737fe --- /dev/null +++ b/tests/engine/gen2_clock.lua @@ -0,0 +1,120 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local V = require('src.core.GameVersion');local version = V.get() +require('src.core.ModCompat').install() +local Clock = require('src.core.gen2.Clock') +local P = require('src.render.PaletteFX');local W = require('src.world.OverworldController') +require('src.script.Gen2Commands');local Commands = require('src.script.Commands');local Loader = require('src.mods.Loader') +local Json = require('src.link.Json');local Save = require('src.core.SaveData') +local date,time = os.date,os.time;local elapsed=0;local baseTime=time() +os.time=function(value)if value then return time(value)end;return baseTime+elapsed end +local host = { hour=13, minute=20, day=2 } +os.date = function(format, now) + if now and now~=os.time() then return date(format,now) end + if format=='%H' then return tostring(host.hour) end + if format=='%M' then return tostring(host.minute) end + if format=='%w' then return tostring(host.day) end + return date(format,now) +end +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.timeOfDay,i) + if name=='Game' then slot=i;oldGame=value;break end + if not name then break end +end +assert(slot) +local nativeGame=require('src.core.Game');local oldSave=nativeGame.save +T.eq(Clock,require('src.core.Gen2Clock'),'public name shares actual native clock') +T.eq(Clock.minutes(),800,'no save reads host RTC') +T.eq(Clock.isSet({}),false,'old save has no fabricated clock anchor') +local malformed={rtc='invalid'} +T.eq(Clock.hour(malformed),13,'malformed optional clock falls back to host') +T.eq(Clock.setTime(malformed,0/0,10),false,'NaN cannot poison saved clock') +T.eq(malformed.rtc,'invalid','refused write is atomic') +T.eq(Clock.setTime(false,10,0),false,'no saved state means no write') +T.eq(Clock.setWeekday({},math.huge),false,'infinite weekday rejected') +T.eq(Clock.weekdayName(0),nil,'weekday label API is one-based') +for _,edition in ipairs({'gold','silver','crystal'}) do + V.set(edition);host.hour=13;host.minute=20;host.day=2;elapsed=0 + local save={playTime=0,g2DayOffset=3};local untouched=Json.encode(save) + T.eq(Clock.hour(save),13,edition..' legacy hour retains host behavior') + T.eq(Clock.weekday(save),5,edition..' legacy native weekday offset retained') + T.eq(Json.encode(save),untouched,edition..' clock reads do not seed saved state') + T.eq(Clock.setTime(save,23,55),true,edition..' clock setting anchors native RTC') + T.eq(Clock.setWeekday(save,6),true,edition..' day picker stores native RTC base') + T.eq(Clock.hour(save),23,edition..' saved hour is observed immediately') + T.eq(Clock.minute(save),55,edition..' saved minute is observed immediately') + T.eq(Clock.weekday(save),6,edition..' weekday is observed immediately') + T.eq(Clock.isSet(save),true,edition..' explicit clock setting is distinguishable') + T.eq(save.g2DayOffset,save.rtc.startDay,edition..' old native day readers retain shared offset') + host.minute=30;elapsed=600 + T.eq(Clock.minutes(save),5,edition..' RTC keeps ticking and wraps at midnight') + T.eq(Clock.weekday(save),0,edition..' saved weekday rolls over at game midnight before host midnight') + local round=assert(Save.decode(Save.encode(save))) + T.eq(Clock.minutes(round),5,edition..' save roundtrip retains RTC minute base') + T.eq(Clock.weekday(round),0,edition..' save roundtrip retains RTC weekday base') + local game={data=T.fixtures.fresh(),save=save} + debug.setupvalue(W.timeOfDay,slot,game);nativeGame.save=save + local world=setmetatable({map={id='RTC',def={mapPalette=0}},player={cellX=0,cellY=0}}, {__index=W}) + -- Control only graphics IO; native clock, period selection and commands run. + world.syncGen2Tod=function()end + for _,case in ipairs({{3,59,'NITE',4},{4,0,'MORNING',1},{9,59,'MORNING',1}, + {10,0,'DAY',2},{17,59,'DAY',2},{18,0,'NITE',4}})do + Clock.setTime(save,case[1],case[2]) + T.eq(world:timeOfDay(),case[3],edition..' native world reads saved boundary '..case[1]) + T.eq(W.clockTimeOfDayBit(),case[4],edition..' object-load bit uses same saved boundary '..case[1]) + T.eq(world.hour(),case[1],edition..' legacy world dot hour agrees') + T.eq(world:minute(),case[2],edition..' legacy world colon minute agrees') + T.eq(P.clockDaytime(),case[3]=='MORNING' and 'MORN' or case[3],edition..' scene palette raw RTC agrees') + T.eq(P.clockDaytime(12),'DAY',edition..' explicit scene hour remains authoritative') + local ctx={save=save,overworld=world} + Commands.g2_readvar(ctx,10);T.eq(ctx.g2Var,case[1],edition..' actual VAR_HOUR agrees') + Commands.g2_readvar(ctx,11);T.eq(ctx.g2Var,Clock.weekday(save),edition..' actual VAR_WEEKDAY agrees') + Commands.g2_checktime(ctx,case[4]);T.eq(ctx.lastCheck,true,edition..' actual checktime agrees') + T.eq(require('src.pokemon.Evolution').isNight(save),case[4]==4,edition..' happiness time gate agrees') + end + -- Native clock-card composition must show RTC, even with zero play time. + Clock.setTime(save,21,47);Clock.setWeekday(save,4) + local text={};local Gear=require('src.ui.PokegearMenu') + local gear=setmetatable({game=game,blink=0,gfx={cards={clock={}}}}, {__index=Gear}) + for _,method in ipairs({'drawTilemap','drawStrip','textbox','printBoxText','drawModeArrow'})do gear[method]=function()end end + gear.text=function(_,value,x,y)text[x..':'..y]=value end + gear:drawClock() + T.eq(text['6:6'],'THURSDAY',edition..' actual clock card shows selected weekday') + T.eq(text['6:8'],' 9',edition..' actual clock card shows RTC hour rather than play hours') + T.eq(text['9:8'],'47',edition..' actual clock card shows RTC minute rather than play minutes') + T.eq(text['12:8'],'PM',edition..' actual clock card shows RTC half-day') + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local files={['mods/rtc/manifest.json']=Json.encode({id='rtc',name='Native RTC observer',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/rtc/main.lua']=[[ + local mod=...; + local Clock=require('src.core.gen2.Clock');mod.exports.events={} + mod.exports.stop=mod.events:on("clock.day_changed",function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99;return false + end) + ]]} + local result=T.sdk.loadMods({'mods/rtc'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + local label=edition..'/'..mode[1]..'/'..mode[2] + T.eq(#result.errors,0,label..' actual loader admits public clock module/event') + local e=result.loader.exports.rtc;local own={};e.save=own + Clock.weekday(own);T.eq(#e.events,0,label..' first read is not a fictitious rollover') + host.day=(host.day+1)%7 + Clock.weekday(own);T.eq(#e.events,1,label..' real read observes rollover exactly once') + T.eq(e.events[1].reason,'rollover',label..' rollover reason supplied') + Clock.weekday(own);T.eq(#e.events,1,label..' reentrant/repeated reads do not repeat event') + Clock.weekday({});T.eq(#e.events,1,label..' another profile starts silently') + local selected=(Clock.weekday(own)+2)%7 + Clock.setWeekday(own,selected) + T.eq(#e.events,2,label..' native day setter observes one change') + T.eq(e.events[2].reason,'set',label..' explicit setter reason supplied') + T.eq(Clock.weekday(own),selected,label..' listener mutation cannot corrupt native weekday') + e.stop();host.day=(host.day+1)%7;Clock.weekday(own) + T.eq(#e.events,2,label..' unsubscribe retires actual observation') + Loader.teardown();result.release() + end +end +nativeGame.save=oldSave;debug.setupvalue(W.timeOfDay,slot,oldGame) +Clock.resetObservations();os.date=date;os.time=time;V.set(version) +T.finish('shared native Gen2 saved RTC, actual world/scripts/clock-card and public event') diff --git a/tests/engine/gen2_clock_card.lua b/tests/engine/gen2_clock_card.lua new file mode 100644 index 000000000..a36d76005 --- /dev/null +++ b/tests/engine/gen2_clock_card.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local Gear=require('src.ui.gen2.Pokegear');local Clock=require('src.core.Gen2Clock') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local date=os.date +os.date=function(format,...)if format=='%H'then return '12'end;if format=='%M'then return '05'end;if format=='%w'then return '1'end;return date(format,...)end +local function parts(screen)return {screen:clockParts()}end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition) + local save={playTime=0};Clock.setTime(save,21,47);Clock.setWeekday(save,4) + local game={save=save,data=T.fixtures.fresh()} + local gear=setmetatable({game=game},{__index=Gear}) + local before=Json.encode(save) + T.same(parts(gear),{21,47,5},edition..' external card reads shared saved RTC, weekday one-based') + T.eq(Json.encode(save),before,edition..' clock presentation does not mutate saved state') + Clock.setTime(save,7,13);Clock.setWeekday(save,0) + T.same(parts(gear),{7,13,1},edition..' native clock edits are immediately visible') + local seen={};local current={hour=22,minute=8,day=6} + game.overworld={hour=function(self)seen.hour=self;return current.hour end, + minute=function(self)seen.minute=self;return current.minute end, + weekday=function(self)seen.weekday=self;return current.day end} + T.same(parts(gear),{22,8,7},edition..' live native world owns presentation') + T.eq(seen.hour,game.overworld,edition..' actual world hour receives its owner') + T.eq(seen.minute,game.overworld,edition..' actual world minute receives its owner') + T.eq(seen.weekday,game.overworld,edition..' actual world weekday receives its owner') + current.hour,current.minute,current.day=4,59,0 + T.same(parts(gear),{4,59,1},edition..' live world changes are not cached as UI state') + game.world=game.overworld;game.overworld=nil + T.same(parts(gear),{4,59,1},edition..' reference game.world spelling uses same read protocol') + game.overworld={hour=function()error('partial clock must not be mixed with saved clock')end} + T.same(parts(gear),{7,13,1},edition..' incomplete world falls back to one complete saved clock') + gear.clock={hour=10,minute=34,weekday=3} + T.same(parts(gear),{10,34,3},edition..' authored card clock override retains precedence') + gear.clock={} + T.same(parts(gear),{0,0,1},edition..' authored empty override retains reference defaults') + gear.clock=nil;game.overworld=nil;game.world=nil + local detached=setmetatable({save=save},{__index=Gear}) + T.same(parts(detached),{7,13,1},edition..' save-only reference card is supported') + -- The native draw and an unchanged external call use the same getter. + local values={};gear.blink=0;gear.gfx={cards={clock={}}} + for _,method in ipairs({'drawTilemap','drawStrip','textbox','printBoxText','drawModeArrow'})do gear[method]=function()end end + gear.clockParts=function()return 23,56,6 end + gear.text=function(_,value,x,y)values[x..':'..y]=value end + gear:drawClock() + T.eq(values['6:6'],'FRIDAY',edition..' native card uses public weekday projection') + T.eq(values['6:8'],'11',edition..' native card uses public hour projection') + T.eq(values['9:8'],'56',edition..' native card uses public minute projection') + T.eq(values['12:8'],'PM',edition..' native card keeps cartridge half-day display') + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/card/manifest.json']=Json.encode({id='card',name='Native clock card reader',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/card/main.lua']=[[ + local mod=...;local Gear=require('src.ui.gen2.Pokegear') + mod.exports.read=function(game) + local screen=setmetatable({game=game},{__index=Gear}) + return screen:clockParts() + end + ]]} + local result=T.sdk.loadMods({'mods/card'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + local label=edition..'/'..mode[1]..'/'..mode[2] + T.eq(#result.errors,0,label..' public clock-card consumer loads unchanged') + T.same({result.loader.exports.card.read(game)},{7,13,1},label..' real loaded consumer reads native RTC rather than fallback zeroes') + Loader.teardown();result.release() + end +end +Clock.resetObservations();os.date=date;V.set(version) +T.finish('native and external Gen2 clock-card presentation over shared RTC') diff --git a/tests/engine/gen2_clock_lifecycle.lua b/tests/engine/gen2_clock_lifecycle.lua new file mode 100644 index 000000000..ab38d51c8 --- /dev/null +++ b/tests/engine/gen2_clock_lifecycle.lua @@ -0,0 +1,119 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local version=V.get() +local C=require('src.core.Gen2Clock');local D=require('src.script.Gen2Daily') +local Save=require('src.core.SaveData');local Json=require('src.link.Json') +local date,time=os.date,os.time +-- A deterministic host clock. Date-table normalization still uses the OS +-- calendar, including real DST rules; elapsed time is independently advanced. +local now=time({year=2026,month=10,day=20,hour=13,min=20,sec=0}) +local origin=now;local shift=0 +os.time=function(value)if value then return time(value)end;return now end +os.date=function(format,stamp)return date(format,(stamp or now)+shift)end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);now=origin;shift=0 + local save={flags={},g2FruitTrees={used=true}} + T.eq(C.setTime(save,23,59),true,edition..' player can anchor before midnight') + T.eq(C.setWeekday(save,4),true,edition..' player chooses Thursday independently of host') + local startDate=C.dateKey(save);local offset=save.rtc.startMinute + T.eq(C.hour(save),23,edition..' explicit hour visible immediately') + T.eq(C.minute(save),59,edition..' explicit minute visible immediately') + D.poll(save);save.g2FruitTrees.used=true + local before=save.g2DailyDay + now=now+59;D.poll(save) + T.eq(C.minute(save),59,edition..' RTC waits for the full elapsed minute') + T.eq(C.weekday(save),4,edition..' weekday remains before game midnight') + T.eq(save.g2FruitTrees.used,true,edition..' daily use retained before midnight') + now=now+1;D.poll(save) + T.eq(C.hour(save),0,edition..' elapsed RTC crosses midnight') + T.eq(C.minute(save),0,edition..' minute wraps with the day') + T.eq(C.weekday(save),5,edition..' weekday crosses at same game midnight') + T.eq(C.hostWeekday(),C.hostWeekday(origin),edition..' host has not crossed midnight') + T.eq(save.g2FruitTrees.used,nil,edition..' native daily reset occurs at game midnight') + T.eq(save.g2DailyDay~=before,true,edition..' daily calendar uses the game boundary') + T.eq(C.dateKey(save)~=startDate,true,edition..' RTC calendar advances at same boundary') + save.g2FruitTrees.used=true;D.poll(save) + T.eq(save.g2FruitTrees.used,true,edition..' repeated poll cannot repeat daily reset') + local round=assert(Save.decode(Save.encode(save))) + now=now+7*86400+123*60 + T.eq(C.weekday(round),5,edition..' offline week retains proper weekday') + T.eq(C.hour(round),2,edition..' offline elapsed hours retained after save reload') + T.eq(C.minute(round),3,edition..' offline elapsed minutes retained after save reload') + local stale=round.g2DailyDay;D.poll(round) + T.eq(round.g2DailyDay~=stale,true,edition..' offline whole week still resets daily identity') + T.eq(round.g2FruitTrees.used,nil,edition..' offline reset does not depend on changed weekday') + local expected=C.minutes(round);local day=C.weekday(round);local calendar=C.dateKey(round) + shift=3600 + T.eq(C.minutes(round),expected,edition..' automatic host DST jump cannot double-advance game RTC') + T.eq(C.weekday(round),day,edition..' host DST cannot independently shift game weekday') + T.eq(C.dateKey(round),calendar,edition..' host DST cannot expire daily identity') + shift=-3600 + T.eq(C.minutes(round),expected,edition..' backward host DST jump cannot freeze game RTC') + shift=0 + T.eq(C.setTime(round,7,15),true,edition..' explicit edit reanchors current native clock') + T.eq(C.weekday(round),day,edition..' editing time preserves the chosen current weekday') + T.eq(C.dateKey(round),calendar,edition..' editing time preserves current game calendar date') + T.eq(C.hour(round),7,edition..' edited hour visible') + T.eq(C.minute(round),15,edition..' edited minute visible') + now=now+2*86400+45*60 + T.eq(C.weekday(round),(day+2)%7,edition..' future days follow new anchor') + T.eq(C.minutes(round),8*60,edition..' new anchor keeps ticking') + local oldDate=C.dateKey(round);C.setWeekday(round,0) + T.eq(C.weekday(round),0,edition..' weekday editing after offline elapsed days is immediate') + T.eq(C.dateKey(round),oldDate,edition..' weekday wheel does not rewrite calendar age') + C.setTime(round,23,59);now=now+60 + T.eq(C.weekday(round),1,edition..' edited weekday and time cross next midnight together') + -- The weekly service has to expire on the selected in-game Friday, not a + -- host Friday that is several hours out of phase. + now=origin;local lottery={flags={}};C.setTime(lottery,23,59);C.setWeekday(lottery,4) + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' fresh lottery remains active Thursday') + now=now+60 + T.eq(C.weekday(lottery),5,edition..' weekly service reaches selected Friday') + T.eq(D.lotteryExpired(lottery),true,edition..' native lottery expires at game Friday midnight') + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' Friday draw waits for next Friday') + now=now+6*86400 + T.eq(D.lotteryExpired(lottery),false,edition..' lottery does not expire a day early') + now=now+86400 + T.eq(D.lotteryExpired(lottery),true,edition..' next game Friday expires weekly draw') + -- Actual subscription dispatch must observe a game midnight even when + -- the host weekday is unchanged, and an offline full week too. + for _,api in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/day/manifest.json']=Json.encode({id='day',name='Native midnight observer',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/day/main.lua']=[[ + local mod=...;mod.exports.events={} + local Clock=require('src.core.gen2.Clock') + mod.events:on('clock.day_changed',function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99 + end) + ]]} + local loaded=T.sdk.loadMods({'mods/day'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,edition..' real loader admits midnight observer') + local export=loaded.loader.exports.day;local own={};export.save=own + now=origin;C.setTime(own,23,59);C.setWeekday(own,4) + -- Explicit staging changes may emit; only changes after the first settled + -- observation are under test here. + C.weekday(own);local n=#export.events;now=now+60;C.weekday(own) + T.eq(#export.events,n+1,edition..' midnight event dispatched once through actual runtime') + T.eq(export.events[n+1].day,5,edition..' midnight event carries new game weekday') + T.eq(export.events[n+1].previous,4,edition..' midnight event retains previous game weekday') + C.weekday(own);T.eq(#export.events,n+1,edition..' reentrant/repeated reads cannot repeat midnight') + now=now+7*86400;C.weekday(own) + T.eq(#export.events,n+2,edition..' offline full week still emits one observed day change') + T.eq(export.events[n+2].day,5,edition..' full-week event correctly has same weekday') + require('src.mods.Loader').teardown();loaded.release() + end + -- Old offsets and old saves remain read-only on load, without introducing + -- an epoch guessed from today's time into another player's save. + local old={rtc={startMinute=offset,startDay=2},g2DayOffset=2} + local bytes=Json.encode(old) + T.eq(C.minutes(old),(C.hostMinutes()+offset)%1440,edition..' old minute offset retains exact semantics') + T.eq(C.weekday(old),(C.hostWeekday()+2)%7,edition..' old weekday offset retains exact semantics') + C.dateKey(old);T.eq(Json.encode(old),bytes,edition..' old save reads do not migrate or mutate state') + local malformed={rtc={anchorTime='bad',anchorMinute=0,anchorDay=0,startMinute=0}} + T.eq(C.minutes(malformed),C.hostMinutes(),edition..' malformed optional anchor falls back to old clock') +end +os.date=date;os.time=time;C.resetObservations();V.set(version) +T.finish('native RTC midnight, offline calendar, host DST isolation and actual daily/lottery lifecycle') diff --git a/tests/engine/gen2_display_loader.lua b/tests/engine/gen2_display_loader.lua new file mode 100644 index 000000000..9130c5514 --- /dev/null +++ b/tests/engine/gen2_display_loader.lua @@ -0,0 +1,45 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion') +require('src.core.ModCompat').install() +local P=require('src.render.PaletteFX');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local A={{255,255,255},{193,41,71},{31,139,83},{0,0,0}} +local R={{239,223,197},{173,137,91},{97,59,29},{19,13,7}} +local S={{217,231,243},{137,171,209},{61,103,151},{11,23,41}} +V.set('crystal') +for _,api in ipairs({1,2})do + local files={['mods/palette-cache/manifest.json']=Json.encode({id='palette_cache',name='Registered indexed texture producer',version='1.0.0',api=api,content_format=api==3 and 'unified'or'native',entry='main.lua',affects_link=false}), + ['mods/palette-cache/main.lua']=[[ + local mod=...;local G=require('src.render.GbcPalette');local Assets=require('src.render.Assets') + local cache,flushes={},0 + Assets.register(function()cache={};flushes=flushes+1 end) + mod.exports.palette=function(source) + local key=G.mode + if not cache[key]then + local resolved=G.resolve(source);local rows={} + for i=1,4 do rows[i]={resolved[i][1],resolved[i][2],resolved[i][3]}end + cache[key]=rows + end + return cache[key],flushes,key + end + ]]} + P.setDisplay('gbc',nil) + local loaded=T.sdk.loadMods({'mods/palette-cache'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,'API'..api..' actual texture producer loads') + local consumer=loaded.loader.exports.palette_cache + local initial=consumer.palette(A);T.same(initial,A,'API'..api..' initial native row') + P.setDisplay('custom',R) + local first,count,mode=consumer.palette(A) + T.same(first,R,'API'..api..' first display ramp reaches registered producer') + T.eq(mode,'custom','API'..api..' public mode follows native owner') + P.setCustomRamp(S) + local second,newCount,sameMode=consumer.palette(A) + T.eq(sameMode,mode,'API'..api..' second ramp retains public mode ID') + T.eq(newCount,count+1,'API'..api..' native change flushes registered cache once') + T.neq(second,first,'API'..api..' producer rebuilds baked colour rows') + T.same(second,S,'API'..api..' second ramp replaces stale atlas colours') + Loader.teardown();loaded.release() +end +P.setDisplay('gbc',nil) +T.finish('native Gen2 display changes invalidate loaded API 1/2 texture producers') diff --git a/tests/engine/gen2_display_modes.lua b/tests/engine/gen2_display_modes.lua new file mode 100644 index 000000000..05aef3eec --- /dev/null +++ b/tests/engine/gen2_display_modes.lua @@ -0,0 +1,145 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local V=require('src.core.GameVersion') +local P=require('src.render.PaletteFX');local G=require('src.render.GbcPalette') +local TR=require('src.render.TileRenderer');local SR=require('src.render.SpriteRenderer') +local ML=require('src.world.MapLoader');local Assets=require('src.render.Assets') +local Data=require('src.core.Data');local Game=require('src.core.Game') +local Battle=require('src.battle.BattleState') +local assetChanges=0 +Assets.register(function()assetChanges=assetChanges+1 end) +local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} +local B={{245,239,227},{31,97,193},{167,59,131},{7,13,19}} +local R={{231,219,207},{181,151,111},{101,67,37},{23,17,11}} +local GRAY={{255,255,255},{170,170,170},{85,85,85},{0,0,0}} +local function buffer(w,h,source) + local b={w=w,h=h,pixels={}} + function b:getDimensions()return self.w,self.h end + function b:getWidth()return self.w end;function b:getHeight()return self.h end + function b:getPixel(x,y) + local p=self.pixels[y*self.w+x];if p then return unpack(p)end + local n=source and 1-x%4/3 or 0;return n,n,n,source and 1 or 0 + end + function b:setPixel(x,y,...)self.pixels[y*self.w+x]={...}end + function b:mapPixel(f)for y=0,self.h-1 do for x=0,self.w-1 do self:setPixel(x,y,f(x,y,self:getPixel(x,y)))end end end + function b:setFilter()end;function b:setWrap()end;function b:release()end + return b +end +love.image={newImageData=function(w,h)return buffer(w,h)end} +love.graphics.newImage=function(b)return b end +local function source(path) + if path=='display-tiles' then return buffer(16,8,true)end + if path=='display-roof' then return buffer(8,8,true)end + return buffer(16,96,true) +end +Assets.image=source;Assets.imageData=source +local function pixels(image,colors,start,keyed,label) + for i=1,4 do + local r,g,b,a=image:getPixel((start or 0)+i-1,0) + if keyed and i==1 then T.eq(a,0,label..' OBJ shade zero remains transparent') + else T.same({r,g,b,a},{colors[i][1]/255,colors[i][2]/255,colors[i][3]/255,1},label..' shade '..i)end + end +end +local function fixture() + local d=T.fixtures.fresh() + local ts={id='DISPLAY',image='display-tiles',tilesPerRow=2,palMap={0,1},palColorsByTod={DAY={A,B},NITE={B,A},DARK={A,B}}, + animatedTiles={{tile=0,kind='hshift',offsets={0,1},period=20}},blockCells=2,blocks={{0,1,0,1}},walkable={0},grassTiles={},waterTiles={}} + d.tilesets={DISPLAY=ts} + d.maps={DISPLAY={id='DISPLAY',label='DISPLAY',width=1,height=1,blocks={0},tileset='DISPLAY',environment=1,mapPalette=1,group=1,number=1}} + d.pokemon={TEST={id='TEST',name='TEST',index=1,dex=1,palette='QA_TEST',spriteFront='display-mon'}} + d.palettes={palettes={QA_TEST=A,MEWMON=A,GRAYMON=GRAY,BLACK={{0,0,0},{0,0,0},{0,0,0},{0,0,0}}},pokemon={TEST='QA_TEST'}} + d.field.gen2Roofs={tilesets={DISPLAY=true},byGroup={[1]=0},images={[0]='display-roof'},slot=1,count=1,palIndex=1, + colors={[1]={DAY={{53,63,73},{83,93,103}}}}} + Data.publishGen2Palettes(d) + return d,ts +end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);P.setDisplay('gbc',nil);P.setShadeMap(nil);P.setDarkWorld(false);P.setGen2MapPalette(1);P.setGen2Tod('DAY') + local d,ts=fixture();ML.invalidateAll() + local mon={species='TEST',dvs={attack=1,defense=1,speed=1,special=1}} + local battle=setmetatable({data=d,game={save={party={}}}},Battle) + local factory + for i=1,100 do local name,value=debug.getupvalue(Battle.speciesSprite,i);if name=='getImage'then factory=value;break end end + assert(factory,'native battle image factory unavailable') + local originalPic=factory('display-mon',{name='QA_TEST',colors=A},false) + local sprite=SR.new({id='DISPLAY',image='display-sprite',gen2ObjPal=A}) + local reloads=0 + Game.overworld={map=ML.load(d,'DISPLAY'),reloadMap=function(self,id,reason) + T.eq(reason,'colors',edition..' native map reload identifies display change') + self.map=ML.load(d,id);self.tiles=TR.new(self.map,d);reloads=reloads+1 + end} + for _,choice in ipairs({{'gbc',A},{'dmg',GRAY},{'classic',GRAY},{'custom',R}})do + local name,wanted=choice[1],choice[2] + local oldMap=Game.overworld.map + if name=='custom' then P.setDisplay(name,R)else P.setDisplay(name,nil)end + T.eq(G.mode,name,edition..' native/public mode agrees '..name) + T.eq(G.customRamp,name=='custom'and R or nil,edition..' public ramp is actual native ramp') + if name~='gbc' then T.neq(Game.overworld.map,oldMap,edition..' current map cache replaced '..name)end + local tiles=TR.new(ML.load(d,'DISPLAY'),d) + pixels(tiles.image,wanted,0,false,edition..' actual atlas '..name) + local roof=name=='gbc'and{B[1],{53,63,73},{83,93,103},B[4]}or wanted + pixels(tiles.image,roof,8,false,edition..' actual roof '..name) + T.check(tiles.anims and tiles.anims[1],edition..' native water animation exists') + pixels(tiles.anims[1].textures[1],wanted,0,false,edition..' actual animated tile '..name) + pixels(sprite:resolveImage(),wanted,0,true,edition..' retained native sprite '..name) + pixels(battle:picImage(originalPic),wanted,0,false,edition..' retained native battle picture '..name) + G.setBgp(0x1b) + pixels(battle:picImage(originalPic),{wanted[4],wanted[3],wanted[2],wanted[1]},0,false, + edition..' actual transient BG register reaches baked battle pixels '..name) + battle.fx={bgp={[0]=3,2,1,0}} + pixels(battle:picImage(originalPic),wanted,0,false, + edition..' actual BG register composes after legacy picture fade '..name) + battle.fx=nil;G.setBgp(nil) + battle.fx={bgp={[0]=3,2,1,0}} + pixels(battle:picImage(originalPic),{wanted[4],wanted[3],wanted[2],wanted[1]},0,false,edition..' battle fade after display '..name) + battle.fx=nil;battle.blackedOut=true + pixels(battle:picImage(originalPic),{wanted[4],wanted[4],wanted[4],wanted[4]},0,false,edition..' battle blackout '..name) + battle.blackedOut=false + if name=='classic' then T.eq(G.presentColors(),P.CLASSIC,edition..' Classic has one final green ramp') + else T.eq(G.presentColors(),nil,edition..' mode has no extra green pass '..name)end + end + local count=reloads;local assetCount=assetChanges;G.setCustomRamp(R);T.eq(reloads,count,edition..' identical ramp avoids reload') + T.eq(assetChanges,assetCount,edition..' identical ramp avoids shared asset churn') + local oldKey=P.displayKey();R[2][1]=R[2][1]+1;G.setCustomRamp(R) + T.neq(P.displayKey(),oldKey,edition..' edited ramp changes native cache key') + T.eq(reloads,count+1,edition..' reapplying edited ramp refreshes current map once') + T.eq(assetChanges,assetCount+1,edition..' edited ramp notifies registered external caches once') + pixels(sprite:resolveImage(),R,0,true,edition..' sprite rebakes edited ramp') + G.setBgp(0xff) + pixels(TR.new(ML.load(d,'DISPLAY'),d).image,R,0,false,edition..' transient BGP never baked into atlas') + G.setBgp(nil);P.setDarkWorld(true) + pixels(sprite:resolveImage(),{R[3],R[4],R[4],R[4]},0,true,edition..' custom world darkness preserved') + P.setDarkWorld(false) + T.eq(G.cycle(), 'dmg',edition..' custom cycle returns to native ladder') + T.eq(G.customRamp,nil,edition..' cycling removes custom ramp') + T.eq(G.cycle(),'classic',edition..' DMG advances to Classic') + T.eq(G.cycle(),'gbc',edition..' Classic advances to GBC') + T.eq(G.cycle(-1),'classic',edition..' backward cycle supported') + count=reloads;assetCount=assetChanges;G.applyOptions({color='custom',palette='m:80a0c0'}) + T.eq(reloads,count+1,edition..' saved mode and palette applied atomically') + T.eq(assetChanges,assetCount+1,edition..' saved mode and palette flush shared assets once') + T.same(G.customRamp,{{128,160,192},{92,115,138},{49,61,73},{15,19,23}},edition..' saved RGB palette reaches native owner') + local opts={color='custom',colors='custom',palette='m:80a0c0'} + P.writeColorOption(opts,P.cycleMode());T.eq(opts.palette,nil,edition..' manual cycle clears saved palette') + T.eq(opts.color,'dmg',edition..' legacy option tracks actual new mode');T.eq(opts.colors,'og',edition..' native option tracks same mode') + G.applyOptions(opts);T.eq(G.mode,'dmg',edition..' reapply cannot revive stale custom selection') + local menuGame={data=d,save={options=opts}} + local menu=require('src.ui.OptionsMenu').new(menuGame) + local row;for _,candidate in ipairs(menu.rows)do if candidate.id=='colors'then row=candidate end end + T.check(row,edition..' actual native options has display row') + T.eq(row.value(menuGame),'DMG',edition..' options reports actual native selection') + row.step(menuGame,1) + T.eq(opts.color,'classic',edition..' options persists legacy Classic selection') + T.eq(opts.colors,'classic',edition..' options persists same native selection') + T.eq(G.mode,'classic',edition..' actual options changes native renderer mode') + T.eq(row.value(menuGame),'CLASSIC',edition..' options reports switched mode') + row.step(menuGame,-1);G.applyOptions(opts) + T.eq(G.mode,'dmg',edition..' backwards options choice survives apply') + Game.overworld=nil +end +V.set('emerald');P.setDisplay('classic',R);T.eq(P.displayColors(A),A,'Gen3 palette behavior preserved');T.eq(P.presentColors(),nil,'Gen3 receives no Classic pass') +V.set('red');P.setDisplay('classic',nil);T.eq(P.displayColors(A),P.CLASSIC,'Gen1 Classic remains its original palette path') +T.finish('native Gen2 modes, cached atlas/roof/animation/OBJ/battle bakes and saved options') diff --git a/tests/engine/gen2_init_clock.lua b/tests/engine/gen2_init_clock.lua new file mode 100644 index 000000000..15e36e5cb --- /dev/null +++ b/tests/engine/gen2_init_clock.lua @@ -0,0 +1,148 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local UI=require('src.ui.Gen2InitClock');local Clock=require('src.core.Gen2Clock') +local Stack=require('src.core.StateStack');local Box=require('src.render.TextBox');local Choice=require('src.ui.ChoiceBox') +local Timing=require('src.core.Timing');local Screens=require('src.ui.Screens') +local Json=require('src.link.Json');local Save=require('src.core.SaveData') +local function game() + local g={data=T.fixtures.fresh(),save={player={name='QA'},flags={},options={}},input={queue={}}} + g.input.wasPressed=function(self,k)return self.queue[k]==true end;g.input.isDown=function()return false end + g.stack=setmetatable({}, {__index=Stack});g.stack:init() + g.data.text={OakTimeWokeUpText='QA opening page.\fQA next page.',OakTimeWhatTimeIsItText='QA choose hour.', + OakTimeHowManyMinutesText='QA choose minute.',OakTimeWhatHoursText='QA hour',OakTimeWhoaMinutesText='QA minute', + ['OakTimeWhatHoursText.OakTimeHoursQuestionMarkText']='?', + ['OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText']='?', + ['OakText_ResponseToSetTime.OakTimeSoDarkText']=' QA night response.', + ['SetDayOfWeek.OakTimeWhatDayIsItText']='QA choose weekday.',['SetDayOfWeek.OakTimeIsItText']=' QA confirm?'} + return g +end +local function tick(g,key,n) + g.input.queue=key and {[key]=true}or{} + for _=1,n or 1 do g.stack:update(1/60)end + g.input.queue={} +end +local function untilState(g,fn,label) + for _=1,2500 do if fn()then return end;tick(g,'a')end + error('native widgets did not reach '..label) +end +local function picker(g,screen,phase) + untilState(g,function()return g.stack:top()==screen and screen.phase==phase and not screen.fade end,phase) +end +local function choice(g) + untilState(g,function()return getmetatable(g.stack:top())==Choice end,'YES/NO over native question') + return g.stack:top() +end +local function answer(g,yes) + choice(g);tick(g,yes and 'a'or'b');tick(g,nil,Timing.YES_NO_ANSWER) +end +T.eq(require('src.ui.gen2.InitClock'),UI,'public reference spelling resolves actual native screen') +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);local label=edition..' ' + T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'init_clock',label..' native speech opens with clock') + local g=game();local n=0;local parent={};g.stack:push(parent) + local ui=Screens.push(g,'Gen2InitClock',{fades=true,onDone=function()n=n+1;g.stack:pop()end}) + T.eq(getmetatable(ui),UI,label..' actual native screen factory used') + T.eq(Clock.isSet(g.save),false,label..' opening screen cannot set clock') + picker(g,ui,'hour') + T.eq(ui:question(),'QA choose hour.',label..' question uses imported native text') + T.eq(ui.hour,10,label..' initial picker has native default') + tick(g,'b');T.eq(g.stack:top(),ui,label..' B cannot abandon required picker') + for _=1,10 do tick(g,'down')end + tick(g,'down');T.eq(ui.hour,23,label..' hour wraps backwards through midnight') + tick(g,'up');T.eq(ui.hour,0,label..' hour wraps forward through midnight') + tick(g,'down');tick(g,'a');local c=choice(g) + T.eq(getmetatable(g.stack.states[#g.stack.states-1]),Box,label..' confirmation retains question under choice') + T.eq(Clock.isSet(g.save),false,label..' hour confirmation has not committed clock') + answer(g,false);T.eq(ui.phase,'hour',label..' declining returns to picker') + T.eq(ui.hour,23,label..' declining retains selected hour') + tick(g,'a');answer(g,true);picker(g,ui,'minute') + tick(g,'down');T.eq(ui.minute,59,label..' minute wraps backwards') + tick(g,'up');T.eq(ui.minute,0,label..' minute wraps forwards') + tick(g,'down');tick(g,'a');answer(g,false) + T.eq(ui.phase,'minute',label..' declining minutes returns without saving') + T.eq(Clock.isSet(g.save),false,label..' declining leaves save unchanged') + tick(g,'a');answer(g,true) + T.eq(ui.phase,'response',label..' both confirmed values are read back') + T.eq(ui:question():find('QA night response.',1,true)~=nil,true,label..' response uses imported native fragment') + T.eq(Clock.isSet(g.save),false,label..' response acknowledgement still owns commitment') + untilState(g,function()return n==1 end,'clock completion') + T.eq(g.stack:top(),parent,label..' completion restores exact parent') + T.eq(Clock.hour(g.save),23,label..' confirmed hour reaches native RTC') + T.eq(Clock.minute(g.save),59,label..' confirmed minute reaches native RTC') + local round=assert(Save.decode(Save.encode(g.save))) + T.eq(Clock.minute(round),59,label..' normal save codec preserves confirmed RTC') + ui:finish();ui:complete();T.eq(n,1,label..' duplicate finish cannot repeat callback') + -- Real special lowering and coroutine runner stay suspended through NO. + g=game();local Runner=require('src.script.ScriptRunner');local runner=Runner.new(g,{}) + local rows=assert(require('src.script.Gen2ScriptVM').compile({map_scripts={scripts={qa={ + {'special',37,'SetDayOfWeek'},{'end'}}}}},'qa')) + T.eq(#Runner.validate(rows),0,label..' native special lowers to valid production commands') + local completed=0;runner:run(rows,{onDone=function()completed=completed+1 end}) + local day=g.stack:top();T.eq(getmetatable(day),UI,label..' actual special opens shared native day mode') + T.eq(runner:isRunning(),true,label..' actual script waits for selection') + T.eq(day.day,0,label..' native day picker opens on Sunday') + tick(g,'down');T.eq(day.day,6,label..' weekday wraps backwards') + tick(g,'a');answer(g,false) + T.eq(runner:isRunning(),true,label..' NO cannot resume original script') + T.eq(g.save.rtc,nil,label..' NO cannot write weekday anchor') + tick(g,'up');T.eq(day.day,0,label..' weekday wraps forwards') + tick(g,'a');answer(g,true) + T.eq(Clock.weekday(g.save),0,label..' accepted special writes shared weekday') + T.eq(runner:isRunning(),false,label..' accepted special resumes real runner') + T.eq(completed,1,label..' real runner completes once') + day:finish();T.eq(completed,1,label..' repeated special finish cannot resume twice') + -- The production Oak step must yield to the same child and advance once. + g=game();local advanced=0;local speech={game=g,cfg={},advance=function()advanced=advanced+1 end} + g.stack:push(parent);require('src.ui.OakSpeech').runStep(speech,{kind='initclock'}) + local child=g.stack:top();T.eq(getmetatable(child),UI,label..' real Oak step opens native clock') + picker(g,child,'hour');tick(g,'a');answer(g,true);picker(g,child,'minute') + tick(g,'a');answer(g,true);untilState(g,function()return advanced==1 end,'Oak resumes') + T.eq(g.stack:top(),parent,label..' Oak clock completion preserves parent ownership') + T.eq(Clock.isSet(g.save),true,label..' actual Oak continuation follows committed RTC') + for _,api in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/setclock/manifest.json']=Json.encode({id='setclock',name='Native clock UI client',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/setclock/main.lua']=[[ + local mod=...;local Screen=require('src.ui.gen2.InitClock') + mod.exports.make=function(game)return Screen.new(game,{autoConfirm=true})end + ]]} + local loaded=T.sdk.loadMods({'mods/setclock'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,label..' actual loader admits native clock UI client') + local own=game();local screen=loaded.loader.exports.setclock.make(own) + T.eq(getmetatable(screen),UI,label..' loaded client uses native owner instead of a stub') + own.stack:push(screen) + untilState(own,function()return screen.done end,'loaded clock UI completion') + T.eq(Clock.isSet(own.save),true,label..' loaded UI client commits native RTC') + T.eq(Clock.hour(own.save),10,label..' loaded UI client keeps native default hour') + require('src.mods.Loader').teardown();loaded.release() + end +end +-- The shared Oak class also serves other editions. Preserve their existing +-- music-before-build contract rather than changing Gen1 while adding GSC. +local Music=require('src.core.Music');local play=Music.play;local order +Music.play=function(...)order[#order+1]='music';return play(...)end +for _,edition in ipairs({'red','blue','yellow','prism','polishedcrystal','gold','silver','crystal'})do + V.set(edition);order={} + local native=edition=='gold'or edition=='silver'or edition=='crystal' + local speech={game=game(),cfg={},buildSteps=function() + order[#order+1]='build';return {{kind=native and'initclock'or'say'}} + end,advance=function()order[#order+1]='advance'end} + require('src.ui.OakSpeech').enter(speech) + T.same(order,native and{'build','advance'}or{'music','build','advance'},edition..' preserves appropriate native music/build ordering') + if native then + order={};speech.buildSteps=function()order[#order+1]='build';return {{kind='say'}}end + require('src.ui.OakSpeech').enter(speech) + T.same(order,{'build','music','advance'},edition..' explicit clock removal still starts professor music') + end +end +Music.play=play +for _,edition in ipairs({'prism','polishedcrystal'})do + V.set(edition);T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'oak_welcome',edition..' keeps its own introduction') +end +for _,hour in ipairs({0,3,4,10,11,17,18,23})do + local response=UI.responseKey(hour) + T.eq(response,(hour<4 or hour>=18)and'soDark'or(hour<=10 and'overslept'or'yikes'),'hour response boundary '..hour) +end +V.set(version);Clock.resetObservations() +T.finish('native initial clock/day widgets, original Oak admission and actual script continuation') diff --git a/tests/engine/gen2_move_presentation.lua b/tests/engine/gen2_move_presentation.lua new file mode 100644 index 000000000..c05c8850e --- /dev/null +++ b/tests/engine/gen2_move_presentation.lua @@ -0,0 +1,96 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Sound=require('src.core.Sound') +local previous=Version.get() +local function make() + local data=T.fixtures.fresh() + data.moves.FIX_MOVE={id='FIX_MOVE',name='MOVE',power=20,type='NORMAL',pp=20} + data.battle_anims={gen2=true,moveAnims={FIX_MOVE=1},scripts={ + [1]={entry=1,code={{op='delay',frames=2},{op='ret'}}}, + [0x10f]={entry=1,code={{op='delay',frames=3},{op='ret'}}}, + [0x110]={entry=1,code={{op='delay',frames=4},{op='ret'}}}, + [0x112]={entry=1,code={{op='delay',frames=5},{op='ret'}}}, + [0x113]={entry=1,code={{op='delay',frames=6},{op='ret'}}}, + }} + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local game={data=data,save=save,stack={top=function()end}} + local s=Battle.newWild(game,'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + return s,game +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,side in ipairs({'player','enemy'})do + local s=make();local starts,ticks,entryCalls,tail,hits=0,0,0,0,0 + local start,update,entry=s.animPlayer.start,s.animPlayer.update,s.animForMove + s.animPlayer.start=function(self,...)starts=starts+1;return start(self,...)end + s.animPlayer.update=function(self,...)ticks=ticks+1;return update(self,...)end + s.animForMove=function(self,id,who,param,...) + entryCalls=entryCalls+1;T.eq(id,'FIX_MOVE','actual queued move reaches public owner') + T.eq(who,side,'queued side remains native');T.eq(param,19,'queued param reaches real VM') + return entry(self,id,who,param,...) + end + s.applyHitFx=function()hits=hits+1 end + s.queue={{anim='FIX_MOVE',attackerIsPlayer=side=='player',animParam=19,animDelayed=true,hit={sfx='Damage'}}, + {fn=function()tail=tail+1 end}} + local hp,pp=s.player.mon.hp,s.player.curMoves[1].pp + s:updateQueue() + T.eq(starts,1,'one native start per queue row');T.eq(entryCalls,1,'real queue calls source owner exactly once') + T.eq(ticks,0,'starting does not invent a native logic frame') + T.eq(s.animPlayer.vars.param,19,'actual native script parameter') + T.eq(s.animId,nil,'presentation observation does not replace screen state') + T.eq(s.anim.animId,'FIX_MOVE','screen observes actual running animation') + T.eq(s.anims,s.data.battle_anims,'screen exposes actual native animation dataset') + T.eq(s.animPlayer.enemySide,side=='enemy','native VM still owns mirroring') + local frames=0 + while s.animPlaying and frames<200 do + local before=ticks;s:updateQueue();frames=frames+1 + T.eq(ticks,before+1,'one native VM tick per held queue update') + T.eq(tail,0,'pending command remains behind actual animation') + end + T.check(frames<200,'native move terminates') + T.eq(hits,1,'native hit applies once after animation completes') + T.eq(s.anim,nil,'finished animation releases live observation') + s:updateQueue();T.eq(tail,1,'queue releases next command after animation') + T.eq(s.player.mon.hp,hp,'presentation cannot change party HP') + T.eq(s.player.curMoves[1].pp,pp,'presentation cannot spend PP') + T.eq(starts,1,'draw/read completion never restarts VM') + end + for _,side in ipairs({'player','enemy'})do for _,kind in ipairs({'damage','statdown'})do + local s=make();local sounds={};local play=Sound.play + Sound.play=function(_,name)sounds[#sounds+1]=name end + T.eq(s:animForMove('FIX_MOVE',side,7,20,kind),true,'native move with after-program starts') + local name=s:afterAnimFor(side,kind) + local observed=s.anim;local hops=0 + while s.animPlayer.moveId=='FIX_MOVE'and hops<200 do s:updateQueue();hops=hops+1 end + T.check(hops<200,'after program starts when native first program ends') + T.eq(s.animPlayer.moveId,name,'actual named ROM after-program runs') + T.eq(s.animPlayer.enemySide,side=='enemy','after-program keeps native attacker side') + T.eq(observed.animId,'FIX_MOVE','old observation retains original program identity') + T.eq(s.anim.animId,name,'current observation follows actual new program') + T.eq(s.pendingAfterAnim,nil,'after-program is consumed once') + T.eq(#sounds,kind=='damage'and 1 or 0,'only damaging after-program plays hit sound') + if kind=='damage'then T.eq(sounds[1],'Super_Effective','native sound spelling matches effectiveness')end + while s.animPlaying and hops<400 do s:updateQueue();hops=hops+1 end + T.check(hops<400,'after program terminates on native VM clock') + T.eq(s.animPlaying,false,'after-chain releases native hold') + Sound.play=play + end end + do + local s,game=make();game.save.options.animations=false + T.eq(s:animForMove('FIX_MOVE','player'),false,'native option skips real move scripts') + T.eq(s:animForId('ANIM_ENEMY_DAMAGE','player'),true,'non-move animation keeps its native option exemption') + local running=s.anim + T.eq(s:animForId('ANIM_MISSING','player'),false,'unavailable ROM programs remain unavailable') + T.eq(s.anim,running,'failed lookup cannot retire a real running observation') + T.eq(s.anims.ids.ANIM_THROW_POKE_BALL,nil,'name metadata does not fabricate absent programs') + T.eq(s.anims.ids.ANIM_ENEMY_DAMAGE,0x10f,'old native caches expose existing named ROM row') + end +end +Version.set(previous) +T.finish('native Gen2 queued move owner and actual after-animation programs') diff --git a/tests/engine/gen2_native_bg_clock.lua b/tests/engine/gen2_native_bg_clock.lua new file mode 100644 index 000000000..2311bc0dd --- /dev/null +++ b/tests/engine/gen2_native_bg_clock.lua @@ -0,0 +1,78 @@ +-- Exercise real native VM commands and the public animation observation. +-- Expected picture and LCD traces come from the pinned GSC BG implementation. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Player=require('src.battle.Gen2AnimPlayer') +local View=require('src.battle.Gen2AnimView') +local Version=require('src.core.GameVersion') +local previous=Version.get() +local function data(code,constants) + return {constants=constants,battle_anims={gen2=true,moveAnims={PROBE=1}, + scripts={[1]={entry=1,code=code}},objects={},framesets={},oam={},gfx={}}} +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,playerTurn in ipairs({true,false})do + local p=Player.new(data({ + {op='bgeffect',args={0x0b,0,1,0}}, + {op='delay',frames=13},{op='ret'}, + })) + T.check(p:start('PROBE',playerTurn),'native VM starts the real test program') + local r=View.runner(p);p.presentation=r + T.eq(r.bg,p.bg,'observation exposes the actual register owner') + local ticks=0;local playFrame=p.bg.playFrame + p.bg.playFrame=function(self,...)ticks=ticks+1;return playFrame(self,...)end + local side=playerTurn and 'player'or'enemy' + for frame=1,13 do + p:update() + local stage=math.floor((frame-1)/4) + local expected=stage<3 and((playerTurn and 2 or 5)-stage)or nil + T.eq(r.bg.picSize[side],expected,'native command/wait path follows four-frame picture stages') + T.eq(ticks,frame,'one existing VM update executes exactly one BG pass') + T.eq(r.frames,frame,'public time is the native VM time') + for _=1,20 do local bg,frames=r.bg,r.frames end + T.eq(ticks,frame,'repeated consumer observations never run the effect') + end + p:update();T.eq(ticks,14,'the last wait frame executes one BG pass') + p:update();T.eq(ticks,15,'ret executes on the next native command frame') + for _=1,20 do p:update()end + T.eq(ticks,15,'a stopped VM has no object-tail clock') + local old=r.bg + p:start('PROBE',not playerTurn) + T.eq(r.bg,old,'retired consumer retains the previous register block') + T.check(p.bg~=old,'restart owns a fresh block rather than mutating retired state') + T.eq(p.bg.env.battleTurn,playerTurn and 1 or 0,'restart binds the current attacker side') + T.eq(p.bg.picSize.player,nil,'restart clears player picture stages') + T.eq(p.bg.picSize.enemy,nil,'restart clears enemy picture stages') + p:release() + end + local p=Player.new(data({{op='bgeffect',args={0x25,0,1,0}}, + {op='delay',frames=4},{op='ret'}})) + p:start('PROBE',true);p:update() + local entry=p.bg.effects[1] + T.eq(entry.func,edition=='crystal'and'BATTLE_BG_EFFECT_BODY_SLAM'or'BATTLE_BG_EFFECT_WOBBLE_MON', + 'raw command resolves through the actual cartridge table') + T.eq(#p:pollEffects(),0,'actual BG owner does not duplicate its motion through legacy SE effects') + p:update();p:update() + if edition=='crystal'then T.eq(p.bg.lyBackup[0x30],0xfe,'real VM drives the signed BODY_SLAM LCD trace')end + p:release() + p=Player.new(data({{op='bgeffect',args={0x0f,0,0,0}}, + {op='delay',frames=1},{op='incbgeffect',args={0x0f}}, + {op='bgp',args={0x1ff}},{op='obp0',args={0x18c}}, + {op='obp1',args={0x124}},{op='delay',frames=1},{op='ret'}})) + p:start('PROBE',true);p:update() + local active=p.bg.effects[1] + T.eq(active.jt,1,'native Teleport initializes its running state') + p:update();p:update() + T.eq(active.func,nil,'increment advances Teleport into its native terminal state') + T.eq(p.bg.lcdc,nil,'native terminal state retires its LCD window') + T.eq(p.bg.obp0,0x8c,'native OBJ0 palette command preserves byte semantics') + T.eq(p.bg.obp1,0x24,'native OBJ1 palette command preserves byte semantics') + T.eq(p.bg.bgp,0xff,'BG palette write survives a non-palette terminal effect') + p:release() + p=Player.new(data({{op='bgp',args={0x1fc}},{op='delay',frames=1},{op='ret'}})) + p:start('PROBE',true);p:update();T.eq(p.bg.bgp,0xfc,'native BG palette write reaches the register block') + p:release() +end +Version.set(previous) +T.finish('native GSC BG commands, one-clock stepping and retired observations') diff --git a/tests/engine/gen2_native_bg_pool.lua b/tests/engine/gen2_native_bg_pool.lua new file mode 100644 index 000000000..ada94a3af --- /dev/null +++ b/tests/engine/gen2_native_bg_pool.lua @@ -0,0 +1,72 @@ +-- GSC register/picture traces against the native BgEffects API. No graphics, +-- game state, ROM, synthetic animation runner or replacement clock is used. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local Bg=require('src.battle.gen2.BgEffects') +local Ids=require('src.battle.Gen2BgEffectIds') +local Math=require('src.battle.Gen2AnimMath') +T.eq(Math.sine(16,32),32,'Q8.8 quarter-wave reaches its exact positive amplitude') +T.eq(Math.sine(48,32),224,'negative amplitude wraps as a hardware byte') +T.eq(Math.cosine(0,32),32,'cosine follows the cartridge quarter-wave shift') +T.eq(Math.sra(0x80),0xc0,'arithmetic right shift preserves a negative byte sign') +T.eq(Math.sra(0xff),0xff,'minus one remains minus one after arithmetic shift') +T.eq(Math.sra(0x7f),0x3f,'positive arithmetic shift does not invent sign bits') +T.eq(Math.swap(0xab),0xba,'nibble swap preserves byte ordering') +for value=0,255 do T.eq(Math.swap(Math.swap(value)),value,'two nibble swaps recover the hardware byte')end + +for _,edition in ipairs({'gold','silver','crystal'})do + local order=Ids.forVersion(edition) + T.eq(#order,edition=='crystal'and 54 or 53,'edition owns its cartridge BG jump table') + T.eq(order[38],edition=='crystal'and'BATTLE_BG_EFFECT_BODY_SLAM'or'BATTLE_BG_EFFECT_WOBBLE_MON', + 'Crystal insertion does not misinterpret the Gold/Silver effect id') + T.eq(order[#order],'BATTLE_BG_EFFECT_WOBBLE_SCREEN','edition table terminates at the proper final effect') + local altered=Ids.forVersion(edition);altered[1]='changed' + T.eq(Ids.forVersion(edition)[1],'BATTLE_BG_EFFECT_END','one consumer cannot mutate another edition registry') + for index,name in ipairs(order)do + T.eq(type(Bg.EFFECTS[name]),'function','every cartridge BG id has an actual native state machine') + local pool=Bg.new({battleBgEffectOrder=order},{battleTurn=0}) + local entry=pool:queue(index-1,0,0,0) + T.eq(entry and entry.func,name,'raw cartridge id selects its actual edition state machine') + local before=#Bg.DROPPED + T.check(pcall(function()for _=1,8 do pool:playFrame()end end), + 'known native effect executes its initial state transitions') + T.eq(#Bg.DROPPED,before,'known cartridge effects never take the unknown-id fallback') + end +end + +-- BattleBGEffect_EnterMon's three four-frame squares. Player uses 2x2, +-- 4x4,6x6; enemy uses 3x3,5x5,7x7 (pic-size selectors2/1/0 and5/4/3). +for _,turn in ipairs({0,1})do + for _,side in ipairs({'player','enemy'})do + local sideFlag=side=='player'and(1-turn)or turn + local pool=Bg.new({}, {battleTurn=turn}) + pool:queue('BATTLE_BG_EFFECT_ENTER_MON',0,sideFlag,0) + for frame=1,13 do + pool:playFrame() + local stage=math.floor((frame-1)/4) + local expected=stage<3 and((side=='player'and 2 or 5)-stage)or nil + T.eq(pool.picSize[side],expected,'native entry follows its own picture-size trace') + T.eq(pool.hidden[side],false,'entry reveals the side selected by battle-turn XOR') + end + T.eq(pool:activeCount(),0,'entry terminates after its native final picture state') + end +end + +-- BODY_SLAM is Crystal's inserted $25 entry. The trace retains the exact +-- LCD window and byte displacement cited by Gen1Recomp bug2102. +local slam=Bg.new({battleBgEffectOrder=Ids.forVersion('crystal')},{battleTurn=0}) +slam:queue(0x25,0,1,0);slam:playFrame() +T.eq(slam.lcdc,'SCX','BODY_SLAM displaces the selected band horizontally') +T.eq(slam.lyStart,0x2d,'player band begins at the cartridge LCD boundary') +T.eq(slam.lyEnd,0x5f,'player band ends one row beyond the picture') +slam:playFrame();slam:playFrame() +T.eq(slam.lyBackup[0x30],0xfe,'native lunge writes the signed two-pixel displacement') + +local pool=Bg.new({}, {battleTurn=0}) +for _=1,5 do T.check(pool:queue('BATTLE_BG_EFFECT_FLASH_WHITE',0,0,0)~=nil,'native pool admits five hardware effect structs')end +T.eq(pool:queue('BATTLE_BG_EFFECT_FLASH_WHITE',0,0,0),nil,'full native pool refuses a sixth struct') +pool:reset();T.eq(pool:activeCount(),0,'native reset retires all effect owners') +T.eq(pool.bgp,0xe4,'native reset restores identity BG palette register') +T.eq(pool.scx,0,'native reset restores horizontal register') +T.eq(pool.scy,0,'native reset restores vertical register') +T.finish('native GSC BG registry, byte arithmetic and register/picture state machines') diff --git a/tests/engine/gen2_object_motion_pixels.lua b/tests/engine/gen2_object_motion_pixels.lua new file mode 100644 index 000000000..5cc50ceef --- /dev/null +++ b/tests/engine/gen2_object_motion_pixels.lua @@ -0,0 +1,40 @@ +-- Counterexample through the native player's actual OAM consumer. The old +-- archetype interpolator produces different pixels and retires at 48 frames. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Player=require('src.battle.Gen2AnimPlayer') +local previous=Version.get() +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,player in ipairs({true,false})do + local data={battle_anims={gen2=true,moveAnims={PROBE=1}, + scripts={[1]={entry=1,code={{op='gfx',ids={1}}, + {op='obj',args={0,64,96,4}},{op='delay',frames=200},{op='ret'}}}}, + objects={[0]={oamFlags=1,fixY=255,frameset=0,fn=0,palette=2,gfx=1}}, + functions={[0]='MoveFromUserToTarget'}, + framesets={[0]={frames={{oam=0,duration=1}},loop='restart'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}}, + gfx={[1]={tiles=1,cols=1,image='native-motion'}}}} + local p=Player.new(data);T.check(p:start('PROBE',player)) + for frame=1,30 do + p:update() + local x=math.min(132,64+4*frame) + local y=96-math.min(17,frame)*2 + local row=p:oam()[1] + T.eq(row and row.x,player and x or 180-x,'actual cartridge X step and stop threshold') + T.eq(row and row.y,player and y or 40+y,'actual cartridge half-speed Y step') + end + for _=31,60 do p:update()end + T.eq(#p:oam(),1,'restarting object remains visible after 48 frames') + for _=61,202 do p:update()end + T.eq(p:isDone(),true,'full wait followed by return has no synthetic tail') + T.eq(#p:oam(),0,'normal top-level return immediately clears OAM') + local terminal=p.frames + for _=1,20 do p:update()end + T.eq(p.frames,terminal,'no clocks run after terminal return') + p:release() + end +end +Version.set(previous) +T.finish('native GSC actual object pixels, cartridge lifetime and terminal timing') diff --git a/tests/engine/gen2_object_runtime.lua b/tests/engine/gen2_object_runtime.lua new file mode 100644 index 000000000..e3e2e5b42 --- /dev/null +++ b/tests/engine/gen2_object_runtime.lua @@ -0,0 +1,135 @@ +-- Actual imported native commands through the GSC object/VM owner. +-- Expectations name cartridge behaviour, not a second simulated controller. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Player=require('src.battle.Gen2AnimPlayer') +local Version=require('src.core.GameVersion') +local previous=Version.get() +local function fixture(code,fn,param,frames) + return {battle_anims={gen2=true,moveAnims={PROBE=1}, + scripts={[1]={entry=1,code=code}}, + functions={[0]=fn or 'Null'}, + objects={[0]={oamFlags=1,fixY=255,frameset=0,fn=0,palette=2,gfx=1}}, + framesets={[0]=frames or {frames={{oam=0,duration=1}},loop='restart'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}}, + gfx={[1]={tiles=1,cols=1,image='native-object'}}}} +end +local function make(fn,param,player,frames,code) + local p=Player.new(fixture(code or {{op='gfx',ids={1}}, + {op='obj',args={0,64,96,param or 0}}, + {op='delay',frames=200},{op='ret'}},fn,param,frames)) + T.check(p:start('PROBE',player~=false),'actual native imported program starts') + return p +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,player in ipairs({true,false})do + local p=make('MoveFromUserToTarget',4,player) + p:update() + local r=p.runtime + local s=r.objects.structs[1] + T.eq(p.objects,r.objects,'native player exposes actual ten-slot pool') + T.eq(p.bg,r.objects.hram,'object functions and BG share actual HRAM') + T.eq(r.env,p.bg.env,'both pools read the same battle environment') + T.eq(s.func,'BATTLE_ANIM_FUNC_USER_TO_TARGET','imported function name resolves real routine') + T.eq(s.x,68,'target routine advances PARAM pixels on its first frame') + T.eq(s.y,94,'target routine advances half as many vertical pixels') + local row=r:oam()[1] + T.eq(row.x,player and 68 or 112,'enemy mirror is applied at OAM output, after motion') + T.eq(row.y,player and 94 or 134,'enemy Y fix is applied at OAM output') + for _=1,17 do p:update()end + T.eq(s.x,132,'target routine stops at its cartridge threshold') + T.eq(s.y,62,'target movement uses actual coordinate state') + T.eq(s.xOffset,0,'target movement is not an interpolated visual offset') + for _=1,42 do p:update()end + T.eq(s.index,1,'restarting frameset object survives beyond former 48-frame cap') + T.eq(r.frames,60,'only native updates advance the interpreter') + for _=1,30 do r:oam()end + T.eq(r.frames,60,'drawing and observation never step the interpreter') + T.eq(s.index,1,'OAM observation cannot retire an object') + for _=61,201 do p:update()end + T.eq(p:isDone(),false,'real anim_wait200 retains all two hundred wait frames') + p:update() + T.eq(r.frames,202,'top-level return follows command frame and full wait') + T.eq(p:isDone(),true,'top-level return is immediately terminal') + T.eq(#r:oam(),0,'terminal normal animation clears OAM immediately') + for _=1,25 do p:update()end + T.eq(r.frames,202,'terminal animation has no arbitrary drain clock') + local old=r + p:start('PROBE',not player) + T.check(old~=p.runtime,'restart creates a distinct real interpreter') + T.eq(old.frames,202,'retained old interpreter keeps its own time') + T.eq(old.objects.structs[1],s,'retained consumer keeps actual old object identity') + p:release() + end + do + local p=make('Null',0,true,nil,{{op='gfx',ids={1}}, + {op='obj',args={0,64,96,0}},{op='delay',frames=1}, + {op='incobj',args={1}},{op='delay',frames=1},{op='ret'}}) + p:update();local s=p.objects.structs[1] + p:update();T.eq(s.index,1,'wait burns a complete native frame') + p:update() + T.eq(s.jt,1,'incobj changes real jump-table state instead of choosing a frameset') + T.eq(s.index,0,'Null routine deinitializes through its actual state machine') + T.eq(#p:oam(),1,'deinitializing function still emits OAM on that same frame') + p:update();T.eq(#p:oam(),0,'the next OAM pass skips the retired slot') + p:update();T.eq(p:isDone(),true,'no synthetic object-tail delay follows ret') + p:release() + end + do + local p=make('MoveInCircle',0x88) + p:update();local s=p.objects.structs[1] + T.eq(s.param,8,'circle bit7 selects phase, not radius') + T.eq(s.var1,33,'circle init falls through into its first motion on the same frame') + T.eq(s.xOffset,248,'half-turn circle offset uses a signed wrapping byte') + T.eq(s.yOffset,0,'half-turn circle starts on the horizontal diameter') + p:release() + end + do + local code={{op='gfx',ids={1}}} + for _=1,11 do code[#code+1]={op='obj',args={0,64,96,0}}end + code[#code+1]={op='delay',frames=1} + code[#code+1]={op='clearobjs'} + code[#code+1]={op='delay',frames=1} + code[#code+1]={op='ret'} + local p=make('Null',0,true,nil,code) + p:update();T.eq(p.objects:activeCount(),10,'hardware pool drops the eleventh object') + T.eq(p.objects.lastIndex,10,'dropped object does not consume an index') + p:update();p:update() + T.eq(p.objects:activeCount(),3,'cartridge ClearObjs preserves slots eight through ten') + T.eq(p.objects.structs[8].index,8,'retained hardware slot keeps identity') + p:release() + end + do + local p=make('Null',0,true) + local sprites=p.runtime.data.oamsets[0].sprites + for i=2,50 do sprites[i]={x=i-1,y=0,tile=0,attr=0}end + p:update();T.eq(#p:oam(),40,'shadow OAM is bounded by actual forty-entry hardware limit') + p:release() + end + do + local p=make('Null',0,true,{frames={{wait=true,duration=1}, + {oam=0,duration=0}},loop='delete'}) + p:update();T.eq(#p:oam(),0,'oamwait emits no sprite on its command frame') + p:update();T.eq(#p:oam(),0,'oamwait preserves its full duration') + p:update();T.eq(#p:oam(),1,'next actual frameset row emits its sprite') + p:update();T.eq(p.objects:activeCount(),0,'oamdelete deinitializes its actual struct') + p:release() + end + do + local p=make('Null',0,true,nil,{{op='gfx',ids={1}}, + {op='obj',args={0,64,96,0}},{op='delay',frames=1}, + {op='keepsprites'},{op='ret'}}) + p:update();p:update();p:update() + T.eq(p:isDone(),true,'capture terminal is immediate') + T.eq(p:oam()[1].palette,'PAL_BATTLE_OB_ENEMY','retained OAM follows native terminal palette rule') + local kept=p:finalSprites() + T.eq(kept[1].x,64,'captured terminal keeps the actual final OAM') + local old=p.runtime;p:start('PROBE',false) + T.eq(kept.runner,old,'retained ball drawing keeps its genuine runner metadata') + T.eq(kept[1].x,64,'new animation cannot change the retained ball coordinates') + p:release() + end +end +Version.set(previous) +T.finish('native GSC object motion, hardware slots, OAM, waits and terminal ownership') diff --git a/tests/engine/gen2_palette_clock.lua b/tests/engine/gen2_palette_clock.lua new file mode 100644 index 000000000..c9d0dc01b --- /dev/null +++ b/tests/engine/gen2_palette_clock.lua @@ -0,0 +1,43 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes') +local previous={dark=P.darkWorld(),tod=P.gen2Tod(),map=P.gen2MapPalette(),date=os.date} +T.eq(P,require('src.render.PaletteFX'),'public Gen2 palette spelling shares actual native module') +P.setDarkWorld(false);P.setGen2MapPalette(0) +local cases={{0,'NITE'},{3.99,'NITE'},{4,'MORN'},{9.99,'MORN'},{10,'DAY'},{17.99,'DAY'}, + {18,'NITE'},{23.99,'NITE'},{24,'NITE'},{28,'MORN'},{34,'DAY'},{-1,'NITE'}} +for _,case in ipairs(cases)do + T.eq(P.clockDaytime(case[1]),case[2],'RTC query boundary '..case[1]) + T.eq(P.daytimeFor({mapPalette=0},case[1],false),case[2],'native AUTO query shares boundary '..case[1]) +end +for _,v in ipairs({'gold','silver','crystal'})do + V.set(v) + os.date=function(format,...)if format=='%H'then return '19'end;return previous.date(format,...)end + P.setGen2Tod('MORN');P.setGen2MapPalette(1);P.setDarkWorld(true) + T.eq(P.clockDaytime(),'NITE',v..' scene RTC query ignores map DAY and darkness') + T.eq(P.daytimeFor({mapPalette=1},nil,true),'DAY',v..' map override still selects its own row') + T.eq(P.daytimeFor({mapPalette=1},nil,false),'DAY',v..' another map does not inherit live cave darkness') + T.eq(P.gen2Tod(),'DAY',v..' public clock query never changes live bake state') + local Json=require('src.link.Json');local d=T.fixtures.fresh() + local files={['mods/clock/manifest.json']=Json.encode({id='clock',name='Clock contract',version='1.0.0',api=2,entry='main.lua',affects_link=false}), + ['mods/clock/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + -- Authored scene contract: select a sky from the raw RTC, independently + -- of the current room's forced palette. No renderer/mod art is included. + local sky={MORN='early',DAY='sunny',NITE='late'} + mod.exports.sky=sky[Palettes.clockDaytime()] + mod.exports.override=Palettes.daytimeFor({mapPalette=1},nil,true) + ]]} + local r=T.sdk.loadMods({'mods/clock'},{data=d,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,v..' actual Loader accepts unchanged public clock call') + local exports=r.loader.exports.clock + T.eq(exports.sky,'late',v..' loaded scene gets RTC sky instead of nil-method failure') + T.eq(exports.override,'DAY',v..' loaded scene can separately query map palette') + require('src.mods.Loader').teardown();r.release() +end +os.date=previous.date;P.setDarkWorld(previous.dark);P.setGen2MapPalette(previous.map);P.setGen2Tod(previous.tod);V.set(version) +T.finish('Gen2 public scene RTC palette query') diff --git a/tests/engine/gen2_palette_context.lua b/tests/engine/gen2_palette_context.lua new file mode 100644 index 000000000..34082161f --- /dev/null +++ b/tests/engine/gen2_palette_context.lua @@ -0,0 +1,165 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes');local Data=require('src.core.Data') +local W=require('src.world.gen2.World');local TR=require('src.render.TileRenderer') +local Context=require('src.render.Gen2PaletteContext');local Assets=require('src.render.Assets') +local Map=require('src.world.Map');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local saved={mode=P.mode,dark=P.darkWorld(),tod=P.gen2Tod(),palette=P.gen2MapPalette(),map=W.map,game=W.game,worldDark=W.dark, + image=Assets.image,imageData=Assets.imageData,loveImage=love.image,newImage=love.graphics.newImage,date=os.date} +os.date=function(format,...)if format=='%H'then return '20'end;return saved.date(format,...)end +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.setDark,i);if name=='Game'then slot=i;oldGame=value;break end end +assert(slot) +local function buffer(w,h,source) + local b={w=w,h=h,pixels={}} + function b:getDimensions()return self.w,self.h end + function b:getWidth()return self.w end + function b:getHeight()return self.h end + function b:getPixel(x,y) + local p=self.pixels[y*self.w+x] + if p then return unpack(p)end + local c=source and(1-x%4/3)or 0;return c,c,c,1 + end + function b:setPixel(x,y,...)self.pixels[y*self.w+x]={...}end + function b:setFilter()end + function b:setWrap()end + function b:release()end + return b +end +love.image={newImageData=function(w,h)return buffer(w,h)end} +love.graphics.newImage=function(value)assert(type(value)=='table');return value end +Assets.image=function()return buffer(16,8,true)end +Assets.imageData=function()return buffer(16,8,true)end +local function palette(seed) + local set={} + for slot=1,8 do set[slot]={};for c=1,4 do set[slot][c]={seed+slot+c,slot*10+c,c*20}end end + return set +end +local function fixture(version) + local d=T.fixtures.fresh();local rows={} + for i,row in ipairs({'MORN','DAY','NITE','DARK'})do rows[row]=palette(i*20)end + local ts={id='QA_CONTEXT',image='qa-context-'..version,tilesPerRow=2,palMap={0,6},palColorsByTod=rows, + animatedTiles={},blockCells=2,blocks={{0,1,0,1}},walkable={0},grassTiles={},waterTiles={}} + d.tilesets={QA_CONTEXT=ts} + d.maps={} + for id,p in pairs({AUTO=0,ROOM=1,NIGHT=2,MORNING=3,CAVE=4,UNUSED=7})do + d.maps[id]={id=id,label=id,width=1,height=1,blocks={0},tileset=ts.id,environment=1,mapPalette=p,group=1,number=1} + end + d.maps.CENTER={id='CENTER',label='CENTER',width=1,height=1,blocks={0},tileset=ts.id,environment=3,mapPalette=1,group=2,number=1} + d.field.gen2MapPalettes={rules={{kind='map',group=2,number=1,set='CENTER'},{kind='landmark',landmark=99,set='OTHER'}}, + sets={CENTER={DAY=palette(120)},OTHER={ALL=palette(160)}}} + d.field.gen2Roofs={tilesets={[ts.id]=true},byGroup={[1]=0},images={[0]='synthetic-roof'},slot=10,count=0,palIndex=6, + colors={[1]={DAY={{201,2,3},{204,5,6}},NITE={{211,12,13},{214,15,16}},DARK={{221,22,23},{224,25,26}}}}} + Data.publishGen2Palettes(d) + return d,ts,rows +end +local function samePixels(renderer,colors,label) + for tile,slot in ipairs({1,7})do + for c=1,4 do + local r,g,b=renderer.image:getPixel((tile-1)*8+c-1,0) + T.same({r,g,b},{colors[slot][c][1]/255,colors[slot][c][2]/255,colors[slot][c][3]/255},label..' native baked tile '..tile..' shade '..c) + end + end +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);P.setMode('gbc');P.setGen2Tod('NITE');P.setGen2MapPalette(1);P.setGen2FlashUsed(false);P.setDarkWorld(false) + local d,ts,rows=fixture(version) + T.eq(d.maps.AUTO.palette,'PALETTE_AUTO',version..' publishes native clock identity') + T.eq(d.maps.ROOM.palette,'PALETTE_DAY',version..' publishes forced room identity') + T.eq(rawget(d.maps.ROOM,'palette'),nil,version..' projection never serializes a copied palette field') + d.maps.ROOM.mapPalette=3;T.eq(d.maps.ROOM.palette,'PALETTE_MORN',version..' byte edit updates public identity immediately') + d.maps.ROOM.palette='PALETTE_DAY';T.eq(Context.id(d.maps.ROOM),1,version..' explicit authored identity remains authoritative') + d.maps.ROOM.mapPalette=1 + local authored={mapPalette=1,palette='CUSTOM'};Context.publish(authored);T.eq(authored.palette,'CUSTOM','authored non-native name preserved') + local meta={__index={other=7}};local custom=setmetatable({mapPalette=2},meta) + T.eq(Context.publish(custom),false,'authored lookup metatable is not replaced');T.eq(getmetatable(custom),meta,'authored metatable identity retained') + T.eq(P.daytimeFor(d.maps.ROOM,20,false),'DAY',version..' pinned room remains day') + T.eq(P.daytimeFor(d.maps.CAVE,12,false),'DARK',version..' neighbouring cave is dark before entry') + P.setDarkWorld(true) + T.eq(P.daytimeFor(d.maps.AUTO,12,false),'DAY',version..' outdoor neighbour cannot inherit current cave darkness') + T.eq(P.daytimeFor(d.maps.ROOM,20,false),'DAY',version..' pinned neighbour cannot inherit current cave darkness') + T.eq(P.daytimeFor(d.maps.CAVE,12,true),'NITE',version..' explicit Flash lights queried cave') + P.setDarkWorld(false) + P.setGen2MapPalette(4,d.maps.CAVE) + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' current-map query retains actual native presentation overrides') + T.eq(P.gen2RowFor(d.maps.ROOM),'DAY',version..' neighbour query still uses own header') + T.eq(P.daytimeFor(d.maps.AUTO,nil,false),'NITE',version..' absent hour reads RTC outside a pinned current map') + P.setGen2Tod('MORN');P.setGen2MapPalette(1,d.maps.ROOM) + T.eq(P.gen2RowFor(d.maps.AUTO),'NITE',version..' pinned room cannot pass an unused morning override to outdoor neighbours') + P.setGen2MapPalette(0,d.maps.AUTO) + T.eq(P.gen2RowFor({mapPalette=0}),'MORN',version..' outdoor neighbours still share current AUTO presentation') + P.setGen2Tod('NITE') + P.setGen2MapPalette(1) + for id,row in pairs({AUTO='NITE',ROOM='DAY',NIGHT='NITE',MORNING='MORN',CAVE='DARK',UNUSED='NITE',CENTER='DAY'})do + local def=d.maps[id];local map=Map.new(def,ts) + local colors,chosen=P.bgSet(d.gen2Palettes,def,P.gen2RowFor(def)) + T.eq(chosen,row,version..' native header row '..id) + local renderer=TR.new(map,d) + T.eq(renderer.trueColor,true,version..' native constructor bakes actual colours '..id) + samePixels(renderer,colors,version..' '..id) + if id=='CENTER'then T.eq(colors[1][1][1],d.field.gen2MapPalettes.sets.CENTER.DAY[1][1][1],version..' Center special rule reaches public query')end + end + local before=rows.NITE[7][2] + local set=P.bgSet(d.gen2Palettes,d.maps.AUTO,'NITE') + T.same(set[7][2],{211,12,13},version..' native roof writes only imported middle pair') + T.eq(rows.NITE[7][2],before,version..' public roof query does not modify shared tileset') + T.eq(set[7][1],rows.NITE[7][1],version..' roof preserves endpoint zero') + T.eq(set[7][4],rows.NITE[7][4],version..' roof preserves endpoint three') + d.field.gen2Roofs.colors[1].NITE[1]={231,32,33} + T.same(P.bgSet(d.gen2Palettes,d.maps.AUTO,'NITE')[7][2],{231,32,33},version..' metadata view sees live roof replacements') + TR.invalidate() + samePixels(TR.new(Map.new(d.maps.AUTO,ts),d),P.bgSet(d,d.maps.AUTO,'NITE'),version..' native invalidation observes changed roof') + d.field.gen2MapPalettes.rules[1].set='MISSING';d.maps.CENTER.landmark=99 + T.eq(P.bgSet(d,d.maps.CENTER,'DAY')[1],rows.DAY[1],version..' unavailable first rule does not fall through to later match') + d.field.gen2MapPalettes.rules[1].set='CENTER' + local game={data=d,save={flags={}}};debug.setupvalue(W.setDark,slot,game) + W.map=Map.new(d.maps.CAVE,ts) + P.setGen2MapPalette(4,d.maps.CAVE) + -- Keep the actual presentation setter while controlling only reload IO. + local reload=W.reloadMap;W.reloadMap=function()end + W:setDark(true) + T.eq(W.daytime,'DARK',version..' public world reflects actual native darkness') + T.eq(W.flashUsed,false,version..' public Flash view reads canonical save state') + require('src.script.Gen2Commands') + require('src.script.Commands').g2_blinding_flash({game=game,save=game.save,overworld=W}) + T.eq(game.save.flashLit,true,version..' original native Flash writer sets actual save bit') + T.eq(W.flashUsed,true,version..' public Flash view follows native script write') + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' native neighbour context follows script Flash write') + T.eq(W.daytime,'NITE',version..' world view follows actual lit-cave palette bake') + W.reloadMap=reload + local oldView=d.gen2Palettes;Data.publishGen2Palettes(d) + T.eq(P.bgSet(oldView,d.maps.ROOM,'DAY'),nil,version..' retired published view cannot mix old/new metadata') + local files={['mods/context/manifest.json']=Json.encode({id='context',name='Synthetic context',version='1.0.0',api=2,entry='main.lua',affects_link=false}), + ['mods/context/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + mod.exports.query=function(world,map) + local row=Palettes.daytimeFor(map.def,20,world.flashUsed) + local current=world.map + local same=current.id==map.id + local clock=current.def.palette=='PALETTE_AUTO'and map.def.palette=='PALETTE_AUTO' + if(same or clock)and Palettes.DAYTIME_ID[world.daytime]then row=world.daytime end + return Palettes.bgSet(world.game.data.gen2Palettes,map.def,row),row + end + ]]} + local r=T.sdk.loadMods({'mods/context'},{data=d,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,version..' real Loader imports public atlas context') + W.game=game;W.map=Map.new(d.maps.AUTO,ts);P.setGen2MapPalette(0);P.setGen2Tod('NITE');W:setDark(false) + local query=r.loader.exports.context.query + local colors,row=query(W,Map.new(d.maps.ROOM,ts)) + T.eq(row,'DAY',version..' actual loaded public consumer keeps neighbouring room pinned') + T.same(colors,P.bgSet(d,d.maps.ROOM,'DAY'),version..' loaded consumer uses same native roof selection') + colors,row=query(W,W.map);T.eq(row,W.daytime,version..' loaded consumer uses actual current bake') + W:syncGen2Tod('NITE') + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' native clock sync rebinds canonical Flash after mod reload') + Loader.teardown();r.release() + T.eq(P.gen2RowFor(d.maps.CAVE),'DARK',version..' teardown retires Flash presentation state') +end +Assets.image,Assets.imageData=saved.image,saved.imageData;love.image=saved.loveImage;love.graphics.newImage=saved.newImage +os.date=saved.date +W.map=saved.map;W.game=saved.game;W.dark=saved.worldDark;debug.setupvalue(W.setDark,slot,oldGame) +P.setMode(saved.mode);P.setGen2MapPalette(saved.palette);P.setGen2Tod(saved.tod);P.setDarkWorld(saved.dark);P.setGen2FlashUsed(false);V.set(original) +T.finish('native Gen2 map context, roof/special rows, world views and actual CPU atlas bake') diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua new file mode 100644 index 000000000..c60ec63a5 --- /dev/null +++ b/tests/engine/gen2_palette_draw.lua @@ -0,0 +1,109 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local P=require('src.render.PaletteFX') +local G=require('src.render.GbcPalette') +local LCD=require('src.render.GBCFX') +local V=require('src.core.GameVersion');local edition=V.get() +local Json=require('src.link.Json');local Loader=require('src.mods.Loader') +local old=love.graphics +local A={{243,241,239},{219,17,31},{19,197,83},{23,29,157}} +local B={{233,229,227},{11,61,211},{173,37,139},{3,7,13}} +local function normalized(p) + local out={};for i=1,4 do local r=p[i];out['pal'..i-1]={r[1]/255,r[2]/255,r[3]/255}end;return out +end +local active, builds, failSend=nil,0,nil +local function fakeShader(code) + builds=builds+1 + return {source=code,values={},send=function(self,name,value) + if name==failSend then failSend=nil;error('uniform upload failed')end + self.values[name]={unpack(value)} + end} +end +love.graphics={newShader=fakeShader,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G,require('src.render.Gen2PaletteDraw'),'public spelling has typed native implementation') +T.neq(G,LCD,'palette drawing must not resolve to LCD screen effect') +T.eq(type(LCD.applyOptions),'function','native LCD options retained') +T.check(G.available(),'indexed shader is available') +local standard,keyed=G.shader(),G.keyedShader() +T.neq(standard,keyed,'keyed drawing has separate alpha policy') +T.eq(builds,2,'one lazy shader per variant') +T.check(standard.source:find('pixel.a',1,true)~=nil,'source alpha retained') +T.check(standard.source:find('* tint',1,true)~=nil,'draw colour retained') +T.check(keyed.source:find('shade < 0.5 ? 0.0',1,true)~=nil,'keyed source shade zero is transparent') +for _,variant in ipairs({'with','withRaw','keyedWith'})do + active={external=true};local external=active + local wanted=variant=='keyedWith'and keyed or standard + T.check(G[variant](A,function() + T.eq(active,wanted,variant..' shader active inside callback') + T.same(active.values,normalized(A),variant..' all four RGB rows normalized') + G.withRaw(B,function()T.same(active.values,normalized(B),variant..' nested different palette active')end) + T.eq(active,wanted,variant..' nested return restores shader') + T.same(active.values,normalized(A),variant..' nested return restores uniforms') + local ok,err=pcall(function()G.withRaw(B,function()error('authored callback failure',0)end)end) + T.check(not ok and err=='authored callback failure',variant..' callback failure propagated') + T.eq(active,wanted,variant..' nested failure restores shader') + T.same(active.values,normalized(A),variant..' nested failure restores outer palette') + end),variant..' returns applied flag') + T.eq(active,external,variant..' restores foreign shader') +end +G.useRaw(A);A[2][1]=111 +G.withRaw(B,function()end) +local saved=normalized(A);saved.pal1[1]=219/255 +T.same(active.values,saved,'restoration does not borrow caller palette rows') +A[2][1]=219 +standard:send('pal1',{0,0,0});G.useRaw(A) +T.same(active.values,normalized(A),'later public call repairs externally edited uniforms') +G.useRaw(A);failSend='pal2' +local ok,err=pcall(function()G.withRaw(B,function()error('body must not run')end)end) +T.check(not ok and tostring(err):find('uniform upload failed',1,true)~=nil,'upload failure is propagated') +T.eq(active,standard,'partial upload restores previous shader') +T.same(active.values,normalized(A),'partial upload restores all previous uniforms') +local nativeMode,nativeMap=P.mode,P.shadeMap() +for _,game in ipairs({'red','gold','silver','crystal'})do + V.set(game) + for _,mode in ipairs(P.MODES)do + P.setMode(mode);P.setShadeMap({[0]=3,[1]=1,[2]=2,[3]=0}) + local resolved=P.effectiveColors(A) + local display=mode=='classic'and'classic'or((mode=='og'or mode=='og_inv')and'dmg'or'gbc') + T.eq(G.mode,display,game..' '..mode..' public mode follows actual native resolution') + local trueColor=true + T.eq(not(trueColor and G.mode=='gbc'),display~='gbc',game..' '..mode..' unchanged evolution colour gate preserves true-colour art') + T.same(G.resolve(A),P.displayColors(A),game..' '..mode..' resolves mode without consuming the register') + G.with(A,function()T.same(active.values,normalized(resolved),game..' '..mode..' actual use shares resolved colours')end) + G.withRaw(A,function()T.same(active.values,normalized(A),game..' '..mode..' raw use bypasses display/register state')end) + T.same(G.color(A,2),resolved[2],game..' '..mode..' solid colour shares same native row') + end +end +P.setShadeMap(nativeMap);P.setMode(nativeMode);V.set(edition) +for _,bad in ipairs({false,{},{{1,2,3}},{{1,2,3},{1,2,3},{1,2,3},{0/0,2,3}},{{1,2,3},{1,2,3},{1,2,3},{256,2,3}}})do + active={foreign=true};local foreign=active;local called=false + T.eq(G.withRaw(bad,function()called=true;T.eq(active,foreign,'invalid palette retains previous shader in body')end),false,'invalid palette declines colourization') + T.check(called,'invalid palette still executes draw callback') + T.eq(active,foreign,'invalid palette restores previous shader') +end +G.clear();T.eq(active,nil,'clear restores ordinary drawing') +love.graphics={} +T.eq(G.available(),false,'headless renderer declines shader availability') +local called=false;T.eq(G.with(A,function()called=true end),false,'headless with declines colourization') +T.check(called,'headless callback still executes') +love.graphics={newShader=function()error('compiler unavailable')end,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G.available(),false,'compile failure is an unavailable feature') +love.graphics.newShader=fakeShader +T.check(G.available(),'replacement compiler can recover after a failed compile') +for _,api in ipairs({1,2})do + local files={['mods/draw/manifest.json']=Json.encode({id='draw',name='Native indexed drawing consumer',version='1.0.0',api=api,content_format=api==3 and 'unified'or'native',entry='main.lua',affects_link=false}), + ['mods/draw/main.lua']=[[ + local mod=...;local Gbc=require('src.render.GbcPalette') + mod.exports.draw=function(colors,body)return Gbc.with(colors,body)end + ]]} + local r=T.sdk.loadMods({'mods/draw'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,'API'..api..' unchanged indexed consumer loads') + local ran=false + T.check(r.loader.exports.draw.draw(A,function()ran=true end),'API'..api..' loaded consumer applies palette') + T.check(ran,'API'..api..' loaded draw callback executes') + Loader.teardown();r.release() +end +love.graphics=old +T.finish('native indexed palette drawing, nested uniform and shader restoration') diff --git a/tests/engine/gen2_palette_queries.lua b/tests/engine/gen2_palette_queries.lua new file mode 100644 index 000000000..3bc458519 --- /dev/null +++ b/tests/engine/gen2_palette_queries.lua @@ -0,0 +1,27 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local P=require('src.render.PaletteFX') +local Data=require('src.core.Data') +local original={tilesets=Data.tilesets,palettes=Data.gen2Palettes,mode=P.mode,dark=P.darkWorld()} +P.setMode('gbc');P.setDarkWorld(false) +local rows={};for i,tod in ipairs({'MORN','DAY','NITE','DARK'})do + rows[tod]={};for slot=1,8 do rows[tod][slot]={{i,slot,1},{i,slot,2},{i,slot,3},{i,slot,4}}end +end +Data.tilesets={JOHTO={palColorsByTod=rows,palMap={0,1,7}}};Data:publishGen2Palettes() +for i,tod in ipairs({'MORN','DAY','NITE','DARK'})do + T.eq(P.DAYTIMES[i],tod,'ROM row order '..tod) + T.eq(P.DAYTIME_ID[tod],i,'public daytime lookup '..tod) + -- Native external atlas call shape: preserve the effective world daytime, + -- then query the published data without changing the live native state. + local effective=tod + local daytime=P.daytimeFor({tileset='JOHTO'},12,false) + if P.DAYTIME_ID[effective]then daytime=effective end + local set,row=P.bgSet(Data.gen2Palettes,{tileset='JOHTO'},daytime) + T.eq(set,rows[tod],'native external atlas receives actual row '..tod) + T.eq(row,tod,'resolved row name '..tod) +end +T.eq(P.DAYTIME_ID.UNKNOWN,nil,'unknown row not accepted') +T.eq(P.bgSet(Data.gen2Palettes,{tileset='MISSING'},'DAY'),nil,'missing tileset stays unavailable') +P.setMode('og');T.eq(P.bgSet(Data.gen2Palettes,{tileset='JOHTO'},'DAY'),nil,'DMG color choice honored') +Data.tilesets,Data.gen2Palettes=original.tilesets,original.palettes;P.setMode(original.mode);P.setDarkWorld(original.dark) +T.finish('Gen2 public palette query rows') diff --git a/tests/engine/gen2_palette_register.lua b/tests/engine/gen2_palette_register.lua new file mode 100644 index 000000000..bee18ef2d --- /dev/null +++ b/tests/engine/gen2_palette_register.lua @@ -0,0 +1,97 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local G=require('src.render.GbcPalette') +local P=require('src.render.PaletteFX') +local V=require('src.core.GameVersion') +local A={{251,241,239},{211,37,53},{17,181,89},{29,31,157}} +local B={{233,227,223},{13,67,197},{163,41,131},{3,7,11}} +local R={{229,199,167},{179,127,79},{109,61,23},{47,19,5}} +local active,fail +local function compiler(code) + return {code=code,values={},send=function(self,name,...) + if fail==name then fail=nil;error('driver rejected uniform')end + local args={...};local out={} + for i,v in ipairs(args)do out[i]=type(v)=='table'and{unpack(v)}or v end + self.values[name]=out + end} +end +love.graphics={newShader=compiler,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G.BGP_IDENTITY,0xe4,'public identity byte') +T.same(G.bgpShades(),{0,1,2,3},'omitted byte is identity') +for _,edition in ipairs({'red','gold','silver','crystal'})do + V.set(edition);P.setMode('gbc');P.setShadeMap(nil) + for byte=0,255 do + local fields,expected={},{} + for i=0,3 do fields[i+1]=math.floor(byte/4^i)%4;expected[i+1]=A[fields[i+1]+1]end + local label=edition..' byte '..byte + T.same(G.bgpShades(byte),fields,label..' reads all two-bit fields') + T.same(G.remap(A,byte),expected,label..' explicit palette permutation') + local before=G.bgp + T.eq(G.setBgp(byte),before,label..' returns actual previous register') + T.eq(G.bgp,byte~=0xe4 and byte or nil,label..' public byte reflects native state') + T.same(P.effectiveColors(A),expected,label..' native renderer receives permutation') + T.eq(G.resolve(A),A,label..' inspecting colours does not consume BGP') + T.same(G.color(A,2),expected[2],label..' solid colours consume it exactly once') + G.use(A) + for i=1,4 do T.same(active.values['pal'..i-1][1],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255},label..' indexed draw row '..i)end + local native=compiler('native');P.sendColors(native,A) + for i=1,4 do T.same(native.values['c'..i-1][1],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255},label..' native uniform row '..i)end + end + P.setShadeMap({[0]=2,3,0,1});T.eq(G.bgp,0x4e,edition..' native register write visible publicly') + G.bgp=0xf9;T.eq(P.bgp(),0xf9,edition..' legacy property writes native register') + G.bgp=nil;T.eq(P.shadeMap(),nil,edition..' legacy nil clears native state') + G.setBgp(0);P.setShadeMap(nil);T.eq(G.bgp,nil,edition..' native frame reset clears public register') +end +T.eq(G.remap(A,nil),A,'nil remap retains identity') +T.eq(G.remap(A,0xe4),A,'identity remap retains table identity') +T.eq(G.remap(nil,0xf9),nil,'missing palette stays missing') +V.set('crystal');P.setShadeMap(nil) +local src,dst,count,ambiguous=G.remapTable({A},0xf9,{B}) +T.eq(count,8,'BG and OBJ rows both recognized') +T.eq(ambiguous,0,'disjoint palette mapping exact') +for i=1,4 do T.same(src[i],A[i],'BG source row');T.same(dst[i],A[({2,3,4,4})[i]],'BG byte applies own palette');T.same(dst[i+4],B[i],'OBJ row stays unchanged')end +T.eq(#src,64,'source bounded and padded');T.eq(#dst,64,'destination bounded and padded') +T.neq(src[9],src[1],'padding owns its row');T.same(src[9],src[1],'padding repeats first colour') +local _,collisionDst,_,collisions=G.remapTable({A},0xff,{A}) +T.eq(collisions,3,'overlapping BG/OBJ collision count is explicit') +T.same(collisionDst[1],A[4],'first BG interpretation wins') +local rampSrc,rampDst,rampCount,rampAmbiguous=G.remapTable({A},0xf9,{B},R,{true}) +T.eq(rampCount,12,'forced ramp included as already drawn source') +T.eq(rampAmbiguous,0,'disjoint forced ramp exact') +T.same(rampDst[1],R[2],'forced BG takes explicit byte') +T.same(rampDst[9],R[1],'ramped OBJ uses unpermuted forced ramp') +local _,unramped=G.remapTable({A},0xf9,{B},R) +T.same(unramped[9],B[1],'unramped OBJ retains original colour') +G.setBgp(0xff) +local u=G.remapUniforms({A},0xf9,{B}) +T.same(u.src[1],{A[1][1]/255,A[1][2]/255,A[1][3]/255},'explicit backward byte ignores standing forward register') +T.same(u.dst[1],{A[2][1]/255,A[2][2]/255,A[2][3]/255},'backward byte consumed once') +G.setBgp(nil) +local ok,n=G.useRemapUniforms(u);T.check(ok,'remap shader applies');T.eq(n,0,'successful apply reports ambiguity') +local sh=active;T.eq(sh,G.remapShader(),'actual remap shader active') +T.check(sh.code:find('vec4(rgb, pixel.a) * tint',1,true)~=nil,'backward shader retains alpha and draw tint') +T.same(sh.values.remapSrc,u.src,'all normalized source entries uploaded') +T.same(sh.values.remapDst,u.dst,'all normalized destination entries uploaded') +T.same(sh.values.remapCount,{8},'bounded live count uploaded') +local first=u.src[1][1];u.src[1][1]=0 +G.withRaw(A,function()G.useRemap({B},0xff)end) +T.eq(active,sh,'nested draw restores remap shader') +T.eq(sh.values.remapSrc[1][1],first,'nested draw restores detached original remap uniforms') +local did,why=pcall(function()G.withRaw(A,function()G.useRemap({B},0xff);error('draw failure',0)end)end) +T.check(not did and why=='draw failure','callback failure propagates') +T.eq(active,sh,'error restores remap shader');T.eq(sh.values.remapSrc[1][1],first,'error restores remap uniforms') +T.eq(G.useRemap({}),false,'empty palette leaves shader alone');T.eq(active,sh,'empty palette keeps prior shader') +local rows={};for p=1,20 do rows[p]={{p,1,1},{p,2,2},{p,3,3},{p,4,4}}end +local hugeSrc,hugeDst,hugeCount=G.remapTable(rows,0xff) +T.eq(hugeCount,64,'overfull palette tables bounded');T.eq(#hugeSrc,64,'source cannot overflow shader');T.eq(#hugeDst,64,'destination cannot overflow shader') +T.eq(G.remapUniforms({false,{},{{1,2,3}}},0xf9),nil,'malformed palette rows decline safely') +T.eq(G.useRemapUniforms({count=65,src={},dst={}}),false,'oversized borrowed uniforms rejected') +fail='remapDst';T.eq(G.useRemap({B},0xff),false,'driver upload failure declines effect') +T.eq(active,sh,'driver failure keeps previous shader');T.eq(sh.values.remapSrc[1][1],first,'partial driver failure restores previous uniforms') +T.eq(G.remapShader(),nil,'failed remap shader disabled for current compiler') +love.graphics.newShader=function(code)return compiler(code)end +T.check(G.useRemap({A},0xf9),'new compiler recovers from failed upload') +love.graphics={};T.eq(G.useRemap({A},0xf9),false,'headless remap declines effect') +T.finish('native palette byte register and composited BG/OBJ remapping') diff --git a/tests/engine/gen2_picture_art_draw.lua b/tests/engine/gen2_picture_art_draw.lua new file mode 100644 index 000000000..5f6711e68 --- /dev/null +++ b/tests/engine/gen2_picture_art_draw.lua @@ -0,0 +1,182 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local F=dofile('tests/gen2_picture_art/fixture.lua') +local Import=require('src.import.Gen2BattlePictureArt') +local Battle=require('src.battle.BattleState') +local V=require('src.core.GameVersion') +local P=require('src.render.PaletteFX') +local G=require('src.render.Gen2PaletteDraw') +local Assets=require('src.render.Assets') +local previous=V.get() +local oldImage,oldData,oldNew,oldDraw=love.image,Assets.imageData,love.graphics.newImage,love.graphics.draw +local graphics=love.graphics +local push,pop,setScissor,getScissor,intersect=graphics.push,graphics.pop,graphics.setScissor, + graphics.getScissor,graphics.intersectScissor +local clip,clipStack=nil,{} +graphics.push=function(...) + clipStack[#clipStack+1]=clip or false + return push(...) +end +graphics.pop=function(...) + clip=table.remove(clipStack)or nil + return pop(...) +end +graphics.getScissor=function()if clip then return unpack(clip)end end +graphics.setScissor=function(x,y,w,h) + clip=x and{x,y,w,h}or nil + return setScissor(x,y,w,h) +end +graphics.intersectScissor=function(x,y,w,h) + if clip then + local right,bottom=math.min(x+w,clip[1]+clip[3]),math.min(y+h,clip[2]+clip[4]) + x,y=math.max(x,clip[1]),math.max(y,clip[2]);w,h=math.max(0,right-x),math.max(0,bottom-y) + end + graphics.setScissor(x,y,w,h) +end +love.image={newImageData=F.image} +local images,calls={},{} +Assets.imageData=function(path) + if images[path]then return images[path]:clone()end + local w=path:find('_back',1,true)and 48 or 56 + local out=F.image(w,w) + out:mapPixel(function(x)return (3-x%4)/3,(3-x%4)/3,(3-x%4)/3,1 end) + return out +end +love.graphics.newImage=function(data) + if type(data)=='table'and data.clone then return data:clone()end + if data=='assets/generated/sprites/monster.png'and images[data]then return images[data]:clone()end + return oldNew(data) +end +love.graphics.draw=function(image,x,y,rotation,sx,sy,extra,...) + if type(x)=='table'and x.getViewport then + x,y,rotation,sx,sy=y,rotation,sx,sy,extra + end + calls[#calls+1]={image=image,x=x,y=y,rotation=rotation,sx=sx or 1,sy=sy or 1, + clip={love.graphics.getScissor()},byte=G.bgp} +end +local A={{255,255,255},{211,41,67},{29,173,101},{3,7,13}} +local function make(edition,side,kind) + V.set(edition);P.setDisplay('gbc',nil);G.setBgp(0xe4) + local e;e,images=F.extractor(edition) + local art=assert(Import.extract(e)) + images['assets/generated/sprites/monster.png']=images[art.substitute.sheet] + local data=T.fixtures.fresh();data.constants.gen=2 + for _,def in pairs(data.pokemon)do def.palette='PIC_ART'end + data.palettes={palettes={PIC_ART=A,GRAYMON=A,MEWMON=A}, + pokemon={FIXMON_A='PIC_ART',FIXMON_B='PIC_ART',FIXMON_C='PIC_ART'}} + require('src.core.Data').publishGen2Palettes(data) + data.moves.PROBE={id='PROBE',name='PROBE',power=0,type='NORMAL',pp=10} + data.battle_anims={gen2=true,pictureArt=art,moveAnims={PROBE=1},scripts={ + [1]={entry=1,code={{op=kind=='substitute'and'raisesub'or'minimizeopp'}, + {op='delay',frames=100},{op='ret'}}}, + }} + Battle.invalidate() + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s.introSlide,s.sendingOut,s.enemySendingOut=0,false,false + s.showPlayerBack,s.showEnemyTrainer=false,false + s:animForMove('PROBE',side);s.animPlayer:update() + return s,s[side],art +end +local function draw(s,b,back) + calls={} + if back==nil or not s.drawPic then s:drawPicsLayer(0,0,0,b.isPlayer and'player'or'enemy',true) + else s:drawPic(b.mon,back)end + return calls +end +local function pixel(image,x,y,color,label) + T.same({image:getPixel(x,y)},{color[1]/255,color[2]/255,color[3]/255,1},label) +end +for _,edition in ipairs({'gold','silver','crystal'})do for _,side in ipairs({'player','enemy'})do + for _,kind in ipairs({'substitute','minimize'})do + local s,b,a=make(edition,side,kind) + local original=b.sprite;local frame=s.anim.frames + local rows=draw(s,b) + T.eq(#rows,1,'native picture layer emits one full ROM box') + local row=rows[1];local n=b.isPlayer and 48 or 56 + T.same({row.image:getDimensions()},{n,n},'real image keeps the six/seven-tile slot') + T.eq(row.x,b.isPlayer and 16 or 96,'actual Gen2 picture box origin') + T.eq(row.y,b.isPlayer and 48 or 0,'actual Gen2 picture box baseline') + local px=kind=='substitute'and 17 or 26 + local py=b.isPlayer and 32 or 40 + pixel(row.image,px,py,A[2],'replacement uses saved Pokemon CGB palette') + local _,_,_,alpha=row.image:getPixel(kind=='substitute'and 16 or 25,py) + T.eq(alpha,0,'background shade remains transparent') + T.eq(b.sprite,original,'replacement does not write native Pokemon image') + T.eq(s.anim.frames,frame,'native drawing does not advance command VM') + if s.pic then + local direct=s:pic(b.mon,b.isPlayer) + T.neq(direct,row.image,'public indexed Pokemon getter retains its original art') + end + if s.drawPic then for _,back in ipairs({false,true})do + rows=draw(s,b,back);T.eq(#rows,1,'public capture emits actual ROM replacement') + row=rows[1];local size=back and 48 or 56 + T.same({row.image:getDimensions()},{size,size},'capture selects requested front/back art') + T.eq(row.x,back and 16 or 96,'public capture correct requested box x') + T.eq(row.y,back and 48 or 0,'public capture correct requested box y') + pixel(row.image,px,back and 32 or 40,A[2],'opposite-side capture retains actual owner palette') + end end + s.anim.bg.slide[side]=5;s.anim.bg.picSize[side]=b.isPlayer and 1 or 4 + rows=draw(s,b);row=rows[1] + local ratio=b.isPlayer and 4/6 or 5/7 + T.eq(row.sx,ratio,'actual resize register scales whole native box') + T.eq(row.x,(b.isPlayer and 16 or 96)+5+math.floor(n*(1-ratio)/2),'slide and resize share selected footprint') + T.eq(row.y,(b.isPlayer and 48 or 0)+math.floor(n*(1-ratio)),'resize retains full picture baseline') + if s.drawPic then + s.anim.bg.picSize[side]=0 + rows=draw(s,b,false);row=rows[1] + T.eq(row.sx,6/7,'front capture uses seven-tile resize denominator even for native player') + T.eq(row.y,math.floor(56*(1-6/7)),'opposite front capture resize pins front baseline') + end + s.anim.bg.picSize[side]=nil;s.anim.bg.slide[side]=0 + s.anim.bg.monShade[side]=0xfc + rows=draw(s,b) + pixel(rows[1].image,px,py,A[4],'per-mon shade register reaches real replacement bake') + T.eq(G.bgp,nil,'native draw restores caller identity-register representation') + s.anim.bg.monShade[side]=nil;G.setBgp(0x1b) + rows=draw(s,b) + pixel(rows[1].image,px,py,A[3],'whole-background BGP reaches real replacement bake') + T.eq(G.bgp,0x1b,'replacement restores caller BGP even when nonidentity') + G.setBgp(0xe4) + do + local base + if s.pic then local _,_,path=s:pic(b.mon,b.isPlayer);base=path + else base=require('src.pokemon.Sprites').path(s.data,b.mon.species,b.isPlayer and 'back'or'front',{mon=b.mon,kind='battle'})end + s.data.battle_sprite_scales={OWNED={path=base,scale=2}} + row=draw(s,b)[1] + T.eq(row.sx,2,'generated picture retains underlying per-image scale override') + s.data.battle_sprite_scales=nil + end + if s.substituteDoll then + local first,quad=s:substituteDoll(true) + T.same({first:getDimensions()},{16,32},'public doll getter exposes genuine indexed ROM sheet') + T.same({quad:getViewport()},{0,16,16,16},'back doll quad selects facing-up frame') + local nextImage,nextQuad=s:substituteDoll(true) + T.eq(nextImage,first,'public doll getter shares native cached image') + T.eq(nextQuad,quad,'public doll getter reuses owned quad') + end + if s.drawPic then + s.anim.bg.liftedRows[side]={4,1} + rows=draw(s,b,false) + T.eq(#rows,2,'normal front capture splits around actual lifted band') + T.eq(rows[1].clip[2],0,'opposite capture lifted band is in front coordinates') + T.eq(rows[1].clip[4],32,'front rows above lifted band remain visible') + T.eq(rows[2].clip[2],40,'front rows below lifted band resume correctly') + s.anim.bg.liftedRows[side]=nil + end + local realDraw=love.graphics.draw + love.graphics.draw=function()error('picture device failure',0)end + T.raises(function()draw(s,b,false)end,'picture device failure','device draw failure remains visible') + T.eq(G.bgp,nil,'failed replacement paint restores caller identity register') + T.eq(s._gen2PicPaletteSide,nil,'failed replacement paint releases palette owner') + love.graphics.draw=realDraw + end +end end +V.set(previous);love.image=oldImage;Assets.imageData=oldData +love.graphics.newImage,love.graphics.draw=oldNew,oldDraw +graphics.push,graphics.pop,graphics.setScissor,graphics.getScissor,graphics.intersectScissor= + push,pop,setScissor,getScissor,intersect +T.finish('native GSC replacement pixels, actual drawing, capture geometry and palette lifetime') diff --git a/tests/engine/gen2_picture_art_import.lua b/tests/engine/gen2_picture_art_import.lua new file mode 100644 index 000000000..88a0a5035 --- /dev/null +++ b/tests/engine/gen2_picture_art_import.lua @@ -0,0 +1,82 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local F=dofile('tests/gen2_picture_art/fixture.lua') +local Art=require('src.import.Gen2BattlePictureArt') +local Data=require('src.battle.Gen2AnimData') +local Player=require('src.battle.Gen2AnimPlayer') +local image=love.image;love.image={newImageData=F.image} +local shades={1,2/3,1/3,0} +for _,edition in ipairs({'gold','silver','crystal','prism'})do + local e,images=F.extractor(edition) + local a=assert(Art.extract(e)) + T.eq(a.version,1,'import advertises actual decoded picture format') + T.eq(a.stagedActorPic,edition=='crystal'or edition=='prism','only positive staged-copy handler is advertised') + local count=0;for _ in pairs(images)do count=count+1 end + T.eq(count,5,'actual importer emits four full boxes and public doll sheet') + for _,back in ipairs({false,true})do + local side=back and 'player'or'enemy' + local n=back and 6 or 7 + local doll=images[a.substitute[side]] + T.same({doll:getDimensions()},{n*8,n*8},'doll uses native full tile box') + for y=0,n*8-1 do for x=0,n*8-1 do + local inside=x>=16 and x<32 and y>=(back and 32 or 40)and y<(back and 48 or 56) + local expected=0 + if inside then + local tile=(back and 4 or 0)+math.floor((y-(back and 32 or 40))/8)*2+math.floor((x-16)/8) + expected=(tile+x%8+2*(y%8))%4 + end + local r,g,b,alpha=doll:getPixel(x,y) + T.same({r,g,b,alpha},{shades[expected+1],shades[expected+1],shades[expected+1],expected==0 and 0 or 1}, + 'ROM doll tile identity, row/column order, shade and transparency') + end end + local mini=images[a.minimize[side]] + for y=0,n*8-1 do for x=0,n*8-1 do + local inside=x>=24 and x<32 and y>=(back and 32 or 40)and y<(back and 40 or 48) + local expected=inside and (3+x%8+2*(y%8))%4 or 0 + local r,g,b,alpha=mini:getPixel(x,y) + T.same({r,g,b,alpha},{shades[expected+1],shades[expected+1],shades[expected+1],expected==0 and 0 or 1}, + 'ROM Minimize tile is copied once into its own native column/row') + end end + end + local tables=Data.tables({pictureArt=a}) + T.eq(tables.pictureArt,a,'native adapter retains actual imported capability and paths') + for _,back in ipairs({false,true})do + for _,kind in ipairs({'minimize','transform'})do + local native={gen2=true,pictureArt=a,moveAnims={PROBE=1},scripts={ + [1]={entry=1,code={{op=kind},{op='delay',frames=3},{op='updateactorpic'}, + {op='delay',frames=3},{op='ret'}}}, + }} + local p=Player.new({battle_anims=native}) + p:start('PROBE',back);local r=p.runtime;local side=back and 'player'or'enemy' + p:update() + local initial + if not a.stagedActorPic and kind=='transform'then initial='transform'end + T.eq(r.picOverride[side],initial, + 'staging cannot repaint actor before update command') + if a.stagedActorPic then T.eq(r.actorPic[side],kind,'real VM owns staged tile identity')end + local steps=0 + while r.frames<5 and steps<100 do p:update();steps=steps+1 end + T.eq(r.picOverride[side],a.stagedActorPic and kind or (kind=='transform'and'transform'or nil), + 'real copy command publishes the staged identity only on capable ROMs') + p:start('PROBE',not back) + T.eq(next(p.runtime.actorPic),nil,'new VM starts with empty staged tiles') + p:release() + end + end +end +for _,change in ipairs({ + function(s)s.MonsterSpriteGFX=nil;s.MinimizePic=nil end, + function(s,r)r[0x5000]=300;r[0x6000]=-1 end, + function(s,r)r[0x5000]=nil;r[0x6000]=nil end, +})do + local e,images=F.extractor('gold',change) + T.eq(Art.extract(e),nil,'missing/malformed ROM bytes do not invent picture art') + T.eq(next(images),nil,'unavailable art cannot leave advertised partial images') +end +local e,images=F.extractor('crystal',function(s)s.MonsterSpriteGFX=nil;s.MinimizePic=nil end) +local a=Art.extract(e) +T.eq(a.stagedActorPic,true,'independent actor-copy capability survives unavailable optional graphics') +T.eq(a.minimize,nil,'missing Minimize symbol does not fabricate a replacement') +T.eq(next(images),nil,'metadata alone emits no invented art') +love.image=image +T.finish('native GSC picture import pixels, staged copy and missing-ROM-data boundaries') diff --git a/tests/engine/gen2_picture_pixels.lua b/tests/engine/gen2_picture_pixels.lua new file mode 100644 index 000000000..f05cf15cf --- /dev/null +++ b/tests/engine/gen2_picture_pixels.lua @@ -0,0 +1,83 @@ +-- Actual existing engine entry points and emitted image calls. The old +-- engine can run this fixture without adding any new helper module. +-- This is a CPU draw-stream counterexample, not GPU pixel qualification. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Effects=require('src.battle.MoveEffects').primary +local before=Version.get() +local function make(edition,side,op) + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.PROBE={id='PROBE',power=0,type='NORMAL',pp=10,name='PROBE'} + data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={ + [1]={entry=1,code={{op='delay',frames=3},{op=op},{op='delay',frames=4},{op='ret'}}}, + }} + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s.introSlide,s.sendingOut,s.enemySendingOut=0,false,false + s.showPlayerBack,s.showEnemyTrainer=false,false + local row={anim='PROBE',attackerIsPlayer=side=='player',animDelayed=true} + s.queue[1],s.moveAnimRow=row,row + return s,s[side],s[side=='player'and'enemy'or'player'],row +end +local calls,draw={},love.graphics.draw +love.graphics.draw=function(image,...)calls[#calls+1]=image;return draw(image,...)end +local function capture(s,b,expected,label) + calls={};s:drawPicsLayer(0,0,0,b.isPlayer and 'player'or'enemy',true) + T.eq(#calls,1,label..' emits exactly one image') + T.eq(calls[1],expected,label..' emits correct picture, not metadata alone') + if s.drawPic then + calls={};s:drawPic(b.mon,b.isPlayer) + T.eq(#calls,1,label..' public capture emits exactly one image') + T.eq(calls[1],expected,label..' public capture shares actual image owner') + end +end +local function command(s,side,expected) + local n=0 + while s.anim.picOverride[side]~=expected and n<100 do s:updateQueue();n=n+1 end + T.check(n>0 and n<100,'native queue reaches actual image opcode') +end +for _,edition in ipairs({'gold','silver','crystal'})do for _,side in ipairs({'player','enemy'})do + local s,b,target=make(edition,side,'transform') + local old=s:picImage(b.sprite) + Effects.TRANSFORM_EFFECT(s,b,target) + local replacement=s:picImage(b.sprite) + T.neq(old,replacement,'fixture uses distinct real image owners') + capture(s,b,old,'queued Transform') + s:updateQueue();capture(s,b,old,'pre-command Transform') + command(s,side,'transform') + capture(s,b,replacement,'post-command Transform') + T.eq(b.mon.species,side=='player'and'FIXMON_A'or'FIXMON_C','actual saved identity is retained') + + s,b=make(edition,side,'raisesub');old=s:picImage(b.sprite) + Effects.SUBSTITUTE_EFFECT(s,b) + capture(s,b,old,'queued Substitute') + s:updateQueue();capture(s,b,old,'pre-command Substitute') + command(s,side,'substitute') + calls={};s:drawPicsLayer(0,0,0,side,true) + T.eq(#calls,1,'post-command Substitute emits actual doll') + T.neq(calls[1],old,'doll image is distinct from normal Pokemon') + + s,b=make(edition,side,'dropsub');b.substituteHP=17;old=s:picImage(b.sprite) + s:updateQueue();command(s,side,false) + capture(s,b,old,'explicit dropsub despite retained substitute HP') + T.eq(b.substituteHP,17,'image command retains real combat substitute') + + local row + s,b,target,row=make(edition,side,'beatup') + row.animParam=207;s.data.pokemon[target.mon.species].index=207 + replacement=s:picImage(s:speciesSprite(target.mon.species,b.isPlayer)) + s:updateQueue() + local n=0 + while not s.anim.picOverride[side]and n<100 do s:updateQueue();n=n+1 end + T.check(n>0 and n<100,'native Beat Up opcode actually runs') + capture(s,b,replacement,'Beat Up species 207 outside party slots') +end end +love.graphics.draw=draw +Version.set(before) +T.finish('actual native GSC picture draw-stream parity') diff --git a/tests/engine/gen2_picture_presentation.lua b/tests/engine/gen2_picture_presentation.lua new file mode 100644 index 000000000..9964e74fe --- /dev/null +++ b/tests/engine/gen2_picture_presentation.lua @@ -0,0 +1,227 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Effects=require('src.battle.MoveEffects').primary +local Pictures=require('src.battle.Gen2PicturePresentation') +local before=Version.get() +local function make(edition,side,op) + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.PIC_TEST={id='PIC_TEST',name='PICTURE TEST',type='NORMAL',pp=10,power=0} + data.battle_anims={gen2=true,moveAnims={PIC_TEST=1},scripts={ + [1]={entry=1,code={{op='delay',frames=3},{op=op},{op='delay',frames=3},{op='ret'}}}, + }} + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s.introSlide,s.sendingOut,s.enemySendingOut=0,false,false + s.showPlayerBack,s.showEnemyTrainer=false,false + local row={anim='PIC_TEST',attackerIsPlayer=side=='player',animDelayed=true} + s.queue[1],s.moveAnimRow=row,row + return s,s[side],s[side=='player'and'enemy'or'player'],row +end +local function endAnim(s) + local count=0 + while s.animPlaying and count<120 do s:updateQueue();count=count+1 end + T.check(count<120,'picture program releases its native queue') +end +for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + for _,side in ipairs({'player','enemy'})do + local s,b,target,row=make(edition,side,'transform') + local sprite,species,hp,pp=b.sprite,b.mon.species,b.mon.hp,b.curMoves[1].pp + local savedStats=b.mon.stats + Effects.TRANSFORM_EFFECT(s,b,target) + local copied=b.sprite + T.neq(copied,sprite,'native effect resolves its replacement immediately') + T.eq(b.curStats.attack,target.curStats.attack,'native copied combat stats are not delayed') + T.eq(b.mon.stats,savedStats,'saved stats retain their owner') + T.eq(s:pictureStill(b),s:picImage(sprite),'queued view retains the actual preceding image') + T.eq(s:battlerPic(b),s:picImage(sprite),'native drawer retains preceding image') + if s.pic then + T.eq(s:pic(b.mon,b.isPlayer),s:picImage(sprite),'same-side public capture retains preceding image') + local front,_,path=s:pic(b.mon,false) + T.check(path:find('fixmon_a',1,true)or path:find('fixmon_c',1,true), + 'opposite capture resolves an existing species path') + end + T.eq(Pictures.species(s,b),species,'queued front identity precedes actual transform command') + s:updateQueue() + T.eq(s.animPlaying,true,'actual native queue starts picture program') + local r=s.anim + T.eq(s._gen2PictureChanges[1].runner,r,'pin is bound to actual command interpreter') + local ticks=0 + while r.picOverride[side]~='transform'and ticks<120 do + T.eq(s:battlerPic(b),s:picImage(sprite),'all pre-command frames show preceding species') + local frames=r.frames + for _=1,3 do + if s.pic then s:pic(b.mon,false)end + s:animPicState(side);s:battlerPic(b) + end + T.eq(r.frames,frames,'drawing never advances the VM') + s:updateQueue();ticks=ticks+1 + end + T.check(ticks>0 and ticks<120,'actual transform opcode is reached') + T.eq(s:battlerPic(b),s:picImage(copied),'the opcode reveals the actual replacement') + T.eq(b.species,target.mon.species,'public species identity commits at that same command') + T.eq(Pictures.species(s,b),target.mon.species,'opposite capture follows transformed identity') + T.eq(#s._gen2PictureChanges,0,'revealed picture releases its transient pin') + endAnim(s) + T.eq(b.mon.species,species,'saved species is never transformed') + T.eq(b.mon.hp,hp,'picture commands do not change HP') + T.eq(b.curMoves[1].pp,5,'native Transform still supplies copied moves at five PP') + T.eq(b.mon.moves[1].pp,pp,'saved PP is not consumed by picture reads') + + s,b,target,row=make(edition,side,'raisesub') + local oldHP=b.mon.hp + Effects.SUBSTITUTE_EFFECT(s,b) + T.check(b.mon.hp0,'native substitute cost resolves before animation') + T.eq(s:substituteVisible(b),false,'new doll is held until real raisesub command') + T.eq(s:animPicState(side).pic,false,'explicit preceding normal picture is observable') + s:updateQueue();r=s.anim;ticks=0 + while r.picOverride[side]~='substitute'and ticks<120 do + T.eq(s:substituteVisible(b),false,'pre-command frames retain normal picture') + s:updateQueue();ticks=ticks+1 + end + T.check(ticks<120,'real raisesub command executes') + T.eq(s:substituteVisible(b),true,'actual raisesub reveals doll') + T.eq(#s._gen2PictureChanges,0,'doll reveal retires transient creation pin') + endAnim(s) + T.eq(s:substituteVisible(b),true,'native substitute survives animation cleanup') + + s,b=make(edition,side,'dropsub');b.substituteHP=17 + s:updateQueue();r=s.anim;ticks=0 + while r.picOverride[side]~=false and ticks<120 do s:updateQueue();ticks=ticks+1 end + T.check(ticks<120,'actual dropsub command executes') + T.eq(s:animPicState(side).pic,false,'false override survives public observation') + T.eq(s:substituteVisible(b),false,'dropsub paints Pokemon despite positive combat substitute HP') + local dolls,draw=0,s.drawSubstituteDoll + s.drawSubstituteDoll=function()dolls=dolls+1 end + if s.drawPic then s:drawPic(b.mon,false) + else s:drawPicsLayer(0,0,0,side,true)end + T.eq(dolls,0,'actual public capture no longer paints hidden doll') + s.drawSubstituteDoll=draw + T.eq(b.substituteHP,17,'temporary drawing never destroys combat substitute') + endAnim(s) + T.eq(s:substituteVisible(b),true,'finishing move restores native retained substitute') + + s,b=make(edition,side,'beatup') + local species=b.mon.species + local chosen=side=='player'and'FIXMON_C'or'FIXMON_A' + s.data.pokemon[chosen].index=207 -- deliberately outside a six-member party + row=s.queue[1];row.animParam=207 + s:updateQueue();r=s.anim;ticks=0 + while not r.picOverride[side]and ticks<120 do s:updateQueue();ticks=ticks+1 end + T.check(ticks<120,'actual Beat Up command executes') + T.eq(Pictures.species(s,b),chosen,'Beat Up wire byte resolves species, not party slot') + T.eq(s:pictureStill(b),s:picImage(s:speciesSprite(chosen,b.isPlayer)), + 'native placement measures actual Beat Up image') + T.eq(s:battlerPic(b),s:pictureStill(b),'native drawer uses actual Beat Up image') + if s.pic then T.eq(s:pic(b.mon,b.isPlayer),s:pictureStill(b),'public same-side capture uses same image')end + T.eq(b.mon.species,species,'Beat Up never replaces the active saved Pokemon') + endAnim(s) + T.eq(Pictures.species(s,b),species,'Beat Up temporary picture is released with native VM') + end +end + +-- Missing/disabled/cancelled programs and a real slot replacement cannot +-- leave a pinned picture hanging or repaint the incoming saved Pokemon. +for _,mode in ipairs({'disabled','missing','cancelled','stopped','no-picture-op','new-program','replacement'})do + local s,b,target,row=make('crystal','player','transform') + local species=b.mon.species + if mode=='disabled'then s.game.save.options.animations=false end + if mode=='missing'then s.animPlayer.anims.scripts[1]=nil end + if mode=='no-picture-op'then + s.data.battle_anims.scripts[1].code={{op='delay',frames=2},{op='ret'}} + end + Effects.TRANSFORM_EFFECT(s,b,target) + if mode=='cancelled'then s:cancelMoveAnim();Pictures.sync(s) + elseif mode=='replacement'then + local incoming=Pokemon.new(s.data,'FIXMON_B',18) + if s.switchPlayerInto then s:switchPlayerInto(Battle.POS.PLAYER_LEFT,incoming) + else s.player={mon=incoming,isPlayer=true,sprite={}}end + Pictures.sync(s) + T.eq(s.player.mon,incoming,'native switch retains actual incoming mon') + T.eq(Pictures.species(s,s.player),'FIXMON_B','old transform cannot repaint replacement slot') + elseif mode=='stopped'then + s:updateQueue();s.anim.stopped=true;s:updateQueue() + elseif mode=='new-program'then + s:updateQueue() + local retired=s.anim + s:animForMove('PIC_TEST','enemy') + T.neq(s.anim,retired,'public start creates a distinct real native runner') + s:updateQueue() + T.eq(retired.picOverride.player,nil,'replacing runner does not invent a retired transform opcode') + elseif mode=='no-picture-op'then s:updateQueue();endAnim(s) + else s:updateQueue()end + T.eq(#(s._gen2PictureChanges or {}),0,mode..' releases native picture pin') + if mode~='replacement'then T.eq(b.species,target.mon.species,mode..' commits resolved native visual identity')end + T.eq(b.mon.species,species,mode..' retains saved species') +end +-- Crystal loads a transformed actor into vTiles0 first; updateactorpic later +-- publishes it. Synthetic old caches/GS keep the previous direct command. +for _,side in ipairs({'player','enemy'})do + local s,b,target,row=make('crystal',side,'transform') + s.data.battle_anims.pictureArt={version=1,stagedActorPic=true} + s.data.battle_anims.scripts[1].code={ + {op='transform'},{op='delay',frames=3},{op='updateactorpic'}, + {op='delay',frames=3},{op='ret'}, + } + local old=s:picImage(b.sprite) + Effects.TRANSFORM_EFFECT(s,b,target) + local replacement=b.sprite + s:updateQueue();local r=s.anim + s:updateQueue() + T.eq(r.actorPic[side],'transform','Crystal stages actual transformed actor tiles') + T.eq(r.picOverride[side],nil,'staging has not repainted the real BG slot') + T.eq(s:battlerPic(b),old,'native picture pin survives the staging command') + local frames=0 + while r.picOverride[side]~='transform'and frames<100 do + T.eq(s:battlerPic(b),old,'all waiting frames retain previous native picture') + s:updateQueue();frames=frames+1 + end + T.check(frames>0 and frames<100,'native copy command is reached') + T.eq(s:battlerPic(b),s:picImage(replacement),'copy command reveals actual transformed actor') + T.eq(b.species,target.mon.species,'shown species commits at actual copy command') + T.eq(#s._gen2PictureChanges,0,'copy command retires the pin exactly once') +end +for _,disabled in ipairs({false,true})do + local s,b,target,row=make('crystal','player','transform') + local initial=s:picImage(b.sprite) + Effects.TRANSFORM_EFFECT(s,b,target) + local middle=b.sprite + local second={anim='PIC_TEST',attackerIsPlayer=true,animDelayed=true} + if not disabled then s.queue[2]=second;s.moveAnimRow=second end + target.species='FIXMON_B' + Effects.TRANSFORM_EFFECT(s,b,target) + local final=b.sprite + T.eq(s:battlerPic(b),initial,'two resolved transforms still show the original queued picture') + if disabled then + s.game.save.options.animations=false + s:updateQueue() + else + s:updateQueue();local r=s.anim;local n=0 + while r.picOverride.player~='transform'and n<120 do s:updateQueue();n=n+1 end + T.check(n<120,'first native transform opcode completes') + T.eq(s:battlerPic(b),s:picImage(middle),'first command reveals only its own queued identity') + endAnim(s) + s:updateQueue();r=s.anim;n=0 + while r.picOverride.player~='transform'and n<120 do s:updateQueue();n=n+1 end + T.check(n<120,'second native transform opcode completes') + end + T.eq(b.species,'FIXMON_B','native admission commits transforms in original queue order') + T.eq(s:battlerPic(b),s:picImage(final),'last resolved visual remains after repeated transforms') + T.eq(#s._gen2PictureChanges,0,'repeated transform pins retire completely') + T.eq(b.mon.species,'FIXMON_A','repeated transforms cannot change the saved species') +end +for _,edition in ipairs({'red','emerald','firered'})do + Version.set(edition) + local s,b=make('crystal','player','dropsub');Version.set(edition) + b.substituteHP=17;s.anim={picOverride={player=false}} + T.check(s:substituteVisible(b),edition..' keeps its original substitute owner') + T.eq(s:pictureSprite(b),b.sprite,edition..' keeps native image owner') +end +Version.set(before) +T.finish('native GSC command-time picture identity and substitute visibility') diff --git a/tests/engine/gen2_pokemon_palette_queries.lua b/tests/engine/gen2_pokemon_palette_queries.lua new file mode 100644 index 000000000..6ce9ae5ff --- /dev/null +++ b/tests/engine/gen2_pokemon_palette_queries.lua @@ -0,0 +1,71 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes');local Data=require('src.core.Data') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local mode=P.mode +local function palette(seed)return {{255,255,255},{seed,2,3},{4,seed+5,6},{0,0,0}}end +local function fixture() + local d=T.fixtures.fresh();local normal,shiny,custom=palette(20),palette(60),palette(100) + d.tilesets={QUERY={palMap={0},palColorsByTod={DAY={normal}}}} + d.pokemon={SPECIES_133={id='SPECIES_133',index=133,dex=133,name='SPECIES_133',palette='QA_NORMAL',shinyPalette='QA_SHINY'}, + SPECIES_149={id='SPECIES_149',index=149,dex=149,name='DRAGONITE'}, + NO_COLORS={id='NO_COLORS'}} + d.palettes={palettes={QA_NORMAL=normal,QA_SHINY=shiny,QA_CUSTOM=custom,QA_MAPPED=palette(140), + MEWMON=palette(170),GRAYMON=palette(180)},pokemon={SPECIES_149='QA_MAPPED'}} + d.constants.speciesOrder={[133]='SPECIES_133',[149]='SPECIES_149'} + Data.publishGen2Palettes(d) + return d,normal,shiny,custom +end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);local d,normal,shiny,custom=fixture();local view=d.gen2Palettes + for _,colorMode in ipairs(P.MODES)do + P.setMode(colorMode) + T.same(P.monColors(view,'SPECIES_133'),normal,edition..' public normal RGB in '..colorMode) + T.same(P.monColors(view,'SPECIES_133',true),shiny,edition..' public shiny RGB in '..colorMode) + T.same(P.monColors(d,'SPECIES_133'),normal,edition..' full native dataset accepted in '..colorMode) + T.eq(P.monPal(d,'SPECIES_133',false,false),normal,edition..' native renderer keeps source table, without copied palettes') + T.eq(P.monPal(d,'SPECIES_133',false,true),shiny,edition..' native shiny renderer shares source selection') + end + P.setMode('gbc') + T.same(P.monColors(view,'SPECIES_149'),d.palettes.palettes.QA_MAPPED,edition..' native species-to-palette map retained') + T.eq(P.monColors(view,'ABSENT'),nil,edition..' unknown species does not acquire arbitrary fallback colours') + T.eq(P.monColors(view,'NO_COLORS'),nil,edition..' existing species without native palette stays unavailable') + local copy=P.monColors(view,'SPECIES_133');copy[2][1]=0;copy[1]=nil + T.eq(normal[2][1],20,edition..' external recolouring cannot mutate native palette') + T.same(P.monColors(view,'SPECIES_133'),normal,edition..' later read is detached from previous query') + d.pokemon.SPECIES_133.palette='QA_CUSTOM' + T.same(P.monColors(view,'SPECIES_133'),custom,edition..' live authored palette replacement reaches external view') + T.eq(P.monPal(d,'SPECIES_133',false,false),custom,edition..' same replacement reaches native renderer') + d.palettes.palettes.QA_SHINY=nil + T.same(P.monColors(view,'SPECIES_133',true),custom,edition..' unavailable shiny palette falls back to same native normal row') + T.eq(P.monPal(d,'SPECIES_133',true,true),d.palettes.palettes.GRAYMON,edition..' transformed native rendering keeps original precedence') + local bytes=Json.encode(d.palettes) + P.monColors(view,'SPECIES_133');T.eq(Json.encode(d.palettes),bytes,edition..' public read does not publish another palette catalogue') + Data.publishGen2Palettes(d) + T.eq(P.monColors(view,'SPECIES_133'),nil,edition..' retired view cannot revive a previous binding') + local legacy={pokemon={LOCAL={normal={{21,22,23},{31,32,33}},shiny={{41,42,43},{51,52,53}}}}} + T.same(P.monColors(legacy,'LOCAL'),{{255,255,255},{21,22,23},{31,32,33},{0,0,0}},edition..' reference pair input preserved') + T.same(P.monColors(legacy,'LOCAL',true),{{255,255,255},{41,42,43},{51,52,53},{0,0,0}},edition..' reference shiny pair preserved') + legacy.pokemon.LOCAL.shiny=nil + T.same(P.monColors(legacy,'LOCAL',true),P.monColors(legacy,'LOCAL'),edition..' reference absent shiny falls back to normal') + legacy.pokemon.LOCAL.normal[1][2]=0/0 + T.eq(P.monColors(legacy,'LOCAL'),nil,edition..' malformed colour is not handed to external shader') + for _,api in ipairs({1,2})do + local files={['mods/colors/manifest.json']=Json.encode({id='colors',name='Native Pokemon palette query',version='1.0.0',api=api,content_format=api==3 and 'unified' or 'native',entry='main.lua',affects_link=false}), + ['mods/colors/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + mod.exports.colors=function(data,species,shiny)return Palettes.monColors(data.gen2Palettes,species,shiny)end + ]]} + local fresh=fixture();local r=T.sdk.loadMods({'mods/colors'},{data=fresh,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,edition..' API'..api..' unchanged public consumer loads') + T.same(r.loader.exports.colors.colors(fresh,'SPECIES_133'),fresh.palettes.palettes.QA_NORMAL,edition..' API'..api..' loaded public query reads actual native palette') + Loader.teardown();r.release() + end +end +for _,args in ipairs({{}, {false}, {{},false}, {{pokemon={BAD=false}},'BAD'}})do + T.eq(P.monColors(unpack(args)),nil,'missing or invalid colour query declines') +end +P.setMode(mode);V.set(version) +T.finish('native Gen2 Pokemon palette queries and shared native selection') diff --git a/tests/engine/palette_library.lua b/tests/engine/palette_library.lua new file mode 100644 index 000000000..3ee468219 --- /dev/null +++ b/tests/engine/palette_library.lua @@ -0,0 +1,50 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local library={{name='Authored',palettes={ + {'BLUE','00112233445566778899aabb'}, + {'GRAY','000000555555aaaaaaffffff'}, + {'MIX','ff000000ff000000ffffffff'}, + {'INVALID','not a ramp'}, +}}} +package.loaded['data.gb_palettes']=library +local P=require('src.render.Palette') +T.eq(P.packs(),library,'named ramps come from actual optional authored library') +T.eq(P.packId('Authored','BLUE'),'p:Authored/BLUE','named palette ID') +T.eq(P.monoId(128,160,192),'m:80a0c0','RGB palette ID') +T.same(P.ramp('m:80a0c0'),{{128,160,192},{92,115,138},{49,61,73},{15,19,23}},'mono ramp uses public four-step intensities') +T.same(P.unhex('00112233445566778899aabb'),{{0,17,34},{51,68,85},{102,119,136},{153,170,187}},'hex row order') +T.same(P.ramp('p:Authored/BLUE'),{{153,170,187},{102,119,136},{51,68,85},{0,17,34}},'named library order reversed into light-to-dark drawing ramp') +T.same(P.swatch('p:Authored/BLUE'),{{0,17,34},{51,68,85},{102,119,136},{153,170,187}},'swatch reverses drawing ramp') +T.eq(P.findPack('Authored','BLUE'),library[1].palettes[1],'lookup keeps library ownership') +T.eq(P.label('p:Authored/BLUE'),'BLUE','named display label') +T.eq(P.label('m:9bbc0f'),'GREEN','known mono colour label') +T.eq(P.label('m:123456'),'MONO','authored mono colour label') +T.eq(P.label('unknown'),'unknown','unknown ID label remains observable') +T.eq(P.label(''),nil,'empty label absent') +for _,id in ipairs({'m:bad','m:1234567','m:xxxxxx','p:missing/entry','p:Authored/INVALID',''})do + T.eq(P.ramp(id),nil,'invalid/missing palette not invented: '..id) +end +for _,value in ipairs({false,'','00112233445566778899aabx','00112233445566778899aab'})do + T.eq(P.unhex(value),nil,'invalid hex refused') +end +T.eq(P.categoryOf(P.unhex('000000555555aaaaaaffffff')),'grey','gray category') +T.eq(P.categoryOf(P.unhex('00112233445566778899aabb')),'single','single hue category') +T.eq(P.categoryOf(P.unhex('ff000000ff000000ffffffff')),'full','full hue category') +T.eq(P.categoryOf({}),nil,'incomplete ramp has no category') +T.eq(P.hueArc({350,10}),20,'hue arc crosses zero') +T.eq(P.hueArc({}),360,'empty hue-arc protocol') +local hue,chroma=P.hueChroma({255,0,0});T.eq(hue,0,'red hue');T.eq(chroma,255,'red chroma') +local categories=P.categories();T.eq(#categories,3,'three public categories') +T.eq(categories[1].key,'full','category order preserved');T.eq(#categories[1].palettes,1,'invalid ramp omitted') +local key,at=P.locate('p:Authored/BLUE');T.eq(key,'single','locate category');T.eq(at,1,'locate index') +T.eq(P.category('single').palettes[1].pack,'Authored','category entry keeps source pack') +T.eq(P.category('unknown'),nil,'unknown category absent') +T.eq(P.locate('m:80a0c0'),nil,'mono ramp is not a named-library row') +local old=P.ramp('p:Authored/BLUE');T.eq(P.ramp('p:Authored/BLUE'),old,'same ramp ID retains cached table') +library[1].palettes[1][2]='112233445566778899aabbcc';P.invalidate() +local updated=P.ramp('p:Authored/BLUE');T.neq(updated,old,'invalidate rebuilds ramps') +T.same(updated[1],{170,187,204},'updated authored library reaches drawing ramp') +T.eq(P.packs(),library,'invalidating derived indices preserves library owner') +T.finish('native public palette IDs, authored libraries and colour categories') diff --git a/tests/engine/screen_space_ui.lua b/tests/engine/screen_space_ui.lua new file mode 100644 index 000000000..c983542a4 --- /dev/null +++ b/tests/engine/screen_space_ui.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local UI=require('src.render.ScreenSpaceUI') +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local scene={states={}} +T.eq(UI.select(stack,scene,1),nil,'ordinary empty frame requests no window-resolution UI') +local trace={} +local parent={drawsWidescreen=function()return true end,drawWidescreen=function(_,w,h) + trace[#trace+1]='wide';T.eq(w,800,'window width reaches the advertised drawer');T.eq(h,720,'window height reaches the advertised drawer') + love.graphics.setColor(1,0,0,1) +end} +local overlay={draw=function()end} +scene.states={parent,overlay} +local owner,index=UI.select(stack,scene,1) +T.eq(owner,parent,'native overlay retains the visible wide parent') +T.eq(index,1,'wide owner position is preserved for overlay separation') +parent.drawsWidescreen=function()return false end +T.eq(UI.select(stack,scene,1),nil,'drawer alone does not opt in') +parent.drawsWidescreen=function()return true end +T.eq(UI.select(stack,scene,2),nil,'hidden below draw boundary is not resurrected') +local renderer={canvas={},WIDTH=160,HEIGHT=144,uiPresentation={x=0,y=0,scaleX=5,scaleY=5}} +function renderer:uiSize()return 160,144 end +function renderer:blitCanvas(canvas,sx,sy) + trace[#trace+1]='overlay';T.eq(canvas,self.canvas,'overlay reuses native canvas');T.eq(sx,5,'native overlay horizontal scale');T.eq(sy,5,'native overlay vertical scale') +end +local viewport={width=800,height=720,gameX=0,gameY=0,scale=5,dpiX=1,dpiY=1} +love.graphics.setColor(.2,.3,.4,1) +UI.draw(parent,renderer,viewport,nil,true) +T.same(trace,{'wide','overlay'},'native prompt is composited after its window-resolution parent') +local r,g,b=love.graphics.getColor();T.eq(r,.2,'drawer graphics state restored');T.eq(g,.3,'drawer cannot leak color');T.eq(b,.4,'overlay graphics state restored') +trace={};UI.draw(parent,renderer,viewport,nil,false) +T.same(trace,{'wide'},'frame without overlay does not duplicate the cartridge canvas') +parent.drawWidescreen=function()love.graphics.setColor(0,0,0,1);error('deliberate wide drawer failure')end +T.raises(function()UI.draw(parent,renderer,viewport,nil,false)end,'deliberate wide drawer failure','drawer errors remain observable') +T.eq(select(1,love.graphics.getColor()),.2,'failed drawer still restores graphics state') +local drew=0;UI.classic(function()drew=drew+1 end,renderer,800,720) +T.eq(drew,1,'classic fallback executes native draw exactly once') +-- Native Gen2 controllers can draw on the full-size output without a +-- low-resolution intermediate, while retaining their native stack lifetime. +trace={};local world={draw=function()trace[#trace+1]='world' end} +local hidden={draw=function()error('hidden native state drawn')end} +local output={width=800,height=720} +local native={draw=function(self) + T.eq(love.graphics.getCanvas(),output,'native drawer receives output canvas directly') + trace[#trace+1]='native';love.graphics.setColor(0,1,0,1) +end} +local front={draw=function() + local r=love.graphics.getColor();T.eq(r,1,'preceding native drawer cannot leak its graphics state') + trace[#trace+1]='front' +end} +scene.states={world,hidden,native,front} +local filtered={renderVisible=function(_,s)return s~=hidden end} +T.eq(UI.hasNativeStack(filtered,scene,1,world),true,'visible native stack requests output-resolution drawing') +T.eq(UI.hasNativeStack(filtered,{states={world,hidden}},1,world),false,'world and mirrored states alone request no UI pass') +love.graphics.setCanvas(output);love.graphics.setColor(.2,.3,.4,1) +UI.drawNativeStack(filtered,scene,1,world,renderer,viewport,false) +T.same(trace,{'native','front'},'world stays single-pass and native stack draw order is retained') +T.eq(love.graphics.getCanvas(),output,'native pass restores output target') +T.eq(select(1,love.graphics.getColor()),.2,'native pass restores caller color') +trace={};UI.drawNativeStack(filtered,scene,4,world,renderer,viewport,false) +T.same(trace,{'front'},'native overlays above wide owner exclude that owner') +native.draw=function()love.graphics.setCanvas({});error('native drawer failure')end +T.raises(function()UI.drawNativeStack(filtered,scene,3,world,renderer,viewport,false)end,'native drawer failure','native errors remain visible') +T.eq(love.graphics.getCanvas(),output,'failed native drawer restores output target') +love.graphics.setCanvas() +T.finish('screen-space UI owner, native overlay ordering and graphics isolation') diff --git a/tests/gen2_picture_art/fixture.lua b/tests/gen2_picture_art/fixture.lua new file mode 100644 index 000000000..6da1e20d3 --- /dev/null +++ b/tests/gen2_picture_art/fixture.lua @@ -0,0 +1,65 @@ +-- CPU image storage for genuine import/bake pixels, not a drawing substitute. +local F={} +function F.image(w,h) + local out={w=w,h=h,pixels={}} + function out:getWidth()return self.w end + function out:getHeight()return self.h end + function out:getDimensions()return self.w,self.h end + function out:setPixel(x,y,...) + assert(x>=0 and x=0 and y=32,flip>=64 + local data={battle_anims={gen2=true,moveAnims={FIX_MOVE=1}, + scripts={[1]={entry=1,code={{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, + objects={[0]={oamFlags=1,fixY=255,frameset=0,fn=0,palette=4,gfx=2}},functions={[0]='Null'}, + framesets={[0]={frames={{oam=0,duration=20,xflip=xf,yflip=yf}},loop='hold'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0},{x=8,y=0,tile=1,attr=32}}}}, + gfx={[2]={tiles=2,cols=2,image=path}},palettes={[2]=colors}}} + local player=Player.new(data);check(player:start('FIX_MOVE',not enemy),'native VM start failed') + player:update();frames=frames+1 + g.setCanvas(canvas);g.origin();g.setScissor();g.setShader();g.setBlendMode('alpha') + g.clear(.125,.25,.5,1);g.setColor(.1,.2,.3,.4);g.setShader(sentinel) + local clock,age=player.frames,player.objects[1].age + player:draw() + check(g.getShader()==sentinel,'native object pass lost caller shader') + local tint={g.getColor()};check(math.abs(tint[1]-.1)<.001 and math.abs(tint[4]-.4)<.001,'object pass lost caller tint') + check(player.frames==clock and player.objects[1].age==age,'object drawing advanced the native animation') + g.setCanvas();g.setShader() + local image=g.readbackTexture(canvas) + local ax,ay=enemy and 140 or 40,enemy and 80 or 40 + for tile=0,1 do + local left=ax+(xf and -tile*8-8 or tile*8)-8 + local top=ay+(yf and -8 or 0)-16 + local spriteXFlip=xf~=(tile==1) + for y=0,7 do for x=0,7 do + local sourceX=tile*8+(spriteXFlip and 7-x or x) + local sourceY=yf and 7-y or y + local shade=(sourceX+2*sourceY)%4 + local got={image:getPixel(left+x,top+y)} + local wanted=shade==0 and {.125,.25,.5}or expected[shade+1] + for channel=1,3 do + local value=shade==0 and wanted[channel]or wanted[channel]/255 + check(math.abs(got[channel]-value)<2/255, + edition..' '..mode..' native OAM flip/position/palette mismatch') + end + check(math.abs(got[4]-1)<2/255,'native OAM lost destination alpha') + end end + end + image:release();player:release() + end end + end + end + end,debug.traceback) + g.setCanvas();g.pop();sentinel:release() + Version.set(before.version);Palette.setDisplay(before.mode,before.ramp) + if not ok then error(why,0)end + return {passed=true,checks=checks,frames=frames, + scope='Generated ROM-free native VM/OAM/object renderer pixels: Gold/Silver/Crystal, both sides, all object flip combinations, per-sprite XOR, GBC/DMG/custom palettes, alpha and caller graphics state. Not whole-mod or OS-window acceptance.'},canvas +end +return M diff --git a/tests/gpu/gen2_battle_scene.lua b/tests/gpu/gen2_battle_scene.lua new file mode 100644 index 000000000..866d0ee47 --- /dev/null +++ b/tests/gpu/gen2_battle_scene.lua @@ -0,0 +1,68 @@ +-- Generated art, actual native BG/OAM scene composition and GPU readbacks. +local M={} +function M.run() + local Version=require('src.core.GameVersion') + local Palette=require('src.render.PaletteFX') + local Player=require('src.battle.Gen2AnimPlayer') + local Battle=require('src.battle.BattleState') + local g=love.graphics + local previous={version=Version.get(),mode=Palette.mode,ramp=Palette.customRamp()} + local checks,frames=0,0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local function pixel(image,x,y,wanted,label) + local got={image:getPixel(x,y)} + for n=1,4 do check(math.abs(got[n]-wanted[n])<2/255,label..' channel '..n)end + end + local art=love.image.newImageData(8,8,'rgba8') + for y=0,7 do for x=0,7 do art:setPixel(x,y,2/3,2/3,2/3,1)end end + local path='qa-native-scene-obj.png';art:encode('png',path);art:release() + local canvas=g.newCanvas(304,144,{format='rgba8',dpiscale=1});M.image=canvas + local sentinel=g.newShader([[vec4 effect(vec4 c,Image t,vec2 uv,vec2 s){return Texel(t,uv)*c;}]]) + g.push('all') + local ok,why=xpcall(function() + for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition);Palette.setDisplay('gbc',nil) + local player=Player.new({battle_anims={gen2=true,moveAnims={FIX_MOVE=1}, + scripts={[1]={entry=1,code={{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, + objects={[0]={oamFlags=0,fixY=255,frameset=0,fn=0,palette=4,gfx=0}},functions={[0]='Null'}, + framesets={[0]={frames={{oam=0,duration=20}},loop='hold'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}}, + gfx={[0]={tiles=1,cols=1,image=path}},palettes={[2]={{255,255,255},{220,40,80},{70,160,30},{0,0,0}}}}}) + check(player:start('FIX_MOVE',true),'native scene animation did not start');player:update() + local screen=setmetatable({frame=0,introSlide=0,fx={shakeX=3,shakeY=2},animPlayer=player,animPlaying=true, + colorMode=function()return false end,worldBackdrop=function()return false end, + drawPicsLayer=function()g.setColor(0,0,1,1);g.rectangle('fill',8,16,8,8)end, + drawHUDs=function()g.setColor(1,0,0,1);g.rectangle('fill',0,0,8,8)end, + drawTextArea=function()g.setColor(0,0,0,1);g.rectangle('fill',0,96,160,48)end},Battle) + for _,case in ipairs({'native','replacement','wide','flash'})do + g.setCanvas(canvas);g.origin();g.setScissor();g.setShader();g.setBlendMode('alpha');g.clear(0,0,0,1) + g.setShader(sentinel);g.setColor(.2,.3,.4,.5) + local ticks,age=player.frames,player.objects[1].age + local function panel()g.setShader();g.setColor(0,0,0,1);g.rectangle('fill',0,0,304,144)end + if case=='native'then screen:drawSceneBody() + elseif case=='replacement'then screen:drawSceneBody(panel) + else + screen.fx.flash=case=='flash'and 2 or nil + screen:drawSceneBody(panel,function()g.push();g.translate(136,0);screen:drawAnimLayer(false);g.pop()end,304,144) + end + frames=frames+1 + check(g.getShader()==sentinel,'native scene lost caller shader') + check(math.abs(select(4,g.getColor())-.5)<.001,'native scene lost caller tint') + check(player.frames==ticks and player.objects[1].age==age,'scene rendering advanced native battle time') + g.setCanvas();g.setShader();local image=g.readbackTexture(canvas) + if case=='native'then pixel(image,12,19,{0,0,1,1},'native BG shake')end + if case~='flash'then + pixel(image,(case=='wide'and 168 or 32)+2,26,{220/255,40/255,80/255,1},'native OAM independent of BG shake') + else pixel(image,300,100,{.85,.85,.85,1},'wide flash covers actual native surface')end + image:release() + end + player:release() + end + end,debug.traceback) + g.setCanvas();g.pop();sentinel:release() + Version.set(previous.version);Palette.setDisplay(previous.mode,previous.ramp) + if not ok then error(why,0)end + return {passed=true,checks=checks,frames=frames, + scope='Generated ROM-free Gold/Silver/Crystal native classic panel and real VM/OAM composition, replacement panel, translated wide OAM, full-width flash, state restoration and draw clock stability. Not full UI/art, scanline-effects, whole-mod or OS-window acceptance.'},canvas +end +return M diff --git a/tests/gpu/gen2_battler_rows.lua b/tests/gpu/gen2_battler_rows.lua new file mode 100644 index 000000000..f9bf43000 --- /dev/null +++ b/tests/gpu/gen2_battler_rows.lua @@ -0,0 +1,189 @@ +-- Generated ROM-free art, real native BattleState -> VM -> OBJ GPU pixels. +local M={} +function M.run() + local Version=require('src.core.GameVersion') + local Palette=require('src.render.PaletteFX') + local Registers=require('src.render.Gen2PaletteDraw') + local Battle=require('src.battle.BattleState') + local Assets=require('src.render.Assets') + local Data=require('src.core.Data') + local g=love.graphics + local previous={version=Version.get(),mode=Palette.mode,ramp=Palette.customRamp()} + local checks,cases=0,0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local bg={32/255,64/255,128/255,1} + local A={{255,255,255},{211,41,67},{29,173,101},{3,7,13}} + local B={{255,255,255},{47,91,223},{193,131,53},{11,17,31}} + local outer={{249,237,225},{191,149,107},{81,59,43},{19,13,7}} + local custom={{247,233,219},{181,151,121},{103,73,43},{23,13,7}} + local canvas=g.newCanvas(160,144,{format='rgba8',dpiscale=1});M.image=canvas + local paths={} + local function sample(kind,back,n,x,y) + if kind=='rgb'then return {x%2==0 and 1 or .2,.4,y%2==0 and .7 or .1,.75}end + if kind=='substitute'then + local top=back and 32 or 40 + if x<16 or x>=32 or y=top+16 then return {1,1,1,1}end + elseif kind=='minimize'then + local left,top=back and 24 or 24,back and 32 or 40 + if x=left+8 or y=top+8 then return {1,1,1,1}end + end + local shade=(math.floor(x/8)*3+math.floor(y/8)*2+x+2*y+(back and 1 or 0))%4 + local value=(3-shade)/3 + return {value,value,value,1},shade + end + for _,kind in ipairs{'normal','rgb','substitute','minimize'}do + paths[kind]={} + for _,back in ipairs{false,true}do + for _,frontSize in ipairs{40,48,56}do + local n=back and 48 or kind=='normal'and frontSize or 56 + local path='qa-row-'..kind..'-'..(back and 'back'or tostring(frontSize))..'.png' + local pixels=love.image.newImageData(n,n,'rgba8') + for y=0,n-1 do for x=0,n-1 do + local rgba=sample(kind,back,n,x,y) + pixels:setPixel(x,y,unpack(rgba)) + end end + pixels:encode('png',path);pixels:release() + paths[kind][back and 'back'or frontSize]=path + end + end + end + local function make(edition,rows,attacker,kind,frontSize) + Version.set(edition);Assets.invalidate();Battle.invalidate() + local data=require('tests.modkit.fixtures').fresh();data.constants.gen=2 + for name,def in pairs(data.pokemon)do + def.palette=name=='FIXMON_A'and 'ROW_BACK'or 'ROW_FRONT' + def.spriteFront=paths[kind][frontSize];def.spriteBack=paths[kind].back + def.trueColor=kind=='rgb'or nil + end + data.palettes={palettes={ROW_BACK=B,ROW_FRONT=A,GRAYMON=A,MEWMON=A}, + pokemon={FIXMON_A='ROW_BACK',FIXMON_B='ROW_FRONT',FIXMON_C='ROW_FRONT'}} + Data.publishGen2Palettes(data) + data.constants.battleAnimGfxOrder={'BATTLE_ANIM_GFX_PLAYERHEAD','BATTLE_ANIM_GFX_ENEMYFEET'} + local code={} + if kind=='substitute'or kind=='minimize'then + code[#code+1]={op=kind=='substitute'and 'raisesub'or 'minimizeopp'} + end + code[#code+1]={op=rows==2 and 'battlergfx_1row'or 'battlergfx_2row'} + code[#code+1]={op='obj',args={0,76,40,0}} + code[#code+1]={op='obj',args={1,40,100,0}} + code[#code+1]={op='delay',frames=100};code[#code+1]={op='ret'} + data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={[1]={entry=1,code=code}}, + functions={[0]='MoveFromUserToTarget'},objects={},framesets={},oam={}} + if kind=='substitute'or kind=='minimize'then + data.battle_anims.pictureArt={version=1,[kind]={ + enemy=paths[kind][frontSize],player=paths[kind].back}} + -- The opposite battler remains a regular Pokemon. Give it generated + -- native art too; the real command decides which picture is replaced. + for _,def in pairs(data.pokemon)do + def.spriteFront=paths.normal[frontSize];def.spriteBack=paths.normal.back + end + end + for id=0,1 do + data.battle_anims.objects[id]={oamFlags=1,fixY=255,fn=0,frameset=id,palette=id,gfx=id} + data.battle_anims.framesets[id]={frames={{oam=id,duration=1}},loop='restart'} + local sprites={} + for tile=0,(id==0 and 7 or 6)*rows-1 do + sprites[#sprites+1]={x=math.floor(tile/rows)*8,y=(tile%rows)*8, + tile=tile,attr=(tile%4)*32} + end + data.battle_anims.oam[id]={tile=0,sprites=sprites} + end + local catalog=require('src.battle.Gen2AnimCatalog').compile(data.battle_anims,data.constants,edition) + catalog.gen2,catalog.format,catalog.moveAnims,catalog.indices,catalog.misc=nil,nil,nil,nil,nil + data.battle_anims=assert(loadstring(require('src.import.LuaWriter').encodeSplit(catalog)))() + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local screen=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + screen.queue,screen.current,screen.waitFrames={},nil,nil + check(screen:animForMove('PROBE',attacker),'native screen did not admit row animation') + screen.animPlayer:update() + return screen + end + g.push('all') + local ok,why=xpcall(function() + for ei,edition in ipairs{'gold','silver','crystal'}do + local frontSize=32+ei*8 + for _,kind in ipairs{'normal','rgb','substitute','minimize'}do + if kind=='normal'or kind=='rgb'or edition=='crystal'then + for _,mode in ipairs(kind=='rgb'and {'gbc'}or {'gbc','dmg','custom'})do + Palette.setDisplay(mode,mode=='custom'and custom or nil) + for _,rows in ipairs{1,2}do for _,attacker in ipairs{'player','enemy'}do + local screen=make(edition,rows,attacker,kind,frontSize) + local player=screen.animPlayer;local runner=player.runtime + local expected={} + for _,obj in ipairs(runner:oam())do + local entry + for i=#runner.loaded,1,-1 do + local item=runner.loaded[i] + if obj.tile>=item.tile and obj.tile=0 and yy>=0 and xx=0 and dx<160 and dy>=0 and dy<144 and raw then + local alpha=currentKind=='rgb'and raw[4]or (shade and shade>0 and 1 or 0) + if alpha>0 then + local rgb=currentKind=='rgb'and raw or palette[shade+1] + local value={} + for channel=1,3 do + local component=currentKind=='rgb'and rgb[channel]or rgb[channel]/255 + value[channel]=component*alpha+bg[channel]*(1-alpha) + end + value[4]=1;expected[dy*160+dx]=value + end + end + end end + end + g.setCanvas(canvas);g.origin();g.setScissor();g.setBlendMode('alpha','alphamultiply') + g.setShader();g.clear(unpack(bg));g.setColor(.2,.3,.4,.5) + Registers.useRaw(outer);local external=g.getShader() + Registers.setBgp(0) -- a BG fade cannot fade copied OBJ pixels + local clock=player.frames + player:draw() + check(player.frames==clock,'GPU drawing advanced the VM') + check(g.getShader()==external,'GPU OAM pass lost outer shader identity') + check(math.abs(select(4,g.getColor())-.5)<1/255,'GPU OAM pass lost caller tint') + -- Draw a four-pixel outer-palette probe after the actual OAM + -- pass. This proves uniforms, not merely shader identity. + local probe=love.image.newImageData(4,1,'rgba8') + for x=0,3 do local gray=1-x/3;probe:setPixel(x,0,gray,gray,gray,1)end + local image=g.newImage(probe);image:setFilter('nearest','nearest');probe:release() + g.setColor(1,1,1,1);g.draw(image,0,140);image:release() + for x=0,3 do expected[140*160+x]={outer[x+1][1]/255,outer[x+1][2]/255,outer[x+1][3]/255,1}end + g.setCanvas();g.setShader();Registers.setBgp(nil) + local pixels=g.readbackTexture(canvas) + for y=0,143 do for x=0,159 do + local got={pixels:getPixel(x,y)};local wanted=expected[y*160+x]or bg + for channel=1,4 do check(math.abs(got[channel]-wanted[channel])<2/255, + edition..' '..kind..' '..mode..' rows'..rows..' '..attacker..' pixel '..x..','..y..' channel'..channel)end + end end + pixels:release();player:release();cases=cases+1 + end end + end + end + end + end + end,debug.traceback) + g.setCanvas();g.pop();Registers.setBgp(nil) + Version.set(previous.version);Palette.setDisplay(previous.mode,previous.ramp) + if not ok then error(why,0)end + return {passed=true,checks=checks,cases=cases, + scope='ROM-free real native BattleState/VM/OBJ GPU pixel equality: GSC, native 5/6/7-tile fronts, both attackers, one/two copied rows, flips, GBC/DMG/custom, RGB alpha, substitute/minimize, outer palette probe and stable draw clock. Generated test art; not unchanged-mod, OS-window, performance or release acceptance.'},canvas +end +return M diff --git a/tests/gpu/gen2_display_modes.lua b/tests/gpu/gen2_display_modes.lua new file mode 100644 index 000000000..71826794c --- /dev/null +++ b/tests/gpu/gen2_display_modes.lua @@ -0,0 +1,157 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- ROM-free native GPU composition, including final CGB zones and window UI. +local M={} +function M.run() + require('src.core.ModCompat').install() + local P=require('src.render.PaletteFX');local G=require('src.render.GbcPalette') + local V=require('src.core.GameVersion');local Renderer=require('src.render.Renderer') + local Present=require('src.render.Gen2Presentation');local haveViewport,Viewport=pcall(require,'src.render.GameViewport') + if not haveViewport then Viewport={setTarget=function()love.graphics.setCanvas()end}end + local Runtime=require('src.mods.Runtime');local Hooks=require('src.mods.Hooks') + local g=love.graphics + local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} + local R={{231,219,207},{181,151,111},{101,67,37},{23,17,11}} + local GRAY={{255,255,255},{170,170,170},{85,85,85},{0,0,0}} + local original={version=V.get(),mode=P.mode,ramp=P.customRamp(),shadeMap=P.shadeMap(),setTarget=Viewport.setTarget,setCanvas=g.setCanvas, + events=Runtime.events,hooks=Runtime.hooks,errors=Runtime.errors} + local w,h=g.getDimensions() + local output=g.newCanvas(w,h,{format='rgba8',dpiscale=1}) + local sheet=g.newCanvas(w,h*4,{format='rgba8',dpiscale=1}) + M.image=sheet + local data=love.image.newImageData(4,1,'rgba8') + for x=0,3 do local n=1-x/3;data:setPixel(x,0,n,n,n,1)end + local image=g.newImage(data);image:setFilter('nearest','nearest') + local checks=0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local function same(actual,wanted,label) + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255,label..' channel '..c..' got '..actual[c]..' expected '..wanted[c])end + end + local function color(c)return {c[1]/255,c[2]/255,c[3]/255,1}end + local function sample(pixels,x,y)return {pixels:getPixel(math.floor(x),math.floor(y))}end + local game={nativeGpuOwner=true} + local bus=Hooks.new();local zoneCalls,composeCalls=0,0 + local nativeViewport + if haveViewport then Viewport.setTarget=function()g.setCanvas(output)end + else local setCanvas=g.setCanvas;g.setCanvas=function(canvas,...)return setCanvas(canvas or output,...)end end -- bounded output IO only + g.push('all') + local ok,why=xpcall(function() + V.set('crystal');P.setShadeMap(nil);P.setDarkWorld(false) + local haveCorrection,Correction=pcall(require,'src.render.GbcColorCorrection');if haveCorrection then Correction.setMode('off')end + require('src.render.GBCFX').setLevel(0) + local havePresets,Presets=pcall(require,'src.render.ShaderPresets');if havePresets then Presets:off()end + Runtime.install(original.events,bus,{}) + Renderer:init() + local function scene(windowMenu) + g.setCanvas(output);g.origin();g.clear(0,0,0,1) + Renderer:beginFrame() + g.setColor(1,1,1,1) + G.with(A,function()g.draw(image,0,0,0,40,72)end) + local native=P.shader() + local cached=require('src.core.Performance').paletteZoneCache + if cached~=nil then check(cached,'native optimized upload enabled')end + P.sendColors(native,A);g.setShader(native);g.draw(image,0,72,0,40,72);g.setShader() + Renderer.gen2FrameManaged=true;Renderer.gen2FrameGame=game + Renderer.gen2FrameZones=Present.zones(game) + if windowMenu then Renderer.gen2WindowDraw=function(viewport) + local vector={draw=function() + g.origin();g.setColor(1,1,1,1);g.rectangle('fill',0,0,w,12) + end} + require('src.render.ScreenSpaceUI').drawNativeStack({}, + {states={vector}},1,nil,Renderer,viewport,false) + end end + nativeViewport=Renderer:endFrame(nil,nil) + g.setCanvas();g.setShader() + original.setCanvas() -- leave the bounded legacy output target before readback + return g.readbackTexture(output) + end + for i,choice in ipairs({{'gbc',A},{'dmg',GRAY},{'classic',P.CLASSIC},{'custom',R}})do + local mode,wanted=choice[1],choice[2] + P.setDisplay(mode,mode=='custom' and R or nil) + local pixels=scene(false) + for _,y in ipairs({36,108})do for x=0,3 do + same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+y*nativeViewport.scale),color(wanted[x+1]),'native/public composite '..mode..' shade '..x..' row '..y) + end end + g.setCanvas(sheet);g.origin();g.setShader();g.setColor(1,1,1,1);g.setBlendMode('replace','premultiplied');g.draw(output,0,(i-1)*h) + g.setCanvas();pixels:release() + end + P.setDisplay('classic',nil) + local pixels=scene(true) + same(sample(pixels,w-4,4),color(P.CLASSIC[1]),'native window-space menu included before Classic at top right') + same(sample(pixels,w-4,h-4),color(P.CLASSIC[4]),'native final image keeps bottom-right orientation') + pixels:release() + local detachZones=bus:wrap('render.zones',function(next,owner,zones) + zoneCalls=zoneCalls+1 + check(owner==game,'zone hook receives actual game owner') + check(zones[1].colors==P.CLASSIC,'zone hook observes final green ramp') + -- Override final Classic as the public seam permits. Resolve must not + -- turn this authored destination back into grayscale. + zones[1].colors=R + return next(owner,zones) + end,0,'gpu-palette-consumer') + pixels=scene(false) + check(zoneCalls==1,'zone hook called once for completed frame') + for x=0,3 do same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+72*nativeViewport.scale),color(R[x+1]),'authored final zone shade '..x)end + pixels:release();detachZones() + local detachCompose=bus:wrap('render.compose',function(_,renderer,ctx) + composeCalls=composeCalls+1 + check(renderer==Renderer and ctx.renderer==Renderer,'native renderer remains composition owner') + check(ctx.game==game and ctx.generation==2,'composition identifies actual game/generation') + check(ctx.worldCanvas==ctx.sceneCanvas and ctx.uiCanvas==ctx.sceneCanvas,'composition receives completed Gen2 scene') + check(ctx.zones[1].colors==P.CLASSIC,'composition receives resolved final palette') + check(g.getCanvas()~=ctx.sceneCanvas,'composition never reads its active render target') + -- Taking ownership bypasses the ordinary tint and post-process, so a + -- reference-style consumer can present the supplied zones itself. + renderer:blitZones(ctx.sceneCanvas,ctx.zones,ctx.ww,ctx.wh) + return true + end,0,'gpu-frame-consumer') + pixels=scene(false) + check(composeCalls==1,'compose invoked once after native scene') + for x=0,3 do same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+72*nativeViewport.scale),color(P.CLASSIC[x+1]),'composition-owned final shade '..x)end + pixels:release();detachCompose() + -- Empty/backwards/outside rectangles are valid no-op draws. A false + -- palette is a raw RGB opt-out, and scoped state must be restored. + local raw=g.newCanvas(4,1,{format='rgba8',dpiscale=1}) + g.setCanvas(raw);g.clear(0,0,0,1) + local external=G.shader();G.useRaw(A) + Present.blit(image,{{x=0,y=0,w=-1,h=144,colors=R},{x=200,w=5,colors=R},{x=0,y=0,w=160,h=144,colors=false}},4,1) + check(g.getShader()==external,'public final blit restores existing shader') + g.setCanvas();g.setShader();local plain=g.readbackTexture(raw) + for x=0,3 do same(sample(plain,x,0),color(GRAY[x+1]),'false-zone raw RGB '..x)end + g.setCanvas(raw);G.useRaw(A) + Present.blit(image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + check(g.getShader()==external,'authored final blit restores shader identity') + g.draw(image,0,0);g.setCanvas();g.setShader() + local restored=g.readbackTexture(raw) + for x=0,3 do same(sample(restored,x,0),color(A[x+1]),'authored final blit restores managed uniforms '..x)end + g.setCanvas(raw);G.useRaw(A) + Present.apply(image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + check(g.getCanvas()==raw and g.getShader()==external,'offscreen final pass restores target and shader') + g.draw(image,0,0);g.setCanvas();g.setShader() + local applied=g.readbackTexture(raw) + for x=0,3 do same(sample(applied,x,0),color(A[x+1]),'offscreen final pass restores managed uniforms '..x)end + local draw=g.draw + g.draw=function(source,...) + if source==image then error('authored final draw failure',0)end + return draw(source,...) + end + g.setCanvas(raw);G.useRaw(A) + local failed,reason=pcall(Present.apply,image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + g.draw=draw + check(not failed and tostring(reason):find('authored final draw failure',1,true),'final draw error propagates') + check(g.getCanvas()==raw and g.getShader()==external,'failed final pass restores target and shader') + g.draw(image,0,0);g.setCanvas();g.setShader() + local recovered=g.readbackTexture(raw) + for x=0,3 do same(sample(recovered,x,0),color(A[x+1]),'failed final pass restores managed uniforms '..x)end + recovered:release() + restored:release();applied:release() + raw:release();plain:release() + end,debug.traceback) + g.setCanvas();g.pop() + g.setCanvas=original.setCanvas;g.setCanvas();Viewport.setTarget=original.setTarget;Runtime.install(original.events,original.hooks,original.errors) + V.set(original.version);P.setDisplay(original.mode,original.ramp);P.setShadeMap(original.shadeMap) + output:release();image:release();data:release() + if not ok then error(why,0)end + return {passed=true,checks=checks,scope='Native Gen2 GPU composition in GBC/DMG/Classic/custom, final Classic/zone hooks, completed-scene composition ownership, window-space menus, scoped raw blits'},sheet +end +return M diff --git a/tests/gpu/gen2_native_stack.lua b/tests/gpu/gen2_native_stack.lua new file mode 100644 index 000000000..755993335 --- /dev/null +++ b/tests/gpu/gen2_native_stack.lua @@ -0,0 +1,41 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Output pixels for the native Gen2 menu layer, independently of screen providers. +local M={} +function M.run() + local g=love.graphics;local Renderer=require('src.render.Renderer') + local oldSize,oldPresentation=Renderer.uiSize,Renderer.uiPresentation + local canvas=g.newCanvas(800,720,{format='rgba8',dpiscale=1});M.image=canvas + local checks=0;local function check(ok,why)checks=checks+1;assert(ok,why)end + g.push('all') + local ok,why=xpcall(function() + -- Bitmap coordinates keep integer cartridge scaling; a vector drawer that + -- resets origin() paints one actual output pixel, without enlarged texels. + Renderer.uiSize=function()return 160,144 end + Renderer.uiPresentation={x=0,y=0,scaleX=5,scaleY=5} + g.setCanvas(canvas);g.origin();g.setScissor();g.setShader();g.clear(0,0,0,1) + local native={draw=function() + check(g.getCanvas()==canvas,'native stack left the full-size output target') + g.setColor(1,1,1,1);g.rectangle('fill',2,4,1,1) + end} + local vector={draw=function() + g.origin();g.setColor(1,0,0,1);g.rectangle('fill',23,31,1,1) + end} + require('src.render.ScreenSpaceUI').drawNativeStack({}, + {states={native,vector}},1,nil,Renderer,{width=800,height=720},false) + g.setCanvas();local pixels=g.readbackTexture(canvas) + local r,green,b=pixels:getPixel(10,20) + check(r>.99 and green>.99 and b>.99,'native bitmap moved from cartridge pixel grid') + r,green,b=pixels:getPixel(14,24) + check(r>.99 and green>.99 and b>.99,'native bitmap scale changed') + r,green,b=pixels:getPixel(23,31) + check(r>.99 and green<.01 and b<.01,'vector drawer lost its output pixel coordinates') + r,green,b=pixels:getPixel(24,31) + check(r<.01 and green<.01 and b<.01,'one vector pixel enlarged through a low-resolution intermediate') + pixels:release() + end,debug.traceback) + g.pop();Renderer.uiSize=oldSize;Renderer.uiPresentation=oldPresentation + if not ok then error(why,0)end + return{passed=true,checks=checks,scope='Real GPU direct native/vector menu output pixels'},canvas +end +return M diff --git a/tests/gpu/gen2_palette_draw.lua b/tests/gpu/gen2_palette_draw.lua new file mode 100644 index 000000000..0c84afaf9 --- /dev/null +++ b/tests/gpu/gen2_palette_draw.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Call run() from an isolated LÖVE host, on each graphics backend. +local M = {} +function M.run() + require('src.core.ModCompat').install() + local G = require('src.render.GbcPalette') + local P = require('src.render.PaletteFX') + local V = require('src.core.GameVersion') + local beforeVersion, beforeMode, beforeMap = V.get(), P.mode, P.shadeMap() + V.set('crystal') + local checks = 0 + local function check(ok, message) + checks = checks + 1 + assert(ok, message) + end + local A = {{247,239,229},{213,19,43},{23,193,79},{17,31,151}} + local B = {{233,227,211},{7,59,199},{163,41,137},{3,11,29}} + local shades = love.image.newImageData(4,1,'rgba8') + for x=0,3 do local n=1-x/3;shades:setPixel(x,0,n,n,n,1-x/4) end + local image = love.graphics.newImage(shades);image:setFilter('nearest','nearest') + local canvas = love.graphics.newCanvas(4,12,{format='rgba8',dpiscale=1}) + local expected = {} + local function draw(row, palette, keyed, tint) + love.graphics.setColor(unpack(tint or {1,1,1,1})) + love.graphics.draw(image,0,row) + expected[row] = {palette=palette,keyed=keyed,tint=tint or {1,1,1,1}} + end + love.graphics.push('all') + local ok, why = pcall(function() + love.graphics.setCanvas(canvas);love.graphics.clear(0,0,0,0) + love.graphics.setBlendMode('replace','premultiplied') + check(G.with(A,function()draw(0,A,false,{0.5,0.75,1,0.8})end),'normal draw unavailable') + check(G.keyedWith(A,function()draw(1,A,true)end),'keyed draw unavailable') + G.withRaw(A,function() + draw(2,A) + G.withRaw(B,function()draw(3,B)end) + draw(4,A) + local success,err=pcall(function()G.withRaw(B,function()error('nested failure',0)end)end) + check(not success and err=='nested failure','nested callback error lost') + draw(5,A) + G.keyedWith(B,function()draw(6,B,true)end) + draw(7,A) + end) + P.setShadeMap({[0]=3,[1]=2,[2]=1,[3]=0}) + G.with(A,function()draw(8,{A[4],A[3],A[2],A[1]})end) + G.withRaw(A,function()draw(9,A)end) + local foreign=love.graphics.newShader([[ + vec4 effect(vec4 tint,Image image,vec2 uv,vec2 screen){return vec4(0.2,0.4,0.6,0.8);} + ]]) + love.graphics.setShader(foreign) + G.withRaw(B,function()draw(10,B)end) + check(love.graphics.getShader()==foreign,'foreign shader identity not restored') + love.graphics.setColor(1,1,1,1);love.graphics.draw(image,0,11) + love.graphics.setCanvas();love.graphics.setShader() + local pixels=canvas:newImageData() + for row,expect in pairs(expected)do + for x=0,3 do + local r,g,b,a=pixels:getPixel(x,row) + local rgb=expect.palette[x+1];local tint=expect.tint + local wanted={rgb[1]/255*tint[1],rgb[2]/255*tint[2],rgb[3]/255*tint[3], + expect.keyed and x==0 and 0 or (1-x/4)*tint[4]} + for c,value in ipairs({r,g,b,a})do + check(math.abs(value-wanted[c])<=2/255,'pixel mismatch row '..row..' shade '..x..' channel '..c) + end + end + end + for x=0,3 do + local pixel={pixels:getPixel(x,11)} + for c,n in ipairs({0.2,0.4,0.6,0.8})do check(math.abs(pixel[c]-n)<=2/255,'foreign shader pixel not restored')end + end + end) + love.graphics.pop() + P.setShadeMap(beforeMap);P.setMode(beforeMode);V.set(beforeVersion) + if not ok then error(why,0)end + return {passed=true,checks=checks,scope='GPU pixel equality for shade palette, tint, alpha, keying, nested success/error and foreign shader restoration'},canvas +end +return M diff --git a/tests/gpu/gen2_palette_register.lua b/tests/gpu/gen2_palette_register.lua new file mode 100644 index 000000000..72814580b --- /dev/null +++ b/tests/gpu/gen2_palette_register.lua @@ -0,0 +1,89 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- ROM-free pixel verification in a separate LÖVE host on each backend. +local M = {} +function M.run() + require('src.core.ModCompat').install() + local G = require('src.render.GbcPalette') + local P = require('src.render.PaletteFX') + local V = require('src.core.GameVersion') + local version, mode, map = V.get(), P.mode, P.shadeMap() + local g = love.graphics + local A={{251,241,239},{211,37,53},{17,181,89},{29,31,157}} + local B={{233,227,223},{13,67,197},{163,41,131},{3,7,11}} + local C={107,149,207} + local shades = love.image.newImageData(4,1,'rgba8') + local rgba = love.image.newImageData(9,1,'rgba8') + for i=0,3 do + local n=1-i/3;shades:setPixel(i,0,n,n,n,1-i/5) + for p,colors in ipairs({A,B}) do + local c=colors[i+1];rgba:setPixel((p-1)*4+i,0,c[1]/255,c[2]/255,c[3]/255,1-i/5) + end + end + rgba:setPixel(8,0,C[1]/255,C[2]/255,C[3]/255,0.4) + local indexed, colored = g.newImage(shades), g.newImage(rgba) + indexed:setFilter('nearest','nearest');colored:setFilter('nearest','nearest') + local canvas=g.newCanvas(17,257,{format='rgba8',dpiscale=1}) + local checks=0 + local function check(ok,why) checks=checks+1;assert(ok,why) end + g.push('all') + local ok,why=xpcall(function() + V.set('crystal');P.setMode('gbc') + g.setCanvas(canvas);g.clear(0,0,0,0);g.origin() + g.setBlendMode('replace','premultiplied');g.setColor(0.5,0.75,1,0.8) + local native = assert(P.shader(),'native palette shader unavailable') + for byte=0,255 do + G.setBgp(byte) + check(G.use(A),'forward shader unavailable') + g.draw(indexed,0,byte) + -- Native PaletteFX owns the same register and the real optimized + -- userdata upload path, rather than just a fake public shader object. + P.sendColors(native,A);g.setShader(native);g.draw(indexed,4,byte) + -- The explicit backward byte is independent of the standing register. + G.setBgp(0xff) + local applied,ambiguous=G.useRemap({A},byte,{B}) + check(applied and ambiguous==0,'backward shader unavailable or ambiguous') + g.draw(colored,8,byte) + end + G.setBgp(nil) + local display=P.displayColors + P.displayColors=function()return B end + local changed,changeError=pcall(function() + if P._beginColorBatch then check(P._beginColorBatch(native)==nil,'overridden display resolver entered a trusted native batch') end + P.sendColors(native,A);g.setShader(native);g.draw(indexed,0,256) + end) + P.displayColors=display + if not changed then error(changeError,0) end + g.setCanvas();g.setShader() + local pixels=g.readbackTexture and g.readbackTexture(canvas) or canvas:newImageData() + for byte=0,255 do + for x=0,16 do + local color,alpha + if x<8 then + local shade=x%4;local field=math.floor(byte/4^shade)%4 + color,alpha=A[field+1],1-shade/5 + elseif x<12 then + local shade=x-8;local field=math.floor(byte/4^shade)%4 + color,alpha=A[field+1],1-shade/5 + elseif x<16 then color,alpha=B[x-11],1-(x-12)/5 + else color,alpha=C,0.4 end + local wanted={color[1]/255*0.5,color[2]/255*0.75,color[3]/255,alpha*0.8} + -- PaletteFX's native final-zone shader deliberately ignores the + -- draw tint. The public indexed/remap shaders compose with it. + if x>=4 and x<8 then wanted={color[1]/255,color[2]/255,color[3]/255,alpha} end + local actual={pixels:getPixel(x,byte)} + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255, + 'register '..byte..' pixel '..x..' channel '..c..' got '..actual[c]..' expected '..wanted[c])end + end + end + for x=0,3 do + local actual={pixels:getPixel(x,256)} + local wanted={B[x+1][1]/255,B[x+1][2]/255,B[x+1][3]/255,1-x/5} + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255,'native upload ignored replaced display resolver')end + end + end,debug.traceback) + g.pop();P.setShadeMap(map);P.setMode(mode);V.set(version) + indexed:release();colored:release();shades:release();rgba:release() + if not ok then canvas:release();error(why,0) end + return {passed=true,checks=checks,scope='All 256 BGP bytes: native and public indexed draws, composited BG permutation, OBJ preservation, unrelated RGB preservation, alpha and tint'},canvas +end +return M diff --git a/tests/love_stub.lua b/tests/love_stub.lua index b7fc25fa9..7b61f0737 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -44,7 +44,15 @@ stub.graphics = { local w, h = pngSize(path) return setmetatable({ w = w, h = h, path = path }, Image) end, - newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end, + newQuad = function(x,y,w,h,sw,sh) + local quad={x=x,y=y,w=w,h=h,sw=sw,sh=sh} + function quad:setViewport(a,b,c,d,e,f) + self.x,self.y,self.w,self.h=a,b,c,d + self.sw,self.sh=e or self.sw,f or self.sh + end + function quad:getViewport()return self.x,self.y,self.w,self.h end + return quad + end, newCanvas = function(w, h) return setmetatable({ w = w, h = h, setFilter = noop }, Image) end,