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,