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/api3-flags.md b/docs/api3-flags.md
new file mode 100644
index 000000000..3eea204a9
--- /dev/null
+++ b/docs/api3-flags.md
@@ -0,0 +1,75 @@
+# API-3 native and private flags
+
+`mod.world:getFlag(reference)` returns a boolean or `nil, reason`.
+`setFlag(reference, boolean)` returns `true` or `nil, reason`. Real booleans are
+required. The facade follows the current Game/save without bootstrapping one.
+Flag reads/writes require a save but can run during menus/scripts; they do not
+start an idle-world action. Writes from a rolled-back/reloaded/unloaded owner
+are retired. Gen4/5 remain outside the shared API-3 world surface.
+
+## Private flags
+
+`mod:` and `mod..` address the same private flag in
+`save.modData[modId].worldFlags`. A foreign `mod..*` reference is
+refused. Keys are nonempty strings up to 256 bytes without control characters.
+A read does not create storage. A clear removes the key. Malformed stores are
+refused without overwriting them. The existing change-only `flag.changed`
+payload remains `{name="mod..", value=boolean}`.
+
+## Cartridge flags
+
+| Version | Public references | Host storage |
+| --- | --- | --- |
+| Red/Blue | Known event symbol or its named bit index | Existing `EVENT_*` save.flags key |
+| Yellow | Yellow event symbol or its named bit index | Existing `EVENT_*` save.flags key, with Yellow's own bit catalog |
+| Gold/Silver | EVENT symbol or whole bit index 0–2047 | `EVENT_G2_%04d`, as used by the VM/save converter |
+| Crystal | Whole bit index 0–2047 | Same native numbered namespace; symbolic references explicitly decline, matching the pinned Gen1 API |
+| FireRed/Emerald | Known native flag symbol/alias, or a named numeric flag in that version's range | `FLAG_G3_%04X`, as used by the VM/save converter |
+
+Native IDs/symbols are cartridge-specific; no equivalence between the same bit
+number in different games is invented. Unknown, fractional, non-finite,
+negative, malformed or out-of-range references decline before writes. Special
+variables are not a new flag namespace. Prism/Polished Crystal native mappings
+are not guessed from Gold; private flags remain available there.
+
+Clearing a native host flag retains `false`, matching native object visibility's
+distinction between "cleared" and "untouched". Gen3 synchronizes active object
+templates carrying that exact native flag through the host's targeted native
+visibility entry; unrelated actors/templates are left alone. Native failures
+there can happen after the flag write and are not transactional rollback.
+
+Native `flag.changed` fires only on a true boolean transition. Its name is the
+Gen1 symbol, Gen2 bit number, or Gen3 version-catalog name (falling back to the
+numeric ID); Gen3 also provides `id`. For Gen3 cross-engine listeners, use
+`event.id` for native identity: the pinned Gen1 implementation's native emitter
+uses its base FireRed name catalog even on Emerald, so `event.name` is not a
+portable label there. The shared fixture consumes `event.id or event.name` on
+both engines. This preserves meaningful native identity rather than claiming
+all label strings are identical. Reentrant writes from an API-3 flag event or
+native visibility callback decline with "world is busy"; reads remain usable.
+
+## Catalog provenance and verification
+
+Generated Red/Yellow/FireRed/Emerald identifier catalogs contain names/numbers
+only. No Gen1 engine implementation or ROM is vendored. Gold/Silver reuse the
+host's existing Gen2Flags table. The reference is pinned to clean
+`bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`.
+
+```sh
+luajit tests/engine/api3_flags.lua
+python3 tools/flag-catalog-check/generate.py --gen1 /clean/pinned/gen1 --lua luajit
+# Add --write to regenerate the deterministic catalogs.
+```
+
+The provenance checker verifies every generated row and all 1235 pinned Gold
+symbol mappings against the host. CI checks provenance alongside the shared
+mod suite. Eight identical public-only mod cases bring that suite to
+72 comparisons / 144 isolated loader runs; compare preflight, numeric/symbolic
+aliases, booleans, no-op event suppression, public event identity/value and
+private ownership. The reference uses actual flag backends with synthetic
+world/store setup.
+
+Local checks cover every shipped catalog alias/named numeric entry, the Gen3
+VM key conversion and all 2048 Gen2 bits, plus malformed inputs/stores,
+owner retirement, reentry and scoped visibility dispatch. These are ROM-free
+runtime/store tests, not cartridge story progression or visual gameplay tests.
diff --git a/docs/api3-invalidate-map.md b/docs/api3-invalidate-map.md
new file mode 100644
index 000000000..a815db481
--- /dev/null
+++ b/docs/api3-invalidate-map.md
@@ -0,0 +1,57 @@
+# API-3 map invalidation
+
+`mod.world:invalidateMap(mapId)` requests the host's native cache invalidation
+and reload for generations 1–3. Capture `mod.world` at entry and call later from
+an idle world. Return `true` or `nil, reason`; query availability through
+`mod:supports("world", "invalidateMap")`. Gen4/5 decline. API-1/2 behavior and
+the portable script vocabulary are unchanged.
+
+```lua
+local mod = ...
+local world = mod.world
+local function refresh(mapId)
+ return world:invalidateMap(mapId)
+end
+```
+
+A known map ID is required: a nonempty string up to 256 bytes without control
+characters. Invalid or unknown IDs return `nil, "unknown map"`, matching Gen1's
+API-3 preflight. Gen3 also resolves unprefixed names using the current version's
+FR_/EM_/RU_/SA_ prefix when no exact ID exists. Map IDs remain dataset-specific.
+
+This shares NPC/object/block mutation admission and reentry protection. A save
+and topmost idle overworld are required. Scripts, queued/parallel work, player
+movement, transitions, menus, link sessions and controller locks refuse before
+cache writes. Missing native reload support declines. Reading the facade never
+constructs Game. Rollback/reload/teardown retire retained mutation facades.
+
+The host's native reloadMap invalidates the target and resident neighbor cache
+entries. For the active map it uses setMap with seamless reload, current player
+cell/facing and keepMusic. If the retained player cell no longer fits the new
+geometry, native reload sends the player to the heal point. Runtime map/actor
+instance identity can change, so obtain fresh NPC handles after a reload.
+Inactive invalidation does not move the player or force an unrelated active
+map reload. Rendering and map lifecycle events remain native, not a new common
+renderer/cache implementation. Successful host invalidation emits map.reloaded
+with mapId and reason="invalidate". Pinned Gen1's Gen2 backend does not emit
+that event for inactive invalidation; this native difference is retained.
+
+This is a cache/instance operation, not a content-write API. Permanent map
+changes belong in content definitions. A native failure returns nil with its
+reason but may occur after cache invalidation; already entered native effects
+are not transactionally rolled back.
+
+## Verification
+
+Run `luajit tests/engine/api3_invalidate.lua` and the pinned checker documented
+in [API-3 portability](api3-portability.md). Eight identical public mod cases
+extend the full checker to 64 comparisons / 128 isolated loader runs. Real
+loaders/native facades compare preflight, active reload, position retention,
+notification and inactive-map acceptance; reload timing is a driver seam.
+
+Host tests additionally run actual OverworldState.reloadMap and
+MapLoader.invalidate against a synthetic cache, with the setMap/render entry
+instrumented. Verify target/neighbor eviction, seamless/music options, valid
+player retention, heal-point fallback, reentry, failure/guard release, busy
+worlds and retired owners. No ROM import or rendered gameplay equivalence is
+claimed.
diff --git a/docs/api3-npc.md b/docs/api3-npc.md
new file mode 100644
index 000000000..3a38e387b
--- /dev/null
+++ b/docs/api3-npc.md
@@ -0,0 +1,142 @@
+# API-3 public NPC actions
+
+Generations 1–3 support the same public creation, lookup and removal calls as
+Gen1Recomp. Check availability and retain the world facade at mod entry:
+
+```lua
+local mod = ...
+local world = mod.world
+
+local function greet(mapId, spriteId)
+ if not mod:supports("world", "spawnNpc") then return end
+ local id, why = world:spawnNpc(mapId, {
+ name = "GREETER", sprite = spriteId, x = 3, y = 4, facing = "down",
+ })
+ if not id then return nil, why end
+ local npc, reason = world:npc(mapId, id)
+ if not npc then return nil, reason end
+ return npc:scriptMove("down", 2, function()
+ npc:face("left")
+ end)
+end
+```
+
+Invoke this later from an idle world. Reading the facade never bootstraps Game.
+Gen4/5 report these three calls unavailable. API-1/2 retain their native behavior;
+this does not add portable `queueScript` verbs.
+
+## Creation, lookup and ownership
+
+- `spawnNpc(mapId, definition)` returns the native runtime ID or `nil, reason`.
+ It can add a runtime definition to a known inactive map; an actor appears when
+ the native map lifecycle instantiates it. The return value promises a recorded
+ definition, not immediate live lookup or a common lifecycle event order.
+- `npc(mapId, reference)` returns an opaque handle or `nil, reason`. References
+ are the live actor's string ID, its definition's numeric index or its name.
+ Only the active map and currently live neighboring map actors can be queried.
+- `removeNpc(id)` returns `true` or `nil, reason`. Only runtime objects owned by
+ the calling mod may be removed. Imported objects and other owners are refused.
+ A mod may still look up and control an imported live actor.
+
+Map, sprite and object identifiers remain dataset-specific. Gen3 recognizes the
+current version's `FR_`, `EM_`, `RU_` or `SA_` map prefix when an exact map ID is
+absent. IDs/names are nonempty strings of at most 256 bytes without control
+characters; numeric references are finite nonnegative integers. Use the ID
+returned by `spawnNpc` for removal.
+
+Definitions are copied before native construction. Required `x` and `y` are
+whole nonnegative walk-cell coordinates, at most 1,000,000. Optional `facing` is
+`up`, `down`, `left` or `right`; optional `name` and `sprite` are identifiers.
+A supplied sprite must exist in the active sprite registry. Native generation
+fields remain supported as data; this API does not invent a canonical sprite,
+movement or NPC definition schema. The host assigns runtime ownership and
+identity, ignoring caller-supplied ownership/index markers.
+
+The copy accepts plain acyclic tables, strings, finite numbers and booleans.
+Functions, userdata, metatables and cycles are refused before native writes.
+Limits are depth 16, 4096 visited values/keys, 256 entries per table, 4096 bytes
+per string and 64 KiB total string bytes. Refusal returns `nil, reason`.
+
+## Handles
+
+Handles expose `id` and these methods; they never expose the native actor:
+
+| Method | Result and behavior |
+| --- | --- |
+| `position()` | `x, y` in zero-based 16-pixel walk cells, in the actor's own map frame. |
+| `isMoving()` | Boolean native movement state. |
+| `canStep(direction)` | Boolean native collision permission, including the host's current seam cast for connected maps. It is a query, not a reservation. |
+| `face(direction)` | Set facing. |
+| `stepNow(direction)` | Begin one authored step; refuse an already moving actor. |
+| `placeAt(x, y, facing?)` | Place at whole nonnegative coordinates up to 1,000,000, clear in-flight movement and synchronize pixel position. |
+| `setPassable(boolean)` | Set the live actor's passability. |
+| `setAppearance(spriteId)` | Refresh a live actor using an existing sprite registry entry. |
+| `scriptMove(direction, tiles?, onDone?)` | Queue native movement, default one cell; `tiles` must be an integer from 1 to 1024. |
+| `marchInPlace(onDone?)` | Queue one native stationary march. |
+
+Directions are the four lowercase names above. Mutations return `true` or
+`nil, reason`; a stale handle returns `nil, "NPC handle is stale"`. Handles are
+bound to the original actor, map instance, Game, save, data and version. Reused
+native IDs or editing the public `id` field cannot redirect a retained handle.
+The facade follows the current Game, while old actor handles do not.
+
+Spawn, removal and handle mutations use the shared API-3 idle-world guard:
+a save and topmost overworld are required; menus, native/portable/parallel
+scripts, player movement, controller locks, transitions and link sessions refuse
+admission. Reads remain usable during overlays. Synchronous reentry into NPC or
+object/block mutations is refused. Movement tracks require the active map;
+other handle calls also work for live neighboring actors.
+
+`stepNow` and `placeAt` are authored operations, matching the native distinction
+from `canStep`: they do not automatically perform collision validation or clamp
+to map bounds. Choose coordinates and query collision as appropriate for the
+scene. Appearance changes construct a renderer before committing the actor
+change, retain its cell position and leave the native template sprite intact;
+they are not persistent content edits.
+
+## Movement and mod lifetime
+
+Completion callbacks are optional, one-shot and attributed to the initiating
+mod. Native synchronous completion is deferred until admission finishes and
+the shared mutation guard is released. Native callback arguments, including
+nil holes, are preserved. Callback errors are contained and reported once.
+
+Pending completions are permanently retired on observed Game/save/data/world,
+map, stack, version, actor or runtime-bus replacement, and on native map lifecycle
+events. The input-step boundary observes replacements before and after native
+updates. Retiring removes only the movement entries owned by that operation;
+it does not pop screens or cancel another mod's tracks.
+
+Rollback, reload and teardown retire the mod's facade and pending tracks and
+remove its recorded runtime definitions/actors, including definitions spawned
+on inactive maps and instantiated later as neighbors. Foreign runtime objects
+and imported actors remain. Controls already applied to imported actors are
+ordinary native scene changes, not a transactional patch with automatic undo.
+Retained handles/facades return `nil, "world facade is retired"` after retirement.
+
+## Verification
+
+```sh
+luajit tests/engine/api3_npc.lua
+python3 tools/api3-contract-check/check.py \
+ --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \
+ --report /tmp/api3-contract-report.json
+```
+
+The clean Gen1 reference stays pinned to
+`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same public-only NPC mod runs
+through both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal,
+FireRed and Emerald. Its eight cases extend the full checker to 48 comparisons
+and 96 isolated runs. The shared fixture compares creation/lookup, detached
+definitions, collision/passability, appearance, authored/queued movement,
+callback replay/arguments, optional callbacks and ownership-aware removal.
+
+Local tests additionally cover bounded validation, busy worlds, reentry, stale
+sessions/handles, callback errors, lifecycle cancellation and scoped cleanup.
+They exercise the host's actual NPC constructor, native runtime add/remove,
+scriptMove/marchInPlace/updateScriptMoves, NPC updates and seam collision casts.
+Synthetic datasets and a deterministic SpriteRenderer construction seam keep
+these ROM-free. The tests verify the documented shared subset, not retail-ROM
+playthroughs, GPU artwork or identical native internals/event timing. Host
+validation and lifecycle guards are intentionally stricter than some reference
+native paths.
diff --git a/docs/api3-portability.md b/docs/api3-portability.md
new file mode 100644
index 000000000..e5d44665b
--- /dev/null
+++ b/docs/api3-portability.md
@@ -0,0 +1,163 @@
+# Shared API 3 entry
+
+Use the same manifest and entry file on Gen1Recomp and Gen2Recomped:
+
+```json
+{
+ "id": "portable_example",
+ "name": "Portable Example",
+ "version": "1.0.0",
+ "api": 3,
+ "games": ["gen1", "gen2", "gen3"],
+ "entry": "main.lua"
+}
+```
+
+```lua
+local mod = ...
+if mod.content.moves:has("TACKLE") then
+ local ok, reason = mod.content.moves:patch("TACKLE", {power = 45})
+ if not ok then mod.log:warn("%s", reason) end
+end
+```
+
+Create a portable starter with:
+
+```sh
+python3 tools/modkit.py scaffold portable_example --api 3
+```
+
+It generates the shared games list and a guarded canonical move patch. It
+omits an engine-release range because Gen1 and Gen2 have independent engine
+version numbers. The default scaffold remains API 2 with its existing range
+and profile templates. The `--api` value is now honored instead of silently
+emitting API 2 for every value.
+
+API 3 automatically selects unified content, callbacks and portable scripts.
+No `content_format`, `callback_format` or `script_format` fields are needed.
+An explicit `"native"` for one of those fields is an error under API 3;
+`"unified"` is accepted. API 1/2 retain native defaults, and API 2 retains
+its existing individual opt-ins. The engine advertises `Version.modApi = 3`.
+A higher unknown API remains a load error.
+
+This is a common entry and a tested compatibility subset, not a claim that
+all of Gen1Recomp's services have been ported. It builds on PRs #90–#99.
+The existing unified-content limitations still apply: unsupported writes
+return `nil, reason` before recording an operation, native diagnostics are
+read-only, and ordinary schema errors raise an attributed load error.
+Entry failure rolls back the mod's content and subscriptions. Content is
+frozen after loading.
+
+## Public contracts
+
+Core `pokemon`, `moves`, `items`, `trainers` and `encounters` handles use the
+existing unified adapters. Other registries retain their native record
+shapes, rather than inventing canonical fields. Every content handle exposes
+`has(id)`, `supports()` and `available`. API-3 passthrough reads and iteration
+return detached values too; change data through the write methods.
+
+The API-3 Pokemon boundary uses short statuses (`SLP`, `PSN`, `BRN`, `FRZ`,
+`PAR`, `TOX`), nickname-first display names and a common Special alias.
+Writing `stats.special` in a writable callback sets both later Special stats;
+contradictory split values are rejected before commit. API-2 unified status
+names and callback behavior are unchanged. DVs/shiny presentation is read-only
+in the callback boundary. Party and battle reads remain detached snapshots.
+
+Capture `mod.world` and `mod.battle` at entry time. World methods can be called
+before Game exists and return `nil, reason`; reading the facade never boots
+Game. The same world handle follows the loader's current Game/save instead of
+retaining an earlier one. Battle snapshots and intents retain their existing
+revision/replay validation.
+
+The common world calls currently enabled for generations 1–3 are:
+
+- `overworld`, `current`, `party`, `activeBlockAt`, `liveMaps`, `mapOverview`,
+ `effectiveEncounters`;
+- `canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`,
+ `canFly`, `flyTo`;
+- [`getFlag`/`setFlag`](api3-flags.md) for private and validated native flags;
+- `queueScript` for the existing bounded portable script language;
+- [`warpTo`](api3-warp.md) for validated native map transitions;
+- [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native
+ visibility and active block/metatile edits;
+- [`spawnNpc`, `removeNpc` and `npc`](api3-npc.md) for runtime NPC creation,
+ ownership-aware removal and opaque live actor controls;
+- [`startWildBattle`](api3-wild-battle.md) for validated native encounters;
+- [`invalidateMap`](api3-invalidate-map.md) for guarded native cache/reload actions.
+
+Check `mod:supports("world", name)` before relying on a call. All 24 world
+method names in the pinned Gen1 API-3 facade now have host adapters for Gen1–3.
+This describes API availability, not identical native internals or full
+cross-engine behavioral coverage. Native flags have version-specific validated mappings; [`mod.datasets:open`](version-datasets.md) supplies read-only, validated views for imported Gen1–3 versions. Gen4/5 do not claim this
+shared world surface.
+
+Private flags use `save.modData[modId].worldFlags`, independently of native
+flags and other mods. A read does not create a bucket. Clearing removes the
+stored key. A change emits `flag.changed` with
+`name = "mod.."` and the boolean value; setting the same value
+emits nothing. Invalid save stores are refused without overwriting them.
+
+Gen1 API-3 callback names from the pinned reference are recognized even when
+this engine has no call site for them. Registration is allowed; availability
+still requires a native call site in the selected version. For example,
+`render.output` can be subscribed to but reports unavailable here. Unknown
+names remain errors. An accepted subscription is not proof that a feature
+runs. Source-specific engine handles in lifecycle/UI payloads remain native.
+
+For scripts, use `mod:supports("scripts", verb)` to test Gen2Recomped's current
+bounded vocabulary. A `queueScript` method does not imply support for every
+Gen1 script verb. See [portable world scripts](portable-world-scripts.md).
+
+## Reproducible contract checks
+
+```sh
+luajit tests/engine/api3_entry.lua
+luajit tests/engine/api3_compat.lua
+python3 tools/api3-contract-check/check.py \
+ --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \
+ --report /tmp/api3-contract-report.json
+```
+
+The external reference must be a clean checkout at
+`bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`.
+Use a separate checkout; do not reset a working installation. No Gen1 engine
+implementation is vendored into this repository. Only public callback names
+are recorded as a compatibility catalog.
+
+The exact same manifest and mod entry run on both engines for Red, Blue,
+Yellow, Gold, Silver, Crystal, FireRed and Emerald, in both success and
+intentional-rollback cases. Each engine/version/case has its own LuaJIT
+process. The shared harness constructs native-shaped synthetic datasets;
+the mod fixtures contain no engine imports or engine-specific branches.
+The runner also generates an API-3 mod through the real scaffold CLI and
+loads those same generated files on both engines for each version. This adds
+eight comparisons to the sixteen normal/rollback cases. Eight additional warp
+cases use the same public mod on both engines and native-shaped timing seams.
+Eight object/block cases and eight NPC cases extend this to 200 comparisons
+and 400 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and
+records hashes of both shared and generated fixture files. It refuses a different or modified reference.
+
+Coverage includes canonical content reads/writes/iteration and native merge
+results, detached core and passthrough reads, callable early handles, later
+Game replacement, party fields, private flags, Gen1 battle snapshot/invalid-intent boundaries, callback payloads and native
+writeback, custom events, unsupported-name handling, frozen content and failed
+owner cleanup. Warp cases also compare validation errors, native dispatch
+arguments, destination events, boundary coordinates, arrival options, callback
+argument holes/replay, stale sessions/owners, synchronous completion and error
+attribution. Object/block cases compare native reference routing, visibility/flag
+changes, block geometry/bounds, native reads and one event per successful edit.
+NPC cases compare runtime creation/lookup, movement/appearance, optional
+completion callbacks and ownership-aware removal using the same public handles.
+Separate Gen2 tests check API-1/2 behavior, contradictory
+manifest fields, honest availability and malformed flag stores. CI pins the
+same external commit and uploads the report.
+
+These are ROM-free loader/runtime-boundary tests, not retail-ROM gameplay
+or a proof of every exposed native callback's payload. Native action details
+remain covered by the prerequisite PR suites. Extend these shared fixtures
+whenever another API-3 service becomes portable; do not make a failing
+comparison pass by changing only one engine's mod fixture.
+
+Final composed-frame ownership is documented in [render-output.md](render-output.md).
+
+Viewport reservation, window composition and pointer coordinates are documented in [game-viewport.md](game-viewport.md).
diff --git a/docs/api3-warp.md b/docs/api3-warp.md
new file mode 100644
index 000000000..6e4c1f055
--- /dev/null
+++ b/docs/api3-warp.md
@@ -0,0 +1,130 @@
+# Portable API-3 world warps
+
+API-3 mods can use the same public call on Gen1Recomp and Gen2Recomped:
+
+```lua
+local mod = ...
+local world = mod.world -- Safe to retain at entry; does not create Game.
+
+-- Call later, from an idle overworld interaction/update.
+local function goToDestination(mapId, cellX, cellY)
+ if not mod:supports("world", "warpTo") then return end
+ local accepted, reason = world:warpTo(mapId, cellX, cellY, "down", {
+ keepMusic = true,
+ onDone = function()
+ mod.log:info("Arrived")
+ end,
+ })
+ if not accepted then mod.log:warn("Warp declined: %s", reason) end
+end
+```
+
+`warpTo(mapId, x, y, facing, options)` returns `true` after native admission,
+or `nil, reason`. Admission does not mean arrival. It uses the existing native
+`OverworldState:startWarpTo` transition, map-entry events, spawn bookkeeping
+and arrival-tile guard. No direct player/save relocation bypasses that path.
+This API-3 adapter does not change API-1/2 facade defaults or validation.
+Generations 1–3 advertise support; API 3 still refuses this world operation on
+Gen4/5. Availability is a capability, not a promise that the current world is
+idle or the named map exists. Reading the facade or querying support does not
+bootstrap Game.
+
+## Admission and coordinates
+
+The current Game must have a save and a live, topmost overworld. Screens,
+native/portable scripts, queued/parallel scripts, movement, transitions,
+field/controller locks, link sessions and another pending API-3 warp decline.
+Gen2Recomped reuses its existing world-action admission guard and checks its
+additional native foreground owners. Idle-state details differ between the
+engines; both keep their own native readiness checks.
+
+Map IDs are nonempty strings of at most 256 bytes, without control characters.
+The map must exist in the current merged `game.data.maps`; custom merged maps
+are supported. Gen3 also accepts unprefixed names when the current version's
+prefixed map exists (`FR_`, `EM_`, `RU_`, `SA_`). An exact ID wins over an alias.
+There is no cross-game map-name translation: the mod selects a real map in
+the active dataset.
+
+`x` and `y` are finite, nonnegative whole numbers in zero-based **16-pixel
+walk cells**. Gen1/2 dimensions are twice the map's block dimensions; Gen3
+uses the map's metatile dimensions directly. The final valid cell is
+`widthInCells - 1, heightInCells - 1`. Invalid/missing map dimensions refuse
+before dispatch. Coordinates are bounded, not clamped; terrain passability is
+not part of this contract.
+
+Facing is `up`, `down`, `left` or `right`. Omitted facing follows Gen1's common
+contract: `down` on generation 1, current player facing on generations 2/3,
+with `down` as fallback. Explicit facing is recommended for portable scenes.
+
+Options must be a plain table. Only these fields are accepted:
+
+| Field | Meaning |
+| --- | --- |
+| `arrive` | `"fly"` or `"teleport"`, using native arrival presentation |
+| `keepMusic` | Boolean; forwarded to native map loading |
+| `onDone` | Function, called at most once after accepted native completion |
+| `fade` | Boolean, generation 3 only; false uses the same native midpoint/completion without pushing a fade screen |
+
+Unknown options, invalid types and unsupported `fade` use decline the whole
+request before native dispatch. Options are copied; later caller edits cannot
+change an accepted request. Invalid admission does not set arrival effects,
+change maps/coordinates/saves, raise map-entry events or call `onDone`.
+Representative reasons include `no game`, `no overworld`, `no save`,
+`world is busy`, `invalid map id`, `unknown map: `, `invalid coordinates`,
+`coordinates out of bounds`, `invalid facing`, `invalid options`,
+`invalid arrival`, `invalid callback`, `unsupported fade` and
+`unknown warp option: `. These are admission failures, not exceptions.
+Native failures after dispatch can already have native effects; there is no
+transactional rollback of an entered transition.
+
+## Callback ownership
+
+`onDone` runs only for an accepted request whose Game, save, data, stack,
+overworld, player, version, loader buses and mod lease are still current, at
+the requested destination. It preserves the native callback arguments,
+including nil holes. Synchronous native completion is held until the native
+return confirms admission. A rejected/throwing native dispatch cannot invoke
+the mod callback, and duplicate native completions are ignored.
+
+A replaced session/owner observed at input-step boundaries, another map entry
+or leaving the destination retires the completion permanently. Returning to an
+older session cannot revive a completion already retired. Loader rollback,
+reload and teardown retire the facade and its pending callback; a retained
+facade returns `nil, "warp facade is retired"`. Retirement does not pop screens
+or undo work belonging to the native controller.
+
+The callback is attributed to its mod, errors enter the loader's runtime error
+list once, and the previous runtime owner is restored. An error never retries
+a warp or the callback. A completion may start a new request after the native
+world becomes idle. The adapter adds no synthetic map-entry event; normal
+native map lifecycle remains in control.
+
+The portable `queueScript` language is unchanged: adding this direct method
+does not enable a `warp` script row.
+
+## Verification
+
+```sh
+luajit tests/engine/api3_warp.lua
+python3 tools/api3-contract-check/check.py \
+ --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \
+ --report /tmp/api3-contract-report.json
+```
+
+The existing Gen1 pin remains
+`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same manifest/entry is loaded by
+both real loaders for Red, Blue, Yellow, Gold, Silver, Crystal, FireRed and
+Emerald. The shared driver builds each engine's native-shaped Game/world data
+and replaces only native transition timing seams. It compares validation
+reasons, dispatch arguments, public destination events and callback results.
+The mod has no engine imports or backend-specific branches. Together with the
+existing normal, rollback, generated-starter and object/block cases, CI now
+performs 48 comparisons / 96 isolated loader runs.
+
+The local suite additionally drives Gen2Recomped's real native
+`startWarpTo` and `Transition` using synthetic map materialization, including
+fade/no-fade, music options, native bookkeeping, lock release and one-shot
+completion. Further cases cover host-specific locks, malformed maps, callback
+reentry, native refusal/exception, lease retirement and API-2 behavior. These
+are ROM-free runtime-boundary tests; no retail-ROM playthrough or rendered
+arrival-effect equivalence is claimed.
diff --git a/docs/api3-wild-battle.md b/docs/api3-wild-battle.md
new file mode 100644
index 000000000..d6896e2f5
--- /dev/null
+++ b/docs/api3-wild-battle.md
@@ -0,0 +1,66 @@
+# API-3 wild encounters
+
+`mod.world:startWildBattle(species, level, onDone?)` starts a native wild
+encounter for generations 1–3. Check `mod:supports("world", "startWildBattle")`.
+Capture `mod.world` at entry and call later from an idle world. Gen4/5 decline;
+API-1/2 behavior and the portable script vocabulary remain unchanged.
+
+```lua
+local mod = ...
+local world = mod.world
+local function encounter()
+ return world:startWildBattle("MEW", 30, function(result)
+ mod.log:info("Encounter ended: %s", tostring(result))
+ end)
+end
+```
+
+The call returns `true` for admission or `nil, reason`. Species must be a
+nonempty identifier of at most 256 bytes without control characters; resolve
+it through the active dataset's canonical species index. Unknown/ambiguous
+species are refused. Level must be a number, a whole integer from 1 to 100;
+strings are not coerced. The optional callback must be a function.
+
+A current save, a healthy non-egg party member and the topmost idle overworld
+are required. Menus, battles/transitions, scripts/parallel scripts, movement,
+link sessions, controller locks and reentry decline before native construction.
+Refused arguments do not consume constructor RNG or mark the species seen.
+Reading the facade/early calls never bootstrap Game.
+
+Use the existing native BattleState constructor, overworld pushBattle and
+post-battle afterBattle path. This preserves native enemy creation, entry
+presentation/music and the usual evolution/blackout handling. Do not substitute
+a hand-built battle screen. The host can still refuse or throw after native
+construction; already entered native effects are not a transactional patch.
+
+Completion is once per battle, after native afterBattle has run. Native result
+arguments, including nil holes, reach the optional callback. Synchronous native
+completion is deferred until admission/guard release; rejected admission never
+calls it. Callback errors are contained and attributed once. A completion may
+start a later action subject to its normal idle-world checks.
+
+Captured Game/save/data/stack/world/player/version/bus and owner identities are
+revalidated. Observed session changes permanently retire pending mod callbacks;
+input.step observes them before and after the native update. Rollback, reload
+and teardown retire the owner's callback/facade. An already admitted native
+battle is not removed when a mod unloads: its native cleanup still runs if the
+original game/session remains current. A late result cannot run cleanup against
+a replaced session. Native cleanup failure is reported and suppresses the mod
+callback; duplicate completion does not repeat native cleanup.
+
+## Verification
+
+Run `luajit tests/engine/api3_wild_battle.lua` and the pinned cross-engine checker
+from [API-3 portability](api3-portability.md). The identical public fixture runs
+on both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal,
+FireRed and Emerald, extending the checker to 56 comparisons / 112 isolated
+runs. It covers early calls, invalid arguments, healthy-party checks, native
+admission, busy-world refusal and synchronous/asynchronous callback results.
+Native battle construction/entry/timing are seams in the shared driver.
+
+Local tests also exercise actual host BattleState/Pokemon construction and
+Pokedex seen updates on synthetic datasets, session replacement/restore,
+mod rollback/reload/unload, native failure, callback replay/errors and guard
+release. Transition and afterBattle entry are instrumented seams; these are
+ROM-free runtime boundary tests, not a retail-ROM battle/evolution/blackout
+playthrough or rendered transition equivalence.
diff --git a/docs/api3-world-edits.md b/docs/api3-world-edits.md
new file mode 100644
index 000000000..1e66a6fa1
--- /dev/null
+++ b/docs/api3-world-edits.md
@@ -0,0 +1,124 @@
+# API-3 object visibility and block edits
+
+API-3 mods can call the same public methods on both engines:
+
+```lua
+local mod = ...
+local world = mod.world -- Retain at entry; invoke later from an idle world.
+
+local function openGate(mapId, objectRef, blockX, blockY, openBlock)
+ if not mod:supports("world", "toggleObject") then return end
+ local ok, why = world:toggleObject(mapId, objectRef, false)
+ if not ok then return nil, why end
+ if mod:supports("world", "replaceBlock") then
+ return world:replaceBlock(blockX, blockY, openBlock)
+ end
+end
+```
+
+Each call returns `true` or `nil, reason`. Calls are independent, not an atomic
+multi-command transaction. Support is advertised for generations 1–3; API-3
+Gen4/5 still refuse both methods. A captured facade follows the current Game
+without bootstrapping one. Rollback, reload and teardown retire its mutation
+lease; retained calls return `nil, "world facade is retired"`. API-1/2 behavior
+and the portable script vocabulary are unchanged.
+
+Both methods require a save and an idle, topmost overworld. Native scripts,
+portable scripts, queued/parallel scripts, menus, movement, transitions,
+controller locks and link sessions refuse admission. Reentrant object/block
+edits from an edit's own event/native callback return `nil, "world is busy"`;
+the guard is released on success and native exceptions.
+
+## `toggleObject(mapId, objectRef, visible)`
+
+`mapId` and string references are nonempty strings of at most 256 bytes without
+control characters. Numeric references must be finite nonnegative integers;
+`visible` must be an actual boolean. The map must exist in the active merged
+dataset. Gen3 also resolves unprefixed map names using the current version's
+prefix when an exact ID is absent, as `warpTo` does.
+
+Reference routing and persistence deliberately retain the Gen1 API-3/native
+contracts, rather than treating all generations' object numbers as identical:
+
+| Generation | Reference | Scope and effects |
+| --- | --- | --- |
+| 1 | Native visibility key, normally the object's `name`; numeric keys are also accepted | May name an inactive map or a key with no currently defined actor. Writes `save.objectToggles`. An active map refresh uses the native seamless reload, retaining player cell/facing and music. |
+| 2 | 1-based position in `map.def.objects`, or its `name` | Active map only. Resolves the public slot to its native template and uses the same effect helper as native appear/disappear. Writes the object's event flag and visit-scoped visibility, synchronizing only that actor. |
+| 3 | Native local object ID, `name` or `id` | Active map only. Uses native add/remove commands, including event flags, spawned-object state and flagless visit state; synchronizes only the selected actor. |
+
+This API does not make map/object IDs portable between cartridges: select a
+reference from the active dataset. On Gen2/3, an unknown reference returns
+`nil, "no such object: "`; an inactive map returns
+`nil, "map is not active: "`. Missing object lists and malformed native
+object/store metadata refuse before native writes. Gen1's key store keeps its
+existing ability to prepare a later map's visibility.
+
+Flagged Gen2/3 objects use their native persistent flags. Flagless objects
+retain native visit-scoped behavior. A targeted sync does not respawn, relocate
+or re-derive unrelated live actors, even when several templates share a flag.
+Other native visibility gates (time, story flags, script-spawn rules) still
+apply; acceptance is not a promise to override every native gate.
+
+A successful call emits one `world.object_toggled` event with
+`mapId`, `objName = objectRef` and the boolean `visible`. Repeating an accepted
+value still follows the native call/event contract. Gen1's active refresh may
+also produce its normal native map lifecycle events. Gen2/3 do not reload the
+whole map for this operation.
+
+## `replaceBlock(blockX, blockY, blockId)`
+
+This changes only the active map. Coordinates are zero-based whole **block**
+coordinates, with `0 <= x < map.width` and `0 <= y < map.height`:
+
+| Generation | One coordinate unit | Valid ID range |
+| --- | --- | --- |
+| 1/2 | 32-pixel block, 2×2 walk cells | 0–255, and the block must exist in the active tileset |
+| 3 | 16-pixel metatile, one walk cell | 0–1023 |
+
+Coordinates/IDs must be finite integers. Gen3 range validation does not promise
+that every ID has artwork in a particular tileset. The adapter checks the
+current block, runtime geometry and redraw entry before mutation. Unknown
+blocks, unavailable geometry/renderers, fractions, NaN/infinity, negatives and
+out-of-bounds coordinates are declined without writing a patch or emitting an
+event. Typical reasons are `invalid block id`, `unknown block id`,
+`invalid block coordinates`, `block coordinates out of bounds`,
+`block unavailable`, `unsupported block geometry` and
+`block renderer unavailable`.
+
+Gen1/2 use the existing native world block replacement. Gen3 uses the host's
+native metatile command with its default non-impassable setting, including
+clearing old collision/shut-cell overrides, and redraws the runtime map.
+Terrain/collision tables remain native; there is no invented shared collision
+registry. `activeBlockAt` immediately sees the change. One successful operation
+emits one `world.block_replaced` event with `mapId`, `bx`, `by` and `block`.
+
+On Gen2Recomped this is an instance patch, leaving generated map data intact.
+It is not a persistent content edit: permanent layout changes belong in the
+map registry. Native execution failures can occur after a native write and do
+not promise transactional rollback; preflight refusals have no such effects.
+
+## Verification
+
+```sh
+luajit tests/engine/api3_world_edits.lua
+python3 tools/api3-contract-check/check.py \
+ --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \
+ --report /tmp/api3-contract-report.json
+```
+
+The existing Gen1 pin stays at
+`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same new fixture manifest/source
+runs under both production loaders for Red, Blue, Yellow, Gold, Silver,
+Crystal, FireRed and Emerald. Engine-specific setup stays in the shared
+driver, never in the mod. Native-shaped map/render/object seams let the driver
+compare refusal reasons, visibility/flag effects, native block reads, geometry
+and event payloads. The full checker now runs 48 comparisons / 96 isolated
+processes, including all previous entry, rollback, generated-starter and warp
+cases.
+
+Local tests additionally exercise the actual Gen2Recomped native Map,
+OverworldState block replacement and object synchronization with synthetic
+pooled actors, including shared flags, retained positions, flagless resets,
+packed metatiles, collision clearing, malformed stores, reentrancy and retired
+owners. These are ROM-free runtime-boundary tests; they do not establish
+rendered retail-ROM equivalence or full scene-playthrough coverage.
diff --git a/docs/battle-actions-api.md b/docs/battle-actions-api.md
new file mode 100644
index 000000000..f0e232773
--- /dev/null
+++ b/docs/battle-actions-api.md
@@ -0,0 +1,96 @@
+# Battle action API
+
+The action extension builds on `mod.battle:snapshot()` and is available to
+API 1 and API 2 mods on the shared Gen 1–5 battle engine. No manifest opt-in
+is required. If capability queries are installed,
+`mod:supports("battle", "submit")` reports that this interface exists. It does
+not promise the current battle accepts a particular command.
+
+```lua
+local nextIntentId = 1 -- keep increasing for this captured facade
+local snapshot = mod.battle:snapshot()
+if snapshot and snapshot.prompt == "menu" then
+ local ok, reason = mod.battle:submit({
+ id = nextIntentId,
+ revision = snapshot.revision,
+ kind = "menu",
+ choice = "fight",
+ })
+ nextIntentId = nextIntentId + 1
+ if not ok then mod.log:info("Battle command refused: %s", reason) end
+end
+```
+
+Always fetch a fresh snapshot before the next command. Do not hold a revision
+across a frame, other input, a native callback, another mod's command, a screen
+change or a Game/battle replacement. The engine rechecks the live context;
+returning to an apparently identical screen does not revive an old command.
+
+## Intent records
+
+Intents must be plain tables without a metatable. Every record contains
+`id`, `revision`, `kind` and only the fields listed below. Integers must be
+finite; `id` and `revision` are positive safe integers up to 2^53−1. Extra
+fields, unknown kinds, strings in numeric fields and missing arguments are
+rejected. The facade copies accepted input fields before calling native code.
+
+| `kind` | Extra field | Meaning |
+| --- | --- | --- |
+| `menu` | `choice`: fight / party / item / run | Select an action on the uncovered regular battle menu |
+| `move` | `slot`: 1–4 | Select a current battle move; may enter the native target selector |
+| `target` | `target`: 0–3 | Select a currently offered, occupied positional battler ID |
+| `back` | none | Cancel move selection/reorder or targeting; in a double's second action menu undo the first choice when native B permits it |
+| `safari` | `action`: ball / bait / rock / run | Select a native Safari command; `rock` is Platinum's mud button |
+| `mimic` | `index`: 1–4 | Select a current Mimic **option index**, not the source move slot |
+
+Positions are 0 player-left, 1 opponent-left, 2 player-right, 3 opponent-right.
+These are not indices into `snapshot.battlers` or `snapshot.targets`. Target
+choices are checked again against the native selector and live field. A
+pending move must still be the same current move and still pass move checks.
+
+`move` uses the actual chooser, including the right flank and Transform's
+current move list. Native Disable, PP, Taunt, Torment and BW Choice/Imprison/
+Gravity/Heal Block restrictions are checked before accepting the request.
+Mimic uses its queued user, including a right-flank user during turn resolution,
+and preserves the native copy/restoration bookkeeping.
+
+`menu` opens the existing bag/party flow. It does not select a party member,
+use an inventory item or throw a normal bag ball directly. Those screens keep
+their own controller. Native forced FIGHT/Struggle, ghost refusals, trainer
+RUN refusals and bug-contest item behavior are preserved. A valid menu choice
+can therefore be accepted and produce a native refusal message.
+
+## Context, replay and errors
+
+`submit(intent)` returns `true` when admitted and dispatched; otherwise
+`nil, reason`. Accepted means the native command path ran, not that a turn
+succeeded, a move hit, a ball caught or a battle finished.
+
+An intent must refer to a snapshot actually returned by this facade and still
+be current. Each facade owns its own monotonically increasing ID sequence;
+IDs survive battle and Game changes. Once native dispatch starts, that ID is
+consumed. Equal or lower IDs are refused as replayed. Skipping IDs is allowed.
+A rejection before dispatch does not consume its ID; correct the problem and
+read a new snapshot before retrying.
+
+All public facades on one Game share an in-progress guard and an invalidation
+epoch outside gameplay/save state. Another mod or a callback cannot submit
+recursively during validation or execution. Snapshots observed during execution
+are locked and cannot authorize a command after it finishes. Accepted calls
+invalidate other mods' old snapshots even if exposed data happens to stay the
+same. These counters are local process bookkeeping, not link/save data.
+
+Native errors are caught, reported and clear the guard. **An entered native
+call is not retried or rolled back.** Its ID stays consumed even if native
+code throws after a partial effect or returns a refusal. Fetch the current
+snapshot and use a new ID for any deliberate next action. Validation errors
+also clear the guard. Errors do not automatically drive subsequent frames or
+drain the turn queue.
+
+No battle, covered menus, mismatched phases, finished battles, fainted/NPC
+choosers, automatic locks, stale targets, demos/tutorials, link/spectator,
+Palace and Arena controllers are refused. This API does not advance text,
+drive a covered party/bag/choice screen, perform a forced replacement, or
+bypass a script/controller. Ordinary keyboard/touch input continues through
+the same extracted native dispatch methods and retains its existing messages,
+queueing, RNG, targeting and per-version behavior.
diff --git a/docs/battle-snapshot-api.md b/docs/battle-snapshot-api.md
new file mode 100644
index 000000000..e6e1cfa3c
--- /dev/null
+++ b/docs/battle-snapshot-api.md
@@ -0,0 +1,126 @@
+# Battle snapshot API
+
+```lua
+local battle = mod.battle -- safe in the mod entry chunk, before Game exists
+local snapshot, reason = battle:snapshot()
+if snapshot then
+ local actor = snapshot.player
+ if actor then print(actor.name, actor.hp, actor.maxHp) end
+end
+```
+
+Available to API 1 and API 2 mods on all supported Gen 1–5 versions. Neither
+`content_format` nor `callback_format` is required. This reads the shared
+BattleState in the current Game's stack, including beneath another screen.
+A read before Game is loaded also returns `nil, "no battle"` without
+bootstrapping Game. Outside battle it returns `nil, "no battle"`. A UI screen merely holding a
+`.battle` reference does not count as another battle.
+
+If the capability-query extension is installed,
+`mod:supports("battle", "snapshot")` returns true without constructing Game.
+When the [battle action extension](battle-actions-api.md) is installed,
+`mod:supports("battle", "submit")` also returns true. Other battle capabilities
+return false with a reason.
+
+## Records
+
+All returned tables, including nested records and `native` diagnostics, are
+copies. They carry no engine objects, functions, userdata, threads or
+metatables. Unknown native Pokemon extension fields are copied as data;
+cycles are preserved. These diagnostics are not a portable write format or
+necessarily JSON-serializable. Changing a snapshot never changes battle/save
+state or future reads.
+
+| Field | Meaning |
+| --- | --- |
+| `revision` | Positive, monotonically increasing within this facade; see below |
+| `kind`, `phase`, `result` | Battle kind (including ghost/safari/oldman variants), native phase and optional outcome |
+| `turn` | Native resolved-turn count; zero before the first turn |
+| `double` | Whether this battle is configured as a double |
+| `active` | Player position the menu currently selects for (0 or 2) |
+| `player` | Occupied battler at `active`, or nil |
+| `enemy` | Occupied opponent-left battler (1), or nil; use `battlers` for all foes |
+| `battlers` | Array of occupied battler records in position order |
+| `party` | Battle-specific player party when supplied (link copies), otherwise save party order, with one-based `slot` and `active` |
+| `moves` | Current actor's battle moves, including Transform/Mimic overrides |
+| `items` | Positive inventory quantities; acquisition order, then sorted unlisted IDs |
+| `catchable` | Wild battle, one living foe, no ghost/noCatch/result restriction |
+| `prompt` | Description of the battle screen's input phase; see below |
+| `message` | Optional array of currently revealed text lines; unknown glyphs leave it unavailable |
+| `messageSource` | Optional current message source text, which can include unrevealed text |
+| `safariBalls` | Optional native remaining Safari ball count |
+| `menuIndex`, `moveIndex`, `moveSwapIndex` | Optional one-based native menu/move/reorder cursors |
+| `targets` | During targetSelect, ordered positional IDs from the native selector; otherwise empty |
+| `targetIndex` | Optional one-based cursor into `targets` |
+| `mimicMoves`, `mimicIndex` | Optional Mimic options and one-based cursor |
+
+Battler IDs are **0 player-left, 1 opponent-left, 2 player-right, 3
+opponent-right**. Array indices are not battler IDs. Empty positions are
+omitted; an enemy at 3 never masquerades as enemy-left. `player` follows the
+second player chooser, including after the left flank leaves the field.
+
+Pokemon records expose `species`, optional `item`, `nickname`, `name`,
+`level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, and copied `native`
+diagnostics. Species/move/item IDs use canonical string names from this boot's
+registries. Numeric DS IDs resolve through the imported names and aliases;
+unresolved IDs use their string form. Pristine IDs remain stable across
+presentation-name patches; definitions registered by later mods are visible.
+Status codes SLP/PSN/BRN/FRZ/PAR/TOX become
+sleep/poison/burn/freeze/paralyze/toxic; unknown values are retained.
+
+Stats use `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`,
+`specialDefense`; Gen 1's special supplies both split stats. `maxHp` comes
+from stored `stats.hp`. HP and status are logical state, not interpolated HUD
+values. Battlers additionally expose `id`, `side` (player/enemy), `active`,
+`absent` (false for occupied rows), `ally`, optional one-based `partyIndex`,
+`displaySpecies`, `battleStats`, `types`, and `stages`. Original species and
+stored stats stay separate from Transform's displayed species/current stats.
+NPC allies are not added to the player's party.
+
+Moves expose one-based `slot`, portable `id`, optional `name`, `pp`, `maxPp`,
+`ppUps`, `type`, `power`, `accuracy`. Max PP follows the native base PP plus
+clamped 0–3 PP Ups, each adding floor(base PP / 5). Definition fields are base
+values, not damage/accuracy predictions. Only the top-level actor `moves`
+also expose `disabled`: true for Disable, no PP, Taunt or Torment; false if
+none applies on Gen 1–4. In BW this field is omitted unless a common
+restriction is known, because additional native Choice/Imprison/Gravity/
+Heal Block controllers may refuse the move. Nil is unknown, not false.
+Mimic options retain their original move `slot` even if options are compacted.
+
+Items expose `id`, optional `name`, `count`, `ball`, `catchable`, optional
+`pocket`. This is an **inventory view**, not a list of currently usable items;
+it can include badges/key items. Ball classification follows imported ball
+metadata. `catchable` describes the target restriction, not permission to use
+an item: noItems/frontier rules, party capacity and the active bag controller
+still own item eligibility. No `needsTarget`, catchChance, hitChance,
+displayPower, effectiveness or guessed preview is produced.
+
+## Prompts and revisions
+
+`prompt` is `menu`, `moves`, `target`, `mimic`, `safari`, `advance`, or
+`locked`. Intro, automatic demos/Palace, conservative Arena menu handling,
+known automatic battler locks and every covered battle are locked. Message
+advance is reported only when native manual text waits have finished their
+pre-advance delay. Party, bag and choice screens above battle stay locked;
+this API does not inspect or drive those screens. Link controllers remain locked.
+Visible messages decode the native revealed glyph buffer through the imported
+charmap and already-loaded Unicode glyph map; this never initializes a font. A prompt is descriptive,
+not an action-permission token. The [battle action extension](battle-actions-api.md) supplies separately validated
+submission; the snapshot's descriptive prompt is not its permission check.
+
+Identical successive snapshot records and action contexts retain their revision.
+The action extension also tracks native controller/selector identity and private
+control flags, so replacement objects can invalidate an otherwise identical
+record. Accepted public actions invalidate all facades on that Game, including
+snapshots observed during native callbacks. A change to
+any exposed data advances it once at the next read; animation clocks and
+interpolated HP alone do not. A different battle or Game also advances it,
+even with identical records. Observing `no battle` invalidates the previous
+record, so re-entry advances it. Unobserved transitions between reads cannot
+be detected if the same Game and battle objects are reused. Each mod has its
+own counter; revisions are not save data or globally synchronized IDs.
+
+Reading constructs no Pokemon or screen, loads no gameplay/graphics module,
+executes no action, emits no event/hook and consumes no RNG. Only facade
+bookkeeping and loader-local ID indexes change. The API provides an observer
+boundary, not hidden-information filtering or a write-back transaction.
diff --git a/docs/game-viewport.md b/docs/game-viewport.md
new file mode 100644
index 000000000..83d8fcc8d
--- /dev/null
+++ b/docs/game-viewport.md
@@ -0,0 +1,65 @@
+# Game viewport and final window composition
+
+API-3 layout mods can reserve an OS-window rectangle for the normally rendered
+Gen1–3 game, then draw their surrounding UI through a shared window hook:
+
+```lua
+mod.hooks:wrap("render.viewport", function(next, metrics)
+ return { x = 0, y = 0, width = metrics.width / 2, height = metrics.height }
+end)
+mod.hooks:wrap("render.window", function(next, game, ctx)
+ -- Draw window-space chrome using the mod's graphics service.
+ return next(game, ctx)
+end)
+```
+
+Viewport metrics are `{ width, height, pixelWidth, pixelHeight, dpiX, dpiY,
+generation }`. Rectangle `x`, `y`, `width`, `height` are LÖVE units. Finite
+values are floored and clamped to the real window, with at least one unit in
+each axis. A plain rectangle table is required; unexpected fields are ignored.
+Returning the whole window preserves the direct path. `capture=true` requests
+a separate target even at full size. No canvas is allocated without capture or
+a changed rectangle. Changing position reuses a same-size target; resizing,
+disabling and retiring a view release its old canvas. Failed allocations fall
+back to the ordinary full game and retry after a changed request.
+
+The normal Renderer uses the rectangle's own dimensions, pixel dimensions and
+DPI. World/UI composition, HUD and final-output owners draw inside it.
+`render.window(game, ctx)` then receives `{ canvas, x, y, width, height,
+windowWidth, windowHeight, dpiX, dpiY, generation }`. Its default draws the
+borrowed game canvas at `x,y`; the OS window starts black. The hook runs only
+when a viewport target is active. It follows ordinary hook/next semantics,
+without a separate boolean ownership result. Mobile controls draw afterward
+in OS-window coordinates, at full size.
+
+Both hooks fence GPU state. The default window draw also fences its own state
+so a failed outer hook cannot redirect it to another canvas or shader.
+Invalid downstream contexts fall back to the original frame. A reset during
+composition defers target release until the frame finishes. Mod reload,
+launcher teardown and renderer reinitialization clear viewport state.
+Gen4/5 and native combined-screen layouts keep their existing display path;
+`mod:supports("hooks", "render.viewport")`/`"render.window"` report only Gen1–3.
+
+The existing touch/mouse-as-touch route now supplies `input.pointer` with
+`{ phase, source, id, x, y, gameX, gameY, insideGame, dx, dy, pressure, button }`.
+Phases are `pressed`, `moved`, `released`. Window `x,y` remain unchanged;
+`gameX,gameY` subtract the viewport origin, and `insideGame` tests its bounds.
+Unspecified movement deltas derive from the last point. Real touch pressure
+is forwarded. This is the existing main-window press/drag route, not a new
+hover, wheel or multitouch input system.
+
+Virtual controls keep their complete gesture first. Mods can claim other
+pointer gestures by returning exactly `true`. Once a native screen has
+captured a gesture, a later mod movement result cannot steal its release.
+Native game-screen handlers receive local coordinates; injected secondary
+screen events and OS-window controls keep their existing coordinates. An
+outside press cannot activate a game screen through the reserved margin.
+
+`love tools/game-viewport-check` drives actual `Game:draw`, the Renderer and the
+same public API-3 layout mod with real screenshot readback on Red, Gold and
+FireRed. It verifies default/captured paths, window chrome, compositor failure,
+deferred retirement, resize, allocation fallback, state restoration and
+unscaled controls. Shared eight-version comparisons additionally check hook
+payloads and pointer coordinates against pinned Gen1Recomp. No ROM is required
+and no game settings are written. Native combined-screen/mobile hardware and
+ROM gameplay are not exercised by this desktop GPU probe.
diff --git a/docs/modding.md b/docs/modding.md
index f6f71594f..79e0fb9cc 100644
--- a/docs/modding.md
+++ b/docs/modding.md
@@ -18,6 +18,71 @@ Regenerate the reference straight into a wiki checkout:
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
```
+## Shared API 3 entry
+
+The [API-3 entry and cross-engine contracts](api3-portability.md) describe
+using the same manifest and mod source on Gen1Recomp and Gen2Recomped.
+API 3 selects the unified adapters automatically; API 1/2 keep their defaults.
+See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime,
+[API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes,
+[API-3 NPC actions](api3-npc.md) for runtime actors and movement handles,
+[API-3 wild encounters](api3-wild-battle.md) for native battle admission,
+[API-3 map invalidation](api3-invalidate-map.md) for native cache/reload,
+and [API-3 flags](api3-flags.md) for validated native references and private flags.
+
+## Feature queries (`mod:supports`)
+
+`mod:supports(surface, name)` returns `true` when the selected host exposes an
+existing API-2 capability, or `false, reason` when it does not. Unknown names,
+unknown surfaces and invalid names return `false` with an explanation. Check
+for the method itself when supporting older engine builds:
+
+```lua
+if mod.supports and mod:supports("hooks", "battle.damage") then
+ mod.hooks:wrap("battle.damage", function(next, ctx)
+ return next(ctx)
+ end)
+end
+
+local canFly = mod.supports and mod:supports("world", "flyTo") -- false here
+```
+
+The four surfaces are:
+
+| Surface | Meaning of `true` |
+| --- | --- |
+| `content` | An API-2 registry exists, e.g. `pokemon` or `render_pipelines`. |
+| `world` | A public `mod.world` method exists, e.g. `warpTo` or `terrainAt`. |
+| `hooks` | The engine has a native hook call site for this version. |
+| `events` | The engine has a native event call site for this version, or the name is a non-empty `mod..` namespace. |
+
+Queries work in the entry chunk without an active overworld, save, graphics
+context or imported ROM. They do not subscribe, wrap hooks, load the Game or
+materialize `mod.world`. Answers are bound to the host selected when that mod
+API was built; changing the mod's copy of `gameVersion` or `host` cannot change
+them. Availability does not imply the current game state permits the action:
+`warpTo` can still return `nil, "no overworld"`, and elevation readers can
+return `nil, "map carries no elevation"`.
+
+Callback availability follows Gen2Recomped's actual implementation, rather
+than Gen1Recomp's newer catalog. For example, `core.logic_speed`, `item.use`
+and `egg.hatched` have no native call sites here; the hatch event here is
+`pokemon.egg_hatched` (Gen 2–5). `battle.status_hud_visible` is available on
+Gen 4–5, `battle.overlay` on Gen 1–3, and the normal start-menu, party-submenu
+and naming-grid hooks on Gen 1–3. `ui.pc.items` is available on Gen 1–2.
+Oak-speech callbacks apply to Gen 1–2 except Prism; Birch-speech events apply
+to Emerald. Other versions use their own intros. An event still needs its
+trigger to occur; custom events also need another mod to emit them.
+
+For API 1/2, the query describes the existing interfaces and does not itself
+normalize records, IDs or callback payloads. API 3 selects the portable
+adapters described in [Shared API 3 entry](api3-portability.md), with explicit
+limits on availability. Existing API-1/2 registration/subscription behavior
+remains unchanged. When adding a native callback or changing screen routing,
+update `src/mods/Capabilities.lua` and the capability tests in the same change.
+
+Additional read-only world summaries: [map overview and encounter queries](world-overview.md).
+
## Bulk storage (`mod.storage`)
`mod.save` is the right home for a handful of per-playthrough values: it lives
@@ -70,6 +135,14 @@ is a normal answer rather than a failure, and telling it apart from
per frame, and a directory walk over several hundred blobs per frame is not
something a menu can afford.
+## Read-only world and party queries
+
+`mod.world:party()`, `activeBlockAt(mapId, bx, by)` and `liveMaps()` expose
+common Pokémon snapshots and current runtime map data across generations
+1–5. Reads return detached data and do not load maps or change gameplay.
+See [record fields, coordinate units and DS limitations](world-read-api.md).
+The existing `loadedMaps()` method retains its legacy shape.
+
## Editing maps in Tiled
Maps are data, not assets, so they can be authored in a real map editor and
@@ -87,6 +160,13 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the
fields that moved; a new map becomes a `:register`. See
`docs/new-features.md` and the extension's own README.
+## Portable dialogue scripts (opt-in)
+
+API-2 mods can select `"script_format": "unified"` for prevalidated, copied
+`mod.world:queueScript()` dialogue/control-flow programs with exactly-once
+completion and owner-only cancellation. Existing manifests keep native script
+behavior. See [portable commands, ownership and limits](portable-world-scripts.md).
+
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
@@ -152,6 +232,17 @@ Three rules worth knowing:
Returning `nil` from `drawWorld` is a normal answer meaning "not this
frame"; the engine draws the vanilla world instead.
+## Read-only battle snapshots
+
+`mod.battle:snapshot()` returns a detached common battle view on Gen 1–5,
+including singles and doubles, or `nil, "no battle"`. It can be captured in
+an entry chunk before Game exists. See [Battle snapshot API](battle-snapshot-api.md)
+for records, revisions, prompt semantics and intentionally omitted previews.
+The snapshot reads state only. The [Battle action API](battle-actions-api.md)
+adds validated `mod.battle:submit(intent)` for the native menu, move, target,
+back, Safari and Mimic paths. When capability queries are
+installed, `mod:supports("battle", "snapshot")` reports this interface.
+
## Battle sprite scaling
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
@@ -192,6 +283,14 @@ default** (1x front, 2x back).
ball-to-pic grow multiplies your scale through each stage, so a rescaled
mon still grows into place from the ball, grounded the whole way.
+## Unified hook and event records (opt-in)
+
+API-2 mods can select `"callback_format": "unified"` independently of their
+content format. Known Pokémon, battler, trainer-slot and encounter records
+then use common fields and symbolic IDs. Existing mods keep native payloads.
+See [the callback format and mutation contract](unified-mod-callbacks.md) for
+adapted arguments/results, native handles and validation rules.
+
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
@@ -219,6 +318,14 @@ The console understands these verbs (anything else is evaluated as Lua):
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
- `help` — list the verbs.
+## Public party, bag and dialogue actions
+
+`mod.ui:snapshot()` returns detached options for the current native screen;
+`mod.ui:submit(intent)` selects a row, goes back, changes bag pocket or advances
+ready text through its native flow. See [Public UI actions](ui-actions-api.md)
+for revision/ID requirements, per-generation menu behavior and supported screens.
+The existing `mod.ui` list-building helpers retain their original signatures.
+
## Tool input and title-menu hooks
Tool mods that need to act once per game logic tick can wrap `input.step`.
@@ -255,6 +362,15 @@ without the engine knowing the layout.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+## Public world actions
+
+`mod.world` exposes guarded party swaps, verified contextual field actions
+and native flight through `canReorderParty`, `reorderParty`,
+`availableFieldActions`, `useFieldAction`, `canFly` and `flyTo`.
+See [contracts, busy-state checks and generation coverage](world-actions-api.md).
+Calls revalidate the live state and preserve native gameplay paths; the
+field-action adapter explicitly refuses unsupported script-driven cases.
+
## Support link (`support`)
A mod can add a link where players can thank its creator: a Ko-fi, Patreon or
@@ -277,3 +393,10 @@ The launcher hands the link to the operating system to open, so only `http://`
and `https://` links are accepted. Any other value, such as a `file:` link or
a custom scheme, is dropped with a warning. The mod itself still loads; it
just gets no button.
+
+## Unified content records (opt-in)
+
+API-2 mods can select `"content_format": "unified"` to read and edit common
+Pokémon, move, item, trainer and encounter definition shapes. Existing mods
+keep native formats. See [the format, supported operations and limitations](unified-mod-content.md)
+for the manifest guard, generation coverage, symbolic IDs and failure behavior.
diff --git a/docs/native-battler-construction.md b/docs/native-battler-construction.md
new file mode 100644
index 000000000..cedfb5684
--- /dev/null
+++ b/docs/native-battler-construction.md
@@ -0,0 +1,34 @@
+# Native battler construction without presentation resources
+
+The shared native `BattleState.makeBattler` previously built combat data,
+selected pictures and created animation resources in one function. Extract
+its data construction into `src/battle/Battler.lua`, and have the existing
+screen constructor hydrate that same table with its original art factories.
+There is no second turn simulator, replacement Pokemon or replacement art.
+
+This offered host retains its existing Gen2 calculation binding and poison
+initialization. It does not import the fork-only `HeldItemRead` dependency
+or silently introduce the fork's later toxic-counter behavior. Party/move/
+stat/registry identities, badge defaults, outside-battle sleep and form
+personal records retain the existing native construction semantics.
+
+On implementation/test commit `281e5185`, fresh plain-Lua construction passes
+577 checks without a LÖVE stub, with presentation-module loads rejected.
+Actual wild construction and switches pass 810 checks across this host's
+15 advertised editions using the real sprite and animation factories.
+These are selected ROM-free checks, not complete current-host, GPU or
+release qualification. The previous round-clock offer has separate scoped
+RNG/link/model evidence; it does not qualify this new combined source.
+
+This is a prerequisite for sharing native battlers with standalone model
+construction. It does not implement the complete source `Battle.new`, pure
+turn/event executor, arbitrary writable model fields, full unchanged-mod
+visual parity or final release acceptance.
+
+The data code is extracted from the existing engine; original authors'
+rights and applicable licences remain effective. Extraction/integration
+and tests by Roxas2712 are offered under
+,
+including its specific permission for Cedric / UNDERdecoded with credit.
+That contribution notice does not replace earlier permissions or assign
+ownership of the underlying engine.
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-appearance-identity.md b/docs/native-gen2-appearance-identity.md
new file mode 100644
index 000000000..e660d80b8
--- /dev/null
+++ b/docs/native-gen2-appearance-identity.md
@@ -0,0 +1,39 @@
+# Native Gen2 appearance identity
+
+The real Gen2 Pokemon constructor now publishes `gender` and `shiny`, including
+ordinary births without a hook subscriber. Previously the native predicates
+could answer these queries, but unchanged consumers reading the Pokemon fields
+directly received nil. This could select a normal model variant for a female.
+
+`opts.dvs` accepts the four finalized native 0–15 DVs for fixed-gene births.
+The constructor owns a copy, derives HP DV and spends no discarded random DV
+roll. Trainer parties, NPC trades, Odd Eggs and inherited day-care eggs use this
+path before appearance hooks run. Definitions and other Pokemon do not share
+the mutable gene table. Ordinary native births still use four random DVs.
+
+Save validation fills absent appearance fields on actual known Pokemon after
+gene/level validation. Explicit saved values survive loading. Summary/stat
+refresh, evolution, hatching, forced-shiny gene changes and companion stat edits
+refresh the actual identity. True shininess remains sticky, matching the Gen1
+Gen2 refresh contract. Refresh does not change HP, stats, genes, moves or EXP
+by itself. Hook context edits retain their existing ownership contract.
+
+`tests/engine/gen2_appearance_identity.lua` covers all seven gender-ratio
+classes, DV boundaries and shiny attack classes in Gold, Silver and Crystal;
+save codec/validation for party, sparse boxes, day care and contest parties;
+fixed trainer genes, Odd Eggs, actual DayCare.step and evolution. `tests/engine/gen2_appearance_transactions.lua` additionally runs the actual
+NPC trade script, party picker and movie continuation, plus the real companion
+DV/species mutation handlers. Passive panel recalculation preserves identity
+and does not dispatch appearance hooks every frame. The existing
+identity authority/roll suites cover hook delegation, suppression, forced shiny
+and failed callback rollback across generations and API formats.
+
+These are engine behavior checks. They do not certify GPU model loading,
+complete menu/battle appearance parity, optional asset availability, or overall
+release readiness. No mod changes are needed for this engine contract.
+
+Upstream adaptation: based on the native species/runtime series in PR371. The
+existing extra-shiny option remains in the upstream birth path. IdentityRolls
+is the exact helper already offered in PR169; this change does not introduce
+a second hook contract or advertise unsupported public hook capability.
+The native-only regression is independent of that SDK integration.
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-frame-queries.md b/docs/native-gen2-frame-queries.md
new file mode 100644
index 000000000..a544159ff
--- /dev/null
+++ b/docs/native-gen2-frame-queries.md
@@ -0,0 +1,70 @@
+# Native source Game2 frame queries
+
+`src.core.Game2` resolves to native `Game`, but the native class lacks the
+source `frameFit(w,h)` and `viewport(w,h)` observations. An unchanged Gen2
+scene therefore uses a fallback instead of asking its source owner.
+
+Add passive queries through the existing Gen2 chrome and native viewport
+owners: logical scale/origin, window DPI, actual drawable dimensions and
+a detached classic 160x144 viewport result. They do not replace native
+composition, mutate the cartridge or allocate another scene renderer.
+Actual drawable-size queries use framebuffer coordinates. The shared
+`PixelFit` helper extracts the existing compositor scale/origin arithmetic;
+drawable inputs must not receive a second DPI multiplication.
+
+This offer is based on the native viewport/touch stack in PR383. It includes
+the already offered Chrome component from PR224 with all of its required
+pieces: `Gen2Chrome`, its module alias, its palette-owner extension and its
+tests. Inverted font ramps must be reversed after display-mode selection
+and before the hardware shade-register permutation; the optional native
+`effectiveColors(c,invert)` argument supplies that operation without a
+second palette state. Calls without the optional flag retain their behavior.
+
+Historical selected validation on offered implementation `5e4ee81f`:
+
+- 3161 frame-query checks through the actual API2 SDK and the actual older
+ native `Renderer:endFrame` compositor, across five GSC editions, six
+ window shapes and DPI 1, 1.25, 1.5, 2 and 3. The newer fork's `frameRects`
+ observation helper is not invented on this older host.
+- 116 native menu-chrome checks, including inverted palettes with a
+ noncommuting shade map, real font/box drawing and input/list behavior.
+- The same frame-query probe fails cleanly on the preceding PR383 source
+ `a3f0d344` because the two methods are missing.
+
+The newer fork's real Metal test then exposed a case those initial logical
+queries did not cover: unchanged VASC passes2048x1536 framebuffer dimensions
+in a1024x768 DPI2 window. The initial query returned scale10.5/origin184,12;
+the actual native compositor uses pixel scale10/origin224,48. That initial
+candidate is rejected for coordinate acceptance. The expanded probe fails
+263 of4454 checks on that fork candidate.
+
+Offered correction `d3652034` independently passes4454 frame-query checks
+through this older host's actual compositor and116 menu-chrome checks.
+The expanded probe includes actual drawable arguments, the recorded Retina
+regression, odd pixel dimensions and decorated native fitting. The same
+shared-policy correction passes the newer fork's4454 query checks plus
+its existing window/playfield/chrome regressions.
+
+The newer fork's corrected source `123b8036` also passes a fresh actual Metal
+checkpoint CONTINUE, connected walk and natural encounter with unchanged
+VASC. Its observed drawable query returns scale10/origin224,48, matching
+the native compositor. Four inspected owned OS images cover both selected
+field camera flags; both select the same authored battle-stage view.
+All 27336 mod files, their QA copies, the ROM and the earned source checkpoint
+are unchanged. This is evidence for the shared coordinate correction on the
+newer fork, not an actual GPU qualification of this older offered host.
+Bottom command art remains partly clipped; full visual parity is rejected.
+
+Complete combined-host tests, every custom skin/cutout/safe-area and odd
+fractional-DPI logical configuration, physical Windows behavior, matched unchanged
+mod GPU/actor pinning and release acceptance remain open. The concrete
+missing API is established; this does not yet prove the cause or correction
+of a particular clipped command card.
+
+Source contract reviewed in Gen1Recomp `535a5ec0`, `src/core/Game2.lua`.
+The existing Chrome component retains its original provenance and licence
+conditions. Original engine authors' rights remain effective. Integration
+and tests by Roxas2712 are offered under
+,
+including its specific Cedric / UNDERdecoded permission with credit; this
+does not replace earlier grants or assign ownership of the underlying engine.
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-model-boundary.md b/docs/native-gen2-model-boundary.md
new file mode 100644
index 000000000..4abcd7bd4
--- /dev/null
+++ b/docs/native-gen2-model-boundary.md
@@ -0,0 +1,38 @@
+# Native Gen2 model boundary
+
+`src.battle.gen2.Battle` is a distinct model class attached to the shared
+native battle controller by `Gen2BattleView`. It is not a name alias for
+`BattleState`, a second controller, or a detached copy of the saved party.
+Methods resolve through the class so an SDK-owned decoration also reaches
+already-created models and is restored by loader retirement.
+
+The boundary supplies live `moveDef`, `speciesDef`, `sideOf`, raw `battleStat`
+and `endBattle` operations. Damage, speed, status immunity, weather and shared
+move effects consult the native bound reads. Stages, badge/status/item
+modifiers, RNG and PP stay with their existing native owners. With no
+decoration the read returns the original native table, without a copy.
+Temporary stat/type answers do not replace a species registry row, party
+statistics, wrapper identity or checkpoint data. Explicit native
+Conversion/Transform types keep their priority over a species decoration.
+
+The native finish path invokes the model's end operation, including the
+skipped battle path. It raises one finish notification and permits transient
+cleanup before subscribers observe it. `completeBattle` runs the existing
+screen finish/return path, with repeated completion prevented from popping
+another screen. Existing native consumers retain the screen in the event's
+`battle`; the distinct model is available in `model`.
+
+This is an implemented boundary, not complete source-model parity. The split
+Gen2 model contract was inspected at Gen1Recomp `535a5ec0` and its current
+`e46d1eb2` counterpart. That model additionally has constructors and a pure
+turn/event API, which are not guessed from screen methods here. Its inspected
+class has no `battleStat` method; the raw-stat operation above is an explicit
+native extension, not a claim that all source versions expose it. Source
+event payload normalization and the remaining model operations still require
+separate implementation and acceptance. Existing project and third-party
+credits remain applicable.
+
+ROM-free coverage uses an SDK-loaded generic decorator and actual native
+damage, speed, status, switch, Transform and completion calls. It also checks
+restoration and direct table identity in other generations. It does not prove
+an unchanged client, GPU presentation, a full story run or a release package.
diff --git a/docs/native-gen2-model-rng.md b/docs/native-gen2-model-rng.md
new file mode 100644
index 000000000..1de093552
--- /dev/null
+++ b/docs/native-gen2-model-rng.md
@@ -0,0 +1,31 @@
+# Native Gen2 model RNG
+
+Expose `rng`, `random` and `roller()` on the live source model, backed by the
+existing native controller's current function. LOVE-style ranges normalize
+missing/small bounds and reversed ranges; the source random/roller domain is
+zero-based for positive integer bounds. Queries consume no randomness and
+retained adapters follow native function replacement, including link setup.
+
+The source random field and decorated roller method stay distinct. The native
+read-only reader policy remains in effect. Model-owned function caches do not
+enter party mons or serialized battlers, and no second generator is created.
+Standalone constructor/RNG-injection and complete source-model parity remain
+separate work.
+
+Reference: read-only Gen1Recomp Gen2 revision
+`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, `Battle.new`, its LOVE-style RNG
+view and `Battle:roller`. This older host's native model, volatile-state and
+protection prerequisites are offered separately. The fork-only semantic
+checkpoint backend is absent here, so its checkpoint result is not inherited.
+
+Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under
+the specific PR contribution permission, with existing rights intact.
+Independent validation on this offered host passes 7951 native RNG checks,
+174 actual host/guest/spectator source-callback checks, model (262), volatile
+(423) and protection-link (66) regressions. A separately executed frozen Gen1
+comparison passes 9471 comparisons / 7020 draw cases over actual seeded native
+LinkBattle backends; reference hashes remain unchanged. The exact official
+whole-host command passes 71/82 engine suites and SDK3/3, with the same eleven
+pre-existing failures as its protection base (69/80). It adds two passing suites
+without adding a whole-host failure; this older prerequisite stack is not
+entirely green. Full model/mod/GPU/release acceptance remains separate.
diff --git a/docs/native-gen2-mon-fields.md b/docs/native-gen2-mon-fields.md
new file mode 100644
index 000000000..1fe84d3be
--- /dev/null
+++ b/docs/native-gen2-mon-fields.md
@@ -0,0 +1,72 @@
+# Live native GSC Pokemon fields
+
+Source GSC consumers read `mon.maxHp`, `mon.name`, `mon.types`, and
+`stats.specialAttack`/`stats.specialDefense`. Native Pokemon store their
+maximum in `stats.hp`, look up species display/types from the data registry,
+and use `spatk`/`spdef`. A real native birth on preceding source `a6eb0c05`
+fails all five source-field checks; the implementation module is absent too.
+Unchanged menu, healing and capture consumers can consequently read missing
+maximums or special statistics despite the native combat values existing.
+
+`Gen2MonFields` supplies those spellings on the actual native tables.
+Reads follow native stat-block replacement and live registry changes;
+source stat/maximum writes reach their native canonical fields. Construction,
+save validation, the native battler data factory and identity refresh bind
+the original Pokemon. Native GSC stat calculation/rehydration publish split-stat aliases. This
+older host republishes species base-stat aliases after its real generic
+registry fold, without adding the newer fork's species-name writer. Data seeding and actual
+mod loading also expose the base-stat spellings used by source consumers.
+There is no parallel Pokemon, duplicated HP counter or per-frame polling.
+
+Aliases live on metatables, without adding helper keys to serialized records.
+Explicit authored stored fields retain their existing precedence. Existing
+custom metamethods are preserved; protected custom metatables are not
+replaced and may therefore decline the additional fields. Live Pokemon retain
+their data owner; abandoned Pokemon/data cycles remain collectible on LuaJIT.
+The helper does not allocate graphics or install a fake LÖVE runtime.
+
+Newer fork selected qualification on corrected source `2a000267` (not an
+independent qualification of this older offered host):
+
+- 156 pure-data checks with no LÖVE, including live owners, canonical writes,
+ replaced registries/stat blocks, custom/protected tables and cycle collection.
+- 162 actual native checks across the registered editions: birth, real save
+ encoding/decoding/validation, native battlers and source model, box stats,
+ actual SDK loading and native registry writes. Non-GSC Pokemon are excluded.
+- Existing data construction 691, native construction/art hydration 972,
+ identity ownership 142, species identity 145 and save-summary 50 checks pass.
+
+The first implementation lost custom metamethods on a cache miss; four pure
+checks caught it. The corrected cache selection passes all checks. The same
+native probe on the preceding committed engine fails six checks, including
+the five observed field gaps. Complete current-tree, unchanged-mod GPU/menu
+and final release qualification remain open. This does not implement the
+still-missing standalone source battle constructor or turn/event executor.
+
+No mod, protected Gen1 installation, ROM original or player profile is edited.
+Existing engine authors' rights remain effective; native field integration
+and tests by Roxas2712 are subject to `LICENSE-ROXAS2712.md`.
+
+This older adaptation uses its actual `BattleState.makeBattler`, controller
+and original RNG constructor signature. It does not fabricate the newer
+fork's extracted Battler, live source model, identity refresh or source
+options-record constructor, which are separate offered work. Its own native
+probe follows the actual older SDK loader/registry fold. Corrected source
+`6985a377` independently passes184 pure-data and170 native checks across
+its15 registered editions, plus4454 frame-query,116 chrome and1031
+screen-position regressions. The managed host is cleaned after exit.
+Its initial independent run on `c0ea73de` passed the pure-data/frame/chrome/
+position checks but rejected the actual SDK `spatk` patch as an unknown
+nested schema field. The schema now accepts native `spatk`/`spdef` with the
+same1..255 integer bounds; invalid inputs remain rejected. The native
+registry-fold test still patches both real columns and now passes.
+The older host has no semantic-checkpoint subsystem: no newer checkpoint
+facade or unqualified checkpoint integration is added to this offer.
+The pure helper authenticates its own unchanged raw-record wrappers and
+provides native-slot rebinding, with custom-owner refusal and no mod-storage
+traversal. It is stacked on upstream PR398 and its existing prerequisites.
+
+Independently owned integration/tests by Roxas2712 are offered under
+,
+including the specific Cedric / UNDERdecoded permission with visible credit.
+Existing engine authors' rights, notices and earlier grants remain effective.
diff --git a/docs/native-gen2-move-priority.md b/docs/native-gen2-move-priority.md
new file mode 100644
index 000000000..235fd9692
--- /dev/null
+++ b/docs/native-gen2-move-priority.md
@@ -0,0 +1,32 @@
+# Native GSC move priority
+
+Older native GSC caches mark priority-hit moves explicitly, but leave Protect,
+Endure, Mirror Coat and other priority classes without a `priority` field.
+The shared comparator then treated them as ordinary moves. A slow Pokemon
+could take damage before its Protect action; the live Gen2 model also reported
+neutral priority for these moves.
+
+`Gen2MovePriority` supplies the cartridge's missing priority classes at the
+actual native comparator and live-model query. Protect/Endure use +3;
+Counter/Mirror Coat and Vital Throw use -1. Roar/Whirlwind use -1 by their
+native IDs because the existing cache combines their effect with Teleport;
+Teleport remains neutral. Explicit native cartridge/mod priority values,
+including zero, take precedence. Other generations retain their own rules.
+This does not require a re-import, change a move record or registry fingerprint,
+or replace the native battle engine. It adds no RNG draw.
+
+The reference is the GSC `GetMovePriority`/effect-priority table also represented
+by the read-only Gen1Recomp Gen2 model at
+`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. This is an implementation in the
+native shared controller, not an alias to a second combat simulator.
+
+The exact old-source regression reproduces 57 failed assertions, including
+actual HP loss before slow Protect. The corrected 83-check contract exercises
+Gold/Silver/Crystal, renamed effect-equivalent moves, explicit overrides,
+unknown moves and the public model. It executes the real queued native actions
+and verifies Protect/Endure state and PP; the enemy's real attack is blocked
+after Protect. Cross-generation comparator checks and existing battle/model,
+queue and held-item regressions remain separate checks. These ROM-free fixtures
+are not a claim of complete move parity, unchanged-mod story/window behavior,
+or release acceptance. Full model-constructor and other Gen1 behavior gaps
+remain open.
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/docs/native-gen2-protection.md b/docs/native-gen2-protection.md
new file mode 100644
index 000000000..e0348b7f6
--- /dev/null
+++ b/docs/native-gen2-protection.md
@@ -0,0 +1,38 @@
+# Native GSC Protect and Endure
+
+Run GSC protection on the real shared-controller battler, with the source
+model's nonzero-byte odds (`255 >> prior successes`), first-side and Substitute
+restrictions. Actual action entry records the first side for that round,
+including switches and failed/recharge actions; called effects retain it.
+The next round replaces it. GSC self-protection bypasses the generic incoming
+status gate so the real handler can clear its failed-use counter.
+
+Changed command/RNG semantics advertise `gsc-1` in link hello and a separate
+GSC fingerprint domain. Older rules cannot enter the changed lockstep battle;
+the compatibility screen names the native rules difference. Other generations
+retain their existing handlers and fingerprints. This older host does not
+carry the fork's semantic-checkpoint backend, so no checkpoint result is
+claimed here.
+
+References: read-only Gen1Recomp Gen2 revision
+`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, its protection handlers and
+`Effects.protectSucceeds`; `pret/pokecrystal`
+`5c877ef55197039c9676e8e3eec6d254b1c40fe8`,
+`engine/battle/move_effects/protect.asm`. The nonzero-domain native RNG call
+matches the source model convention; this does not emulate a hardware PRNG or
+claim raw zero-rejection trace parity.
+
+The native model/volatile/priority prerequisites remain separate offered PRs.
+Independent results on this offered host: 3796 native effect/queued-order/PP/
+switch checks, 66 host/guest/spectator and admission checks, 37 actual protocol/
+cache/digest checks, 423 volatile checks and 83 priority checks pass. The frozen
+Gen1 comparison independently passes 30,624 actual effect cases / 165,336
+comparisons. A separate Python integer-FNV audit confirms this host's legacy
+fixture digest e33d251961f2a41d and GSC digest 40df18dc503213c8. Tests drive this
+older host's actual queue-row closures; they do not assume the newer fork's
+runQueueAction or beginTurn helpers exist. Fork-only checkpoint and complete
+release acceptance are not inherited. GPU, complete called-move/turn mechanics,
+unchanged-mod gameplay and full Gen1 feature parity remain separate work.
+
+Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under
+the fork's specific PR contribution permission, with existing rights intact.
diff --git a/docs/native-gen2-round-clock.md b/docs/native-gen2-round-clock.md
new file mode 100644
index 000000000..faeda7aae
--- /dev/null
+++ b/docs/native-gen2-round-clock.md
@@ -0,0 +1,32 @@
+# Native Gen2 model round clock
+
+An API2 reader of `screen.battle.turn` previously received `nil` even while
+the native controller emitted numbered turn events. The frozen Gen1 Gen2
+model starts its `turn` field at zero and increments at round admission
+(`src/battle/gen2/Battle.lua`, reference revision
+`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`).
+
+The source model now reads the existing native `turnCount`, or zero before
+its first round. There is no second counter, timer, event or random stream.
+The native controller still owns round admission. Repeated reads, individual
+moves, residuals and switches do not advance or reset that clock. Link hosts,
+guests and spectators read their actual lockstep controller. Semantic
+checkpoint reconstruction reads its restored native count.
+
+Validation uses actual native constructors, queued actions, API2 event
+callbacks, lockstep participants/spectator and semantic checkpoint restore.
+The corrected queue probe on the preceding engine reports 295 failures in
+400 checks: the public clock is absent at initialization and actual execution
+edges. With the engine reader, all 400 pass across Gold, Silver, Crystal,
+Prism and Polished Crystal. The link probe passes 210 checks, checkpoint
+probe 57, unchanged RNG probe 7951, calculation/completion probe 252 and
+friendship checkpoint probe 30 on the fork. The earlier queue-probe drafts
+had test-harness mistakes; only its corrected negative run supports the
+295-failure comparison.
+
+This closes the missing live clock observation. It does not implement the
+source standalone constructor/turn executor, arbitrary writes to source
+model fields, every battle event, full mod visual parity or release-package
+acceptance. Those remain separate parity requirements. No mod or original
+Gen1/player installation is changed. Implementation and tests are original
+contributions by Roxas2712 under `LICENSE-ROXAS2712.md`.
diff --git a/docs/native-gen2-sfx-admission.md b/docs/native-gen2-sfx-admission.md
new file mode 100644
index 000000000..2f79b3d74
--- /dev/null
+++ b/docs/native-gen2-sfx-admission.md
@@ -0,0 +1,49 @@
+# Native GSC SFX admission
+
+Normal `Sound.play` and battle-animation `Sound.playStereo` now have separate
+native GSC admission. Previously both called the unrestricted static-source
+path, so the fork had neither the ordinary priority rule nor an explicit
+animation entry. The animation VM now uses the latter for indexed native SFX.
+
+The priority byte follows `PlaySFX` in `pret/pokecrystal` commit
+`5c877ef55197039c9676e8e3eec6d254b1c40fe8`, `home/audio.asm:180–207`:
+while an indexed native SFX source is playing, a larger low byte is refused;
+equal or smaller replaces the native SFX group. Completion comes from the
+actual Source rather than a second timer. `PlayStereoSFX` bypasses admission
+and preserves the current byte; mono mode clears preceding indexed voices.
+See also `audio/engine.asm:2472–2670` at the same revision. No assembly or
+ROM-derived audio is included in this contribution.
+
+The native importer supplies sparse zero-based `sfxIndex` with `engine=gen2`
+headers. The source-native `sfxOrder` format and `generation=2` headers are
+also accepted. Exact labels take precedence over definition/header aliases;
+no priority is guessed from an effect name. Non-GSC/file definitions retain
+their previous playback path. Accepted playback is registered before public
+sound events. The fork adaptation also revokes borrowed playback on ordinary preemption;
+that separate lease API does not exist in this upstream baseline.
+Invalidation and session suspension retire the same source owner.
+
+`tests/engine/gen2_sfx_arbitration.lua` exercises actual Sound, ChipAudio,
+ChipSynth and the native animation VM in Gold, Silver and Crystal, with both
+index schemas. The only device seam is `love.audio.newSource`; a finite GSC
+bytecode program is genuinely synthesized into non-silent PCM. It checks
+priority, low-byte comparison, completion, stereo/mono entry, alias mapping,
+source reuse, public events, reentrancy and lifecycle. The fork-only lease
+and weather assertions are absent here because those owners are not present
+in this older upstream baseline.
+The source-native VM also resolves sounds from the actual `sfxOrder`.
+
+## Limits of this change
+
+This adds SFX admission over the existing static PCM voices. It does not
+implement a live, shared hardware-channel mixer: partial per-channel
+replacement, cry-versus-SFX channel arbitration, animation panning/duration
+and authentic stereo PCM remain separate work. Stereo overlapping voices
+retain the current Gen1 reference behavior. The existing PCM renderer still
+writes duplicated mono to the front stereo pair. CPU/device-seam checks do
+not establish audible output, complete unchanged-VASC gameplay, Windows
+execution, GPU/window acceptance or a release qualification.
+
+Only engine sources change. VASC, protected Gen1/player installations,
+original ROMs and player saves are untouched. Contributions by Roxas2712;
+see `LICENSE-ROXAS2712.md`, with existing third-party rights retained.
diff --git a/docs/native-gen2-sleep-turns.md b/docs/native-gen2-sleep-turns.md
new file mode 100644
index 000000000..058f57454
--- /dev/null
+++ b/docs/native-gen2-sleep-turns.md
@@ -0,0 +1,51 @@
+# Native GSC sleep lifetime and turn admission
+
+The shared controller previously lost the waking turn in Gen2, blocked
+Snore/Sleep Talk during sleep, processed flinch before sleep, and applied
+Gen1's pre-recharge status checks. Its default sleep roll included a one-turn
+counter, and the party Pokemon did not receive the remaining counter.
+
+`Gen2Sleep` now uses the actual native battler, party Pokemon and battle RNG:
+
+- A waking Pokemon acts that turn; Nightmare and the completed counter clear.
+- Sleeping Snore/Sleep Talk reaches the existing move-effect owner. Each
+ move's own failure when awake still runs after waking.
+- Sleep precedes flinch; mandatory recharge precedes both and does not tick
+ sleep. CantMove cancels Bide/rampage/charge/Rollout/Fury Cutter state and
+ the selected Fly/Dig flags. The existing picture owner follows those flags.
+- Default SleepTarget draws a full byte, masks and rejects zero/seven before
+ incrementing: counters 2–7. The Tower flag selects the two-bit mask. An
+ explicitly configured sleep range and custom status handler remain owned
+ by their existing records.
+- Each tick updates the existing party sleep counter, so reconstruction
+ reads the remaining count. Rest initializes three, allowing two blocked
+ attempts before waking. No second turn or presentation clock is added.
+- Native held cures and medicine clear both party and active counters at the
+ existing cure site, including field medicine and revives.
+
+Source: `pret/pokecrystal` commit
+`5c877ef55197039c9676e8e3eec6d254b1c40fe8`,
+`engine/battle/effect_commands.asm`: CheckTurn 140–194, enemy twin 370–422,
+CantMove 333–356, SleepTarget 3571–3625, Heal/Rest 6032–6080.
+The frozen Gen1 GSC `CrystalRules.canAct` and sleep record provide the second
+engine reference. No assembly or ROM-derived program is included here.
+
+Tests use actual StatusRegistry, Status, native action/move dispatch and
+picture ownership, both sides in Gold/Silver/Crystal. Every possible input
+byte and both masks are checked, including rejected draws. They also cover
+explicit status/range overrides, Rest, awake failure, charge cleanup,
+party reconstruction and shared Gen1/GBA regressions. The fork has a separate semantic checkpoint test; that newer checkpoint
+fixture/interface is absent from this upstream baseline and is not copied.
+The older upstream enemy-PP policy and Gen1/GBA wake/flinch behavior retain
+their native baseline; the adapted assertions explicitly reflect those owners.
+
+This fixes sleep lifetime/admission. Sleep Talk's older generic choice pool,
+exact called-move history, Snore's remaining secondary-effect details and
+other GSC status/turn-rule differences remain separate work. A conditional
+Tower flag test does not establish complete native Battle Tower gameplay.
+CPU checks establish neither visible/audible output nor whole unchanged-VASC,
+Windows or release qualification. Mods, protected Gen1/player installations,
+original ROMs and player saves are unchanged.
+
+Original contributions by Roxas2712; see `LICENSE-ROXAS2712.md`. Existing
+source rights and notices remain effective.
diff --git a/docs/native-gen2-species-runtime.md b/docs/native-gen2-species-runtime.md
new file mode 100644
index 000000000..d5202e853
--- /dev/null
+++ b/docs/native-gen2-species-runtime.md
@@ -0,0 +1,41 @@
+# Native GSC species identities
+
+An unchanged consumer can use the native Pokemon's `species` to select its
+model. Numbered importer placeholders such as `SPECIES_155` previously stayed
+on the runtime Pokemon, even though named registry lookups were available.
+That lookup alone cannot satisfy a catalog keyed by `CYNDAQUIL`.
+
+The normal pre-mod `Data:seedDefaults` pass now canonicalizes a complete native
+Gold/Silver/Crystal graph. It requires all 251 declared slots, matching record
+IDs, native indices and dex numbers, and no authored lookup/collision. Partial
+datasets, other generations and alternative cartridges retain their own IDs.
+The names are stable constants rather than localized display names.
+
+Species records, encounter/trainer/gift/evolution references and species-keyed
+runtime tables change together, in place, after an atomic collision preflight.
+Existing table identity, native indices, art paths and literal text/provenance
+remain intact. `FARFETCHD` and `MR_MIME` use the public punctuation convention;
+their earlier spellings remain lookup aliases. Numbered addresses also remain
+aliases, so registry editing, rollback, replacement and deletion still share
+one stored record. A second default-seeding pass cannot revive a deleted row.
+
+Native script byte operands resolve through the current species order. Numeric
+writeback reads the native definition's index. Ditto/Nidoran breeding, Togepi
+hatching, party queries, gifts, trades and roamers use the same identity space.
+Save validation folds old IDs in party, sparse boxes, day care, held parties,
+Hall of Fame, roaming and Pokedex state. It preserves true dex bits, native
+health/moves and literal nicknames. The ordinary save codec owns persistence;
+there is no file migration or original-save write in this normalization.
+
+ROM-free regressions exercise the normal Data publication pass, actual Pokemon
+construction for all 251 slots in three editions, the real registry, native
+script compiler/executor, breeding/hatching and save encode/decode/validation.
+The private unchanged-source diagnostic separately passes native Pokemon and
+frozen Gen1-reference Pokemon through the supplied real model selector and
+catalog: the preceding fork rejected 753/753 identities, and the corrected
+graph accepts 753/753. That is selector evidence, not complete model rendering,
+Windows, whole-mod or release qualification. GPU observations are recorded
+separately. No mod source or protected installation is changed.
+
+Original contributions by Roxas2712; see `LICENSE-ROXAS2712.md`. Existing
+source rights and notices remain effective.
diff --git a/docs/native-gen2-stat-refresh.md b/docs/native-gen2-stat-refresh.md
new file mode 100644
index 000000000..53e2f9236
--- /dev/null
+++ b/docs/native-gen2-stat-refresh.md
@@ -0,0 +1,48 @@
+# Source GSC stat refresh on current upstream
+
+Prepared on actual original main `8d5b4387`, composing existing appearance
+PR373, Unown PR307, battler/model foundation PR396 and field/viewport PR399.
+The combined older host was independently qualified at `26114b40` with all
+17 selected native/SDK suites passing. No earlier parent receipt is used to
+qualify these newly composed bytes.
+
+The adapter adds stats, hpDV, refreshStats(mon,data) and the correctly
+ordered syncIdentity(mon,data) over real native owners. Its older inherited
+RNG constructor remains unchanged. Source options-record construction, the
+remaining Mon API and standalone battle constructor/turn APIs remain open.
+No nonexistent identity, Unown or battler facade is invented: their actual
+offered implementations are composed first.
+
+One shared native GSC formula derives HP from the four genes and retains
+legacy Special fallbacks. Native summary recalculates existing stats;
+weak native ownership follows shared party-stat replacement and preserves
+separate Transform/current overrides. Explicit later native IV/EV/nature
+models are retained. Pokemon/HP are not duplicated, no extra clock runs.
+
+Prerequisite conflict resolution keeps current upstream move refusals in
+the actual BattleCommands owner, both source-composition and output paths,
+viewport/reset/cancellation services, display/shade palette separation,
+canonical/numeric registry IDs, field binding and original author credits.
+The first combined run (`7782ccdc`) caught a real integration regression:
+Gen2 field metatables were attached to ten non-GSC editions. The native birth
+boundary now applies that binding only to generation 2; the same unchanged
+negative generation checks pass on the corrected tree.
+
+Independent corrected receipt:
+`native-selected-suites-1793245754545259000/receipt.json`: 185360 formula,
+238 native refresh, 184 data fields, 170 native fields, 577 battler data,
+810 native battler construction, 5290 identity, 63 identity transactions,
+66245 Unown, 4454 frame queries, 116 chrome, 1031 screen position,
+5 composition seam, 1095 status, 617 picture, 1490 vanish and 300 move
+presentation checks. These use the actual composed older engine/SDK owners.
+This does not claim full-suite, GPU or packaged release qualification.
+
+Newer fork source273692e8 passes185360 pure-data and238 actual native checks,
+plus summary/identity/field/battler/checkpoint regressions; its predecessor
+fails the real source probes. These results do not qualify this older host,
+GPU or a release. No mod or protected/player installation is edited.
+
+Integration/tests by Roxas2712 are offered under
+https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md,
+including the specific Cedric / UNDERdecoded permission with visible credit.
+Existing engine authors' rights, notices and earlier grants remain effective.
diff --git a/docs/native-gen2-status-presentation.md b/docs/native-gen2-status-presentation.md
new file mode 100644
index 000000000..fb1cd2f76
--- /dev/null
+++ b/docs/native-gen2-status-presentation.md
@@ -0,0 +1,67 @@
+# Native GSC status presentation
+
+The native shared controller selected Gen1 SLP/CONF animation names for GSC.
+Those programs do not exist in the GSC catalog. It also recognized a status by
+English substrings in already-translated text, dropping the animation under a
+translation or inventing one for unrelated text containing the same words.
+
+GSC sleep now selects ANIM_SLP; confusion selects ANIM_CONFUSED. Player sleep
+runs before its caption, enemy sleep and both confusion sides after theirs.
+The actual imported misc-ID table admits those rows even when ordinary move
+animations are off, through the real public animForId entry point. Actual move
+rows retain their existing option gate. Ball metadata retains its own path.
+
+The native status decision reports optional presentation metadata in its fifth
+return value: kind, exact message index and actual blocking status. Existing
+decision/message/self-hit values keep their native meaning; optional registered
+reason/kind now propagates in slot four. Registered
+Gen1-style handlers with an explicit kind in slot four are recognized while
+retaining that value. The original caption is preserved. A later paralysis
+line cannot replace the earlier confusion caption, and arbitrary English words
+cannot create a status animation.
+
+The same structured blocker fixes localized full-paralysis cleanup: the
+actual PAR handler, rather than the words in its message, owns the existing
+native volatile cancellation. Counter order, damage arithmetic and random
+rolls stay at their existing owners. The fork additionally marks its existing GBA Snore caption. This older
+upstream tree has no Gen3SleepTalk module; no such implementation is imported
+by this adaptation. Its original non-GSC native paths stay in place.
+
+## Sources
+
+The explicit event-kind/index approach is compared with Gen1Recomp's frozen
+535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 src/battle/Status.lua and the shared
+BattleState:sayStatusMsg boundary. The real GSC program names and side-specific
+ordering are independently checked in [Crystal effect_commands.asm](https://github.com/pret/pokecrystal/blob/5c877ef55197039c9676e8e3eec6d254b1c40fe8/engine/battle/effect_commands.asm).
+Pinned source blob: 0100aadbd8d8efe2495422d3209c9ffe9068533e. Relevant lines:
+player sleep160/178, player confusion256/260, enemy sleep387/391 and enemy
+confusion484/489. Existing Gen1Recomp/Cedric licences and credits remain intact;
+see compat/gen1-gsc/ and LICENSE-ROXAS2712.md.
+
+## Verification and limits
+
+gen2_status_presentation drives the real native statusInterrupt, queue, public
+misc admission and command VM for Gold/Silver/Crystal, both sides, old flat and
+original source-native animation schemas, English/German captions and the
+scene-off option. It covers late paralysis, actual confusion damage, exact
+message ownership, explicit registered metadata, misleading text, wake/snap/
+flinch/skip/missing-program paths, PP/random draw preservation and non-GSC
+animation identities. The fork also runs its existing GBA Sleep Talk/Disable/link/secondary suites
+as regression coverage. The adapted upstream suite tests its own actual
+controller and optional idle picture/queue boundaries independently.
+
+A separate local original-Crystal-ROM run uses ROM-free battlers with actual
+ROM program data. Eight fork-native sleep/confusion cases (both sides, both
+languages, ordinary move animations off) match the frozen reference's complete
+command/object/OAM/BG state. The actual programs take198/98 VM frames; each
+passes the public entry point once and adds no battle random draw beyond the
+native confusion decision. Existing full428-program/ID comparison,2804 cases/
+355718 VM steps, picture data and native Fly/Dig checks still pass.
+
+Original-ROM evidence belongs to the fork; it is not an independent ROM run
+on this adapted tree. Adapted source has its own ROM-free qualification. No new GPU, audible
+output, unchanged-VASC visual, OS-window, Windows, performance or packaged
+release acceptance is claimed. This change does not overhaul GSC's remaining
+native sleep/recharge/turn rules; their separate gameplay audit remains open.
+No mod, original ROM or player profile is changed, and no ROM bytes/art are
+committed.
diff --git a/docs/native-gen2-trainer-friendship.md b/docs/native-gen2-trainer-friendship.md
new file mode 100644
index 000000000..b21b759eb
--- /dev/null
+++ b/docs/native-gen2-trainer-friendship.md
@@ -0,0 +1,66 @@
+# Native GSC trainer friendship and source class predicates
+
+The shared native trainer constructor now applies the original Gold/Silver/
+Crystal `GYMBATTLE` friendship event before the first turn. Previously the
+award was coupled to `BattleState:enter()` and the narrower badge-leader
+marker: the Elite Four, Champion and Red received no award, and construction
+alone did not expose the updated values to source consumers.
+
+`src.battle.gen2.Battle` exposes the source `GYM_LEADER_CLASSES`,
+`KANTO_GYM_LEADER_CLASSES`, `isGymLeader(class)` and
+`isKantoGymLeader(class)` contracts. The first predicate covers all 22 classes
+in the cartridge's combined leader list; the second covers its eight Kanto
+classes. The actual native constructor calls the public predicate, so an
+API2 class decoration affects the real battle rather than a second model.
+
+Imported native records retain their numeric group index. The engine maps
+that identity to the canonical class identifier rather than comparing the
+localized display name, which is often just `LEADER`. It does not mutate the
+imported trainer record or claim that every source trainer-view field is
+implemented.
+
+The award uses the existing native friendship owner and the battle's ordered
+party view. Living, non-egg members receive +3 below 100, +2 below 200, or +1
+otherwise, clamped to 255. Excluded party members are untouched. API2
+`happiness.changed` callbacks carry the original native mon and the source
+symbolic event (`GYMBATTLE`); the numeric cartridge index remains available
+as `eventId` (4). Applied events at saturation (and other zero-delta happiness
+events) remain observable, as on the reference engine; unchanged walking
+steps remain silent.
+
+The award is not replayed by screen entry. Native badge/music and Yellow companion markers keep
+their separate meaning. Other editions and Gen2 hacks keep their existing
+friendship rules. No mod, Gen1 installation, player profile, save or ROM is
+modified by this change.
+
+## Older-host party prerequisite
+
+This offered host did not have the fork's battle-local party selection or
+`playerPartyView()`. Supply the original native selection integration here:
+validate and retain ordered original mon references in the trainer constructor,
+then use the same roster for the lead, doubles, native party menus, benches,
+party-wide effects, EXP/EXP.ALL and defeat decisions. Never replace or reorder
+`save.party`. Invalid selections fall back to the complete original party.
+
+The standalone `native_trainer_party_scope` regression drives actual older-host
+constructors, generation-appropriate menus, AI, Heal Bell/Beat Up, EXP and defeat
+across its supported editions. Qualification requires execution on this offered
+host; the corresponding fork result is not inherited. Execution status is
+recorded in the draft PR.
+
+## Validation
+
+The committed ROM-free regressions exercise real `BattleState.newTrainer`,
+the production API2 loader and event bus, all vanilla classes and seven
+friendship boundaries, native egg representations, fainted/selected party
+members, decorator reachability and other-edition guards. This older offered
+host lacks the fork's semantic checkpoint backend, so the fork-only checkpoint
+test/result is not inherited. Execution results and independent frozen-reference
+comparison are recorded in the PR after running those checks.
+
+This closes the class-predicate/constructor-award gap. It does not establish
+complete source-model constructors, turn/event parity, graphical parity,
+unchanged VASC acceptance or release readiness.
+
+Native integration/tests by Roxas2712, offered to Cedric / UNDERdecoded under
+the specific PR permission in LICENSE-ROXAS2712.md; existing rights remain intact.
diff --git a/docs/native-gen2-unown.md b/docs/native-gen2-unown.md
new file mode 100644
index 000000000..2706cc7c2
--- /dev/null
+++ b/docs/native-gen2-unown.md
@@ -0,0 +1,42 @@
+# Native GSC Unown state
+
+`src.pokemon.Gen2Unown` shares native Gold/Silver/Crystal form identity and
+catch-order state between sprite selection, battle captures, script gifts,
+VAR_UNOWNCOUNT, the native Unown dex and public consumers. Stored reference
+letter overrides and DV-derived forms use the same sprite/counter decision.
+The byte calculation follows the existing native GetUnownLetter arithmetic.
+
+`src.core.gen2.Unown` is an explicit adapter to this service. `word(letter)`
+reads the active native `Game.data.unown_dex.words`; an explicit native dataset
+may be supplied as a second argument. No retail word catalogue or picture
+paths are embedded in the adapter. `forms(pokemon)` maps A–Z names to the
+actual numeric native form records; `formSprite` resolves the same records.
+Reads follow the current dataset instead of retaining an old edition's data.
+
+The native save remains `save.pokedex.unownDex` and `unownForms`. Public `dex`
+reads return a detached list. Known ordered forms keep their order; missing
+valid entries from an older set-only save are appended alphabetically. Reads
+do not migrate saves. A new form writes the normalized ordered list and set;
+no separate `save.unownDex` is created. New party and PC captures and script
+gifts register only after successful transfer. Eggs and refused transfers do
+not register a form.
+
+`unown.unlocked` reports a newly recorded form once, after native state is
+committed. Payload fields are `letter`, `name`, imported `word` and `count`.
+Native API1/2 consumers use this same producer. Repeated catches,
+reentrant duplicate writes and another species do not fire the event.
+
+## Verification and limits
+
+ROM-free coverage includes all 65,536 packed DV byte combinations, native
+sprite and stored-letter selection, actual party/PC capture and gift paths,
+old save reads, refused full storage, native counter/menu agreement, imported
+word replacement, actual Loader consumers and event rollback boundaries.
+Real-ROM checks are separate private receipts; ROM data is never committed.
+
+This does not establish the complete reference Unown API: unlocked-set/wild
+DV selection, puzzle/encounter gates, direct mutation of reference-shaped view
+tables and the remaining legacy Pokédex screen protocol need further work.
+Polished Crystal's 28 forms and other editions retain their previous native
+catch paths. Full Unown story/GPU/hardware and complete VASC/Gen1 parity are
+not claimed. VASC files and protected Gen1/player instances are unchanged.
diff --git a/docs/native-gen2-vanish-presentation.md b/docs/native-gen2-vanish-presentation.md
new file mode 100644
index 000000000..dd2070905
--- /dev/null
+++ b/docs/native-gen2-vanish-presentation.md
@@ -0,0 +1,65 @@
+# Native GSC Fly/Dig presentation
+
+The shared controller previously replaced a first charge with Gen1's
+`TELEPORT` or `SLIDE_DOWN_ANIM`. Its genuine GSC interpreter accepted
+`env.flying`, but actual move admission never bound either native battler.
+`SHOW_MON` and the two battler-row OBJ effects could therefore treat an absent
+Fly/Dig target as present. A cleared picture box also had no persistent native
+owner between moves.
+
+## Result
+
+GSC charge moves retain their own native ROM animation row with parameter 1;
+the release uses parameter 0. Existing damage, accuracy, PP, charge flags,
+charge-required hooks and random draws retain their native owners. Gen1/GBA
+charge selection is unchanged.
+
+`Gen2VanishPresentation` observes the actual native `invulnerable`/`hiddenAs`
+state and the admitted row's genuine interpreter. The first takeoff/burrow
+starts visible; only completion of that animation latches the cleared box.
+Each new VM receives its own snapshot of both battlers through the native
+`isUnderground(side, mon)` reader. A retained runner keeps its original flags.
+The release holds an already-cleared box until its actual BG `SHOW_MON`
+produces `picSize`, or until normal completion/fallback releases it.
+
+Cancelled/missing/disabled animations, replacement, switch and faint retire
+only their own picture relationship. Native picture-state readers and the existing native drawer share the same
+visibility. Reads never step an interpreter, spend PP
+or consume RNG. The latch has at most one record per active side and does not
+allocate work per draw.
+
+This upstream adaptation uses the existing native updateQueue owner. That
+source tree has no semantic checkpoint service; none is invented here. The
+fork has an additional checked integration with its existing checkpoint graph.
+
+## Reference and verification
+
+The timing follows the frozen Gen1Recomp reference
+`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, especially
+`src/ui/gen2/BattleState.lua` (`isUnderground`, `startAnim`, `revealVanished`)
+and its documented GSC `BattleCommand_Charge` / `CheckFlyDigStatus` behavior.
+Existing Gen1Recomp/Cedric rights and interpreter provenance remain intact;
+see `compat/gen1-gsc/` and `LICENSE-ROXAS2712.md`. Original contributions are
+offered to Cedric / UNDERdecoded under the latter permission, including
+visible credit; earlier grants and other authors' terms remain effective.
+
+The ROM-free `gen2_vanish_presentation` suite drives the actual shared native
+controller, queue, command VM and BG/object pools for Gold, Silver and Crystal,
+legacy and original source-native schemas, both attacking sides, both charge
+moves and an ordinary visible charge. It also covers missing/disabled data,
+cancel/replacement/switch/faint/missed release, retained runners, PP/RNG reads
+and non-GSC isolation. `gen2_vanish_checkpoint` drives the actual public
+semantic capture/restore and the restored second turn.
+
+A separate local actual-Crystal-ROM check runs both Fly/Dig halves through the
+same controller with ROM-free test battlers. Actual ROM timings on both sides:
+Fly charge 66 VM frames, Dig charge 179; release SHOW_MON at frame 34 and
+completion at frame 67. A hidden-target Growl runs 170 VM frames with complete
+command/object/OAM/BG state equal to the frozen reference receiving the same
+flags. No ROM bytes, mod changes, saves or machine paths are committed.
+
+These are engine CPU/ROM-data checks. They do not qualify unchanged VASC,
+visible OS windows, GPU output, Windows, performance or a packaged release.
+The upstream adaptation uses its existing native queue; its source tree has
+no semantic checkpoint service, so that fork-only service is neither invented
+nor claimed there.
diff --git a/docs/native-gen2-volatile-state.md b/docs/native-gen2-volatile-state.md
new file mode 100644
index 000000000..32fdaf375
--- /dev/null
+++ b/docs/native-gen2-volatile-state.md
@@ -0,0 +1,35 @@
+# Native GSC volatile state
+
+The live Gen2 battle model exposes `volatile(mon)` as a view of its existing
+native battler, rather than a disconnected metadata table. Canonical source
+names `confuseCount`, `flinched`, `endure`, `recharge`, `protectCount`,
+`focusEnergy` and `xAccuracy` address the native confusion counter, admission,
+damage, action-lock, protection, critical and hit-check owners. Substitute,
+protect and mist aliases are also supported. Unknown keys remain transient
+consumer metadata, not newly implemented mechanics.
+
+Views are cached on the transient model only. Module-global weak-key caches can
+retain retired battlers on LuaJIT when value closures reference the key. The
+same active native owner has a stable view; withdrawn entries retire on query
+and incoming wrappers get new views. No view or metadata is stored in a party
+mon or serialized battler.
+
+GSC consecutive-protection retention follows the actual PROTECT/ENDURE effect,
+including renamed moves; an old PROTECT ID with another effect resets it.
+Protection RNG, opponent-first and Substitute rules are addressed by the
+separate [native protection work](native-gen2-protection.md).
+No second battle simulator is introduced.
+
+On this older host, 423 real native state/mechanics/ownership/collection checks,
+262 model checks and 83 move-priority checks pass independently. The fixture
+loads an actual API2 source consumer and covers Gold/Silver/Crystal, Prism and
+Polished Crystal. The fork's additional semantic-checkpoint backend is absent
+here, so its checkpoint result is not claimed for this offered branch.
+These ROM-free CPU checks do not establish complete source-model, GPU,
+unchanged-mod or release acceptance. Depends on the native model boundary and
+move-priority contributions offered separately.
+
+Source-field names were inspected against Gen1Recomp Gen2 revision
+`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Native integration and tests are
+contributions by Roxas2712, offered to Cedric / UNDERdecoded under the fork's
+specific PR contribution permission, with existing third-party rights intact.
diff --git a/docs/native-screen-position.md b/docs/native-screen-position.md
new file mode 100644
index 000000000..1364c9e2e
--- /dev/null
+++ b/docs/native-screen-position.md
@@ -0,0 +1,48 @@
+# Native screen positioning
+
+The source Gen1/Gen2 option `screenPos` selects CENTER, UPPER or TOP. The
+newer fork listed that key in its Gen2 video group but had no descriptor,
+runtime module or compositor application. This older native host also lacks
+the setting and source helper. Add the actual option, apply it on load,
+and keep the default CENTER geometry.
+
+`ScreenPosition` retains the source normalization/cycling/lift contract.
+The native renderer applies the lift in whole framebuffer pixels, converts
+it using its actual vertical DPI, and shares the result between world and
+UI origins. The world canvas grows below the raised camera to keep covering
+the window. Source Game2 drawable queries and native Gold menu queries
+observe the same positioned rectangle. Safe-area top insets bound the lift;
+active touch-skin and native split-screen owners keep their own origin.
+Selecting a position while a skin owns the viewport leaves the stored
+selection intact and displays SKIN.
+
+The source helper is adapted from Gen1Recomp revision
+`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, copyright BOIS CLUB GAMES, LLC,
+under the retained GPLv3/Section 7 terms in
+`licenses/GEN1RECOMP-TOUCH-SKINS.md`. Native integration by Roxas2712 retains
+those terms. No mod, protected Gen1 install or player profile is changed.
+
+Implementation `f137c679` passes 1,145 selected contract checks across eight
+editions, portrait/landscape/Retina sizes, all three positions, safe insets,
+the actual skin parser, source queries and native option input/save/boot
+application. Existing frame-query 4,454, chrome 116, viewport 56, skin 59,
+playfield 585 and battle-window 315 checks also pass. A later probe adds
+actual final world/UI blit observations; that extended probe has not yet
+been run at this checkpoint.
+
+Complete composed-tree tests, actual GPU/window acceptance, every custom
+skin/device and final release qualification remain open. This offer is stacked on PR397 and its existing viewport/chrome prerequisites.
+It uses this older host's actual `Renderer:endFrame` and flat options menu,
+without adding the newer fork's `frameRects` or grouped-menu methods. Its independent qualification on offered source `b83c8fa4` passes 1,031
+screen-position checks, including actual world/UI blits and real flat-menu
+input/save/boot application. Its existing 4,454 frame-query and 116 chrome
+checks also pass. The managed frozen host is cleaned after successful exit.
+These are selected plain-Lua/compositor-stub proofs, not GPU or full-suite
+qualification of this older host.
+
+Existing engine authors' rights remain effective. Independently owned integration/test
+contributions by Roxas2712 are offered under
+,
+including the specific Cedric / UNDERdecoded permission with visible credit.
+The source helper and its integration retain their existing GPLv3/Section 7
+provenance and terms; that offer does not narrow earlier grants.
diff --git a/docs/native-touch-skins.md b/docs/native-touch-skins.md
new file mode 100644
index 000000000..bb7002172
--- /dev/null
+++ b/docs/native-touch-skins.md
@@ -0,0 +1,85 @@
+# Native custom touch skins
+
+Upstream stack: this change is based on the offered viewport/window contract
+PR #124 (and its prerequisites). Merge those first. The runtime needs their
+actual game rectangle and input mapping; no viewport stub is provided here.
+
+The shared engine now supplies the real `src.core.TouchSkin` service used by
+Gen1 mods. This is engine work; mod files and their optional geometry consumers
+do not need changes. The implementation is adapted from Gen1Recomp
+`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, with its original copyright and
+[licence](../licenses/GEN1RECOMP-TOUCH-SKINS.md) retained. No proprietary Gen1
+launcher code or bundled third-party skin artwork is included.
+
+## Installed skins and options
+
+Skins live under `skins//skin.lua`, `skins//overlay.cfg`, or in an
+installed `skins/.zip` / `.deltaskin` inside the engine's writable storage.
+`TouchSkin.installArchive(name, bytes)` installs archive bytes through the same
+persistence filesystem used by native options. `TouchSkin.list()` discovers
+native, RetroArch and Delta configurations; `load` loads their artwork and
+controls. A wrapped Delta archive may contain its metadata in a subdirectory.
+
+When installed skins exist, native OPTIONS offers **TOUCH SKIN**. LEFT/RIGHT
+cycles the library and DEFAULT, applies the choice immediately and saves
+`options.touchControls.skin`. DEFAULT restores the existing native pad. The
+position/size editor retains this selection in its save snapshot. Turning the
+pad off clears the active skin and its cutout. Invalid/missing selections
+restore the native presentation and expose `TouchControls.skinError`.
+
+Mobile OPTIONS also offers **HAPTICS**: OFF, LIGHT, NORMAL and STRONG, with
+Gen1's light default and legacy-name normalization. The preference is shared
+across cartridges, outside position/size layout snapshots. The first owner of
+a button/key/action pulses through the real `love.system.vibrate`; additional
+contacts holding that same input, release and focus reset do not retrigger it.
+Runtimes without a vibrator retain normal input. Unit pulse checks do not
+constitute physical phone vibration acceptance.
+
+The custom-skin studio/library editor and its launcher integration remain a
+separate, open Gen1 parity requirement. This runtime PR does not claim that
+the existing native position editor implements that studio.
+
+## Input and presentation owners
+
+* Controls support GB/GBA buttons, combinations, directional areas, sectors,
+ arbitrary key bindings, held speed actions and overlay page switching.
+ Multiple contacts share per-button and per-key ownership. Release, a reused
+ pointer id, cancellation polling and focus reset release the correct holds.
+* The real `Game.touchSkinHotkey` routes held/toggled speed, reset and OPTIONS
+ through native engine owners. Existing fixed-speed state locks still apply.
+* Native `GameViewport` fits the game into the active skin's screen cutout and
+ maps gameplay coordinates into it. Explicit `render.viewport` mods retain
+ final layout authority; the skin is their default rectangle. Unsupported
+ native combined-screen layouts retain their existing owner.
+* Bezel/control artwork draws in OS-window space after final composition.
+ A desktop bezel survives controller use without capturing gameplay input.
+ With no selected skin, the viewport stays on its existing direct path.
+* The native default pad, Gen4 X/Y layout, sticks, free-camera drag and touch
+ cancellation recovery are retained; the Gen1 controller file is not used
+ as a replacement for those implementations.
+
+The library retains native/RetroArch/Delta export, native editing data helpers,
+image import and viewport detection. Raster JPEG data embedded in a PDF may
+be recovered; vector-only PDF artwork reports that conversion is required.
+
+## Verification scope
+
+ROM-free suites:
+
+```sh
+luajit tests/engine/touch_skin_test.lua
+luajit tests/engine/touch_skin_dpad_area.lua
+luajit tests/engine/skin_format_import_test.lua
+luajit tests/engine/touch_skin_native.lua
+luajit tests/engine/touch_haptics.lua
+luajit tests/engine/touch_orientation_bug633.lua
+luajit tests/engine/game_viewport.lua
+```
+
+The first three preserve Gen1 format/input contracts, with third-party bundled
+art assertions replaced by owned memory-only assets in the native suite. The
+native suite also exercises the actual mod loader, normal OPTIONS input,
+save-codec round trips, contact ownership, rendering calls and viewport hooks.
+Archive-member fixtures model PhysFS mounts explicitly; they are not evidence
+of real ZIP decoding or GPU/window acceptance. Real runtime import, both GPU
+backends, rotation and packaged desktop/mobile builds still require acceptance.
diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md
new file mode 100644
index 000000000..ad8e876c9
--- /dev/null
+++ b/docs/portable-world-scripts.md
@@ -0,0 +1,514 @@
+# Portable dialogue scripts
+
+API-2 mods can opt into a bounded, prevalidated language for
+`mod.world:queueScript(rows, options)`:
+
+```json
+{
+ "id": "quest_dialogue",
+ "name": "Quest dialogue",
+ "version": "1.0.0",
+ "api": 2,
+ "entry": "main.lua",
+ "script_format": "unified"
+}
+```
+
+Check `mod.scriptFormat == "unified"` in the entry file. Older engines may
+ignore unknown manifest fields; checking the marker prevents a portable row
+list from silently reaching their native script runner. Omitted or explicit
+`"native"` formats preserve existing API-1/API-2 queueScript behavior. Other
+values, or API 1 with `"unified"`, are load errors. Content and callback format
+opt-ins are independent. The engine's Mod API major remains 2.
+
+## Contract
+
+```lua
+return function(mod)
+ assert(mod.scriptFormat == "unified", "portable scripts required")
+
+ -- Run this from an idle-world interaction/update, not at entry time.
+ local function offerQuest()
+ local ok, why = mod.world:queueScript({
+ { "ask", "Help with the quest, {PLAYER}?" },
+ { "jump_if_false", "declined" },
+ { "show_text", "Thank you!" },
+ { "jump", "end" },
+ { "label", "declined" },
+ { "text", "Maybe another time." },
+ }, {
+ onDone = function(completed, reason)
+ if not completed then mod.log:warn("Dialogue stopped: %s", reason) end
+ end,
+ })
+ if not ok then mod.log:warn("Dialogue not started: %s", why) end
+ end
+end
+```
+
+Admission returns `true`, or `nil, reason` without executing any prefix of a
+rejected program. The entire list, every row/argument and every jump target is
+validated and copied first, including unreachable rows. Caller edits to the
+original list, choice labels/options or callback options cannot change an
+accepted program. Reading the facade and an early request do not construct
+Game. A request with no live save/world fails quietly.
+
+`true` means admitted, not successfully completed. After admission,
+`onDone(true)` reports completion and `onDone(false, reason)` reports a runtime
+failure or cancellation. It fires exactly once, including failures during the
+initial dispatch. It is not called for rejected requests. A throwing completion
+callback is isolated and attributed to its mod; completion is not retried.
+Already entered native work is not rolled back or automatically replayed.
+
+Only `onDone` is accepted in options. Programs and rows must be dense plain
+arrays without metatables or extra keys. Functions/userdata/native handles are
+not command arguments. Choice options are a closed plain table.
+
+## Initial command set
+
+| Row | Meaning |
+| --- | --- |
+| `{ "show_text", text }` / `{ "text", text }` | Native manual TextBox; wait for all pages and final dismissal |
+| `{ "ask", text }` | Native text and YES/NO overlay; YES is true, NO/B is false |
+| `{ "choice", labels, opts }` | Native N-way menu; choice 1 is true, every other choice is false |
+| `{ "wait", frames }` | Native logic-frame wait, integer 0–36000; zero waits until the next world update |
+| `{ "label", name }` | Named jump destination |
+| `{ "jump", target }` | Unconditional jump |
+| `{ "jump_if_true", target }` | Jump after a true answer |
+| `{ "jump_if_false", target }` | Jump after false/nil, including before any answer |
+| `{ "end" }` | Finish the program |
+
+Text is literal UTF-8, up to 4096 bytes per row, without NUL. Native TextBox
+placeholder substitution (e.g. `{PLAYER}`), prose pagination, typing, scroll/
+page holds and answer holds remain in use. These rows do not resolve native
+text registry IDs or lower ROM script bytecode. Future pages are not skipped.
+
+`labels` contains 1–16 nonempty UTF-8 strings of at most 128 bytes each.
+Optional `opts.default` and `opts.cancel` are valid 1-based indices; defaults
+are 1 and the last choice. B follows `cancel`. Answers remain internal to the
+program for conditional jumps; the completion callback reports success/failure.
+
+Labels contain letters, digits, `_`, `-` or `:`, at most 64 bytes, and are unique.
+`end` is reserved. Jump targets are existing labels, valid 1-based row indices,
+or the string `"end"`. No arbitrary native verbs, custom command handlers,
+extra callback arguments or executable rows are accepted.
+
+A program has at most 1024 rows and 128 KiB of text/choice labels. Execution
+yields after each slice of 128 instructions and fails after 65536 instructions
+in total. This bounds authored jump loops without a Lua debug hook or a frame
+stall; it is not a wall-clock deadline on a player reading text.
+
+## Ownership and cancellation
+
+Admission requires the current overworld to own the top of the stack. Native
+scripts, queued/parallel scripts, movement, transitions, field animations,
+input/controller locks, link sessions and another portable program decline.
+Only one portable foreground program can own a Game at a time.
+
+An accepted program temporarily installs an isolated instance of the native
+ScriptRunner. Its `isRunning()` participates in the world's existing input and
+script-scheduling gates. It uses native TextBox, ChoiceBox and Menu classes,
+including their normal keyboard/touch handling and delayed answer completion.
+The idle runner is restored at completion/cancellation, provided it has not
+already been replaced by another controller. Global runner/UI classes and the
+native command registry are unchanged.
+
+`mod.world:cancelScript()` cancels only this facade's active portable program.
+It returns `true`, or `false, "no portable script"`. It cannot cancel another
+mod or a native story script. Cancellation removes only the run's exact native
+windows, even beneath a foreign overlay, and releases its coroutine. It does
+not pop unrelated screens or release another controller's locks. A covering
+foreign screen pauses normal progress. Removing an awaited owned window
+externally cancels at the next input-step boundary.
+
+A replaced Game/save/data/stack/world/map/player/runner, changed map identity or
+player position/version, transition, link session, mod rollback, reload or
+teardown cancels the program. Session changes are checked at input-step and UI/
+runner boundaries, including while the world is covered. Retained callbacks
+cannot resume a cancelled program, skip a later row's wait or pop a new screen.
+Facades from an unloaded/reloaded/failed mod are retired.
+
+Native `script.started`/`script.ended` lifecycle events retain their context
+shape; `source.modId` attributes the portable run and `completed` describes its
+outcome. End notification/completion follow owned cleanup. Portable rows use
+this validated language rather than resolving native/custom handlers or firing
+`script.command` wrappers; native-format scripts retain those contracts.
+Direct changes to engine objects from such event handlers or other mods remain
+outside this boundary's guarantees; this is not a mod security sandbox.
+
+When optional capability queries are installed, unified mods can probe
+`mod:supports("scripts", verb)` and `mod:supports("world", "cancelScript")`.
+Unknown/unimplemented portable verbs return false. The existing native
+`world/queueScript` capability is not, by itself, evidence of this opt-in.
+
+## Scope and verification
+
+This first adapter covers dialogue/control flow across the 15 supported
+version selections (Gen 1–5). Items/Pokémon, world movement/warps,
+battles, shops and other Gen1Recomp portable verbs need separately verified
+adapters; they refuse the complete list before the first effect here.
+
+The contract is informed by Gen1Recomp's
+[UnifiedScripts](https://github.com/bryanthaboi/gen1recomp/blob/79ea62f45fb2ea8f65d081a109431e4eb1bbdb36/src/mods/UnifiedScripts.lua).
+This implementation uses this host's native shared UI and runner architecture.
+
+Run `luajit tests/engine/portable_world_scripts.lua` and
+`LUA=luajit bash scripts/test.sh`. The ROM-free suite uses the real Loader,
+ScriptRunner, state stack and native UI classes. It compares complete per-frame
+native/portable dialogue traces, checks preflight/argument copying, rejection
+immutability, identity/session changes, replay, cancellation/reentry, budget and
+native failures. Fixture runs do not establish rendered retail-ROM coverage.
+
+When the unified content/callback PRs are also present, run
+`PORTABLE_SCRIPT_ALL_FORMATS=1 luajit tests/engine/portable_world_scripts.lua`
+to repeat the suite with all three format opt-ins enabled together.
+
+## Portable flag commands
+
+The validated language also supports:
+
+| Row | Meaning |
+| --- | --- |
+| `{ "set_flag", reference }` / `{ "setflag", reference }` | Set a private/native boolean flag. |
+| `{ "clear_flag", reference }` / `{ "clearflag", reference }` | Clear it. |
+| `{ "check_flag", reference }` | Set the conditional result to the flag's boolean value. |
+| `{ "wait_flag", reference, timeout? }` | Yield until true or until the optional timeout expires; set conditional result true/false. |
+
+References use [API-3 flag addressing](api3-flags.md): `mod:` is private to
+the initiating mod, while native symbols/numbers retain their version-specific
+meaning. Crystal native references remain numeric-only; unknown hack mappings
+are declined. These rows are available through the unified script opt-in and
+API 3. Gen1's pinned reference does not offer the host's `scripts` capability
+query surface; portable API-3 mods can use the common world/queueScript query,
+while host-specific tools may inspect scripts/.
+
+Every reference, including unreachable rows, is validated and copied before
+admission. All referenced save stores are checked before the first effect;
+an invalid later row never permits an earlier set_flag to execute. Native and
+private writes share the public flag implementation, including private ownership,
+native storage, reentry protection and Gen3 visibility synchronization. Native
+flag handling uses the already resolved world and never asks the loader to boot
+Game.
+
+A timeout is a whole number from 0 to 36000 logic frames. Omission waits until
+signaled or cancelled. Even a zero timeout or already-set flag yields once;
+on the next condition poll a true flag wins over timeout expiry. Waiting pauses
+under a foreign covering screen, preserving its lifetime and timeout. Waits do
+not consume the script instruction budget on every frame. Existing cancellation,
+owner/session checks, one-shot completion and instruction/slice budgets apply.
+A flag store that becomes malformed after admission fails the run and restores
+the original runner; already executed native writes are not rolled back.
+
+The identical public-only shared mod verifies branching, both aliases, signal
+resume, timeout and complete-list refusal on both engines for eight versions.
+It extends the pinned suite to 80 comparisons /160 isolated loader runs.
+Local tests additionally verify detached rows, malformed stores/timeouts, paused
+foreign overlays, native flag conditions and stale-session/owner cancellation.
+The existing 5268-check dialogue suite remains unchanged and passing.
+
+## Wallet, inventory and party state commands
+
+The bounded language also accepts these synchronous rows:
+
+| Row | Meaning |
+| --- | --- |
+| `{ "give_money", amount }` / `{ "take_money", amount }` | Add/subtract money; subtraction saturates at zero. |
+| `{ "check_money", amount }` | Set the conditional result to whether the current wallet covers the amount. |
+| `{ "take_coins", amount }` | Subtract coins, saturating at zero. |
+| `{ "check_item", itemId }` | Set the conditional result to whether the bag contains the item. |
+| `{ "take_item", itemId, count? }` | Remove up to count (default one), using native Bag.remove/order cleanup. |
+| `{ "check_dex_owned", amount? }` | Check the owned/caught species count, default threshold one. |
+| `{ "mark_seen", speciesId }` | Mark an existing species seen using the host's native storage key. |
+| `{ "heal_party" }` | Heal native party HP/status and restore PP from the current Game's move data. |
+
+Amounts are finite whole nonnegative numbers up to Lua's exact integer limit
+(2^53-1). Item count is 1–999. Item/species IDs are canonical strings, resolved
+through the existing loader index rather than assumed cartridge numbers.
+Missing/ambiguous content, malformed referenced wallets/inventory/dex/party
+stores or malformed arguments reject the complete list before any effect,
+including unreachable rows. Accepted rows are detached; live stores are checked
+again when dispatching after a wait. Native effects already executed before a
+later runtime failure are not rolled back.
+
+Wallet writes retain the verified Gen1 API's generation behavior: Gen2/3
+addition caps at 999999; Gen1 retains its native unbounded script wallet.
+Queries and item/coin removal do not create gifts or UI. Inventory and party
+structures retain this host's native layouts. The optional moves argument on
+Pokemon.heal lets this path use Game.data instead of an unrelated Data singleton;
+existing native callers keep their default behavior.
+
+The same public script compares branches and resulting money, coins, item
+quantity, HP/PP and seen state through both native backends on eight versions.
+The full pinned checker now runs 88 comparisons /176 isolated loader runs.
+Host tests add malformed/unreachable-row refusal with no prefix effects, wallet
+caps, saturation, actual Bag order removal, live-store failure and argument
+copying. These are ROM-free runtime/store checks, not a story playthrough.
+
+## Item and Pokemon gifts
+
+- `{ "give_item", itemId, count?, gotText?, bagFullText? }`: use the native
+ bag gift command. Count defaults to one (1–999). Optional receipt/full-bag
+ text is literal UTF-8 up to 4096 bytes; `gotText=false` suppresses the receipt.
+ A full bag shows its native failure message and halts before later gift flags.
+- `{ "give_pokemon", speciesId, level, skipNickname?, gotText? }`: use native
+ creation, OT stamping, party/box placement and nickname flow. Level is 1–100;
+ the optional flags are actual booleans. `gotText=true` requests a receipt
+ before the nickname question. A full party sends the gift through native box
+ storage and waits for its sent-to-box text; completely full storage leaves
+ the native conditional result false for the script to branch on.
+
+The complete row list's content references, counts/options and referenced
+inventory/party/player/dex/box store shapes are checked before a prefix runs.
+IDs are canonical, but native bag capacities, native Pokemon fields and native
+storage layouts remain the host's. Already entered native effects are not a
+transaction. Scripts cannot pass executable callbacks or native options into
+these rows. Gifts do not introduce new species constructors or storage slots.
+
+The isolated runner adopts exact native screens created by the gift operation
+and screens subsequently created by those owned screens. Native receipt,
+YES/NO and naming callbacks receive per-operation, per-phase one-shot guards;
+an old receipt/question/naming callback cannot mutate a nickname twice, bypass
+a later screen or skip the next row's wait. Cancellation removes owned screens
+and retains a foreign overlay. A mod retired by its before-give hook cannot
+continue into native Pokemon construction. Native callers outside the portable
+context retain their existing callback/default behavior.
+
+The same public mod on both engines verifies item and Pokemon gifts with
+receipt/nickname suppressed, real native bag/party/dex effects and whole-list
+preflight refusal. This extends the pinned suite to 96 comparisons /192 isolated
+loader runs. Host tests additionally drive actual native receipts, bag-full
+messages, YES/NO, naming, box deposits, callback replay and unload/session
+cancellation on synthetic data. Rendering/pixel equivalence and retail-ROM
+playthroughs are not claimed. Direct mutations of engine objects by other mods
+remain outside the portable runner's ownership guarantees.
+
+
+## Object and block scripts
+
+API 3 also accepts `show_object(mapId, objectRef)`,
+`hide_object(mapId, objectRef)` and `replace_block(bx, by, blockId)` on
+Gen 1–3. Object references must name an existing definition; Gen 2/3
+require the active map. Block coordinates use the same geometry and limits
+as `mod.world:replaceBlock`. Validation covers every row before any prefix
+runs, then repeats against current stores at dispatch.
+
+These rows run through the validated native edit plans under the script's
+existing foreground ownership. Ordinary public world actions retain their
+idle-world admission guard. Gen 1's seamless object refresh may replace
+both the map and its default runner: the script continues on that map and
+restores the new idle runner when finished. Foreign overlays pause the
+program; unloading, changed saves/maps and invalid live stores cancel it.
+Edits already committed before cancellation are retained, and public world
+events follow the native edit behavior.
+
+## Movement and facing
+
+On Gen1–3, API 3 accepts `move_player(dir, tiles?)`,
+`move_npc(ref, dir, tiles?)`, `move_npc_to(ref, x, y)`,
+`place_npc(ref, x, y, facing?)`, `face_object(ref, dir, {hold=frames}?)`,
+`face_player_dir(dir)`, `face(dir)`, `face_npc()` and `face_player()`.
+`face` turns the talking NPC; `face_npc` turns the player toward that NPC,
+and `face_player` turns that NPC toward the player. The last three require
+an existing talking-NPC context. Object references resolve existing native
+actors on the active map. Counts are 0–1024 cells (default 1).
+
+All target references and argument shapes are checked before any command
+runs. Paths are computed against the live walkable map at dispatch, using
+a bounded search; an unavailable path fails the admitted program. Steps
+use the native movement queue and walker updates. Each completion is tied
+to its exact run, step and actor. Movement only permits its own expected
+player position changes. Callbacks are one-shot, and continuation waits
+for a foreground logic frame, so foreign overlays pause later effects.
+
+Cancellation removes only the run's exact queued entries and leaves foreign
+tracks intact. Invalid sessions/actors, refused native steps and a stalled
+step (3,600 foreground logic frames) terminate the script. A native step
+which crosses into a different map ends this run; use explicit `warp` for
+portable cross-map scripts when supported. These limits are intentionally
+reported rather than silently claiming arbitrary native movement parity.
+
+## Explicit travel
+
+Gen1–3 accept `warp(mapId, x, y, facing?)` and
+`remember_outdoor(mapId, x, y)`. Destination names, 0-based cell coordinates,
+dimensions and facing use the public API-3 warp planner. `remember_outdoor`
+sets the native outdoor/escape destination without travelling.
+
+Preflight follows the linear row list's map context, just as the reference
+engine does. Object/block and movement rows after a warp are checked against
+the destination's definitions; path/block geometry requires the destination
+tileset to be available. These plans do not load a live map or run callbacks.
+All dispatches still validate their actual live targets. Branches and native
+entry scripts can change runtime conditions, so successful preflight is not
+a guarantee that an admitted asynchronous program will complete.
+
+A warp uses the native transition and arrival bookkeeping. Its continuation
+binds to the destination map and idle runner only after the native arrival
+callback; it never resumes beneath a foreign overlay. A busy destination
+runner is preserved and the portable run fails. Talking-NPC context is
+cleared at arrival. One-shot callbacks, session checks and a 3,600-frame
+native-transition/foreground deadline prevent late script effects.
+
+The transition belongs to the engine: unloading or timing out a portable
+script leaves the already admitted native warp free to finish and release
+its own locks. It suppresses the script continuation, rather than undoing
+committed native travel or removing the engine's transition screen.
+
+## Wild and static battles
+
+Gen1–3 accept `start_battle("wild", speciesId, level)`,
+`static_battle(speciesId, level, beatFlag?)` and
+`check_battle_result(result, ...)` (1–4 of `win`, `lose`, `run`, `caught`).
+Species IDs are canonical strings. Levels are whole numbers 1–100.
+All species references and optional beat flags are checked before any
+prefix runs. Healthy-party admission is checked at dispatch, so an earlier
+`heal_party` can repair the party before the encounter.
+
+The private script admission guard reuses the public wild-battle adapter,
+native constructors, entry transition and `afterBattle`. Ordinary public
+calls retain idle-world admission. A static encounter sets its optional
+beat flag and hides the talking NPC on win, catch or flee; loss skips both.
+Result checks set the condition used by `jump_if_true`/`jump_if_false`.
+The foreground continuation waits behind native post-battle and foreign UI.
+
+Cancelling/unloading suppresses the script continuation while preserving
+native battle screens and native cleanup for the same live game/save.
+Changed sessions suppress that old-session cleanup. Native constructor,
+admission and cleanup errors fail the script; late callbacks are one-shot.
+No rollback of already committed battle effects is implied.
+
+Private beat flags use this host's same namespaced flag service on every
+generation. The pinned Gen1 reference delegates Gen1 static encounters to
+its native command, which does not route private beat flags through that
+namespace. The shared Gen1 static cases therefore exercise a real native
+beat flag; host-specific private-flag behavior has separate coverage.
+Trainer encounters are described below.
+
+
+## Trainer encounters
+
+`start_battle("trainer", trainerRef, partyIndex?)` uses this version's native
+trainer data and constructor. Gen1 normally selects a class key and numbered
+party; Gen2 accepts the native class number (resolved to the host's class
+record); Gen3 accepts a native trainer number through `trainerOrder`, with
+one party per trainer. Existing host keys are also accepted. These selectors
+are game-specific and are not universal trainer IDs. Use an explicit party
+index for shared Gen2 scripts: the pinned reference requires that argument.
+
+Preflight validates every referenced roster before a prefix runs and repeats
+that check at dispatch. Unknown species, invalid levels, empty/oversized
+parties, ambiguous class aliases and unavailable Gen3 flag metadata are
+refused. Native class aliases select the canonical class deterministically.
+Gen3 named reference constants are not guessed from display names: use the
+native trainer number for engine portability.
+
+Trainer battles share the same owned continuation, cancellation and native
+cleanup lifetime as wild encounters. Gen3 wins use the host's native trainer
+victory flag and Match Call registration helpers; loss does not mark a win.
+Cleanup remains valid after the originating mod retires, provided the same
+native session still exists. Native battle mechanics and game-specific story
+rewards remain the responsibility of this host's battle/field code.
+
+## Native marts
+
+`open_mart(textConstant)` opens the native mart selected by the active map's
+`data:textEntry(map.label, textConstant).mart` entry. Entries hold 1–99 item
+IDs; canonical names and known native item IDs resolve through this boot's
+item registry. Every mart row, including a row after a warp, is validated
+before any prefix runs. Missing data is refused rather than guessed from
+unrelated ROM pointers. Live dispatch repeats content/store validation.
+
+The screen factory and native buy/sell/quantity/confirmation flows remain
+in charge of transactions. The script waits until the root counter closes.
+UI callbacks are bound to their exact active screen and phase: closed,
+cancelled, superseded or retired quantity/confirmation callbacks cannot
+replay a transaction. Foreign overlays block callbacks and continuation.
+Nested owned screens are removed on cancellation while foreign UI remains.
+A retired constructor cannot leave its late native root on the stack.
+
+Wallet/inventory shapes, counts and prices must be representable (whole
+non-negative amounts; prices at most 999,999, inventory at most 4,096
+entries). Checked sale totals must fit the exact integer range. Replaced
+inventories or changed item records/prices cancel an open shop before later
+callbacks run. Committed native transactions remain committed after
+cancellation. The same guard covers classic lists, FireRed's inline sale
+quantity and Emerald's native sell bag. Closed UI records are released
+between frames rather than accumulated for a long shopping session.
+
+## Sound and music
+
+Gen1–3 accept `play_sound(id)`, `wait_sound()`,
+`play_music(id, {keep=boolean, reason=string}?)`, `stop_music()`,
+`fade_music(control?)`, `play_once(id)` and `play_default_music()`.
+The active game's native audio definitions must contain the cue. All rows
+are checked before a prefix runs and checked again at dispatch. Gen3
+numeric and extracted `musName`/`sfxName` aliases resolve to an unambiguous
+native sequence; unknown aliases are refused. Gen3 `play_music(0)` retains
+the native stop convention. `play_once("Get_Item1")` can resolve the native
+Gen3 obtain-item fanfare role.
+
+Playback uses the existing Sound/Music controllers. `wait_sound` waits for
+native SFX activity and its last returned source, with the reference's
+600-foreground-frame release ceiling. A one-shot waits on native restore
+state, releases if another song takes over, and fails after 3,600 foreground
+frames if it stalls. Foreign overlays pause continuation. Music `keep` is
+armed only while the script still owns the context. Fade control is a whole
+number 0–255 (default 10); timing follows this host's native fade controller.
+
+Names/numbers remain game-specific unless the mod supplies a shared cue.
+The reference Gen3 resolver can return an unknown string unchanged; this
+host requires a real definition before admission. Missing audio data is
+therefore a preflight failure; a muted/unavailable device keeps the native
+headless playback behavior. Already started audio remains engine-owned on
+script cancellation. Dialogue-bound cues are described below.
+
+
+### Deferred dialogue audio
+
+`play_cry(canonicalSpecies, waitForButton?)`, `text_sound(id)` and
+`engage_music(id)` arm the next `show_text` or `ask`, rather than playing
+while its text types. Species and native cue assets are checked before any
+row runs, at arm time, at box creation and again at playback. Native numeric
+cry identities are resolved through canonical species names. Unknown assets
+fail admission; muted devices retain native no-source behavior.
+
+The native TextBox waits for returned sources, with its 600-frame ceiling.
+An ordinary cry auto-closes after playback; `waitForButton=true` retains the
+button wait. Text sounds and engage-only music retain the button wait.
+Engage music starts once, after sound completion or the native ceiling.
+Questions always wait for audio before opening their native choice box.
+Gen3 sequence text cues use the native fanfare music duck controller.
+
+Retained callbacks cannot replay cues after the box closes, beneath a foreign
+overlay or after the script retires. Reentrant unload during playback aborts
+the native update before it can pop an unrelated UI state. Already started
+sources remain engine-owned. A combined cry and text sound plays both on this
+host; the reference Gen1 opts merge drops the text sound in that combination.
+Shared cases deliberately verify individual cues, not that reference defect.
+They drive real native dialogue controls with synthetic font/text data and
+a fake audio-device clock; they do not claim imported-ROM or PCM equivalence.
+
+### Save fields and options
+
+`set_field(key, value?)` writes a native save-root field; `mod:key` writes
+only the current mod's `save.modData[modId][key]`. `set_option(key, value?)`
+writes the native `save.options[key]` dictionary. As in the reference,
+`set_option("mod:key", value)` retains that literal option key; it does not
+redirect to a private bucket. Omitting the value deletes the selected entry.
+Native root field and option names/meanings remain game-specific.
+
+Values must be finite numbers, strings, booleans or acyclic plain data tables
+with string/positive-integer keys. Functions, metatables and foreign mod
+names (`mod.other.key`, `other:key`) are refused. Replacing the root `modData`
+store is refused so `set_field` cannot bypass ownership. This is intentionally
+stricter than the reference's unrestricted root write. A value is limited to
+16 nested levels, 1,024 nodes and 32 KiB of strings; the complete program is
+limited to 8,192 nodes and 128 KiB of field strings.
+
+The compiler snapshots values; each execution copies again so neither the
+caller nor an earlier loop iteration can mutate compiled data. All target
+store shapes are checked before admission and again at dispatch. A linear
+preflight accounts for an earlier root `options` replacement before a later
+`set_option`. Conditional/loop behavior can still change native store validity
+at runtime; a failed runtime check stops further rows. Root writes do not
+translate game-specific semantics or persist the save file automatically.
diff --git a/docs/render-output.md b/docs/render-output.md
new file mode 100644
index 000000000..a5995b5cb
--- /dev/null
+++ b/docs/render-output.md
@@ -0,0 +1,51 @@
+# Final render output for mods
+
+API-3 mods can take ownership of the normally composed frame:
+
+```lua
+mod.hooks:wrap("render.output_enabled", function(next)
+ return enabled or next()
+end)
+mod.hooks:wrap("render.output", function(next, ctx)
+ if not enabled then return next(ctx) end
+ -- Draw ctx.canvas through the mod's graphics/presentation service.
+ return true
+end)
+```
+
+`render.output_enabled` has no arguments and defaults to `false`. Capture is
+allocated only when an output subscriber exists and the predicate returns
+exactly `true`. With neither an output owner nor another present-stage effect,
+the renderer retains its direct path and allocates no present canvas.
+
+`render.output(ctx)` receives the finished composition after native
+post-process pipelines and before optional display effects. Its context is
+`{ canvas, width, height, gameX, gameY, gameWidth, gameHeight, scale, dpiX,
+dpiY, generation }`. Sizes and positions are LÖVE units; `scale` is native
+framebuffer pixels per game pixel. The canvas is borrowed for that frame and
+must not be released or retained across resize/context loss.
+
+Returning exactly `true` takes ownership of presentation. Other results fall
+back to the ordinary display path. An earlier `render.compose` owner bypasses
+normal composition and this output seam, as in the reference. The output
+seam does not take over HUD/mobile control drawing. A disabled/unloaded owner
+cannot force capture through a predicate alone.
+
+Callbacks begin with an identity transform, no shader/scissor, ordinary alpha
+blend and white color. GPU state and the render target are restored afterward,
+including when a callback throws or leaks graphics pushes. A callback cannot
+pop the caller's graphics state through the scoped stack API. The same fence
+protects the predicate. Existing hook-chain error/next semantics still apply.
+A callback that returns `true` is responsible for drawing a complete output.
+
+The public hook contract is available on the host's common renderer across
+its generations. Shared fixtures compare the same API-3 mod on eight Gen1–3
+versions against pinned Gen1Recomp using explicit canvas/dispatch seams.
+`love tools/render-output-check` additionally drives the actual Renderer and
+public mod with real GPU draws and screenshot readback: disabled direct path,
+false fallback, identity ownership, inversion with clean HUD state, callback
+failure, nonboolean predicate and owner removal. No ROM is required and the
+probe writes no game settings.
+
+With a [game viewport](game-viewport.md), output metrics refer to that game rectangle.
+Window composition and physical controls follow the output stage.
diff --git a/docs/ui-actions-api.md b/docs/ui-actions-api.md
new file mode 100644
index 000000000..caa7c4d36
--- /dev/null
+++ b/docs/ui-actions-api.md
@@ -0,0 +1,128 @@
+# Public UI actions
+
+`mod.ui:snapshot()` and `mod.ui:submit(intent)` drive the **currently open**
+native party, bag or dialogue screen. They are additive for API 1 and 2, with
+no manifest opt-in. Existing `mod.ui.insertBefore`, `insertAfter`, step-list
+helpers and their dot-call signatures remain available. Each mod receives its
+own facade. If the optional capability-query feature is installed,
+`mod:supports("ui", "snapshot")` and `mod:supports("ui", "submit")` return true.
+This describes the interface, not whether the current screen accepts an action.
+
+The battle API can open the native party/bag menu. This API then selects its
+rows and follows the native target, item, confirmation and message flow. It
+does not open a menu from arbitrary world/script states.
+
+## Snapshot and intents
+
+A snapshot returns a detached table, or `nil, reason` if the top screen is
+absent or unsupported. It never searches through a covering screen. Reading
+early does not bootstrap Game, load a screen, poll input or render graphics.
+
+| Field | Meaning |
+| --- | --- |
+| `revision` | Positive per-facade context revision; obtain it from this facade |
+| `kind` | `party`, `party_actions`, `bag`, `bag_actions`, `text` or `choice` |
+| `rows` | Ordered options, including disabled placeholders; empty for text |
+| `index` | Native selected row, where applicable |
+| `locked` | Animation, typing, controller or script currently owns input |
+| `canBack`, `canAdvance`, `canPocket` | Which non-selection intents are offered |
+| `pocket` | Optional native pocket identifier; diagnostic, not portable |
+| `page` | DS battle bag stage: `menu`, `pocket` or `use` |
+
+Every row has `index` and `enabled`. Depending on the native screen it can also
+carry a string `label`/`action`, portable `item`/`species` ID, item `count`, or
+`partyIndex`, `hp`, `maxHp`. Missing fields remain absent. The current dataset
+resolves IDs, including numeric DS IDs and ROM-hack aliases. Labels can be
+localized; native action strings are diagnostic, not a common semantic action
+enumeration. There are no callbacks, native objects or future dialogue pages
+in the public record.
+
+`select.index` is **1-based in the returned rows**. It is not always a party
+slot or item number: party confirmation/cancel rows, DS list headers, GBA
+blank action cells, and DS battle-bag navigation buttons retain their native
+positions. Disabled rows cannot be submitted. In field bags the rows cover the
+current pocket in native order; HGSS/BW scroll/page cursors are positioned for
+the selected row. DS battle bags expose only their current page's six item
+slots plus previous/next/back buttons.
+
+Intents must be plain tables with exactly the fields below:
+
+```lua
+{ id = 1, revision = snapshot.revision, kind = "select", index = 2 }
+{ id = 2, revision = snapshot.revision, kind = "back" }
+{ id = 3, revision = snapshot.revision, kind = "advance" }
+{ id = 4, revision = snapshot.revision, kind = "pocket", direction = 1 }
+```
+
+`id` is a strictly increasing positive safe integer **per UI facade**;
+`revision` must come from a fresh snapshot returned by that facade. Pocket
+`direction` is exactly `-1` or `1`. Unknown fields/kinds, malformed integers,
+missing/disabled rows, stale contexts and unavailable actions return
+`nil, reason` before native dispatch. Failed admission does not consume the ID.
+Keep the ID counter across menu/Game changes.
+
+```lua
+local nextId = 0
+local function chooseRow(index)
+ local snapshot, why = mod.ui:snapshot()
+ if not snapshot then return nil, why end
+ local row = snapshot.rows[index]
+ if snapshot.locked or not row or not row.enabled then
+ return nil, "row is not currently available"
+ end
+ nextId = nextId + 1
+ return mod.ui:submit({
+ id = nextId, revision = snapshot.revision, kind = "select", index = index,
+ })
+end
+```
+
+Take a new snapshot after each accepted action or native input/update. Revisions
+track native screen/option/callback identity, live party/inventory and relevant
+selection/control state. A private per-Game guard/epoch serializes UI and battle
+submissions, rejects callback reentry and expires other mods' old and in-flight
+snapshots. A snapshot obtained during dispatch reports `locked = true`.
+
+## Native behavior and limits
+
+- Party lists/pickers, swap/field/item submenus, forced replacement and team
+ selection retain their native eligibility, callback and cancellation paths.
+ `back` uses that screen's B behavior; it may clear a pending swap or submenu
+ instead of closing. The underlying battle decides how a cancelled forced
+ replacement is reopened. GB/GBA/DS each keep their own return semantics.
+- Bags preserve native pocket order, item-use eligibility, target pickers and
+ consumption. The classic bag's own action menu and GBA item action grid are
+ supported; unrelated generic menus/lists are not inferred from their shape.
+ DS battle-bag presses complete on subsequent native updates, including their
+ page/category return paths. `true` does not mean an item has already run.
+- `advance` handles a ready manual page/line break or final text, a bag's
+ result message, or a DS party notice. Typing and page/scroll holds are never
+ skipped. Multi-page question/auto/stay text can advance its intermediate
+ manual pages; the final script-owned page stays with its controller.
+- TextBox's native choice creates the separate YES/NO screen on the next
+ native update. `select` chooses YES/NO; `back` selects NO. The native answer
+ hold and delayed callback are preserved, so the answer cannot be replayed
+ while pending.
+- Scripts, no-input/tutorial states, healing fills, pending answer/DS battle
+ button animations, item sorting and quantity entry are locked. QuantityBox,
+ summary screens, PC/shop-specific auxiliary screens, TM cases/berry pouches,
+ arbitrary custom menus and custom screen subclasses have no adapter here;
+ they return `nil, reason`. This is not a generic UI automation API.
+
+`true` means a native action was admitted and entered. It may open another
+screen, display a native refusal or schedule work. IDs are consumed **before**
+native code. An error after partial native effects returns
+`nil, "UI action failed; intent consumed: ..."`; the guard is released, but no
+rollback or automatic retry occurs. Inspect a fresh snapshot before continuing.
+The read/validation boundary is not a security sandbox for mods that directly
+replace native engine methods or mutate Game themselves.
+
+## Reproducible tests
+
+Run `luajit tests/engine/ui_actions.lua` and `LUA=luajit bash scripts/test.sh`.
+The focused suite uses real Loader/native menus, Pokémon, item effects and text
+updates with the ROM-free fixture data across all 15 supported version IDs.
+It checks native A/B parity, copied reads, full-graph immutability on refusal,
+identity/revision/replay checks, callbacks and recovery after partial errors.
+DS UI fixtures include native pocket/answer/press delays. Imported-ROM gameplay
+and rendered cartridge scenes require a separate ROM-backed run.
diff --git a/docs/unified-mod-callbacks.md b/docs/unified-mod-callbacks.md
new file mode 100644
index 000000000..a79661ca6
--- /dev/null
+++ b/docs/unified-mod-callbacks.md
@@ -0,0 +1,177 @@
+# Unified hook and event records
+
+This is an additive, explicit Mod API 2 option inspired by Gen1Recomp's
+unified callback boundary. It adapts Gen2Recomped's existing call sites across
+generations 1–5. It does not introduce Gen1Recomp API 3 hooks or events.
+
+```json
+{
+ "id": "portable_hooks",
+ "name": "Portable hooks",
+ "version": "1.0.0",
+ "api": 2,
+ "entry": "main.lua",
+ "callback_format": "unified"
+}
+```
+
+Absent/`"native"` keeps the original API, including object identities and
+custom hook names. Unknown format values and unified callbacks on API 1 fail
+manifest validation. `mod.callbackFormat` exposes the selected format. The
+option is independent of `content_format`; either can be adopted first.
+There is no global conversion of saves, registry data or battle state.
+
+```lua
+return function(mod)
+ mod.events:on("pokemon.level_up", function(event)
+ mod.log:info("%s reached %d", event.mon.species, event.level)
+ end)
+
+ mod.hooks:wrap("battle.damage", function(next, ctx)
+ local damage, info = next()
+ if ctx.user.mon.species == "PIKACHU" then damage = damage + 1 end
+ return damage, info
+ end)
+
+ mod.events:on("pokemon.before_give", function(gift)
+ if gift.species == "EEVEE" then gift.nickname = "Buddy" end
+ end)
+end
+```
+
+## Records
+
+IDs are resolved through this boot's registries. Pokémon, move and item IDs
+are symbolic strings, including on numeric-ID DS hosts. Imported numeric
+records use their names (e.g. `PIKACHU`); named/custom keys retain their IDs.
+No National Dex lookup or hard-coded item-number conversion is used. Unknown
+or ambiguous replacement IDs fail validation. An unchanged unknown native
+ID can round-trip without being guessed or rewritten. Native aliases remain
+one identity even after registry merging creates separate row tables.
+
+| Record | Common fields |
+| --- | --- |
+| Live Pokémon | `species`, `item`, `nickname`, `name`, `level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, `native` |
+| Stats | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` |
+| Live move slot | `{ id, pp, maxPp, ppUps }` |
+| Battler | `{ mon, id, side, native }`; `side` is `"player"`/`"enemy"` when known |
+| Trainer party slot | `species`, `level`, optional `item`, `moves` as symbolic ID strings, `native`; other native metadata is read-only |
+| Encounter / fishing slot | `species`, `level` or `min`/`max`; native slot metadata retained |
+| Move definition | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `priority`, `category`, symbolic `effect` when available, `native` |
+| Species definition | `id`, `name`, `baseStats`, `types`, `baseExp`, `catchRate`, `growthRate`, `native` |
+| Item definition | `id`, `name`, `price`, `description`, `consumable`, `native` |
+
+Optional fields remain absent if the native data does not provide them.
+`maxHp` maps to native `mon.stats.hp`. Special stats map to `spatk`/`spdef`;
+Gen 1's single special stat is exposed through all three special fields.
+Editing one keeps its aliases consistent; contradictory edits are rejected.
+Type aliases match unified content IDs (`ELECTR` → `ELECTRIC`, `FIGHT` →
+`FIGHTING`, `PSYCHIC`/`PSYCHC` → `PSYCHIC_TYPE`, `MYSTERY` → `CURSE_TYPE`).
+Status values are `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or
+`nil`; clearing uses `nil` or `false`. Unknown existing statuses are retained
+until explicitly changed. `isEgg` is the native boolean flag.
+
+Definitions and `native` snapshots are detached, read-only diagnostics. Use
+content registration during mod initialization to change definitions. The
+callback view intentionally exposes a limited common definition shape;
+additional generation-specific fields are available under `native`.
+Battler metadata is read-only. Change its live Pokémon via `battler.mon`.
+Shared native records share one public view within a call, so `user.mon` and
+`target.mon` remain the same object when they reference the same Pokémon.
+
+## Adapted hooks
+
+| Hook | Adapted boundary |
+| --- | --- |
+| `battle.accuracy`, `battle.damage`, `battle.crit`, `exp.gain` | Context records: battlers, Pokémon, move/species definitions, known ID fields |
+| `battle.run`, `battle.catch_exp`, `battle.low_health_alarm` | Context shell; native battle handle |
+| `battle.turn_order` | Both battler/move pairs; bound battler result preserves native identity |
+| `battle.enemy_action` | Second argument battler; move-action `id` in result |
+| `battle.exp_award` | `alive` Pokémon list and `applyShare` helper |
+| `catch.rate` | Ball ID, Pokémon, species definition, `opts.ballDef` item definition |
+| `trainer.party` | Third argument and result use trainer slots, not live saved Pokémon |
+| `evolution.check` | Second argument live Pokémon, evolution/trigger context known IDs |
+| `fieldmove.eligibility` | Move ID and bound live Pokémon result |
+| `encounter.fishing` | Rod ID, candidate list and encounter result |
+| `encounter.roll` | Context and encounter result; imported encounter-table argument stays native |
+| `encounter.species` | Encounter argument/result and context |
+| `pokemon.sprite`, `pokemon.icon` | Context Pokémon and species ID; path result stays native |
+| `ui.party.submenu` | Third argument live Pokémon; menu rows and nested callbacks stay native |
+
+The remaining native hooks pass their arguments/results through. In
+particular, UI callbacks, renderers, input, script arguments, maps, movement,
+saves, intro speech and link fingerprint data retain their engine contracts.
+Registering an unknown engine callback in unified mode fails immediately.
+The source-catalog test forces an explicit boundary decision for new seams;
+registration is not a promise that a given game executes that seam. Where
+available, use `mod:supports("hooks"/"events", name)` to query the host.
+
+`battle.exp_award`'s `ctx.applyShare(mon, divisor, announce)` accepts a bound
+public Pokémon, commits pending validated edits, calls the native helper,
+and refreshes the public views. It retains native argument/result behavior,
+including errors. Retained helpers and `next` cannot be used after the hook
+callback returns. Live party membership/order cannot be changed through this
+view. New encounter, enemy-action and trainer-party records may be returned;
+live Pokémon/battler replacements must be records bound during that call.
+
+## Mutation and failure behavior
+
+Edits to represented live Pokémon fields, trainer slots, encounters and
+context fields are validated before being written. Levels must be 1–100;
+stats/max HP positive integers; HP must fit the maximum. Move lists contain
+at most four entries, PP must fit the selected move and PP Ups, and `maxPp`
+is read-only. Trainer parties contain at most six slots. Unsupported fields,
+read-only changes and invalid IDs reject the transaction before any staged
+record is changed. Hidden native metadata (IVs, EVs, personality, slot fields)
+is retained. Native Pokémon, stats tables, move lists and existing move slots
+keep their identities, including battle aliases into those tables.
+
+Before `next()`, validated edits are committed together. After downstream
+returns, public views refresh from native state. A successful callback's
+later edits commit together too. Throwing before `next()` skips the wrapper;
+throwing/validation failure after `next()` keeps downstream results and
+changes. Already committed edits and native helper effects are not undone.
+Calling `next()` again returns the cached result (including nil holes), or
+rethrows the cached error; it never repeats downstream gameplay. Native
+errors preserve their identity and are not converted into mod failures.
+
+Only the represented record edits participate in this transaction. Game,
+battle, save, world, map, data and RNG handles stay native, as do unclassified
+engine-specific fields; direct changes through such handles have ordinary
+native side effects. Changing a species/level does not automatically rebuild
+battle definitions, recalculate stats, grant moves or emit evolution events.
+Use the engine's gameplay operations when that bookkeeping is required.
+
+## Events
+
+Ordinary engine notifications copy their outer context and adapt known
+record fields/IDs: battle Pokémon/battlers/moves, level-up learnable move IDs,
+move actions, caught/received/evolved Pokémon and trade records. Edits to
+these snapshots do not write back. Engine handles remain native, including
+`side`, `battle`, `game`, interaction targets and script contexts. A status
+source such as `SYNCHRONIZE` remains an engine label rather than a guessed
+move ID.
+
+`pokemon.before_give` is the explicit mutable exception: `species`, `level`
+and optional `nickname` are validated and committed atomically. Its script
+`ctx` stays native. A failure changes none of those gift fields, and later
+listeners continue through the original event bus.
+
+Save lifecycle/repair events, screen and intro-speech events, `game.ready`,
+`mods.loaded`, and mod option events pass through natively. This preserves
+save repair semantics, forwarding metatables and nested callback identities.
+Namespaced `mod..` events also pass through unchanged.
+Mods may listen to other mods' events and emit only their own namespace.
+Priorities, unsubscribe, once semantics, owner attribution and failed-entry
+rollback are provided by the original event/hook buses.
+
+## Verification
+
+`tests/engine/unified_mod_callbacks.lua` runs through a ROM-free in-memory
+filesystem and the real loader/event/hook buses across every supported host.
+It covers string/numeric IDs and aliases, transactional writeback, native
+identity, mixed mod chains, `next`/helper failures, event isolation, mutable
+gifts, priorities, ownership rollback and the native default. The catalog
+checks compare against actual source call sites, rather than another list.
+Run it with the same LuaJIT runtime as the engine, then run `scripts/test.sh`.
+ROM-dependent gameplay checks still require an imported legal ROM.
diff --git a/docs/unified-mod-content.md b/docs/unified-mod-content.md
new file mode 100644
index 000000000..6108608a9
--- /dev/null
+++ b/docs/unified-mod-content.md
@@ -0,0 +1,187 @@
+# Unified definition records (opt-in)
+
+A mod can use the same core content records when modifying different game
+versions. Enable the format explicitly in an API-2 manifest:
+
+```json
+{
+ "id": "portable_balance",
+ "name": "Portable Balance",
+ "version": "1.0.0",
+ "api": 2,
+ "content_format": "unified",
+ "generations": [1, 2, 3, 4, 5],
+ "entry": "main.lua"
+}
+```
+
+```lua
+return function(mod)
+ -- Required when distributing to older engine builds: those builds do not
+ -- understand content_format and would otherwise supply native records.
+ assert(mod.contentFormat == "unified", "This mod needs unified content support")
+ if mod.content.moves:has("TACKLE") then
+ local ok, reason = mod.content.moves:patch("TACKLE", { power = 45 })
+ assert(ok, reason)
+ end
+end
+```
+
+Omitting the field (or using `"native"`) preserves existing mod behavior.
+API 1 cannot opt in. Mod API stays at **2**: this is the content-record part of
+portability, not Gen1Recomp's complete API 3. Callback payloads, saved Pokémon,
+world/battle services, scripts and other registries retain their native forms.
+A mod still needs testing on every game it claims.
+
+## Available records
+
+The five handles retain `register`, `override`, `patch`, `remove`, `get` and
+`each`, and add `has`, `supports()` and `available`. Other content handles pass
+through unchanged. `mod.assets` retains its existing content-handle aliases.
+On engines that provide `mod:supports`, content queries follow the selected
+format's availability; other capability surfaces retain their existing answers.
+
+| Handle | Unified fields | Supported versions |
+| --- | --- | --- |
+| `pokemon` | `id`, `name`, `dex`, `types`, `baseStats`, `catchRate`, `baseExp`, `growthRate`, `level1Moves`, `learnset`, `evolutions`, `tmhm`; optional breeding/art fields | Existing definitions, Gen 1–5 |
+| `moves` | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `effect`, `secondaryChance`, `category`, `priority`, `description` | Existing definitions, Gen 1–5 |
+| `items` | `id`, `name`, `price`, `machine`, `effect`, `ball`, `tossable`, `needsTarget`, `description` | Existing definitions, Gen 1–5 |
+| `trainers` | `id`, `name`, `pic`, `baseMoney`, `battleTheme`, `partyNames`, `parties` | Existing definitions, Gen 1–5 |
+| `encounters` | `id`, `grass`, `water` | Gen 1–3 grass/water tables |
+
+Reads and iteration return detached records. Editing a returned table does
+nothing until a write method is called. Each record also contains a detached,
+**read-only** `native` snapshot for diagnostics. Unexposed native fields are
+preserved by edits; changing that snapshot is refused. `override` requires a
+complete unified record and preserves omitted optional/native extension fields;
+use `mod.DELETE` to clear a represented optional field. For normal
+edits use `patch`; this keeps earlier and later mods' independent fields in the
+registry's normal operation order. Writes return `true` on success.
+
+`register` supports string-key definitions on Gen 1–3, except new Gen 3 species:
+their EV/ability/storage metadata has no canonical constructor here. Numeric
+DS allocations are refused. This layer does not invent storage slots or
+metadata. Use native content for those additions. Existing records can be
+patched, overridden and removed across all five generations.
+
+### IDs and references
+
+Species, move and item IDs are uppercase symbolic strings, such as `PIKACHU`,
+`TACKLE` and `POTION`. Resolution uses the selected dataset's native keys and
+names, not fixed cartridge numbers. `FARFETCHD`, `MR_MIME`, `NIDORAN_F` and
+`NIDORAN_M` retain distinct names. `TM01`/`HM01` names normalize to
+`TM_01`/`HM_01`. Types use Gen 1 spellings, including `PSYCHIC_TYPE`; the native GBA abbreviations `PSYCHC`,
+`FIGHT` and `ELECTR` map to their common names.
+
+A numeric table or a generated key such as `SPECIES_025` is mapped through its
+name in that ROM. Renaming a definition through a mod does not change its ID
+within that load. Numeric/string aliases of one record are read once and
+written or removed together. Multiple distinct records with one canonical
+name are ambiguous: the adapter refuses to choose one. Missing or ambiguous
+`get` returns `nil, reason`; `has` returns false; ambiguous iteration raises.
+
+Trainer and encounter IDs remain game-specific; no cross-game equivalence
+between a trainer class, a numbered individual or two maps is implied.
+Numeric trainer IDs are exposed as decimal strings. References must resolve
+when a write is made: define a referenced record first or declare a mod
+dependency that provides it. Missing references return `nil, reason` before
+an operation is appended. Removing a referenced definition retains the
+loader's post-merge, owner-attributed reference diagnostics, including DS
+numeric targets and Gen 2 time tables.
+
+### Pokémon and trainers
+
+`baseStats` uses `hp`, `attack`, `defense`, `speed`, `special`, with optional
+`specialAttack` and `specialDefense` for the later split. A patch to `special`
+sets both halves on Gen 2–5; explicit split fields can change them separately.
+Conflicting values are rejected. Gen 1 refuses unequal Special stats.
+
+`level1Moves` is a list of move IDs. `learnset` is
+`{ { level = 9, move = "TACKLE" } }`. Evolutions use
+`{ method = "LEVEL", level = 20, species = "RAICHU" }`, or `ITEM`, `TRADE`
+and `FRIENDSHIP` with `item`, `time` (`ANYTIME`, `MORNDAY`, `NITE`) or
+`comparison` (`ATK_GT_DEF`, `ATK_LT_DEF`, `ATK_EQ_DEF`) where representable.
+Held trade items and friendship/stat conditions are translated for Gen 2–3.
+DS level evolutions are translated to their numeric target and parameter.
+Other DS evolution writes and unknown method changes are refused; unchanged
+native methods survive a detached read/modify/write. Gen 1 refuses eggs,
+breeding fields, held items and later evolution conditions.
+
+Trainer `parties` always has the form
+`{ { { species = "PIKACHU", level = 18, item = "POTION", moves = { "TACKLE" } } } }`.
+Items and moves are optional, and held items require Gen 2 or later. Native
+per-slot IVs, flags and AI metadata are retained. A DS trainer's single `party`
+and `parties[1]` aliases stay synchronized.
+
+Move accuracy is percent, as in the engine's existing normalized definitions.
+Gen1–3 move effects use canonical IDs, resolved to the host's actual native
+battle handlers before an operation is journaled. For example
+`SPECIAL_UP1_EFFECT` selects `SP_ATK_UP1_EFFECT` on Gen2/3; the Gen1 Special
+stage does not leak into split Special Attack/Defense. `HIT` and `SLEEP` are
+accepted aliases. Registered custom native effects retain their own IDs.
+
+`secondaryChance` is an optional whole percentage (0–100) on Gen2/3. Explicit
+odds convert to the nearest native fraction out of 256; detached unmodified
+records retain their exact original byte. Explicit zero is distinguished from
+a legacy native zero byte whose controller uses a fallback. Changing a
+canonical effect applies its reference default chance unless the caller
+supplies odds. Authored aliases survive later odds edits.
+
+Unknown native selectors are opaque `native:generation:selector` tokens on
+Gen2/3. They roundtrip only with their original record; they cannot assign an
+unknown handler to another move. Gen4/5 retain the previous diagnostic-only
+numeric selector behavior and refuse effect/chance changes. Item behavior
+and changing a DS move type remain unavailable without their native adapters.
+
+The data-only binding catalog is reproduced from pinned Gen1Recomp contract
+maps plus the host importer's native names by
+`tools/move-effect-contract/generate.py --gen1 CLEAN_REFERENCE --lua LUAJIT
+--check`. It does not execute imported content. A binding still requires a
+real registered host handler: FOLLOW_ME, HELPING_HAND, MAGIC_COAT, SNATCH,
+IMPRISON, ASSIST, NATURE_POWER and SECRET_POWER are refused on the current
+host. Identity-dependent native Rest/SonicBoom variants are not assigned to
+an incompatible move. This adapter selects native behavior; it does not add
+missing battle mechanics or promise cross-engine RNG/pixel equivalence.
+
+### Encounters
+
+An area is `{ rate, slots = { { species = "PIKACHU", level = 5 } } }`.
+A range uses `minLevel` and `maxLevel` instead of `level`. Rates are fractions
+out of **256**, including fractional values needed to represent GBA rates
+exactly. Conversion refuses a rate that cannot be represented in native units;
+it never silently rounds. Slot edits retain the native count and probability
+buckets. Changing slot counts or weights requires native content.
+
+Gen 2 also exposes `grass.times.MORN`, `DAY` and `NITE` in the same area shape.
+A shared rate/slot patch applies to every native time. An explicitly changed
+time can override that patch. Unchanged inherited times in a detached record
+do not undo a shared edit.
+
+DS encounter tables carry daily, seasonal, swarm and other replacements.
+This adapter reports `available = false` and `supports()` returns `false,
+reason` for them; reads/writes return `nil, reason` and iteration is empty.
+The native encounter API remains available to mods using the native format.
+
+### Lists, failures and lifecycle
+
+Lists replace; `__append` and `__prepend` extend them. `{}` and `mod.DELETE`
+clear represented lists, including inherited learnsets and TM lists. Required
+scalar fields, base stats and species types cannot be deleted. Other optional
+fields support `mod.DELETE` where their native representation permits it.
+
+Malformed records raise schema errors. Non-representable operations return
+`nil, reason` before recording anything. Payloads, returned records and native
+snapshots never share writable tables with the operation log. Frozen content
+still rejects writes. An entry failure rolls back every operation owned by
+that mod, including all aliases. No Game, save, graphics or imported ROM is
+needed to query or construct the content facade.
+
+## Validation
+
+Run `luajit tests/engine/unified_content.lua`. The tests load synthetic datasets
+through the real loader, exercise all registered host versions and compare
+native and unified round trips. Behavioral checks use native Pokémon/trainer
+construction, encounter selection, DS move parsing and evolution dispatch.
+This proves the exercised formats and consumers, not real-ROM playthroughs or
+visual correctness. The complete local suite and its base comparison are
+reported in the pull request.
diff --git a/docs/version-datasets.md b/docs/version-datasets.md
new file mode 100644
index 000000000..28a3e418d
--- /dev/null
+++ b/docs/version-datasets.md
@@ -0,0 +1,67 @@
+# Read-only version datasets
+
+API-3 mods can inspect another imported Gen1–3 version without importing engine
+modules or switching the active game:
+
+```lua
+local view, why = mod.datasets:open("gold")
+if view then
+ local mew = view.content.pokemon:get("MEW")
+ for id, species in view.content.pokemon:each() do
+ -- Canonical content IDs and detached data records.
+ end
+end
+```
+
+The view contains `version`, `generation`, `content` and `assets`. Registry
+handles expose `get`, `has` and sorted `each`; write methods are absent. Core
+records use the existing unified content shapes. Function-bearing engine
+records are omitted. No live game, save, mod overlays or runtime registration
+state is exposed or changed. A view is independent of enabled mods.
+
+`open` returns `nil, "unknown_version"`, `"unsupported_generation"`,
+`"not_imported"` or `"invalid_cache"` when appropriate. The host's exact
+import format and ROM-hash marker is authoritative; required generated
+metadata must be present. A complete developer source can be used without a
+marker only with verified source provenance for every required module.
+Gen4/5 dataset adapters remain unavailable.
+
+Generated files are parsed as literals, never compiled or executed. Limits
+are 8 MiB per module, 48 MiB aggregate, depth 64, 500,000 nodes, 250,000 entries
+per table and 2 MiB per string. Arrays, keyed tables, comments, quoted/long
+strings and finite numeric literals are accepted; code, operators, duplicate
+keys and invalid/nonfinite data are refused. Native record schemas validate
+reads. `open` leaves semantic data lazy; engine descriptor shaping may load
+additional data roots at first access.
+
+Repeated reads check cached module bytes and rebuild the isolated view after
+valid edits. A rejected view stays retired; reopen it after repairing the
+cache. Iteration returns a detached, validated snapshot rather than mixing
+records from different revisions. Observed content changes, marker changes
+and root changes retire outstanding iterators. Merely editing a file does not
+mutate an already captured snapshot. Iteration does not re-read every large
+generated module for every returned row.
+
+`view.assets:path("assets/generated/...")` returns the host's native virtual
+path; `assets:info` returns copied file metadata. Paths reject traversal,
+backslashes and control characters. Cache layouts differ between engines:
+use the returned path rather than composing a prefix. Gen2Recomped's Red
+cache remains at the root. While another edition's overlay is active, a Red
+asset path is refused (`nil, "unavailable asset path"`) because that string
+would resolve to the active edition. Exact Red metadata and asset info still
+use the correct physical cache. Other editions retain explicit prefixes.
+
+Native reads use the already resolved cache root, default save directory or
+verified developer source. They never initialize a cache root, mount or
+unmount a version, change `CacheFs.prefix`, change `GameVersion`, load a Game,
+execute an imported module or rewrite a cache. If root resolution has not run,
+only verified developer source is considered. Asset paths are native resource
+addresses, not permanent handles to immutable file bytes.
+
+Shared contract fixtures use the same mod on eight versions and both engines,
+with each backend's own synthetic cache format. This validates public reads,
+mutation isolation, canonical iteration and asset metadata; it does not claim
+ROM-content equality or real asset decoding. Local tests additionally cover
+parser bounds, stale/malformed caches, repairs, iterator revisions, real
+physical file reads beneath a conflicting virtual overlay and the developer
+permission tripwire.
diff --git a/docs/world-actions-api.md b/docs/world-actions-api.md
new file mode 100644
index 000000000..230b7737e
--- /dev/null
+++ b/docs/world-actions-api.md
@@ -0,0 +1,135 @@
+# Public world actions
+
+`mod.world` adds six methods without a manifest opt-in or Mod API bump:
+`canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`,
+`canFly`, and `flyTo`. The new actions validate free-roam state before using
+existing native gameplay entry points. Existing world methods, party menus
+and script commands keep their contracts.
+
+```lua
+local ok, reason = mod.world:reorderParty(1, 2)
+if not ok then mod.log:info("%s", reason) end
+
+local actions = mod.world:availableFieldActions()
+for _, action in ipairs(actions) do
+ -- { id = "surf", label = "SURF" }, for example.
+ -- Show a companion UI; revalidate when the player actually chooses it.
+end
+
+-- Later, from an explicit player choice:
+local started, why = mod.world:useFieldAction("surf")
+local flying, flyWhy = mod.world:flyTo("A_NATIVE_DESTINATION_MAP_ID")
+```
+
+## Free-roam boundary
+
+The save, overworld map, player, native script runner and authoritative stack
+top must be present and ready. Methods refuse while another screen owns the
+stack (including battles/dialogue), a script is running/queued, scripted
+movement is pending, a transition or field animation is active, the player
+is moving/locked, a mod script lock is held, a decoration mode owns input,
+or a link session is live. Native engine errors propagate; an action that
+throws is never retried by this facade.
+
+`canReorderParty()` and `canFly()` return booleans. Mutations return `true`
+when accepted, or `nil, reason` when refused. `availableFieldActions()` gives
+an array of detached `{ id, label }` records, or an empty array and a reason
+when the world is unavailable/busy. Queries use native eligibility checks,
+including existing mod hooks; the facade does not itself perform an action
+while probing. A mod's own hook can still have ordinary native side effects.
+Recursive mutations from those eligibility hooks are refused, and a changed
+source context invalidates the pending request.
+
+A previous successful probe/list is not a reservation. Every action checks
+state again when invoked. Typical reasons are `no save`, `no overworld`,
+`world is busy`, invalid arguments, unavailable destination or unavailable
+field action. Availability of the interface does not promise every action
+exists in every generation.
+
+## Party ordering
+
+`reorderParty(fromSlot, toSlot)` swaps two existing party references, matching
+the native party menus. It does not insert/remove Pokémon, recalculate them
+or copy their data. Slots are integer, one-based indices from 1 to 6; both
+must exist. Fainted Pokémon and eggs can be reordered. A same-slot request is
+a successful no-op. Invalid slots refuse before changing the party.
+
+`canReorderParty()` is true only when the world is ready and there are at
+least two party members. A retained facade reads the current save when used.
+No menu selection or battle party order is changed behind a screen's back.
+
+## Flight
+
+`canFly()` additionally requires a real non-egg party carrier, the native
+badge/location checks and at least one currently unlocked, valid destination.
+`flyTo(mapId)` accepts a native map ID from that game's destination set. It
+is not a general warp: unknown, locked, unvisited or invalid landing points
+are refused before departure state changes. A caller-provided coordinate,
+spawn-table index or arbitrary destination object is not accepted.
+
+| Host | Eligibility and destinations | Native departure |
+| --- | --- | --- |
+| Gen 1/2 | `partyKnows("FLY")`, outside-map rules, visited `field.flyOrder` towns with `flyWarps` | `OverworldState:flyTo` |
+| Gen 3 | Native carrier/badge check, `Gen3FieldRules`, visited town set | Native field-move cut-in, then `flyTo` |
+| Platinum / HGSS | `Gen4FieldMoves`, `Gen4Fly.check/destinations`, partner/Safari/badge checks and HGSS costume refusal | `gen4FlyTo` |
+| Black / White | Native numeric move lookup, `Gen5Fly.allowed/destinations/destination`; no invented badge gate | `gen4FlyTo` |
+
+The native handlers retain departure timing, player input lock, bike/surf
+state changes and exact landing coordinates. The direct map-ID call does
+not open a destination picker. Gen 3 keeps its native Pokémon cut-in, falling
+back directly when there is no picture to display.
+
+## Verified contextual field actions
+
+This initial adapter deliberately exposes only these native paths:
+
+| Action ID | Gen 1 | Gen 2 | Gen 3 | Gen 4/5 |
+| --- | --- | --- | --- | --- |
+| `cut` | Native cut eligibility + `tryCut` | Same, reaching native Gen 2 Cut | Refused: object-script adapter required | Refused: native script adapter required |
+| `surf` | Native eligibility + `trySurf` | Same | Same, with native field-move presentation | Refused: native script adapter required |
+| `dig` | Eligible dungeon → native last-heal departure | Eligible cave/dungeon → recorded entrance | `Gen3FieldRules` → recorded entrance | Refused: native script adapter required |
+| `teleport` | Outside map → last heal | Outside map → last heal | Native travel rules → last heal | Refused: native script adapter required |
+
+Native carrier lookup selects a current non-egg party member. Badge checks
+and target/location gates are retained; fainted carriers remain eligible
+where the native engine permits them. Surf starts mounting; there is no
+invented generic dismount action. Dig and Teleport keep distinct native
+escape/last-heal semantics. Cut uses live target eligibility rather than
+matching a global block number.
+
+`useFieldAction(id, opts)` accepts no options or an empty plain options table.
+Unsupported options, action IDs and generation adapters return a reason.
+This version does not expose bicycle/fishing, Strength/Flash, healing moves,
+custom source selection, Gen 3 object-script actions or DS field-script
+activation. These require separate adapters with their native checks; callers
+must use the returned action list rather than assuming a fixed menu. Fly is
+separate because it needs a destination.
+
+## Deferred completion and cancellation
+
+A `true` result means the native request was accepted, not that the animation,
+dialogue or warp has finished. Native gameplay owns those asynchronous flows.
+A Gen 3 cut-in blocks competing public mutations until it finishes. Its
+effect is guarded by the source save/data/map/player, position/facing, version,
+party membership and fresh eligibility. Changing that context cancels the
+effect. A repeated completion callback cannot replay it. Abandoning the
+presentation does not leave a permanent facade lock; a stale callback cannot
+start a later effect. Already executed native gameplay is not rolled back.
+
+## Capabilities and tests
+
+With the optional capability-query feature, each of these six method names
+returns true from `mod:supports("world", name)` without constructing Game or
+the world. Other capability answers are delegated unchanged. Use
+`canFly`/`availableFieldActions` and the mutation's return value for current
+state and action-level availability.
+
+`tests/engine/world_actions_api.lua` uses the real loader across all 15 host
+version IDs. It exercises refusal parity for world/player/script/link locks,
+party identities, slots, destination gates, eggs, partner/costume checks,
+contextual dispatch and reentrant/deferred/error paths. It also runs actual
+native carrier/badge lookup, Fly/DS Fly departure methods and Gen 1–3
+Dig/Teleport departure state changes without ROM assets or graphics. A cut-in
+test double drives completion/cancellation deterministically; it is not a
+rendered playthrough. ROM-dependent map scripts and graphics still need an
+imported legal ROM.
diff --git a/docs/world-overview.md b/docs/world-overview.md
new file mode 100644
index 000000000..e3c494197
--- /dev/null
+++ b/docs/world-overview.md
@@ -0,0 +1,105 @@
+# World overview and encounter queries
+
+These additive API-1/API-2 readers use the Gen1Recomp `mapOverview` and
+`effectiveEncounters` result shapes. They return detached tables and do not
+load maps, consume RNG, run mod callbacks, refresh objects, or change the save.
+They are available through the normal lazy `mod.world` facade. Hosts with
+`mod:supports` report both names on the `world` surface before Game exists.
+This does not opt a mod into API 3 or change existing actions/encounter rolls.
+
+```lua
+local overview, reason = mod.world:mapOverview()
+if overview then
+ -- Cells and marker coordinates start at zero; Lua row arrays start at one.
+ local terrain = overview.rows[y + 1]:sub(x + 1, x + 1)
+end
+
+local encounters, reason = mod.world:effectiveEncounters(mapId, "grass", {
+ daytime = "NITE", -- optional; MORN/MORNING, DAY, NITE/NIGHT
+})
+if encounters then
+ local total = 0
+ for species, weight in pairs(encounters.dist) do total = total + weight end
+ -- Conditional species share = weight / total (when total > 0).
+end
+```
+
+## `mapOverview()`
+
+Requires an active overworld; menus/battles above it do not prevent reads.
+At the title screen, returns `nil, "no overworld"`.
+
+| Field | Meaning |
+| --- | --- |
+| `mapId`, `width`, `height` | Active map ID and size in 16px walk cells |
+| `rows` | `height` strings, each exactly `width` ASCII characters |
+| `markers` | Array of detached `{kind, x, y}` records |
+| `tileRows`, `tileWidth`, `tileHeight` | Optional visual shade grid at twice the cell resolution |
+| `tileDetailRows`, `tileDetailWidth`, `tileDetailHeight` | Optional visual shade grid at four times the cell resolution |
+
+Terrain characters are `+` for a native warp tile, `~` for water, `.` for
+walkable ground, and a space for a wall, in that order of precedence. This
+summarizes terrain, not directional movement permissions, elevation crossings,
+NPC occupancy, or a pathfinding result. Runtime block/collision patches win.
+Packed GBA blocks are decoded privately; a cold map remains cold afterwards.
+
+Markers identify in-bounds `warp` events, `item` objects actually in the active
+NPC list (at their current cells, excluding hidden objects and fruit trees),
+and untaken `hidden` item spots. Hidden-item signs also honor their event flags
+and their distinct native taken keys. Neighbour-map objects are not included.
+The snapshot reflects the loaded object list; it does not run visibility or
+spawn filters again. Hidden markers deliberately reveal item locations to the
+requesting mod, as in Gen1Recomp.
+
+GB visual grids use the resolved, mod-overridable atlas's pixel luminance:
+`"0"` is lightest, `"3"` darkest. `tileRows` averages each 8x8 tile;
+`tileDetailRows` preserves each 4x4 quadrant. Missing/invalid art is omitted.
+The private visual cache is invalidated with the engine's asset cache.
+GBA metatile compositions and DS 3D art have no GB atlas, so visual grids
+are omitted there. Terrain grids support GB/GBA maps and resident DS interior
+permission grids. DS maps without a resident behaviour grid return
+`nil, "map overview unavailable for streamed terrain"`; reading streamed
+renderer chunks would load/cache terrain during a read query.
+
+## `effectiveEncounters(mapId, terrain, opts?)`
+
+Takes an explicit, known map ID, including at the title screen. `terrain` is
+`"grass"`, `"water"`, or `"indoor"` (the grass table used by cave floors).
+Returns `{chance = number, dist = {[publicSpeciesId] = integerWeight}}`.
+Unknown maps, invalid terrain/options/time, unloaded encounter data and invalid
+bucket/rate metadata return `nil, reason`. A known map without that table
+returns `{chance = 0, dist = {}}`.
+
+`chance` is the table's independent integer rate gate, clamped to its native
+denominator (256 by default, 100 on DS adapter tables). It is **not** the final
+probability of a battle on the player's next step. `dist` aggregates consecutive
+cumulative bucket differences for duplicate species. Weights are not percentages:
+use their sum when computing conditional shares. Missing slots/uncovered bucket
+space remain empty outcomes, just as in the native chooser. Zero-rate tables
+return an empty distribution. Species IDs use the same dataset-derived public
+names as the other portable readers, including numeric DS species and aliases.
+Loader identity metadata is captured before presentation-name patches.
+
+Gen2 land tables use the current clock period, or the last effective cached
+period when a `world.tod` hook is installed. `opts.daytime` overrides this
+without dispatching the hook or updating the world. Active Gold/Silver/Crystal
+swarm tables are selected by the native resolver. DS previews use the native
+map-to-table indirection, day/night substitutions, active daily slots/outbreaks
+and BW season selection. DS `daytime` selects a representative hour (6/12/22).
+Water uses its own table.
+
+This is a **table preview**: it excludes per-step ability/radio/held-item rate
+changes, movement/grace gates, Repel, roamers, lead-based slot filtering,
+fishing, Radar, phenomena and dark-grass overrides. It never samples or executes
+`encounter.roll`, `encounter.species`, or `world.tod`. Gen2Recomped has no native
+`encounter.table` hook; this PR does not introduce one or pretend to preview
+arbitrary mod callbacks. A future shared table-transform seam can extend both
+the native roll and preview together.
+
+HGSS's position-dependent Safari Zone returns
+`nil, "position-dependent Safari encounters unavailable"`; its native resolver
+consults the global player's position and can initialize save state. Emerald's
+Battle Pike wild room and Battle Pyramid floors return
+`nil, "facility encounters unavailable"`; these use separate facility tables.
+These refusals keep an explicit-map query from reporting another map's table
+or changing gameplay state.
diff --git a/docs/world-read-api.md b/docs/world-read-api.md
new file mode 100644
index 000000000..01b2a60d0
--- /dev/null
+++ b/docs/world-read-api.md
@@ -0,0 +1,151 @@
+# Read-only world and party queries
+
+Native mods can read `mod.world:party()`, `activeBlockAt(mapId, bx, by)` and
+`liveMaps()` across generations 1–5. These are additive methods: no manifest
+format switch or Mod API bump is required. Their records use the common live
+Pokémon shape from unified callbacks, independently of the mod's selected
+content/callback format. Existing world actions and `loadedMaps()` retain
+their previous behavior.
+
+The methods read the current save/runtime map when called. They do not load
+maps, decode whole packed grids, create Pokémon, emit events, write state or
+advance random generators. Returned tables are detached plain data; editing
+or retaining them never changes the game. Native engine handles remain
+available through the existing APIs, but these three methods return none.
+
+```lua
+return function(mod)
+ mod.events:on("world.stepped", function()
+ local here = mod.world:current()
+ if not here then return end
+
+ local party, reason = mod.world:party()
+ if not party then mod.log:warn("%s", reason); return end
+
+ local maps = mod.world:liveMaps()
+ local geometry = maps and maps[1].block
+ if geometry then
+ -- current().x/y use walk cells; activeBlockAt uses map blocks.
+ local cellsPerBlock = geometry.tileSize / geometry.cellSize
+ local block, info = mod.world:activeBlockAt(here.mapId,
+ math.floor(here.x / cellsPerBlock), math.floor(here.y / cellsPerBlock))
+ if block ~= nil then mod.log:info("%s block %d", info.kind, block) end
+ end
+ end)
+end
+```
+
+## Party snapshots
+
+`party()` returns an array in native party order. A save with an empty or
+absent party gives `{}`; no save gives `nil, "no save"`. Reading a saved party
+does not require an active overworld, so menus and a title-screen save are
+valid contexts. A retained facade observes a replacement of `game.save` on
+its next call; previously returned records remain snapshots.
+
+Each Pokémon has the following common fields (optional native values may
+be absent):
+
+| Field | Meaning |
+| --- | --- |
+| `species`, `item` | Symbolic IDs; an absent/zero held item gives `nil` |
+| `nickname`, `name` | Native nickname and optional native name |
+| `level`, `hp`, `maxHp` | Current values; `maxHp` is native `stats.hp` |
+| `status` | `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or absent; unknown native status labels are retained |
+| `isEgg` | Native egg flag as a boolean |
+| `stats` | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` |
+| `moves` | Array of `{ id, pp, maxPp, ppUps }`; absent definitions leave `maxPp` absent |
+| `native` | Detached data-only diagnostics, including IVs/EVs and custom save fields |
+
+IDs use this boot's actual registries, including custom records and numeric
+DS IDs, rather than hard-coded National Dex numbers. Numeric IDs resolve via
+imported names; existing named keys retain their names. Standard aliases use
+the shared unified-ID resolver. Identity/name metadata is captured before
+mod merging, so a display-name patch does not rename a canonical ID. Records
+registered later during mod initialization remain visible. Unknown IDs are
+preserved as strings; this is a read-only view, not an allocation or reference
+validation API. The native diagnostics retain the underlying numeric IDs.
+
+Gen 1's single special stat fills both special-attack and special-defense
+fields; later split stats read native `spatk` and `spdef`. PP maxima use native
+PP-Up rounding (`base + ppUps * floor(base / 5)`). Reads never recalculate
+stats, learn moves, infer shininess or consume RNG. Functions, userdata,
+threads, metatables and table-valued keys are excluded from diagnostics;
+cycles in data are copied without retaining native tables.
+
+## Active block lookup
+
+`activeBlockAt(mapId, bx, by)` returns `blockId, metadata`, or `nil, reason`.
+The expected map ID is required: a request prepared before a warp cannot
+silently inspect the replacement map. Only the active runtime map is read.
+Coordinates are finite, nonnegative integers in **zero-based block units**;
+off-map coordinates are refused rather than returning the map's border fill.
+A block ID of zero is a valid result: check `blockId ~= nil`.
+
+Runtime `blockPatch` entries take precedence, including zero-valued changes.
+Table grids, packed little-endian 16-bit grids and an existing decoded cache
+follow the runtime Map representation. A cold packed read extracts only the
+requested word and its low ten ID bits; it does not populate `_blockArray`.
+Missing/truncated data, negative/invalid IDs and inconsistent geometry give
+`nil, reason`. No call into map loading, rendering or collision is made.
+
+Metadata is a fresh record:
+
+| Field | Units / meaning |
+| --- | --- |
+| `tileSize` | Pixels per map block, from runtime `blockTiles * 8` |
+| `cellSize` | 16 pixels per walk cell |
+| `coordinateBase` | `0` |
+| `width`, `height` | Map dimensions in blocks |
+| `kind` | `block`, `metatile`, or `runtime_cell` |
+
+Ordinary Gen 1/2 blocks are 32 pixels; Gen 3 metatiles are 16 pixels. Geometry
+comes from the actual runtime tileset so custom maps need not match their
+selected generation's usual size. Gen 4/5 imported maps use a 16-pixel proxy
+grid in the shared runtime; `runtime_cell` explicitly identifies that grid.
+Its values are synthetic runtime/behavior values, **not DS mesh/model IDs or
+a public collision/elevation encoding**. Use the existing terrain/elevation
+services for those questions. A DS map must carry the runtime grid; this
+method does not generate one from meshes or lazily load shared layouts.
+
+## Loaded map snapshots
+
+`liveMaps()` returns only maps already held by the active overworld, with the
+active map first and neighbors in native order. It never enumerates all ROM
+maps or loads missing neighbors. No overworld gives `nil, "no overworld"`.
+Each row contains:
+
+```lua
+{
+ mapId = "MAP_ID", active = true,
+ x = 0, y = 0, -- placement relative to active map, walk cells
+ width = 20, height = 18, -- dimensions in walk cells
+ block = { -- absent if runtime geometry is unavailable
+ tileSize = 32, cellSize = 16, coordinateBase = 0,
+ width = 10, height = 9, kind = "block" -- dimensions in blocks
+ }
+}
+```
+
+Neighbor offsets may be negative. Exact duplicate placements collapse;
+distinct placements of one map at different offsets remain separate rows.
+Each row carries its own geometry, including mixed-size neighborhoods.
+The legacy `loadedMaps()` method keeps its original shape, duplicates and
+order for existing consumers.
+
+## Availability and tests
+
+When the optional `mod:supports` capability-query feature is installed,
+`mod:supports("world", "party")`, `activeBlockAt` and `liveMaps` report true.
+Probes do not construct Game or a world. Availability describes the method;
+a call can still return `no save`, `no overworld`, `map is not active`, invalid
+coordinates, unavailable geometry or unavailable data. Other capability
+answers retain their existing behavior.
+
+`tests/engine/world_read_api.lua` exercises the real loader and Map objects
+without an imported ROM or a graphics context, across all supported version
+IDs. It checks detached nested data, ID aliases and late registrations,
+current save/map replacement, block sizes/bounds, cold/warm packed data,
+runtime patches, map placements, native `loadedMaps()` parity and RNG/gameplay
+tripwires. When the unified-callback feature is present, it also compares the
+party shape against an actual event's Pokémon record.
diff --git a/licenses/GEN1RECOMP-TOUCH-SKINS.md b/licenses/GEN1RECOMP-TOUCH-SKINS.md
new file mode 100644
index 000000000..806424f26
--- /dev/null
+++ b/licenses/GEN1RECOMP-TOUCH-SKINS.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/main.lua b/main.lua
index cf453d89d..3be881e0c 100644
--- a/main.lua
+++ b/main.lua
@@ -905,7 +905,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
end
if Importer then return Importer:touchpressed(id,x,y,dx,dy,pressure) end
if not Game then return end
- Game:touchpressed(id, x, y)
+ Game:touchpressed(id, x, y, dx, dy, pressure)
end
function love.touchmoved(id, x, y, dx, dy, pressure)
@@ -919,7 +919,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
end
if Importer then return Importer:touchmoved(id,x,y,dx,dy,pressure) end
if not Game then return end
- Game:touchmoved(id, x, y)
+ Game:touchmoved(id, x, y, dx, dy, pressure)
end
function love.touchreleased(id, x, y, dx, dy, pressure)
@@ -933,7 +933,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
end
if Importer then return Importer:touchreleased(id,x,y,dx,dy,pressure) end
if not Game then return end
- Game:touchreleased(id, x, y)
+ Game:touchreleased(id, x, y, dx, dy, pressure)
end
function love.wheelmoved(x, y)
diff --git a/scripts/pack_love.sh b/scripts/pack_love.sh
index 216375469..d7e660c06 100755
--- a/scripts/pack_love.sh
+++ b/scripts/pack_love.sh
@@ -155,6 +155,18 @@ for licence in LICENSE LICENSE.MD LICENSE.md; do
[ -f "$ROOT/$licence" ] && cp "$ROOT/$licence" "$STAGE/" && break
done
+for notice in NOTICE.md LICENSE-ROXAS2712.md; do
+ if [ -f "$ROOT/$notice" ]; then cp "$ROOT/$notice" "$STAGE/"; fi
+done
+
+# Preserve the original licence alongside the actual derived skin runtime.
+if [ -f "$ROOT/src/core/TouchSkin.lua" ]; then
+ skin_licence="licenses/GEN1RECOMP-TOUCH-SKINS.md"
+ [ -f "$ROOT/$skin_licence" ] || fail "missing Gen1 touch-skin licence"
+ mkdir -p "$STAGE/licenses"
+ cp "$ROOT/$skin_licence" "$STAGE/$skin_licence"
+fi
+
# ---------------------------------------------------------------- excludes
# Pruned from the STAGE rather than passed to zip as -x patterns: the two
# forms disagree on how they anchor a leading directory, and a silent
diff --git a/src/audio/Gen2Sfx.lua b/src/audio/Gen2Sfx.lua
new file mode 100644
index 000000000..f1899e242
--- /dev/null
+++ b/src/audio/Gen2Sfx.lua
@@ -0,0 +1,77 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- GSC PlaySFX/PlayStereoSFX arbitration over the real native Sources. There
+-- is no audio clock here: CheckSFX observes whether those Sources are playing.
+local M={}
+local Bus={};Bus.__index=Bus
+local function native(def)
+ return type(def)=='table' and (def.engine=='gen2' or def.generation==2)
+end
+local function sameHeader(a,b)
+ return native(a) and native(b) and a.bank~=nil and a.address~=nil
+ and a.bank==b.bank and a.address==b.address
+end
+local function validId(id)
+ return type(id)=='number' and id==id and id>=0 and id<=65535 and id%1==0
+end
+
+-- The current importer supplies a sparse zero-based sfxIndex; original
+-- source-native data supplies sfxOrder. Prefer an exact native label, then
+-- the same aliased definition/header. Never guess an id from a sound's name.
+function M.idFor(data,name,def)
+ if not native(def)then return end
+ local audio=data and data.audio or {}
+ local exact,alias
+ local function consider(id,label)
+ if not validId(id)then return end
+ if label==name then exact=math.max(exact or -1,id)
+ else
+ local other=audio.sfx and audio.sfx[label]
+ if other==def or sameHeader(other,def)then alias=math.max(alias or -1,id)end
+ end
+ end
+ if type(audio.sfxIndex)=='table'then
+ for id,label in pairs(audio.sfxIndex)do consider(tonumber(id),label)end
+ elseif type(audio.sfxOrder)=='table'then
+ for index,label in ipairs(audio.sfxOrder)do consider(index-1,label)end
+ end
+ return exact or alias
+end
+function M.new(stop)
+ return setmetatable({voices=setmetatable({},{__mode='k'}),current=0,stop=stop},Bus)
+end
+function Bus:clear()
+ for source in pairs(self.voices)do self.stop(source);self.voices[source]=nil end
+end
+function Bus:reset()
+ self:clear();self.current,self.audio=0,nil
+end
+function Bus:forget(name)
+ if name==nil then return self:reset()end
+ for source,row in pairs(self.voices)do if row.name==name then self.voices[source]=nil end end
+end
+function Bus:busy()
+ local busy=false
+ for source in pairs(self.voices)do
+ local ok,playing=pcall(source.isPlaying,source)
+ if ok and playing then busy=true else self.voices[source]=nil end
+ end
+ return busy
+end
+function Bus:play(data,name,def,stereo,mono,start)
+ local id=M.idFor(data,name,def)
+ if id==nil then return start()end
+ if self.audio~=data.audio then self:reset();self.audio=data.audio end
+ -- home/audio.asm:180-207 compares E with wCurSFX only while CheckSFX
+ -- reports an active channel. PlayStereoSFX never writes that byte.
+ if not stereo and self:busy() and self.current 0
+ and pending
+ and not self:menuLockedAction(left)
+ then
+ return left
+ end
+end
+function Methods.cancelMove(self)
+ if self.phase == "moveSelect" then
+ self.moveSwapIndex, self.phase = nil, "menu"
+ elseif self.phase == "targetSelect" then
+ self.pendingMove, self.targetChoices = nil, nil
+ self.phase = "moveSelect"
+ elseif self.phase == "menu" and backToFirst(self) then
+ self.pendingActions[0] = nil
+ if self.chosenTargets then
+ self.chosenTargets[0] = nil
+ end
+ self.choosingSlot, self.menuIndex, self.moveIndex = 0, 1, 1
+ pcall(function()
+ require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE")
+ end)
+ else
+ return nil, "nothing to cancel"
+ end
+ return true
+end
+
+local function moveReason(b, actor, slot, move)
+ if not move or not b.data.moves[move.id] then
+ return "invalid move slot"
+ end
+ if actor.disabledSlot == slot then
+ return "move is disabled"
+ end
+ if (move.pp or 0) <= 0 then
+ return "move has no PP"
+ end
+ local def = b.data.moves[move.id]
+ if b.bw and require("src.battle.Gen5MoveRules").moveBlocked(b, actor, def, true) then
+ return "move is blocked by native BW rules"
+ end
+ if b:tauntBlocks(actor, def) then
+ return "move is blocked by Taunt"
+ end
+ if b:tormentBlocks(actor, def) then
+ return "move is blocked by Torment"
+ end
+end
+
+-- Admission checks are read-only. Native refusal messages still belong to
+-- chooseMove when driven by ordinary input; invalid API requests do not act.
+function Commands.reason(b, intent)
+ if b.kind == "link" or b.spectating or b.demo or b.dudeDemo or b.pokedudeDemo or b.palace or b.arena then
+ return "battle controller owns input"
+ end
+ if b.result or b.dead or b.waitingUI then
+ return "battle is not ready"
+ end
+ if intent.kind == "safari" then
+ if b.phase ~= "menu" or not b.safari then
+ return "safari menu is not active"
+ end
+ if (b.safari.balls or 0) <= 0 then
+ return "no safari balls remain"
+ end
+ return
+ end
+ if b.safari then
+ return "safari controller owns input"
+ end
+ local actor = intent.kind == "mimic" and b.mimicCtx and b.mimicCtx.user or b:menuBattler()
+ if not (actor and actor.mon) or actor.ally or (actor.mon.hp or 0) <= 0 then
+ return "player battler is not ready"
+ end
+ if
+ intent.kind ~= "mimic"
+ and (
+ b:menuLockedAction(actor)
+ or actor.mustRecharge
+ or actor.charging
+ or actor.rageMove
+ or (actor.thrashTurns and actor.thrashTurns > 0)
+ )
+ then
+ return "player battler is locked"
+ end
+ if intent.kind == "menu" then
+ if b.phase ~= "menu" then
+ return "battle menu is not active"
+ end
+ local acting = b.player or b:activeOn(1)
+ if not acting or (acting.mon.hp or 0) <= 0 or b:menuLockedAction(acting) then
+ return "battle menu is not ready"
+ end
+ elseif intent.kind == "move" then
+ if b.phase ~= "moveSelect" then
+ return "move menu is not active"
+ end
+ if b.moveSwapIndex then
+ return "move reorder is active"
+ end
+ return moveReason(b, actor, intent.slot, (actor.curMoves or {})[intent.slot])
+ elseif intent.kind == "target" then
+ if b.phase ~= "targetSelect" then
+ return "target menu is not active"
+ end
+ local move, slot = b.pendingMove, nil
+ for index, mv in ipairs(actor.curMoves or {}) do
+ if mv == move then
+ slot = index
+ break
+ end
+ end
+ if not slot then
+ return "pending move is no longer current"
+ end
+ local reason = moveReason(b, actor, slot, move)
+ if reason then
+ return reason
+ end
+ if not Targeting.needsChoice(b, actor, b.data.moves[move.id]) then
+ return "target choice is no longer needed"
+ end
+ local pick = b:battlerAt(intent.target)
+ if not (pick and pick.mon and (pick.mon.hp or 0) > 0) then
+ return "target is not on the field"
+ end
+ local offered = false
+ for _, target in ipairs(b.targetChoices or {}) do
+ if target == pick then
+ offered = true
+ break
+ end
+ end
+ if not offered then
+ return "target is not in the current selector"
+ end
+ for _, target in ipairs(Targeting.choices(b, actor)) do
+ if target == pick then
+ return
+ end
+ end
+ return "target is no longer eligible"
+ elseif intent.kind == "back" then
+ if b.phase == "moveSelect" or b.phase == "targetSelect" or (b.phase == "menu" and backToFirst(b)) then
+ return
+ end
+ return "nothing to cancel"
+ elseif intent.kind == "mimic" then
+ if b.phase ~= "mimicSelect" then
+ return "mimic menu is not active"
+ end
+ local ctx, pick = b.mimicCtx, (b.mimicMoves or {})[intent.index]
+ if not ctx or not pick or ctx.user ~= actor or not ctx.target or not ctx.moveInst then
+ return "invalid mimic context"
+ end
+ local targetPresent = false
+ for pos = 0, 3 do
+ if b:battlerAt(pos) == ctx.target then
+ targetPresent = true
+ end
+ end
+ if not targetPresent or #(actor.curMoves or {}) == 0 then
+ return "invalid mimic context"
+ end
+ local onField = false
+ for _, pos in ipairs({ 0, 2 }) do
+ if b:battlerAt(pos) == actor then
+ onField = true
+ end
+ end
+ if not onField then
+ return "mimic user is no longer on the field"
+ end
+ local source = (ctx.target.curMoves or {})[pick.slot]
+ if not source or source.id ~= pick.id then
+ return "mimic option is no longer current"
+ end
+ else
+ return "unknown battle intent"
+ end
+end
+
+local DISPATCH = {
+ menu = "chooseMenu",
+ move = "chooseMove",
+ target = "chooseTarget",
+ back = "cancelMove",
+ safari = "chooseSafari",
+ mimic = "chooseMimic",
+}
+function Commands.dispatch(b, intent)
+ -- Ordinary input already performs this at its menu boundary.
+ if b.phase == "menu" and not b.safari then
+ b:clearTurnFlinches()
+ end
+ if intent.kind == "target" then
+ local pick = b:battlerAt(intent.target)
+ for index, target in ipairs(b.targetChoices or {}) do
+ if target == pick then
+ return b:chooseTarget(index)
+ end
+ end
+ return nil, "target is not in the current selector"
+ end
+ local value = intent.choice or intent.slot or intent.action or intent.index
+ return b[DISPATCH[intent.kind]](b, value)
+end
+return Commands
diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua
index 8f8e1409c..795189d91 100644
--- a/src/battle/BattleState.lua
+++ b/src/battle/BattleState.lua
@@ -38,6 +38,11 @@ local WideBattle = require("src.battle.WideBattle")
local Gen3Battle = require("src.battle.Gen3Battle")
local Gen4Battle = require("src.battle.Gen4Battle")
local GameVersion = require("src.core.GameVersion")
+local BattleCommands = require("src.battle.BattleCommands")
+local Gen2Vanish = require('src.battle.Gen2VanishPresentation')
+local Gen2BattleView = require('src.battle.Gen2BattleView')
+local Gen2Read = require('src.battle.Gen2BattleRead')
+local Battler = require('src.battle.Battler')
-- FORWARD-DECLARED, because they are defined next to the other doubles
-- helpers two thousand lines down but read from `stepHPDrain` up here. A
@@ -53,6 +58,10 @@ local TEXT_AREA_ROWS = { 112, 128 }
local BattleState = {}
BattleState.__index = BattleState
BattleState.isOpaque = true
+-- Data readers can identify the shared Gen 1-5 battle without loading this module.
+BattleState.isBattleState = true
+-- Input and public intents share the exact same native command dispatch.
+for name, method in pairs(BattleCommands.methods) do BattleState[name] = method end
-- pokered prints the battle lines itself (engine/battle/core.asm and the
-- move-effect banks), and the importer extracts every one of them, so the
@@ -639,7 +648,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 +711,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 +838,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 +866,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 +884,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 +913,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 +926,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);
@@ -981,75 +1029,30 @@ end
-- construction
-- ---------------------------------------------------------------------
-local function makeBattler(data, mon, isPlayer, save)
- local def = require('src.pokemon.Gen4Forms').definition(data,mon)
- local badgeBoosts = data.constants and data.constants.badgeBoosts
- local badges = nil
- if isPlayer and save then
- -- Gen 1 badge stat boosts (x9/8); the badge set follows the merged
- -- badgeBoosts rows so a retuned list changes what gets baked in
- badges = {}
- for _, row in ipairs(badgeBoosts or Damage.BADGE_BOOSTS) do
- if save.inventory[row.badge] then badges[row.badge] = true end
- end
- end
- return {
- mon = mon,
- def = def,
- name = mon.nickname or def.name,
- -- The species this battler is SHOWING, which is not always `mon.species`:
- -- a Transformed mon wears the copied species' pic and a Tower ghost wears
- -- none at all. Both of those move this field where they move `sprite`,
- -- so anything drawing the battler -- the flat pic here, or a renderer
- -- standing a model in its place -- reads one answer and agrees with the
- -- other. `mon.species` stays the truth about the Pokemon itself; this is
- -- the truth about the picture.
- species = mon.species,
- isPlayer = isPlayer,
- badges = badges,
- -- merged registry views consumed by the pure battle modules
- badgeBoosts = badgeBoosts,
- statuses = data.statuses,
- -- ...and the two HoldItems needs: the item table to look `mon.item` up
- -- in, and the nature table, because whether a FIGY BERRY confuses the
- -- Pokemon that ate it depends on which stat its nature lowers. Views,
- -- not copies, so a berry eaten mid-battle is seen the next time it is
- -- asked for.
- items = data.items,
- natures = data.constants and data.constants.natures,
- shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
- -- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
- -- while the tilemap still shows the prior condition until the next
- -- post-action HUD refresh (core.asm after Execute*Move)
- shownStatus = mon.status,
- -- a sleep put on outside battle with its own count (the Battle Pike's
- -- Kirlia: STATUS1_SLEEP, all three bits); spent, it is gone
- sleepTurns = (mon.status == "SLP" and mon.sleepTurns) or nil,
- stages = {},
- -- volatile state; Transform/Conversion/Mimic override the cur* fields
- curStats = mon.stats,
- curTypes = def.types,
- curMoves = mon.moves,
- sprite = (function()
- local Sprites = require("src.pokemon.Sprites")
- local path, tc = Sprites.path(data, mon.species,
- isPlayer and "back" or "front",
- { mon = mon, kind = "battle" })
- return getImage(path, monPalette(data, mon.species,
- require("src.pokemon.Pokemon").isShiny(mon)), tc)
- end)(),
- -- Crystal's front-pic frame animation. Only the FRONT pic carries
- -- animation tiles, so the player's back pic has none -- and Gold and
- -- Silver have none at all, where this stays nil and the pic holds still.
- -- forMon rather than new: a shiny Gen 3 Pokemon animates from its own
- -- strip, and only the Pokemon can say whether it is one
- picAnim = (not isPlayer or (data.pokemon[mon.species] or {}).gen5Animation)
- and require("src.pokemon.PicAnim").forMon(data, mon,isPlayer and "back" or "front") or nil,
- -- Emerald's procedural front-pic animation -- the squash/hop/glow the
- -- sprite itself does when it appears. Front pic only, same as above.
- monAnim = (not isPlayer)
- and require("src.pokemon.MonAnim").new(data, mon.species) or nil,
- }
+local function makeBattler(data, mon, isPlayer, save, battle)
+ local battler = Battler.new(data, mon, isPlayer, save, battle)
+ -- The regular native screen hydrates the same art as before. Pure model
+ -- construction can use the data factory without allocating GPU resources.
+ battler.sprite = (function()
+ local Sprites = require("src.pokemon.Sprites")
+ local path, tc = Sprites.path(data, mon.species,
+ isPlayer and "back" or "front",
+ { mon = mon, kind = "battle" })
+ return getImage(path, monPalette(data, mon.species,
+ require("src.pokemon.Pokemon").isShiny(mon)), tc)
+ end)()
+ -- Crystal's front-pic frame animation. Only the FRONT pic carries
+ -- animation tiles, so the player's back pic has none -- and Gold and
+ -- Silver have none at all, where this stays nil and the pic holds still.
+ -- forMon rather than new: a shiny Gen 3 Pokemon animates from its own
+ -- strip, and only the Pokemon can say whether it is one.
+ battler.picAnim = (not isPlayer or (data.pokemon[mon.species] or {}).gen5Animation)
+ and require("src.pokemon.PicAnim").forMon(data, mon, isPlayer and "back" or "front") or nil
+ -- Emerald's procedural front-pic animation -- the squash/hop/glow the
+ -- sprite itself does when it appears. Front pic only, same as above.
+ battler.monAnim = (not isPlayer)
+ and require("src.pokemon.MonAnim").new(data, mon.species) or nil
+ return battler
end
-- LinkBattle builds clamped copies with save=nil (no badge boosts); wild
@@ -1164,12 +1167,9 @@ local function newBattle(game)
local self = setmetatable({}, BattleState)
self.game = game
self.data = game.data
- -- On this engine the battle screen IS the battle logic object: there is no
- -- separate model that a screen points at. Publishing the self-reference makes
- -- that explicit so anything holding "a thing with a .battle" (the party menu
- -- during a switch, mods walking the state stack) can treat the battle screen
- -- and the party menu uniformly instead of special-casing this one screen.
- self.battle = self
+ -- Gen2 exposes its actual party through a distinct live model; native
+ -- decisions, wrappers and presentation remain on this controller.
+ if GameVersion.isGen2() then Gen2BattleView.bind(self) else self.battle = self end
-- BLACK AND WHITE: their move effects named for this engine, their species'
-- abilities and their ruleset, before the ruleset below is chosen
-- (src/battle/Gen5MoveEffects.lua; sets self.bw on a BW cache only)
@@ -1215,10 +1215,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")
@@ -1371,7 +1375,7 @@ function BattleState.newWild(game, species, level, opts)
Logger.warn("wild battle with no healthy party; skipping")
self.dead = true
else
- self.player = makeBattler(game.data, playerMon, true, game.save)
+ self.player = makeBattler(game.data, playerMon, true, game.save, self)
end
local form=opts and opts.form
if form==nil and (game.data.constants or {}).gen==4 then
@@ -1428,7 +1432,7 @@ function BattleState.newWild(game, species, level, opts)
if opts and opts.roamerSeed then
Pokemon.applySeed(game.data, wild, opts.roamerSeed)
end
- self.enemy = makeBattler(game.data, wild, false)
+ self.enemy = makeBattler(game.data, wild, false, nil, self)
require("src.battle.Gen2Wild").initialize(self, opts)
if self.roamer then
local kept = math.floor(tonumber(opts.roamerHP) or 0)
@@ -1552,9 +1556,9 @@ function BattleState.newPokedudeDemo(game, lesson)
local self = newBattle(game)
self.kind = "wild"
self.noRun = true
- self.player = makeBattler(game.data, party[1], true, game.save)
+ self.player = makeBattler(game.data, party[1], true, game.save, self)
self.enemyParty = { pokedudeMon(game, def.enemy) }
- self.enemy = makeBattler(game.data, self.enemyParty[1], false)
+ self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self)
self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!",
self.enemy.name)
self.demoName = "POKé DUDE"
@@ -1651,15 +1655,17 @@ local function buildTrainerParty(game, oppClass, partyIndex, partyDef)
goto continue
end
do
- local mon = Pokemon.new(game.data, slot.species, slot.level, nil, slot.form)
+ local dvs = slot.dvs or trainerDvs
+ local gen2 = require('src.core.GameVersion').generation()==2
+ local mon = Pokemon.new(game.data, slot.species, slot.level, nil, slot.form,
+ gen2 and {dvs=dvs} or nil)
-- fixed trainer DVs, recomputed stats. A party slot that carries its OWN
-- DVs and stat exp is a stored mon rather than a generated one -- the
-- Battle Tower's opponents come out of BattleTowerMons with both -- so its
-- numbers win, and Stats.calc reproduces the cartridge's own stat block
-- from them. No ordinary TrainerGroups slot has either field, so every
-- other trainer in the game is unchanged.
- local dvs = slot.dvs or trainerDvs
- mon.dvs = dvs
+ if gen2 then dvs=mon.dvs else mon.dvs=dvs end
if slot.statExp then mon.statExp = slot.statExp end
if slot.happiness then mon.happiness = slot.happiness end
if slot.stats then
@@ -1702,14 +1708,20 @@ end
BattleState.buildTrainerParty = buildTrainerParty
+function BattleState:playerPartyView()
+ return self.playerParty or self.game.save.party or {}
+end
+
function BattleState:trainerLabel_()
return self.trainerLabel or (self.trainer and self.trainer.name) or "?"
end
-function BattleState.newTrainer(game, oppClass, partyIndex)
+function BattleState.newTrainer(game, oppClass, partyIndex, opts)
local self = newBattle(game)
self.kind = "trainer"
self.oppClass = oppClass
+ self.playerParty, self.playerPartyIndices = require("src.battle.PlayerPartyScope").select(game,
+ type(opts) == "table" and opts.playerPartyIndices or nil)
-- wOtherTrainerID: which trainer inside the class. PlayBattleMusic reads
-- it to tell the two RIVAL2 fights apart, so keep it on the battle.
self.partyIndex = tonumber(partyIndex) or 1
@@ -1737,14 +1749,14 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
end
self.enemyParty = buildTrainerParty(game, oppClass, partyIndex or 1, partyDef)
self.enemyIndex = 1
- local playerMon = Party.firstHealthy(game.save.party)
+ local playerMon = Party.firstHealthy(self:playerPartyView())
if not playerMon then
Logger.warn("trainer battle with no healthy party; skipping")
self.dead = true
else
- self.player = makeBattler(game.data, playerMon, true, game.save)
+ self.player = makeBattler(game.data, playerMon, true, game.save, self)
end
- self.enemy = makeBattler(game.data, self.enemyParty[1], false)
+ self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self)
self.aiUses = self:aiUsesFor() -- wAICount, reset per enemy mon
markSeen(game, self.enemyParty[1].species, self.enemyParty[1])
@@ -1788,6 +1800,15 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
self.trainerLabel = shownName
end
self.introText = Strings("%s wants\nto fight!", shownName)
+ -- InitEnemyTrainer awards GSC friendship before the first turn, including
+ -- Elite Four/Champion/Red. Resolve the public source predicate so API2
+ -- decorators affect this actual native owner. The party view contains the
+ -- original selected mons; excluded save-party members receive no award.
+ if require("src.pokemon.Gen2Friendship").isVanilla()
+ and require("src.battle.gen2.Battle").isGymLeader(
+ require("src.battle.Gen2TrainerClass").fromIndex(self.trainer.index)) then
+ require("src.pokemon.Gen2Friendship").gymBattle({party=self:playerPartyView()})
+ end
return self
end
@@ -1836,7 +1857,7 @@ function BattleState:sendOutSecondPair()
if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and self.ally then
local first = Party.firstHealthy(self.ally.party or {})
if first then
- local b = makeBattler(game.data, first, true, nil)
+ local b = makeBattler(game.data, first, true, nil, self)
b.ally = true
self:placeBattler(BattleState.POS.PLAYER_RIGHT, b)
end
@@ -1844,10 +1865,10 @@ function BattleState:sendOutSecondPair()
if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and not self.ally then
-- the next healthy one that is not already standing in the left slot
local lead = self.player and self.player.mon
- local second = Party.firstHealthy(game.save.party, lead)
+ local second = Party.firstHealthy(self:playerPartyView(), lead)
if second then
self:placeBattler(BattleState.POS.PLAYER_RIGHT,
- makeBattler(game.data, second, true, game.save))
+ makeBattler(game.data, second, true, game.save, self))
elseif GameVersion.isGen4() then
-- ...EXCEPT ON PLATINUM, where an escort makes it reachable: the
-- partner flag forces hasTwoAliveMons, so a same-id double spots a
@@ -1874,7 +1895,7 @@ function BattleState:sendOutSecondPair()
if party and party[2] then
self.enemyIndexRight = 2
self:placeBattler(BattleState.POS.OPPONENT_RIGHT,
- makeBattler(game.data, party[2], false))
+ makeBattler(game.data, party[2], false, nil, self))
markSeen(game, party[2].species, party[2])
else
-- a "double" trainer with one Pokemon fights alone on their side,
@@ -1932,7 +1953,7 @@ function BattleState:addOpponentTrainer(oppClass)
-- the right-hand opponent is the SECOND trainer's lead, replacing
-- whatever the first trainer's second Pokemon had been put there
self:placeBattler(BattleState.POS.OPPONENT_RIGHT,
- makeBattler(self.game.data, self.enemyPartyB[1], false))
+ makeBattler(self.game.data, self.enemyPartyB[1], false, nil, self))
markSeen(self.game, self.enemyPartyB[1].species, self.enemyPartyB[1])
end
return true
@@ -1991,7 +2012,7 @@ BattleState.EDITOR_TRAINER_CLASS = "OPP_EDITOR_TRAINER"
-- `party` is an array of slots in newTrainer's own shape; `look` is the class
-- to borrow a pic, palette and AI from (the NPC's own trainerClass, when it
-- named one).
-function BattleState.newEditorTrainer(game, party, look, name)
+function BattleState.newEditorTrainer(game, party, look, name, opts)
local trainers = game.data.trainers
if type(party) ~= "table" or #party == 0 then return nil, "that team is empty" end
local base = look and trainers[look] or nil
@@ -2009,7 +2030,7 @@ function BattleState.newEditorTrainer(game, party, look, name)
def.paletteSource, def.aiMods = base.paletteSource, base.aiMods
end
trainers[BattleState.EDITOR_TRAINER_CLASS] = def
- return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1)
+ return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1, opts)
end
-- PLATINUM'S ESCORT WILD BATTLE (BATTLE_TYPE_AI_PARTNER, wild_encounters.c):
@@ -2167,7 +2188,7 @@ function BattleState:makeOldManDemo(name)
if not self.player then
self.dead = false
self.player = makeBattler(self.game.data,
- Pokemon.new(self.game.data, self.enemy.mon.species, 5), true)
+ Pokemon.new(self.game.data, self.enemy.mon.species, 5), true, nil, self)
end
end
@@ -2299,7 +2320,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 +2333,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 +2716,10 @@ function BattleState:updateQueue()
end
if self.animPlaying then
self.animPlayer:update()
+ if GameVersion.isGen2() then
+ require('src.battle.Gen2PicturePresentation').sync(self)
+ Gen2Vanish.sync(self)
+ end
if self.animPlayer.pollEffects and self.applyAnimEffect then
for _, ev in ipairs(self.animPlayer:pollEffects()) do
self:applyAnimEffect(ev)
@@ -2717,16 +2742,27 @@ 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)
+ Gen2Vanish.sync(self)
+ end
local item = table.remove(self.queue, 1)
if not item then return false end
if item.fn then
@@ -2802,7 +2838,10 @@ function BattleState:updateQueue()
-- ball/send-out anims ignore the OPTIONS toggle: PlayMoveAnimation
-- short-circuits to TossBallAnimation before its wOptions check
-- (engine/battle/animations.asm:415)
- if item.anim and (self:animationsOn() or BALL_ANIMS[item.anim]) then
+ local gen2Ids=GameVersion.isGen2() and self.animPlayer and self.animPlayer.anims
+ and self.animPlayer.anims.ids
+ local gen2Misc=gen2Ids and gen2Ids[item.anim] and not mdef
+ if item.anim and (self:animationsOn() or BALL_ANIMS[item.anim] or gen2Misc) then
-- HOENN'S OWN ANIMATION, when the move has one. It does not take the
-- animPlaying path: that one belongs to the Game Boy players, which
-- carry their own per-row sounds, and this one does not -- so the
@@ -2843,9 +2882,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 +2890,29 @@ 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)
+ elseif gen2Misc and not opts then
+ ok,started=pcall(self.animForId,self,item.anim,
+ item.attackerIsPlayer and 'player' or 'enemy',item.animParam)
+ 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 +2981,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 +3014,11 @@ function BattleState:updateQueue()
self:applyHitFx(item.hit)
end
end
+ if GameVersion.isGen2() then
+ Gen2Vanish.admitted(self,item,self.animPlaying and self.animPlayer.runtime or nil)
+ require('src.battle.Gen2PicturePresentation').admitted(self,item,
+ self.animPlaying and self.animPlayer.runtime or nil)
+ end
self.current = nil
return true
end
@@ -3432,7 +3496,10 @@ end
function BattleState:placeBattler(pos, battler)
local side, flank = slotOf(pos)
self.sides[side].battlers[flank] = battler
- if battler then battler.position = pos end
+ if battler then
+ battler.position = pos
+ Gen2Read.bind(battler, self)
+ end
if flank == 1 then
if side == 1 then self.player = battler else self.enemy = battler end
end
@@ -3552,6 +3619,9 @@ function BattleState:syncSides()
if pr then pr.position = BattleState.POS.PLAYER_RIGHT end
if orr then orr.position = BattleState.POS.OPPONENT_RIGHT end
end
+ for _, battler in activeBattlers(self) do
+ Gen2Read.bind(battler, self)
+ end
end
-- IS THIS SIDE STILL ARRIVING?
@@ -3616,7 +3686,8 @@ function BattleState:enter()
self.result = "lose"
self.game.stack:pop()
require("src.core.Music").restoreMap(self.data)
- Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true })
+ if GameVersion.isGen2() then self.battle:endBattle('lose', {skipped=true})
+ else Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true }) end
local onFinish = self.onFinish
local function blackedOut()
if onFinish then onFinish("lose") end
@@ -3637,7 +3708,6 @@ function BattleState:enter()
if self.isGymLeader then
require("src.world.PikachuFollower")
.modifyHappiness(self.game.save, "GYMLEADER")
- require("src.pokemon.Gen2Friendship").gymBattle(self.game.save)
end
-- normally already playing: the transition wipe starts the theme
-- (audio/play_battle_music.asm runs before the transition, and
@@ -4109,6 +4179,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
@@ -4440,7 +4522,7 @@ function BattleState:update(dt)
if self.palPark then require("src.battle.Gen4PalParkBattle").skipRow(self, dir) end
end
if input:wasPressed("a") then
- self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
+ self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex])
end
return
end
@@ -4466,7 +4548,7 @@ function BattleState:update(dt)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
- self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
+ self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex])
end
return
end
@@ -4494,7 +4576,7 @@ function BattleState:update(dt)
-- loops the party menu until a healthy mon is picked, so B and
-- fainted picks land back here and reopen it
if acting.mon.hp <= 0 then
- if Party.firstHealthy(self.game.save.party) then
+ if Party.firstHealthy(self:playerPartyView()) then
self:openReplacementMenu()
end
return
@@ -4546,60 +4628,10 @@ function BattleState:update(dt)
-- asked (fainted, or locked into a move it cannot choose).
if input:wasPressed("b") and self:isDouble()
and self:choosingSlotNow() == BattleState.POS.PLAYER_RIGHT then
- local left = self:battlerAt(BattleState.POS.PLAYER_LEFT)
- local pend = self.pendingActions and self.pendingActions[BattleState.POS.PLAYER_LEFT]
- if left and not left.ally and (left.mon and left.mon.hp or 0) > 0 and pend
- and not self:menuLockedAction(left) then
- self.pendingActions[BattleState.POS.PLAYER_LEFT] = nil
- if self.chosenTargets then self.chosenTargets[BattleState.POS.PLAYER_LEFT] = nil end
- self.choosingSlot = BattleState.POS.PLAYER_LEFT
- self.menuIndex, self.moveIndex = 1, 1
- pcall(function() require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") end)
- return
- end
+ if self:cancelMove() then return end
end
if input:wasPressed("a") then
- local choice = self:menuActions()[self.menuIndex]
- -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL /
- -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the
- -- third slot IS the ball, so the item action becomes the throw.
- if choice == "item" and self.bugContest then
- self:bugContestBall()
- return
- end
- if choice == "fight" and self.ghost then
- self:say(Strings("%s is too\nscared to move!", self.player.name))
- self.phase = "messages"
- self.afterQueue = "menu"
- self:act(function()
- self:executeAction(self.enemy, self.player, self:enemyAction())
- end)
- self:act(function() self:endOfTurn() end)
- elseif choice == "fight" then
- -- After the menu: own trapping/Bide or foe Wrap skips the move
- -- list and forces the locked action (core.asm:320-329)
- local fightLock = self:fightLockedAction(self.player)
- if fightLock then
- self:resolveTurn(fightLock)
- return
- end
- local who = self:menuBattler()
- if not self:playerHasPP(who) then
- -- _NoMovesLeftText, then Struggle engages
- self:say(Strings("%s has no\nmoves left!", who.name))
- self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true })
- return
- end
- self.phase = "moveSelect"
- self.moveIndex = math.min(self.moveIndex, #who.curMoves)
- self.moveSwapIndex = nil
- elseif choice == "run" then
- self:tryRun()
- elseif choice == "item" then
- self:openItems()
- else
- self:openParty()
- end
+ self:chooseMenu(self:menuActions()[self.menuIndex])
end
return
end
@@ -4625,63 +4657,14 @@ function BattleState:update(dt)
self.moveSwapIndex = self.moveIndex
end
elseif input:wasPressed("b") then
- self.moveSwapIndex = nil
- self.phase = "menu"
+ self:cancelMove()
elseif input:wasPressed("a") then
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
return
end
- local mv = moves[self.moveIndex]
- local mvDef = mv and self.data.moves[mv.id]
- if chooser.disabledSlot == self.moveIndex then
- self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
- self.phase = "messages"
- self.afterQueue = "menu"
- -- TAUNT and TORMENT refuse the move at SELECTION rather than after the
- -- fact, the way DISABLE already does, so the player is never told to
- -- pick again for something the game was never going to allow.
- elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then
- -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block
- self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true))
- self.phase = "messages"
- self.afterQueue = "menu"
- elseif self:tauntBlocks(chooser, mvDef) then
- self:say(Strings("%s can't use\n%s after the\ntaunt!",
- chooser.name, mvDef and mvDef.name or mv.id))
- self.phase = "messages"
- self.afterQueue = "menu"
- elseif self:tormentBlocks(chooser, mvDef) then
- self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!",
- chooser.name))
- self.phase = "messages"
- self.afterQueue = "menu"
- elseif self.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef) then
- -- PLATINUM / HGSS: Gravity and Heal Block (BattleSystem_CanUseMove)
- self:say(require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef))
- self.phase = "messages"
- self.afterQueue = "menu"
- elseif mv.pp <= 0 then
- self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
- self.phase = "messages"
- self.afterQueue = "menu"
- elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then
- -- WHICH OF THE TWO, when the move takes one and there are two.
- --
- -- Emerald's selector cycles over the legal slots and names the
- -- candidate; nothing else in the menu changes. A single battle
- -- never gets here, because needsChoice answers false whenever there
- -- is only one thing to aim at -- and on the Game Boy cartridges the
- -- moves carry no target byte at all, so it answers false for every
- -- move in the game.
- self.pendingMove = mv
- self.targetChoices = Targeting.choices(self, self:menuBattler())
- self.targetIndex = 1
- self.phase = "targetSelect"
- else
- self:chooseAction(mv)
- end
+ self:chooseMove(self.moveIndex)
end
return
end
@@ -4699,16 +4682,9 @@ function BattleState:update(dt)
elseif input:wasPressed("right") or input:wasPressed("down") then
self.targetIndex = self.targetIndex < #list and self.targetIndex + 1 or 1
elseif input:wasPressed("b") then
- self.pendingMove, self.targetChoices = nil, nil
- self.phase = "moveSelect"
+ self:cancelMove()
elseif input:wasPressed("a") then
- local mv = self.pendingMove
- local pick = list[self.targetIndex]
- self.pendingMove, self.targetChoices = nil, nil
- self.chosenTargets = self.chosenTargets or {}
- local who = self:menuBattler()
- if who and who.position then self.chosenTargets[who.position] = pick end
- self:chooseAction(mv)
+ self:chooseTarget(self.targetIndex)
end
return
end
@@ -4729,12 +4705,7 @@ function BattleState:update(dt)
elseif input:wasPressed("down") then
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
elseif input:wasPressed("a") then
- local pick = moves[self.mimicIndex]
- local ctx = self.mimicCtx
- self.mimicMoves, self.mimicCtx = nil, nil
- self.phase = "messages"
- self.nextInsert = 0 -- the copy's anim + text go to the queue head
- self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
+ self:chooseMimic(self.mimicIndex)
end
return
end
@@ -5104,7 +5075,7 @@ function BattleState:openPokedudeTarget(step, switchMode)
if switchMode and p.scriptTick == 48 then p.index = step.switchTo or 2 end
if p.scriptTick > 96 and not p._pokedudePicked then
p._pokedudePicked = true
- local mon = self.game.save.party[p.index]
+ local mon = self:playerPartyView()[p.index]
p:close()
if switchMode then
self:resolveSwitch(mon)
@@ -5904,7 +5875,7 @@ function BattleState:switchPlayerInto(pos, newMon)
end
local previous = self:battlerAt(pos)
self:restoreMimicked(previous) -- the battle copy leaves with it
- local incoming = makeBattler(self.data, newMon, true, self.game.save)
+ local incoming = makeBattler(self.data, newMon, true, self.game.save, self)
self:placeBattler(pos, incoming)
-- BATON PASS: the stat stages and the volatiles go with the switch, which
-- is the whole move. Nothing else survives a switch, so a pass that was
@@ -6019,7 +5990,7 @@ function BattleState:abilitySwitchIn(battler)
local side = self.sideOf and self:sideOf(battler)
if side and side.spikes then
local grounded = Abilities.of(battler) ~= "LEVITATE"
- for _, t in ipairs(battler.curTypes or {}) do
+ for _, t in ipairs(Gen2Read.types(battler) or {}) do
if t == "FLYING" then grounded = false end
end
-- PLATINUM / HGSS: subscript_hazards_check decides -- Magic Guard spares
@@ -6232,7 +6203,7 @@ function BattleState:abilityForecast(battler)
if not (battler and battler.mon and battler.mon.hp > 0) then return false end
local want = Abilities.forecastType(battler, Weather.current(self))
if not want then return false end
- local types = battler.curTypes
+ local types = Gen2Read.types(battler)
if type(types) == "table" and #types == 1 and types[1] == want then
return false
end
@@ -6756,6 +6727,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
@@ -7716,6 +7702,7 @@ function BattleState:executeAction(user, target, action)
if user.g4Gone then return end
-- this battler's action is under way (Payback, Sucker Punch, Me First)
user.actedTurn = self.turnCount
+ if GameVersion.isGen2() then require("src.battle.Gen2Protection").actionStarted(self, user) end
-- A SWITCH NEEDS NO TARGET. Everything else does, and with four on the
-- field the one it was aimed at can already be off it -- the entry's
-- target is resolved as the entry runs, and there may be nobody left to
@@ -7785,7 +7772,7 @@ function BattleState:executeAction(user, target, action)
local previous = self.enemy
local oldName = self.enemy.name
self.enemyIndex = action.index
- self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
+ self.enemy = makeBattler(self.data, self.enemyParty[action.index], false, nil, self)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
@@ -7824,8 +7811,9 @@ function BattleState:executeAction(user, target, action)
if action.special == "recharge" then
-- only reaching .HyperBeamCheck consumes the flag (core.asm:3384-
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
- if self:preRechargeChecks(user, target) then return end
+ if not GameVersion.isGen2() and self:preRechargeChecks(user, target) then return end
user.mustRecharge = nil
+ if GameVersion.isGen2()then require('src.battle.Gen2Sleep').cancel(user,action)end
self:sayNext(self:romText("_MustRechargeText", "%s\nmust recharge!", displayName(user)))
return
end
@@ -7850,7 +7838,7 @@ function BattleState:executeAction(user, target, action)
return
end
- if self:statusInterrupt(user, target) then return end
+ if self:statusInterrupt(user, target, action) then return end
-- PLATINUM / HGSS: Gravity and Heal Block refuse a move outright
if self.g4fx and require("src.battle.Gen4MoveEffects").beforeMove(self, user, target, action) then
return
@@ -7904,17 +7892,19 @@ end
-- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia
-- move rows. Player sleep plays the anim before FastAsleepText;
-- enemy sleep and both confusion sides print the text first.
-function BattleState:statusOnomatopoeia(user, kind)
+function BattleState:statusOnomatopoeia(user, kind, caption)
local isPlayer = user.isPlayer
local anim
- if kind == "sleep" then
+ if GameVersion.isGen2() then
+ anim=kind=='sleep' and 'ANIM_SLP' or 'ANIM_CONFUSED'
+ elseif kind == "sleep" then
anim = isPlayer and "SLP_PLAYER_ANIM" or "SLP_ANIM"
else
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
end
- local text = kind == "sleep"
+ local text = caption or (kind == "sleep"
and self:romText("_FastAsleepText", "%s\nis fast asleep!", displayName(user))
- or self:romText("_IsConfusedText", "%s\nis confused!", displayName(user))
+ or self:romText("_IsConfusedText", "%s\nis confused!", displayName(user)))
if kind == "sleep" and isPlayer then
self:animNext(anim, isPlayer)
self:sayNext(text)
@@ -7924,14 +7914,12 @@ function BattleState:statusOnomatopoeia(user, kind)
end
end
--- Queue status text (+ sleep/confusion FX when the line matches).
+-- Queue status text and the effect belonging to this actual status event.
-- Wake / snap-out / flinch / etc. stay text-only.
-function BattleState:sayStatusMsg(user, msg)
+function BattleState:sayStatusMsg(user, msg, kind)
local text = prefixEnemy(msg, user)
- if msg:find("is fast asleep!", 1, true) then
- self:statusOnomatopoeia(user, "sleep")
- elseif msg:find("is confused!", 1, true) then
- self:statusOnomatopoeia(user, "confused")
+ if kind then
+ self:statusOnomatopoeia(user, kind, text)
else
self:sayNext(text)
end
@@ -7992,7 +7980,7 @@ end
-- Runs Status.beforeMove plus the shared interruption bookkeeping;
-- returns true when the user's action is interrupted.
-function BattleState:statusInterrupt(user, target)
+function BattleState:statusInterrupt(user, target, move)
-- TRUANT, before the conditions. SLAKING and SLAKOTH move every OTHER
-- turn: the cartridge sets a flag after a move and reads it before the
-- next one, which is why this is a toggle rather than "every second turn
@@ -8006,8 +7994,10 @@ function BattleState:statusInterrupt(user, target)
end
user.truantLoafing = true
end
- local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
- for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
+ local canMove,msgs,selfHit,reason,presentation=Status.beforeMove(user,self.rng,self,move)
+ for i,m in ipairs(msgs)do
+ self:sayStatusMsg(user,m,presentation and presentation.index==i and presentation.kind or nil)
+ end
if selfHit then
-- confusion self-hit (core.asm:3428-3434): clears everything in
-- status1 except CONFUSED, then HandleSelfConfusionDamage deals a
@@ -8028,8 +8018,7 @@ function BattleState:statusInterrupt(user, target)
-- full paralysis (core.asm:3459-3464) clears bide/thrash/charge/
-- trapping; sleep, freeze, flinch and held-in-place leave every
-- volatile in place (a sleeping wrapper keeps its victim held)
- if user.mon.status == "PAR" and msgs[#msgs]
- and msgs[#msgs]:find("fully paralyzed", 1, true) then
+ if presentation and presentation.blockedStatus=='PAR' then
self:clearVolatiles(user, false)
end
return true
@@ -8120,7 +8109,7 @@ function BattleState:applyGen3TransformVisual(user, target, whiteCoeff)
end
local function battlerHasType(battler, wanted)
- for _, t in ipairs(battler and battler.curTypes or {}) do
+ for _, t in ipairs(Gen2Read.types(battler) or {}) do
if t == wanted then return true end
end
return false
@@ -8182,6 +8171,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima
-- charge release?
local releasing = user.charging == moveInst and user.chargeReady
if releasing then
+ Gen2Vanish.releasing(self,user)
user.charging, user.chargeReady, user.invulnerable = nil, nil, nil
user.hiddenAs = nil
end
@@ -8259,6 +8249,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima
attackerPosition = user.position,
targetPosition = target and target.position,
animContext = animContext }
+ if GameVersion.isGen2() and releasing then Gen2Vanish.releaseRow(self,user,self.moveAnimRow) end
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
end
end
@@ -8297,9 +8288,12 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima
-- FURY CUTTER and ROLLOUT double for each consecutive LANDING and reset
-- the same way. Both counters live on the user and are cleared here,
-- which is the one place every move passes through.
- if move.id ~= "PROTECT" and move.id ~= "DETECT" and move.id ~= "ENDURE" then
- user.protectRun = nil
+ local keepsProtection = move.id == "PROTECT" or move.id == "DETECT" or move.id == "ENDURE"
+ if GameVersion.isGen2() then
+ -- The GSC counter follows the effect, including renamed source moves.
+ keepsProtection = move.effect == "PROTECT_EFFECT" or move.effect == "ENDURE_EFFECT"
end
+ if not keepsProtection then user.protectRun = nil end
if user.rampMove and user.rampMove ~= move.id then
user.rampRun, user.rampMove = nil, nil
end
@@ -8317,7 +8311,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima
-- straight past `charge` while the sun is up and fires on the turn it was
-- selected. Every other charge move ignores the weather.
if record and record.charge and not releasing and not skipCharge then
- local branchAware = GameVersion.get() == "firered"
+ local branchAware = GameVersion.isGen2() or GameVersion.get() == "firered"
and hasMoveTurnVariant(move.anim)
if not branchAware then self:cancelMoveAnim() end
user.charging = moveInst
@@ -8334,6 +8328,12 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima
-- (a BW record names its own place: underwater, underground, vanished)
user.hiddenAs = move.hiddenAs or ((move.id == "DIG") and "underground" or "air")
end
+ if GameVersion.isGen2() then
+ -- BattleCommand_Charge runs this move's own param-1 program before
+ -- DisappearUser; Gen1's TELEPORT/SLIDE_DOWN substitutes do not apply.
+ if self.moveAnimRow then self.moveAnimRow.animParam=1 end
+ Gen2Vanish.charge(self,user,self.moveAnimRow)
+ end
if not branchAware then
local chargeAnim = record.charge.anim
if move.id == "DIG" then
@@ -8701,7 +8701,7 @@ function BattleState:benchFor(isPlayer, position)
if isPlayer and self.ally and position == BattleState.POS.PLAYER_RIGHT then
return self.ally.party or {}
end
- if isPlayer then return self.game.save.party or {} end
+ if isPlayer then return self:playerPartyView() end
if position == BattleState.POS.OPPONENT_RIGHT and self.enemyPartyB then
return self.enemyPartyB
end
@@ -8786,7 +8786,7 @@ function BattleState:fillEmptySlots()
if pick then
local allySlot = isPlayer and self.ally and pos == BattleState.POS.PLAYER_RIGHT
self:placeBattler(pos, makeBattler(self.data, pick, isPlayer,
- (isPlayer and not allySlot) and self.game.save or nil))
+ (isPlayer and not allySlot) and self.game.save or nil, self))
self:syncSides()
if not isPlayer then markSeen(self.game, pick.species, pick) end
local who = self:battlerAt(pos)
@@ -8828,7 +8828,7 @@ function BattleState:promptDoubleReplacement(pos, rest)
end
end
done = true
- self:placeBattler(pos, makeBattler(self.data, mon, true, game.save))
+ self:placeBattler(pos, makeBattler(self.data, mon, true, game.save, self))
self:syncSides()
local who = self:battlerAt(pos)
self.nextInsert = 0
@@ -8889,7 +8889,7 @@ function BattleState:awardExp(fallen)
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
-- the surviving participants are counted and paid
local participants, alive = 0, {}
- for _, mon in ipairs(self.game.save.party) do
+ for _, mon in ipairs(self:playerPartyView()) do
if self.participants and self.participants[mon] then
participants = participants + 1
if mon.hp > 0 then table.insert(alive, mon) end
@@ -9010,7 +9010,7 @@ function BattleState:awardExp(fallen)
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
if not expAll then
local holders = {}
- for _, mon in ipairs(self.game.save.party) do
+ for _, mon in ipairs(self:playerPartyView()) do
if (mon.hp or 0) > 0 and holdsExpShare(mon) then
holders[#holders + 1] = mon
end
@@ -9040,9 +9040,9 @@ function BattleState:awardExp(fallen)
-- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
- for _, mon in ipairs(self.game.save.party) do
+ for _, mon in ipairs(self:playerPartyView()) do
if mon.hp > 0 then
- ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
+ ctx.applyShare(mon, math.max(1, ctx.participants) * #self:playerPartyView() * 2, "expAll")
end
end
end
@@ -9087,7 +9087,7 @@ function BattleState:enemyMonFainted()
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower()
- local partyCount = #self.game.save.party
+ local partyCount = #self:playerPartyView()
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
@@ -9125,7 +9125,7 @@ function BattleState:enemyMonFainted()
end
self:act(function()
local previous = self.enemy
- self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
+ self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false, nil, self)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
@@ -9163,7 +9163,7 @@ function BattleState:enemyMonFainted()
local mon = shiftSwitchMon
if not mon then return end
local previous = self.player
- self.player = makeBattler(self.data, mon, true, self.game.save)
+ self.player = makeBattler(self.data, mon, true, self.game.save, self)
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
@@ -9417,7 +9417,7 @@ function BattleState.isOaksLabStarterRival(self)
end
function BattleState:playerMonFainted()
- local nextMon = Party.firstHealthy(self.game.save.party)
+ local nextMon = Party.firstHealthy(self:playerPartyView())
-- Being out of useable POKéMON blacks you out even when the battle was
-- already decided in our favour. A double faint -- our last mon dying
-- to residual damage on the turn it lands the KO -- used to hit the
@@ -9505,7 +9505,7 @@ function BattleState:playerMonFainted()
local ChoiceBox = require("src.ui.ChoiceBox")
return ChoiceBox.new(game, function(yes)
if yes then return end -- the menu-phase guard opens the party menu
- local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0
+ local pSpd = (self:playerPartyView()[1].stats or { speed = 0 }).speed or 0
if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then
require("src.core.Sound").play(self.data, "Run")
self:say(self:romText("_GotAwayText", "Got away safely!"))
@@ -9536,7 +9536,7 @@ function BattleState:openReplacementMenu()
end
self:restoreMimicked(self.player)
local previous = self.player
- self.player = makeBattler(self.data, mon, true, game.save)
+ self.player = makeBattler(self.data, mon, true, game.save, self)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
@@ -9563,7 +9563,7 @@ function BattleState:openReplacementMenu()
-- unasked (PlayerHandleChoosePokemon's arena arm returns the next slot)
if self.palace or self.arena then
self:act(function()
- for _, mon in ipairs(game.save.party or {}) do
+ for _, mon in ipairs(self:playerPartyView()) do
if mon.hp > 0 and not Party.isEgg(mon) and mon ~= (self.player and self.player.mon) then
onSwitch(mon)
return
@@ -9764,7 +9764,7 @@ function BattleState:safariEnemyTurn()
self:sayNext(self:romText("_SafariZoneAngryText", "Wild %s\nis angry!", self.enemy.name))
end
self:act(function()
- local speed = self.enemy.curStats.speed % 256
+ local speed = Gen2Read.stats(self.enemy).speed % 256
local fled = speed > 127
local b = (speed * 2) % 256
if not fled then
@@ -10048,7 +10048,8 @@ function BattleState:storeCaughtMon()
-- Ruins of Alph researcher and the dex's UNOWN mode gate on
local form = require("src.pokemon.Sprites").formIndex(
game.data.pokemon[species], self.enemy.mon)
- if dex and type(form)=='number' then
+ local nativeUnown = require("src.pokemon.Gen2Unown")
+ if not nativeUnown.supported() and dex and type(form)=='number' then
dex.unownForms = dex.unownForms or {}
if not dex.unownForms[form] then
dex.unownForms[form] = true
@@ -10092,12 +10093,14 @@ function BattleState:storeCaughtMon()
self.enemy.mon.ball = self.lastBall
end
if Party.add(game.save.party, self.enemy.mon) then
+ if nativeUnown.supported() then nativeUnown.registerCatch(game.save, self.enemy.mon, game.data) end
require('src.pokemon.Gen4PoketchState').remember(game, self.enemy.mon)
askCaughtNickname()
else
destination = "box"
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
if boxNum then
+ if nativeUnown.supported() then nativeUnown.registerCatch(game.save, self.enemy.mon, game.data) end
require('src.pokemon.Gen4PoketchState').remember(game, self.enemy.mon)
askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
@@ -10288,7 +10291,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 +10435,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()
@@ -10800,6 +10803,7 @@ function BattleState:playVictoryMusic()
end
function BattleState:finish()
+ if self._gen2Complete then return end
local pd = self.pokedudeDemo
if pd and not pd.finalVoiceQueued then
pd.finalVoiceQueued = true
@@ -10842,7 +10846,7 @@ function BattleState:finish()
-- go on, the partner heals in the field), so it stands.
if self.result ~= "lose" and not self.demo and not self.pokedudeDemo
and not (self.ally and self.result == "win")
- and not Party.firstHealthy(self.game.save.party) then
+ and not Party.firstHealthy(self:playerPartyView()) then
Logger.warn("battle finished %s with no healthy party; forcing blackout",
tostring(self.result))
self.result = "lose"
@@ -10860,8 +10864,10 @@ function BattleState:finish()
-- like the overworld reload's PlayDefaultMusicFadeOutCurrent
-- (home/overworld.asm:2343-2348)
require("src.core.Music").restoreMap(self.data)
+ if GameVersion.isGen2() then self._gen2Complete = true end
self.game.stack:pop()
- Runtime.emit("battle.ended", { battle = self, result = self.result or "run" })
+ if GameVersion.isGen2() then self.battle:endBattle(self.result or 'run', {nativeNotification=true})
+ else Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) end
-- Coming back from the battle screen is a fade, not a cut: EnterMap sees
-- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle
-- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite. This is the one
@@ -11613,6 +11619,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 +11658,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 +11712,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 +12160,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 +12274,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 +12401,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 +12419,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 +12436,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 +12456,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(),
@@ -12589,7 +12610,7 @@ function BattleState:drawHUDs(slide)
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
hudTile(0x6F, 72, 88)
love.graphics.setColor(1, 1, 1, 1)
- self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
+ self:drawBallRow(self:playerPartyView(), 88, 80, 8)
end
local hidePlayer = self.safari or self:demoHidesPlayer()
if self.player and not hidePlayer and not self.showPlayerBack
@@ -12850,6 +12871,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 +12985,15 @@ function BattleState:drawClassic()
end
end
+require('src.battle.Gen2MovePresentation').install(BattleState)
+require('src.battle.Gen2BattleScene').install(BattleState)
+Gen2BattleView.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})
+Gen2Vanish.install(BattleState)
+
return BattleState
diff --git a/src/battle/Battler.lua b/src/battle/Battler.lua
new file mode 100644
index 000000000..f34d0f248
--- /dev/null
+++ b/src/battle/Battler.lua
@@ -0,0 +1,53 @@
+-- Native battler data, extracted from BattleState.makeBattler.
+-- Existing engine authors' rights and licences remain in effect (see the upstream licences).
+-- Data/presentation boundary contributions by Roxas2712; see
+-- https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+-- This module allocates no picture or animation resource.
+local Forms = require('src.pokemon.Gen4Forms')
+local Gen2 = require('src.battle.Gen2BattleRead')
+local Battler = {}
+
+function Battler.new(data, mon, isPlayer, save, battle)
+ if require('src.core.GameVersion').isGen2() then require('src.pokemon.Gen2MonFields').bind(data,mon)end
+ local def = Forms.definition(data, mon)
+ local badgeBoosts = data.constants and data.constants.badgeBoosts
+ local badges
+ if isPlayer and save then
+ -- Gen 1 badge stat boosts (x9/8); the badge set follows the merged
+ -- badgeBoosts rows so a retuned list changes what gets baked in.
+ badges = {}
+ for _, row in ipairs(badgeBoosts or require('src.battle.Damage').BADGE_BOOSTS) do
+ if save.inventory[row.badge] then badges[row.badge] = true end
+ end
+ end
+ local battler = {
+ mon = mon,
+ def = def,
+ name = mon.nickname or def.name,
+ -- Presentation species may later change for Transform or a Tower ghost;
+ -- mon.species remains the Pokemon's identity in the save.
+ species = mon.species,
+ isPlayer = isPlayer,
+ badges = badges,
+ badgeBoosts = badgeBoosts,
+ statuses = data.statuses,
+ -- Registry views, not copies: a consumed berry and its nature lookup
+ -- must see the live merged records throughout the battle.
+ items = data.items,
+ natures = data.constants and data.constants.natures,
+ shownHP = mon.hp,
+ -- HUDs catch up at native refresh edges, not during a move's mutation.
+ shownStatus = mon.status,
+ -- A sleep applied outside battle keeps its own remaining count.
+ sleepTurns = (mon.status == 'SLP' and mon.sleepTurns) or nil,
+ stages = {},
+ -- Transform/Conversion/Mimic replace these transient views.
+ curStats = mon.stats,
+ curTypes = def.types,
+ curMoves = mon.moves,
+ }
+ if require('src.core.GameVersion').isGen2() then require('src.pokemon.Stats').trackBattler(battler)end
+ return Gen2.bind(battler, battle)
+end
+
+return Battler
diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua
index c287f54a9..60cd86214 100644
--- a/src/battle/Damage.lua
+++ b/src/battle/Damage.lua
@@ -6,6 +6,7 @@
-- Battlers built by makeBattler also carry the merged badgeBoosts rows and
-- statuses records; hand-built battlers fall back to the vanilla tables.
+local Gen2Read = require('src.battle.Gen2BattleRead')
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Stats = require("src.pokemon.Stats")
@@ -115,7 +116,7 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data)
local function shl(x) return math.min(255, x * 2) end
local speed
if ruleset.critUsesBaseSpeed == false then
- speed = Stats.applyStage(attacker.curStats.speed,
+ speed = Stats.applyStage(Gen2Read.stats(attacker).speed,
attacker.stages and attacker.stages.speed or 0)
else
speed = attacker.def.baseStats.speed
@@ -231,6 +232,8 @@ end
-- self-hit reads the opponent's screens).
-- Returns damage, {crit=bool, typeMult=x10}.
function Damage.compute(ruleset, attacker, defender, move, opts)
+ local attackStats, defendStats = Gen2Read.stats(attacker), Gen2Read.stats(defender)
+ local attackTypes, defendTypes = Gen2Read.types(attacker), Gen2Read.types(defender)
opts = opts or {}
local rng = opts.rng or love.math.random
-- ...and the same field again, because a STATUS move must not roll damage.
@@ -297,18 +300,18 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
-- Gen 2 kept the same type-based physical/special split but gave the
-- attacker Sp.Atk and the defender Sp.Def. A Gen 1 stat block has
-- neither key, so it stays on the single `special`.
- local split = special and attacker.curStats.spatk and defender.curStats.spdef
+ local split = special and attackStats.spatk and defendStats.spdef
local atkStat = special and (split and "spatk" or "special") or "attack"
local defStat = special and (split and "spdef" or "special") or "defense"
local atk, dfn
if crit and ruleset.critIgnoresStages then
- atk = attacker.curStats[atkStat]
- dfn = defender.curStats[defStat]
+ atk = attackStats[atkStat]
+ dfn = defendStats[defStat]
else
- atk = Stats.applyStage(attacker.curStats[atkStat],
+ atk = Stats.applyStage(attackStats[atkStat],
stageOf(attacker, atkStat))
- dfn = Stats.applyStage(defender.curStats[defStat],
+ dfn = Stats.applyStage(defendStats[defStat],
stageOf(defender, defStat))
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
@@ -464,7 +467,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
if not opts.typeless then
-- STAB
local stab = false
- for _, t in ipairs(attacker.curTypes) do
+ for _, t in ipairs(attackTypes) do
if t == move.type then stab = true break end
end
if stab then
@@ -474,7 +477,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
-- type effectiveness: each TypeEffects row is applied to the
-- running damage separately with its own floor (0.5*0.5 lands on
-- floor(floor(d/2)/2), not d*0.25)
- mult = TypeChart.effectiveness(move.type, defender.curTypes)
+ mult = TypeChart.effectiveness(move.type, defendTypes)
-- FORESIGHT / ODOR SLEUTH: a GHOST that has been identified stops being
-- immune to NORMAL and FIGHTING. It does not become weak to them --
-- the immunity simply becomes neutral -- so this reads the chart's
@@ -489,7 +492,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
if mult == 0 and bwLift then
foresight = true
mult = 10
- for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do
+ for _, m in ipairs(TypeChart.rows(move.type, defendTypes)) do
if m ~= 0 then mult = math.floor(mult * m / 10) end
end
end
@@ -504,7 +507,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
return 0, { crit = false, typeMult = 0,
ability = Abilities.of(defender) }
end
- for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do
+ for _, m in ipairs(TypeChart.rows(move.type, defendTypes)) do
-- the identified GHOST's zero row is the one skipped; every other
-- row a dual-typed Pokemon carries still applies
if not (foresight and m == 0) then d = math.floor(d * m / 10) end
diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua
index 87a4bd83a..25c1f8600 100644
--- a/src/battle/EffectRegistry.lua
+++ b/src/battle/EffectRegistry.lua
@@ -44,6 +44,9 @@ EffectRegistry.displayName = displayName
-- flags recorded (every Gen 1 and Gen 2 one) is affected, that generation's
-- rule.
function EffectRegistry.protectAffected(move)
+ -- GSC self-protection must reach its own restriction even if the foe protects.
+ if require('src.core.GameVersion').isGen2()
+ and (move.effect == 'PROTECT_EFFECT' or move.effect == 'ENDURE_EFFECT') then return false end
if move.protectAffected ~= nil then return move.protectAffected ~= false end
if move.gen4Effect ~= nil and tonumber(move.flags) then
return math.floor(tonumber(move.flags) / 2) % 2 == 1
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..5d5f1ef74 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,105 @@ 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)]
+ or audio.sfxOrder and audio.sfxOrder[id+1])
+ if name and audio.sfx and audio.sfx[name]then return Sound.playStereo(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 +238,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/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua
new file mode 100644
index 000000000..787584ccb
--- /dev/null
+++ b/src/battle/Gen2BattleView.lua
@@ -0,0 +1,67 @@
+-- Copyright (c) 2026 Roxas2712. See the offered contribution licence.
+-- Separate live Gen2 model over the existing shared native controller.
+local View = {}
+local Model = require('src.battle.gen2.Battle')
+local function partyOf(screen)
+ return screen.playerParty or screen.game.save.party or {}
+end
+
+local function indexOf(party, mon)
+ for index, candidate in ipairs(party or {}) do
+ if candidate == mon then return index end
+ end
+end
+
+function View.bind(screen)
+ -- Bind once; callers read the current native catalog without copying a
+ -- move, caching a definition, or switching the controller's moveset.
+ local readers = {
+ player = function() return screen.player and screen.player.mon end,
+ enemy = function() return screen.enemy and screen.enemy.mon end,
+ party = function() return partyOf(screen) end,
+ enemyParty = function() return screen.enemyParty end,
+ save = function() return screen.game.save end,
+ data = function() return screen.data end,
+ rng = function() return rawget(screen.battle, '_nativeLoveRng') end,
+ random = function() return rawget(screen.battle, '_nativeRandom') end,
+ wild = function() return screen.kind == "wild" end,
+ playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end,
+ enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end,
+ over = function() return screen.result ~= nil or screen.dead == true or screen._gen2Complete == true end,
+ outcome = function() return screen.result end,
+ -- The controller increments once per actual round, before turn-start
+ -- observers, including link participants and spectators. A source query
+ -- must not start a round or initialize checkpoint/persistent state.
+ turn = function() return screen.turnCount or 0 end,
+ battleType = function() return screen.battleType end,
+ trainer = function() return screen.trainer end,
+ tutorial = function() return screen.demo or screen.dudeDemo or screen.pokedudeDemo end,
+ contest = function() return screen.contest end,
+ safari = function() return screen.safari and true or false end,
+ link = function() return screen.kind == "link" end,
+ kind = function() return screen.kind end,
+ inBattleTowerBattle = function() return screen.battleTower == true end,
+ }
+ screen.battle = Model.bind(setmetatable({}, {
+ __index = function(_, key)
+ local read = readers[key]
+ if read then return read() end
+ return Model[key]
+ end,
+ __newindex = function(model, key, value)
+ if readers[key] or Model[key] ~= nil then error("native Gen2 battle view is read-only", 2) end
+ -- Native combat fields stay owned by the engine. Presentation consumers
+ -- may attach and release their own transient owners, as on the native
+ -- Gen2 model; these extensions never write to a battler or save.
+ rawset(model, key, value)
+ end,
+ }), screen)
+end
+
+function View.install(Screen)
+ function Screen:completeBattle()
+ assert(require('src.core.GameVersion').isGen2(), 'Gen2 completion requires a Gen2 battle')
+ return self:finish()
+ end
+end
+return View
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..a8becf560
--- /dev/null
+++ b/src/battle/Gen2MovePresentation.lua
@@ -0,0 +1,71 @@
+-- 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
+ -- CheckFlyDigStatus reads both actual battlers at animation admission.
+ -- A retained runner keeps this snapshot when the next move starts.
+ local options={};for key,value in pairs(opts or {})do options[key]=value end
+ options.flying={player=screen:isUnderground('player'),enemy=screen:isUnderground('enemy')}
+ if player:start(id,isPlayer,options)==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/Gen2MovePriority.lua b/src/battle/Gen2MovePriority.lua
new file mode 100644
index 000000000..9f9ac43c5
--- /dev/null
+++ b/src/battle/Gen2MovePriority.lua
@@ -0,0 +1,23 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- pokecrystal GetMovePriority/data/moves/effects_priorities.asm. Older native
+-- caches only stamp priority-hit moves; derive the other GSC classes from
+-- their native effect rather than treating Protect/Endure as ordinary moves.
+local Priority = {}
+local effects = {
+ PROTECT_EFFECT = 3, ENDURE_EFFECT = 3,
+ COUNTER_EFFECT = -1, MIRROR_COAT_EFFECT = -1,
+ FORCE_SWITCH_EFFECT = -1,
+}
+local ids = {
+ QUICK_ATTACK = 1, COUNTER = -1, VITAL_THROW = -1,
+ -- The shared native cache merges Roar/Whirlwind and Teleport into
+ -- SWITCH_AND_TELEPORT_EFFECT. Teleport is not a negative-priority move.
+ ROAR = -1, WHIRLWIND = -1,
+}
+function Priority.get(move)
+ if not move then return 0 end
+ -- Preserve explicit cartridge/mod data, including an explicit zero.
+ if move.priority then return move.priority end
+ return effects[move.effect] or ids[move.id] or 0
+end
+return Priority
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/Gen2Protection.lua b/src/battle/Gen2Protection.lua
new file mode 100644
index 000000000..299260dc6
--- /dev/null
+++ b/src/battle/Gen2Protection.lua
@@ -0,0 +1,30 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- GSC ProtectChance: a nonzero byte minus one against FF >> prior uses.
+-- Keep the existing native action, battler and RNG owners.
+local Protection={}
+local function side(user)return user.isPlayer and 'player' or 'enemy'end
+
+function Protection.actionStarted(battle,user)
+ local turn=battle.turnCount or 0
+ if battle._gen2ProtectionTurn~=turn then
+ battle._gen2ProtectionTurn=turn;battle._gen2ProtectionFirst=nil
+ end
+ if not battle._gen2ProtectionFirst then battle._gen2ProtectionFirst=side(user)end
+end
+
+function Protection.apply(battle,user,field)
+ local turn=battle.turnCount or 0
+ local first=battle._gen2ProtectionTurn==turn and battle._gen2ProtectionFirst
+ local count=math.max(0,math.floor(tonumber(user.protectRun)or 0))
+ local threshold=count<8 and math.floor(255/2^count)or 0
+ -- These branches precede the draw. A called protection effect retains the
+ -- original round's first side, including after a native switch replaces it.
+ if (first and first~=side(user))or(user.substituteHP or 0)>0
+ or threshold==0 or battle.rng(1,255)-1>=threshold then
+ user.protectRun=0
+ return false
+ end
+ user.protectRun=count+1;user[field]=true
+ return true
+end
+return Protection
diff --git a/src/battle/Gen2Sleep.lua b/src/battle/Gen2Sleep.lua
new file mode 100644
index 000000000..4aa4b33cd
--- /dev/null
+++ b/src/battle/Gen2Sleep.lua
@@ -0,0 +1,58 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- GSC sleep uses the live native battler/party state and the battle's RNG.
+-- No parallel turn clock or presentation queue is maintained here.
+local Strings=require('src.core.Strings')
+local M={}
+local function moveId(move)
+ if type(move)=='table'then return move.id end
+ return move
+end
+
+-- CantMove also runs on waking. It cancels the locked sequences, including
+-- the selected Fly/Dig's hidden flags, before the same turn continues.
+function M.cancel(user,move)
+ local id=moveId(move)or user.charging and user.charging.id
+ user.bideTurns,user.bideDamage=nil,nil
+ user.thrashTurns,user.thrashMove,user.thrashAnnounced=nil,nil,nil
+ user.charging,user.chargeReady=nil,nil
+ user.rampRun,user.rampMove=nil,nil
+ if id=='FLY'or id=='DIG'then
+ user.invulnerable,user.hiddenAs=nil,nil
+ end
+end
+
+function M.beforeMove(user,_,_,move)
+ local turns=(tonumber(user.sleepTurns)or tonumber(user.mon.sleepTurns)or 1)-1
+ if turns<=0 then
+ user.mon.status,user.sleepTurns,user.mon.sleepTurns=nil,nil,nil
+ user.nightmare,user.expNightmare=nil,nil
+ M.cancel(user,move)
+ return true,{Strings('%s\nwoke up!',user.name)}
+ end
+ user.sleepTurns,user.mon.sleepTurns=turns,turns
+ local id=moveId(move)
+ local allowed=id=='SNORE'or id=='SLEEP_TALK'
+ if not allowed then M.cancel(user,move)end
+ return allowed,{Strings('%s\nis fast asleep!',user.name)},nil,nil,'sleep'
+end
+
+function M.inflict(battle,user,low,high)
+ local rules=battle.ruleset or {}
+ local turns
+ if rules.sleepTurnsMin~=nil or rules.sleepTurnsMax~=nil then
+ turns=battle.rng(low,high)
+ else
+ -- SleepTarget masks a byte, rejects 0/7, then increments. The Tower
+ -- uses the two-bit mask; custom explicit rules retain their own range.
+ local mask=battle.inBattleTowerBattle and 4 or 8
+ local value
+ repeat value=battle.rng(0,255)%mask until value~=0 and value~=7
+ turns=value+1
+ end
+ user.sleepTurns,user.mon.sleepTurns=turns,turns
+end
+
+function M.rest(user)
+ user.sleepTurns,user.mon.sleepTurns=3,3
+end
+return M
diff --git a/src/battle/Gen2TrainerClass.lua b/src/battle/Gen2TrainerClass.lua
new file mode 100644
index 000000000..3a4044175
--- /dev/null
+++ b/src/battle/Gen2TrainerClass.lua
@@ -0,0 +1,20 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- Canonical vanilla GSC class identities in TrainerGroups order. Imported
+-- records carry this numeric index; their translated/display name is not an
+-- identity (all badge leaders can be named LEADER).
+local Class = {}
+local names = {
+ 'FALKNER','WHITNEY','BUGSY','MORTY','PRYCE','JASMINE','CHUCK','CLAIR',
+ 'RIVAL1','POKEMON_PROF','WILL','CAL','BRUNO','KAREN','KOGA','CHAMPION',
+ 'BROCK','MISTY','LT_SURGE','SCIENTIST','ERIKA','YOUNGSTER','SCHOOLBOY',
+ 'BIRD_KEEPER','LASS','JANINE','COOLTRAINERM','COOLTRAINERF','BEAUTY','POKEMANIAC',
+ 'GRUNTM','GENTLEMAN','SKIER','TEACHER','SABRINA','BUG_CATCHER','FISHER','SWIMMERM',
+ 'SWIMMERF','SAILOR','SUPER_NERD','RIVAL2','GUITARIST','HIKER','BIKER','BLAINE',
+ 'BURGLAR','FIREBREATHER','JUGGLER','BLACKBELT_T','EXECUTIVEM','PSYCHIC_T',
+ 'PICNICKER','CAMPER','EXECUTIVEF','SAGE','MEDIUM','BOARDER','POKEFANM',
+ 'KIMONO_GIRL','TWINS','POKEFANF','RED','BLUE','OFFICER','GRUNTF','MYSTICALMAN',
+}
+function Class.fromIndex(index)
+ return names[tonumber(index)]
+end
+return Class
diff --git a/src/battle/Gen2VanishPresentation.lua b/src/battle/Gen2VanishPresentation.lua
new file mode 100644
index 000000000..11678c9ef
--- /dev/null
+++ b/src/battle/Gen2VanishPresentation.lua
@@ -0,0 +1,101 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- Fly/Dig's cleared picture box follows the native animation queue. Combat
+-- owns invulnerable/hiddenAs; this latch owns only when that picture is seen.
+local Version=require('src.core.GameVersion')
+local M={}
+local function side(b)return b.isPlayer and 'player' or 'enemy'end
+
+local function vanished(b)
+ return b and b.invulnerable and (b.hiddenAs=='air' or b.hiddenAs=='underground') or false
+end
+local function current(screen,b,p)
+ return screen[side(b)]==b and b.mon==p.mon
+ and not b.fainted and (b.mon.hp or 0)>0
+end
+local function queued(screen,row)
+ for _,item in ipairs(screen.queue or {})do if item==row then return true end end
+ return false
+end
+local function states(screen)
+ screen._gen2VanishPictures=screen._gen2VanishPictures or {}
+ return screen._gen2VanishPictures
+end
+
+function M.charge(screen,b,row)
+ if not (Version.isGen2() and vanished(b))then return end
+ states(screen)[side(b)]={battler=b,mon=b.mon,row=row,phase=row and 'charge' or 'hidden',hidden=not row}
+end
+
+-- Native resolution clears the charge flags before announcing the release.
+-- Keep an already-cleared box until that row's SHOW_MON, not until its text.
+function M.releasing(screen,b)
+ if not Version.isGen2()then return end
+ local all=screen._gen2VanishPictures
+ local p=all and all[side(b)]
+ if not p then return end
+ if not (p.battler==b and current(screen,b,p) and p.hidden)then all[side(b)]=nil;return end
+ p.phase,p.row,p.runner='release',nil,nil
+end
+function M.releaseRow(screen,b,row)
+ local p=screen._gen2VanishPictures and screen._gen2VanishPictures[side(b)]
+ if p and p.battler==b and p.phase=='release'then p.row=row end
+end
+
+function M.admitted(screen,row,runner)
+ for key,p in pairs(screen._gen2VanishPictures or {})do
+ local b=p.battler
+ if p.row==row then
+ p.row,p.runner=nil,runner
+ if not runner then
+ if p.phase=='charge' and vanished(b)then p.phase,p.hidden='hidden',true
+ else screen._gen2VanishPictures[key]=nil end
+ end
+ end
+ end
+end
+
+-- One observation after the native VM tick; reads and draws never call this.
+function M.sync(screen)
+ local all=screen._gen2VanishPictures
+ for key,p in pairs(all or {})do
+ local b=p.battler
+ local remove=not current(screen,b,p)
+ if p.phase=='hidden'then remove=remove or not vanished(b)
+ elseif p.runner then
+ local r=p.runner
+ local ended=r~=screen.animPlayer.runtime or r.stopped
+ if p.phase=='charge'then
+ if ended then
+ if vanished(b)then p.phase,p.hidden,p.runner='hidden',true,nil
+ else remove=true end
+ end
+ elseif ended or r.bg.picSize[key]~=nil then remove=true end
+ elseif not (p.row and queued(screen,p.row))then remove=true end
+ if remove then all[key]=nil end
+ end
+end
+
+function M.hidden(screen,b)
+ local p=b and screen._gen2VanishPictures and screen._gen2VanishPictures[side(b)]
+ return p and p.battler==b and current(screen,b,p) and p.hidden
+ and (p.phase=='release' or vanished(b)) or false
+end
+function M.install(Screen)
+ function Screen:isUnderground(side,mon)
+ local b=(side=='player' or side=='enemy')and self[side]
+ if not b or mon and mon~=b and mon~=b.mon then return false end
+ return vanished(b) and M.hidden(self,b) or false
+ end
+ local hidden=Screen.fxHidden
+ function Screen:fxHidden(b)
+ return Version.isGen2() and M.hidden(self,b) or hidden(self,b)
+ end
+ local read=Screen.animPicState
+ function Screen:animPicState(side)
+ local state=read and read(self,side)
+ local b=(side=='player' or side=='enemy')and self[side]
+ if Version.isGen2() and M.hidden(self,b)then state=state or {};state.hidden=true end
+ return state
+ end
+end
+return M
diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua
index 1704c46d1..384f1af58 100644
--- a/src/battle/Gen3MoveEffects.lua
+++ b/src/battle/Gen3MoveEffects.lua
@@ -256,7 +256,7 @@ P.HEAL_BELL_EFFECT = function(battle, user)
user.mon.status = nil
user.toxicCounter = nil
local party = user.isPlayer and battle.game and battle.game.save
- and battle.game.save.party
+ and (battle.playerParty or battle.game.save.party)
for _, mon in ipairs(party or {}) do mon.status = nil end
return { Strings("A bell chimed!") }
end
@@ -284,6 +284,10 @@ end
-- else, so a spammed PROTECT fails the way the cartridge makes it fail.
local function protection(field, text)
return function(battle, user)
+ if require("src.core.GameVersion").isGen2() then
+ if not require("src.battle.Gen2Protection").apply(battle, user, field) then return { Strings("But, it failed!") } end
+ return { Strings(text, displayName(user)) }
+ end
local run = user.protectRun or 0
-- 1/1, then 1/2, 1/4, 1/8... A run of four is already a coin flip
-- three times over, which is what stops it being a stall lock.
@@ -1024,7 +1028,7 @@ F.FUTURE_SIGHT_EFFECT = {
F.BEAT_UP_EFFECT = {
hitCount = function(ctx)
local party = ctx.user.isPlayer and ctx.battle.game
- and ctx.battle.game.save and ctx.battle.game.save.party
+ and ctx.battle.game.save and (ctx.battle.playerParty or ctx.battle.game.save.party)
if not party then return 1 end
local n = 0
for _, mon in ipairs(party) do
diff --git a/src/battle/Gen5MoveEffects.lua b/src/battle/Gen5MoveEffects.lua
index 64682dade..46abc5276 100644
--- a/src/battle/Gen5MoveEffects.lua
+++ b/src/battle/Gen5MoveEffects.lua
@@ -1545,7 +1545,7 @@ end })
DEF.BW_BEAT_UP = F({
hitCount = function(ctx)
local party = ctx.user.isPlayer and ctx.battle.game and ctx.battle.game.save
- and ctx.battle.game.save.party or ctx.battle.enemyParty
+ and (ctx.battle.playerParty or ctx.battle.game.save.party) or ctx.battle.enemyParty
local n = 0
for _, mon in ipairs(party or {}) do
if (mon.hp or 0) > 0 and not mon.status then n = n + 1 end
@@ -1708,7 +1708,7 @@ end })
local function benchOf(battle, b)
local out = {}
if b.isPlayer then
- local party = battle.game and battle.game.save and battle.game.save.party or {}
+ local party = battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or {}
for _, mon in ipairs(party) do
if mon ~= b.mon and (mon.hp or 0) > 0 and not mon.isEgg and not mon.egg then
local onField = false
@@ -2105,7 +2105,7 @@ DEF.BW_REFRESH = P({ run = enginePrimary("REFRESH_EFFECT") })
DEF.BW_HEAL_BELL = P({ run = function(ctx)
local battle, user = ctx.battle, ctx.user
user.mon.status, user.toxicCounter = nil, nil
- local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party
+ local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party)
or battle.enemyParty
for _, mon in ipairs(party or {}) do mon.status = nil end
return { T.std(battle, ctx.move.id == 312 and "aroma" or "bell") }
@@ -2490,7 +2490,7 @@ DEF.BW_METRONOME = F({ callsMove = function(ctx)
end })
DEF.BW_ASSIST = F({ callsMove = function(ctx)
local battle, u = ctx.battle, ctx.user
- local party = u.isPlayer and battle.game and battle.game.save and battle.game.save.party or battle.enemyParty
+ local party = u.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty
local pool = {}
for _, mon in ipairs(party or {}) do
if mon ~= u.mon then
diff --git a/src/battle/Gen5MoveRules.lua b/src/battle/Gen5MoveRules.lua
index b6faa003d..c8afd08cf 100644
--- a/src/battle/Gen5MoveRules.lua
+++ b/src/battle/Gen5MoveRules.lua
@@ -609,7 +609,7 @@ end
local function beatUp(G5)
return F({ perform = function(ctx, rec)
local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move
- local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party
+ local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party)
or battle.enemyParty or {}
local members = {}
for _, mon in ipairs(party) do
diff --git a/src/battle/HeldItems.lua b/src/battle/HeldItems.lua
index 195f4ad0b..fd0767743 100644
--- a/src/battle/HeldItems.lua
+++ b/src/battle/HeldItems.lua
@@ -338,6 +338,7 @@ local function curePersistent(holder, wanted)
if not status then return false end
if wanted and norm(status) ~= wanted then return false end
mon.status = nil
+ if require('src.core.GameVersion').isGen2()then mon.sleepTurns=nil end
holder.sleepTurns, holder.toxicCounter = nil, nil
return true
end
diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua
index 5772141ae..5f98b5f08 100644
--- a/src/battle/MoveEffects.lua
+++ b/src/battle/MoveEffects.lua
@@ -15,6 +15,7 @@ local Logger = require("src.core.Logger")
local StatusRegistry = require("src.battle.StatusRegistry")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
+local Gen2Read = require('src.battle.Gen2BattleRead')
local Strings = require("src.core.Strings")
local HeldItems = require("src.battle.HeldItems")
local Weather = require("src.battle.Weather")
@@ -155,7 +156,7 @@ end
-- SHIELD DUST still refuses a doubled roll.
local function sideChance(move, fallback, user, target)
local own = move and tonumber(move.effectChance)
- local base = (own and own > 0) and own or fallback
+ local base = (own and (own > 0 or move._api3EffectChance)) and own or fallback
if user or target then
base = require("src.battle.Abilities").secondaryOdds(user, target, base)
end
@@ -274,7 +275,7 @@ MoveEffects.primary = {
if target.leechSeeded then
return { Strings("But, it failed!") }
end
- for _, t in ipairs(target.curTypes) do
+ for _, t in ipairs(Gen2Read.types(target)) do
if t == "GRASS" then return { Strings("But, it failed!") } end
end
target.leechSeeded = true
@@ -288,6 +289,7 @@ MoveEffects.primary = {
mon.hp = mon.stats.hp
mon.status = "SLP"
user.sleepTurns = 2
+ if GameVersion.isGen2()then require('src.battle.Gen2Sleep').rest(user)end
user.toxicCounter = nil
return { Strings("%s\nstarted sleeping!", displayName(user)) }
end
@@ -360,6 +362,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!") }
@@ -370,7 +373,8 @@ MoveEffects.primary = {
if target.invulnerable then
return { Strings("But, it failed!") }
end
- user.curTypes = { target.curTypes[1], target.curTypes[2] }
+ local types = Gen2Read.types(target)
+ user.curTypes = { types[1], types[2] }
-- _ConvertedTypeText
return { Strings("Converted type to\n%s's!", displayName(target)) }
end,
@@ -386,7 +390,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
@@ -394,13 +400,15 @@ MoveEffects.primary = {
local okT, T = pcall(require, "src.battle.Gen5Transform")
if okT then T.apply(battle, user, target) end
end
+ local stats = Gen2Read.stats(target)
user.curStats = {
hp = user.mon.stats.hp, -- HP is kept
- attack = target.curStats.attack, defense = target.curStats.defense,
- speed = target.curStats.speed, special = target.curStats.special,
- spatk = target.curStats.spatk, spdef = target.curStats.spdef,
+ attack = stats.attack, defense = stats.defense,
+ speed = stats.speed, special = stats.special,
+ spatk = stats.spatk, spdef = stats.spdef,
}
- user.curTypes = { target.curTypes[1], target.curTypes[2] }
+ local types = Gen2Read.types(target)
+ user.curTypes = { types[1], types[2] }
-- transform.asm:130-132 copies the target's stat MODS into the user
-- (wEnemyMonStatMods -> wPlayerMonStatMods), it does NOT clear them;
-- deep copy so later stage changes on either mon stay independent
@@ -566,7 +574,7 @@ end
-- effectiveness step. So in Gen 1 NIGHT_SHADE hits Normal-types and
-- SUPER_FANG hits Ghosts, and only OHKO_EFFECT calls this (#616).
local function immuneMsg(ctx)
- if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
+ if TypeChart.effectiveness(ctx.move.type, Gen2Read.types(ctx.target)) == 0 then
return Strings("It doesn't affect\n%s!", displayName(ctx.target))
end
return nil
diff --git a/src/battle/PlayerPartyScope.lua b/src/battle/PlayerPartyScope.lua
new file mode 100644
index 000000000..6706a2b4b
--- /dev/null
+++ b/src/battle/PlayerPartyScope.lua
@@ -0,0 +1,36 @@
+-- Battle-local ordered references; never reorder or replace save.party.
+local Logger = require("src.core.Logger")
+local M = {}
+function M.select(game, indices)
+ if indices == nil then
+ return nil, nil
+ end
+ local function fallback()
+ Logger.warn("trainer battle party scope is invalid; using full party")
+ return nil, nil
+ end
+ if type(indices) ~= "table" or #indices == 0 then
+ return fallback()
+ end
+ local count = #indices
+ local keys = 0
+ for key in pairs(indices) do
+ keys = keys + 1
+ if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then
+ return fallback()
+ end
+ end
+ if keys ~= count then
+ return fallback()
+ end
+ local party, normalized, seen = {}, {}, {}
+ for i = 1, count do
+ local index = indices[i]
+ if type(index) ~= "number" or index % 1 ~= 0 or not game.save.party[index] or seen[index] then
+ return fallback()
+ end
+ seen[index], normalized[i], party[i] = true, index, game.save.party[index]
+ end
+ return party, normalized
+end
+return M
diff --git a/src/battle/Status.lua b/src/battle/Status.lua
index 955361db0..e4763a4fb 100644
--- a/src/battle/Status.lua
+++ b/src/battle/Status.lua
@@ -5,6 +5,7 @@
-- and residual sweep. Callers without a battle (pure-module tests) fall
-- back to the vanilla records, which is bit-identical behavior.
+local Gen2Read = require('src.battle.Gen2BattleRead')
local Strings = require("src.core.Strings")
local HeldItems = require("src.battle.HeldItems")
@@ -24,7 +25,7 @@ end
local VOLATILE_PRIORITY = 20
local function hasType(battler, wanted)
- for _, t in ipairs(battler.curTypes or {}) do
+ for _, t in ipairs(Gen2Read.types(battler) or {}) do
if t == wanted then return true end
end
return false
@@ -132,17 +133,22 @@ Status.RECORDS = {
id = "SLP", label = "SLP", hudLabel = "SLP",
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 40,
- beforeMove = function(battler)
+ beforeMove = function(battler,rng,battle,move)
+ if require('src.core.GameVersion').isGen2()then
+ return require('src.battle.Gen2Sleep').beforeMove(battler,rng,battle,move)
+ end
battler.sleepTurns = (battler.sleepTurns or 1) - 1
if battler.sleepTurns <= 0 then
battler.mon.status = nil
return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn
end
- return false, { Strings("%s\nis fast asleep!", name(battler)) }
+ return false, { Strings("%s\nis fast asleep!", name(battler)) }, nil, nil, "sleep"
end,
onInflict = function(battle, target, opts, display)
- target.sleepTurns = battle.rng(rule(battle, "sleepTurnsMin"),
- rule(battle, "sleepTurnsMax"))
+ local low,high=rule(battle,'sleepTurnsMin'),rule(battle,'sleepTurnsMax')
+ if require('src.core.GameVersion').isGen2()then
+ require('src.battle.Gen2Sleep').inflict(battle,target,low,high)
+ else target.sleepTurns=battle.rng(low,high)end
return { Strings("%s\nfell asleep!", display) }
end,
},
@@ -239,11 +245,13 @@ local function battleStatuses(battle)
return battle and battle.data and battle.data.statuses
end
--- Returns canMove, messages, selfHit (true -> hurt itself in confusion).
+-- Existing decision/messages/selfHit keep their native owners. Optional
+-- registered reason/kind is retained in slot four; slot five describes the
+-- actual presentation kind, message index and blocking status, never text.
-- The active status record's beforeMove runs at its priority slot: above
-- VOLATILE_PRIORITY before the held/disable/confusion block (sleep,
-- freeze), at or below after it (paralysis) -- the original's order.
-function Status.beforeMove(battler, rng, battle)
+function Status.beforeMove(battler, rng, battle, move)
local mon = battler.mon
-- Haze curing this mon's sleep/freeze forfeits its pending move for
-- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected
@@ -252,7 +260,8 @@ function Status.beforeMove(battler, rng, battle)
battler.skipMove = nil
return false, {}
end
- if battler.flinched then
+ local gsc=require('src.core.GameVersion').isGen2()
+ if battler.flinched and not gsc then
battler.flinched = false
return false, { Strings("%s\nflinched!", name(battler)) }
end
@@ -260,20 +269,37 @@ function Status.beforeMove(battler, rng, battle)
local handler = record and record.beforeMove
local priority = handler and (record.beforeMovePriority or 0)
local msgs = {}
+ local presentation
+ local function describe(kind,index,blocked)
+ if not (kind or blocked)then return end
+ presentation=presentation or {}
+ if kind then presentation.kind,presentation.index=kind,index end
+ if blocked then presentation.blockedStatus=blocked end
+ end
local function runStatus()
- local canMove, statusMsgs, selfHit = handler(battler, rng, battle)
- for _, m in ipairs(statusMsgs or {}) do msgs[#msgs + 1] = m end
- return canMove, selfHit
+ local statusId=mon.status
+ local canMove,statusMsgs,selfHit,reason,kind=handler(battler,rng,battle,move)
+ for _,m in ipairs(statusMsgs or {})do msgs[#msgs+1]=m end
+ if kind==nil and (reason=='sleep' or reason=='confused')then kind=reason end
+ if statusMsgs and #statusMsgs>0 and (kind=='sleep' or kind=='confused')then describe(kind,#msgs)end
+ if not canMove then describe(nil,nil,record.id or statusId)end
+ return canMove,selfHit,reason
end
if handler and priority > VOLATILE_PRIORITY then
- local canMove, selfHit = runStatus()
- if not canMove or selfHit then return canMove, msgs, selfHit end
+ local canMove,selfHit,reason=runStatus()
+ if not canMove or selfHit then return canMove,msgs,selfHit,reason,presentation end
handler = nil
end
+ if gsc and battler.flinched then
+ battler.flinched=false
+ msgs[#msgs+1]=Strings('%s\nflinched!',name(battler))
+ require('src.battle.Gen2Sleep').cancel(battler,move)
+ return false,msgs,false,nil,presentation
+ end
if battler.boundTurns and battler.boundTurns > 0 then
battler.boundTurns = battler.boundTurns - 1
msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler))
- return false, msgs
+ return false,msgs,nil,nil,presentation
end
if battler.disabledTurns then
battler.disabledTurns = battler.disabledTurns - 1
@@ -291,17 +317,18 @@ function Status.beforeMove(battler, rng, battle)
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
else
table.insert(msgs, Strings("%s\nis confused!", name(battler)))
+ describe('confused',#msgs)
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
if rng(0, 255) < 128 then
- return false, msgs, true -- hurt itself
+ return false,msgs,true,nil,presentation -- hurt itself
end
end
end
if handler then
- local canMove, selfHit = runStatus()
- if not canMove or selfHit then return canMove, msgs, selfHit end
+ local canMove,selfHit,reason=runStatus()
+ if not canMove or selfHit then return canMove,msgs,selfHit,reason,presentation end
end
- return true, msgs
+ return true,msgs,nil,nil,presentation
end
-- End-of-turn residual damage; opponent is needed for Leech Seed.
diff --git a/src/battle/StatusRegistry.lua b/src/battle/StatusRegistry.lua
index d65321c78..b0b44f619 100644
--- a/src/battle/StatusRegistry.lua
+++ b/src/battle/StatusRegistry.lua
@@ -3,6 +3,7 @@
-- (canInflict) and so does the landing text (onInflict), so a mod status
-- inflicts through the same path as the vanilla five.
+local Gen2Read = require('src.battle.Gen2BattleRead')
local Runtime = require("src.mods.Runtime")
local Status = require("src.battle.Status")
local Strings = require("src.core.Strings")
@@ -32,7 +33,7 @@ function StatusRegistry.inflict(battle, target, status, opts)
-- move's type matches either of the target's types (Body Slam can't
-- paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice)
if opts.secondary and status ~= "PSN" then
- for _, t in ipairs(target.curTypes or {}) do
+ for _, t in ipairs(Gen2Read.types(target) or {}) do
if opts.moveType == t then return {} end
end
end
diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua
index bb481a7ad..1cd1a6cf2 100644
--- a/src/battle/TurnOrder.lua
+++ b/src/battle/TurnOrder.lua
@@ -3,6 +3,7 @@
-- record's priority field; the id table below covers pre-existing
-- imported caches (Gen 1 has only QUICK_ATTACK first and COUNTER last).
+local Gen2Read = require('src.battle.Gen2BattleRead')
local Damage = require("src.battle.Damage")
local Stats = require("src.pokemon.Stats")
local Status = require("src.battle.Status")
@@ -17,7 +18,7 @@ local Abilities = require("src.battle.Abilities")
-- nowhere else the weather is visible from here. Every existing caller
-- passes nothing and gets exactly the old answer.
local function effectiveSpeed(battler, battle)
- local spd = Stats.applyStage(battler.curStats.speed,
+ local spd = Stats.applyStage(Gen2Read.stats(battler).speed,
battler.stages and battler.stages.speed or 0)
-- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from
-- the battler's merged badgeBoosts with the vanilla list as fallback
@@ -65,6 +66,9 @@ end
local PRIORITY = { QUICK_ATTACK = 1, COUNTER = -1 }
local function priority(move)
+ if require('src.core.GameVersion').isGen2() then
+ return require('src.battle.Gen2MovePriority').get(move)
+ end
if not move then return 0 end
if move.priority then return move.priority end
return PRIORITY[move.id] or 0
diff --git a/src/battle/Weather.lua b/src/battle/Weather.lua
index e3a3fc95e..fc9a18bef 100644
--- a/src/battle/Weather.lua
+++ b/src/battle/Weather.lua
@@ -14,6 +14,7 @@
-- Weather.start and every other entry point is a nil check on
-- battle.field.weather.
+local Gen2Read = require('src.battle.Gen2BattleRead')
local Strings = require("src.core.Strings")
local Weather = {}
@@ -296,7 +297,7 @@ local function weatherDamage(battle, battler, weather)
return
end
local immune = WEATHER_IMMUNE[weather] or {}
- for _, t in ipairs(battler.curTypes or {}) do
+ for _, t in ipairs(Gen2Read.types(battler) or {}) do
if immune[t] then return end
end
battle:animNext(weather == Weather.HAIL and "inHail" or "inSandstorm",
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/Battle.lua b/src/battle/gen2/Battle.lua
new file mode 100644
index 000000000..a4a0252d7
--- /dev/null
+++ b/src/battle/gen2/Battle.lua
@@ -0,0 +1,146 @@
+-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+-- The live Gen2 model boundary of the shared native controller. This is not
+-- an alias to the screen and does not construct or advance a second battle.
+local Runtime = require('src.mods.Runtime')
+local Model = {}
+-- IsGymLeader's cartridge list includes the Elite Four, Champion and Red,
+-- then falls through into the eight Kanto leaders. Keep this public source
+-- predicate distinct from the native badge/Pikachu companion marker.
+Model.KANTO_GYM_LEADER_CLASSES = {
+ BROCK=true, MISTY=true, LT_SURGE=true, ERIKA=true,
+ JANINE=true, SABRINA=true, BLAINE=true, BLUE=true,
+}
+Model.GYM_LEADER_CLASSES = {
+ FALKNER=true, WHITNEY=true, BUGSY=true, MORTY=true,
+ PRYCE=true, JASMINE=true, CHUCK=true, CLAIR=true,
+ WILL=true, BRUNO=true, KAREN=true, KOGA=true, CHAMPION=true, RED=true,
+}
+for class in pairs(Model.KANTO_GYM_LEADER_CLASSES) do
+ Model.GYM_LEADER_CLASSES[class] = true
+end
+function Model.isGymLeader(class)
+ return class ~= nil and Model.GYM_LEADER_CLASSES[class] == true
+end
+function Model.isKantoGymLeader(class)
+ return class ~= nil and Model.KANTO_GYM_LEADER_CLASSES[class] == true
+end
+local screens = setmetatable({}, { __mode = 'kv' })
+local ended = setmetatable({}, { __mode = 'k' })
+
+local function owner(model)
+ return assert(screens[model], 'native Gen2 model is not attached to a battle')
+end
+
+function Model.bind(model, screen)
+ screens[model] = screen
+ rawset(model, '_nativeRandom', function(n) return owner(model).rng(n) - 1 end)
+ -- Source GSC's LOVE-style view uses the current native function even if
+ -- link setup or a scoped native owner replaces it after this bind.
+ rawset(model, '_nativeLoveRng', function(lo, hi)
+ local rng = owner(model).rng
+ if hi == nil then return rng(math.max(1, lo or 1)) end
+ if hi < lo then lo, hi = hi, lo end
+ return rng(lo, hi)
+ end)
+ return model
+end
+
+function Model:roller()
+ owner(self)
+ local roller = rawget(self, '_nativeRoller')
+ if not roller then
+ -- Zero-based BattleRandom over the same existing stream. Keep the cache
+ -- on the transient model; a global value closure could root a weak key.
+ local random = rawget(self, '_nativeRandom')
+ roller = function(n) return random(n) end
+ rawset(self, '_nativeRoller', roller)
+ end
+ return roller
+end
+
+local function active(screen, mon)
+ if screen.player == mon or screen.enemy == mon then return mon end
+ if screen.player and screen.player.mon == mon then return screen.player end
+ if screen.enemy and screen.enemy.mon == mon then return screen.enemy end
+end
+
+local volatileNames={substitute='substituteHP',protect='protecting',mist='mist',
+ confusion='confused',confusionTurns='confusionTurns',flinch='flinched',
+ -- Canonical source GSC fields must drive the actual shared controller.
+ confuseCount='confusedTurns',flinched='flinched',endure='enduring',
+ recharge='mustRecharge',protectCount='protectRun',
+ focusEnergy='focusEnergy',xAccuracy='xAccuracy'}
+function Model:volatile(mon)
+ local screen=owner(self)
+ local b=assert(active(screen,mon),'volatile state requires an active battler')
+ -- A global weak-key cache still roots its battlers on LuaJIT when the
+ -- value's closures retain their key. Keep views on the transient model,
+ -- never on a serialized battler or party mon, and retire withdrawn owners.
+ local views=rawget(self,'_nativeVolatileViews')
+ if not views then views={};rawset(self,'_nativeVolatileViews',views)end
+ for previous in pairs(views)do
+ if previous~=screen.player and previous~=screen.enemy then views[previous]=nil end
+ end
+ if not views[b] then
+ local extensions={}
+ views[b]=setmetatable({}, {
+ __index=function(_,key)local field=volatileNames[key];if field then return b[field] end;return extensions[key]end,
+ __newindex=function(_,key,value)local field=volatileNames[key];if field then b[field]=value else extensions[key]=value end end,
+ })
+ end
+ return views[b]
+end
+
+
+function Model:moveDef(move)
+ -- Older native screens take a move instance; scalar catalog queries belong
+ -- to this model and must not pass an id through that older signature.
+ if type(move) ~= 'table' then return self.data.moves[move] end
+ return owner(self):moveDef(move)
+end
+
+function Model:movePriority(id)
+ return require('src.battle.Gen2MovePriority').get(self:moveDef(id))
+end
+
+function Model:speciesDef(mon)
+ return mon and self.data.pokemon and self.data.pokemon[mon.species] or nil
+end
+
+-- Raw native battle statistics, before stages, badges, status or held items.
+-- Those modifiers remain in the actual damage/order owners and run once.
+-- A caller's temporary shadow mon uses its own stats without changing a party
+-- member. Active Transform statistics remain on the existing native wrapper.
+function Model:battleStat(mon, key)
+ local screen = screens[self]
+ local battler = screen and active(screen, mon)
+ local stats = battler and battler.curStats or mon and mon.stats
+ return stats and stats[key]
+end
+
+function Model:sideOf(mon)
+ local screen = owner(self)
+ local battler = active(screen, mon)
+ if battler then return battler.isPlayer and 'player' or 'enemy' end
+end
+
+-- Finish notification is owned by the native result path. Presentation stays
+-- on the native queue; completeBattle/finish perform its actual screen return.
+-- Existing native event consumers retain the screen in `battle`; the distinct
+-- live model is also provided explicitly. No second save/party snapshot exists.
+function Model:endBattle(outcome, details)
+ local screen = owner(self)
+ -- Preserve link/spectator and extension-owned native string outcomes.
+ assert(type(outcome) == 'string' and outcome ~= '', 'invalid native Gen2 battle outcome')
+ if ended[self] then return false end
+ if not (details and details.nativeNotification) then screen.result = outcome end
+ screen:restoreMimicked(screen.player)
+ screen:restoreMimicked(screen.enemy)
+ ended[self] = true
+ local event = { battle=screen, model=self, result=outcome }
+ if details and details.skipped then event.skipped = true end
+ Runtime.emit('battle.ended', event)
+ return true
+end
+
+return Model
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/compat/gen2/Mon.lua b/src/compat/gen2/Mon.lua
new file mode 100644
index 000000000..4cd35118d
--- /dev/null
+++ b/src/compat/gen2/Mon.lua
@@ -0,0 +1,14 @@
+-- Copyright (c) 2026 Roxas2712. See the offered contribution licence:
+-- https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+-- Source stat/signature methods over the actual native Pokemon owner.
+-- The inherited older RNG constructor keeps its existing signature;
+-- source options-record construction remains a separate capability.
+local Native=require('src.pokemon.Pokemon')
+local Mon={}
+function Mon.hpDV(dvs)return require('src.pokemon.Stats').hpDV(dvs)end
+function Mon.stats(base,dvs,level,statExp)
+ return require('src.pokemon.Stats').calcGen2(base,dvs,level,statExp)
+end
+function Mon.syncIdentity(mon,data)return Native.syncIdentity(data,mon)end
+function Mon.refreshStats(mon,data)return Native.refreshStats(data,mon)end
+return setmetatable(Mon,{__index=Native})
diff --git a/src/compat/gen2/Unown.lua b/src/compat/gen2/Unown.lua
new file mode 100644
index 000000000..c69e3e0af
--- /dev/null
+++ b/src/compat/gen2/Unown.lua
@@ -0,0 +1,19 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- Reference spellings over the native imported records and native save.
+local Native = require("src.pokemon.Gen2Unown")
+local M = {NUM_UNOWN=Native.NUM_UNOWN, SPECIES="UNOWN", ALPHABET=Native.ALPHABET,
+ name=Native.name, index=Native.index, letterFromDVs=Native.letterFromDVs,
+ dvsForLetter=Native.dvsForLetter, monLetter=Native.monLetter,
+ word=Native.word, formSprite=Native.formSprite,
+ dex=Native.letters, caught=Native.caught, count=Native.count,
+ updateDex=Native.updateDex, registerCatch=Native.registerCatch}
+-- Reference consumers index forms by A..Z; native storage indexes by number.
+-- The public name map is derived on demand; picture records remain native.
+function M.forms(pokemon)
+ local source = Native.forms(pokemon)
+ if not source then return nil end
+ local out = {}
+ for i = 1, 26 do if source[i] then out[Native.name(i)] = source[i] end end
+ return out
+end
+return M
diff --git a/src/core/Data.lua b/src/core/Data.lua
index 02984c906..3082a0ca4 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
@@ -1893,6 +1897,9 @@ function Data:seedDefaults()
end
seedMissingGen2Tilesets(self)
self:publishGen2Palettes()
+ require("src.core.Gen2SpeciesNames").canonicalize(self)
+ require("src.core.Gen2SpeciesNames").publish(self)
+ require('src.pokemon.Gen2MonFields').publish(self)
ensureGen2WarpFallbacks(self)
ensureGen2HomeTextFallbacks(self)
end
diff --git a/src/core/DeltaSkin.lua b/src/core/DeltaSkin.lua
new file mode 100644
index 000000000..ac5b62e6a
--- /dev/null
+++ b/src/core/DeltaSkin.lua
@@ -0,0 +1,544 @@
+-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91.
+-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 with Section 7 terms;
+-- see licenses/GEN1RECOMP-TOUCH-SKINS.md. Native integration by Roxas2712.
+local Json = require("src.link.Json")
+local TouchSkin = require("src.core.TouchSkin")
+
+local DeltaSkin = {}
+
+DeltaSkin.INFO_NAME = "info.json"
+DeltaSkin.MAX_INFO_BYTES = 4 * 1024 * 1024
+
+DeltaSkin.GAME_TYPE_PREFIXES = {
+ "com.rileytestut.delta.game.",
+ "public.aoshuang.game.",
+}
+
+DeltaSkin.SYSTEMS = { gb = true, gbc = true, gba = true }
+
+DeltaSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
+
+DeltaSkin.DEVICE_ORDER = { "iphone", "ipad", "tv" }
+DeltaSkin.DISPLAY_ORDER = { "edgeToEdge", "standard", "splitView" }
+DeltaSkin.ORIENTATIONS = { "portrait", "landscape" }
+DeltaSkin.SIDES = { "up", "down", "left", "right" }
+
+DeltaSkin.ASSET_LADDER = { "small", "medium", "large" }
+DeltaSkin.ASSET_WIDTHS = { small = 640, medium = 750, large = 1080 }
+DeltaSkin.DEFAULT_TARGET_WIDTH = 1080
+
+DeltaSkin.INPUTS = {
+ a = "a", b = "b", start = "start", select = "select",
+ up = "up", down = "down", left = "left", right = "right",
+ l = "l", r = "r",
+ triggerl = "l", triggerr = "r",
+ shoulderl = "l", shoulderr = "r",
+ menu = "menu_toggle",
+ fastforward = "hold_fast_forward",
+ togglefastforward = "toggle_fast_forward",
+}
+
+DeltaSkin.OUTPUT_HOTKEYS = {
+ menu = "menu",
+ fast_forward_hold = "fastForward",
+ fast_forward_toggle = "toggleFastForward",
+}
+
+DeltaSkin.MAPPING = {
+ portrait = { width = 1080, height = 1920 },
+ landscape = { width = 1920, height = 1080 },
+}
+
+DeltaSkin.SCREEN_WIDTH = 160
+DeltaSkin.SCREEN_HEIGHT = 144
+
+local function pick(t, key)
+ if type(t) ~= "table" then return nil end
+ local direct = t[key]
+ if direct ~= nil then return direct end
+ local want = tostring(key):lower()
+ for k, v in pairs(t) do
+ if tostring(k):lower() == want then return v end
+ end
+ return nil
+end
+
+local function numOr(v, fallback)
+ local n = tonumber(v)
+ if not n or n ~= n then return fallback end
+ return n
+end
+
+local function round(n)
+ return math.floor(numOr(n, 0) + 0.5)
+end
+
+local function isArray(t)
+ return type(t) == "table" and t[1] ~= nil
+end
+
+local function addWarning(list, text)
+ if type(list) ~= "table" then return end
+ for _, existing in ipairs(list) do
+ if existing == text then return end
+ end
+ list[#list + 1] = text
+end
+
+function DeltaSkin.findInfo(root)
+ local direct = root .. "/" .. DeltaSkin.INFO_NAME
+ if TouchSkin.readFile(direct) then return direct, "" end
+ local items = TouchSkin.listDir(root)
+ for _, name in ipairs(items) do
+ if tostring(name):lower() == DeltaSkin.INFO_NAME then
+ return root .. "/" .. name, ""
+ end
+ end
+ table.sort(items)
+ for _, name in ipairs(items) do
+ local nested = root .. "/" .. name .. "/" .. DeltaSkin.INFO_NAME
+ if TouchSkin.readFile(nested) then return nested, name .. "/" end
+ end
+ return nil
+end
+
+function DeltaSkin.resolveName(name, opts)
+ name = tostring(name or ""):gsub("\\", "/"):gsub("^%./", "")
+ if name == "" then return nil end
+ local names = opts and opts.names
+ if type(names) == "table" then
+ local want = name:lower()
+ for _, entry in ipairs(names) do
+ if tostring(entry):lower() == want then
+ name = tostring(entry)
+ break
+ end
+ end
+ end
+ return ((opts and opts.prefix) or "") .. name
+end
+
+function DeltaSkin.pickAsset(assets, opts, pdfFiles)
+ if type(assets) ~= "table" then return nil end
+ pdfFiles = pdfFiles or {}
+ local raster = {}
+ local pdfName
+ for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
+ local name = pick(assets, key)
+ if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
+ if type(name) == "string" and name ~= "" then
+ if name:lower():match("%.pdf$") then
+ pdfName = name
+ pdfFiles[#pdfFiles + 1] = name
+ else
+ raster[#raster + 1] = { key = key, name = name }
+ end
+ end
+ end
+ local resizable = pick(assets, "resizable")
+ if type(resizable) == "string" and resizable ~= "" then
+ if resizable:lower():match("%.pdf$") then
+ pdfName = resizable
+ pdfFiles[#pdfFiles + 1] = resizable
+ else
+ raster[#raster + 1] = { key = "large", name = resizable }
+ end
+ end
+ local pdfPath = pdfName and DeltaSkin.resolveName(pdfName, opts) or nil
+ if #raster == 0 then return nil, pdfPath end
+
+ local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
+ local chosen
+ for _, cand in ipairs(raster) do
+ if not chosen and (DeltaSkin.ASSET_WIDTHS[cand.key] or 0) >= target then
+ chosen = cand.name
+ end
+ end
+ if not chosen then chosen = raster[#raster].name end
+ return DeltaSkin.resolveName(chosen, opts), nil
+end
+
+function DeltaSkin.mergeEdges(base, item)
+ local out = { top = 0, bottom = 0, left = 0, right = 0 }
+ for _, side in ipairs({ "top", "bottom", "left", "right" }) do
+ local v = pick(item, side)
+ if v == nil then v = pick(base, side) end
+ out[side] = numOr(v, 0)
+ end
+ return out
+end
+
+function DeltaSkin.representation(reps, orient)
+ for _, device in ipairs(DeltaSkin.DEVICE_ORDER) do
+ local dev = pick(reps, device)
+ if type(dev) == "table" then
+ for _, display in ipairs(DeltaSkin.DISPLAY_ORDER) do
+ local shown = pick(dev, display)
+ if type(shown) == "table" then
+ local obj = pick(shown, orient)
+ if type(obj) == "table" then return obj, device, display end
+ end
+ end
+ local flat = pick(dev, orient)
+ if type(flat) == "table" and (pick(flat, "items") or pick(flat, "mappingSize")) then
+ return flat, device, nil
+ end
+ end
+ end
+ return nil
+end
+
+function DeltaSkin.directionalInputs(inputs)
+ if type(inputs) ~= "table" or isArray(inputs) then return nil end
+ local out, found = {}, 0
+ for _, side in ipairs(DeltaSkin.SIDES) do
+ local v = pick(inputs, side)
+ if type(v) == "string" then
+ local lower = v:lower()
+ local mapped = DeltaSkin.INPUTS[lower]
+ if not mapped and lower:find(side, 1, true) then mapped = side end
+ if mapped then
+ out[side] = mapped
+ found = found + 1
+ end
+ end
+ end
+ if found >= 2 then return out end
+ return nil
+end
+
+function DeltaSkin.specFor(inputs)
+ local parts = {}
+ local function add(v)
+ if type(v) ~= "string" then return end
+ local mapped = DeltaSkin.INPUTS[v:lower()]
+ if mapped then parts[#parts + 1] = mapped end
+ end
+ if type(inputs) == "string" then
+ add(inputs)
+ elseif type(inputs) == "table" then
+ if isArray(inputs) then
+ for _, v in ipairs(inputs) do add(v) end
+ else
+ local keys = {}
+ for k in pairs(inputs) do keys[#keys + 1] = tostring(k) end
+ table.sort(keys)
+ for _, k in ipairs(keys) do add(inputs[k]) end
+ end
+ end
+ if #parts == 0 then return "nul" end
+ return table.concat(parts, "|")
+end
+
+function DeltaSkin.screenRect(obj, mapW, mapH)
+ local frame
+ local screens = pick(obj, "screens")
+ if type(screens) == "table" and type(screens[1]) == "table" then
+ frame = pick(screens[1], "outputFrame")
+ end
+ if type(frame) ~= "table" then frame = pick(obj, "gameScreenFrame") end
+ if type(frame) ~= "table" then return nil end
+ local w = numOr(pick(frame, "width"), 0)
+ local h = numOr(pick(frame, "height"), 0)
+ if w <= 0 or h <= 0 then return nil end
+ return {
+ x = numOr(pick(frame, "x"), 0) / mapW,
+ y = numOr(pick(frame, "y"), 0) / mapH,
+ w = w / mapW, h = h / mapH,
+ }
+end
+
+function DeltaSkin.addItem(page, item, baseEdges, mapW, mapH)
+ if type(item) ~= "table" then return end
+ local frame = pick(item, "frame")
+ if type(frame) ~= "table" then return end
+ local fw = numOr(pick(frame, "width"), 0)
+ local fh = numOr(pick(frame, "height"), 0)
+ if fw <= 0 or fh <= 0 then return end
+ local fx = numOr(pick(frame, "x"), 0)
+ local fy = numOr(pick(frame, "y"), 0)
+
+ local edges = DeltaSkin.mergeEdges(baseEdges, pick(item, "extendedEdges"))
+ local cx, cy = (fx + fw * 0.5) / mapW, (fy + fh * 0.5) / mapH
+ local w, h = fw / mapW, fh / mapH
+ local reachLeft = 1 + edges.left / (fw * 0.5)
+ local reachRight = 1 + edges.right / (fw * 0.5)
+ local reachUp = 1 + edges.top / (fh * 0.5)
+ local reachDown = 1 + edges.bottom / (fh * 0.5)
+
+ local inputs = pick(item, "inputs")
+ local dirs = DeltaSkin.directionalInputs(inputs)
+ if dirs then
+ local base = {
+ x = cx, y = cy, rangeX = w * 0.5, rangeY = h * 0.5,
+ rangeMod = 1, alphaMod = page.alphaMod, shape = "rect",
+ reachLeft = reachLeft, reachRight = reachRight,
+ reachUp = reachUp, reachDown = reachDown,
+ }
+ for _, ctl in ipairs(TouchSkin.expandDirectional(base, dirs)) do
+ page.controls[#page.controls + 1] = ctl
+ end
+ return
+ end
+
+ local shape = tostring(pick(item, "mask") or ""):lower() == "circle" and "radial" or "rect"
+ local ctl = TouchSkin.newControl(DeltaSkin.specFor(inputs), cx, cy, w, h, shape)
+ ctl.alphaMod = page.alphaMod
+ ctl.reachLeft, ctl.reachRight = reachLeft, reachRight
+ ctl.reachUp, ctl.reachDown = reachUp, reachDown
+ page.controls[#page.controls + 1] = ctl
+end
+
+function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
+ local mapping = pick(obj, "mappingSize")
+ local mapW = numOr(pick(mapping, "width"), 0)
+ local mapH = numOr(pick(mapping, "height"), 0)
+ if mapW <= 0 or mapH <= 0 then
+ mapW, mapH = 320, 240
+ addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
+ end
+
+ local imagePath, pdfPath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles)
+ local page = {
+ name = orient,
+ orient = orient,
+ imagePath = imagePath,
+ pdfPath = pdfPath,
+ fullScreen = true,
+ normalized = true,
+ pixelCoords = false,
+ rangeMod = 1,
+ alphaMod = pick(obj, "translucent") == true and 0.7 or 1,
+ aspect = mapW / mapH,
+ aspectFromCfg = false,
+ rect = { x = 0, y = 0, w = 1, h = 1 },
+ mappingWidth = mapW,
+ mappingHeight = mapH,
+ controls = {},
+ }
+
+ local screen = DeltaSkin.screenRect(obj, mapW, mapH)
+ if screen then
+ page.viewport = screen
+ page.viewportFill = false
+ else
+ -- mappingSize is the overlay, not the device. Portrait controller
+ -- skins (GBA4iOS-era 320x240 decks, this Pikachu skin, etc.) keep
+ -- that aspect, sit at the bottom, and leave the leftover for the
+ -- Game Boy picture. A screens/gameScreenFrame rect still fills.
+ page.aspectFromCfg = true
+ page.screenFit = "remainder"
+ if orient == "portrait" then page.anchor = "bottom" end
+ end
+
+ local baseEdges = pick(obj, "extendedEdges")
+ local items = pick(obj, "items")
+ if type(items) == "table" then
+ for _, item in ipairs(items) do
+ DeltaSkin.addItem(page, item, baseEdges, mapW, mapH)
+ end
+ end
+ return page
+end
+
+function DeltaSkin.systemOf(gameType)
+ if type(gameType) ~= "string" or gameType == "" then return nil end
+ for _, prefix in ipairs(DeltaSkin.GAME_TYPE_PREFIXES) do
+ if gameType:sub(1, #prefix) == prefix then
+ return gameType:sub(#prefix + 1):lower()
+ end
+ end
+ return nil
+end
+
+function DeltaSkin.parse(text, opts)
+ opts = opts or {}
+ local info, err = Json.decode(tostring(text or ""), DeltaSkin.MAX_INFO_BYTES)
+ if type(info) ~= "table" then
+ return nil, "info.json does not parse: " .. tostring(err)
+ end
+
+ local gameType = info.gameTypeIdentifier
+ if type(gameType) ~= "string" or gameType == "" then
+ return nil, "old GBA4iOS skin, not supported: info.json has no gameTypeIdentifier"
+ end
+ if gameType:lower():find("gba4ios", 1, true) then
+ return nil, "old GBA4iOS skin, not supported"
+ end
+ local system = DeltaSkin.systemOf(gameType)
+ if not system then
+ return nil, "not a Delta skin: unknown gameTypeIdentifier " .. gameType
+ end
+
+ local warnings = {}
+ if not DeltaSkin.SYSTEMS[system] then
+ addWarning(warnings, "this skin is for " .. system .. ", not Game Boy")
+ end
+
+ local reps = info.representations
+ if type(reps) ~= "table" then return nil, "info.json has no representations" end
+
+ local pdfFiles, pages = {}, {}
+ for _, orient in ipairs(DeltaSkin.ORIENTATIONS) do
+ local obj = DeltaSkin.representation(reps, orient)
+ if obj then
+ local page = DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
+ page.index = #pages + 1
+ pages[#pages + 1] = page
+ end
+ end
+ if #pages == 0 then return nil, "info.json has no usable representation" end
+
+ return {
+ pages = pages,
+ name = info.name,
+ author = info.author,
+ notes = info.notes,
+ format = "delta",
+ system = system,
+ identifier = info.identifier,
+ warnings = warnings,
+ pdfFiles = pdfFiles,
+ }
+end
+
+function DeltaSkin.needsConversion(skin)
+ if type(skin) ~= "table" then return nil end
+ local files = skin.pdfFiles
+ if type(files) ~= "table" or #files == 0 then return nil end
+ for _, page in ipairs(skin.pages or {}) do
+ -- A raster asset, or a JPEG recovered from the PDF at load, means the
+ -- skin can draw. Parse-only callers still see pdfOnly because they
+ -- have not run extract yet.
+ if page.rasterData then return nil end
+ if page.imagePath then return nil end
+ end
+ return { pdfOnly = true, files = files }
+end
+
+function DeltaSkin.outputInputs(ctl)
+ local out = {}
+ for _, b in ipairs(ctl.buttons or {}) do out[#out + 1] = b end
+ for _, h in ipairs(ctl.hotkeys or {}) do
+ local mapped = DeltaSkin.OUTPUT_HOTKEYS[h]
+ if mapped then out[#out + 1] = mapped end
+ end
+ return out
+end
+
+function DeltaSkin.buildRepresentation(page, orient, warnings)
+ local map = DeltaSkin.MAPPING[orient] or DeltaSkin.MAPPING.portrait
+ local mapW, mapH = map.width, map.height
+ local items, files = {}, {}
+
+ for _, ctl in ipairs(page.controls or {}) do
+ local names = DeltaSkin.outputInputs(ctl)
+ if ctl.sector and ctl.sector ~= 1 then
+ names = {}
+ elseif ctl.sector and ctl.areaNames then
+ local TouchSkin = require("src.core.TouchSkin")
+ local dirs = {}
+ for _, side in ipairs({ "up", "down", "left", "right" }) do
+ local mapped = TouchSkin.GB_BUTTONS[tostring(ctl.areaNames[side]):lower()]
+ if mapped then dirs[side] = mapped end
+ end
+ names = next(dirs) and dirs or {}
+ end
+ if names.up or names.down or names.left or names.right or #names > 0 then
+ local item = {
+ inputs = names,
+ frame = {
+ x = round((ctl.x - ctl.rangeX) * mapW),
+ y = round((ctl.y - ctl.rangeY) * mapH),
+ width = round(ctl.rangeX * 2 * mapW),
+ height = round(ctl.rangeY * 2 * mapH),
+ },
+ }
+ if ctl.shape == "radial" then item.mask = "circle" end
+ local edges, any = {}, false
+ local pairsList = {
+ { key = "left", reach = ctl.reachLeft, half = ctl.rangeX * mapW },
+ { key = "right", reach = ctl.reachRight, half = ctl.rangeX * mapW },
+ { key = "top", reach = ctl.reachUp, half = ctl.rangeY * mapH },
+ { key = "bottom", reach = ctl.reachDown, half = ctl.rangeY * mapH },
+ }
+ for _, side in ipairs(pairsList) do
+ local reach = numOr(side.reach, 1)
+ if reach ~= 1 then
+ edges[side.key] = round((reach - 1) * side.half)
+ any = true
+ end
+ end
+ if any then item.extendedEdges = edges end
+ items[#items + 1] = item
+ elseif ctl.imagePath then
+ addWarning(warnings, "per-button art is dropped: Delta keeps all art in one image")
+ end
+ end
+
+ local obj = {
+ items = items,
+ mappingSize = { width = mapW, height = mapH },
+ extendedEdges = { top = 0, bottom = 0, left = 0, right = 0 },
+ translucent = false,
+ }
+ if page.imagePath then
+ obj.assets = {
+ small = page.imagePath, medium = page.imagePath, large = page.imagePath,
+ }
+ files[#files + 1] = page.imagePath
+ end
+ if page.viewport then
+ obj.screens = { {
+ inputFrame = { x = 0, y = 0,
+ width = DeltaSkin.SCREEN_WIDTH, height = DeltaSkin.SCREEN_HEIGHT },
+ outputFrame = {
+ x = round(page.viewport.x * mapW), y = round(page.viewport.y * mapH),
+ width = round(page.viewport.w * mapW), height = round(page.viewport.h * mapH),
+ },
+ } }
+ end
+ return obj, files
+end
+
+function DeltaSkin.build(skin, opts)
+ if type(skin) ~= "table" or not skin.pages or not skin.pages[1] then
+ return nil, "skin has no pages"
+ end
+ opts = opts or {}
+ local standard, edgeToEdge = {}, {}
+ local assets, warnings, used = {}, {}, {}
+
+ for _, page in ipairs(skin.pages) do
+ local orient = TouchSkin.pageOrient(page)
+ if orient ~= "portrait" and orient ~= "landscape" then
+ orient = (numOr(page.aspect, 1) < 1) and "portrait" or "landscape"
+ end
+ if not used[orient] then
+ used[orient] = true
+ local obj, files = DeltaSkin.buildRepresentation(page, orient, warnings)
+ standard[orient] = obj
+ edgeToEdge[orient] = obj
+ for _, rel in ipairs(files) do assets[#assets + 1] = rel end
+ end
+ end
+
+ local system = tostring(opts.system or "gbc")
+ local info = {
+ name = skin.name or skin.id or "skin",
+ identifier = opts.identifier
+ or ("com.gen1recomp.skin." .. tostring(skin.id or "skin")),
+ gameTypeIdentifier = DeltaSkin.GAME_TYPE_PREFIXES[1] .. system,
+ debug = false,
+ representations = { iphone = { standard = standard, edgeToEdge = edgeToEdge } },
+ }
+ return info, assets, warnings
+end
+
+function DeltaSkin.encodeInfo(skin, opts)
+ local info, assets, warnings = DeltaSkin.build(skin, opts)
+ if not info then return nil, assets end
+ return Json.encode(info), assets, warnings
+end
+
+return DeltaSkin
diff --git a/src/core/Game.lua b/src/core/Game.lua
index fc5db6c50..71586bce1 100644
--- a/src/core/Game.lua
+++ b/src/core/Game.lua
@@ -7,6 +7,7 @@ local Input = require("src.core.Input")
local Logger = require("src.core.Logger")
local Platform = require("src.core.Platform")
local Renderer = require("src.render.Renderer")
+local GameViewport = require("src.render.GameViewport")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local FrameProfile = require("src.core.FrameProfile")
@@ -30,6 +31,7 @@ end
function Game:load()
self.pointerOwners={}
+ self.modPointers={}
self.secondScreenPointerOwner=nil
self.secondScreenOverworldPoketch=nil
self.data = Data
@@ -84,6 +86,9 @@ function Game:load()
self.touchControls = TouchControls
TouchControls:init()
+ TouchControls:setHotkeyHandler(function(action, pressed)
+ self:touchSkinHotkey(action, pressed)
+ end)
self.renderer = Renderer
Renderer:init()
@@ -308,6 +313,36 @@ function Game:returnToTitle()
self.stack:push(self:makeTitleState())
end
+-- Skin actions use the same native speed/title/options owners as keyboard input.
+Game.SKIN_FAST_FORWARD = 4
+function Game:touchSkinHotkey(action, pressed)
+ if action == "fast_forward_hold" then
+ if pressed then
+ self.skinSpeedSaved = self.speedOverride
+ self.speedOverride = Game.SKIN_FAST_FORWARD
+ else
+ self.speedOverride = self.skinSpeedSaved
+ self.skinSpeedSaved = nil
+ end
+ elseif action == "fast_forward_toggle" and pressed then
+ if self:speedLocked() or not (self.save and self.save.options) then return end
+ local Speed = require("src.core.GameSpeed")
+ self.save.options.speed = Speed.cycle(self.save.options.speed, 1)
+ self.speedOverride = nil
+ self:writeOptions()
+ elseif action == "soft_reset" and pressed then
+ Input:reset()
+ TouchControls:reset()
+ self:returnToTitle()
+ elseif action == "menu" and pressed then
+ local top = self.stack and self.stack:top()
+ if top and not top.isTouchSkinMenu then
+ local state = require("src.ui.Screens").build(self, "OptionsMenu")
+ if state then state.isTouchSkinMenu = true; self.stack:push(state) end
+ end
+ end
+end
+
function Game:step(dt)
-- THE WHOLE LOGIC STEP, because `stack:update` alone accounted for under
-- 2 ms of a 43 ms update and everything else in here was invisible. Run 4
@@ -381,6 +416,20 @@ end
-- Options row takes effect immediately; speedOverride is the --speed /
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
-- or screenshot run does not depend on whatever the player last chose.
+function Game:speedLocked()
+ if self.linkSession or (self.linkNet and not self.linkNet.closed) then
+ return true, 'link'
+ end
+ local states = self.stack and self.stack.states or {}
+ for i = #states, 1, -1 do
+ local state = states[i]
+ if state and (state.isFixedSpeed or state.isMinigame) then
+ return true, 'minigame'
+ end
+ end
+ return false
+end
+
function Game:logicSpeed()
local GameSpeed = require("src.core.GameSpeed")
-- Link play is always 1X on both machines, and this wins over every other
@@ -389,7 +438,7 @@ function Game:logicSpeed()
-- than the opponent it is racing, and drove its own animation/message
-- queue at a different rate than the peer it is locked to. Nothing about
-- a match should depend on what either player set this to.
- if self.linkSession or (self.linkNet and not self.linkNet.closed) then
+ if self:speedLocked() then
return 1
end
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
@@ -917,6 +966,8 @@ Game.centerClassicZones = centerClassicZones
function Game:draw()
local closeDraw = FrameProfile.section("draw: whole frame")
local screenLayout=require('src.ui.SecondScreen')
+ local generation = require('src.core.GameVersion').generation()
+ GameViewport.begin(generation, generation <= 3 and not screenLayout.combined(self))
Renderer.splitViewport=nil
if screenLayout.combined(self) then
local w,h=love.graphics.getDimensions()
@@ -979,8 +1030,9 @@ function Game:draw()
if screenLayout.combined(self) then
screenLayout.composeWindow(self)
- TouchControls:draw()
end
+ GameViewport.finish(self)
+ TouchControls:draw()
closeDraw()
-- THE SECOND PANEL'S FRAME LEAVES LAST.
--
@@ -1004,6 +1056,45 @@ function Game:toggleFrameProfile()
return FrameProfile.toggle()
end
+-- Source Game2 queries use the native Gen2 chrome's coordinate contract.
+-- These are observations only: do not resize a canvas or change the active
+-- compositor, skin rectangle, renderer scale or cartridge on a query.
+function Game:frameFit(w, h)
+ local window = love and love.window
+ local dpi = window and window.getDPIScale and tonumber(window.getDPIScale()) or 1
+ local pw, ph = GameViewport.pixelDimensions()
+ local ww, wh = GameViewport.dimensions()
+ -- Source battle scenes query their actual drawable size, whereas menus
+ -- query LOVE-unit dimensions. Do not multiply a framebuffer query by DPI
+ -- a second time. Share the compositor's integer-pixel policy and origin.
+ if w == pw and h == ph and (pw ~= ww or ph ~= wh) then
+ local fit = require('src.render.PixelFit')
+ local surfaceW, surfaceH = Renderer:uiSize()
+ local scale = surfaceW == 160 and surfaceH == 144 and Renderer:fitScale()
+ or fit.scale(pw, ph, 160, 144)
+ local x, y = fit.origin(pw, ph, 160, 144, scale)
+ if not Renderer.splitViewport then
+ y = y - require('src.core.ScreenPosition').pixelLift(ww, wh, ph,
+ 144 * scale, wh > 0 and ph / wh or dpi)
+ end
+ return scale, x, y, dpi, pw, ph
+ end
+ local chrome = require('src.ui.Gen2Chrome')
+ local scale = chrome.fitScale(w, h)
+ local x, y = chrome.fitOrigin(w, h, scale)
+ return scale, x, y, dpi, pw, ph
+end
+
+function Game:viewport(w, h)
+ local scale, x, y, dpi = self:frameFit(w, h)
+ return {
+ width=w, height=h, gameX=x, gameY=y,
+ gameWidth=160*scale, gameHeight=144*scale,
+ scale=scale, dpiX=dpi, dpiY=dpi,
+ }
+end
+
+
function Game:_draw()
-- the UI canvas clears transparent when the overworld's world pass
-- shows through beneath it; opaque full-screen states get the classic
@@ -1013,6 +1104,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 +1193,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 +1221,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 +1275,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 +1291,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.
@@ -1191,7 +1312,7 @@ function Game:_draw()
ModRuntime.call("render.hud", function() end, self, viewport)
end
-- on-screen mobile controls: pure screen-space, over the finished frame
- if not Renderer.splitViewport then TouchControls:draw() end
+ -- OS-window controls draw after GameViewport.finish in Game:draw.
end
-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out
@@ -1408,7 +1529,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
@@ -1725,9 +1846,13 @@ end
local function offerPointer(self, method, id, x, y)
self.pointerOwners=self.pointerOwners or {}
local captured=self.pointerOwners[id]
+ local gameX, gameY, inside = GameViewport.toLocal(x, y)
+ local panel = self.secondScreenPointerOwner
local function offer(screen)
if not (screen and type(screen[method])=='function') then return false end
- local ok,claimed=pcall(screen[method],screen,id,x,y)
+ local ok,claimed=pcall(screen[method],screen,id,
+ (self.secondScreenInjecting or screen == panel) and x or gameX,
+ (self.secondScreenInjecting or screen == panel) and y or gameY)
if not ok then
require('src.core.Logger').warn('%s raised in a touch handler: %s',
tostring(screen.name or 'a screen'),tostring(claimed))
@@ -1742,21 +1867,50 @@ local function offerPointer(self, method, id, x, y)
return true
end
local top = self.stack and self.stack.top and self.stack:top()
- if offer(top) then return true end
+ if (inside or self.secondScreenInjecting) and offer(top) then return true end
-- The always-present Android lower-panel watch is drawn outside the stack.
-- Its coordinate mapper rejects main-window input in display mode.
- local panel=self.secondScreenPointerOwner
return panel~=top and offer(panel) or false
end
-function Game:touchpressed(id, x, y)
+-- Window-space pointer metadata plus coordinates local to the game rectangle.
+local function pointerUnclaimed() return false end
+function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
+ local gameX, gameY, inside = GameViewport.toLocal(x, y)
+ return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
+ phase = phase, source = source, id = id, x = x, y = y,
+ gameX = gameX, gameY = gameY, insideGame = inside,
+ dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
+ }) == true
+end
+local function modPointer(self, phase, id, x, y, dx, dy, pressure)
+ if self.secondScreenInjecting then return false end
+ local p = self.modPointers and self.modPointers[id]
+ if phase == "pressed" then
+ if not ModRuntime.wantsHook("input.pointer") then return false end
+ self.modPointers = self.modPointers or {}
+ p = { source = id == "mouse" and "mouse" or "touch", x = x, y = y }
+ self.modPointers[id] = p
+ elseif not p then return false end
+ dx, dy = dx or (x - p.x), dy or (y - p.y)
+ p.x, p.y = x, y
+ if pressure ~= nil then p.pressure = pressure end
+ local nativeOwner = self.pointerOwners and self.pointerOwners[id]
+ local claimed = self:pointerEvent(phase, p.source, id, x, y, dx, dy, p.pressure, id == "mouse" and 1 or nil)
+ if not nativeOwner and claimed then p.claimed = true end
+ if phase == "released" and self.modPointers then self.modPointers[id] = nil end
+ return not nativeOwner and p.claimed == true
+end
+
+function Game:touchpressed(id, x, y, dx, dy, pressure)
-- Visible virtual controls own their complete gesture, even when pressing
-- one opens a different screen before the finger is lifted.
if not self.secondScreenInjecting and TouchControls.visible and TouchControls:visible()
and TouchControls:hitTest(x,y) then
- TouchControls:touchpressed(id,x,y)
- return
+ if TouchControls:touchpressed(id,x,y)
+ or (TouchControls.touches and TouchControls.touches[id]) then return end
end
+ if modPointer(self, "pressed", id, x, y, dx, dy, pressure) then return end
if offerPointer(self, "touchpressed", id, x, y) then return end
if self.secondScreenInjecting then return end
TouchControls:touchpressed(id, x, y)
@@ -1771,21 +1925,23 @@ function Game:hasPointerScreen()
return top and type(top.touchpressed)=='function' or false
end
-function Game:touchmoved(id, x, y)
+function Game:touchmoved(id, x, y, dx, dy, pressure)
if not self.secondScreenInjecting and TouchControls.touches and TouchControls.touches[id] then
TouchControls:touchmoved(id,x,y)
return
end
+ if modPointer(self, "moved", id, x, y, dx, dy, pressure) then return end
if offerPointer(self, "touchmoved", id, x, y) then return end
if self.secondScreenInjecting then return end
TouchControls:touchmoved(id, x, y)
end
-function Game:touchreleased(id, x, y)
+function Game:touchreleased(id, x, y, dx, dy, pressure)
if not self.secondScreenInjecting and TouchControls.touches and TouchControls.touches[id] then
TouchControls:touchreleased(id,x,y)
return
end
+ if modPointer(self, "released", id, x, y, dx, dy, pressure) then return end
if offerPointer(self, "touchreleased", id, x, y) then return end
if self.secondScreenInjecting then return end
TouchControls:touchreleased(id, x, y)
@@ -1882,6 +2038,7 @@ function Game:applyOptions(opts)
-- after VideoMode: a faithful-resolution lock is an exact window size, so
-- it has to be the last word on the window (it drops fullscreen to hold)
require("src.core.FaithfulRes").applyOptions(opts)
+ require("src.core.ScreenPosition").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
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/Gen2SpeciesNames.lua b/src/core/Gen2SpeciesNames.lua
new file mode 100644
index 000000000..1d3579430
--- /dev/null
+++ b/src/core/Gen2SpeciesNames.lua
@@ -0,0 +1,214 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- G/S/C species identifiers used by the separate Gen1Recomp reference
+-- 535a5ec0 (constant names only; no ROM bytes or artwork). Complete native
+-- cartridges use named runtime identities; old SPECIES_nnn addresses remain
+-- lookup aliases. Partial/authored datasets retain their existing identities.
+local Version = require("src.core.GameVersion")
+local M = {}
+local order = [[
+BULBASAUR IVYSAUR VENUSAUR CHARMANDER CHARMELEON CHARIZARD SQUIRTLE WARTORTLE
+BLASTOISE CATERPIE METAPOD BUTTERFREE WEEDLE KAKUNA BEEDRILL PIDGEY
+PIDGEOTTO PIDGEOT RATTATA RATICATE SPEAROW FEAROW EKANS ARBOK
+PIKACHU RAICHU SANDSHREW SANDSLASH NIDORAN_F NIDORINA NIDOQUEEN NIDORAN_M
+NIDORINO NIDOKING CLEFAIRY CLEFABLE VULPIX NINETALES JIGGLYPUFF WIGGLYTUFF
+ZUBAT GOLBAT ODDISH GLOOM VILEPLUME PARAS PARASECT VENONAT
+VENOMOTH DIGLETT DUGTRIO MEOWTH PERSIAN PSYDUCK GOLDUCK MANKEY
+PRIMEAPE GROWLITHE ARCANINE POLIWAG POLIWHIRL POLIWRATH ABRA KADABRA
+ALAKAZAM MACHOP MACHOKE MACHAMP BELLSPROUT WEEPINBELL VICTREEBEL TENTACOOL
+TENTACRUEL GEODUDE GRAVELER GOLEM PONYTA RAPIDASH SLOWPOKE SLOWBRO
+MAGNEMITE MAGNETON FARFETCH_D DODUO DODRIO SEEL DEWGONG GRIMER
+MUK SHELLDER CLOYSTER GASTLY HAUNTER GENGAR ONIX DROWZEE
+HYPNO KRABBY KINGLER VOLTORB ELECTRODE EXEGGCUTE EXEGGUTOR CUBONE
+MAROWAK HITMONLEE HITMONCHAN LICKITUNG KOFFING WEEZING RHYHORN RHYDON
+CHANSEY TANGELA KANGASKHAN HORSEA SEADRA GOLDEEN SEAKING STARYU
+STARMIE MR__MIME SCYTHER JYNX ELECTABUZZ MAGMAR PINSIR TAUROS
+MAGIKARP GYARADOS LAPRAS DITTO EEVEE VAPOREON JOLTEON FLAREON
+PORYGON OMANYTE OMASTAR KABUTO KABUTOPS AERODACTYL SNORLAX ARTICUNO
+ZAPDOS MOLTRES DRATINI DRAGONAIR DRAGONITE MEWTWO MEW CHIKORITA
+BAYLEEF MEGANIUM CYNDAQUIL QUILAVA TYPHLOSION TOTODILE CROCONAW FERALIGATR
+SENTRET FURRET HOOTHOOT NOCTOWL LEDYBA LEDIAN SPINARAK ARIADOS
+CROBAT CHINCHOU LANTURN PICHU CLEFFA IGGLYBUFF TOGEPI TOGETIC
+NATU XATU MAREEP FLAAFFY AMPHAROS BELLOSSOM MARILL AZUMARILL
+SUDOWOODO POLITOED HOPPIP SKIPLOOM JUMPLUFF AIPOM SUNKERN SUNFLORA
+YANMA WOOPER QUAGSIRE ESPEON UMBREON MURKROW SLOWKING MISDREAVUS
+UNOWN WOBBUFFET GIRAFARIG PINECO FORRETRESS DUNSPARCE GLIGAR STEELIX
+SNUBBULL GRANBULL QWILFISH SCIZOR SHUCKLE HERACROSS SNEASEL TEDDIURSA
+URSARING SLUGMA MAGCARGO SWINUB PILOSWINE CORSOLA REMORAID OCTILLERY
+DELIBIRD MANTINE SKARMORY HOUNDOUR HOUNDOOM KINGDRA PHANPY DONPHAN
+PORYGON2 STANTLER SMEARGLE TYROGUE HITMONTOP SMOOCHUM ELEKID MAGBY
+MILTANK BLISSEY RAIKOU ENTEI SUICUNE LARVITAR PUPITAR TYRANITAR
+LUGIA HO_OH CELEBI
+]]
+local nativeByName = {}
+local nameByNumber = {}
+local count = 0
+for name in order:gmatch("%S+") do
+ count = count + 1
+ nativeByName[name] = string.format("SPECIES_%03d", count)
+ nameByNumber[count] = ({FARFETCH_D='FARFETCHD',MR__MIME='MR_MIME'})[name] or name
+end
+local states = setmetatable({}, {__mode="k"})
+local function supported()
+ local v = Version.get()
+ return v == "gold" or v == "silver" or v == "crystal"
+end
+
+-- The original importer scaffold used numbered placeholders. Normal game
+-- loads now publish one authoritative, named GSC graph before mods or game
+-- objects are created. Old addresses remain lookup aliases, not extra rows.
+-- A total conversion, partial fixture, authored lookup or identity collision
+-- is not enough evidence to reinterpret its species numbering.
+function M.canonicalize(data)
+ local pokemon=type(data)=='table'and data.pokemon
+ local prior=type(pokemon)=='table'and states[pokemon]
+ if prior and prior.canonical then return getmetatable(pokemon)==prior.meta end
+ if not supported()or type(pokemon)~='table'or getmetatable(pokemon)~=nil then return false end
+ local constants=data.constants
+ local species=constants and constants.speciesOrder
+ if type(species)~='table'or #species~=251 then return false end
+ local aliases={}
+ for i=1,251 do
+ local id=string.format('SPECIES_%03d',i)
+ local def=rawget(pokemon,id)
+ local name=nameByNumber[i]
+ if species[i]~=id or type(def)~='table'or def.id~=id or def.index~=i or def.dex~=i
+ or rawget(pokemon,name)~=nil then return false end
+ aliases[id]=name
+ end
+ aliases.FARFETCH_D,aliases.MR__MIME='FARFETCHD','MR_MIME'
+ -- Plan the complete graph transaction before changing any table. Exact
+ -- identity tokens change; prose, filesystem paths and provenance do not.
+ local literal={text=true,text_pointers=true,name=true,nickname=true,title=true,body=true,
+ message=true,description=true,label=true,prompt=true,caption=true,source=true,
+ sourceId=true,native=true,path=true,file=true,filename=true}
+ local seen,plan={},{}
+ local function visit(row)
+ if type(row)~='table'or seen[row]then return true end
+ seen[row]=true
+ local targets={}
+ for key,value in pairs(row)do
+ if not literal[key]then
+ local target=aliases[key]or key
+ local replacement=type(value)=='string'and(aliases[value]or value)or value
+ if target~=key then
+ local existing=rawget(row,target)
+ if existing~=nil and existing~=replacement then return false end
+ end
+ if targets[target]~=nil and targets[target]~=replacement then return false end
+ targets[target]=replacement
+ if target~=key or replacement~=value then
+ plan[#plan+1]={row=row,key=key,target=target,value=replacement}
+ end
+ if not visit(value)then return false end
+ end
+ end
+ return true
+ end
+ if not visit(data)then return false end
+ for _,op in ipairs(plan)do
+ if op.key~=op.target then op.row[op.key]=nil end
+ op.row[op.target]=op.value
+ end
+ local meta={__index=function(t,id)
+ if supported()then local name=aliases[id];if name then return rawget(t,name)end end
+ end}
+ states[pokemon]={aliases=aliases,meta=meta,canonical=true}
+ setmetatable(pokemon,meta)
+ constants.speciesIdentity='gsc-canonical-v1'
+ return true
+end
+
+function M.fromNumber(data,index)
+ index=math.floor(tonumber(index)or 0)
+ local species=data and data.constants and data.constants.speciesOrder
+ return species and species[index]or string.format('SPECIES_%03d',index)
+end
+
+function M.number(data,species)
+ if type(species)=='table'then species=species.species end
+ local def=data and data.pokemon and data.pokemon[species]
+ return tonumber(def and def.index)or tonumber(species)
+ or(type(species)=='string'and tonumber(species:match('^SPECIES_(%d+)$')))or 0
+end
+
+function M.same(data,a,b)
+ local pokemon=data and data.pokemon
+ return M.registryId(pokemon,a)==M.registryId(pokemon,b)
+end
+
+-- Save validation already owns the native Pokemon lists. Normalize their
+-- identity when it scrubs them, and fold legacy Pokedex keys without losing
+-- a previously true seen/owned bit. No save file is written here.
+function M.normalizeDex(data,dex)
+ local state=data and data.pokemon and states[data.pokemon]
+ if not(supported()and state and state.canonical and type(dex)=='table')then return end
+ for _,kind in ipairs{'seen','owned'}do
+ local flags=dex[kind]
+ if type(flags)=='table'then
+ local changes={}
+ for id,value in pairs(flags)do
+ local target=state.aliases[id]
+ if target then changes[#changes+1]={id,target,value}end
+ end
+ for _,entry in ipairs(changes)do
+ flags[entry[2]]=flags[entry[2]]or entry[3]
+ flags[entry[1]]=nil
+ end
+ end
+ end
+end
+
+function M.publish(data)
+ local pokemon = type(data) == "table" and data.pokemon
+ if not supported() or type(pokemon) ~= "table" then return false end
+ local previous = states[pokemon]
+ if previous then return getmetatable(pokemon) == previous.meta end
+ -- Authored dataset lookup behavior is never replaced or wrapped.
+ if getmetatable(pokemon) ~= nil then return false end
+ local nativeOrder = data.constants and data.constants.speciesOrder
+ if type(nativeOrder) ~= "table" then return false end
+ local aliases = {}
+ for name, id in pairs(nativeByName) do
+ local def = rawget(pokemon, id)
+ local index = tonumber(id:match("(%d+)$"))
+ if nativeOrder[index] == id and type(def) == "table" and def.id == id
+ and def.dex == index and def.index == index
+ and rawget(pokemon, name) == nil then
+ aliases[name] = id
+ end
+ end
+ if not next(aliases) then return false end
+ local meta = {__index=function(t, name)
+ if supported() then
+ local id = aliases[name]
+ if id then return rawget(t, id) end
+ end
+ end}
+ states[pokemon] = {aliases=aliases, meta=meta}
+ setmetatable(pokemon, meta)
+ return true
+end
+
+-- The real Registry uses the same address for reads, edits, deletion,
+-- conflicts and rollback. Explicit authored names always retain precedence.
+function M.registryId(pokemon, id)
+ if not supported() or type(pokemon) ~= "table" or rawget(pokemon, id) ~= nil then return id end
+ local state = states[pokemon]
+ if not state or getmetatable(pokemon) ~= state.meta then return id end
+ return state.aliases[id] or id
+end
+
+function M.write(target, registry)
+ local state = supported() and states[target]
+ for id in pairs(registry.ops) do
+ local value = registry:get(id)
+ -- Full replacements addressed by a legacy name still carry a native id.
+ if state and getmetatable(target) == state.meta and type(value) == "table"
+ and state.aliases[value.id] == id then value.id = id end
+ target[id] = value
+ if supported() and type(value)=='table' and type(value.baseStats)=='table' then
+ require('src.pokemon.Gen2MonFields').stats(value.baseStats)
+ end
+ end
+end
+return M
diff --git a/src/core/GenOptions.lua b/src/core/GenOptions.lua
index 001e56185..83d96a6d2 100644
--- a/src/core/GenOptions.lua
+++ b/src/core/GenOptions.lua
@@ -51,6 +51,7 @@ GenOptions.SHARED_ONLY = {
modUpdateCache = true,
saveSlots = true,
touchControls = true,
+ haptics = true,
tutorials = true,
perGeneration = true,
dataDir = true,
diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua
index ee5e198fd..80f93e294 100644
--- a/src/core/ModCompat.lua
+++ b/src/core/ModCompat.lua
@@ -32,22 +32,13 @@
-- hook, not one module
-- src.world.gen2.Follower PikachuFollower is Yellow's, not a general
-- 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.
--- src.ui.gen2.BattleState is already mapped to
--- src.battle.BattleState below; a port that
--- spells BOTH names is not spelling one thing
--- twice, it is naming the battle CONTROLLER and
--- the battle STATE. This engine has one object
--- for both, so whichever of the two a mod meant
--- it would get -- and the half of the contract
--- it did not mean would be missing, somewhere
--- far away from the require. It goes in when a
--- mod that wants it says what it calls on it.
+--
+-- src.battle.gen2.Battle is a real live model module, not an alias. Its native
+-- reads/completion are attached by Gen2BattleView and Gen2BattleRead. Unsupported
+-- constructors and turn APIs are not guessed from similarly named screens.
--
-- Each of those turns one optional feature off in the mod and says so in the
-- log, which is the outcome we want.
@@ -62,11 +53,15 @@ 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.Unown"] = "src.compat.gen2.Unown",
["src.core.gen2.BugContest"] = "src.world.BugContest",
["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects",
["src.core.gen2.Save"] = "src.core.SaveData",
@@ -78,8 +73,9 @@ ModCompat.ALIASES = {
["src.battle.gen2.Catching"] = "src.battle.Catching",
["src.battle.gen2.Damage"] = "src.battle.Damage",
["src.battle.gen2.Effects"] = "src.battle.MoveEffects",
- ["src.battle.gen2.Mon"] = "src.pokemon.Pokemon",
+ ["src.battle.gen2.Mon"] = "src.compat.gen2.Mon",
["src.ui.gen2.BattleState"] = "src.battle.BattleState",
+ ["src.ui.gen2.Chrome"] = "src.ui.Gen2Chrome",
-- menus. The Gold port names two of these differently rather than
-- namespacing them, so both spellings land on the one implementation.
@@ -97,7 +93,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/core/PdfImage.lua b/src/core/PdfImage.lua
new file mode 100644
index 000000000..671e94bcc
--- /dev/null
+++ b/src/core/PdfImage.lua
@@ -0,0 +1,118 @@
+-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91.
+-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 with Section 7 terms;
+-- see licenses/GEN1RECOMP-TOUCH-SKINS.md. Native integration by Roxas2712.
+-- Recover a raster from a PDF that is really a wrapped JPEG. Delta skins
+-- ship artwork that way so iOS can scale it; LOVE has no PDF renderer, so
+-- import pulls the embedded image out instead of refusing the skin. True
+-- vector PDFs (no Image XObject, no JPEG) still fail.
+
+local PdfImage = {}
+
+local function isPdf(bytes)
+ return type(bytes) == "string" and bytes:sub(1, 5) == "%PDF-"
+end
+
+-- After the `stream` keyword the spec allows \n or \r\n before the bytes.
+-- `endstream` also contains the letters "stream", so skip that match.
+local function streamDataStart(bytes, from)
+ local s, e = bytes:find("stream", from, true)
+ while s do
+ if s == 1 or bytes:sub(s - 3, s - 1) ~= "end" then
+ local p = e + 1
+ if bytes:sub(p, p) == "\r" then p = p + 1 end
+ if bytes:sub(p, p) == "\n" then p = p + 1 end
+ return p, s
+ end
+ s, e = bytes:find("stream", e + 1, true)
+ end
+ return nil
+end
+
+local function dictWindow(bytes, imageAt)
+ local from = imageAt > 400 and (imageAt - 400) or 1
+ local to = math.min(#bytes, imageAt + 800)
+ return bytes:sub(from, to)
+end
+
+local function dictNumber(window, key)
+ -- Prefer an indirect ref so `/Length 5 0 R` is not read as length 5.
+ if window:find("/" .. key .. "%s+%d+%s+%d+%s+R") then return nil end
+ return tonumber(window:match("/" .. key .. "%s+(%d+)"))
+end
+
+local function dictFilter(window)
+ local named = window:match("/Filter%s*/(%w+)")
+ if named then return named end
+ return window:match("/Filter%s*%[%s*/(%w+)")
+end
+
+local function jpegIn(bytes, from, to)
+ if from < 1 then from = 1 end
+ if not to or to > #bytes then to = #bytes end
+ if to < from then return nil end
+ local region = bytes:sub(from, to)
+ local soi = region:find("\255\216\255", 1, true)
+ if not soi then return nil end
+ local eoi = region:find("\255\217", soi + 3, true)
+ if not eoi then return nil end
+ return region:sub(soi, eoi + 1)
+end
+
+local function candidate(data, width, height, ext)
+ if not data or data == "" then return nil end
+ return {
+ data = data,
+ ext = ext or "jpg",
+ width = width or 0,
+ height = height or 0,
+ }
+end
+
+local function bigger(a, b)
+ if not a then return b end
+ if not b then return a end
+ local as = (a.width or 0) * (a.height or 0)
+ local bs = (b.width or 0) * (b.height or 0)
+ if bs ~= as then return bs > as and b or a end
+ return #b.data > #a.data and b or a
+end
+
+-- Walk Image XObjects and take the largest DCTDecode (JPEG) stream.
+local function fromImageXObjects(bytes)
+ local best
+ local i = 1
+ while true do
+ local s, e = bytes:find("/Subtype%s*/Image", i)
+ if not s then break end
+ local window = dictWindow(bytes, s)
+ local filter = dictFilter(window)
+ local width = dictNumber(window, "Width")
+ local height = dictNumber(window, "Height")
+ local dataStart = streamDataStart(bytes, e)
+ i = e + 1
+ if dataStart and filter == "DCTDecode" then
+ local es = bytes:find("endstream", dataStart, true)
+ local jpeg = jpegIn(bytes, dataStart, es and (es - 1) or nil)
+ best = bigger(best, candidate(jpeg, width, height, "jpg"))
+ end
+ end
+ return best
+end
+
+-- Image-to-PDF converters (3-Heights, Preview, etc.) leave a single JPEG
+-- body even when /Length is an indirect object we do not resolve.
+local function fromBareJpeg(bytes)
+ local jpeg = jpegIn(bytes, 1, #bytes)
+ if not jpeg then return nil end
+ return candidate(jpeg, 0, 0, "jpg")
+end
+
+function PdfImage.extract(bytes)
+ if not isPdf(bytes) then return nil, "not a pdf" end
+ local best = fromImageXObjects(bytes)
+ if not best then best = fromBareJpeg(bytes) end
+ if not best then return nil, "no extractable image" end
+ return best
+end
+
+return PdfImage
diff --git a/src/core/SafeArea.lua b/src/core/SafeArea.lua
index 86c404fbc..bf57c3c6d 100644
--- a/src/core/SafeArea.lua
+++ b/src/core/SafeArea.lua
@@ -8,23 +8,33 @@
-- canvas may still letterbox into the full framebuffer for immersion.
local SafeArea = {}
+local Viewport = require("src.render.GameViewport")
-function SafeArea.rect()
+function SafeArea.windowRect()
local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then
ww, wh = love.graphics.getDimensions()
end
- if ww <= 0 then ww = 1 end
- if wh <= 0 then wh = 1 end
+ if ww <= 0 then
+ ww = 1
+ end
+ if wh <= 0 then
+ wh = 1
+ end
if not (love and love.window and love.window.getSafeArea) then
return 0, 0, ww, wh
end
local x, y, w, h = love.window.getSafeArea()
- if type(x) ~= "number" or type(y) ~= "number"
- or type(w) ~= "number" or type(h) ~= "number"
- or w <= 0 or h <= 0 then
+ if
+ type(x) ~= "number"
+ or type(y) ~= "number"
+ or type(w) ~= "number"
+ or type(h) ~= "number"
+ or w <= 0
+ or h <= 0
+ then
return 0, 0, ww, wh
end
@@ -37,4 +47,8 @@ function SafeArea.rect()
return x, y, w, h
end
+function SafeArea.rect()
+ return Viewport.localSafeRect(SafeArea.windowRect())
+end
+
return SafeArea
diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua
index 2bfe53530..a2918783e 100644
--- a/src/core/SaveData.lua
+++ b/src/core/SaveData.lua
@@ -520,6 +520,7 @@ function SaveData.defaultOptions()
-- layout (#633). Pre-#633 files stored one top-level positions table;
-- TouchControls.normalizeConfig folds it into both orientations on load.
touchControls = { enabled = true },
+ haptics = "light",
-- Where imported game data lives. false = LOVE's save directory (the
-- appdata folder); a string is an absolute folder the player chose in
-- LAUNCHER SETTINGS. See the game-data folder block above.
@@ -1978,6 +1979,7 @@ end
-- indexes; the level floor widens to 1 because a freshly caught level-1
-- mon can legitimately sit in a save
local function scrubKnownMon(mon, data)
+ mon.species=require('src.core.Gen2SpeciesNames').registryId(data.pokemon,mon.species)
if type(mon.dvs) == "table" then
for stat, v in pairs(mon.dvs) do mon.dvs[stat] = clamp(v, 0, 15, 0) end
end
@@ -1985,6 +1987,7 @@ local function scrubKnownMon(mon, data)
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
end
mon.level = clamp(mon.level, 1, 100, 1)
+ require('src.pokemon.Pokemon').ensureIdentity(data,mon)
-- Box mons imported from a real .sav carry NO stat block: box_struct stops
-- before MON_LEVEL/MON_STATS, so src/save_convert/GenSave.lua decodeMon
-- only fills `stats` for party slots. Every HP-bar draw then nil-indexes
@@ -1996,6 +1999,9 @@ local function scrubKnownMon(mon, data)
-- so the derived stats use a sane level. A save that already has stats is
-- untouched.
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
+ if require('src.core.GameVersion').isGen2() then
+ require('src.pokemon.Gen2MonFields').bind(data,mon)
+ end
local moves = mon.moves
if type(moves) ~= "table" then return end
local hadMoves = #moves > 0
@@ -2111,6 +2117,13 @@ function SaveData.validate(save, data)
for b, box in ipairs(save.boxes or {}) do
scrubMonList(box, "box " .. b, save, data, report, Boxes.capacity())
end
+ if GameVersion.isGen2() then
+ scrubMonList(save.g2SecondMonBackup,"held party",save,data,report)
+ local contest=type(save.g2BugContest)=='table'and save.g2BugContest
+ if contest then
+ scrubMonList(contest.party,"contest held party",save,data,report)
+ end
+ end
local daycare = save.daycare
if type(daycare) == "table" and type(daycare.mon) == "table" then
if not known(data.pokemon, daycare.mon.species) then
@@ -2161,6 +2174,7 @@ function SaveData.validate(save, data)
end
scrubMaps(save, data, report)
local dex = save.pokedex
+ require("src.core.Gen2SpeciesNames").normalizeDex(data,dex)
if type(dex) == "table" then
for _, key in ipairs({ "seen", "owned" }) do
if type(dex[key]) == "table" then
@@ -2180,13 +2194,27 @@ function SaveData.validate(save, data)
if type(entry) == "table" then
for i = 1, #entry do
local mon = entry[i]
- if type(mon) == "table" and mon.species ~= nil
- and not known(data.pokemon, mon.species) then
- mon.species = nil
+ if type(mon) == "table" and mon.species ~= nil then
+ if known(data.pokemon, mon.species) then
+ mon.species = require('src.core.Gen2SpeciesNames').registryId(data.pokemon,mon.species)
+ else
+ mon.species = nil
+ end
end
end
end
end
+ if GameVersion.isGen2() then
+ local Species=require('src.core.Gen2SpeciesNames')
+ if save.g2PokeonlySpecies~=nil then
+ save.g2PokeonlySpecies=Species.registryId(data.pokemon,save.g2PokeonlySpecies)
+ end
+ for _,roamer in pairs(type(save.g2Roam)=='table'and save.g2Roam or {})do
+ if type(roamer)=='table'and roamer.speciesId~=nil then
+ roamer.speciesId=Species.registryId(data.pokemon,roamer.speciesId)
+ end
+ end
+ end
-- BERRY TREES A SAVE PREDATING THEM NEVER GOT.
--
-- Reported from play, after the plots were made to draw what is growing in
diff --git a/src/core/ScreenPosition.lua b/src/core/ScreenPosition.lua
new file mode 100644
index 000000000..bf96b6339
--- /dev/null
+++ b/src/core/ScreenPosition.lua
@@ -0,0 +1,84 @@
+-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91.
+-- Copyright (C) 2026 BOIS CLUB GAMES, LLC.
+-- GPLv3 with Section 7 terms retained in licenses/GEN1RECOMP-TOUCH-SKINS.md.
+-- Native viewport/pixel integration by Roxas2712 under the same terms.
+local ScreenPosition = {}
+
+ScreenPosition.MODES = { "center", "upper", "top" }
+ScreenPosition.DEFAULT = "center"
+ScreenPosition.mode = ScreenPosition.DEFAULT
+
+local LABELS = { center = "CENTER", upper = "UPPER", top = "TOP" }
+
+function ScreenPosition.normalize(v)
+ if LABELS[v] then return v end
+ return ScreenPosition.DEFAULT
+end
+
+function ScreenPosition.label(v)
+ if ScreenPosition.skinActive() then return "SKIN" end
+ return LABELS[ScreenPosition.normalize(v)]
+end
+
+function ScreenPosition.cycle(v, dir)
+ if ScreenPosition.skinActive() then return ScreenPosition.normalize(v) end
+ v = ScreenPosition.normalize(v)
+ local modes = ScreenPosition.MODES
+ local cur = 1
+ for i, mode in ipairs(modes) do
+ if mode == v then cur = i break end
+ end
+ return modes[(cur - 1 + (dir or 1)) % #modes + 1]
+end
+
+function ScreenPosition.setMode(v)
+ ScreenPosition.mode = ScreenPosition.normalize(v)
+end
+
+function ScreenPosition.applyOptions(opts)
+ ScreenPosition.setMode(opts and opts.screenPos)
+end
+
+function ScreenPosition.safeTop()
+ local ok, SafeArea = pcall(require, "src.core.SafeArea")
+ if not ok then return 0 end
+ local okr, _, y = pcall(SafeArea.rect)
+ if not okr then return 0 end
+ return math.max(0, tonumber(y) or 0)
+end
+
+function ScreenPosition.skinActive(w, h)
+ local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
+ if not ok or type(TouchSkin.viewport) ~= "function" then return false end
+ if (not w or not h) and love and love.graphics and love.graphics.getDimensions then
+ w, h = love.graphics.getDimensions()
+ end
+ local okv, x = pcall(TouchSkin.viewport, w, h)
+ return okv and x ~= nil
+end
+
+function ScreenPosition.lift(viewH, contentH, safeTop)
+ if ScreenPosition.mode == "center" then return 0 end
+ viewH = tonumber(viewH) or 0
+ contentH = tonumber(contentH) or 0
+ local slack = viewH - contentH
+ if slack <= 0 then return 0 end
+ local centered = math.floor(slack / 2)
+ local target = ScreenPosition.mode == "top" and 0 or math.floor(slack / 4)
+ safeTop = math.floor(tonumber(safeTop) or 0)
+ if safeTop > 0 and target < safeTop then
+ target = math.min(safeTop, centered)
+ end
+ return centered - target
+end
+
+-- The native compositor measures physical pixels; safe-area ownership stays
+-- with the current logical viewport. Skin/split layouts keep their own origin.
+function ScreenPosition.pixelLift(windowW, windowH, pixelH, contentH, dpiY)
+ if ScreenPosition.mode == "center" then return 0 end
+ local Viewport = require("src.render.GameViewport")
+ if Viewport.active() or ScreenPosition.skinActive(windowW, windowH) then return 0 end
+ return ScreenPosition.lift(pixelH, contentH, ScreenPosition.safeTop() * dpiY)
+end
+
+return ScreenPosition
diff --git a/src/core/SkinZip.lua b/src/core/SkinZip.lua
new file mode 100644
index 000000000..acc6cf287
--- /dev/null
+++ b/src/core/SkinZip.lua
@@ -0,0 +1,95 @@
+-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91.
+-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 with Section 7 terms;
+-- see licenses/GEN1RECOMP-TOUCH-SKINS.md. Native integration by Roxas2712.
+local SkinZip = {}
+
+local bxor
+do
+ local ok, bit = pcall(require, "bit")
+ if ok and bit and bit.bxor then
+ bxor = function(a, b) return bit.bxor(a, b) % 0x100000000 end
+ else
+ local byteXor = {}
+ local function xor8(a, b)
+ local key = a * 256 + b
+ local memo = byteXor[key]
+ if memo then return memo end
+ local r, bitv = 0, 1
+ local x, y = a, b
+ for _ = 1, 8 do
+ if (x % 2) ~= (y % 2) then r = r + bitv end
+ x, y, bitv = math.floor(x / 2), math.floor(y / 2), bitv * 2
+ end
+ byteXor[key] = r
+ return r
+ end
+ bxor = function(a, b)
+ local r, mul = 0, 1
+ for _ = 1, 4 do
+ r = r + xor8(a % 256, b % 256) * mul
+ a, b, mul = math.floor(a / 256), math.floor(b / 256), mul * 256
+ end
+ return r
+ end
+ end
+end
+
+local crcTable
+local function crc32(s)
+ if not crcTable then
+ crcTable = {}
+ for i = 0, 255 do
+ local c = i
+ for _ = 1, 8 do
+ if c % 2 == 1 then
+ c = bxor(math.floor(c / 2), 0xEDB88320)
+ else
+ c = math.floor(c / 2)
+ end
+ end
+ crcTable[i] = c
+ end
+ end
+ local crc = 0xFFFFFFFF
+ for i = 1, #s do
+ crc = bxor(crcTable[bxor(crc, s:byte(i)) % 256], math.floor(crc / 256))
+ end
+ return bxor(crc, 0xFFFFFFFF) % 0x100000000
+end
+
+local function le16(n)
+ n = math.floor(n) % 0x10000
+ return string.char(n % 256, math.floor(n / 256) % 256)
+end
+
+local function le32(n)
+ n = math.floor(n) % 0x100000000
+ return string.char(n % 256, math.floor(n / 256) % 256,
+ math.floor(n / 65536) % 256, math.floor(n / 16777216) % 256)
+end
+
+function SkinZip.encode(entries)
+ local out, central, offset = {}, {}, 0
+ for _, entry in ipairs(entries) do
+ local name, data = entry.name, entry.data or ""
+ local crc, size = crc32(data), #data
+ local local_ = "PK\3\4" .. le16(20) .. le16(0) .. le16(0)
+ .. le16(0) .. le16(0)
+ .. le32(crc) .. le32(size) .. le32(size)
+ .. le16(#name) .. le16(0) .. name
+ out[#out + 1] = local_
+ out[#out + 1] = data
+ central[#central + 1] = "PK\1\2" .. le16(20) .. le16(20) .. le16(0) .. le16(0)
+ .. le16(0) .. le16(0)
+ .. le32(crc) .. le32(size) .. le32(size)
+ .. le16(#name) .. le16(0) .. le16(0) .. le16(0) .. le16(0)
+ .. le32(0) .. le32(offset) .. name
+ offset = offset + #local_ + #data
+ end
+ local dir = table.concat(central)
+ return table.concat(out) .. dir
+ .. "PK\5\6" .. le16(0) .. le16(0) .. le16(#entries) .. le16(#entries)
+ .. le32(#dir) .. le32(offset) .. le16(0)
+end
+
+return SkinZip
diff --git a/src/core/Sound.lua b/src/core/Sound.lua
index 7093a32f5..a7a31b204 100644
--- a/src/core/Sound.lua
+++ b/src/core/Sound.lua
@@ -52,6 +52,7 @@ local pikaScale = 1
-- Sound.resume drops the cache so sources built against the OLD session
-- are re-rendered the next time they are asked for.
local sessionSuspended = false
+local gen2SfxMono = false
-- See sessionSuspended: logged at debug rather than swallowed, so a raise
-- from one of these on desktop (where there is no session to lose) still
@@ -264,20 +265,40 @@ end
-- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it
-- Resolve a cartridge effect by its extracted native name, including effects
-- that have no shared engine role in audio.sfx.
-function Sound.playNamedEffect(data,name)
- for id,song in pairs(data and data.audio and data.audio.songs or {}) do
- if type(song)=="table" and song.sfxName==name and tonumber(song.index) then
- local src=playPath(data,id,{song=id,m4a=tonumber(song.index)})
- if src then played("sfx",name) end
- return src
+-- Native numeric/symbolic sequence aliases must resolve unambiguously.
+function Sound.sequenceKey(data, name)
+ if type(name) ~= "string" and type(name) ~= "number" then return nil end
+ local songs = data and data.audio and data.audio.songs or {}
+ if (type(name) == "string" or type(name) == "number") and (type(songs[name]) == "string" or type(songs[name]) == "table") then
+ return name, songs[name]
+ end
+ local key, found
+ for id, song in pairs(songs) do
+ if type(song) == "table" and (song.sfxName == name or song.musName == name
+ or type(name) == "number" and song.index == name) then
+ if found and found ~= song then return nil, "ambiguous native audio id" end
+ key, found = id, song
end
end
+ return key, found
end
-function Sound.play(data, name)
- local sfx = data.audio and data.audio.sfx
- local def = sfx and sfx[name]
- local src = playPath(data, name, def)
+function Sound.playNamedEffect(data, name)
+ local id, song = Sound.sequenceKey(data, name)
+ if not id then return nil end
+ local def = song
+ if type(song) == "table" and tonumber(song.index) and not song.file then
+ def = { song = id, m4a = tonumber(song.index) }
+ end
+ local src = playPath(data, id, def)
+ if src then played("sfx", name) end
+ return src
+end
+
+local gen2Sfx=require('src.audio.Gen2Sfx').new(function(src)
+ safeAudio('Source:stop',src.stop,src)
+end)
+local function finishSfx(data,name,def,src)
if not src then return end
if ducks(data, name, def) then
require("src.core.Music").duckForFanfare(src)
@@ -286,6 +307,25 @@ function Sound.play(data, name)
return src
end
+function Sound.play(data,name)
+ if sessionSuspended or not love.audio then return end
+ local sfx=data.audio and data.audio.sfx
+ local def=sfx and sfx[name]
+ local src=gen2Sfx:play(data,name,def,false,false,function()return playPath(data,name,def)end)
+ return finishSfx(data,name,def,src)
+end
+
+-- GSC anim_sound bypasses normal PlaySFX priority without changing wCurSFX.
+-- Commit Source ownership before public sound.played observers can reenter.
+function Sound.playStereo(data,name)
+ if sessionSuspended or not love.audio then return end
+ local sfx=data.audio and data.audio.sfx
+ local def=sfx and sfx[name]
+ local src=gen2Sfx:play(data,name,def,true,gen2SfxMono,function()return playPath(data,name,def)end)
+ return finishSfx(data,name,def,src)
+end
+function Sound.sfxChannelsOff()gen2Sfx:clear()end
+
-- PLAY A CARTRIDGE SOUND BY ITS NUMBER.
--
-- Every other caller here names a ROLE -- "Ball_Toss", "Press_AB" -- because
@@ -781,6 +821,7 @@ end
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
-- variants included) or all of them, so the next play re-resolves the def
function Sound.invalidate(name)
+ gen2Sfx:forget(name)
local function evict(store, key)
local src = store[key]
if src then pcall(src.stop, src) end
@@ -813,6 +854,7 @@ Assets.register(Sound.invalidate)
function Sound.suspend()
if sessionSuspended then return end
sessionSuspended = true
+ gen2Sfx:reset()
-- A LOOPING source (the low-health siren, the link tournament theme) is the
-- only kind that is still sounding when the call arrives. Ask it to stop
-- while the device may still take the call, and forget it, so its owner's
@@ -840,6 +882,7 @@ end
-- re-apply persisted audio options (Game calls this on boot and after
-- loading a save)
function Sound.applyOptions(opts)
+ gen2SfxMono = opts and opts.stereo == false or false
Sound.setVolumeLevel(opts and opts.sfxVol or 7)
Sound.setPikaVolumeLevel(opts and opts.pikaVol or 7)
end
diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua
index f9917f278..c4c089316 100644
--- a/src/core/TouchControls.lua
+++ b/src/core/TouchControls.lua
@@ -30,6 +30,8 @@ local Input = require("src.core.Input")
local SafeArea = require("src.core.SafeArea")
local GameVersion = require("src.core.GameVersion")
+local TouchSkin = require("src.core.TouchSkin")
+
local TouchControls = {}
-- idle vs pressed overlay opacity
@@ -112,6 +114,52 @@ local function clampScale(v)
return v
end
+-- Haptic controls adapted from Gen1Recomp 535a5ec0.
+-- Copyright (C) 2026 BOIS CLUB GAMES, LLC; licenses/GEN1RECOMP-TOUCH-SKINS.md.
+TouchControls.HAPTICS = { "off", "light", "normal", "strong" }
+TouchControls.HAPTIC_DEFAULT = "light"
+
+local HAPTIC_SECONDS = {
+ off = 0, light = 0.012, normal = 0.025, strong = 0.045,
+}
+local HAPTIC_LABELS = {
+ off = "OFF", light = "LIGHT", normal = "NORMAL", strong = "STRONG",
+}
+
+function TouchControls.normalizeHaptics(level)
+ if level == "physical" then return "light" end
+ if level == "medium" then return "normal" end
+ if level == "heavy" then return "strong" end
+ if HAPTIC_SECONDS[level] then return level end
+ return TouchControls.HAPTIC_DEFAULT
+end
+
+function TouchControls.hapticLabel(level)
+ return HAPTIC_LABELS[TouchControls.normalizeHaptics(level)]
+end
+
+function TouchControls.cycleHaptics(level, dir)
+ local cur, idx = TouchControls.normalizeHaptics(level), 1
+ for i, m in ipairs(TouchControls.HAPTICS) do
+ if m == cur then idx = i break end
+ end
+ local n = #TouchControls.HAPTICS
+ return TouchControls.HAPTICS[(idx - 1 + (dir or 1)) % n + 1]
+end
+
+-- One pulse at the given level. Feature-guarded rather than platform-gated:
+-- love.system.vibrate is a no-op on desktop and absent from the headless love
+-- stubs, so the press path below stays identical everywhere and the tests
+-- never reach a vibrator.
+function TouchControls.buzz(level)
+ local secs = HAPTIC_SECONDS[TouchControls.normalizeHaptics(level)]
+ if not secs or secs <= 0 then return false end
+ if not (love and love.system and love.system.vibrate) then return false end
+ pcall(love.system.vibrate, secs)
+ return true
+end
+
+
-- Copy a persisted positions table, dropping unknown / non-numeric entries.
-- Always a fresh table: two orientations seeded from the same pre-#633
-- layout must not alias, or dragging one would still move the other.
@@ -152,6 +200,7 @@ function TouchControls.normalizeConfig(tc)
-- no caller ever has to nil-check a bucket's scale
if type(tc) ~= "table" then tc = {} end
if tc.enabled == false then out.enabled = false end
+ if type(tc.skin) == "string" and tc.skin ~= "" then out.skin = tc.skin end
local saved = type(tc.layouts) == "table" and tc.layouts or nil
for _, o in ipairs(ORIENTATIONS) do
local b = saved and saved[o]
@@ -226,6 +275,11 @@ end
function TouchControls:init()
self.active = wantsOverlay()
+ TouchSkin.setOverlayLive(self.active)
+ TouchSkin.setActive(nil)
+ self.skinId, self.skinError = nil, nil
+ self.hotkeysHeld, self.keysHeld = {}, {}
+ self.haptics = TouchControls.HAPTIC_DEFAULT
self.enabled = true
-- per-orientation buckets (#633); self.positions / self.scale mirror the
-- one currently on screen so layout(), the editor and the tests keep a
@@ -267,6 +321,9 @@ end
function TouchControls:applyOptions(opts)
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
self.enabled = cfg.enabled
+ self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
+ TouchSkin.setOverlayLive(self.active)
+ self:selectSkin(cfg.enabled and cfg.skin or nil)
self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
@@ -282,7 +339,7 @@ end
-- Snapshot for the editor's save path: enabled plus both orientation
-- buckets, matching what options.lua stores (#633).
function TouchControls:config()
- local out = { enabled = self.enabled ~= false, layouts = {} }
+ local out = { enabled = self.enabled ~= false, skin = self.skinId, layouts = {} }
for _, o in ipairs(ORIENTATIONS) do
local b = self.layouts and self.layouts[o] or nil
out.layouts[o] = {
@@ -305,11 +362,59 @@ function TouchControls:setPreview(on)
end
function TouchControls:visible()
- if self.preview then return self.img ~= nil end
- return self.active and self.enabled ~= false and self.img ~= nil
- and not self.controllerHidden
+ local art = TouchSkin.active ~= nil or self.img ~= nil
+ if self.preview then return art end
+ if self.enabled == false or not art then return false end
+ -- A selected skin is also a desktop/TV bezel. Input remains gated in
+ -- touchpressed, but the artwork must not disappear when a controller is
+ -- connected or the platform is not touch-first.
+ if TouchSkin.active then return true end
+ return self.active and not self.controllerHidden
+end
+
+local function surfaceRect()
+ local r = TouchSkin.surfaceRect
+ if r then return r.w, r.h, r.x, r.y end
+ if love and love.graphics and love.graphics.getDimensions then
+ local w, h = love.graphics.getDimensions()
+ return w, h, 0, 0
+ end
+ return 0, 0, 0, 0
+end
+
+TouchControls.surfaceRect = surfaceRect
+
+function TouchControls:selectSkin(id)
+ if id == self.skinId and TouchSkin.active then return TouchSkin.active end
+ self:reset()
+ self.skinError = nil
+ if not id or id == "" then
+ self.skinId = nil
+ TouchSkin.setActive(nil)
+ return nil
+ end
+ local skin, err = TouchSkin.select(id)
+ if not skin then
+ self.skinId = nil
+ self.skinError = err
+ TouchSkin.setActive(nil)
+ return nil, err
+ end
+ self.skinId = id
+ return skin
+end
+
+function TouchControls:skin()
+ if not self:visible() then return nil end
+ return TouchSkin.active
end
+function TouchControls:setHotkeyHandler(fn)
+ self.hotkeyHandler = type(fn) == "function" and fn or nil
+end
+
+local skinHitSet, applySkinSet
+
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
local function clampZone(zone, x0, y0, x1, y1)
local half = zone.w * 0.5
@@ -321,7 +426,7 @@ end
-- demand. Mirrors it into self.orientation / self.positions / self.scale,
-- which layout(), the editor chrome and the tests read.
function TouchControls:currentBucket()
- local _, _, sw, sh = SafeArea.rect()
+ local _, _, sw, sh = (SafeArea.windowRect or SafeArea.rect)()
local o = orientationFor(sw, sh)
self.layouts = self.layouts or { portrait = {}, landscape = {} }
local b = self.layouts[o]
@@ -347,7 +452,7 @@ end
-- while sizes stay derived from the short edge, times the orientation's
-- size setting (#633).
function TouchControls:layout()
- local ox, oy, sw, sh = SafeArea.rect()
+ local ox, oy, sw, sh = (SafeArea.windowRect or SafeArea.rect)()
if self.layoutW == sw and self.layoutH == sh
and self.layoutOx == ox and self.layoutOy == oy
and self.layoutVersion == GameVersion.get() and self.L then
@@ -383,7 +488,7 @@ end
-- Move one control to a screen-space point and persist its normalized
-- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy)
- local ox, oy, sw, sh = SafeArea.rect()
+ local ox, oy, sw, sh = (SafeArea.windowRect or SafeArea.rect)()
local L = self:layout()
local zone = L[name]
if not zone then return end
@@ -437,6 +542,17 @@ end
-- Which control (if any) contains (x, y). Prefer face buttons over the
-- d-pad when they overlap, matching touchpressed's order.
function TouchControls:hitTest(x, y)
+ local page = TouchSkin.page()
+ if page then
+ local ww, wh, ox, oy = surfaceRect()
+ for i = #page.controls, 1, -1 do
+ local ctl = page.controls[i]
+ if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then
+ return ctl.spec, ctl
+ end
+ end
+ return nil
+ end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if buttonAvailable(self,btn) and L[btn] and inCircle(L[btn], x, y, SLOP[btn]) then return btn end
@@ -462,7 +578,10 @@ end
local function pressBtn(self, btn)
local n = (self.held[btn] or 0) + 1
self.held[btn] = n
- if n == 1 then Input:overlayPressed(btn) end
+ if n == 1 then
+ Input:overlayPressed(btn)
+ TouchControls.buzz(self.haptics)
+ end
end
local function releaseBtn(self, btn)
@@ -484,16 +603,114 @@ local function setDpad(self, touch, dir)
if dir then pressBtn(self, dir) end
end
+-- Custom-skin control ownership adapted from Gen1Recomp 535a5ec0.
+-- Copyright (C) 2026 BOIS CLUB GAMES, LLC; licenses/GEN1RECOMP-TOUCH-SKINS.md.
+local function pressKey(self, key, down)
+ self.keysHeld = self.keysHeld or {}
+ local before = self.keysHeld[key] or 0
+ local count = math.max(0, before + (down and 1 or -1))
+ self.keysHeld[key] = count > 0 and count or nil
+ if down and before > 0 or not down and (before == 0 or count > 0) then return end
+ local fn = down and love.keypressed or love.keyreleased
+ if type(fn) == "function" then pcall(fn, key, key, false) end
+end
+
+local function fireHotkey(self, action, pressed, ctl)
+ if action == "overlay_next" then
+ if pressed then TouchSkin.nextPage(ctl and ctl.nextTarget) end
+ return pressed
+ end
+ if action == "overlay_prev" then
+ if pressed then TouchSkin.setPage(TouchSkin.pageIndex - 1) end
+ return pressed
+ end
+ self.hotkeysHeld = self.hotkeysHeld or {}
+ local n = (self.hotkeysHeld[action] or 0) + (pressed and 1 or -1)
+ if n < 0 then n = 0 end
+ self.hotkeysHeld[action] = n > 0 and n or nil
+ local edge = (pressed and n == 1) or (not pressed and n == 0)
+ if edge and self.hotkeyHandler then
+ pcall(self.hotkeyHandler, action, pressed)
+ end
+ return false
+end
+
+local function enterControl(self, ctl)
+ local freshButton, freshAction = false, false
+ for _, btn in ipairs(ctl.buttons) do if not self.held[btn] then freshButton = true end end
+ for _, key in ipairs(ctl.keys) do if not (self.keysHeld and self.keysHeld[key]) then freshAction = true end end
+ for _, action in ipairs(ctl.hotkeys) do if not (self.hotkeysHeld and self.hotkeysHeld[action]) then freshAction = true end end
+ for _, btn in ipairs(ctl.buttons) do
+ pressBtn(self, btn)
+ end
+ for _, key in ipairs(ctl.keys) do pressKey(self, key, true) end
+ local switched = false
+ for _, action in ipairs(ctl.hotkeys) do
+ if fireHotkey(self, action, true, ctl) then switched = true end
+ end
+ if not freshButton and freshAction then TouchControls.buzz(self.haptics) end
+ return switched
+end
+
+local function exitControl(self, ctl)
+ for _, btn in ipairs(ctl.buttons) do releaseBtn(self, btn) end
+ for _, key in ipairs(ctl.keys) do pressKey(self, key, false) end
+ for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
+end
+
+function skinHitSet(self, x, y, prev)
+ local page = TouchSkin.page()
+ if not page then return nil end
+ local ww, wh, ox, oy = surfaceRect()
+ local set = nil
+ for _, ctl in ipairs(page.controls) do
+ local held = (prev and prev[ctl]) == true
+ if not ctl.decorative
+ and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy, held) then
+ set = set or {}
+ set[ctl] = true
+ end
+ end
+ return set
+end
+
+function applySkinSet(self, touch, set)
+ local prev = touch.set or {}
+ local switched = false
+ for ctl in pairs(prev) do
+ if not (set and set[ctl]) then exitControl(self, ctl) end
+ end
+ for ctl in pairs(set or {}) do
+ if not prev[ctl] then
+ if enterControl(self, ctl) then switched = true end
+ end
+ end
+ touch.set = set
+ if switched then
+ for ctl in pairs(touch.set or {}) do exitControl(self, ctl) end
+ touch.set = nil
+ end
+end
+
+
function TouchControls:touchpressed(id, x, y)
-- preview mode is layout-edit only: never press GB buttons
if self.preview then return end
- if not (self.active and self.enabled ~= false and self.img) then return end
+ if not (self.active and self.enabled ~= false and (TouchSkin.active or self.img)) then return end
-- A reused native pointer id must release its previous hold first.
if self.touches[id] then self:touchreleased(id,x,y) end
-- A controller hid the overlay; restore it and accept this same press.
if self.controllerHidden then
self.controllerHidden = false
end
+ if TouchSkin.active then
+ local set = skinHitSet(self, x, y, nil)
+ if not set then return end
+ local touch = { control = "skin" }
+ self.touches[id] = touch
+ applySkinSet(self, touch, set)
+ return true
+ end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if buttonAvailable(self,btn) and L[btn] and inCircle(L[btn], x, y, SLOP[btn]) then
@@ -528,6 +745,10 @@ function TouchControls:touchmoved(id, x, y)
if self.preview then return end
local touch = self.touches[id]
if not touch then return end
+ if touch.control == "skin" then
+ applySkinSet(self, touch, skinHitSet(self, x, y, touch.set))
+ return
+ end
-- Gen4 free camera look
if touch.control == "cameralook" and self.cameraLookStart then
@@ -560,7 +781,9 @@ function TouchControls:touchreleased(id, x, y)
if not touch then return end
self.touches[id] = nil
- if touch.control == "cameralook" then
+ if touch.control == "skin" then
+ applySkinSet(self, touch, nil)
+ elseif touch.control == "cameralook" then
self.cameraLookTouch = nil
self.cameraLookStart = nil
elseif touch.control == "dpad" then
@@ -576,6 +799,17 @@ end
-- touchreleased and would strand its button held forever. Called from
-- Game alongside Input:reset() on focus/visibility loss.
function TouchControls:reset()
+ for _, touch in pairs(self.touches or {}) do
+ if touch.control == "skin" then applySkinSet(self, touch, nil) end
+ end
+ for key in pairs(self.keysHeld or {}) do
+ self.keysHeld[key] = 1
+ pressKey(self, key, false)
+ end
+ for action in pairs(self.hotkeysHeld or {}) do
+ if self.hotkeyHandler then pcall(self.hotkeyHandler, action, false) end
+ end
+ self.keysHeld, self.hotkeysHeld = {}, {}
for btn in pairs(self.held or {}) do
Input:overlayReleased(btn)
end
@@ -637,8 +871,53 @@ end
-- Screen-space, called by Game:draw after Renderer:endFrame so the
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
-- Also used by the launcher layout editor under preview mode.
+local function drawOverlayImage(img, x, y, w, h, alpha)
+ if not img or alpha <= 0 then return end
+ local sx, sy = TouchSkin.imageFit(img:getWidth(), img:getHeight(), w, h)
+ if not sx then return end
+ love.graphics.setColor(1, 1, 1, math.min(1, alpha))
+ love.graphics.draw(img, x, y, 0, sx, sy)
+end
+
+function TouchControls:drawSkin(alphaMul)
+ local page = TouchSkin.page()
+ if not page then return false end
+ local ww, wh, sox, soy = surfaceRect()
+ local bx, by, bw, bh = TouchSkin.pageBox(page, ww, wh, sox, soy)
+ local opacity = (self.skinOpacity or 1) * alphaMul
+
+ love.graphics.push("all")
+ love.graphics.origin()
+ drawOverlayImage(page.image, bx, by, bw, bh, opacity)
+
+ local pressed = {}
+ for _, touch in pairs(self.touches or {}) do
+ for ctl in pairs(touch.set or {}) do pressed[ctl] = true end
+ end
+
+ for _, ctl in ipairs(page.controls) do
+ local down = pressed[ctl] == true
+ local img = (down and ctl.pressedImage) or ctl.image
+ if img then
+ local cx, cy, halfW, halfH =
+ TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
+ local alpha = opacity
+ if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
+ drawOverlayImage(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
+ end
+ end
+
+ love.graphics.pop()
+ return true
+end
+
+
function TouchControls:draw()
if not self:visible() then return end
+ if TouchSkin.active then
+ local mul = (self.preview and self.enabled == false) and 0.45 or 1
+ if self:drawSkin(mul) then return end
+ end
local L = self:layout()
-- when the player disabled the overlay but the editor is previewing,
-- draw dimmed so the layout is still editable
diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua
new file mode 100644
index 000000000..cbaa824d4
--- /dev/null
+++ b/src/core/TouchSkin.lua
@@ -0,0 +1,1530 @@
+-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91.
+-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 with Section 7 terms;
+-- see licenses/GEN1RECOMP-TOUCH-SKINS.md. Native integration by Roxas2712.
+local TouchSkin = {}
+
+TouchSkin.BUNDLED_ROOT = "assets/skins"
+TouchSkin.USER_ROOT = "skins"
+TouchSkin.EXPORT_ROOT = "skins/_export"
+
+TouchSkin.GB_BUTTONS = {
+ a = "a", b = "b", start = "start", select = "select",
+ up = "up", down = "down", left = "left", right = "right",
+ l = "l", r = "r",
+ l1 = "l", r1 = "r",
+ l2 = "l", r2 = "r",
+ trigger_l = "l", trigger_r = "r",
+ shoulder_l = "l", shoulder_r = "r",
+}
+
+TouchSkin.HOTKEYS = {
+ overlay_next = "overlay_next",
+ overlay_previous = "overlay_prev",
+ menu_toggle = "menu",
+ reset = "soft_reset",
+ hold_fast_forward = "fast_forward_hold",
+ fast_forward = "fast_forward_hold",
+ toggle_fast_forward = "fast_forward_toggle",
+ screenshot = "screenshot",
+ pause_toggle = "pause",
+ exit_emulator = "quit",
+}
+
+local DEFAULT_ASPECT = 16 / 9
+local PORTRAIT_ASPECT = 9 / 16
+
+local function trim(s)
+ return (tostring(s or ""):match("^%s*(.-)%s*$"))
+end
+
+local function unquote(s)
+ s = trim(s)
+ local quoted = s:match('^"([^"]*)"')
+ if quoted then return quoted end
+ s = s:gsub("%s*//.*$", ""):gsub("%s*#.*$", "")
+ s = trim(s)
+ local inner = s:match('^"(.*)"$')
+ return inner or s
+end
+
+local function toBool(v)
+ v = trim(v):lower()
+ return v == "true" or v == "1"
+end
+
+local function tokens(s)
+ local out = {}
+ for tok in tostring(s or ""):gmatch("[^,%s]+") do out[#out + 1] = tok end
+ return out
+end
+
+local function num(v, fallback)
+ local n = tonumber(trim(v))
+ if not n or n ~= n then return fallback end
+ return n
+end
+
+function TouchSkin.parseConfig(text)
+ local kv = {}
+ for line in tostring(text or ""):gmatch("[^\r\n]+") do
+ if not line:match("^%s*#") then
+ local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$")
+ if key then kv[key] = unquote(value) end
+ end
+ end
+ return kv
+end
+
+local function parseBinds(spec)
+ local buttons, hotkeys, keys, decorative = {}, {}, {}, true
+ for raw in tostring(spec or ""):gmatch("[^|]+") do
+ local name = trim(raw):lower()
+ local key = name:match("^key:(.+)$") or name:match("^retrok_(.+)$")
+ if name == "nul" or name == "" then
+ -- decoration
+ elseif key then
+ keys[#keys + 1] = key
+ decorative = false
+ elseif TouchSkin.GB_BUTTONS[name] then
+ buttons[#buttons + 1] = TouchSkin.GB_BUTTONS[name]
+ decorative = false
+ elseif TouchSkin.HOTKEYS[name] then
+ hotkeys[#hotkeys + 1] = TouchSkin.HOTKEYS[name]
+ decorative = false
+ end
+ end
+ return buttons, hotkeys, keys, decorative
+end
+
+TouchSkin.AREA_DEFAULTS = {
+ dpad_area = { up = "up", down = "down", left = "left", right = "right" },
+ abxy_area = { up = "x", down = "b", left = "y", right = "a" },
+ analog_left = { up = "up", down = "down", left = "left", right = "right" },
+ analog_right = { up = "up", down = "down", left = "left", right = "right" },
+}
+
+local DIRECTIONAL_CELLS = {
+ { col = 1, row = 1, h = "left", v = "up" },
+ { col = 2, row = 1, v = "up" },
+ { col = 3, row = 1, h = "right", v = "up" },
+ { col = 1, row = 2, h = "left" },
+ { col = 3, row = 2, h = "right" },
+ { col = 1, row = 3, h = "left", v = "down" },
+ { col = 2, row = 3, v = "down" },
+ { col = 3, row = 3, h = "right", v = "down" },
+}
+
+local function outwardReach(reach)
+ return 1 + 3 * ((num(reach, 1)) - 1)
+end
+
+function TouchSkin.expandDirectional(base, names)
+ names = names or {}
+ local cellX = math.abs(num(base.rangeX, 0.05)) / 3
+ local cellY = math.abs(num(base.rangeY, 0.05)) / 3
+ local out = {}
+ for _, cell in ipairs(DIRECTIONAL_CELLS) do
+ local parts = {}
+ if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end
+ if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end
+ local spec = #parts > 0 and table.concat(parts, "|") or "nul"
+ local ctl = TouchSkin.newControl(spec,
+ num(base.x, 0.5) + (cell.col - 2) * cellX * 2,
+ num(base.y, 0.5) + (cell.row - 2) * cellY * 2,
+ cellX * 2, cellY * 2, "rect")
+ ctl.rangeMod = num(base.rangeMod, 1)
+ ctl.alphaMod = num(base.alphaMod, 1)
+ ctl.reachLeft = cell.col == 1 and outwardReach(base.reachLeft) or 1
+ ctl.reachRight = cell.col == 3 and outwardReach(base.reachRight) or 1
+ ctl.reachUp = cell.row == 1 and outwardReach(base.reachUp) or 1
+ ctl.reachDown = cell.row == 3 and outwardReach(base.reachDown) or 1
+ ctl.pixelCoords = base.pixelCoords
+ ctl.movable = base.movable
+ ctl.exclusive = base.exclusive
+ out[#out + 1] = ctl
+ end
+ return out
+end
+
+local SECTOR_CELLS = {
+ { h = "right" },
+ { h = "right", v = "down" },
+ { v = "down" },
+ { h = "left", v = "down" },
+ { h = "left" },
+ { h = "left", v = "up" },
+ { v = "up" },
+ { h = "right", v = "up" },
+}
+
+TouchSkin.SECTOR_SPAN = math.pi / 4
+
+function TouchSkin.sectorHit(sector, dx, dy)
+ local span = TouchSkin.SECTOR_SPAN
+ local start = (sector - 1) * span - span * 0.5
+ local a = (math.atan2(dy, dx) - start) % (math.pi * 2)
+ return a < span
+end
+
+function TouchSkin.expandSectors(base, names)
+ names = names or {}
+ local out = {}
+ for i, cell in ipairs(SECTOR_CELLS) do
+ local parts = {}
+ if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end
+ if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end
+ local spec = #parts > 0 and table.concat(parts, "|") or "nul"
+ local ctl = TouchSkin.newControl(spec, num(base.x, 0.5), num(base.y, 0.5),
+ math.abs(num(base.rangeX, 0.05)) * 2, math.abs(num(base.rangeY, 0.05)) * 2,
+ base.shape)
+ ctl.sector = i
+ ctl.areaKind = base.areaKind
+ ctl.areaNames = base.areaNames
+ ctl.rangeMod = num(base.rangeMod, 1)
+ ctl.alphaMod = num(base.alphaMod, 1)
+ ctl.reachLeft = num(base.reachLeft, 1)
+ ctl.reachRight = num(base.reachRight, 1)
+ ctl.reachUp = num(base.reachUp, 1)
+ ctl.reachDown = num(base.reachDown, 1)
+ ctl.pixelCoords = base.pixelCoords
+ ctl.movable = base.movable
+ ctl.exclusive = base.exclusive
+ out[#out + 1] = ctl
+ end
+ return out
+end
+
+local function areaSide(kv, prefix, side, fallback)
+ local v = kv[prefix .. "_" .. side]
+ if v == nil or trim(v) == "" then return fallback end
+ return trim(v)
+end
+
+local function parseDesc(kv, prefix, page)
+ local spec = kv[prefix]
+ if not spec then return nil end
+ local t = tokens(spec)
+ if #t < 6 then return nil end
+
+ local shape = trim(t[4]):lower()
+ if shape ~= "radial" and shape ~= "rect" then shape = "rect" end
+
+ local buttons, hotkeys, keys, decorative = parseBinds(t[1])
+ local reachX = num(kv[prefix .. "_reach_x"], 1)
+ local reachY = num(kv[prefix .. "_reach_y"], 1)
+
+ local ctl = {
+ spec = t[1],
+ buttons = buttons,
+ hotkeys = hotkeys,
+ keys = keys,
+ decorative = decorative,
+ x = num(t[2], 0.5),
+ y = num(t[3], 0.5),
+ shape = shape,
+ rangeX = math.abs(num(t[5], 0.05)),
+ rangeY = math.abs(num(t[6], 0.05)),
+ rangeMod = num(kv[prefix .. "_range_mod"], page.rangeMod),
+ alphaMod = num(kv[prefix .. "_alpha_mod"], page.alphaMod),
+ reachUp = num(kv[prefix .. "_reach_up"], reachY),
+ reachDown = num(kv[prefix .. "_reach_down"], reachY),
+ reachLeft = num(kv[prefix .. "_reach_left"], reachX),
+ reachRight = num(kv[prefix .. "_reach_right"], reachX),
+ imagePath = kv[prefix .. "_overlay"],
+ pressedImagePath = kv[prefix .. "_overlay_pressed"],
+ nextTarget = kv[prefix .. "_next_target"],
+ movable = toBool(kv[prefix .. "_movable"]) or nil,
+ exclusive = (toBool(kv[prefix .. "_exclusive"])
+ or toBool(kv[prefix .. "_range_mod_exclusive"])) or nil,
+ saturatePct = num(kv[prefix .. "_saturate_pct"], nil),
+ }
+ if ctl.imagePath == "" then ctl.imagePath = nil end
+ if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end
+
+ local normalized = kv[prefix .. "_normalized"]
+ if normalized ~= nil then ctl.pixelCoords = not toBool(normalized) end
+
+ local areaKind = trim(t[1]):lower()
+ local defaults = TouchSkin.AREA_DEFAULTS[areaKind]
+ if defaults then
+ ctl.areaKind = areaKind
+ ctl.areaNames = {
+ up = areaSide(kv, prefix, "up", defaults.up),
+ down = areaSide(kv, prefix, "down", defaults.down),
+ left = areaSide(kv, prefix, "left", defaults.left),
+ right = areaSide(kv, prefix, "right", defaults.right),
+ }
+ end
+ return ctl
+end
+
+function TouchSkin.parse(text)
+ local kv = TouchSkin.parseConfig(text)
+ local count = math.floor(num(kv.overlays, 0))
+ if count <= 0 then return nil, "no overlays" end
+
+ local pages, warnings = {}, {}
+ local function warn(text)
+ for _, existing in ipairs(warnings) do
+ if existing == text then return end
+ end
+ warnings[#warnings + 1] = text
+ end
+
+ for i = 0, count - 1 do
+ local p = "overlay" .. i
+ local page = {
+ index = i + 1,
+ name = kv[p .. "_name"] or ("overlay" .. i),
+ imagePath = kv[p .. "_overlay"],
+ fullScreen = toBool(kv[p .. "_full_screen"]),
+ normalized = toBool(kv[p .. "_normalized"]),
+ rangeMod = num(kv[p .. "_range_mod"], 1),
+ alphaMod = num(kv[p .. "_alpha_mod"], 1),
+ aspect = num(kv[p .. "_aspect_ratio"], nil),
+ controls = {},
+ }
+ if page.imagePath == "" then page.imagePath = nil end
+ -- An explicit aspect_ratio is the overlay's design aspect. RetroArch
+ -- letterboxes to it even when full_screen is set, so range_x/range_y
+ -- that were authored as a circle stay a circle. #1503
+ page.aspectFromCfg = page.aspect ~= nil and page.aspect > 0
+ if not page.aspectFromCfg then
+ page.aspect = page.name:lower():find("portrait", 1, true)
+ and PORTRAIT_ASPECT or DEFAULT_ASPECT
+ end
+
+ local rect = tokens(kv[p .. "_rect"])
+ page.rect = { x = 0, y = 0, w = 1, h = 1 }
+ if #rect >= 4 then
+ page.rect = { x = num(rect[1], 0), y = num(rect[2], 0),
+ w = num(rect[3], 1), h = num(rect[4], 1) }
+ end
+
+ local vp = tokens(kv[p .. "_viewport"])
+ if #vp >= 4 then
+ page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
+ w = num(vp[3], 1), h = num(vp[4], 1) }
+ page.viewportFill = toBool(kv[p .. "_viewport_fill"])
+ end
+
+ page.pixelCoords = not page.normalized
+ if page.pixelCoords and not page.imagePath then
+ page.pixelCoords = false
+ warn(page.name .. " has no base image: desc coordinates read as normalized")
+ end
+
+ local descs = math.floor(num(kv[p .. "_descs"], 0))
+ for d = 0, descs - 1 do
+ local ctl = parseDesc(kv, p .. "_desc" .. d, page)
+ if not ctl then
+ warn(page.name .. " is missing desc " .. d)
+ elseif ctl.areaKind then
+ if ctl.imagePath or ctl.pressedImagePath then
+ local art = TouchSkin.newControl("nul", ctl.x, ctl.y,
+ ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape)
+ art.imagePath = ctl.imagePath
+ art.pressedImagePath = ctl.pressedImagePath
+ art.rangeMod, art.alphaMod = ctl.rangeMod, ctl.alphaMod
+ art.pixelCoords = ctl.pixelCoords
+ art.movable, art.exclusive = ctl.movable, ctl.exclusive
+ page.controls[#page.controls + 1] = art
+ end
+ for _, cell in ipairs(TouchSkin.expandSectors(ctl, ctl.areaNames)) do
+ page.controls[#page.controls + 1] = cell
+ end
+ else
+ page.controls[#page.controls + 1] = ctl
+ end
+ end
+ pages[#pages + 1] = page
+ end
+
+ -- RetroArch auto-rotate: overlay names containing portrait / landscape
+ -- are the lock. Stamp it so the studio does not need a second click. #1503
+ for _, page in ipairs(pages) do
+ if not page.orient then page.orient = TouchSkin.pageOrient(page) end
+ end
+
+ return { pages = pages, warnings = warnings }
+end
+
+local function readFile(path)
+ if love and love.filesystem and love.filesystem.read then
+ local ok, data = pcall(love.filesystem.read, path)
+ if ok and data then return data end
+ end
+ local ok, handle = pcall(io.open, path, "rb")
+ if not ok or not handle then return nil end
+ local read, bytes = pcall(handle.read, handle, "*a")
+ pcall(handle.close, handle)
+ return read and bytes or nil
+end
+
+local function listDir(path)
+ if love and love.filesystem and love.filesystem.getDirectoryItems then
+ local ok, items = pcall(love.filesystem.getDirectoryItems, path)
+ if ok and items then return items end
+ end
+ return {}
+end
+
+local function wfs()
+ return require("src.core.SaveData").persistenceFs(love.filesystem)
+end
+
+local function isDir(path)
+ if love and love.filesystem and love.filesystem.getInfo then
+ local ok, info = pcall(love.filesystem.getInfo, path)
+ if ok and info then return info.type == "directory" end
+ end
+ return false
+end
+
+local function joinPath(root, rel)
+ rel = tostring(rel or ""):gsub("^%./", ""):gsub("\\", "/")
+ if rel:sub(1, 1) == "/" then return rel:sub(2) end
+ return root .. "/" .. rel
+end
+
+TouchSkin.NATIVE_NAME = "skin.lua"
+
+TouchSkin.readFile = readFile
+TouchSkin.listDir = listDir
+TouchSkin.isDir = isDir
+
+local function findConfig(root)
+ if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
+ return root .. "/" .. TouchSkin.NATIVE_NAME, "native", ""
+ end
+ local named = { "overlay.cfg", "skin.cfg", "layout.cfg" }
+ for _, name in ipairs(named) do
+ if readFile(root .. "/" .. name) then
+ return root .. "/" .. name, "retroarch", ""
+ end
+ end
+ local infoPath, prefix = require("src.core.DeltaSkin").findInfo(root)
+ if infoPath then return infoPath, "delta", prefix end
+ local items = listDir(root)
+ table.sort(items)
+ for _, name in ipairs(items) do
+ if name:match("%.cfg$") then return root .. "/" .. name, "retroarch", "" end
+ end
+ return nil
+end
+
+local function loadDataChunk(text, name)
+ if loadstring then
+ local chunk, err = loadstring(text, name)
+ if not chunk then return nil, err end
+ return setfenv(chunk, {})
+ end
+ return load(text, name, "t", {})
+end
+
+function TouchSkin.parseNative(text)
+ local chunk, err = loadDataChunk(tostring(text or ""), "skin")
+ if not chunk then return nil, "skin.lua does not parse: " .. tostring(err) end
+ local ok, data = pcall(chunk)
+ if not ok or type(data) ~= "table" then return nil, "skin.lua returned no table" end
+ if type(data.pages) ~= "table" or not data.pages[1] then
+ return nil, "skin.lua has no pages"
+ end
+
+ local pages = {}
+ for i, raw in ipairs(data.pages) do
+ if type(raw) ~= "table" then return nil, "page " .. i .. " is not a table" end
+ local page = {
+ index = i,
+ name = tostring(raw.name or ("page" .. i)),
+ imagePath = raw.image,
+ fullScreen = raw.fullScreen ~= false,
+ normalized = true,
+ pixelCoords = false,
+ rangeMod = num(raw.rangeMod, 1),
+ alphaMod = num(raw.alphaMod, 1),
+ aspect = num(raw.aspect, DEFAULT_ASPECT),
+ aspectFromCfg = raw.fitAspect == true,
+ orient = (raw.orient == "portrait" or raw.orient == "landscape"
+ or raw.orient == "any") and raw.orient or nil,
+ screenFit = raw.screenFit == "remainder" and "remainder" or nil,
+ anchor = (raw.anchor == "top" or raw.anchor == "bottom"
+ or raw.anchor == "left" or raw.anchor == "right")
+ and raw.anchor or nil,
+ rect = { x = 0, y = 0, w = 1, h = 1 },
+ controls = {},
+ }
+ if type(raw.rect) == "table" then
+ page.rect = { x = num(raw.rect.x, 0), y = num(raw.rect.y, 0),
+ w = num(raw.rect.w, 1), h = num(raw.rect.h, 1) }
+ end
+ if type(raw.viewport) == "table" then
+ page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
+ w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
+ page.viewportFill = raw.viewport.fill == true
+ end
+ for _, c in ipairs(raw.controls or {}) do
+ local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
+ local sector = tonumber(c.sector)
+ if sector then
+ sector = math.floor(sector)
+ if sector < 1 or sector > #SECTOR_CELLS then sector = nil end
+ end
+ local areaNames
+ if type(c.areaNames) == "table" then
+ areaNames = {}
+ for _, side in ipairs({ "up", "down", "left", "right" }) do
+ if type(c.areaNames[side]) == "string" then
+ areaNames[side] = c.areaNames[side]
+ end
+ end
+ end
+ page.controls[#page.controls + 1] = {
+ sector = sector,
+ areaKind = type(c.areaKind) == "string" and c.areaKind or nil,
+ areaNames = areaNames,
+ spec = tostring(c.bind or "nul"),
+ buttons = buttons, hotkeys = hotkeys, keys = keys,
+ decorative = decorative,
+ x = num(c.x, 0.5), y = num(c.y, 0.5),
+ shape = c.shape == "radial" and "radial" or "rect",
+ rangeX = math.abs(num(c.w, 0.1)) * 0.5,
+ rangeY = math.abs(num(c.h, 0.1)) * 0.5,
+ rangeMod = num(c.rangeMod, page.rangeMod),
+ alphaMod = num(c.alphaMod, page.alphaMod),
+ reachUp = num(c.reachUp, 1), reachDown = num(c.reachDown, 1),
+ reachLeft = num(c.reachLeft, 1), reachRight = num(c.reachRight, 1),
+ imagePath = c.image,
+ pressedImagePath = c.imagePressed,
+ nextTarget = c.nextTarget,
+ movable = c.movable == true or nil,
+ exclusive = c.exclusive == true or nil,
+ }
+ end
+ if not page.orient then page.orient = TouchSkin.pageOrient(page) end
+ pages[#pages + 1] = page
+ end
+ return { pages = pages, name = data.name, author = data.author,
+ notes = data.notes, format = "native" }
+end
+
+function TouchSkin.toNative(skin)
+ local out = {
+ name = skin.name or skin.id,
+ author = skin.author,
+ notes = skin.notes,
+ format = 1,
+ pages = {},
+ }
+ for _, page in ipairs(skin.pages or {}) do
+ local p = {
+ name = page.name,
+ image = page.imagePath,
+ fullScreen = page.fullScreen ~= false,
+ rangeMod = page.rangeMod,
+ alphaMod = page.alphaMod,
+ aspect = page.aspect,
+ fitAspect = page.aspectFromCfg or nil,
+ screenFit = page.screenFit == "remainder" and "remainder" or nil,
+ anchor = page.anchor,
+ orient = (page.orient == "portrait" or page.orient == "landscape"
+ or page.orient == "any") and page.orient or nil,
+ controls = {},
+ }
+ if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
+ or page.rect.w ~= 1 or page.rect.h ~= 1) then
+ p.rect = { x = page.rect.x, y = page.rect.y, w = page.rect.w, h = page.rect.h }
+ end
+ if page.viewport then
+ p.viewport = {
+ x = page.viewport.x, y = page.viewport.y,
+ w = page.viewport.w, h = page.viewport.h,
+ fill = page.viewportFill or nil,
+ }
+ end
+ for _, ctl in ipairs(page.controls or {}) do
+ p.controls[#p.controls + 1] = {
+ bind = ctl.spec,
+ x = ctl.x, y = ctl.y,
+ w = ctl.rangeX * 2, h = ctl.rangeY * 2,
+ shape = ctl.shape,
+ rangeMod = ctl.rangeMod ~= p.rangeMod and ctl.rangeMod or nil,
+ alphaMod = ctl.alphaMod ~= p.alphaMod and ctl.alphaMod or nil,
+ reachUp = ctl.reachUp ~= 1 and ctl.reachUp or nil,
+ reachDown = ctl.reachDown ~= 1 and ctl.reachDown or nil,
+ reachLeft = ctl.reachLeft ~= 1 and ctl.reachLeft or nil,
+ reachRight = ctl.reachRight ~= 1 and ctl.reachRight or nil,
+ image = ctl.imagePath,
+ imagePressed = ctl.pressedImagePath,
+ nextTarget = ctl.nextTarget,
+ movable = ctl.movable or nil,
+ exclusive = ctl.exclusive or nil,
+ sector = ctl.sector,
+ areaKind = ctl.areaKind,
+ areaNames = ctl.areaNames and {
+ up = ctl.areaNames.up, down = ctl.areaNames.down,
+ left = ctl.areaNames.left, right = ctl.areaNames.right,
+ } or nil,
+ }
+ end
+ out.pages[#out.pages + 1] = p
+ end
+ return out
+end
+
+function TouchSkin.serialize(skin)
+ return require("src.import.LuaWriter").encode(TouchSkin.toNative(skin))
+end
+
+local imageCache = setmetatable({}, { __mode = "v" })
+
+local function loadImage(path)
+ if not (love and love.graphics and love.graphics.newImage) then return nil end
+ local cached = imageCache[path]
+ if cached then return cached end
+ local ok, img = pcall(love.graphics.newImage, path)
+ if not ok or not img then return nil end
+ if img.setFilter then img:setFilter("linear", "linear") end
+ imageCache[path] = img
+ return img
+end
+
+-- FileData so LOVE sniffs JPEG/PNG from the name, not a path inside the zip.
+local function loadImageFromBytes(bytes, name)
+ if not bytes or bytes == "" then return nil end
+ if not (love and love.graphics and love.graphics.newImage) then return nil end
+ if not (love.filesystem and love.filesystem.newFileData) then return nil end
+ local key = "bytes:" .. tostring(name) .. ":" .. tostring(#bytes)
+ local cached = imageCache[key]
+ if cached then return cached end
+ local okFd, fd = pcall(love.filesystem.newFileData, bytes, name or "bezel.jpg")
+ if not okFd or not fd then return nil end
+ local ok, img = pcall(love.graphics.newImage, fd)
+ if not ok or not img then
+ if love.image and love.image.newImageData then
+ local okData, data = pcall(love.image.newImageData, fd)
+ if okData and data then ok, img = pcall(love.graphics.newImage, data) end
+ end
+ end
+ if not ok or not img then return nil end
+ if img.setFilter then img:setFilter("linear", "linear") end
+ imageCache[key] = img
+ return img
+end
+
+local function rasterizePdfPage(page, root)
+ if not page or page.image or not page.pdfPath then return end
+ local pdf = readFile(joinPath(root, page.pdfPath))
+ local raster = require("src.core.PdfImage").extract(pdf)
+ if not raster then return end
+ local name = tostring(page.pdfPath):gsub("%.[Pp][Dd][Ff]$", "") .. "." .. raster.ext
+ page.rasterData = raster.data
+ page.rasterName = name:match("([^/]+)$") or name
+ page.image = loadImageFromBytes(raster.data, page.rasterName)
+end
+
+local function pixelScalePending(page)
+ if page.pixelCoords then return true end
+ for _, ctl in ipairs(page.controls or {}) do
+ if ctl.pixelCoords then return true end
+ end
+ return false
+end
+
+local function applyPixelScale(page)
+ if not pixelScalePending(page) then return true end
+ if not page.image or not page.image.getDimensions then return false end
+ local iw, ih = page.image:getDimensions()
+ if not iw or not ih or iw <= 0 or ih <= 0 then return false end
+ for _, ctl in ipairs(page.controls or {}) do
+ local pixel = ctl.pixelCoords
+ if pixel == nil then pixel = page.pixelCoords end
+ if pixel then
+ ctl.x, ctl.y = ctl.x / iw, ctl.y / ih
+ ctl.rangeX, ctl.rangeY = ctl.rangeX / iw, ctl.rangeY / ih
+ ctl.pixelCoords = false
+ end
+ end
+ page.pixelCoords = false
+ return true
+end
+
+-- An overlay image is its own design canvas. Older RetroArch cfg files often
+-- omit `aspect_ratio`; reading the dimensions here keeps that legacy art and
+-- all of its normalized controls on the same uniform scale.
+function TouchSkin.applyImageAspect(page)
+ if not page or page.aspectFromCfg or not page.image
+ or not page.image.getDimensions then return false end
+ local iw, ih = page.image:getDimensions()
+ if not iw or not ih or iw <= 0 or ih <= 0 then return false end
+ page.aspect = iw / ih
+ page.aspectFromImage = true
+ return true
+end
+
+function TouchSkin.load(root, id)
+ local cfgPath, format, prefix = findConfig(root)
+ if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
+ local text = readFile(cfgPath)
+ if not text then return nil, "unreadable " .. cfgPath end
+ local skin, err
+ if format == "native" then
+ skin, err = TouchSkin.parseNative(text)
+ elseif format == "delta" then
+ local dir = cfgPath:match("^(.*)/[^/]+$") or root
+ skin, err = require("src.core.DeltaSkin").parse(text,
+ { prefix = prefix or "", names = listDir(dir) })
+ else
+ skin, err = TouchSkin.parse(text)
+ end
+ if not skin then return nil, err end
+
+ skin.id = id or root:match("([^/]+)$") or root
+ skin.root = root
+ skin.configPath = cfgPath
+ skin.format = format
+ skin.name = skin.name or skin.pages[1] and skin.pages[1].name or skin.id
+
+ for _, page in ipairs(skin.pages) do
+ if page.imagePath then
+ page.image = loadImage(joinPath(root, page.imagePath))
+ elseif page.pdfPath then
+ rasterizePdfPage(page, root)
+ end
+ TouchSkin.applyImageAspect(page)
+ if not applyPixelScale(page) then
+ return nil, "could not read " .. tostring(page.imagePath)
+ .. ", which " .. page.name .. " measures its coordinates against"
+ end
+ for _, ctl in ipairs(page.controls) do
+ if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
+ if ctl.pressedImagePath then
+ ctl.pressedImage = loadImage(joinPath(root, ctl.pressedImagePath))
+ end
+ end
+ end
+ return skin
+end
+
+local mountedArchives = {}
+local function unmountZip(archive)
+ local point = mountedArchives[archive]
+ if not point then return true end
+ if not (love and love.filesystem and love.filesystem.unmount) then
+ return nil, "cannot release mounted skin archive"
+ end
+ local ok, removed = pcall(love.filesystem.unmount, archive)
+ if not ok or removed ~= true then return nil, "skin archive is still in use" end
+ mountedArchives[archive] = nil
+ if TouchSkin.active and TouchSkin.active.root == point then TouchSkin.setActive(nil) end
+ for key in pairs(imageCache) do
+ if key:sub(1, #point + 1) == point .. "/" or key:sub(1, 6) == "bytes:" then
+ imageCache[key] = nil
+ end
+ end
+ return true
+end
+
+local function mountZip(archive, point)
+ if not (love and love.filesystem and love.filesystem.mount) then return false end
+ -- PhysFS rejects a second mount of an already-mounted archive. A library
+ -- refresh must keep its existing entry instead of making it disappear.
+ if mountedArchives[archive] == point and findConfig(point) then return true end
+ local ok, mounted = pcall(love.filesystem.mount, archive, point)
+ if ok and mounted == true then mountedArchives[archive] = point end
+ return ok and mounted == true
+end
+
+TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
+TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
+TouchSkin.PDF_ONLY_MESSAGE =
+ "This skin uses PDF artwork with no extractable image. "
+ .. "Ask the author for a PNG version."
+
+function TouchSkin.archiveId(name)
+ name = tostring(name or "")
+ local ext = name:match("%.([%w]+)$")
+ if not ext or not TouchSkin.ARCHIVE_EXTS[ext:lower()] then return nil end
+ local id = name:sub(1, #name - #ext - 1)
+ if id == "" then return nil end
+ return id, ext:lower()
+end
+
+function TouchSkin.list()
+ local out, seen = {}, {}
+ local function scan(root, source)
+ for _, name in ipairs(listDir(root)) do
+ local archiveId = TouchSkin.archiveId(name)
+ local id = archiveId or name
+ if not seen[id] and name:sub(1, 1) ~= "_" then
+ local path = root .. "/" .. name
+ if archiveId then
+ local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
+ if mountZip(path, point) and findConfig(point) then
+ seen[id] = true
+ out[#out + 1] = { id = id, root = point, source = source, archive = path }
+ end
+ elseif isDir(path) and findConfig(path) then
+ seen[id] = true
+ out[#out + 1] = { id = id, root = path, source = source }
+ end
+ end
+ end
+ end
+ if love and love.filesystem and love.filesystem.createDirectory then
+ pcall(wfs().createDirectory, TouchSkin.USER_ROOT)
+ end
+ scan(TouchSkin.USER_ROOT, "user")
+ scan(TouchSkin.BUNDLED_ROOT, "bundled")
+ table.sort(out, function(a, b) return a.id < b.id end)
+ return out
+end
+
+-- Drop a .zip or .deltaskin into /skins and report the id it lists under.
+function TouchSkin.installArchive(name, data)
+ if not data or data == "" then return nil, "empty archive" end
+ if not (love and love.filesystem and love.filesystem.write) then
+ return nil, "no writable filesystem"
+ end
+ name = tostring(name or ""):match("([^/\\]+)$") or ""
+ name = name:gsub("[^%w%._%-]", "_"):gsub("^_+", "")
+ local legacy = name:match("%.([%w]+)$")
+ if legacy and TouchSkin.LEGACY_EXTS[legacy:lower()] then
+ return nil, "old GBA4iOS skin, not supported"
+ end
+ local id = TouchSkin.archiveId(name)
+ if not id then return nil, "not a .zip or .deltaskin" end
+
+ pcall(wfs().createDirectory, TouchSkin.USER_ROOT)
+ local dest = TouchSkin.USER_ROOT .. "/" .. name
+ local released, releaseError = unmountZip(dest)
+ if not released then return nil, releaseError end
+ local ok, err = wfs().write(dest, data)
+ if not ok then return nil, tostring(err) end
+
+ local entry = TouchSkin.find(id)
+ if not entry then
+ wfs().remove(dest)
+ return nil, "no skin.lua, .cfg or info.json inside " .. name
+ end
+ local skin = TouchSkin.load(entry.root, entry.id)
+ if skin and require("src.core.DeltaSkin").needsConversion(skin) then
+ unmountZip(dest)
+ wfs().remove(dest)
+ return nil, TouchSkin.PDF_ONLY_MESSAGE
+ end
+ return id, skin and skin.warnings or nil
+end
+
+function TouchSkin.find(id)
+ if not id or id == "" then return nil end
+ for _, entry in ipairs(TouchSkin.list()) do
+ if entry.id == id then return entry end
+ end
+ return nil
+end
+
+-- Remove only a user-installed skin. Bundled skins are shipped with the
+-- game and intentionally have no delete affordance.
+function TouchSkin.remove(id)
+ local entry = TouchSkin.find(id)
+ if not entry then return nil, "no skin " .. tostring(id) end
+ if entry.source ~= "user" then return nil, "bundled skins cannot be deleted" end
+ if not (love and love.filesystem and love.filesystem.remove) then
+ return nil, "no writable filesystem"
+ end
+ if entry.archive then
+ local released, err = unmountZip(entry.archive)
+ if not released then return nil, err end
+ end
+ local function removeTree(path)
+ if isDir(path) and love.filesystem.getDirectoryItems then
+ for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do
+ local ok, err = removeTree(path .. "/" .. name)
+ if not ok then return nil, err end
+ end
+ end
+ local ok, err = wfs().remove(path)
+ return ok and true or nil, err
+ end
+ return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id))
+end
+
+function TouchSkin.assetPaths(skin)
+ local out, seen = {}, {}
+ local function add(rel)
+ if rel and rel ~= "" and not seen[rel] then
+ seen[rel] = true
+ out[#out + 1] = rel
+ end
+ end
+ for _, page in ipairs(skin.pages or {}) do
+ add(page.imagePath)
+ for _, ctl in ipairs(page.controls or {}) do
+ add(ctl.imagePath)
+ add(ctl.pressedImagePath)
+ end
+ end
+ return out
+end
+
+local function writeArchive(entries, destPath)
+ local blob = require("src.core.SkinZip").encode(entries)
+ local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
+ if not absolute and love and love.filesystem and love.filesystem.createDirectory then
+ local dir = destPath:match("^(.*)/[^/]+$")
+ if dir then pcall(wfs().createDirectory, dir) end
+ end
+ if not absolute and love and love.filesystem and love.filesystem.write then
+ local ok, err = wfs().write(destPath, blob)
+ if not ok then return nil, tostring(err) end
+ else
+ local handle = io.open(destPath, "wb")
+ if not handle then return nil, "cannot write " .. destPath end
+ handle:write(blob)
+ handle:close()
+ end
+ return destPath
+end
+
+local function collectAssets(skin, rels)
+ local entries, missing = {}, {}
+ for _, rel in ipairs(rels) do
+ local data = readFile(joinPath(skin.root, rel))
+ if data then
+ entries[#entries + 1] = { name = rel, data = data }
+ else
+ missing[#missing + 1] = rel
+ end
+ end
+ return entries, missing
+end
+
+function TouchSkin.export(skin, destPath)
+ if not skin then return nil, "no skin" end
+ local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
+ local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin))
+ for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
+ if skin.configPath and skin.format == "retroarch" then
+ local cfg = readFile(skin.configPath)
+ if cfg then
+ entries[#entries + 1] =
+ { name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
+ end
+ end
+ destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-export.zip")
+ local written, err = writeArchive(entries, destPath)
+ if not written then return nil, err end
+ return destPath, missing
+end
+
+local function fmtNum(n)
+ n = tonumber(n) or 0
+ if n == math.floor(n) then return string.format("%d", n) end
+ local s = string.format("%.6f", n):gsub("0+$", ""):gsub("%.$", "")
+ return s
+end
+
+local function fmtRect(r)
+ return ('"%s,%s,%s,%s"'):format(fmtNum(r.x), fmtNum(r.y), fmtNum(r.w), fmtNum(r.h))
+end
+
+local function cfgSpec(spec)
+ local parts = {}
+ for raw in tostring(spec or ""):gmatch("[^|]+") do
+ local name = trim(raw)
+ local key = name:lower():match("^key:(.+)$")
+ parts[#parts + 1] = key and ("retrok_" .. key) or name
+ end
+ return table.concat(parts, "|")
+end
+
+function TouchSkin.toRetroArchConfig(skin)
+ local pages = (skin and skin.pages) or {}
+ local out = { "overlays = " .. #pages }
+ for i, page in ipairs(pages) do
+ local p = "overlay" .. (i - 1)
+ out[#out + 1] = ""
+ out[#out + 1] = p .. '_name = "' .. tostring(page.name or ("overlay" .. (i - 1))) .. '"'
+ if page.imagePath then out[#out + 1] = p .. "_overlay = " .. page.imagePath end
+ out[#out + 1] = p .. "_full_screen = " .. (page.fullScreen ~= false and "true" or "false")
+ out[#out + 1] = p .. "_normalized = true"
+ if num(page.rangeMod, 1) ~= 1 then
+ out[#out + 1] = p .. "_range_mod = " .. fmtNum(page.rangeMod)
+ end
+ if num(page.alphaMod, 1) ~= 1 then
+ out[#out + 1] = p .. "_alpha_mod = " .. fmtNum(page.alphaMod)
+ end
+ if page.aspectFromCfg and page.aspect and page.aspect > 0 then
+ out[#out + 1] = p .. "_aspect_ratio = " .. fmtNum(page.aspect)
+ end
+ local r = page.rect
+ if r and (r.x ~= 0 or r.y ~= 0 or r.w ~= 1 or r.h ~= 1) then
+ out[#out + 1] = p .. "_rect = " .. fmtRect(r)
+ end
+ if page.viewport then
+ out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
+ if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
+ end
+ local controls = {}
+ for _, ctl in ipairs(page.controls or {}) do
+ if not ctl.sector or ctl.sector == 1 then controls[#controls + 1] = ctl end
+ end
+ out[#out + 1] = p .. "_descs = " .. #controls
+ for j, ctl in ipairs(controls) do
+ local d = p .. "_desc" .. (j - 1)
+ local spec = ctl.areaKind and ctl.sector and ctl.areaKind
+ or cfgSpec(ctl.spec)
+ if spec == "" then spec = "nul" end
+ out[#out + 1] = ('%s = "%s,%s,%s,%s,%s,%s"'):format(d, spec,
+ fmtNum(ctl.x), fmtNum(ctl.y),
+ ctl.shape == "radial" and "radial" or "rect",
+ fmtNum(ctl.rangeX), fmtNum(ctl.rangeY))
+ if ctl.imagePath then out[#out + 1] = d .. "_overlay = " .. ctl.imagePath end
+ if ctl.pressedImagePath then
+ out[#out + 1] = d .. "_overlay_pressed = " .. ctl.pressedImagePath
+ end
+ if num(ctl.rangeMod, 1) ~= num(page.rangeMod, 1) then
+ out[#out + 1] = d .. "_range_mod = " .. fmtNum(ctl.rangeMod)
+ end
+ if num(ctl.alphaMod, 1) ~= num(page.alphaMod, 1) then
+ out[#out + 1] = d .. "_alpha_mod = " .. fmtNum(ctl.alphaMod)
+ end
+ for key, value in pairs({ up = ctl.reachUp, down = ctl.reachDown,
+ left = ctl.reachLeft, right = ctl.reachRight }) do
+ if num(value, 1) ~= 1 then
+ out[#out + 1] = d .. "_reach_" .. key .. " = " .. fmtNum(value)
+ end
+ end
+ if ctl.movable then out[#out + 1] = d .. "_movable = true" end
+ if ctl.exclusive then out[#out + 1] = d .. "_exclusive = true" end
+ if ctl.nextTarget then
+ out[#out + 1] = d .. '_next_target = "' .. tostring(ctl.nextTarget) .. '"'
+ end
+ if ctl.areaKind and ctl.sector and ctl.areaNames then
+ local defaults = TouchSkin.AREA_DEFAULTS[ctl.areaKind] or {}
+ for _, side in ipairs({ "up", "down", "left", "right" }) do
+ local name = ctl.areaNames[side]
+ if name and name ~= defaults[side] then
+ out[#out + 1] = d .. "_" .. side .. ' = "' .. name .. '"'
+ end
+ end
+ end
+ end
+ end
+ return table.concat(out, "\n") .. "\n"
+end
+
+function TouchSkin.exportRetroArch(skin, destPath)
+ if not skin then return nil, "no skin" end
+ local entries = { { name = "overlay.cfg", data = TouchSkin.toRetroArchConfig(skin) } }
+ local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin))
+ for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
+ destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-retroarch.zip")
+ local written, err = writeArchive(entries, destPath)
+ if not written then return nil, err end
+ return destPath, missing
+end
+
+function TouchSkin.exportDelta(skin, opts)
+ if not skin then return nil, "no skin" end
+ opts = opts or {}
+ local DeltaSkin = require("src.core.DeltaSkin")
+ local info, assetRels, warnings = DeltaSkin.build(skin, opts)
+ if not info then return nil, assetRels end
+ local entries = {
+ { name = DeltaSkin.INFO_NAME, data = require("src.link.Json").encode(info) },
+ }
+ local assets, missing = collectAssets(skin, assetRels)
+ for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
+ local destPath = opts.path
+ or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. ".deltaskin")
+ local written, err = writeArchive(entries, destPath)
+ if not written then return nil, err end
+ return destPath, missing, warnings
+end
+
+TouchSkin.BINDS = {
+ "nul",
+ "up", "down", "left", "right",
+ "a", "b", "l", "r", "start", "select",
+ "left|up", "right|up", "left|down", "right|down",
+ "hold_fast_forward", "toggle_fast_forward", "reset", "menu_toggle",
+ "overlay_next",
+}
+
+function TouchSkin.describeBind(spec)
+ local buttons, hotkeys, keys, decorative = parseBinds(spec)
+ if decorative then return "decoration" end
+ local parts = {}
+ for _, b in ipairs(buttons) do parts[#parts + 1] = "GB " .. b:upper() end
+ for _, h in ipairs(hotkeys) do parts[#parts + 1] = h end
+ for _, k in ipairs(keys) do parts[#parts + 1] = "key " .. k end
+ return table.concat(parts, " + ")
+end
+
+function TouchSkin.newControl(spec, x, y, w, h, shape)
+ local buttons, hotkeys, keys, decorative = parseBinds(spec)
+ return {
+ spec = spec, buttons = buttons, hotkeys = hotkeys, keys = keys,
+ decorative = decorative,
+ x = x, y = y, rangeX = w * 0.5, rangeY = h * 0.5,
+ shape = shape == "radial" and "radial" or "rect",
+ rangeMod = 1, alphaMod = 1,
+ reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1,
+ }
+end
+
+function TouchSkin.setBind(ctl, spec)
+ ctl.spec = spec
+ ctl.buttons, ctl.hotkeys, ctl.keys, ctl.decorative = parseBinds(spec)
+ return ctl
+end
+
+function TouchSkin.newSkin(id)
+ local page = {
+ index = 1, name = "main", fullScreen = true, normalized = true,
+ rangeMod = 1, alphaMod = 1, aspect = PORTRAIT_ASPECT,
+ rect = { x = 0, y = 0, w = 1, h = 1 },
+ viewport = { x = 0, y = 0, w = 1, h = 0.5 },
+ viewportFill = false,
+ controls = {},
+ }
+ return { id = id or "new_skin", name = id or "new_skin",
+ root = TouchSkin.USER_ROOT .. "/" .. (id or "new_skin"),
+ format = "native", pages = { page } }
+end
+
+local function copyTable(src)
+ if type(src) ~= "table" then return src end
+ local out = {}
+ for k, v in pairs(src) do out[k] = copyTable(v) end
+ return out
+end
+
+function TouchSkin.clone(skin)
+ if not skin then return nil end
+ local out = {
+ id = skin.id, name = skin.name, root = skin.root, format = skin.format,
+ author = skin.author, notes = skin.notes, configPath = skin.configPath,
+ source = skin.source, pages = {},
+ warnings = skin.warnings and copyTable(skin.warnings) or nil,
+ }
+ for i, page in ipairs(skin.pages or {}) do
+ local p = copyTable(page)
+ p.image = page.image
+ p.controls = {}
+ for j, ctl in ipairs(page.controls or {}) do
+ local c = copyTable(ctl)
+ c.image = ctl.image
+ c.pressedImage = ctl.pressedImage
+ p.controls[j] = c
+ end
+ out.pages[i] = p
+ end
+ return out
+end
+
+function TouchSkin.resolveImage(root, rel)
+ if not rel or rel == "" then return nil end
+ return loadImage(joinPath(root, rel))
+end
+
+function TouchSkin.detectViewport(root, imagePath)
+ if not (love and love.image and love.image.newImageData) then return nil end
+ if not imagePath or imagePath == "" then return nil end
+ local ok, data = pcall(love.image.newImageData, joinPath(root, imagePath))
+ if not ok or not data then return nil end
+ local w, h = data:getWidth(), data:getHeight()
+ if w < 2 or h < 2 then return nil end
+
+ local function clear(x, y)
+ if x < 0 or y < 0 or x >= w or y >= h then return false end
+ local okp, _, _, _, a = pcall(data.getPixel, data, x, y)
+ return okp and a ~= nil and a < 0.05
+ end
+
+ local cx, cy = math.floor(w / 2), math.floor(h / 2)
+ if not clear(cx, cy) then return nil end
+
+ local left, right = cx, cx
+ while left > 0 and clear(left - 1, cy) do left = left - 1 end
+ while right < w - 1 and clear(right + 1, cy) do right = right + 1 end
+ local top, bottom = cy, cy
+ while top > 0 and clear(cx, top - 1) do top = top - 1 end
+ while bottom < h - 1 and clear(cx, bottom + 1) do bottom = bottom + 1 end
+
+ local rw, rh = right - left + 1, bottom - top + 1
+ if rw < 8 or rh < 8 then return nil end
+ return { x = left / w, y = top / h, w = rw / w, h = rh / h }, rw, rh
+end
+
+-- Bring outside art into the skin's own folder. A skin still living in
+-- assets/ or a mounted zip is saved out to /skins/ first, because
+-- that is the only root the engine can write to.
+function TouchSkin.importImage(skin, name, data)
+ if not skin then return nil, "no skin" end
+ if not data or data == "" then return nil, "no image data" end
+ if not (love and love.filesystem and love.filesystem.write) then
+ return nil, "no writable filesystem"
+ end
+ name = tostring(name):gsub("[^%w%._%-]", "_")
+ if name == "" then return nil, "bad file name" end
+
+ local dest = TouchSkin.USER_ROOT .. "/" .. skin.id
+ if skin.root ~= dest then
+ local saved, err = TouchSkin.saveTo(skin, skin.id)
+ if not saved then return nil, tostring(err) end
+ end
+ pcall(wfs().createDirectory, dest .. "/img")
+ local rel = "img/" .. name
+ local ok, err = wfs().write(dest .. "/" .. rel, data)
+ if not ok then return nil, tostring(err) end
+ return rel
+end
+
+function TouchSkin.listImages(root)
+ local out = {}
+ local function scan(dir, prefix)
+ for _, name in ipairs(listDir(dir)) do
+ local path = dir .. "/" .. name
+ local lower = name:lower()
+ if lower:match("%.png$") or lower:match("%.jpg$") or lower:match("%.jpeg$") then
+ out[#out + 1] = prefix .. name
+ elseif isDir(path) and prefix == "" then
+ scan(path, name .. "/")
+ end
+ end
+ end
+ scan(root, "")
+ table.sort(out)
+ return out
+end
+
+function TouchSkin.saveTo(skin, id)
+ id = id or skin.id
+ if not id or id == "" then return nil, "no skin id" end
+ if not (love and love.filesystem and love.filesystem.write) then
+ return nil, "no writable filesystem"
+ end
+ local dest = TouchSkin.USER_ROOT .. "/" .. id
+ pcall(wfs().createDirectory, dest)
+
+ local copied, failed = 0, {}
+ for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
+ local target = dest .. "/" .. rel
+ local dir = target:match("^(.*)/[^/]+$")
+ if dir then pcall(wfs().createDirectory, dir) end
+ if skin.root ~= dest then
+ local data = readFile(joinPath(skin.root, rel))
+ if data then
+ if wfs().write(target, data) then copied = copied + 1 end
+ else
+ failed[#failed + 1] = rel
+ end
+ end
+ end
+
+ local ok, err = wfs().write(dest .. "/" .. TouchSkin.NATIVE_NAME,
+ TouchSkin.serialize(skin))
+ if not ok then return nil, tostring(err) end
+ skin.id, skin.root, skin.format = id, dest, "native"
+ return dest, failed, copied
+end
+
+TouchSkin.active = nil
+TouchSkin.pageIndex = 1
+-- RetroArch Auto-Rotate Overlay (1.7.9, default on mobile): a cfg whose
+-- pages are named portrait / landscape is swapped to match the display.
+-- The Skin Studio turns this off so PAGE and the canvas preset stay independent.
+TouchSkin.autoOrient = true
+
+TouchSkin.surfaceRect = nil
+
+function TouchSkin.setSurface(x, y, w, h)
+ if not w or w <= 0 or not h or h <= 0 then
+ TouchSkin.surfaceRect = nil
+ else
+ TouchSkin.surfaceRect = { x = x, y = y, w = w, h = h }
+ end
+ return TouchSkin.surfaceRect
+end
+
+function TouchSkin.setActive(skin)
+ TouchSkin.active = skin or nil
+ TouchSkin.pageIndex = 1
+ return TouchSkin.active
+end
+
+function TouchSkin.select(id)
+ if not id or id == "" then return TouchSkin.setActive(nil) end
+ local entry = TouchSkin.find(id)
+ if not entry then return nil, "no skin " .. tostring(id) end
+ local skin, err = TouchSkin.load(entry.root, entry.id)
+ if not skin then return nil, err end
+ skin.source = entry.source
+ return TouchSkin.setActive(skin)
+end
+
+local function displaySize()
+ local r = TouchSkin.surfaceRect
+ if r and r.w and r.h and r.w > 0 and r.h > 0 then return r.w, r.h end
+ if love and love.graphics and love.graphics.getDimensions then
+ return love.graphics.getDimensions()
+ end
+ return 0, 0
+end
+
+-- Explicit lock (studio) wins; otherwise the page name, the RetroArch
+-- auto-rotate convention. "any" means unlocked even if the name says
+-- portrait or landscape.
+function TouchSkin.pageOrient(page)
+ if not page then return nil end
+ if page.orient == "any" then return nil end
+ if page.orient == "portrait" or page.orient == "landscape" then
+ return page.orient
+ end
+ local n = tostring(page.name or ""):lower()
+ if n:find("landscape", 1, true) then return "landscape" end
+ if n:find("portrait", 1, true) then return "portrait" end
+ return nil
+end
+
+function TouchSkin.hasOrientPair(skin)
+ local saw = {}
+ for _, page in ipairs(skin and skin.pages or {}) do
+ local o = TouchSkin.pageOrient(page)
+ if o then saw[o] = true end
+ end
+ return saw.portrait == true and saw.landscape == true
+end
+
+local function findOrientPage(skin, keyword)
+ for i, page in ipairs(skin.pages or {}) do
+ if TouchSkin.pageOrient(page) == keyword then return i end
+ end
+ return nil
+end
+
+-- If the current page is the wrong orientation of a portrait/landscape pair,
+-- jump to the matching one. Pages locked to neither (gb_anim's GameBoy /
+-- GameBoyColor) are left alone. #1503
+function TouchSkin.syncOrientation(w, h)
+ if not TouchSkin.autoOrient then return end
+ local skin = TouchSkin.active
+ if not skin or not w or not h or w <= 0 or h <= 0 then return end
+ local want = w > h and "landscape" or "portrait"
+ local unwant = w > h and "portrait" or "landscape"
+ local page = skin.pages[TouchSkin.pageIndex] or skin.pages[1]
+ local current = TouchSkin.pageOrient(page)
+ if current == want then return end
+ if current ~= unwant then return end
+ local idx = findOrientPage(skin, want)
+ if idx then TouchSkin.pageIndex = idx end
+end
+
+function TouchSkin.page()
+ local skin = TouchSkin.active
+ if not skin then return nil end
+ local w, h = displaySize()
+ TouchSkin.syncOrientation(w, h)
+ return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
+end
+
+function TouchSkin.setPage(target)
+ local skin = TouchSkin.active
+ if not skin then return nil end
+ if type(target) == "number" then
+ local n = #skin.pages
+ TouchSkin.pageIndex = ((math.floor(target) - 1) % n) + 1
+ return TouchSkin.page()
+ end
+ for i, page in ipairs(skin.pages) do
+ if page.name == target then
+ TouchSkin.pageIndex = i
+ return page
+ end
+ end
+ return TouchSkin.page()
+end
+
+function TouchSkin.nextPage(target)
+ local skin = TouchSkin.active
+ if not skin then return nil end
+ if target and target ~= "" then return TouchSkin.setPage(target) end
+ return TouchSkin.setPage(TouchSkin.pageIndex + 1)
+end
+
+function TouchSkin.pageBox(page, w, h, ox, oy)
+ ox, oy = ox or 0, oy or 0
+ if not page then return ox, oy, w, h end
+ local bx, by, bw, bh = ox, oy, w, h
+ -- full_screen means "relative to the window, not the game viewport".
+ -- When the cfg also names an aspect_ratio, that window is then fitted
+ -- to the overlay's design aspect so buttons do not stretch. #1503
+ local fit = ((not page.fullScreen) or page.aspectFromCfg or page.aspectFromImage)
+ and page.aspect and page.aspect > 0 and h > 0
+ if fit then
+ local displayAspect = w / h
+ local anchor = page.anchor
+ if displayAspect > page.aspect then
+ bw = h * page.aspect
+ local extra = w - bw
+ if anchor == "right" then
+ bx = ox + extra
+ elseif anchor == "left" then
+ bx = ox
+ else
+ bx = ox + extra * 0.5
+ end
+ else
+ bh = w / page.aspect
+ local extra = h - bh
+ if anchor == "bottom" then
+ by = oy + extra
+ elseif anchor == "top" then
+ by = oy
+ elseif page.aspect < 1 then
+ -- A portrait bezel with controls is a controller deck. On an
+ -- unusually tall display, pin the deck to the lower edge and leave
+ -- the additional room for the game above it.
+ by = oy + extra
+ else
+ by = oy + extra * 0.5
+ end
+ end
+ end
+ local r = page.rect
+ return bx + r.x * bw, by + r.y * bh, r.w * bw, r.h * bh
+end
+
+function TouchSkin.imageFit(iw, ih, w, h)
+ if not iw or not ih or iw <= 0 or ih <= 0 then return nil end
+ return w / iw, h / ih
+end
+
+function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
+ local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
+ local cx, cy = bx + ctl.x * bw, by + ctl.y * bh
+ local halfW, halfH = ctl.rangeX * bw, ctl.rangeY * bh
+ return cx, cy, halfW, halfH
+end
+
+function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy, held)
+ local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
+ local mod = held == false and 1 or ctl.rangeMod
+ local left = halfW * ctl.reachLeft * mod
+ local right = halfW * ctl.reachRight * mod
+ local up = halfH * ctl.reachUp * mod
+ local down = halfH * ctl.reachDown * mod
+ local dx = px - cx
+ local dy = py - cy
+ local rx = dx < 0 and left or right
+ local ry = dy < 0 and up or down
+ if rx <= 0 or ry <= 0 then return false end
+ if ctl.sector and not TouchSkin.sectorHit(ctl.sector, dx, dy) then
+ return false
+ end
+ if ctl.shape == "radial" then
+ return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
+ end
+ return math.abs(dx) <= rx and math.abs(dy) <= ry
+end
+
+TouchSkin.overlayLive = false
+
+function TouchSkin.setOverlayLive(on)
+ TouchSkin.overlayLive = on and true or false
+end
+
+function TouchSkin.decorativeOnly()
+ local page = TouchSkin.page()
+ if not page then return false end
+ for _, ctl in ipairs(page.controls) do
+ if not ctl.decorative then return false end
+ end
+ return true
+end
+
+function TouchSkin.drawable()
+ -- A selected skin is a presentation choice, not a mobile-only input mode.
+ -- Its artwork and screen placement therefore belong on every platform;
+ -- `overlayLive` still controls whether touch input is available.
+ return TouchSkin.active ~= nil
+end
+
+function TouchSkin.hasViewport()
+ local page = TouchSkin.page()
+ if not page or not TouchSkin.drawable() then return false end
+ return page.viewport ~= nil or page.screenFit == "remainder"
+end
+
+-- Largest strip of (ox,oy,w,h) that does not overlap the overlay box.
+local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
+ local right, bottom = ox + w, oy + h
+ local cand = {
+ { ox, oy, w, by - oy },
+ { ox, by + bh, w, bottom - (by + bh) },
+ { ox, oy, bx - ox, h },
+ { bx + bw, oy, right - (bx + bw), h },
+ }
+ local best, bestArea
+ for _, r in ipairs(cand) do
+ if r[3] > 1 and r[4] > 1 then
+ local area = r[3] * r[4]
+ if not best or area > bestArea then
+ best, bestArea = r, area
+ end
+ end
+ end
+ if not best then return nil end
+ return best[1], best[2], best[3], best[4]
+end
+
+-- The deck box pinned to the lower edge (see pageBox) leaves room above it
+-- that belongs to the game, so a screen rect flush with the top of the deck
+-- grows into it instead of showing a black band.
+local function deckHeadroom(y, vh, by, bh, oy, h)
+ if by <= oy + 0.5 then return y, vh end
+ if by + bh < oy + h - 0.5 then return y, vh end
+ if y - by > math.max(2, bh * 0.01) then return y, vh end
+ return oy, vh + (y - oy)
+end
+
+function TouchSkin.pageViewport(page, w, h, ox, oy)
+ if not page then return nil end
+ ox, oy = ox or 0, oy or 0
+ local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
+ if page.viewport then
+ local v = page.viewport
+ local x, y = bx + v.x * bw, by + v.y * bh
+ local vw, vh = v.w * bw, v.h * bh
+ if vw <= 0 or vh <= 0 then return nil end
+ y, vh = deckHeadroom(y, vh, by, bh, oy, h)
+ return x, y, vw, vh, page.viewportFill == true
+ end
+ if page.screenFit == "remainder" then
+ local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
+ if not x then return nil end
+ return x, y, vw, vh, false
+ end
+ return nil
+end
+
+-- Centre of the page's screen cutout. The renderer fits the 160x144
+-- picture into that rect; this helper is for the studio preview.
+function TouchSkin.screenCenter(w, h, ox, oy, page)
+ page = page or TouchSkin.page()
+ if not page then return nil end
+ local x, y, vw, vh = TouchSkin.pageViewport(page, w, h, ox, oy)
+ if not x then return nil end
+ return x + vw * 0.5, y + vh * 0.5
+end
+
+function TouchSkin.viewport(w, h, ox, oy)
+ local page = TouchSkin.page()
+ if not page or not TouchSkin.drawable() then return nil end
+ return TouchSkin.pageViewport(page, w, h, ox, oy)
+end
+
+return TouchSkin
diff --git a/src/core/Version.lua b/src/core/Version.lua
index 82cad2f61..b7ba8b6df 100644
--- a/src/core/Version.lua
+++ b/src/core/Version.lua
@@ -16,7 +16,7 @@ local Version = {
-- binary (e.g. a LOVE version bump); an older shell
-- refuses to chainload a payload whose minShell
-- exceeds the shell it provides.
- modApi = 2, -- mod API major (manifest `api`)
+ modApi = 3, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 4, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua
index 814ced9e5..9c4238476 100644
--- a/src/import/CacheFs.lua
+++ b/src/import/CacheFs.lua
@@ -399,6 +399,12 @@ end
-- PORTABLE FIRST. A portable copy has already said where it keeps its
-- things, and it says so with a file sitting next to the executable, which
-- beats a line in an options file the portable copy may not even be reading.
+-- Observational counterpart for dataset views: never resolves or mounts a root.
+function CacheFs.datasetRoot()
+ if not portableResolved or (not portableRoot and not customResolved) then return nil, false end
+ return portableRoot or customRoot, true
+end
+
function CacheFs.root()
return resolvePortableRoot() or resolveCustomRoot()
end
@@ -1191,6 +1197,19 @@ function CacheFs.activePrefixShim()
return shimPrefix
end
+-- Exact developer-source reads for read-only version datasets. Refuse an
+-- active overlay rather than reading another edition under an unprefixed path.
+function CacheFs.datasetSourceFile(path, maximum, infoOnly)
+ local fs = love and love.filesystem
+ if not fs or not fs.getRealDirectory or not fs.getSource
+ or fs.getRealDirectory(path) ~= fs.getSource() then return nil end
+ local info = (shimRealGetInfo or fs.getInfo)(path, "file")
+ if not info or info.type and info.type ~= "file" then return nil end
+ if infoOnly then return info end
+ if info.size and info.size > maximum then return nil, "size limit" end
+ return (shimRealRead or fs.read)(path)
+end
+
-- Undo mountVersion. A process normally mounts exactly one version and then
-- boots it, but the launcher can open the save editor on a Blue/Yellow save,
-- close it, and press Play on Red: with that version's subtree still
diff --git a/src/import/DataLiteral.lua b/src/import/DataLiteral.lua
new file mode 100644
index 000000000..97bacfbbf
--- /dev/null
+++ b/src/import/DataLiteral.lua
@@ -0,0 +1,253 @@
+-- Bounded generated-data grammar. Never compiles or executes input.
+local D = {}
+D.LIMITS = { bytes = 8 * 1024 * 1024, depth = 64, nodes = 500000, string = 2 * 1024 * 1024, entries = 250000 }
+function D.decode(source, limits)
+ limits = limits or D.LIMITS
+ if type(source) ~= "string" or #source > limits.bytes then
+ return nil, "generated data size limit"
+ end
+ local p, nodes, depth = 1, 0, 0
+ local function fail(why)
+ error("data literal at byte " .. p .. ": " .. why, 0)
+ end
+ local function node()
+ nodes = nodes + 1
+ if nodes > limits.nodes then
+ fail("node limit")
+ end
+ end
+ local function long()
+ local equals = source:match("^%[(=*)%[", p)
+ if not equals or #equals > 16 then
+ fail("invalid long string")
+ end
+ p = p + #equals + 2
+ local finish = source:find("]" .. equals .. "]", p, true)
+ if not finish then
+ fail("unterminated long string")
+ end
+ local value = source:sub(p, finish - 1)
+ if #value > limits.string then
+ fail("string limit")
+ end
+ p = finish + #equals + 2
+ if value:sub(1, 2) == "\r\n" then
+ value = value:sub(3)
+ elseif value:sub(1, 1) == "\n" or value:sub(1, 1) == "\r" then
+ value = value:sub(2)
+ end
+ return value
+ end
+ local function skip()
+ while true do
+ local _, last = source:find("^%s*", p)
+ p = last + 1
+ if source:sub(p, p + 1) ~= "--" then
+ return
+ end
+ p = p + 2
+ if source:match("^%[=*%[", p) then
+ long()
+ else
+ p = (source:find("\n", p, true) or #source) + 1
+ end
+ end
+ end
+ local escapes =
+ { a = "\a", b = "\b", f = "\f", n = "\n", r = "\r", t = "\t", v = "\v", ["\\"] = "\\", ['"'] = '"', ["'"] = "'" }
+ local function quoted()
+ local quote = source:sub(p, p)
+ p = p + 1
+ local out, size = {}, 0
+ while p <= #source do
+ local c = source:sub(p, p)
+ p = p + 1
+ if c == quote then
+ return table.concat(out)
+ end
+ if c == "\\" then
+ c = source:sub(p, p)
+ p = p + 1
+ if c:match("%d") then
+ local digits = source:match("^%d%d?%d?", p - 1)
+ local byte = tonumber(digits)
+ if byte > 255 then
+ fail("invalid byte escape")
+ end
+ p = p - 1 + #digits
+ c = string.char(byte)
+ elseif c == "x" then
+ local digits = source:sub(p, p + 1)
+ if not digits:match("^%x%x$") then
+ fail("invalid hex escape")
+ end
+ p = p + 2
+ c = string.char(tonumber(digits, 16))
+ elseif c == "\n" then
+ c = "\n"
+ elseif c == "\r" then
+ if source:sub(p, p) == "\n" then
+ p = p + 1
+ end
+ c = "\n"
+ elseif c == "z" then
+ local _, last = source:find("^%s*", p)
+ p = last + 1
+ c = ""
+ else
+ c = escapes[c]
+ if not c then
+ fail("invalid string escape")
+ end
+ end
+ elseif c == "\n" or c == "\r" then
+ fail("newline in quoted string")
+ end
+ size = size + #c
+ if size > limits.string then
+ fail("string limit")
+ end
+ out[#out + 1] = c
+ end
+ fail("unterminated string")
+ end
+ local value
+ local function identifier()
+ local word = source:match("^[%a_][%w_]*", p)
+ if not word then
+ fail("expected name")
+ end
+ p = p + #word
+ if #word > limits.string then
+ fail("string limit")
+ end
+ return word
+ end
+ local function array()
+ depth = depth + 1
+ if depth > limits.depth then
+ fail("depth limit")
+ end
+ p = p + 1
+ local out, seen, index, count = {}, {}, 1, 0
+ skip()
+ while source:sub(p, p) ~= "}" do
+ count = count + 1
+ if count > limits.entries then
+ fail("table entry limit")
+ end
+ local key, child
+ local c = source:sub(p, p)
+ if c == "[" and not source:match("^%[=*%[", p) then
+ p = p + 1
+ key = value()
+ skip()
+ if source:sub(p, p) ~= "]" then
+ fail("expected ]")
+ end
+ p = p + 1
+ skip()
+ if source:sub(p, p) ~= "=" then
+ fail("expected =")
+ end
+ p = p + 1
+ child = value()
+ elseif c:match("[%a_]") then
+ local start = p
+ key = identifier()
+ skip()
+ if source:sub(p, p) == "=" then
+ node()
+ p = p + 1
+ child = value()
+ else
+ p = start
+ key = index
+ index = index + 1
+ child = value()
+ end
+ else
+ key = index
+ index = index + 1
+ child = value()
+ end
+ if type(key) ~= "string" and type(key) ~= "number" and type(key) ~= "boolean" then
+ fail("invalid key")
+ end
+ if seen[key] then
+ fail("duplicate key")
+ end
+ seen[key] = true
+ out[key] = child
+ skip()
+ c = source:sub(p, p)
+ if c == "," or c == ";" then
+ p = p + 1
+ skip()
+ elseif c ~= "}" then
+ fail("expected separator")
+ end
+ end
+ p = p + 1
+ depth = depth - 1
+ return out
+ end
+ value = function()
+ skip()
+ node()
+ local c = source:sub(p, p)
+ if c == "{" then
+ return array()
+ end
+ if c == '"' or c == "'" then
+ return quoted()
+ end
+ if source:match("^%[=*%[", p) then
+ return long()
+ end
+ if c:match("[%a_]") then
+ local word = identifier()
+ if word == "true" then
+ return true
+ elseif word == "false" then
+ return false
+ elseif word == "nil" then
+ return nil
+ end
+ fail("unexpected name")
+ end
+ local token = source:match("^[^%s,%]}=;]+", p)
+ if token and #token <= 128 then
+ local number = tonumber(token)
+ if number and number == number and math.abs(number) < math.huge then
+ p = p + #token
+ return number
+ end
+ end
+ fail("expected finite literal")
+ end
+ local ok, result = pcall(function()
+ skip()
+ if identifier() ~= "return" then
+ fail("expected return")
+ end
+ local result = value()
+ skip()
+ if source:sub(p, p) == ";" then
+ p = p + 1
+ skip()
+ end
+ if p <= #source then
+ fail("trailing content")
+ end
+ if type(result) ~= "table" then
+ fail("root must be a table")
+ end
+ return result
+ end)
+ if ok then
+ return result
+ end
+ return nil, result
+end
+return D
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..83a670830 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
@@ -1690,6 +1694,24 @@ local function requiredFiles(version)
return REQUIRED_FILES_GEN1
end
+-- Read-only metadata inspection shares the importer's exact format/hash gate.
+-- This descriptor does not read files, mount caches or touch active prefixes.
+function RomImporter.datasetContract(version)
+ local info = GameVersion.info(version)
+ if not info or info.generation > 3 then return nil end
+ local modules, seen = {}, {}
+ for _, path in ipairs(requiredFiles(version)) do
+ local name = path:match("^data/generated/(.+)%.lua$")
+ if name and not seen[name] then modules[#modules + 1] = name; seen[name] = true end
+ end
+ for _, name in ipairs({ "constants", "pokemon", "moves", "items", "maps",
+ "trainers", "encounters", "type_chart" }) do
+ if not seen[name] then modules[#modules + 1] = name; seen[name] = true end
+ end
+ table.sort(modules)
+ return { marker = markerFor(version), modules = modules }
+end
+
local function gen2TilesetRegistryLooksValid(version)
if GameVersion.generation(version) ~= 2 then return true end
local CacheFs = require("src.import.CacheFs")
@@ -5705,7 +5727,13 @@ 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)'
+ ..'\nContributions by Roxas2712 — https://github.com/Roxas2712'
+ local creditWidth = math.max(1, appW - 2 * padH)
+ local _, creditLines = self.hintFont:getWrap(creditText, creditWidth)
+ local creditH = #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 +6268,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 + padH, creditY, creditWidth, "center")
self.donateButtons = {}
self.donateButton = nil
if #footerLinks > 0 then
diff --git a/src/import/VersionFiles.lua b/src/import/VersionFiles.lua
new file mode 100644
index 000000000..de1d2aae7
--- /dev/null
+++ b/src/import/VersionFiles.lua
@@ -0,0 +1,117 @@
+-- Exact version-qualified reads. Never changes GameVersion, prefixes or mounts.
+local Version = require("src.core.GameVersion")
+local V = {}
+function V.new(fs)
+ local native = love and fs == love.filesystem
+ local function physical(path, maximum, infoOnly)
+ local f = io.open(path, "rb")
+ if not f then
+ return nil
+ end
+ local size = f:seek("end")
+ if not size then
+ f:close()
+ return nil
+ end
+ if infoOnly then
+ f:close()
+ return { type = "file", size = size }
+ end
+ if size > maximum then
+ f:close()
+ return nil, "size limit"
+ end
+ f:seek("set")
+ local data = f:read(maximum + 1)
+ f:close()
+ if type(data) == "string" and #data <= maximum then
+ return data
+ end
+ return nil, "size limit"
+ end
+ local function identity()
+ if not native then
+ return fs
+ end
+ local Cache = require("src.import.CacheFs")
+ return table.concat(
+ {
+ Cache.datasetRoot() or "",
+ fs.getSaveDirectory and fs.getSaveDirectory() or "",
+ fs.getSource and fs.getSource() or "",
+ },
+ "\n"
+ )
+ end
+ local function file(version, path, maximum, infoOnly)
+ local full = Version.cachePrefix(version) .. path
+ if not native then
+ if infoOnly then
+ return fs.getInfo(full, "file")
+ end
+ return fs.read(full)
+ end
+ local Cache = require("src.import.CacheFs")
+ local root, ready = Cache.datasetRoot()
+ if root then
+ return physical(root .. "/" .. full, maximum, infoOnly)
+ end
+ local save = ready and fs.getSaveDirectory and fs.getSaveDirectory()
+ if save then
+ local data, why = physical(save .. "/" .. full, maximum, infoOnly)
+ if data ~= nil or why then
+ return data, why
+ end
+ end
+ local source = fs.getSource and fs.getSource()
+ if source then
+ local data, why = physical(source .. "/" .. full, maximum, infoOnly)
+ if data ~= nil or why then
+ return data, why
+ end
+ end
+ return Cache.datasetSourceFile(full, maximum, infoOnly)
+ end
+ local function source(version, paths)
+ local prefix = Version.cachePrefix(version)
+ if native then
+ local Cache = require("src.import.CacheFs")
+ if Cache.datasetRoot() then
+ return false
+ end
+ for _, path in ipairs(paths) do
+ if not Cache.datasetSourceFile(prefix .. path, 0, true) then
+ return false
+ end
+ end
+ return true
+ end
+ if not fs.getSource or not fs.getRealDirectory then
+ return false
+ end
+ for _, path in ipairs(paths) do
+ if fs.getRealDirectory(prefix .. path) ~= fs.getSource() then
+ return false
+ end
+ end
+ return true
+ end
+ return {
+ read = function(v, p, max)
+ return file(v, p, max, false)
+ end,
+ info = function(v, p)
+ return file(v, p, 0, true)
+ end,
+ identity = identity,
+ source = source,
+ path = function(version, path)
+ -- An unprefixed asset path would be redirected by the active overlay.
+ if native and version == "red" and Version.get() ~= "red" then
+ return nil, "unavailable asset path"
+ end
+ return Version.cachePrefix(version) .. path
+ end,
+ }
+end
+return V
diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua
index c9119139b..99f6e829b 100644
--- a/src/inventory/ItemEffects.lua
+++ b/src/inventory/ItemEffects.lua
@@ -600,9 +600,14 @@ end
-- `res BADLY_POISONED`); the raw w*ToxicCounter is NOT reset by item
-- cures in Gen 1, so battle.sideToxic is deliberately left alone.
local function cureActiveToxic(battle, target)
+ local gscSleep=require('src.core.GameVersion').isGen2()and target and target.status~='SLP'
+ if gscSleep then target.sleepTurns=nil end
if not battle then return end
for _, b in ipairs({ battle.player, battle.enemy }) do
- if b and b.mon == target then b.toxicCounter = nil end
+ if b and b.mon == target then
+ b.toxicCounter = nil
+ if gscSleep then b.sleepTurns=nil end
+ end
end
end
@@ -1028,6 +1033,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local function wake(mon)
if mon and mon.status == "SLP" then
mon.status = nil
+ cureActiveToxic(battle,mon)
woke = true
end
end
@@ -1212,6 +1218,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
return "failed", { Strings("It won't have\nany effect.") }
end
target.status = nil
+ cureActiveToxic(battle,target)
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
require("src.core.Sound").play(data, "Heal_HP")
-- a revive takes the same .healHP -> .doneHealing route, animating up
diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua
index 699eb68ef..fd8654fb8 100644
--- a/src/link/Fingerprint.lua
+++ b/src/link/Fingerprint.lua
@@ -17,6 +17,10 @@
local Runtime = require("src.mods.Runtime")
local Fingerprint = {}
+-- Command/RNG compatibility is independent of byte-identical ROM data.
+function Fingerprint.nativeBattleRevision()
+ return require('src.core.GameVersion').isGen2() and 'gsc-1' or nil
+end
-- ------- FNV-1a, two lanes
@@ -238,6 +242,8 @@ local function surface(data, mods)
writeSection(out, data, "move_effects")
writeConstants(out, data)
writeLinkFields(out, data)
+ local revision=Fingerprint.nativeBattleRevision()
+ if revision then out[#out+1]='[native_gsc_commands]'..revision end
out[#out + 1] = "[mods]" .. modKey(mods)
return table.concat(out)
end
@@ -247,7 +253,7 @@ Fingerprint.surface = surface
-- mods: { { id, version, affectsLink } } -- the hello's mod array
function Fingerprint.compute(data, mods)
if not data then return digest("") end
- local key = modKey(mods)
+ local key = modKey(mods).."|native_commands:"..tostring(Fingerprint.nativeBattleRevision())
local hit = cache[data]
if hit and hit.key == key then return hit.value end
local value = Runtime.call("link.fingerprint", function(d, m)
diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua
index aec8437e7..cec478157 100644
--- a/src/link/Handshake.lua
+++ b/src/link/Handshake.lua
@@ -10,6 +10,7 @@
local Fingerprint = require("src.link.Fingerprint")
local Schemas = require("src.mods.Schemas")
local Version = require("src.core.Version")
+local Strings = require("src.core.Strings")
local Handshake = {}
@@ -158,6 +159,7 @@ function Handshake.hello(game, mode)
engineVersion = Version.engine,
apiVersion = Version.modApi,
fingerprint = Fingerprint.compute(game and game.data, mods),
+ nativeBattleRevision = Fingerprint.nativeBattleRevision(),
linkModified = Handshake.linkModified(game),
mods = mods,
}
@@ -174,6 +176,7 @@ end
function Handshake.checkCompat(localHello, remoteHello)
localHello = localHello or {}
if not remoteHello or not remoteHello.protocol then
+ if localHello.nativeBattleRevision then return "refused", "native_rules_required" end
if localHello.linkModified then
return "refused", "peer_v1_modified"
end
@@ -182,6 +185,7 @@ function Handshake.checkCompat(localHello, remoteHello)
if major(remoteHello.engineVersion) ~= major(localHello.engineVersion) then
return "refused", "engine_mismatch"
end
+ if localHello.nativeBattleRevision ~= remoteHello.nativeBattleRevision then return "subset", "native_rules_mismatch" end
if remoteHello.fingerprint == localHello.fingerprint then
return "full", nil
end
@@ -264,6 +268,11 @@ end
function Handshake.describe(localHello, remoteHello, verdict, mode)
local lines = {}
local peer = (remoteHello and remoteHello.name) or "THEY"
+ if (localHello and localHello.nativeBattleRevision) ~= (remoteHello and remoteHello.nativeBattleRevision) then
+ wrap(lines, Strings("The native battle rules differ."))
+ wrap(lines, Strings("Use matching engine builds for link battles."))
+ return lines
+ end
if verdict == "refused" then
if not (remoteHello and remoteHello.protocol) then
wrap(lines, "The other game is")
diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua
new file mode 100644
index 000000000..770c737e0
--- /dev/null
+++ b/src/mods/Api3.lua
@@ -0,0 +1,194 @@
+-- API-3 entry over the existing native adapters. Unsupported operations have
+-- callable handles but never delegate to a native method with a different contract.
+local Records = require("src.mods.Api3Records")
+local Runtime = require("src.mods.Runtime")
+local Warp = require("src.mods.Api3Warp")
+local Edits = require("src.mods.Api3WorldEdits")
+local NPC = require("src.mods.Api3NPC")
+local Wild = require("src.mods.Api3WildBattle")
+local Flags = require("src.mods.Api3Flags")
+local copy = require("src.mods.Merge").deepCopy
+local A = {}
+local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty
+ availableFieldActions useFieldAction canFly flyTo mapOverview warpTo toggleObject
+ setFlag getFlag effectiveEncounters replaceBlock spawnNpc liveMaps removeNpc npc
+ queueScript startWildBattle invalidateMap]]
+local SUPPORTED = {}
+for name in
+ ([[overworld current activeBlockAt party canReorderParty reorderParty
+ availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters
+ setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc startWildBattle invalidateMap]]):gmatch(
+ "%S+"
+ )
+do
+ SUPPORTED[name] = true
+end
+local function pack(...)
+ return { n = select("#", ...), ... }
+end
+local unpack = table.unpack or unpack
+local function block(gen)
+ return {
+ tileSize = gen == 3 and 16 or 32,
+ cellSize = 16,
+ coordinateBase = 0,
+ kind = gen == 3 and "metatile" or "block",
+ }
+end
+function A.install(api, loader, readContext, overviewContext, engineRequire, engineCall)
+ local gen = api.generation
+ local supportedGeneration = gen >= 1 and gen <= 3
+ -- Passthrough registries keep native shapes but expose the same handle metadata.
+ for name, entry in pairs(api.content) do
+ local handle = entry
+ if type(handle) == "table" and handle.supports == nil then
+ local available, reason = api:supports("content", name)
+ handle.available = available
+ local get, each = handle.get, handle.each
+ if get then
+ handle.get = function(self, id)
+ local value, why = get(self, id)
+ return copy(value), why
+ end
+ end
+ if each then
+ handle.each = function(self)
+ local iter, state, key = each(self)
+ return function()
+ local id, value = iter(state, key)
+ key = id
+ return id, copy(value)
+ end
+ end
+ end
+ handle.supports = function()
+ return available, reason
+ end
+ end
+ if type(handle) == "table" and handle.has == nil and handle.get then
+ handle.has = function(_, id)
+ return handle:get(id) ~= nil
+ end
+ end
+ end
+ local scripts = api.world -- PortableScripts owns the per-loader/session queue.
+ local world = {}
+ local nativeGame, nativeWorld
+ local function current()
+ return loader:_game(true) -- Never bootstrap Game for an observational early call.
+ end
+ local function native(game)
+ if game ~= nativeGame then
+ nativeWorld = engineRequire("src.world.WorldAPI").new(game, api.id, readContext)
+ nativeWorld.overviewContext = overviewContext
+ nativeGame = game
+ end
+ return nativeWorld
+ end
+ local warp, licensed, npc, wild
+ if supportedGeneration then
+ warp, licensed = Warp.new(loader, api.id, gen, current, native)
+ npc = NPC.new(loader, api.id, gen, current, native, licensed, engineRequire)
+ wild = Wild.new(loader, api.id, current, native, licensed, engineRequire)
+ end
+ local function dispatch(name, ...)
+ if not supportedGeneration or not SUPPORTED[name] then
+ return nil, "unsupported: API 3 world method " .. name
+ end
+ if name == "spawnNpc" or name == "removeNpc" or name == "npc" then
+ return npc(name, ...)
+ end
+ if name == "startWildBattle" then
+ return wild(...)
+ end
+ if name == "warpTo" then
+ return warp(...)
+ end
+ local edit = name == "toggleObject" or name == "replaceBlock" or name == "invalidateMap"
+ if edit and not licensed() then
+ return nil, "world facade is retired"
+ end
+ local game = current()
+ if not game then
+ return nil, "no game"
+ end
+ if edit then
+ return Edits.call(game, native(game), gen, engineRequire, name, ...)
+ end
+ if name == "setFlag" or name == "getFlag" then
+ local key, value = ...
+ return Flags.call(game, api.id, gen, key, value, name == "setFlag", licensed, function(live)
+ return native(live):overworld()
+ end)
+ end
+ if name == "queueScript" then
+ return scripts:queueScript(...)
+ end
+ local raw = native(game)
+ local result = pack(raw[name](raw, ...))
+ if name == "party" and type(result[1]) == "table" then
+ for _, mon in ipairs(result[1]) do
+ Records.mon(mon, game.data, gen)
+ end
+ elseif (name == "current" or name == "mapOverview") and type(result[1]) == "table" then
+ result[1].block = block(gen)
+ end
+ return unpack(result, 1, result.n)
+ end
+ for name in WORLD:gmatch("%S+") do
+ local method = name
+ world[method] = function(_, ...)
+ local owner = Runtime.currentMod
+ Runtime.currentMod = nil
+ local values = pack(pcall(dispatch, method, ...))
+ Runtime.currentMod = owner
+ if not values[1] then
+ return nil, "world operation failed: " .. tostring(values[2])
+ end
+ return unpack(values, 2, values.n)
+ end
+ end
+ api.world = world
+ local battle = api.battle
+ api.battle = {
+ snapshot = function()
+ local snapshot, why = battle:snapshot()
+ if not snapshot then
+ return nil, why
+ end
+ local game = current()
+ local data = game and game.data
+ Records.mon(snapshot.player, data, gen)
+ Records.mon(snapshot.enemy, data, gen)
+ for _, name in ipairs({ "party", "battlers" }) do
+ for _, mon in ipairs(snapshot[name] or {}) do
+ Records.mon(mon, data, gen)
+ end
+ end
+ return snapshot
+ end,
+ submit = function(_, ...)
+ return battle:submit(...)
+ end,
+ }
+ api.datasets = {
+ open = function(_, version)
+ if not loader.datasetViews then
+ local Views = engineRequire("src.mods.DatasetViews")
+ loader.datasetViews = Views.new(loader.fs, engineRequire, engineCall)
+ end
+ return loader.datasetViews:open(version)
+ end,
+ }
+ local supports = api.supports
+ function api:supports(surface, name)
+ if surface == "world" then
+ if supportedGeneration and SUPPORTED[name] then
+ return true
+ end
+ return false, "unsupported: API 3 world method " .. tostring(name)
+ end
+ return supports(self, surface, name)
+ end
+end
+return A
diff --git a/src/mods/Api3Catalog.lua b/src/mods/Api3Catalog.lua
new file mode 100644
index 000000000..0549286b9
--- /dev/null
+++ b/src/mods/Api3Catalog.lua
@@ -0,0 +1,57 @@
+-- Public callback-name snapshot from Gen1Recomp API 3, commit
+-- 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36. Recognition is not availability:
+-- Capabilities still requires a native Gen2Recomped call site.
+local function names(text)
+ local out = {}
+ for name in text:gmatch("%S+") do
+ out[name] = true
+ end
+ return out
+end
+local C = {}
+C.hooks = names([[
+battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.caught_marker_visible
+battle.charge_required battle.crit battle.damage battle.enemy_action
+battle.enemy_switch_or_item battle.exp_award battle.field_residual battle.low_health_alarm
+battle.menu_auxiliary battle.move_grid_navigation battle.overlay battle.run
+battle.status_hud_visible battle.style battle.turn_order breeding.compatibility
+catch.nickname catch.party_full catch.rate core.logic_speed
+core.quit_to_launcher core.update encounter.fishing encounter.roll
+encounter.species encounter.table evolution.check exp.gain
+fieldmove.eligibility gender.roll held_item.trigger input.gamepad
+input.key input.pointer input.step input.wheel
+intro.oak_speech.build item.use link.fingerprint map.palette
+movement.collision movement.speed music.select music.volume
+phone.contact_list player.sprite pokemon.icon pokemon.level_visible
+pokemon.sprite render.compose render.hud render.letterbox
+render.output render.output_enabled render.viewport render.window
+render.zones save.new_game save.write screen.render_visible
+script.command shiny.roll trainer.before_battle trainer.party
+transition.style ui.list_menu ui.naming.grid ui.options.rows
+ui.party.grid_navigation ui.party.submenu ui.pc.items ui.start_menu.items
+ui.title_menu.items warp.destination world.follower.spawn world.talk
+world.tod zoom.range
+]])
+C.events = names([[
+apricorn.converted assets.transformed battle.ball_thrown battle.battler_switched
+battle.damage_dealt battle.ended battle.exp_gained battle.fainted
+battle.move_used battle.started battle.status_inflicted battle.turn_ended
+battle.turn_started breeding.egg_created bug_contest.scored checkpoint.restored
+clock.day_changed discord.join_requested egg.hatched flag.changed
+game.ready happiness.changed intro.boot.copyright intro.boot.gamefreak
+intro.boot.movie intro.boot.movie_ended intro.boot.title intro.oak_speech.answered
+intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step link.battle_ended
+link.connected link.desync link.ended mail.read
+mail.written map.entered map.exited map.reloaded
+mod.options_changed mods.loaded music.started music.stopped
+phone.call_received player.warped pokemon.before_give pokemon.caught
+pokemon.evolved pokemon.level_up pokemon.move_learned pokemon.received
+pokerus.infected radio.channel roamer.encountered roamer.moved
+save.created save.loaded save.loading save.writing
+screen.popped screen.pushed script.ended script.started
+sound.played trade.completed unown.unlocked world.blacked_out
+world.block_replaced world.boulder_moved world.interacted world.live_maps_preparing
+world.live_maps_updated world.npc_spawned world.object_toggled world.stepped
+world.tod_changed world.trainer_engaged
+]])
+return C
diff --git a/src/mods/Api3Flags.lua b/src/mods/Api3Flags.lua
new file mode 100644
index 000000000..c7b937c73
--- /dev/null
+++ b/src/mods/Api3Flags.lua
@@ -0,0 +1,201 @@
+-- Portable references mapped to the flag stores the host VM/save converter use.
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local F = {}
+local catalogs, writing = {}, setmetatable({}, { __mode = "k" })
+local function integer(value, minimum, maximum)
+ return type(value) == "number"
+ and value == value
+ and value % 1 == 0
+ and value >= minimum
+ and value <= maximum
+end
+local function identifier(value)
+ return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c")
+end
+local function catalog(version)
+ local key = version == "blue" and "red" or version
+ if catalogs[key] then
+ return catalogs[key]
+ end
+ local data = require("src.mods.flag_names." .. key)
+ local out = { names = {}, ids = {}, limit = data.limit }
+ for id, row in pairs(data.records) do
+ if row[1] then
+ out.names[id] = row[1]
+ end
+ for _, name in ipairs(row) do
+ if name then
+ out.ids[name] = id
+ end
+ end
+ end
+ catalogs[key] = out
+ return out
+end
+function F.resolve(generation, version, name)
+ local supported = ({
+ red = 1,
+ blue = 1,
+ yellow = 1,
+ gold = 2,
+ silver = 2,
+ crystal = 2,
+ firered = 3,
+ emerald = 3,
+ })[version]
+ if supported ~= generation then
+ return nil, "native flag mapping unavailable for selected version"
+ end
+ if generation == 1 then
+ local t = catalog(version)
+ local id = integer(name, 0, 65535) and name or identifier(name) and t.ids[name]
+ if id ~= nil and t.names[id] then
+ return t.names[id], t.names[id], id
+ end
+ elseif generation == 2 then
+ local native = require("src.script.Gen2Flags")
+ local id = name
+ if identifier(name) then
+ if version == "crystal" then
+ return nil, "symbolic event flags unavailable for Crystal; use native ids"
+ end
+ id = native.EVENT_FLAG_INDEX[name]
+ end
+ if integer(id, 0, 2047) then
+ return native.eventFlag(id), id, id
+ end
+ elseif generation == 3 then
+ local t = catalog(version)
+ local id
+ if type(name) == "number" then
+ if integer(name, 1, t.limit - 1) and t.names[name] then
+ id = name
+ end
+ elseif identifier(name) then
+ id = t.ids[name]
+ end
+ if id and integer(id, 1, t.limit - 1) then
+ return ("FLAG_G3_%04X"):format(id), t.names[id] or id, id
+ end
+ end
+ return nil, "invalid or unknown flag"
+end
+-- Resolve without a Game/save for whole-program script preflight.
+function F.address(owner, gen, version, name)
+ local key
+ if type(name) == "string" then
+ if name:sub(1, 4) == "mod:" then
+ key = name:sub(5)
+ elseif name:sub(1, 4) == "mod." then
+ local prefix = "mod." .. owner .. "."
+ if name:sub(1, #prefix) ~= prefix then
+ return nil, "foreign mod flag"
+ end
+ key = name:sub(#prefix + 1)
+ end
+ end
+ if key then
+ if not identifier(key) then
+ return nil, "invalid mod flag"
+ end
+ return { kind = "mod", key = key }
+ end
+ local storage, public, number = F.resolve(gen, version, name)
+ if not storage then
+ return nil, public
+ end
+ return { kind = "native", key = storage, public = public, number = number }
+end
+function F.call(game, owner, gen, name, value, write, licensed, overworld)
+ if write and not licensed() then
+ return nil, "world facade is retired"
+ end
+ if write and type(value) ~= "boolean" then
+ return nil, "flag value must be boolean"
+ end
+ local save = game.save
+ if not save then
+ return nil, "no save"
+ end
+ local address, reason = F.address(owner, gen, Version.get(), name)
+ if not address then
+ return nil, reason
+ end
+ if address.kind == "mod" then
+ local key = address.key
+ local store = save.modData
+ if store ~= nil and type(store) ~= "table" then
+ return nil, "invalid mod save store"
+ end
+ local bucket = store and store[owner]
+ if bucket ~= nil and type(bucket) ~= "table" then
+ return nil, "invalid mod save store"
+ end
+ local flags = bucket and bucket.worldFlags
+ if flags ~= nil and type(flags) ~= "table" then
+ return nil, "invalid mod flag store"
+ end
+ if not write then
+ return flags ~= nil and flags[key] == true
+ end
+ if writing[save] then
+ return nil, "world is busy"
+ end
+ local before = flags ~= nil and flags[key] == true
+ save.modData = store or {}
+ bucket = bucket or {}
+ save.modData[owner] = bucket
+ bucket.worldFlags = flags or {}
+ bucket.worldFlags[key] = value or nil
+ if before ~= value then
+ writing[save] = true
+ local ok, err =
+ pcall(Runtime.emit, "flag.changed", { name = "mod." .. owner .. "." .. key, value = value })
+ writing[save] = nil
+ if not ok then
+ error(err, 0)
+ end
+ end
+ return true
+ end
+ local storage, public, number = address.key, address.public, address.number
+ if type(save.flags) ~= "table" then
+ return nil, "invalid flag store"
+ end
+ if not write then
+ return save.flags[storage] == true
+ end
+ if writing[save] then
+ return nil, "world is busy"
+ end
+ writing[save] = true
+ local ok, err = pcall(function()
+ local before = save.flags[storage] == true
+ -- Native clear keeps false: visibility distinguishes cleared from untouched.
+ save.flags[storage] = value
+ if gen == 3 then
+ local ow = overworld(game)
+ if ow and ow.syncObjectVisibility then
+ for _, object in ipairs(ow.map and ow.map.def and ow.map.def.objects or {}) do
+ if object.eventFlag == storage then
+ ow:syncObjectVisibility(object)
+ end
+ end
+ end
+ end
+ if before ~= value then
+ local event = { name = public, value = value }
+ if gen == 3 then
+ event.id = number
+ end
+ Runtime.emit("flag.changed", event)
+ end
+ end)
+ writing[save] = nil
+ if not ok then
+ error(err, 0)
+ end
+ return true
+end
+return F
diff --git a/src/mods/Api3NPC.lua b/src/mods/Api3NPC.lua
new file mode 100644
index 000000000..8a4091f2a
--- /dev/null
+++ b/src/mods/Api3NPC.lua
@@ -0,0 +1,565 @@
+-- Public API-3 NPC handles. Engine objects never cross the handle boundary.
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local Edits = require("src.mods.Api3WorldEdits")
+local Collision = require("src.world.Collision")
+local copy = require("src.mods.Merge").deepCopy
+local N = {}
+local services = setmetatable({}, { __mode = "k" })
+local observers = setmetatable({}, { __mode = "k" })
+local moves = {}
+local dirs = { up = true, down = true, left = true, right = true }
+local unpack = table.unpack or unpack
+local function pack(...)
+ return { n = select("#", ...), ... }
+end
+local function integer(n, minimum, maximum)
+ return type(n) == "number"
+ and n == n
+ and n >= (minimum or 0)
+ and n < math.huge
+ and n % 1 == 0
+ and (not maximum or n <= maximum)
+end
+local function message(value)
+ local ok, text = pcall(tostring, value)
+ return ok and text or "unprintable error"
+end
+local function identifier(s)
+ return type(s) == "string" and #s > 0 and #s <= 256 and not s:find("%c")
+end
+local function reference(v)
+ return identifier(v) or integer(v)
+end
+-- Bounded data-only copy, including extra generation-native definition fields.
+local function definition(value)
+ local visiting, nodes, bytes = {}, 0, 0
+ local function clone(v, depth)
+ nodes = nodes + 1
+ if nodes > 4096 or depth > 16 then
+ error("NPC definition too large", 0)
+ end
+ local t = type(v)
+ if t == "string" then
+ bytes = bytes + #v
+ if #v > 4096 or bytes > 65536 then
+ error("NPC definition too large", 0)
+ end
+ return v
+ elseif t == "number" then
+ if v ~= v or v == math.huge or v == -math.huge then
+ error("invalid NPC definition", 0)
+ end
+ return v
+ elseif t == "nil" or t == "boolean" then
+ return v
+ elseif t ~= "table" or getmetatable(v) ~= nil or visiting[v] then
+ error("invalid NPC definition", 0)
+ end
+ visiting[v] = true
+ local out, count = {}, 0
+ for k, item in pairs(v) do
+ count = count + 1
+ if count > 256 or type(k) ~= "string" and not integer(k) then
+ error("invalid NPC definition", 0)
+ end
+ out[clone(k, depth + 1)] = clone(item, depth + 1)
+ end
+ visiting[v] = nil
+ return out
+ end
+ if type(value) ~= "table" then
+ return nil, "invalid NPC definition"
+ end
+ local ok, result = pcall(clone, value, 0)
+ if not ok then
+ return nil, result
+ end
+ if not integer(result.x, 0, 1000000) or not integer(result.y, 0, 1000000) then
+ return nil, "invalid NPC coordinates"
+ end
+ for _, key in ipairs({ "name", "sprite" }) do
+ if result[key] ~= nil and not identifier(result[key]) then
+ return nil, "invalid NPC " .. key
+ end
+ end
+ if result.facing ~= nil and not dirs[result.facing] then
+ return nil, "invalid NPC facing"
+ end
+ return result
+end
+local function removeFrom(list, predicate)
+ for i = #(list or {}), 1, -1 do
+ if predicate(list[i]) then
+ table.remove(list, i)
+ end
+ end
+end
+local function retire(move)
+ move.done = true
+ moves[move] = nil
+ move.callback = nil
+ local removed = false
+ removeFrom(move.world.scriptMoves, function(entry)
+ if move.entries[entry] then
+ removed = true
+ return true
+ end
+ end)
+ if removed then
+ local foreign = false
+ for _, entry in ipairs(move.world.scriptMoves or {}) do
+ if entry.entity == move.actor then
+ foreign = true
+ end
+ end
+ if not foreign then
+ move.actor.moving, move.actor.marching = false, false
+ move.actor.targetX, move.actor.targetY = nil, nil
+ move.actor.px, move.actor.py = move.actor.cellX * 16, move.actor.cellY * 16
+ end
+ end
+end
+local function removeSpawn(token)
+ -- Scoped by exact native record identity, even after Game/provider changes.
+ local rows = token.data.maps and token.data.maps[token.mapId]
+ local removed = false
+ removeFrom(rows and rows.objects, function(row)
+ if row == token.record then
+ removed = true
+ return true
+ end
+ end)
+ local function owned(actor)
+ return actor
+ and (
+ actor == token.actor
+ or actor.def == token.record
+ or removed and actor.id == token.mapId .. "_obj_" .. tostring(token.record.index)
+ )
+ end
+ removeFrom(token.world.npcs, owned)
+ removeFrom(token.world.entities, owned)
+ removeFrom(token.world.ghosts, function(g)
+ return owned(g.npc)
+ end)
+ for id, actor in pairs(token.world.npcPool or {}) do
+ if owned(actor) then
+ token.world.npcPool[id] = nil
+ end
+ end
+end
+function N.cancelAll(loader, owner)
+ for service in pairs(services) do
+ if (not loader or loader == service.loader) and (not owner or owner == service.owner) then
+ service.closed = true
+ for move in pairs(moves) do
+ if move.service == service then
+ retire(move)
+ end
+ end
+ for _, token in ipairs(service.spawned) do
+ removeSpawn(token)
+ end
+ service.spawned = {}
+ services[service] = nil
+ end
+ end
+end
+local function inspect(loader)
+ for move in pairs(moves) do
+ if move.service.loader == loader then
+ local ok, valid = pcall(move.valid)
+ if not ok or not valid then
+ retire(move)
+ end
+ end
+ end
+end
+local function observe(loader)
+ if observers[loader] and observers[loader][1] == loader.hooks then
+ return
+ end
+ observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" })
+ loader.hooks:wrap("input.step", function(nextFn, ...)
+ if next(moves) == nil then
+ return nextFn(...)
+ end
+ inspect(loader)
+ local result = pack(pcall(nextFn, ...))
+ inspect(loader)
+ if not result[1] then
+ error(result[2], 0)
+ end
+ return unpack(result, 2, result.n)
+ end, math.huge, "engine:api3_npc")
+ for _, event in ipairs({ "map.exited", "map.entered" }) do
+ loader.events:on(event, function()
+ for move in pairs(moves) do
+ if move.service.loader == loader then
+ retire(move)
+ end
+ end
+ end, math.huge, "engine:api3_npc")
+ end
+end
+function N.new(loader, owner, gen, current, native, licensed, req)
+ local service = { loader = loader, owner = owner, spawned = {} }
+ services[service] = true
+ observe(loader)
+ local function alive()
+ return not service.closed and licensed()
+ end
+ local function resolveGame()
+ if not alive() then
+ return nil, nil, "world facade is retired"
+ end
+ local game = current()
+ if not game then
+ return nil, nil, "no game"
+ end
+ local ow = native(game):overworld()
+ if not ow then
+ return nil, nil, "no overworld"
+ end
+ return game, ow
+ end
+ local function mapId(game, id)
+ if gen == 3 and not (game.data.maps or {})[id] then
+ local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_"
+ if (game.data.maps or {})[prefix .. id] then
+ return prefix .. id
+ end
+ end
+ return id
+ end
+ local function actorAt(ow, id, ref)
+ local function matches(actor)
+ local def = actor.def or {}
+ return actor.id == ref or def.index == ref or def.name == ref
+ end
+ if ow.map and ow.map.id == id then
+ for _, actor in ipairs(ow.npcs or {}) do
+ if matches(actor) then
+ return actor, ow.map, ow.cast or ow.entities
+ end
+ end
+ return nil, "no such object: " .. tostring(ref)
+ end
+ for _, g in ipairs(ow.ghosts or {}) do
+ local actor = g.npc
+ local map = g.map
+ if map and map.id == id and actor and matches(actor) then
+ local peers = g.nb and g.nb.cast or g.peers
+ if not peers then
+ peers = {}
+ for _, sibling in ipairs(ow.ghosts) do
+ if sibling.map == map then
+ peers[#peers + 1] = sibling.npc
+ end
+ end
+ end
+ return actor, map, peers, true
+ end
+ end
+ return nil, "map is not live"
+ end
+ local function sprite(game, id)
+ return game.data and game.data.sprites and game.data.sprites[id]
+ end
+ local function queue(game, ow, actor, valid, name, a, b, callback)
+ local hooks, events = Runtime.hooks, Runtime.events
+ local move = { service = service, world = ow, actor = actor, entries = {}, callback = callback }
+ local save, data, stack, map, version, fastWorld =
+ game.save, game.data, game.stack, ow.map, Version.get(), game.overworld
+ function move.valid()
+ return alive()
+ and current() == game
+ and game.save == save
+ and game.data == data
+ and game.stack == stack
+ and game.overworld == fastWorld
+ and ow.map == map
+ and Version.get() == version
+ and Runtime.hooks == hooks
+ and Runtime.events == events
+ and valid()
+ end
+ local accepted, deferred
+ local function finish(...)
+ if move.done then
+ return
+ end
+ if accepted == nil then
+ if not deferred then
+ deferred = pack(...)
+ end
+ return
+ end
+ local checked, valid = pcall(move.valid)
+ local fn = accepted and checked and valid and move.callback
+ retire(move)
+ if not fn then
+ return
+ end
+ local previous = Runtime.currentMod
+ Runtime.currentMod = owner
+ local ok, why = pcall(fn, ...)
+ Runtime.currentMod = previous
+ if not ok then
+ Runtime.reportError(owner, "NPC movement callback failed: " .. message(why))
+ end
+ end
+ local before = {}
+ for _, entry in ipairs(ow.scriptMoves or {}) do
+ before[entry] = true
+ end
+ moves[move] = true
+ local called, result, detail
+ if name == "scriptMove" then
+ called, result, detail = pcall(ow.scriptMove, ow, actor, a, b or 1, finish)
+ else
+ called, result, detail = pcall(ow.marchInPlace, ow, actor, finish)
+ end
+ for _, entry in ipairs(ow.scriptMoves or {}) do
+ if not before[entry] and entry.entity == actor then
+ move.entries[entry] = true
+ end
+ end
+ accepted = called and result ~= false and not (result == nil and detail ~= nil)
+ if accepted then
+ local checked, valid = pcall(move.valid)
+ accepted = checked and valid and not move.done
+ end
+ if not accepted then
+ retire(move)
+ return nil,
+ called and (detail or "NPC movement failed") or "world operation failed: " .. message(result)
+ end
+ local drain = deferred and function()
+ finish(unpack(deferred, 1, deferred.n))
+ end
+ return true, nil, drain
+ end
+ local function handle(game, ow, id, ref)
+ local actor, map = actorAt(ow, id, ref)
+ if not actor then
+ return nil, map
+ end
+ local publicId = actor.id
+ if not identifier(publicId) then
+ return nil, "NPC identity unavailable"
+ end
+ local save, data, version, fastWorld = game.save, game.data, Version.get(), game.overworld
+ local function live()
+ if
+ not alive()
+ or current() ~= game
+ or game.save ~= save
+ or game.data ~= data
+ or game.overworld ~= fastWorld
+ or Version.get() ~= version
+ or native(game):overworld() ~= ow
+ then
+ return
+ end
+ local found, activeMap, peers, ghost = actorAt(ow, id, publicId)
+ if found ~= actor or activeMap ~= map then
+ return
+ end
+ return actor, activeMap, peers, ghost
+ end
+ local out = { id = publicId }
+ local function invoke(name, a, b, c)
+ if not alive() then
+ return nil, "world facade is retired"
+ end
+ local npc, onMap, peers, ghost = live()
+ if not npc then
+ return nil, "NPC handle is stale"
+ end
+ if name == "position" then
+ return npc.cellX, npc.cellY
+ end
+ if name == "isMoving" then
+ return not not npc.moving
+ end
+ if name == "canStep" or name == "stepNow" or name == "face" or name == "scriptMove" then
+ if not dirs[a] then
+ return nil, "invalid direction"
+ end
+ end
+ if name == "canStep" then
+ return not not Collision.canMove(onMap, peers or {}, npc, a)
+ end
+ if
+ name == "scriptMove" and (b ~= nil and not integer(b, 1, 1024) or c ~= nil and type(c) ~= "function")
+ then
+ return nil, "invalid movement"
+ end
+ if name == "marchInPlace" and a ~= nil and type(a) ~= "function" then
+ return nil, "invalid callback"
+ end
+ if
+ name == "placeAt"
+ and (not integer(a, 0, 1000000) or not integer(b, 0, 1000000) or c ~= nil and not dirs[c])
+ then
+ return nil, "invalid coordinates"
+ end
+ if name == "setPassable" and type(a) ~= "boolean" then
+ return nil, "invalid passability"
+ end
+ if name == "setAppearance" and not identifier(a) then
+ return nil, "invalid sprite id"
+ end
+ local ok, why = Edits.ready(game, ow)
+ if not ok then
+ return nil, why
+ end
+ if ghost and (name == "scriptMove" or name == "marchInPlace") then
+ return nil, "movement tracks require the active map"
+ end
+ local drain
+ local value, detail = Edits.run(game, function()
+ if name == "scriptMove" or name == "marchInPlace" then
+ local value, why
+ local callback = a
+ if name == "scriptMove" then
+ callback = c
+ end
+ value, why, drain = queue(game, ow, npc, live, name, a, b, callback)
+ return value, why
+ elseif name == "face" then
+ npc.facing = a
+ elseif name == "stepNow" then
+ if npc.moving then
+ return nil, "already moving"
+ end
+ npc.facing = a
+ npc.targetX, npc.targetY = Collision.target(npc.cellX, npc.cellY, a)
+ npc.moving, npc.progress = true, 0
+ elseif name == "placeAt" then
+ npc.moving, npc.marching, npc.progress = false, false, 0
+ npc.targetX, npc.targetY = nil, nil
+ npc.cellX, npc.cellY, npc.px, npc.py = a, b, a * 16, b * 16
+ if c then
+ npc.facing = c
+ end
+ elseif name == "setPassable" then
+ npc.passable = a
+ elseif name == "setAppearance" then
+ if not sprite(game, a) then
+ return nil, "unknown sprite: " .. a
+ end
+ local proxy = { id = npc.id, def = copy(npc.def) }
+ proxy.def.sprite = a
+ proxy.def.graphicsId, proxy.def.graphics, proxy.def.graphicsVar, proxy.def.varSprite =
+ nil, nil, nil, nil
+ req("src.world.NPC").refreshSprite(proxy, game.data)
+ npc.def, npc.sprite, npc.appearance = proxy.def, proxy.sprite, a
+ npc.big = proxy.sprite and proxy.sprite.big or false
+ end
+ return true
+ end)
+ if drain then
+ drain()
+ end
+ return value, detail
+ end
+ for _, name in ipairs({
+ "position",
+ "isMoving",
+ "canStep",
+ "scriptMove",
+ "marchInPlace",
+ "face",
+ "stepNow",
+ "placeAt",
+ "setPassable",
+ "setAppearance",
+ }) do
+ local method = name
+ out[method] = function(_, ...)
+ local previous = Runtime.currentMod
+ Runtime.currentMod = nil
+ local result = pack(pcall(invoke, method, ...))
+ Runtime.currentMod = previous
+ if not result[1] then
+ return nil, "world operation failed: " .. message(result[2])
+ end
+ return unpack(result, 2, result.n)
+ end
+ end
+ return out
+ end
+ return function(name, a, b)
+ local game, ow, why = resolveGame()
+ if not game then
+ return nil, why
+ end
+ if name == "npc" then
+ if not identifier(a) or not reference(b) then
+ return nil, "invalid NPC reference"
+ end
+ return handle(game, ow, mapId(game, a), b)
+ end
+ local ok, reason = Edits.ready(game, ow)
+ if not ok then
+ return nil, reason
+ end
+ return Edits.run(game, function()
+ if name == "spawnNpc" then
+ if not identifier(a) or type(b) ~= "table" then
+ return nil, "invalid NPC definition"
+ end
+ local id = mapId(game, a)
+ local mapDef = game.data.maps and game.data.maps[id]
+ if not mapDef then
+ return nil, "unknown map: " .. a
+ end
+ local row, err = definition(b)
+ if not row then
+ return nil, err
+ end
+ if row.sprite and not sprite(game, row.sprite) then
+ return nil, "unknown sprite: " .. row.sprite
+ end
+ local before = {}
+ for _, item in ipairs(mapDef.objects or {}) do
+ before[item] = true
+ end
+ local called, value, detail = pcall(native(game).spawnNpc, native(game), id, row)
+ for _, item in ipairs(mapDef.objects or {}) do
+ if not before[item] and item.runtime and item.owner == owner then
+ local publicId = id .. "_obj_" .. tostring(item.index)
+ local actor = actorAt(ow, id, publicId)
+ local token = { data = game.data, world = ow, mapId = id, record = item, actor = actor }
+ if not called or not value then
+ removeSpawn(token)
+ else
+ service.spawned[#service.spawned + 1] = token
+ end
+ end
+ end
+ if not called then
+ return nil, "world operation failed: " .. message(value)
+ end
+ return value, detail
+ end
+ if not reference(a) or gen == 3 and not identifier(a) then
+ return nil, "invalid NPC id"
+ end
+ local value, err = native(game):removeNpc(a)
+ if value then
+ for i = #service.spawned, 1, -1 do
+ local token = service.spawned[i]
+ if token.mapId .. "_obj_" .. tostring(token.record.index) == a then
+ table.remove(service.spawned, i)
+ end
+ end
+ end
+ return value, err
+ end)
+ end
+end
+return N
diff --git a/src/mods/Api3Records.lua b/src/mods/Api3Records.lua
new file mode 100644
index 000000000..22d540b20
--- /dev/null
+++ b/src/mods/Api3Records.lua
@@ -0,0 +1,59 @@
+-- API-3 public Pokemon values. API-2 unified records keep their existing shape.
+local R = {}
+local Stats = require("src.pokemon.Stats")
+local bit = require("bit")
+local LONG = { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" }
+local SHORT = {}
+for short, long in pairs(LONG) do
+ SHORT[long], SHORT[short] = short, short
+end
+function R.status(value)
+ if value == false or value == "" or value == 0 then
+ return nil
+ end
+ return SHORT[value] or value
+end
+function R.nativeStatus(value, previous)
+ if value == nil or value == false then
+ return nil
+ end
+ local short = R.status(value)
+ assert(LONG[short], "unknown status")
+ if previous and LONG[SHORT[previous]] == previous then
+ return LONG[short]
+ end
+ return short
+end
+function R.stats(out, gen)
+ out.special = gen ~= 1 and out.specialAttack or out.special
+ return out
+end
+function R.shiny(raw, gen)
+ if raw.shiny ~= nil then
+ return raw.shiny == true
+ end
+ if gen ~= 3 then
+ return Stats.isShiny(raw.dvs)
+ end
+ if raw.isShiny ~= nil then
+ return raw.isShiny == true
+ end
+ local p = (tonumber(raw.personality) or 0) % 4294967296
+ local id = (tonumber(raw.otId or raw.trainerId) or 0) % 4294967296
+ local secret = tonumber(raw.otSecretId) or math.floor(id / 65536)
+ return bit.bxor(id % 65536, secret, math.floor(p / 65536), p % 65536) < 8
+end
+function R.mon(out, data, gen)
+ if type(out) ~= "table" then
+ return out
+ end
+ out.status = R.status(out.status)
+ out.stats = R.stats(out.stats or {}, gen)
+ local raw = out.native or {}
+ local def = ((data or {}).pokemon or {})[raw.species or out.species] or {}
+ out.name = out.nickname and out.nickname ~= "" and out.nickname or out.name or def.name or out.species
+ out.dvs = gen ~= 3 and type(raw.dvs) == "table" and raw.dvs or nil -- raw is already a detached snapshot
+ out.shiny = R.shiny(raw, gen)
+ return out
+end
+return R
diff --git a/src/mods/Api3Warp.lua b/src/mods/Api3Warp.lua
new file mode 100644
index 000000000..c447cdec1
--- /dev/null
+++ b/src/mods/Api3Warp.lua
@@ -0,0 +1,265 @@
+-- API-3 warp admission and callback lifetime over the native overworld path.
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local Actions = require("src.world.WorldActions")
+local W = {}
+local contexts = setmetatable({}, { __mode = "k" })
+local observers = setmetatable({}, { __mode = "k" })
+local pending = setmetatable({}, { __mode = "k" })
+local dirs = { up = true, down = true, left = true, right = true }
+local unpack = table.unpack or unpack
+local function pack(...)
+ return { n = select("#", ...), ... }
+end
+local function integer(n)
+ return type(n) == "number" and n == n and n >= 0 and n < math.huge and n % 1 == 0
+end
+local function retire(token)
+ token.done = true
+ token.callback = nil
+ if pending[token.game] == token then
+ pending[token.game] = nil
+ end
+end
+function W.cancelAll(loader, owner)
+ for candidate, context in pairs(contexts) do
+ if not loader or candidate == loader then
+ if owner then
+ context.owners[owner] = nil
+ else
+ contexts[candidate] = nil
+ end
+ end
+ end
+ for _, token in pairs(pending) do
+ if (not loader or token.loader == loader) and (not owner or token.owner == owner) then
+ retire(token)
+ end
+ end
+end
+local function inspect(loader)
+ for _, token in pairs(pending) do
+ if token.loader == loader and not token.valid() then
+ retire(token)
+ end
+ end
+end
+local function observe(loader)
+ local old = observers[loader]
+ if old and old[1] == loader.hooks then
+ return
+ end
+ observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" })
+ -- Installed at facade creation, never spliced into a dispatching hook chain.
+ loader.hooks:wrap("input.step", function(nextFn, ...)
+ if next(pending) == nil then
+ return nextFn(...)
+ end
+ inspect(loader)
+ local result = pack(pcall(nextFn, ...))
+ inspect(loader)
+ if not result[1] then
+ error(result[2], 0)
+ end
+ return unpack(result, 2, result.n)
+ end, math.huge, "engine:api3_warp")
+ for _, event in ipairs({ "map.entered", "map.exited" }) do
+ local name = event
+ loader.events:on(name, function(payload)
+ for _, token in pairs(pending) do
+ if token.loader == loader then
+ if not token.valid() then
+ retire(token)
+ elseif name == "map.entered" then
+ if token.entered or payload.mapId ~= token.destination then
+ retire(token)
+ else
+ token.entered = true
+ end
+ elseif token.entered then
+ retire(token)
+ end
+ end
+ end
+ end, math.huge, "engine:api3_warp")
+ end
+end
+-- Pure destination validation shared by public warps and portable scripts.
+function W.plan(game, ow, generation, mapId, x, y, facing)
+ if type(mapId) ~= "string" or #mapId == 0 or #mapId > 256 or mapId:find("%c") then
+ return nil, "invalid map id"
+ end
+ local maps = game.data and game.data.maps
+ local destination = mapId
+ if generation == 3 and maps and not maps[destination] then
+ local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_"
+ if maps[prefix .. destination] then
+ destination = prefix .. destination
+ end
+ end
+ local def = maps and maps[destination]
+ if not def then
+ return nil, "unknown map: " .. mapId
+ end
+ if not integer(x) or not integer(y) then
+ return nil, "invalid coordinates"
+ end
+ local scale = generation == 3 and 1 or 2
+ if not integer(def.width) or def.width == 0 or not integer(def.height) or def.height == 0 then
+ return nil, "invalid map dimensions"
+ end
+ if x >= def.width * scale or y >= def.height * scale then
+ return nil, "coordinates out of bounds"
+ end
+ if facing ~= nil and not dirs[facing] then
+ return nil, "invalid facing"
+ end
+ return {
+ mapId = destination,
+ x = x,
+ y = y,
+ facing = facing or (generation ~= 1 and ow.player.facing) or "down",
+ def = def,
+ }
+end
+function W.new(loader, owner, generation, current, native)
+ local context = contexts[loader]
+ if not context then
+ context = { owners = {} }
+ contexts[loader] = context
+ end
+ local lease = {}
+ context.owners[owner] = lease
+ local function licensed()
+ return contexts[loader] == context and context.owners[owner] == lease
+ end
+ observe(loader)
+ local function warp(mapId, x, y, facing, options)
+ if not licensed() then
+ return nil, "warp facade is retired"
+ end
+ local game = current()
+ if not game then
+ return nil, "no game"
+ end
+ local api = native(game)
+ local ow = api:overworld()
+ if not ow then
+ return nil, "no overworld"
+ end
+ local ready, why = Actions.ready(game, ow)
+ if not ready then
+ return nil, why
+ end
+ inspect(loader)
+ if pending[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then
+ return nil, "world is busy"
+ end
+ local plan, reason = W.plan(game, ow, generation, mapId, x, y, facing)
+ if not plan then
+ return nil, reason
+ end
+ local destination = plan.mapId
+ if options ~= nil and (type(options) ~= "table" or getmetatable(options) ~= nil) then
+ return nil, "invalid options"
+ end
+ local opts = {}
+ for key, value in pairs(options or {}) do
+ if key == "arrive" then
+ if value ~= "fly" and value ~= "teleport" then
+ return nil, "invalid arrival"
+ end
+ elseif key == "onDone" then
+ if type(value) ~= "function" then
+ return nil, "invalid callback"
+ end
+ elseif key == "keepMusic" or key == "fade" then
+ if type(value) ~= "boolean" then
+ return nil, "invalid options"
+ end
+ if key == "fade" and generation ~= 3 then
+ return nil, "unsupported fade"
+ end
+ else
+ return nil, "unknown warp option: " .. tostring(key)
+ end
+ opts[key] = value
+ end
+ facing = plan.facing
+ local hooks, events = Runtime.hooks, Runtime.events
+ local token =
+ { game = game, loader = loader, owner = owner, destination = destination, callback = opts.onDone }
+ local save, data, stack, player, version = game.save, game.data, game.stack, ow.player, Version.get()
+ function token.valid()
+ return not token.done
+ and licensed()
+ and current() == game
+ and game.save == save
+ and game.data == data
+ and game.stack == stack
+ and ow.player == player
+ and Version.get() == version
+ and Runtime.hooks == hooks
+ and Runtime.events == events
+ and native(game):overworld() == ow
+ end
+ local accepted, deferred
+ local function finish(...)
+ if token.done then
+ return
+ end
+ if accepted == nil then
+ if not deferred then
+ deferred = pack(...)
+ end
+ return
+ end
+ local valid = token.valid() and ow.map and ow.map.id == destination
+ local callback = valid and token.callback
+ retire(token)
+ if not accepted or not callback then
+ return
+ end
+ local previous = Runtime.currentMod
+ Runtime.currentMod = owner
+ local result = pack(pcall(callback, ...))
+ Runtime.currentMod = previous
+ if not result[1] then
+ Runtime.reportError(owner, "world warp callback failed: " .. tostring(result[2]))
+ end
+ end
+ pending[game] = token
+ local arrival = ow.arriveWarp
+ if opts.arrive then
+ ow.arriveWarp = opts.arrive
+ end
+ local called, result, detail = pcall(
+ ow.startWarpTo,
+ ow,
+ destination,
+ x,
+ y,
+ facing,
+ finish,
+ { via = "warp", keepMusic = opts.keepMusic, fade = opts.fade }
+ )
+ -- The native controller is a void method. Explicit false/refusal and errors
+ -- never count as admission; a callback fired before that result is deferred.
+ accepted = called and result ~= false and not (result == nil and detail ~= nil)
+ if not accepted then
+ retire(token)
+ ow.arriveWarp = arrival
+ if not called then
+ return nil, "world operation failed: " .. tostring(result)
+ end
+ return nil, detail or "world operation failed"
+ end
+ if deferred then
+ finish(unpack(deferred, 1, deferred.n))
+ end
+ return true
+ end
+ -- Synchronous API-3 edits share this owner lease and loader lifecycle.
+ return warp, licensed
+end
+return W
diff --git a/src/mods/Api3WildBattle.lua b/src/mods/Api3WildBattle.lua
new file mode 100644
index 000000000..8f5ca5ece
--- /dev/null
+++ b/src/mods/Api3WildBattle.lua
@@ -0,0 +1,205 @@
+-- API-3 encounter admission over native construction, transition and afterBattle.
+-- Portable scripts may supply a private owned guard and native trainer plan.
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local Edits = require("src.mods.Api3WorldEdits")
+local Ids = require("src.mods.UnifiedIds")
+local Party = require("src.pokemon.Party")
+local B = {}
+local pending, observers = {}, setmetatable({}, { __mode = "k" })
+local unpack = table.unpack or unpack
+local function pack(...)
+ return { n = select("#", ...), ... }
+end
+local function text(value)
+ local ok, result = pcall(tostring, value)
+ return ok and result or "unprintable error"
+end
+local function retire(token)
+ token.retired, token.callback = true, nil
+ pending[token] = nil
+end
+function B.cancelAll(loader, owner)
+ for token in pairs(pending) do
+ if (not loader or loader == token.loader) and (not owner or owner == token.owner) then
+ retire(token)
+ end
+ end
+end
+local function inspect(loader)
+ for token in pairs(pending) do
+ if token.loader == loader and not token.valid() then
+ retire(token)
+ end
+ end
+end
+local function observe(loader)
+ if observers[loader] == loader.hooks then
+ return
+ end
+ observers[loader] = loader.hooks
+ loader.hooks:wrap("input.step", function(nextFn, ...)
+ inspect(loader)
+ local result = pack(pcall(nextFn, ...))
+ inspect(loader)
+ if not result[1] then
+ error(result[2], 0)
+ end
+ return unpack(result, 2, result.n)
+ end, math.huge, "engine:api3_wild_battle")
+end
+function B.new(loader, owner, current, native, licensed, req, ownedReady, ownedNative)
+ local ids = Ids.new(loader)
+ observe(loader)
+ return function(species, level, callback)
+ if not licensed() then
+ return nil, "world facade is retired"
+ end
+ local game = current()
+ if not game then
+ return nil, "no game"
+ end
+ local ow = native(game):overworld()
+ if not ow then
+ return nil, "no overworld"
+ end
+ local ok, reason = (ownedReady or Edits.ready)(game, ow)
+ if not ok then
+ return nil, reason
+ end
+ for token in pairs(pending) do
+ if token.game == game then
+ if token.valid() then
+ return nil, "world is busy"
+ end
+ retire(token)
+ end
+ end
+ local key, why
+ if ownedNative then
+ key, why = ownedNative.resolve(game, species, level)
+ if not key then
+ return nil, why
+ end
+ if callback ~= nil and type(callback) ~= "function" then
+ return nil, "invalid callback"
+ end
+ else
+ if type(species) ~= "string" or #species == 0 or #species > 256 or species:find("%c") then
+ return nil, "invalid species id"
+ end
+ if type(level) ~= "number" or level ~= level or level < 1 or level > 100 or level % 1 ~= 0 then
+ return nil, "level must be a whole number 1..100"
+ end
+ if callback ~= nil and type(callback) ~= "function" then
+ return nil, "invalid callback"
+ end
+ local group, why = ids:resolve("pokemon", species)
+ if not group then
+ return nil, why:find("^ambiguous") and why or "unknown species: " .. species
+ end
+ key = group.key
+ if not (game.data.pokemon and game.data.pokemon[key]) then
+ return nil, "unknown species: " .. species
+ end
+ end
+ if not Party.firstHealthy(game.save.party or {}) then
+ return nil, "no healthy party"
+ end
+ if type(ow.pushBattle) ~= "function" or type(ow.afterBattle) ~= "function" then
+ return nil, "battle runtime unavailable"
+ end
+ local save, data, stack, player, world = game.save, game.data, game.stack, ow.player, game.overworld
+ local version, hooks, events = Version.get(), Runtime.hooks, Runtime.events
+ local token = { loader = loader, owner = owner, game = game, callback = callback }
+ local function live()
+ return current() == game
+ and game.save == save
+ and game.data == data
+ and game.stack == stack
+ and game.overworld == world
+ and ow.player == player
+ and native(game):overworld() == ow
+ and Version.get() == version
+ end
+ function token.valid()
+ return not token.retired
+ and licensed()
+ and live()
+ and Runtime.hooks == hooks
+ and Runtime.events == events
+ end
+ local admitted, deferred, finished, battle
+ local function finish(...)
+ if finished then
+ return
+ end
+ if admitted == nil then
+ if not deferred then
+ deferred = pack(...)
+ end
+ return
+ end
+ finished = true
+ local valid, fn = token.valid(), token.callback
+ retire(token)
+ if not admitted or not live() then
+ return
+ end
+ -- Native cleanup still runs after the originating mod has been unloaded.
+ local outcome = (...)
+ local called, err = pcall(function()
+ if ownedNative and ownedNative.cleanup then
+ ownedNative.cleanup(game, ow, key, outcome, battle)
+ end
+ ow:afterBattle(outcome, battle)
+ end)
+ if not called then
+ Runtime.reportError(owner, "world battle cleanup failed: " .. text(err))
+ if ownedReady and valid and fn then
+ pcall(fn, nil, "native battle cleanup failed: " .. text(err))
+ end
+ return
+ end
+ if valid and licensed() and live() and fn then
+ local previous = Runtime.currentMod
+ Runtime.currentMod = owner
+ called, err = pcall(fn, ...)
+ Runtime.currentMod = previous
+ if not called then
+ Runtime.reportError(owner, "world battle callback failed: " .. text(err))
+ end
+ end
+ end
+ pending[token] = true
+ local called, accepted, err = pcall(Edits.run, game, function()
+ if ownedNative then
+ battle = ownedNative.construct(game, key)
+ else
+ battle = req("src.battle.BattleState").newWild(game, key, level)
+ end
+ if not battle or battle.dead then
+ return nil, "battle failed"
+ end
+ if not token.valid() or ownedReady and not ownedReady(game, ow) then
+ return nil, "battle session changed"
+ end
+ battle.onFinish = finish
+ local value, detail = ow:pushBattle(battle)
+ if value == false or value == nil and detail ~= nil then
+ return nil, detail or "battle failed"
+ end
+ return true
+ end)
+ admitted = called and accepted == true
+ if not admitted then
+ retire(token)
+ return nil, called and (err or "battle failed") or "world operation failed: " .. text(accepted)
+ end
+ if deferred then
+ finish(unpack(deferred, 1, deferred.n))
+ end
+ return true
+ end
+end
+return B
diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua
new file mode 100644
index 000000000..77d8ce2eb
--- /dev/null
+++ b/src/mods/Api3WorldEdits.lua
@@ -0,0 +1,213 @@
+-- Validated API-3 object/block edits over this host's native runtime paths.
+local Actions = require("src.world.WorldActions")
+local Reads = require("src.mods.WorldReads")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local E = {}
+local running = setmetatable({}, { __mode = "k" })
+local function integer(value, maximum)
+ return type(value) == "number"
+ and value == value
+ and value >= 0
+ and value < math.huge
+ and value % 1 == 0
+ and (not maximum or value <= maximum)
+end
+local function identifier(value)
+ return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c")
+end
+local function store(value)
+ return value == nil or type(value) == "table"
+end
+local function toggle(game, ow, api, gen, req, mapId, ref, visible)
+ if not identifier(mapId) or not (identifier(ref) or integer(ref)) or type(visible) ~= "boolean" then
+ return nil, "invalid object toggle"
+ end
+ local maps = game.data and game.data.maps
+ local id = mapId
+ if gen == 3 and maps and not maps[id] then
+ local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_"
+ if maps[prefix .. id] then
+ id = prefix .. id
+ end
+ end
+ local def = maps and maps[id]
+ if not def then
+ return nil, "unknown map: " .. mapId
+ end
+ local save = game.save
+ if not store(save.objectToggles) or not store(save.objectToggles and save.objectToggles[id]) then
+ return nil, "invalid object toggle store"
+ end
+ if not store(save.flags) then
+ return nil, "invalid flag store"
+ end
+ if gen == 1 then
+ -- Gen 1 permits off-map keys and resolves their visibility on map load.
+ return function()
+ return api:toggleObject(id, ref, visible)
+ end
+ end
+ if ow.map.id ~= id then
+ return nil, "map is not active: " .. mapId
+ end
+ local objects = ow.map.def and ow.map.def.objects
+ if type(objects) ~= "table" then
+ return nil, "map has no objects"
+ end
+ local object
+ for i, candidate in ipairs(objects) do
+ local numeric = gen == 2 and i or tonumber(candidate.localId or candidate.index)
+ if numeric == ref or candidate.name == ref or gen == 3 and candidate.id == ref then
+ object = candidate
+ break
+ end
+ end
+ if not object then
+ return nil, "no such object: " .. tostring(ref)
+ end
+ if type(ow.syncObjectVisibility) ~= "function" then
+ return nil, "object runtime unavailable"
+ end
+ local ctx = { game = game, save = save, overworld = ow, mapId = id }
+ local index = object.index
+ if gen == 2 then
+ if not store(save.g2ObjectToggles) then
+ return nil, "invalid object toggle store"
+ end
+ else
+ if not integer(index, 254) or index == 0 then
+ return nil, "invalid object definition"
+ end
+ if
+ not store(save.gen3Spawned)
+ or not store(save.gen3Spawned and save.gen3Spawned[id])
+ or not store(save.gen3SessionObjects)
+ then
+ return nil, "invalid object toggle store"
+ end
+ end
+ return function()
+ if gen == 2 then
+ req("src.script.Gen2Commands").setObjectVisibility(ctx, object, id, visible)
+ else
+ req("src.script.Gen3Commands")
+ local commands = req("src.script.Commands")
+ local command = visible and commands.g3_show_object or commands.g3_hide_object
+ command(ctx, index)
+ end
+ Runtime.emit("world.object_toggled", { mapId = id, objName = ref, visible = visible })
+ return true
+ end
+end
+local function replace(game, ow, api, gen, req, bx, by, block)
+ if not integer(block, gen == 3 and 1023 or 255) then
+ return nil, "invalid block id"
+ end
+ local present, metadata = Reads.activeBlockAt(ow.map, ow.map.id, bx, by)
+ if present == nil then
+ return nil, metadata
+ end
+ local cells = gen == 3 and 1 or 2
+ if ow.map.blockCells ~= cells then
+ return nil, "unsupported block geometry"
+ end
+ if gen ~= 3 and not (ow.map.tileset and ow.map.tileset.blocks and ow.map.tileset.blocks[block + 1]) then
+ return nil, "unknown block id"
+ end
+ local renderer = ow.map.renderer
+ if not renderer or type(renderer.rebuild) ~= "function" then
+ return nil, "block renderer unavailable"
+ end
+ if gen ~= 3 then
+ return function()
+ return api:replaceBlock(bx, by, block)
+ end
+ end
+ return function()
+ -- Like Gen1's Gen3 Field.setMetatile default, use the native non-impassable
+ -- metatile command; it replaces the old collision override as well.
+ req("src.script.Gen3Commands")
+ req("src.script.Commands").g3_set_metatile(
+ { game = game, save = game.save, overworld = ow },
+ bx,
+ by,
+ block,
+ 0
+ )
+ renderer:rebuild()
+ ow.map.blocksDirty = nil
+ Runtime.emit("world.block_replaced", { mapId = ow.map.id, bx = bx, by = by, block = block })
+ return true
+ end
+end
+local function invalidate(game, ow, api, gen, req, mapId)
+ if not identifier(mapId) then
+ return nil, "unknown map"
+ end
+ local maps = game.data and game.data.maps
+ local id = mapId
+ if gen == 3 and maps and not maps[id] then
+ local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_"
+ if maps[prefix .. id] then
+ id = prefix .. id
+ end
+ end
+ if not (maps and maps[id]) then
+ return nil, "unknown map"
+ end
+ if type(ow.reloadMap) ~= "function" then
+ return nil, "map reload unavailable"
+ end
+ return function()
+ return api:invalidateMap(id)
+ end
+end
+-- Shared admission/commit guard for synchronous API-3 world mutations.
+function E.ready(game, ow)
+ local ok, reason = Actions.ready(game, ow)
+ if not ok then
+ return nil, reason
+ end
+ if running[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then
+ return nil, "world is busy"
+ end
+ return true
+end
+function E.run(game, operation, ...)
+ running[game] = true
+ local called, value, why = pcall(operation, ...)
+ running[game] = nil
+ if not called then
+ error(value, 0)
+ end
+ return value, why
+end
+-- Pure validation shared by public edits and an already owned script run.
+function E.plan(game, api, generation, req, name, ...)
+ local ow = api:overworld()
+ if not ow then
+ return nil, "no overworld"
+ end
+ local operation = name == "toggleObject" and toggle or name == "invalidateMap" and invalidate or replace
+ return operation(game, ow, api, generation, req, ...)
+end
+function E.call(game, api, generation, req, name, ...)
+ local ow = api:overworld()
+ if not ow then
+ return nil, "no overworld"
+ end
+ local ok, reason = E.ready(game, ow)
+ if not ok then
+ return nil, reason
+ end
+ local function execute(...)
+ local operation, reason = E.plan(game, api, generation, req, name, ...)
+ if not operation then
+ return nil, reason
+ end
+ return operation()
+ end
+ return E.run(game, execute, ...)
+end
+return E
diff --git a/src/mods/BattleIntents.lua b/src/mods/BattleIntents.lua
new file mode 100644
index 000000000..5daa69311
--- /dev/null
+++ b/src/mods/BattleIntents.lua
@@ -0,0 +1,178 @@
+-- Data-only intent validation and private cross-facade submission state.
+local Intents = {}
+local states = setmetatable({}, { __mode = "k" })
+local FIELDS = {
+ menu = { choice = true },
+ move = { slot = true },
+ target = { target = true },
+ back = {},
+ safari = { action = true },
+ mimic = { index = true },
+}
+local MENU = { fight = true, party = true, item = true, run = true }
+local SAFARI = { ball = true, bait = true, rock = true, run = true }
+local function integer(v, lo, hi)
+ return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi
+end
+function Intents.normalize(raw)
+ if type(raw) ~= "table" or getmetatable(raw) ~= nil then
+ return nil, "intent must be a plain table"
+ end
+ if not integer(raw.id, 1, 9007199254740991) then
+ return nil, "intent id must be a positive safe integer"
+ end
+ if not integer(raw.revision, 1, 9007199254740991) then
+ return nil, "invalid battle revision"
+ end
+ local fields = type(raw.kind) == "string" and FIELDS[raw.kind]
+ if not fields then
+ return nil, "unknown battle intent"
+ end
+ for key in pairs(raw) do
+ if key ~= "id" and key ~= "revision" and key ~= "kind" and not fields[key] then
+ return nil, "unknown battle intent field"
+ end
+ end
+ local out = { id = raw.id, revision = raw.revision, kind = raw.kind }
+ if raw.kind == "menu" then
+ if type(raw.choice) ~= "string" or not MENU[raw.choice] then
+ return nil, "unknown battle menu choice"
+ end
+ out.choice = raw.choice
+ elseif raw.kind == "move" then
+ if not integer(raw.slot, 1, 4) then
+ return nil, "invalid move slot"
+ end
+ out.slot = raw.slot
+ elseif raw.kind == "target" then
+ if not integer(raw.target, 0, 3) then
+ return nil, "target must be a battler id 0-3"
+ end
+ out.target = raw.target
+ elseif raw.kind == "safari" then
+ if type(raw.action) ~= "string" or not SAFARI[raw.action] then
+ return nil, "invalid safari action"
+ end
+ out.action = raw.action
+ elseif raw.kind == "mimic" then
+ if not integer(raw.index, 1, 4) then
+ return nil, "invalid mimic index"
+ end
+ out.index = raw.index
+ end
+ return out
+end
+function Intents.state(game)
+ local state = states[game]
+ if not state then
+ state = { epoch = 0, busy = false }
+ states[game] = state
+ end
+ return state
+end
+
+local BATTLE_FLAGS = {
+ "waitingUI",
+ "dead",
+ "demo",
+ "dudeDemo",
+ "pokedudeDemo",
+ "palace",
+ "arena",
+ "spectating",
+ "bugContest",
+ "noItems",
+ "gen4Safari",
+}
+local BATTLER_FLAGS = {
+ "disabledSlot",
+ "tauntTurns",
+ "tormented",
+ "lastMove",
+ "mustRecharge",
+ "charging",
+ "thrashTurns",
+ "thrashMove",
+ "rageMove",
+ "trappingTurns",
+ "bideTurns",
+ "encoreTurns",
+ "encoreMove",
+ "bwSkyDropped",
+ "bwChoiceLock",
+ "bwImprison",
+ "bwHealBlock",
+ "bwEmbargo",
+}
+-- Reference identity matters as well as record values: an identical replacement
+-- Pokemon, move slot, selector or covering screen must invalidate old commands.
+-- refs are compared shallowly; values are copied by the snapshot owner.
+function Intents.context(game, b, top, at)
+ local refs = {
+ game = game,
+ battle = b,
+ top = top,
+ stack = game.stack,
+ queue = b.queue,
+ current = b.current,
+ targets = b.targetChoices,
+ mimic = b.mimicCtx,
+ mimicMoves = b.mimicMoves,
+ pendingMove = b.pendingMove,
+ pending = b.pendingActions,
+ chosen = b.chosenTargets,
+ safari = b.safari,
+ }
+ local values = { queueLength = #(b.queue or {}), flags = {}, battlers = {} }
+ for _, key in ipairs(BATTLE_FLAGS) do
+ values.flags[key] = type(b[key]) == "table" and true or b[key]
+ end
+ values.bw = type(b.bw) == "table"
+ and { gravity = b.bw.field and b.bw.field.gravity, magicRoom = b.bw.field and b.bw.field.magicRoom }
+ or b.bw
+ if b.mimicCtx then
+ refs.mimicUser, refs.mimicTarget, refs.mimicMove = b.mimicCtx.user, b.mimicCtx.target, b.mimicCtx.moveInst
+ end
+ for i, move in ipairs(b.mimicMoves or {}) do
+ refs["mimicOption" .. i] = move
+ end
+ for pos = 0, 3 do
+ local actor = at(b, pos)
+ refs["b" .. pos] = actor
+ if actor then
+ refs["mon" .. pos] = actor.mon
+ refs["moves" .. pos] = actor.curMoves
+ local flags = {}
+ for _, key in ipairs(BATTLER_FLAGS) do
+ flags[key] = type(actor[key]) == "table" and true or actor[key]
+ end
+ values.battlers[pos] = flags
+ for slot, mv in ipairs(actor.curMoves or {}) do
+ refs["move" .. pos .. ":" .. slot] = mv
+ end
+ end
+ refs["pending" .. pos] = b.pendingActions and b.pendingActions[pos]
+ refs["chosen" .. pos] = b.chosenTargets and b.chosenTargets[pos]
+ end
+ for i, target in ipairs(b.targetChoices or {}) do
+ refs["target" .. i] = target
+ end
+ return refs, values
+end
+function Intents.sameRefs(a, b)
+ if not a or not b then
+ return a == b
+ end
+ for key, value in pairs(a) do
+ if b[key] ~= value then
+ return false
+ end
+ end
+ for key, value in pairs(b) do
+ if a[key] ~= value then
+ return false
+ end
+ end
+ return true
+end
+return Intents
diff --git a/src/mods/BattleSnapshots.lua b/src/mods/BattleSnapshots.lua
new file mode 100644
index 000000000..1fce0a816
--- /dev/null
+++ b/src/mods/BattleSnapshots.lua
@@ -0,0 +1,583 @@
+-- Detached Gen 1-5 snapshots and revision-bound native command admission.
+-- Snapshot building stays data-only; submit validates before native dispatch.
+local Ids = require("src.mods.UnifiedIds")
+local Intents = require("src.mods.BattleIntents")
+local Commands = require("src.battle.BattleCommands")
+local Runtime = require("src.mods.Runtime")
+-- Native work requested through a facade remains engine work. The buses keep
+-- their existing callback/error attribution; lazy requires must not be blamed
+-- on the requesting mod by the dev-mode permission shim.
+local function enginePcall(fn, ...)
+ local owner = Runtime.currentMod
+ Runtime.currentMod = nil
+ local ok, a, b = pcall(fn, ...)
+ Runtime.currentMod = owner
+ return ok, a, b
+end
+local Snapshots = {}
+
+local STATUS =
+ { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" }
+local STATS = {
+ hp = "hp",
+ attack = "attack",
+ defense = "defense",
+ speed = "speed",
+ special = "special",
+ specialAttack = "spatk",
+ specialDefense = "spdef",
+}
+
+local function copy(value, seen)
+ local kind = type(value)
+ if kind ~= "table" then
+ if kind == "string" or kind == "boolean" or kind == "number" then
+ return value
+ end
+ return nil
+ end
+ seen = seen or {}
+ if seen[value] then
+ return seen[value]
+ end
+ local out = {}
+ seen[value] = out
+ for key, v in pairs(value) do
+ if type(key) == "string" or type(key) == "number" then
+ out[key] = copy(v, seen)
+ end
+ end
+ return out
+end
+
+local function equal(a, b, seen)
+ if type(a) ~= type(b) then
+ return false
+ end
+ if type(a) ~= "table" then
+ return a == b or (a ~= a and b ~= b)
+ end
+ seen = seen or {}
+ if seen[a] then
+ return seen[a] == b
+ end
+ seen[a] = b
+ for k, v in pairs(a) do
+ if not equal(v, b[k], seen) then
+ return false
+ end
+ end
+ for k in pairs(b) do
+ if a[k] == nil then
+ return false
+ end
+ end
+ return true
+end
+
+local function stats(raw)
+ local out = {}
+ for public, native in pairs(STATS) do
+ out[public] = (raw or {})[native]
+ end
+ out.specialAttack = out.specialAttack or out.special
+ out.specialDefense = out.specialDefense or out.special
+ return out
+end
+
+local function at(battle, pos)
+ -- Constructors fill these aliases before enter()/syncSides populates the
+ -- positional arrays. A cleared alias also truthfully represents a faint.
+ if pos == 0 then
+ return battle.player
+ end
+ if pos == 1 then
+ return battle.enemy
+ end
+ local side = battle.sides and battle.sides[pos % 2 + 1]
+ return side and side.battlers and side.battlers[math.floor(pos / 2) + 1]
+end
+
+local function activeSlot(battle)
+ if battle.phase == "mimicSelect" and battle.mimicCtx then
+ for _, pos in ipairs({ 0, 2 }) do
+ if at(battle, pos) == battle.mimicCtx.user then
+ return pos
+ end
+ end
+ end
+ if not battle.double then
+ return 0
+ end
+ local pos = battle.choosingSlot
+ if (pos == 0 or pos == 2) and at(battle, pos) then
+ return pos
+ end
+ for _, id in ipairs({ 0, 2 }) do
+ local b = at(battle, id)
+ if b and not b.ally then
+ return id
+ end
+ end
+ return 0
+end
+
+local function moveView(move, slot, data, ids)
+ local id = type(move) == "table" and move.id or move
+ local def = (data.moves or {})[id] or {}
+ local ups = type(move) == "table" and math.min(3, math.max(0, math.floor(tonumber(move.ppUps) or 0))) or 0
+ return {
+ slot = slot,
+ id = ids:read("moves", id),
+ name = def.name,
+ pp = type(move) == "table" and move.pp or nil,
+ maxPp = def.pp and (def.pp + ups * math.floor(def.pp / 5)) or nil,
+ ppUps = ups,
+ type = def.type,
+ power = def.power,
+ accuracy = def.accuracy,
+ }
+end
+
+local function monView(raw, data, ids)
+ if not raw then
+ return nil
+ end
+ local out = {
+ species = ids:read("pokemon", raw.species),
+ item = ids:read("items", raw.item),
+ nickname = raw.nickname,
+ name = raw.name or raw.nickname or ((data.pokemon or {})[raw.species] or {}).name,
+ level = raw.level,
+ hp = raw.hp,
+ maxHp = raw.stats and raw.stats.hp,
+ status = STATUS[raw.status] or raw.status,
+ isEgg = raw.isEgg == true,
+ stats = stats(raw.stats),
+ moves = {},
+ native = copy(raw),
+ }
+ for slot, move in ipairs(raw.moves or {}) do
+ out.moves[slot] = moveView(move, slot, data, ids)
+ end
+ return out
+end
+
+local function battlerView(b, pos, party, data, ids)
+ if not (b and b.mon) then
+ return nil
+ end
+ local out = monView(b.mon, data, ids)
+ out.name = b.name or out.name
+ out.id, out.side, out.active, out.absent = pos, pos % 2 == 0 and "player" or "enemy", true, false
+ out.displaySpecies = ids:read("pokemon", b.species or b.mon.species)
+ out.battleStats, out.types, out.stages =
+ stats(b.curStats or b.mon.stats), copy(b.curTypes), copy(b.stages or {})
+ out.ally = b.ally == true
+ for index, mon in ipairs(party) do
+ if mon == b.mon then
+ out.partyIndex = index
+ break
+ end
+ end
+ out.moves = {}
+ for slot, move in ipairs(b.curMoves or b.mon.moves or {}) do
+ out.moves[slot] = moveView(move, slot, data, ids)
+ end
+ return out
+end
+
+-- Prompt describes the battle screen only. Covered screens and automatic
+-- controllers stay locked; no attempt is made to inspect or drive their UI.
+local function prompt(b, covered, actor)
+ if
+ covered
+ or b.result
+ or b.waitingUI
+ or b.demo
+ or b.pokedudeDemo
+ or b.palace
+ or b.kind == "link"
+ or (b.arena and b.phase == "menu")
+ then
+ return "locked"
+ end
+ if b.phase == "menu" then
+ if b.safari then
+ return (b.safari.balls or 0) > 0 and "safari" or "locked"
+ end
+ if
+ not (actor and actor.mon and (actor.mon.hp or 0) > 0)
+ or actor.ally
+ or actor.mustRecharge
+ or actor.charging
+ or actor.rageMove
+ or (actor.thrashTurns and actor.thrashTurns > 0)
+ then
+ return "locked"
+ end
+ -- Sky Drop's controller in BW owns the menu; unknown native locks are
+ -- not advertised as actionable (this API never submits input).
+ if b.bw and actor.bwSkyDropped then
+ return "locked"
+ end
+ return "menu"
+ end
+ if b.phase == "moveSelect" then
+ return "moves"
+ end
+ if b.phase == "targetSelect" then
+ return "target"
+ end
+ if b.phase == "mimicSelect" then
+ return "mimic"
+ end
+ if
+ b.phase == "messages"
+ and b.current
+ and not b.current.choice
+ and ((b.msgWaiting and (b.msgPreWait or 0) <= 0) or (b.msgPrompt and (b.msgPromptWait or 0) <= 0))
+ then
+ return "advance"
+ end
+ return "locked"
+end
+
+-- Decode only glyphs already revealed by the native message clock. Unknown
+-- glyphs leave the optional visible message unavailable, never reveal ahead.
+local function visibleMessage(battle, covered, data)
+ if covered or battle.phase ~= "messages" or not battle.shown then
+ return nil
+ end
+ local reverse = {}
+ local function add(rows)
+ for _, row in ipairs(rows or {}) do
+ if type(row.code) == "number" and type(row.seq) == "string" and reverse[row.code] == nil then
+ reverse[row.code] = row.seq
+ end
+ end
+ end
+ add(data.font and data.font.charmap)
+ local pages, names = data.font and data.font.pages or {}, {}
+ for name in pairs(pages) do
+ names[#names + 1] = name
+ end
+ table.sort(names)
+ for _, name in ipairs(names) do
+ add(type(pages[name]) == "table" and pages[name].charmap)
+ end
+ local unicode = package.loaded["src.translation.UnicodeFont"]
+ local chars = type(unicode) == "table" and unicode.chars or {}
+ local scaffold = data.font
+ and type(data.font.source) == "string"
+ and data.font.source:find("Gen2 scaffold", 1, true)
+ local out = {}
+ for i, codes in ipairs(battle.shown) do
+ local line = {}
+ for _, code in ipairs(codes) do
+ local seq = chars[code] or reverse[code]
+ if scaffold and type(code) == "number" and code >= 0 and code <= 255 then
+ seq = string.char(code)
+ end
+ if not seq then
+ return nil
+ end
+ line[#line + 1] = seq
+ end
+ out[i] = table.concat(line)
+ end
+ return out
+end
+
+local function kind(battle)
+ -- The same variants as BattleState:battleKind(), without a method call.
+ if battle.ghost then
+ return "ghost"
+ end
+ if battle.safari then
+ return "safari"
+ end
+ if battle.demo and not battle.dudeDemo then
+ return "oldman"
+ end
+ return battle.kind
+end
+
+local function build(game, battle, covered, ids)
+ local data, save = battle.data or game.data or {}, game.save or {}
+ local party, enemyParty = battle.playerParty or save.party or {}, battle.enemyParty or {}
+ local active = activeSlot(battle)
+ local actor = at(battle, active)
+ local out = {
+ kind = kind(battle),
+ phase = battle.phase,
+ double = battle.double == true,
+ active = active,
+ turn = battle.turnCount or 0,
+ prompt = prompt(battle, covered, actor),
+ message = visibleMessage(battle, covered, data),
+ messageSource = battle.current and battle.current.text,
+ safariBalls = battle.safari and battle.safari.balls,
+ menuIndex = battle.menuIndex,
+ moveIndex = battle.moveIndex,
+ moveSwapIndex = battle.moveSwapIndex,
+ result = battle.result,
+ party = {},
+ battlers = {},
+ moves = {},
+ items = {},
+ targets = {},
+ }
+ local foes = 0
+ local rows = {}
+ for pos = 0, (out.double and 3 or 1) do
+ local b = at(battle, pos)
+ local row = battlerView(
+ b,
+ pos,
+ pos % 2 == 0 and (b and b.ally and battle.ally and battle.ally.party or party) or enemyParty,
+ data,
+ ids
+ )
+ rows[pos] = row
+ if row then
+ out.battlers[#out.battlers + 1] = row
+ if pos % 2 == 1 and (row.hp or 0) > 0 then
+ foes = foes + 1
+ end
+ end
+ end
+ out.player, out.enemy = rows[active], rows[1]
+ out.catchable = battle.kind == "wild"
+ and not battle.ghost
+ and not battle.noCatch
+ and not battle.result
+ and foes == 1
+ for slot, raw in ipairs(party) do
+ local row = monView(raw, data, ids)
+ row.slot, row.active = slot, false
+ for _, pos in ipairs({ 0, 2 }) do
+ local b = at(battle, pos)
+ if b and not b.ally and b.mon == raw then
+ row.active = true
+ end
+ end
+ out.party[slot] = row
+ end
+ for slot, move in ipairs(actor and (actor.curMoves or actor.mon.moves) or {}) do
+ local row = moveView(move, slot, data, ids)
+ local def = (data.moves or {})[move.id] or {}
+ -- The three explicit guards used by BattleState's move selector. This
+ -- is a local menu restriction, not a guarantee a move will execute.
+ row.disabled = actor.disabledSlot == slot
+ or (move.pp or 0) <= 0
+ or (actor.tauntTurns ~= nil and (def.power or 0) == 0)
+ or (actor.tormented and actor.lastMove ~= nil and move.id == actor.lastMove)
+ or false
+ -- BW adds Choice/Imprison/Gravity/Heal Block checks through native
+ -- controllers. Do not report an unverified false for those moves.
+ if battle.bw and not row.disabled then
+ row.disabled = nil
+ end
+ out.moves[slot] = row
+ end
+ -- Inventory quantities, not a use-eligibility result. Keep acquisition
+ -- order, then append positive unlisted IDs deterministically.
+ local inventory, seen, remaining = save.inventory or {}, {}, {}
+ local function item(id)
+ if seen[id] then
+ return
+ end
+ seen[id] = true
+ local count = inventory[id]
+ if type(count) ~= "number" or count <= 0 then
+ return
+ end
+ local def = (data.items or {})[id] or {}
+ local balls = data.balls or {}
+ local ball = def.ball ~= nil and def.ball ~= false or balls[id] ~= nil or balls[tostring(id)] ~= nil
+ out.items[#out.items + 1] = {
+ id = ids:read("items", id),
+ name = def.name,
+ count = count,
+ ball = ball,
+ catchable = ball and out.catchable,
+ pocket = def.pocket,
+ }
+ end
+ for _, id in ipairs(save.bagOrder or {}) do
+ item(id)
+ end
+ for id in pairs(inventory) do
+ if not seen[id] then
+ remaining[#remaining + 1] = id
+ end
+ end
+ table.sort(remaining, function(a, b)
+ return type(a) == type(b) and a < b or type(a) < type(b)
+ end)
+ for _, id in ipairs(remaining) do
+ item(id)
+ end
+ if battle.phase == "targetSelect" then
+ for _, target in ipairs(battle.targetChoices or {}) do
+ for pos = 0, 3 do
+ if at(battle, pos) == target then
+ out.targets[#out.targets + 1] = pos
+ break
+ end
+ end
+ end
+ out.targetIndex = battle.targetIndex
+ end
+ if battle.phase == "mimicSelect" then
+ out.mimicMoves, out.mimicIndex = {}, battle.mimicIndex
+ for index, move in ipairs(battle.mimicMoves or {}) do
+ out.mimicMoves[index] = moveView(move, move.slot or index, data, ids)
+ end
+ end
+ return out
+end
+
+function Snapshots.new(gameProvider, loader)
+ -- Ids keeps alias metadata on the loader, never on gameplay records.
+ local initial = Ids.new(loader)
+ for _, kind in ipairs({ "pokemon", "moves", "items" }) do
+ initial:entries(kind)
+ end
+ local facade = {}
+ local lastGame, lastBattle, previous, revision = nil, nil, nil, 0
+ local lastRefs, lastControl, lastEpoch, offeredRevision, lastIntentId = nil, nil, nil, nil, 0
+ local function refresh()
+ local game = gameProvider()
+ local states = game and game.stack and game.stack.states or {}
+ local battle, index
+ for i = #states, 1, -1 do
+ if states[i].isBattleState == true then
+ battle, index = states[i], i
+ break
+ end
+ end
+ if not battle then
+ lastGame, lastBattle, previous, lastRefs, lastControl, offeredRevision = nil, nil, nil, nil, nil, nil
+ return nil, nil, nil, "no battle"
+ end
+ local state = Intents.state(game)
+ local out = copy(build(game, battle, index ~= #states, Ids.new(loader)))
+ local refs, control = Intents.context(game, battle, states[#states], at)
+ control = copy(control)
+ if
+ game ~= lastGame
+ or battle ~= lastBattle
+ or not equal(out, previous)
+ or not Intents.sameRefs(refs, lastRefs)
+ or not equal(control, lastControl)
+ or state.epoch ~= lastEpoch
+ then
+ revision = revision + 1
+ end
+ lastGame, lastBattle, previous = game, battle, out
+ lastRefs, lastControl, lastEpoch = refs, control, state.epoch
+ return game, battle, index, state
+ end
+ function facade:snapshot()
+ local game, battle, _, state = refresh()
+ if not battle then
+ return nil, "no battle"
+ end
+ local result = copy(previous)
+ result.revision = revision
+ if state.busy then
+ result.prompt = "locked"
+ end
+ offeredRevision = revision
+ return result
+ end
+ function facade:submit(raw)
+ local intent, reason = Intents.normalize(raw)
+ if not intent then
+ return nil, reason
+ end
+ if intent.id <= lastIntentId then
+ return nil, "replayed intent"
+ end
+ local game, battle, index, state = refresh()
+ if not battle then
+ return nil, "no battle"
+ end
+ if state.busy then
+ return nil, "battle action is already in progress"
+ end
+ if intent.revision ~= revision then
+ return nil, "stale battle context"
+ end
+ if offeredRevision ~= revision then
+ return nil, "fresh battle snapshot required"
+ end
+ if battle.game ~= game then
+ return nil, "battle does not belong to current Game"
+ end
+ if index ~= #game.stack.states then
+ return nil, "battle menu is covered"
+ end
+ -- The native admission helpers are read-only. Lock during validation too,
+ -- so a mod callback/overridden helper cannot recursively enter submission.
+ local refs, control = lastRefs, lastControl
+ state.busy = true
+ local checked, refusal = enginePcall(Commands.reason, battle, intent)
+ if not checked or refusal then
+ state.busy = false
+ return nil, checked and refusal or "battle validation failed"
+ end
+ local fresh, currentGame, currentBattle = pcall(refresh)
+ if not fresh then
+ state.busy = false
+ return nil, "battle validation failed"
+ end
+ if
+ game ~= currentGame
+ or battle ~= currentBattle
+ or revision ~= intent.revision
+ or not Intents.sameRefs(refs, lastRefs)
+ or not equal(control, lastControl)
+ then
+ state.busy = false
+ return nil, "stale battle context"
+ end
+ -- Consume BEFORE native code: a callback or partial native failure cannot
+ -- replay this ID. Both boundaries invalidate snapshots held by other mods,
+ -- including snapshots observed halfway through an accepted action.
+ lastIntentId, offeredRevision = intent.id, nil
+ state.epoch = state.epoch + 1
+ local ok, accepted, err = enginePcall(Commands.dispatch, battle, intent)
+ state.epoch = state.epoch + 1
+ state.busy = false
+ if not ok then
+ local printable, message = pcall(tostring, accepted)
+ return nil, "battle action failed; intent consumed" .. (printable and ": " .. message or "")
+ end
+ if not accepted then
+ return nil, err or "battle action did not complete; intent consumed"
+ end
+ return true
+ end
+ return facade
+end
+-- Run after construction so an optional capability provider and its other
+-- surface wrappers are already installed. Standalone main has no provider.
+function Snapshots.withCapabilities(api)
+ if api.supports then
+ local supports = api.supports
+ function api:supports(surface, name)
+ if surface == "battle" then
+ if name == "snapshot" or name == "submit" then
+ return true
+ end
+ return false, "unknown battle capability: " .. tostring(name)
+ end
+ return supports(self, surface, name)
+ end
+ end
+ return api
+end
+return Snapshots
diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua
new file mode 100644
index 000000000..4ce1679c5
--- /dev/null
+++ b/src/mods/CallbackRecords.lua
@@ -0,0 +1,731 @@
+local Api3Records = require("src.mods.Api3Records")
+-- A call-local transaction. Only known record roles cross this boundary;
+-- opaque engine handles (Game, battle, save, map, RNG, UI callbacks) stay native.
+local Ids = require("src.mods.UnifiedIds")
+local R = {}
+local unpack = table.unpack or unpack
+local function pack(...)
+ return { n = select("#", ...), ... }
+end
+local function shallow(t)
+ local out = {}
+ for k, v in pairs(t or {}) do
+ out[k] = v
+ end
+ return out
+end
+local function copy(v, seen)
+ if type(v) ~= "table" then
+ if type(v) == "function" then
+ return nil
+ end
+ return v
+ end
+ seen = seen or {}
+ if seen[v] then
+ return seen[v]
+ end
+ local out = {}
+ seen[v] = out
+ for k, x in pairs(v) do
+ out[k] = copy(x, seen)
+ end
+ return out
+end
+local function equal(a, b, seen)
+ if a == b then
+ return true
+ end
+ if type(a) ~= "table" or type(b) ~= "table" then
+ return false
+ end
+ seen = seen or {}
+ if seen[a] == b then
+ return true
+ end
+ seen[a] = b
+ for k, v in pairs(a) do
+ if not equal(v, b[k], seen) then
+ return false
+ end
+ end
+ for k in pairs(b) do
+ if a[k] == nil then
+ return false
+ end
+ end
+ return true
+end
+local function integer(v, lo, hi, label)
+ assert(type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi, label .. " out of range")
+ return v
+end
+local status =
+ { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" }
+local short = {}
+for k, v in pairs(status) do
+ short[v] = k
+end
+local stats = {
+ hp = "hp",
+ attack = "attack",
+ defense = "defense",
+ speed = "speed",
+ special = "special",
+ specialAttack = "spatk",
+ specialDefense = "spdef",
+}
+local typeAliases = {
+ PSYCHIC = "PSYCHIC_TYPE",
+ PSYCHC = "PSYCHIC_TYPE",
+ MYSTERY = "CURSE_TYPE",
+ FIGHT = "FIGHTING",
+ ELECTR = "ELECTRIC",
+}
+local function typeRead(id)
+ return typeAliases[id] or id
+end
+local monFields = {
+ species = true,
+ item = true,
+ nickname = true,
+ name = true,
+ level = true,
+ hp = true,
+ maxHp = true,
+ status = true,
+ isEgg = true,
+ stats = true,
+ moves = true,
+ native = true,
+}
+local idRoles = {
+ species = "pokemon",
+ fromSpecies = "pokemon",
+ toSpecies = "pokemon",
+ evolveTo = "pokemon",
+ moveId = "moves",
+ item = "items",
+ itemId = "items",
+ ball = "items",
+}
+local recordRoles = {
+ mon = "mon",
+ pokemon = "mon",
+ user = "battler",
+ target = "battler",
+ attacker = "battler",
+ defender = "battler",
+ battler = "battler",
+ incoming = "battler",
+ previous = "battler",
+ move = "move",
+ defeatedDef = "species",
+ ballDef = "item",
+ opts = "context",
+ alive = "party",
+ sent = "mon",
+ received = "mon",
+ playerAction = "action",
+ enemyAction = "action",
+}
+local function array(t, label, max)
+ assert(type(t) == "table", label .. " must be a list")
+ local n, count = #t, 0
+ assert(not max or n <= max, label .. " too long")
+ for k in pairs(t) do
+ count = count + 1
+ assert(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= n, label .. " must be dense")
+ end
+ assert(count == n, label .. " must be dense")
+end
+
+function R.new(loader, name, generation, api3)
+ local ids = Ids.new(loader)
+ local portable = api3 and Api3Records or nil
+ local function readStatus(value)
+ if portable then
+ return portable.status(value)
+ end
+ return status[value] or value
+ end
+ local function writeStatus(value, old)
+ if portable then
+ return portable.nativeStatus(value, old)
+ end
+ assert(value == nil or value == false or short[value], "unknown status")
+ return short[value]
+ end
+ local byRaw, byView, entries = {}, {}, {}
+ local view, restore, commit, refresh
+ local open = true
+ local function read(kind, id)
+ return ids:read(kind, id)
+ end
+ local function native(kind, id, old)
+ if id == read(kind, old) then
+ return old
+ end
+ if id == nil or id == false then
+ return nil
+ end
+ assert(type(id) == "string", kind .. " ID must be a string")
+ local entry, why = ids:resolve(kind, id)
+ assert(entry, why)
+ -- Keep each host's existing numeric/string representation.
+ if type(old) == "string" then
+ for _, key in ipairs(entry.keys) do
+ if type(key) == "string" then
+ return key
+ end
+ end
+ end
+ return entry.key
+ end
+ local function moveDef(id)
+ local group = ids:resolve("moves", read("moves", id))
+ return group and loader.content.moves:get(group.key) or nil
+ end
+ local function moveViews(raw)
+ local out = {}
+ for i, m in ipairs(raw or {}) do
+ local id = type(m) == "table" and m.id or m
+ local def = moveDef(id)
+ local ups = type(m) == "table" and (m.ppUps or 0) or 0
+ out[i] = {
+ id = read("moves", id),
+ pp = type(m) == "table" and m.pp or nil,
+ maxPp = def and math.floor((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil,
+ ppUps = ups,
+ }
+ end
+ return out
+ end
+ local function statView(raw)
+ local out = {}
+ for k, n in pairs(stats) do
+ out[k] = (raw or {})[n]
+ end
+ out.specialAttack = out.specialAttack or out.special
+ out.specialDefense = out.specialDefense or out.special
+ if portable then
+ portable.stats(out, generation)
+ end
+ return out
+ end
+ local function fields(raw, kind)
+ if kind == "mon" then
+ return {
+ species = read("pokemon", raw.species),
+ item = read("items", raw.item),
+ nickname = raw.nickname,
+ name = portable and raw.nickname and raw.nickname ~= "" and raw.nickname or raw.name,
+ level = raw.level,
+ hp = raw.hp,
+ maxHp = raw.stats and raw.stats.hp,
+ status = readStatus(raw.status),
+ isEgg = raw.isEgg == true,
+ dvs = portable and generation ~= 3 and type(raw.dvs) == "table" and copy(raw.dvs) or nil,
+ shiny = portable and portable.shiny(raw, generation),
+ stats = statView(raw.stats),
+ moves = moveViews(raw.moves),
+ native = copy(raw),
+ }
+ elseif kind == "battler" then
+ return {
+ mon = view(raw.mon, "mon"),
+ id = raw.id,
+ side = raw.isPlayer == true and "player" or raw.isPlayer == false and "enemy" or nil,
+ native = copy({ name = raw.name, species = raw.species, stages = raw.stages }),
+ }
+ elseif kind == "move" or kind == "species" or kind == "item" then
+ local out = { native = copy(raw), name = raw.name }
+ local registryKind = kind == "species" and "pokemon" or kind == "move" and "moves" or "items"
+ local key = raw.id or raw.index or raw.numId
+ if key == nil then
+ for id, row in loader.content[registryKind]:each() do
+ if row == raw then
+ key = id
+ break
+ end
+ end
+ end
+ out.id = read(registryKind, key)
+ if kind == "species" then
+ out.baseStats = statView(raw.baseStats)
+ out.types = {}
+ for i, t in ipairs(raw.types or {}) do
+ out.types[i] = typeRead(t)
+ end
+ out.baseExp = raw.baseExp or raw.expYield
+ out.catchRate = raw.catchRate
+ out.growthRate = raw.growthRate
+ elseif kind == "move" then
+ for _, key in ipairs({ "type", "power", "accuracy", "pp", "priority" }) do
+ out[key] = raw[key]
+ end
+ out.type = typeRead(out.type)
+ out.category = raw.category or raw.class
+ out.effect = type(raw.effect) == "string" and raw.effect or nil
+ else
+ for _, key in ipairs({ "price", "description", "consumable" }) do
+ out[key] = raw[key]
+ end
+ end
+ return out
+ elseif kind == "party" or kind == "candidates" or kind == "trainerParty" then
+ local out = {}
+ local role = kind == "party" and "mon" or kind == "trainerParty" and "trainerMon" or "encounter"
+ for i, v in ipairs(raw) do
+ out[i] = view(v, role)
+ end
+ return out
+ elseif kind == "trainerMon" then
+ local out = copy(raw)
+ out.species = read("pokemon", raw.species)
+ out.item = read("items", raw.item)
+ out.moves = {}
+ for i, id in ipairs(raw.moves or {}) do
+ out.moves[i] = read("moves", id)
+ end
+ out.native = copy(raw)
+ return out
+ end
+ local out = shallow(raw)
+ for k, v in pairs(raw) do
+ local role = recordRoles[k]
+ if name == "world.interacted" and k == "target" then
+ role = nil
+ end
+ if name == "trade.completed" and k == "evolution" then
+ role = "context"
+ end
+ if name == "pokemon.before_give" and k == "ctx" then
+ role = nil
+ end
+ if k == "status" then
+ out[k] = readStatus(v)
+ elseif k == "move" and type(v) ~= "table" then
+ out[k] = read("moves", v)
+ elseif kind == "action" and k == "id" and not raw.special then
+ out[k] = read("moves", v)
+ elseif idRoles[k] and type(v) ~= "table" then
+ out[k] = read(idRoles[k], v)
+ elseif role and type(v) == "table" then
+ out[k] = view(v, role)
+ elseif k == "learnable" and name == "pokemon.level_up" then
+ out[k] = {}
+ for i, id in ipairs(v) do
+ out[k][i] = read("moves", id)
+ end
+ elseif k == "applyShare" and name == "battle.exp_award" and type(v) == "function" then
+ out[k] = function(mon, ...)
+ local bound = byView[mon]
+ assert(bound and bound.kind == "mon", "applyShare requires a bound Pokemon")
+ commit()
+ local result = pack(pcall(v, bound.raw, ...))
+ if not result[1] then
+ error(result[2], 0)
+ end
+ refresh()
+ return unpack(result, 2, result.n)
+ end
+ end
+ end
+ return out
+ end
+ view = function(raw, kind)
+ if type(raw) ~= "table" then
+ return raw
+ end
+ byRaw[raw] = byRaw[raw] or {}
+ if byRaw[raw][kind] then
+ return byRaw[raw][kind].public
+ end
+ local e = { raw = raw, kind = kind, public = {} }
+ byRaw[raw][kind] = e
+ byView[e.public] = e
+ entries[#entries + 1] = e
+ for k, v in pairs(fields(raw, kind)) do
+ e.public[k] = v
+ end
+ e.before = (
+ kind == "action"
+ or kind == "gift"
+ or kind == "context"
+ or kind == "encounter"
+ or kind == "party"
+ or kind == "trainerParty"
+ or kind == "candidates"
+ )
+ and shallow(e.public)
+ or copy(e.public)
+ return e.public
+ end
+ local function statChanges(public, old, baseline, ops)
+ assert(type(old) == "table", "native stats are missing")
+ assert(type(public) == "table", "stats must be a record")
+ for k in pairs(public) do
+ assert(stats[k], "unknown stat " .. tostring(k))
+ end
+ if portable then
+ local common = public.special ~= baseline.special and public.special or nil
+ if generation == 1 then
+ for _, k in ipairs({ "specialAttack", "specialDefense" }) do
+ if public[k] ~= baseline[k] then
+ assert(common == nil or common == public[k], "conflicting special stats")
+ common = public[k]
+ end
+ end
+ elseif common ~= nil then
+ for _, k in ipairs({ "specialAttack", "specialDefense" }) do
+ assert(public[k] == baseline[k] or public[k] == common, "conflicting special stats")
+ end
+ end
+ for k, n in pairs(stats) do
+ if k ~= "special" or generation == 1 then
+ local value = public[k]
+ if common ~= nil and (k == "special" or k == "specialAttack" or k == "specialDefense") then
+ value = common
+ end
+ if value ~= baseline[k] then
+ value = integer(value, 1, 65535, "stats." .. k)
+ if generation == 1 and (k == "specialAttack" or k == "specialDefense") then
+ n = "special"
+ end
+ local target = n
+ ops[#ops + 1] = function()
+ old[target] = value
+ if generation > 1 and target == "spatk" and old.special ~= nil then
+ old.special = value
+ end
+ end
+ end
+ end
+ end
+ return
+ end
+ local specialValue
+ for _, k in ipairs({ "special", "specialAttack", "specialDefense" }) do
+ if public[k] ~= baseline[k] and (generation == 1 or k ~= "specialDefense") then
+ assert(not specialValue or specialValue == public[k], "conflicting special stats")
+ specialValue = public[k]
+ end
+ end
+ for k, n in pairs(stats) do
+ local value = public[k]
+ if generation == 1 and (k == "special" or k == "specialAttack" or k == "specialDefense") then
+ value = specialValue or baseline[k]
+ n = "special"
+ elseif specialValue and (k == "special" or k == "specialAttack") then
+ value = specialValue
+ end
+ if value ~= baseline[k] then
+ value = integer(value, 1, 65535, "stats." .. k)
+ ops[#ops + 1] = function()
+ old[n] = value
+ end
+ end
+ end
+ end
+ local pending, visiting
+ local function stage(raw, key, value)
+ assert(type(raw) == "table", "native record is missing")
+ pending[#pending + 1] = function()
+ raw[key] = value
+ end
+ end
+ local function movesNative(public, old, baseline)
+ array(public, "moves", 4)
+ local out = {}
+ for i, m in ipairs(public) do
+ assert(type(m) == "table", "move must be a record")
+ for k in pairs(m) do
+ assert(k == "id" or k == "pp" or k == "maxPp" or k == "ppUps", "unknown move field")
+ end
+ local prev = (old or {})[i]
+ local prior = (baseline or {})[i] or {}
+ assert(m.maxPp == prior.maxPp, "maxPp is read-only")
+ local row = type(prev) == "table" and shallow(prev) or {}
+ row.id = native("moves", m.id, row.id)
+ assert(row.id, "move ID is required")
+ row.ppUps = integer(m.ppUps or 0, 0, 3, "ppUps")
+ local def = moveDef(row.id)
+ local max = def and math.floor((def.pp or 0) + row.ppUps * math.floor((def.pp or 0) / 5)) or 0
+ row.pp = integer(m.pp, 0, max, "pp")
+ out[i] = row
+ end
+ return out
+ end
+ restore = function(value, kind)
+ if type(value) ~= "table" then
+ assert(
+ value == nil or (kind == "encounter" and value == false),
+ "expected " .. tostring(kind) .. " record"
+ )
+ return value
+ end
+ local e = byView[value]
+ if not e then
+ assert(
+ kind == "action"
+ or kind == "encounter"
+ or kind == "trainerMon"
+ or kind == "candidates"
+ or kind == "trainerParty",
+ "replacement " .. tostring(kind) .. " must be a bound record"
+ )
+ e = { public = value, kind = kind, raw = {}, before = {} }
+ end
+ if visiting[e] then
+ return e.raw
+ end
+ visiting[e] = true
+ local raw, before = e.raw, e.before
+ kind = e.kind
+ if kind == "move" or kind == "species" or kind == "item" or kind == "battler" then
+ if kind == "battler" then
+ assert(
+ value.id == before.id and value.side == before.side and equal(value.native, before.native),
+ "battler metadata is read-only"
+ )
+ assert(byView[value.mon] and byView[value.mon].raw == raw.mon, "battler.mon identity is read-only")
+ for k in pairs(value) do
+ assert(k == "mon" or k == "id" or k == "side" or k == "native", "unknown battler field")
+ end
+ restore(value.mon, "mon")
+ else
+ assert(equal(value, before), kind .. " definition is read-only; use mod.content")
+ end
+ elseif kind == "mon" then
+ for k in pairs(value) do
+ assert(
+ monFields[k] or (portable and (k == "dvs" or k == "shiny")),
+ "unknown Pokemon field " .. tostring(k)
+ )
+ end
+ assert(equal(value.native, before.native), "native snapshot is read-only")
+ assert(value.name == before.name, "name is read-only; set nickname")
+ if portable then
+ assert(equal(value.dvs, before.dvs) and value.shiny == before.shiny, "DVs and shiny are read-only")
+ end
+ for _, key in ipairs({ "species", "item", "nickname", "level", "hp", "maxHp", "status", "isEgg" }) do
+ local v = value[key]
+ if not equal(v, before[key]) then
+ if key == "species" or key == "item" then
+ v = native(key == "species" and "pokemon" or "items", v, raw[key])
+ assert(key ~= "species" or v, "species is required")
+ elseif key == "level" then
+ v = integer(v, 1, 100, key)
+ elseif key == "hp" then
+ v = integer(v, 0, 65535, key)
+ elseif key == "maxHp" then
+ v = integer(v, 1, 65535, key)
+ elseif key == "status" then
+ v = writeStatus(v, raw.status)
+ elseif key == "nickname" then
+ assert(v == nil or type(v) == "string", "nickname must be a string")
+ elseif key == "isEgg" then
+ assert(type(v) == "boolean", "isEgg must be boolean")
+ end
+ if key == "maxHp" then
+ stage(raw.stats, "hp", v)
+ else
+ stage(raw, key, v)
+ end
+ end
+ end
+ if value.maxHp ~= before.maxHp and value.stats.hp ~= before.stats.hp then
+ assert(value.maxHp == value.stats.hp, "conflicting maxHp and stats.hp")
+ end
+ local limit = value.stats.hp ~= before.stats.hp and value.stats.hp or value.maxHp
+ if value.hp ~= before.hp or limit ~= before.maxHp then
+ assert(
+ type(value.hp) == "number" and type(value.maxHp) == "number" and value.hp <= limit,
+ "hp exceeds maxHp"
+ )
+ end
+ if not equal(value.stats, before.stats) then
+ statChanges(value.stats, raw.stats, before.stats, pending)
+ end
+ if not equal(value.moves, before.moves) then
+ local new = movesNative(value.moves, raw.moves, before.moves)
+ local target = raw.moves or {}
+ stage(raw, "moves", target)
+ pending[#pending + 1] = function()
+ local previous = shallow(target)
+ for i in pairs(target) do
+ target[i] = nil
+ end
+ for i, row in ipairs(new) do
+ local slot = type(previous[i]) == "table" and previous[i] or {}
+ for k in pairs(slot) do
+ slot[k] = nil
+ end
+ for k, v in pairs(row) do
+ slot[k] = v
+ end
+ target[i] = slot
+ end
+ end
+ end
+ elseif kind == "party" or kind == "candidates" or kind == "trainerParty" then
+ array(value, kind, kind ~= "candidates" and 6 or nil)
+ local out = {}
+ local role = kind == "party" and "mon" or kind == "trainerParty" and "trainerMon" or "encounter"
+ for i, v in ipairs(value) do
+ out[i] = restore(v, role)
+ end
+ if kind == "party" then
+ assert(#value == #raw, "live party membership is read-only")
+ for i, v in ipairs(out) do
+ assert(v == raw[i], "live party order is read-only")
+ end
+ else
+ pending[#pending + 1] = function()
+ for k in pairs(raw) do
+ raw[k] = nil
+ end
+ for i, v in ipairs(out) do
+ raw[i] = v
+ end
+ end
+ end
+ elseif kind == "gift" then
+ assert(value.ctx == before.ctx, "gift context is read-only")
+ for k in pairs(value) do
+ assert(k == "ctx" or k == "species" or k == "level" or k == "nickname", "unknown gift field")
+ end
+ stage(raw, "species", assert(native("pokemon", value.species, raw.species), "species is required"))
+ stage(raw, "level", integer(value.level, 1, 100, "level"))
+ assert(value.nickname == nil or type(value.nickname) == "string", "nickname must be a string")
+ stage(raw, "nickname", value.nickname)
+ elseif kind == "trainerMon" then
+ assert(value.native == nil or equal(value.native, before.native), "native snapshot is read-only")
+ local allowed = { species = true, item = true, level = true, moves = true, native = true }
+ for k, v in pairs(value) do
+ if not allowed[k] then
+ assert(equal(v, before[k]), "trainer metadata is read-only")
+ end
+ end
+ stage(raw, "species", assert(native("pokemon", value.species, raw.species), "species is required"))
+ stage(raw, "item", native("items", value.item, raw.item))
+ stage(raw, "level", integer(value.level, 1, 100, "level"))
+ if not equal(value.moves, before.moves) then
+ array(value.moves, "trainer moves", 4)
+ local out = {}
+ for i, id in ipairs(value.moves) do
+ out[i] = assert(native("moves", id, (raw.moves or {})[i]), "move is required")
+ end
+ stage(raw, "moves", out)
+ end
+ else
+ if kind == "encounter" then
+ assert(value.species ~= nil, "encounter species is required")
+ if value.level ~= nil then
+ integer(value.level, 1, 100, "encounter level")
+ end
+ for _, k in ipairs({ "min", "max" }) do
+ if value[k] ~= nil then
+ integer(value[k], 1, 100, k)
+ end
+ end
+ assert(
+ value.level ~= nil or (value.min and value.max and value.min <= value.max),
+ "encounter level is required"
+ )
+ end
+ local keys = shallow(before)
+ for k in pairs(value) do
+ keys[k] = true
+ end
+ for k in pairs(keys) do
+ local v = value[k]
+ local old = raw[k]
+ if type(v) == "table" and byView[v] then
+ local restored = restore(v)
+ if v ~= before[k] then
+ stage(raw, k, restored)
+ end
+ elseif k == "move" and type(v) ~= "table" and v ~= before[k] then
+ stage(raw, k, native("moves", v, old))
+ elseif kind == "action" and k == "id" and not value.special and v ~= before[k] then
+ stage(raw, k, native("moves", v, old))
+ elseif k == "status" and v ~= before[k] then
+ stage(raw, k, writeStatus(v, old))
+ elseif idRoles[k] and v ~= before[k] then
+ stage(raw, k, native(idRoles[k], v, old))
+ elseif k == "applyShare" and name == "battle.exp_award" then
+ assert(v == before[k], "applyShare is read-only")
+ elseif k == "learnable" and name == "pokemon.level_up" then
+ assert(equal(v, before[k]), "learnable is read-only")
+ elseif v ~= before[k] then
+ local role = recordRoles[k]
+ if kind == "encounter" and k == "level" then
+ integer(v, 1, 100, "encounter level")
+ end
+ if role and type(v) == "table" then
+ v = restore(v, role)
+ end
+ stage(raw, k, v)
+ end
+ end
+ end
+ return raw
+ end
+ commit = function(extra)
+ assert(open, "callback transaction is closed")
+ pending, visiting = {}, {}
+ for _, e in ipairs(entries) do
+ restore(e.public, e.kind)
+ end
+ local result
+ if extra then
+ result = extra()
+ end
+ for _, op in ipairs(pending) do
+ op()
+ end
+ return result
+ end
+ refresh = function()
+ -- Reuse public identities, but refresh after vanilla mutates native data.
+ for _, e in ipairs(entries) do
+ local nextValue = fields(e.raw, e.kind)
+ for k in pairs(e.public) do
+ e.public[k] = nil
+ end
+ for k, v in pairs(nextValue) do
+ e.public[k] = v
+ end
+ end
+ for _, e in ipairs(entries) do
+ e.before = (
+ e.kind == "action"
+ or e.kind == "gift"
+ or e.kind == "context"
+ or e.kind == "encounter"
+ or e.kind == "party"
+ or e.kind == "trainerParty"
+ or e.kind == "candidates"
+ )
+ and shallow(e.public)
+ or copy(e.public)
+ end
+ end
+ return {
+ close = function()
+ open = false
+ end,
+ view = view,
+ restore = function(v, kind)
+ return restore(v, kind)
+ end,
+ commit = commit,
+ refresh = refresh,
+ read = read,
+ native = native,
+ copy = copy,
+ }
+end
+return R
diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua
new file mode 100644
index 000000000..0fe2cd6ad
--- /dev/null
+++ b/src/mods/Capabilities.lua
@@ -0,0 +1,108 @@
+-- Availability of the existing API-2 surfaces, before a Game/overworld exists.
+-- Keep callback names tied to native call sites, not to the permissive buses:
+-- successfully subscribing to a name does not mean the engine ever fires it.
+local GameVersion = require("src.core.GameVersion")
+local Schemas = require("src.mods.Schemas")
+
+local Capabilities = {}
+local callbacks = { hooks = {}, events = {} }
+local function add(surface, generations, names)
+ for name in names:gmatch("%S+") do
+ callbacks[surface][name] = generations
+ end
+end
+
+-- Shared simulation, world, input, rendering and lifecycle code. A callback
+-- need not fire on every action (e.g. an always-hit move skips accuracy).
+add("hooks", "12345", [[
+ battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action
+ battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate
+ encounter.fishing encounter.roll encounter.species evolution.check exp.gain
+ fieldmove.eligibility input.pointer input.step link.fingerprint map.palette movement.collision
+ movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite
+ render.compose render.hud render.letterbox render.output render.output_enabled render.zones save.new_game save.write
+ script.command trainer.party transition.style ui.list_menu ui.options.rows
+ ui.title_menu.items warp.destination world.tod zoom.range
+]])
+-- Screens have distinct implementations: Screens.lua aliases the DS party,
+-- naming and start menus; those implementations do not call the GB/GBA hooks.
+-- OverworldController:openPC bypasses ui.pc.items on Gen 3/4 (and Gen 5 uses
+-- its own script PC). Gen4Battle paints its HUD through a separate seam and
+-- does not dispatch battle.overlay, unlike BattleState/Gen3Battle/WideBattle.
+add("hooks", "123", "render.viewport render.window battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items")
+add("hooks", "12", "ui.pc.items")
+add("hooks", "45", "battle.status_hud_visible")
+add("hooks", "12", "intro.oak_speech.build")
+
+add("events", "12345", [[
+ assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt
+ battle.ended battle.exp_gained battle.fainted battle.move_used battle.started
+ battle.status_inflicted battle.turn_ended battle.turn_started discord.join_requested
+ flag.changed game.ready link.connected link.desync link.ended map.entered map.exited
+ map.reloaded mod.option_action mod.options_changed mods.loaded music.started
+ music.stopped player.warped pokemon.before_give pokemon.caught pokemon.evolved
+ pokemon.level_up pokemon.move_learned pokemon.received save.created save.loaded
+ save.loading save.writing screen.popped screen.pushed script.ended script.started
+ sound.played trade.completed world.blacked_out world.block_replaced
+ world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned
+ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged
+]])
+add("events", "2345", "pokemon.egg_hatched")
+add("events", "12", [[
+ intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started
+ intro.oak_speech.step
+]])
+add("events", "3", "intro.birch_speech.started intro.birch_speech.finished")
+
+-- Data:applyDefaults selects BirchSpeech on Emerald, Gen3OakSpeechFRLG on
+-- FireRed, and no OakSpeech on Prism. DS imports select their own intros.
+local function nativeVersion(name, version)
+ if name:match("^intro%.birch_speech%.") then return version == "emerald" end
+ if name:match("^intro%.oak_speech%.") then return version ~= "prism" end
+ return true
+end
+
+-- Public WorldAPI methods only; constructors, internal helpers and methods
+-- on returned NPC handles are not mod.world methods. These remain callable
+-- at the title screen, returning their normal "no overworld"/"no save" error.
+-- Elevation readers can return "map carries no elevation" on any dataset.
+local world = {}
+for name in ([[
+ overworld current warpTo toggleObject setFlag getFlag replaceBlock spawnNpc
+ removeNpc loadedMaps elevationInfo elevationAt terrainAt elevationRanks heightAt
+ elevationGrid npc queueScript invalidateMap
+]]):gmatch("%S+") do world[name] = true end
+
+function Capabilities.supports(surface, name, version)
+ if surface ~= "content" and surface ~= "world"
+ and surface ~= "hooks" and surface ~= "events" then
+ return false, "unsupported capability surface: " .. tostring(surface)
+ end
+ if type(name) ~= "string" or name == "" then
+ return false, "capability name must be a non-empty string"
+ end
+ local info = GameVersion.VERSIONS[version]
+ if not info then return false, "unknown game version: " .. tostring(version) end
+ if surface == "content" then
+ -- Every loader builds the same API-2 registries from Schemas. This says
+ -- the registration API exists, not that record shapes/IDs are portable.
+ if Schemas.REGISTRIES[name] then return true end
+ elseif surface == "world" then
+ if world[name] then return true end
+ else
+ -- A mod may listen to another mod's namespace without that mod installed;
+ -- this only promises the bus supports it, never that a producer is present.
+ if surface == "events" and name:match("^mod%.[%w_%-]+%..+") then return true end
+ local gens = callbacks[surface][name]
+ if gens then
+ if gens:find(tostring(info.generation), 1, true) and nativeVersion(name, version) then
+ return true
+ end
+ return false, ("%s capability %s has no native call site in %s")
+ :format(surface, name, version)
+ end
+ end
+ return false, ("unknown %s capability: %s"):format(surface, name)
+end
+
+return Capabilities
diff --git a/src/mods/DatasetViews.lua b/src/mods/DatasetViews.lua
new file mode 100644
index 000000000..6dfc962df
--- /dev/null
+++ b/src/mods/DatasetViews.lua
@@ -0,0 +1,415 @@
+-- Read-only version datasets. Generated modules are parsed as bounded data.
+local Version = require("src.core.GameVersion")
+local Literal = require("src.import.DataLiteral")
+local Files = require("src.import.VersionFiles")
+local Registry = require("src.mods.Registry")
+local Schemas = require("src.mods.Schemas")
+local Unified = require("src.mods.UnifiedContent")
+local Merge = require("src.mods.Merge")
+local D = {}
+local CORE = {
+ pokemon = true,
+ moves = true,
+ items = true,
+ maps = true,
+ trainers = true,
+ encounters = true,
+ type_chart = true,
+ constants = true,
+}
+local MAX_TOTAL = 48 * 1024 * 1024
+local function dataOnly(value, seen)
+ if type(value) ~= "table" then
+ return type(value) ~= "function" and type(value) ~= "userdata" and type(value) ~= "thread"
+ end
+ if getmetatable(value) ~= nil then
+ return false
+ end
+ seen = seen or {}
+ if seen[value] then
+ return false
+ end
+ seen[value] = true
+ for k, v in pairs(value) do
+ if not dataOnly(k, seen) or not dataOnly(v, seen) then
+ return false
+ end
+ end
+ seen[value] = nil
+ return true
+end
+local function asset(path)
+ if
+ type(path) ~= "string"
+ or #path > 1024
+ or path:sub(1, 17) ~= "assets/generated/"
+ or path:find("[%c\\]")
+ then
+ error("dataset asset requires a generated relative path", 0)
+ end
+ for segment in path:gmatch("[^/]+") do
+ if segment == "." or segment == ".." or segment:find(":", 1, true) then
+ error("dataset asset path traversal", 0)
+ end
+ end
+ return path
+end
+function D.new(fs, req, trust)
+ req = req or require
+ trust = trust or function(fn, ...)
+ return fn(...)
+ end
+ local files = Files.new(fs)
+ local function descriptor(version)
+ local importer = req("src.import.RomImporter")
+ return importer.datasetContract(version)
+ end
+ local function inspect(version)
+ if type(version) ~= "string" or not Version.VERSIONS[version] then
+ return nil, "unknown_version"
+ end
+ if Version.generation(version) > 3 then
+ return nil, "unsupported_generation"
+ end
+ local contract = descriptor(version)
+ local paths, names, total = {}, {}, 0
+ for _, name in ipairs(contract.modules) do
+ names[name] = true
+ end
+ for _, spec in pairs(Schemas.REGISTRIES) do
+ local root = spec.target and spec.target:match("^[^%.]+")
+ if root then
+ names[root] = true
+ end
+ end
+ local marker = files.read(version, "rom-cache.complete", 65536)
+ if marker and (#marker > 65536 or marker:match("^[^\r\n]*") ~= contract.marker) then
+ return nil, "not_imported"
+ end
+ local absent = {}
+ for line in (marker or ""):gmatch("[^\r\n]+") do
+ local name = line:match("^absent:data/generated/(.+)%.lua$")
+ if name then
+ absent[name] = true
+ end
+ end
+ local mandatory = {}
+ for _, name in ipairs(contract.modules) do
+ if CORE[name] or not absent[name] then
+ mandatory[name] = true
+ end
+ end
+ for name in pairs(CORE) do
+ mandatory[name] = true
+ end
+ for name in pairs(names) do
+ local path = "data/generated/" .. name .. ".lua"
+ local info = files.info(version, path)
+ if info and (info.type == nil or info.type == "file") then
+ if info.size and (info.size < 0 or info.size > Literal.LIMITS.bytes) then
+ return nil, "invalid_cache"
+ end
+ total = total + (info.size or 0)
+ if total > MAX_TOTAL then
+ return nil, "invalid_cache"
+ end
+ paths[name] = path
+ elseif mandatory[name] then
+ return nil, "not_imported"
+ end
+ end
+ local required = {}
+ for name in pairs(mandatory) do
+ required[#required + 1] = "data/generated/" .. name .. ".lua"
+ end
+ if not marker and not files.source(version, required) then
+ return nil, "not_imported"
+ end
+ return {
+ paths = paths,
+ marker = marker,
+ identity = files.identity(),
+ source = not marker,
+ required = required,
+ }
+ end
+ local function open(version)
+ local plan, why = inspect(version)
+ if not plan then
+ return nil, why
+ end
+ local view = { version = version, generation = Version.generation(version) }
+ local cache, decodedBytes, revision = {}, 0, 0
+ local registries, handles, invalid
+ local function reject(reason)
+ invalid = reason or "invalid_cache"
+ return nil
+ end
+ local function safe(fn, ...)
+ local function pack(...)
+ return { n = select("#", ...), ... }
+ end
+ local r = pack(trust(pcall, fn, ...))
+ if not r[1] then
+ return reject("invalid_cache")
+ end
+ return (table.unpack or unpack)(r, 2, r.n)
+ end
+ local function ready()
+ if invalid then
+ return false
+ end
+ local nextPlan = inspect(version)
+ if not nextPlan or nextPlan.identity ~= plan.identity or nextPlan.marker ~= plan.marker then
+ return reject("not_imported")
+ end
+ for name, path in pairs(plan.paths) do
+ if nextPlan.paths[name] ~= path then
+ return reject("not_imported")
+ end
+ end
+ local changed = false
+ for name in pairs(nextPlan.paths) do
+ if not plan.paths[name] then
+ changed = true
+ end
+ end
+ for name, old in pairs(cache) do
+ local source = files.read(version, plan.paths[name], Literal.LIMITS.bytes)
+ if type(source) ~= "string" then
+ return reject("invalid_cache")
+ end
+ if source ~= old.source then
+ changed = true
+ end
+ end
+ if changed then
+ cache = {}
+ decodedBytes = 0
+ registries = nil
+ handles = nil
+ revision = revision + 1
+ end
+ plan = nextPlan
+ return true
+ end
+ local function module(name)
+ if cache[name] then
+ return cache[name].value
+ end
+ local path = plan.paths[name]
+ if not path then
+ return nil
+ end
+ local source = files.read(version, path, Literal.LIMITS.bytes)
+ if type(source) ~= "string" or #source > Literal.LIMITS.bytes then
+ return reject("invalid_cache")
+ end
+ decodedBytes = decodedBytes + #source
+ if decodedBytes > MAX_TOTAL then
+ return reject("invalid_cache")
+ end
+ local value = Literal.decode(source)
+ if not value then
+ return reject("invalid_cache")
+ end
+ cache[name] = { source = source, value = value }
+ return value
+ end
+ local function build()
+ if handles then
+ return handles
+ end
+ local data = setmetatable({}, {
+ __index = function(t, key)
+ local value = module(key)
+ if value ~= nil then
+ rawset(t, key, value)
+ end
+ return value
+ end,
+ })
+ local constants = data.constants
+ if constants and constants.generation ~= nil and constants.generation ~= view.generation then
+ return reject("invalid_cache")
+ end
+ registries = {}
+ for name, spec in pairs(Schemas.REGISTRIES) do
+ local shape = Merge.deepCopy(spec)
+ if name == "pokemon" and view.generation > 1 then
+ for _, key in ipairs({ "spatk", "spdef" }) do
+ shape.fields.baseStats.fields[key] = Schemas.f.opt(Schemas.f.int(1, 255))
+ end
+ end
+ local r = Registry.new(name, shape)
+ if spec.target then
+ local target = spec.target
+ r.base = function()
+ local node = data
+ for key in target:gmatch("[^%.]+") do
+ node = type(node) == "table" and node[key] or nil
+ end
+ return node
+ end
+ end
+ registries[name] = r
+ end
+ -- Seed engine data descriptors in isolated registries; function-bearing
+ -- runtime records are filtered from every public read below.
+ req("src.mods.Builtins").install(registries, data)
+ if invalid then
+ return nil
+ end
+ for _, r in pairs(registries) do
+ r:freeze()
+ end
+ local raw = {}
+ for name, r in pairs(registries) do
+ raw[name] = {
+ get = function(_, id)
+ return r:get(id)
+ end,
+ has = function(_, id)
+ return r:get(id) ~= nil
+ end,
+ each = function()
+ return r:each()
+ end,
+ }
+ end
+ handles = Unified.new({ content = registries }, { manifest = { id = "dataset" } }, raw, view.generation)
+ return handles
+ end
+ local function rawValid(name, id)
+ local r = registries[name]
+ local raw = r:get(id)
+ if raw == nil or not dataOnly(raw) then
+ return false
+ end
+ local ok = Schemas.check(r.spec, name, id, raw, "override")
+ if not ok then
+ reject("invalid_cache")
+ return false
+ end
+ return true
+ end
+ local function current(name)
+ if not ready() then
+ return nil
+ end
+ local all = build()
+ if invalid then
+ return nil
+ end
+ return all and all[name]
+ end
+ local function get(name, id, prepared)
+ if type(id) ~= "string" or id == "" then
+ return nil
+ end
+ local handle = prepared and handles[name] or current(name)
+ if not handle then
+ return nil
+ end
+ local value = handle:get(id)
+ if invalid or value == nil or not dataOnly(value) then
+ return nil
+ end
+ -- Unified core IDs may have native aliases; validate all rows involved
+ -- through the native registry, not just the normalized projection.
+ if require("src.mods.UnifiedSchemas")[name] then
+ local group = require("src.mods.UnifiedIds").new({ content = registries }):resolve(name, id)
+ if not group or not rawValid(name, group.key) then
+ return nil
+ end
+ elseif not rawValid(name, id) then
+ return nil
+ end
+ return Merge.deepCopy(value)
+ end
+ view.content = {}
+ for name in pairs(Schemas.REGISTRIES) do
+ local registryName = name
+ view.content[name] = {
+ get = function(_, id)
+ return safe(get, registryName, id)
+ end,
+ has = function(_, id)
+ return safe(get, registryName, id) ~= nil
+ end,
+ each = function()
+ return safe(function()
+ local handle = current(registryName)
+ local rows = {}
+ if handle then
+ for id in handle:each() do
+ local value = get(registryName, id, true)
+ if invalid then
+ rows = {}
+ break
+ end
+ if value ~= nil then
+ rows[#rows + 1] = { id, value }
+ end
+ end
+ end
+ local initial, index = revision, 0
+ return function()
+ return safe(function()
+ if
+ invalid
+ or revision ~= initial
+ or files.identity() ~= plan.identity
+ or files.read(version, "rom-cache.complete", 65536) ~= plan.marker
+ then
+ return nil
+ end
+ index = index + 1
+ local row = rows[index]
+ if row then
+ return row[1], Merge.deepCopy(row[2])
+ end
+ end)
+ end
+ end) or function() end
+ end,
+ }
+ end
+ for alias, name in pairs(Schemas.ALIASES) do
+ view.content[alias] = view.content[name]
+ end
+ view.assets = {
+ path = function(_, path)
+ path = asset(path)
+ return safe(function()
+ if not ready() then
+ return nil
+ end
+ return files.path(version, path)
+ end)
+ end,
+ info = function(_, path)
+ path = asset(path)
+ return safe(function()
+ if not ready() then
+ return nil
+ end
+ local info = files.info(version, path)
+ return info and (info.type == nil or info.type == "file") and { type = "file", size = info.size }
+ or nil
+ end)
+ end,
+ }
+ return view
+ end
+ return {
+ open = function(_, version)
+ local ok, view, why = trust(pcall, open, version)
+ if not ok then
+ return nil, "invalid_cache"
+ end
+ return view, why
+ end,
+ }
+end
+return D
diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua
index 4094a38c4..447cf300b 100644
--- a/src/mods/Loader.lua
+++ b/src/mods/Loader.lua
@@ -11,18 +11,28 @@ local Data = require("src.core.Data")
local Version = require("src.core.Version")
local Assets = require("src.render.Assets")
local ModUI = require("src.ui.ModUI")
+local PortableScripts = require("src.mods.PortableScripts")
+local Api3Warp = require("src.mods.Api3Warp")
+local Api3NPC = require("src.mods.Api3NPC")
+local Api3WildBattle = require("src.mods.Api3WildBattle")
local AssetTransform = require("src.mods.AssetTransform")
local Manifest = require("src.mods.Manifest")
local Merge = require("src.mods.Merge")
local Registry = require("src.mods.Registry")
local Schemas = require("src.mods.Schemas")
+local Capabilities = require("src.mods.Capabilities")
local Semver = require("src.mods.Semver")
local Events = require("src.mods.Events")
local Hooks = require("src.mods.Hooks")
local ModStorage = require("src.mods.Storage")
+local WorldReads = require("src.mods.WorldReads")
local Runtime = require("src.mods.Runtime")
+local BattleSnapshots = require("src.mods.BattleSnapshots")
+local UIActions = require("src.mods.UIActions")
local ModGens = require("src.mods.ModGens")
local ModSeams = require("src.mods.ModSeams")
+local WorldActions = require("src.world.WorldActions")
+local WorldOverview = require("src.mods.WorldOverview")
-- Module-name aliases for mods written against the Gold port's per-generation
-- layout (src.world.gen2.Player and friends). Installed here because this is
-- the module that loads mods: the searcher has to be in place before the
@@ -119,6 +129,17 @@ local function engineRequire(name)
return module
end
+-- Native facade work stays outside the mod permission scanner, including
+-- nested lazy reads performed after mod initialization.
+local function engineCall(fn, ...)
+ local function pack(...) return { n = select("#", ...), ... } end
+ devShim.depth = devShim.depth + 1
+ local result = pack(pcall(fn, ...))
+ devShim.depth = devShim.depth - 1
+ if not result[1] then error(result[2], 0) end
+ return (table.unpack or unpack)(result, 2, result.n)
+end
+
function Loader:_installDevShim()
for id, mod in pairs(self.mods) do
devShim.permissions[id] = mod.manifest.permissionSet
@@ -831,7 +852,7 @@ function Loader:_api(mod)
-- new game and a new load.
local hostGen, hostId, hostInfo = 1, nil, nil
do
- local okV, GameVersion = pcall(require, "src.core.GameVersion")
+ local okV, GameVersion = pcall(engineRequire, "src.core.GameVersion")
if okV and GameVersion then
local okG, g = pcall(GameVersion.generation)
if okG and tonumber(g) then hostGen = tonumber(g) end
@@ -841,9 +862,11 @@ function Loader:_api(mod)
if okN then hostInfo = info end
end
end
+ -- Capture pristine ID aliases before mod merges; the facade resolves Game only on a read.
+ local battle = BattleSnapshots.new(function() return loader:_game(true) end, loader)
local engineVersion
do
- local okE, V = pcall(require, "src.core.Version")
+ local okE, V = pcall(engineRequire, "src.core.Version")
engineVersion = okE and V and V.engine or nil
end
local api = {
@@ -868,6 +891,7 @@ function Loader:_api(mod)
-- returns the same module table the engine holds, so a mod that mutates
-- one is changing the engine, exactly as it always was.
engineRequire = engineRequire,
+ battle = battle,
-- 1 to 5. The one field worth reaching for before anything else.
generation = hostGen,
-- the version id the launcher started: "red", "crystal", "emerald",
@@ -884,13 +908,14 @@ function Loader:_api(mod)
label = hostInfo and hostInfo.label or hostId,
engine = engineVersion,
modApi = (function()
- local okE, V = pcall(require, "src.core.Version")
+ local okE, V = pcall(engineRequire, "src.core.Version")
return okE and V and V.modApi or nil
end)(),
developer = loader.dev == true,
},
-- a deep copy: what a mod does to its own view never reaches the loader
manifest = Merge.deepCopy(mod.manifest),
+ callbackFormat = mod.manifest.callback_format or "native",
content = {},
exports = {},
DELETE = Registry.DELETE,
@@ -914,9 +939,9 @@ function Loader:_api(mod)
hooks = { wrap = function(_, name, callback, priority)
return loader.hooks:wrap(name, callback, priority, modId)
end },
- -- the widget toolkit facade (12 4.5) is one shared surface, not
- -- per-mod state; each widget inside it loads on first touch
- ui = ModUI,
+ -- Per-mod action/revision state, with the existing lazy widget builders.
+ -- Reading the facade early must not construct Game or a native screen.
+ ui = UIActions.new(function() return loader:_game(true) end, loader, ModUI),
-- namespaced per mod; M11 backs these with save.modData /
-- options.modOptions, the shape mods compile against is already final
save = {
@@ -999,6 +1024,9 @@ function Loader:_api(mod)
},
}
self.exports[modId] = api.exports
+ if api.callbackFormat == "unified" then
+ engineRequire("src.mods.UnifiedCallbacks").install(loader, api, modId, hostGen)
+ end
-- a handle, not the mod object: {id, version, exports} or nil when the
-- other mod is absent, disabled, failed, or has not run yet. Tolerates
-- mod:find(id) as well as the documented mod.find(id).
@@ -1020,6 +1048,10 @@ function Loader:_api(mod)
api.content[alias] = self:_contentApi(mod, self.content[canonical],
("the %s registry is deprecated; use %s"):format(alias, canonical))
end
+ api.contentFormat = mod.manifest.content_format or "native"
+ if api.contentFormat == "unified" then
+ api.content = engineRequire("src.mods.UnifiedContent").new(loader, mod, api.content, hostGen)
+ end
-- assets keeps the v1 alias to the content accessors and adds the file
-- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve
-- A RELATIVE PATH STAYS RELATIVE.
@@ -1119,13 +1151,41 @@ function Loader:_api(mod)
end
return data
end)
+ -- Capture the host selected for this API, as with generation/gameVersion.
+ -- Probing must not instantiate mod.world/mod.game or register callbacks.
+ function api:supports(surface, name)
+ return Capabilities.supports(surface, name, hostId)
+ end
-- mod.world / mod.game / mod.storage all materialize on first touch, for
- -- the same reason: a headless load must not drag the world stack in, and
- -- the Game the facade acts on is still being wired when the entry chunk
+ -- the same reason: a headless load must not drag the world stack in.
+ -- Battle capability queries can also be installed before Game exists.
+ BattleSnapshots.withCapabilities(api)
+ UIActions.withCapabilities(api)
+ -- The Game the facade acts on is still being wired when the entry chunk
-- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed
-- namespace per mod for payloads too large to belong in a save file.
+ -- When capability queries are installed, these additive methods exist on
+ -- every host; individual actions still validate native version and state.
+ if api.supports then
+ local supports = api.supports
+ function api:supports(surface, name)
+ if surface == "world" and WorldActions.supports(name) then return true end
+ return supports(self, surface, name)
+ end
+ end
local world, storage
- setmetatable(api, { __index = function(_, key)
+ local overviewContext = WorldOverview.install(api, loader)
+ local readContext = WorldReads.context(loader)
+ -- Capability queries are optional on older API-2 hosts. When present,
+ -- report the additive readers without constructing Game or the world.
+ if api.supports then
+ local supports = api.supports
+ function api:supports(surface, name)
+ if surface == "world" and WorldReads.supports(name) then return true end
+ return supports(self, surface, name)
+ end
+ end
+ setmetatable(api, PortableScripts.wrap({ __index = function(_, key)
if key == "game" then return loader:_game() end
if key == "storage" then
-- Do not pass loader.fs when it is love.filesystem: that always writes
@@ -1139,17 +1199,37 @@ function Loader:_api(mod)
local game = loader:_game()
local module = game and engineRequire("src.world.WorldAPI")
if not module then return nil end
- world = module.new(game, modId)
+ world = module.new(game, modId, readContext)
+ world.overviewContext = overviewContext
return world
- end })
+ end }, api, loader, mod.manifest))
+ -- When capability queries are installed, report the selected content
+ -- format's actual availability. Other surfaces retain their host query.
+ if api.contentFormat == "unified" and api.supports then
+ local supports, content = api.supports, api.content
+ api.supports = function(self, surface, name)
+ local handle = type(name) == "string" and content[name]
+ if surface == "content" and handle and handle.supports then return handle:supports() end
+ return supports(self, surface, name)
+ end
+ end
+ if (mod.manifest.api or 1) >= 3 then
+ engineRequire("src.mods.Api3").install(api, loader, readContext, overviewContext, engineRequire, engineCall)
+ end
return api
end
-- the live Game. An injected reference wins so a headless caller can hand
-- over a stub; otherwise the boot singleton, whose stack and overworld fill
-- in after this loader returns -- holding the table keeps the facade live.
-function Loader:_game()
- return self.game or engineRequire("src.core.Game")
+function Loader:_game(loadedOnly)
+ if self.game then return self.game end
+ -- Observational facades must not bootstrap Game from an early/headless read.
+ if loadedOnly then
+ local game = package.loaded["src.core.Game"]
+ return type(game) == "table" and game or nil
+ end
+ return engineRequire("src.core.Game")
end
function Loader:_loadMod(mod)
@@ -1196,6 +1276,10 @@ end
-- migration it took goes with them. The journal only exists around an
-- entry chunk; a later failure (script validation) purges every registry.
function Loader:_rollback(modId)
+ Api3WildBattle.cancelAll(self, modId)
+ Api3NPC.cancelAll(self, modId)
+ Api3Warp.cancelAll(self, modId)
+ PortableScripts.cancelAll(self, modId, "mod rolled back")
for name in pairs(self.journal or self.content) do
self.content[name]:rollback(modId)
end
@@ -1310,12 +1394,24 @@ local function stampAudioOwners(data, name, registry)
end
function Loader:load(data)
+ local viewport = package.loaded["src.render.GameViewport"]
+ if viewport then viewport.reset() end
+ Api3WildBattle.cancelAll()
+ Api3NPC.cancelAll()
+ Api3Warp.cancelAll()
+ PortableScripts.cancelAll(nil, nil, "mods reloaded")
+ require("src.core.Gen2SpeciesNames").publish(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
-- wraps and flags the previous load put on engine tables come off first.
ModSeams.unwindAll()
self.baseData = data
+ require('src.pokemon.Gen2MonFields').publish(data)
-- every registry folds against the pristine view of its Data target;
-- resolution is lazy so optional namespaces may appear later
for _, registry in pairs(self.content) do
@@ -1551,6 +1647,9 @@ function Loader:load(data)
stampAudioOwners(data, name, registry)
end
end
+ -- Native registry folds replace their tables. Reapply source stat views
+ -- after the real older writer, before exposing the completed dataset.
+ require('src.pokemon.Gen2MonFields').publish(data)
-- dangling f.id references are attributed to the id's last writer;
-- api 1 mods keep the warning-only compat path
if data then
@@ -1653,6 +1752,16 @@ 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 viewport = package.loaded["src.render.GameViewport"]
+ if viewport then viewport.reset() end
+ Api3WildBattle.cancelAll()
+ Api3NPC.cancelAll()
+ Api3Warp.cancelAll()
+ PortableScripts.cancelAll(nil, nil, "mods unloaded")
+ 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/mods/Manifest.lua b/src/mods/Manifest.lua
index ac5614634..3267b2c54 100644
--- a/src/mods/Manifest.lua
+++ b/src/mods/Manifest.lua
@@ -223,6 +223,16 @@ function Manifest.validate(raw, path)
assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d")
:format(api, Version.modApi))
local strict = api >= 2
+ -- API 3 is one portable contract, not three independent API-2 opt-ins.
+ for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do
+ assert(api < 3 or raw[field] == nil or raw[field] == "unified",
+ "API 3 requires unified " .. field)
+ end
+ local defaultFormat = api >= 3 and "unified" or "native"
+ assert(raw.content_format == nil or raw.content_format == "native"
+ or raw.content_format == "unified", "content_format must be native or unified")
+ assert(raw.content_format ~= "unified" or api >= 2,
+ "unified content requires mod API 2")
local profile = raw.profile or "content"
if not Manifest.PROFILES[profile] then
@@ -240,6 +250,10 @@ function Manifest.validate(raw, path)
end
end
+ local scriptFormat = raw.script_format == nil and defaultFormat or raw.script_format
+ assert(scriptFormat == "native" or scriptFormat == "unified", "script_format must be native or unified")
+ assert(scriptFormat ~= "unified" or api >= 2, "unified scripts require mod API 2")
+
local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version)
assert(gameVersionOk, ("malformed game_version %q: %s")
:format(tostring(raw.game_version), tostring(gameVersionErr)))
@@ -279,6 +293,10 @@ function Manifest.validate(raw, path)
return value
end
+ assert(raw.callback_format == nil or raw.callback_format == "native" or raw.callback_format == "unified",
+ "callback_format must be native or unified")
+ assert(raw.callback_format ~= "unified" or api >= 2, "callback_format unified requires mod API 2")
+
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
-- `support`: where to thank the mod's creator -- a Ko-fi, a Patreon, a
@@ -305,6 +323,7 @@ function Manifest.validate(raw, path)
version = raw.version,
entry = raw.entry,
api = api,
+ content_format = raw.content_format or defaultFormat,
priority = tonumber(raw.priority) or 0,
dependencies = array(raw.dependencies),
optional_dependencies = array(raw.optional_dependencies),
@@ -316,6 +335,7 @@ function Manifest.validate(raw, path)
category = raw.category or "OTHER",
game_version = raw.game_version,
description = raw.description or "",
+ callback_format = raw.callback_format or defaultFormat,
github = github,
support = support,
updateCheck = updateCheck,
@@ -325,6 +345,7 @@ function Manifest.validate(raw, path)
affects_link = affectsLink,
permissions = permissions,
permissionSet = permissionSet,
+ script_format = scriptFormat,
options_schema = optionalFile(raw.options_schema, "options_schema"),
-- Base files the mod needs but cannot ship -- a Stadium 2 cartridge, a
-- second game's ROM. Declared here, satisfied by the launcher, read by
diff --git a/src/mods/MoveEffectContract.lua b/src/mods/MoveEffectContract.lua
new file mode 100644
index 000000000..1d1ec1c27
--- /dev/null
+++ b/src/mods/MoveEffectContract.lua
@@ -0,0 +1,1213 @@
+-- Generated by tools/move-effect-contract/generate.py; do not hand-edit.
+return {
+ ["bindings"] = {
+ ["ACCURACY_DOWN1_EFFECT"] = {["1"]={["native"]="ACCURACY_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ACCURACY_DOWN",["semantic"]=23},["3"]={["chance"]=0,["native"]=23,["semantic"]=23}},
+ ["ASSIST"] = {["3"]={["native"]=180,["semantic"]=180}},
+ ["ATTACK_DOWN1_EFFECT"] = {["1"]={["native"]="ATTACK_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_DOWN",["semantic"]=18},["3"]={["chance"]=0,["native"]=18,["semantic"]=18}},
+ ["ATTACK_DOWN2_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_DOWN_2",["semantic"]=58},["3"]={["chance"]=0,["native"]=58,["semantic"]=58}},
+ ["ATTACK_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="ATTACK_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_ATTACK_DOWN_HIT",["semantic"]=68},["3"]={["chance"]=33,["native"]=68,["semantic"]=68}},
+ ["ATTACK_TWICE_EFFECT"] = {["1"]={["native"]="ATTACK_TWICE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DOUBLE_HIT",["semantic"]=44},["3"]={["chance"]=0,["native"]=44,["semantic"]=44}},
+ ["ATTACK_UP1_EFFECT"] = {["1"]={["native"]="ATTACK_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_UP",["semantic"]=10},["3"]={["chance"]=0,["native"]=10,["semantic"]=10}},
+ ["ATTACK_UP2_EFFECT"] = {["1"]={["native"]="ATTACK_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_UP_2",["semantic"]=50},["3"]={["chance"]=0,["native"]=50,["semantic"]=50}},
+ ["BIDE_EFFECT"] = {["1"]={["native"]="BIDE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_BIDE",["semantic"]=26},["3"]={["chance"]=0,["native"]=26,["semantic"]=26}},
+ ["BLAZE_KICK"] = {["3"]={["native"]=200,["semantic"]=200}},
+ ["BRICK_BREAK"] = {["3"]={["native"]=186,["semantic"]=186}},
+ ["BULK_UP"] = {["3"]={["native"]=208,["semantic"]=208}},
+ ["BURN_SIDE_EFFECT1"] = {["1"]={["native"]="BURN_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_BURN_HIT",["semantic"]=4},["3"]={["chance"]=10,["native"]=4,["semantic"]=4}},
+ ["BURN_SIDE_EFFECT2"] = {["1"]={["native"]="BURN_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_BURN_HIT",["semantic"]=4},["3"]={["chance"]=30,["native"]=4,["semantic"]=4}},
+ ["CALM_MIND"] = {["3"]={["native"]=211,["semantic"]=211}},
+ ["CAMOUFLAGE"] = {["3"]={["native"]=213,["semantic"]=213}},
+ ["CHARGE"] = {["3"]={["native"]=174,["semantic"]=174}},
+ ["CHARGE_EFFECT"] = {["1"]={["native"]="CHARGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAZOR_WIND",["semantic"]=39},["3"]={["chance"]=0,["native"]=39,["semantic"]=39}},
+ ["CONFUSION_EFFECT"] = {["1"]={["native"]="CONFUSION_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_CONFUSE",["semantic"]=49},["3"]={["chance"]=0,["native"]=49,["semantic"]=49}},
+ ["CONFUSION_SIDE_EFFECT"] = {["1"]={["native"]="CONFUSION_SIDE_EFFECT"},["2"]={["chance"]=10,["native"]="EFFECT_CONFUSE_HIT",["semantic"]=76},["3"]={["chance"]=10,["native"]=76,["semantic"]=76}},
+ ["CONVERSION_2"] = {["3"]={["native"]=93,["semantic"]=93}},
+ ["CONVERSION_EFFECT"] = {["1"]={["native"]="CONVERSION_EFFECT"},["3"]={["chance"]=0,["native"]=30,["semantic"]=30}},
+ ["COSMIC_POWER"] = {["3"]={["native"]=206,["semantic"]=206}},
+ ["DEFENSE_DOWN1_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_DOWN",["semantic"]=19},["3"]={["chance"]=0,["native"]=19,["semantic"]=19}},
+ ["DEFENSE_DOWN2_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_DOWN_2",["semantic"]=59},["3"]={["chance"]=0,["native"]=59,["semantic"]=59}},
+ ["DEFENSE_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_DEFENSE_DOWN_HIT",["semantic"]=69},["3"]={["chance"]=33,["native"]=69,["semantic"]=69}},
+ ["DEFENSE_UP1_EFFECT"] = {["1"]={["native"]="DEFENSE_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_UP",["semantic"]=11},["3"]={["chance"]=0,["native"]=11,["semantic"]=11}},
+ ["DEFENSE_UP2_EFFECT"] = {["1"]={["native"]="DEFENSE_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_UP_2",["semantic"]=51},["3"]={["chance"]=0,["native"]=51,["semantic"]=51}},
+ ["DESTINY_BOND"] = {["3"]={["native"]=98,["semantic"]=98}},
+ ["DISABLE_EFFECT"] = {["1"]={["native"]="DISABLE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DISABLE",["semantic"]=86},["3"]={["chance"]=0,["native"]=86,["semantic"]=86}},
+ ["DOUBLE_EDGE"] = {["3"]={["native"]=198,["semantic"]=198}},
+ ["DRAGON_DANCE"] = {["3"]={["native"]=212,["semantic"]=212}},
+ ["DRAIN_HP_EFFECT"] = {["1"]={["native"]="DRAIN_HP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEECH_HIT",["semantic"]=3},["3"]={["chance"]=0,["native"]=3,["semantic"]=3}},
+ ["DREAM_EATER_EFFECT"] = {["1"]={["native"]="DREAM_EATER_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DREAM_EATER",["semantic"]=8},["3"]={["chance"]=0,["native"]=8,["semantic"]=8}},
+ ["EARTHQUAKE"] = {["3"]={["native"]=147,["semantic"]=147}},
+ ["EFFECT_ACCURACY_DOWN_HIT"] = {["2"]={["native"]="EFFECT_ACCURACY_DOWN_HIT",["semantic"]=73},["3"]={["native"]=73,["semantic"]=73}},
+ ["EFFECT_ALL_UP_HIT"] = {["2"]={["native"]="EFFECT_ALL_UP_HIT",["semantic"]=140},["3"]={["native"]=140,["semantic"]=140}},
+ ["EFFECT_ATTACK_UP_HIT"] = {["2"]={["native"]="EFFECT_ATTACK_UP_HIT",["semantic"]=139},["3"]={["native"]=139,["semantic"]=139}},
+ ["EFFECT_ATTRACT"] = {["2"]={["native"]="EFFECT_ATTRACT",["semantic"]=120},["3"]={["native"]=120,["semantic"]=120}},
+ ["EFFECT_BATON_PASS"] = {["2"]={["native"]="EFFECT_BATON_PASS",["semantic"]=127},["3"]={["native"]=127,["semantic"]=127}},
+ ["EFFECT_BEAT_UP"] = {["2"]={["native"]="EFFECT_BEAT_UP",["semantic"]=154},["3"]={["native"]=154,["semantic"]=154}},
+ ["EFFECT_BELLY_DRUM"] = {["2"]={["native"]="EFFECT_BELLY_DRUM",["semantic"]=142},["3"]={["native"]=142,["semantic"]=142}},
+ ["EFFECT_CONVERSION2"] = {["3"]={["native"]=93,["semantic"]=93}},
+ ["EFFECT_CURSE"] = {["2"]={["native"]="EFFECT_CURSE",["semantic"]=109},["3"]={["native"]=109,["semantic"]=109}},
+ ["EFFECT_DEFENSE_CURL"] = {["2"]={["native"]="EFFECT_DEFENSE_CURL",["semantic"]=156},["3"]={["native"]=156,["semantic"]=156}},
+ ["EFFECT_DEFENSE_UP_HIT"] = {["2"]={["native"]="EFFECT_DEFENSE_UP_HIT",["semantic"]=138},["3"]={["native"]=138,["semantic"]=138}},
+ ["EFFECT_DESTINY_BOND"] = {["3"]={["native"]=98,["semantic"]=98}},
+ ["EFFECT_EARTHQUAKE"] = {["3"]={["native"]=147,["semantic"]=147}},
+ ["EFFECT_ENCORE"] = {["2"]={["native"]="EFFECT_ENCORE",["semantic"]=90},["3"]={["native"]=90,["semantic"]=90}},
+ ["EFFECT_ENDURE"] = {["2"]={["native"]="EFFECT_ENDURE",["semantic"]=116},["3"]={["native"]=116,["semantic"]=116}},
+ ["EFFECT_FAKE_OUT"] = {["3"]={["native"]=158,["semantic"]=158}},
+ ["EFFECT_FALSE_SWIPE"] = {["2"]={["native"]="EFFECT_FALSE_SWIPE",["semantic"]=101},["3"]={["native"]=101,["semantic"]=101}},
+ ["EFFECT_FLAME_WHEEL"] = {["3"]={["native"]=125,["semantic"]=125}},
+ ["EFFECT_FORESIGHT"] = {["2"]={["native"]="EFFECT_FORESIGHT",["semantic"]=113},["3"]={["native"]=113,["semantic"]=113}},
+ ["EFFECT_FRUSTRATION"] = {["2"]={["native"]="EFFECT_FRUSTRATION",["semantic"]=123},["3"]={["native"]=123,["semantic"]=123}},
+ ["EFFECT_FURY_CUTTER"] = {["2"]={["native"]="EFFECT_FURY_CUTTER",["semantic"]=119},["3"]={["native"]=119,["semantic"]=119}},
+ ["EFFECT_FUTURE_SIGHT"] = {["2"]={["native"]="EFFECT_FUTURE_SIGHT",["semantic"]=148},["3"]={["native"]=148,["semantic"]=148}},
+ ["EFFECT_GUST"] = {["3"]={["native"]=149,["semantic"]=149}},
+ ["EFFECT_HEAL_BELL"] = {["3"]={["native"]=102,["semantic"]=102}},
+ ["EFFECT_LOCK_ON"] = {["2"]={["native"]="EFFECT_LOCK_ON",["semantic"]=94},["3"]={["native"]=94,["semantic"]=94}},
+ ["EFFECT_MAGNITUDE"] = {["2"]={["native"]="EFFECT_MAGNITUDE",["semantic"]=126},["3"]={["native"]=126,["semantic"]=126}},
+ ["EFFECT_MEAN_LOOK"] = {["2"]={["native"]="EFFECT_MEAN_LOOK",["semantic"]=106},["3"]={["native"]=106,["semantic"]=106}},
+ ["EFFECT_MIRROR_COAT"] = {["2"]={["native"]="EFFECT_MIRROR_COAT",["semantic"]=144},["3"]={["native"]=144,["semantic"]=144}},
+ ["EFFECT_MOONLIGHT"] = {["2"]={["native"]="EFFECT_MOONLIGHT",["semantic"]=134},["3"]={["native"]=134,["semantic"]=134}},
+ ["EFFECT_MORNING_SUN"] = {["2"]={["native"]="EFFECT_MORNING_SUN",["semantic"]=132},["3"]={["native"]=132,["semantic"]=132}},
+ ["EFFECT_NIGHTMARE"] = {["2"]={["native"]="EFFECT_NIGHTMARE",["semantic"]=107},["3"]={["native"]=107,["semantic"]=107}},
+ ["EFFECT_PAIN_SPLIT"] = {["3"]={["native"]=91,["semantic"]=91}},
+ ["EFFECT_PERISH_SONG"] = {["2"]={["native"]="EFFECT_PERISH_SONG",["semantic"]=114},["3"]={["native"]=114,["semantic"]=114}},
+ ["EFFECT_PRESENT"] = {["3"]={["native"]=122,["semantic"]=122}},
+ ["EFFECT_PRIORITY_HIT"] = {["2"]={["native"]="EFFECT_PRIORITY_HIT",["semantic"]=103}},
+ ["EFFECT_PROTECT"] = {["2"]={["native"]="EFFECT_PROTECT",["semantic"]=111},["3"]={["native"]=111,["semantic"]=111}},
+ ["EFFECT_PSYCH_UP"] = {["2"]={["native"]="EFFECT_PSYCH_UP",["semantic"]=143},["3"]={["native"]=143,["semantic"]=143}},
+ ["EFFECT_PURSUIT"] = {["3"]={["native"]=128,["semantic"]=128}},
+ ["EFFECT_RAIN_DANCE"] = {["2"]={["native"]="EFFECT_RAIN_DANCE",["semantic"]=136},["3"]={["native"]=136,["semantic"]=136}},
+ ["EFFECT_RAPID_SPIN"] = {["3"]={["native"]=129,["semantic"]=129}},
+ ["EFFECT_RETURN"] = {["2"]={["native"]="EFFECT_RETURN",["semantic"]=121},["3"]={["native"]=121,["semantic"]=121}},
+ ["EFFECT_REVERSAL"] = {["3"]={["native"]=99,["semantic"]=99}},
+ ["EFFECT_ROLLOUT"] = {["2"]={["native"]="EFFECT_ROLLOUT",["semantic"]=117},["3"]={["native"]=117,["semantic"]=117}},
+ ["EFFECT_SACRED_FIRE"] = {["2"]={["native"]="EFFECT_SACRED_FIRE",["semantic"]=125},["3"]={["native"]=125,["semantic"]=125}},
+ ["EFFECT_SAFEGUARD"] = {["2"]={["native"]="EFFECT_SAFEGUARD",["semantic"]=124},["3"]={["native"]=124,["semantic"]=124}},
+ ["EFFECT_SANDSTORM"] = {["2"]={["native"]="EFFECT_SANDSTORM",["semantic"]=115},["3"]={["native"]=115,["semantic"]=115}},
+ ["EFFECT_SKETCH"] = {["2"]={["native"]="EFFECT_SKETCH",["semantic"]=95},["3"]={["native"]=95,["semantic"]=95}},
+ ["EFFECT_SLEEP_TALK"] = {["2"]={["native"]="EFFECT_SLEEP_TALK",["semantic"]=97},["3"]={["native"]=97,["semantic"]=97}},
+ ["EFFECT_SNORE"] = {["2"]={["native"]="EFFECT_SNORE",["semantic"]=92},["3"]={["native"]=92,["semantic"]=92}},
+ ["EFFECT_SPIKES"] = {["2"]={["native"]="EFFECT_SPIKES",["semantic"]=112},["3"]={["native"]=112,["semantic"]=112}},
+ ["EFFECT_SPITE"] = {["2"]={["native"]="EFFECT_SPITE",["semantic"]=100},["3"]={["native"]=100,["semantic"]=100}},
+ ["EFFECT_SP_ATK_DOWN_HIT"] = {["3"]={["native"]=71,["semantic"]=71}},
+ ["EFFECT_SP_ATK_UP_2"] = {["3"]={["native"]=53,["semantic"]=53}},
+ ["EFFECT_STOMP"] = {["3"]={["native"]=150,["semantic"]=150}},
+ ["EFFECT_SUNNY_DAY"] = {["2"]={["native"]="EFFECT_SUNNY_DAY",["semantic"]=137},["3"]={["native"]=137,["semantic"]=137}},
+ ["EFFECT_SWAGGER"] = {["3"]={["native"]=118,["semantic"]=118}},
+ ["EFFECT_SYNTHESIS"] = {["2"]={["native"]="EFFECT_SYNTHESIS",["semantic"]=133},["3"]={["native"]=133,["semantic"]=133}},
+ ["EFFECT_THIEF"] = {["2"]={["native"]="EFFECT_THIEF",["semantic"]=105},["3"]={["native"]=105,["semantic"]=105}},
+ ["EFFECT_THUNDER"] = {["3"]={["native"]=152,["semantic"]=152}},
+ ["EFFECT_TRIPLE_KICK"] = {["2"]={["native"]="EFFECT_TRIPLE_KICK",["semantic"]=104},["3"]={["native"]=104,["semantic"]=104}},
+ ["EFFECT_TRI_ATTACK"] = {["3"]={["native"]=36,["semantic"]=36}},
+ ["EFFECT_TWISTER"] = {["3"]={["native"]=146,["semantic"]=146}},
+ ["ENDEAVOR"] = {["3"]={["native"]=189,["semantic"]=189}},
+ ["ERUPTION"] = {["3"]={["native"]=190,["semantic"]=190}},
+ ["EVASION_DOWN1_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_EVASION_DOWN",["semantic"]=24},["3"]={["chance"]=0,["native"]=24,["semantic"]=24}},
+ ["EVASION_UP1_EFFECT"] = {["1"]={["native"]="EVASION_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_EVASION_UP",["semantic"]=16},["3"]={["chance"]=0,["native"]=16,["semantic"]=16}},
+ ["EXPLODE_EFFECT"] = {["1"]={["native"]="EXPLODE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SELFDESTRUCT",["semantic"]=7},["3"]={["chance"]=0,["native"]=7,["semantic"]=7}},
+ ["FACADE"] = {["3"]={["native"]=169,["semantic"]=169}},
+ ["FAKE_OUT"] = {["3"]={["native"]=158,["semantic"]=158}},
+ ["FLAIL"] = {["3"]={["native"]=99,["semantic"]=99}},
+ ["FLATTER"] = {["3"]={["native"]=166,["semantic"]=166}},
+ ["FLINCH_MINIMIZE_HIT"] = {["3"]={["native"]=150,["semantic"]=150}},
+ ["FLINCH_SIDE_EFFECT1"] = {["1"]={["native"]="FLINCH_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_FLINCH_HIT",["semantic"]=31},["3"]={["chance"]=10,["native"]=31,["semantic"]=31}},
+ ["FLINCH_SIDE_EFFECT2"] = {["1"]={["native"]="FLINCH_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_FLINCH_HIT",["semantic"]=31},["3"]={["chance"]=30,["native"]=31,["semantic"]=31}},
+ ["FLY_EFFECT"] = {["1"]={["native"]="FLY_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FLY",["semantic"]=155},["3"]={["chance"]=0,["native"]=155,["semantic"]=155}},
+ ["FOCUS_ENERGY_EFFECT"] = {["1"]={["native"]="FOCUS_ENERGY_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FOCUS_ENERGY",["semantic"]=47},["3"]={["chance"]=0,["native"]=47,["semantic"]=47}},
+ ["FOCUS_PUNCH"] = {["3"]={["native"]=170,["semantic"]=170}},
+ ["FOLLOW_ME"] = {["3"]={["native"]=172,["semantic"]=172}},
+ ["FREEZE_SIDE_EFFECT1"] = {["1"]={["native"]="FREEZE_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_FREEZE_HIT",["semantic"]=5},["3"]={["chance"]=10,["native"]=5,["semantic"]=5}},
+ ["FREEZE_SIDE_EFFECT2"] = {["2"]={["chance"]=30,["native"]="EFFECT_FREEZE_HIT",["semantic"]=5},["3"]={["chance"]=30,["native"]=5,["semantic"]=5}},
+ ["GRUDGE"] = {["3"]={["native"]=194,["semantic"]=194}},
+ ["GUST"] = {["3"]={["native"]=149,["semantic"]=149}},
+ ["HAIL"] = {["3"]={["native"]=164,["semantic"]=164}},
+ ["HAZE_EFFECT"] = {["1"]={["native"]="HAZE_EFFECT"},["3"]={["chance"]=0,["native"]=25,["semantic"]=25}},
+ ["HEAL_BELL"] = {["3"]={["native"]=102,["semantic"]=102}},
+ ["HEAL_EFFECT"] = {["1"]={["native"]="HEAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_HEAL",["semantic"]=32},["3"]={["chance"]=0,["native"]=32,["semantic"]=32}},
+ ["HELPING_HAND"] = {["3"]={["native"]=176,["semantic"]=176}},
+ ["HIT"] = {["1"]={["native"]="NO_ADDITIONAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_NORMAL_HIT",["semantic"]=0},["3"]={["chance"]=0,["native"]=0,["semantic"]=0}},
+ ["HYPER_BEAM_EFFECT"] = {["1"]={["native"]="HYPER_BEAM_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_HYPER_BEAM",["semantic"]=80},["3"]={["chance"]=0,["native"]=80,["semantic"]=80}},
+ ["IMPRISON"] = {["3"]={["native"]=192,["semantic"]=192}},
+ ["INGRAIN"] = {["3"]={["native"]=181,["semantic"]=181}},
+ ["JUMP_KICK_EFFECT"] = {["1"]={["native"]="JUMP_KICK_EFFECT"},["3"]={["chance"]=0,["native"]=45,["semantic"]=45}},
+ ["KNOCK_OFF"] = {["3"]={["native"]=188,["semantic"]=188}},
+ ["LEECH_SEED_EFFECT"] = {["1"]={["native"]="LEECH_SEED_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEECH_SEED",["semantic"]=84},["3"]={["chance"]=0,["native"]=84,["semantic"]=84}},
+ ["LIGHT_SCREEN_EFFECT"] = {["1"]={["native"]="LIGHT_SCREEN_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LIGHT_SCREEN",["semantic"]=35},["3"]={["chance"]=0,["native"]=35,["semantic"]=35}},
+ ["LOW_KICK"] = {["3"]={["native"]=196,["semantic"]=196}},
+ ["MAGIC_COAT"] = {["3"]={["native"]=183,["semantic"]=183}},
+ ["MEMENTO"] = {["3"]={["native"]=168,["semantic"]=168}},
+ ["METRONOME_EFFECT"] = {["1"]={["native"]="METRONOME_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_METRONOME",["semantic"]=83},["3"]={["chance"]=0,["native"]=83,["semantic"]=83}},
+ ["MIMIC_EFFECT"] = {["1"]={["native"]="MIMIC_EFFECT"},["3"]={["chance"]=0,["native"]=82,["semantic"]=82}},
+ ["MINIMIZE"] = {["3"]={["native"]=108,["semantic"]=108}},
+ ["MIRROR_MOVE_EFFECT"] = {["1"]={["native"]="MIRROR_MOVE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MIRROR_MOVE",["semantic"]=9},["3"]={["chance"]=0,["native"]=9,["semantic"]=9}},
+ ["MIST_EFFECT"] = {["1"]={["native"]="MIST_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MIST",["semantic"]=46},["3"]={["chance"]=0,["native"]=46,["semantic"]=46}},
+ ["MUD_SPORT"] = {["3"]={["native"]=201,["semantic"]=201}},
+ ["NATURE_POWER"] = {["3"]={["native"]=173,["semantic"]=173}},
+ ["NO_ADDITIONAL_EFFECT"] = {["1"]={["native"]="NO_ADDITIONAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_NORMAL_HIT",["semantic"]=0},["3"]={["chance"]=0,["native"]=0,["semantic"]=0}},
+ ["OHKO_EFFECT"] = {["1"]={["native"]="OHKO_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_OHKO",["semantic"]=38},["3"]={["chance"]=0,["native"]=38,["semantic"]=38}},
+ ["OVERHEAT"] = {["3"]={["native"]=204,["semantic"]=204}},
+ ["PAIN_SPLIT"] = {["3"]={["native"]=91,["semantic"]=91}},
+ ["PARALYZE_EFFECT"] = {["1"]={["native"]="PARALYZE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_PARALYZE",["semantic"]=67},["3"]={["chance"]=0,["native"]=67,["semantic"]=67}},
+ ["PARALYZE_SIDE_EFFECT1"] = {["1"]={["native"]="PARALYZE_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_PARALYZE_HIT",["semantic"]=6},["3"]={["chance"]=10,["native"]=6,["semantic"]=6}},
+ ["PARALYZE_SIDE_EFFECT2"] = {["1"]={["native"]="PARALYZE_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_PARALYZE_HIT",["semantic"]=6},["3"]={["chance"]=30,["native"]=6,["semantic"]=6}},
+ ["PAY_DAY_EFFECT"] = {["1"]={["native"]="PAY_DAY_EFFECT"},["2"]={["chance"]=100,["native"]="EFFECT_PAY_DAY",["semantic"]=34},["3"]={["chance"]=100,["native"]=34,["semantic"]=34}},
+ ["POISON_EFFECT"] = {["1"]={["native"]="POISON_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_POISON",["semantic"]=66},["3"]={["chance"]=0,["native"]=66,["semantic"]=66}},
+ ["POISON_FANG"] = {["3"]={["native"]=202,["semantic"]=202}},
+ ["POISON_SIDE_EFFECT1"] = {["1"]={["native"]="POISON_SIDE_EFFECT1"},["2"]={["chance"]=20,["native"]="EFFECT_POISON_HIT",["semantic"]=2},["3"]={["chance"]=20,["native"]=2,["semantic"]=2}},
+ ["POISON_SIDE_EFFECT2"] = {["1"]={["native"]="POISON_SIDE_EFFECT2"},["2"]={["chance"]=40,["native"]="EFFECT_POISON_HIT",["semantic"]=2},["3"]={["chance"]=40,["native"]=2,["semantic"]=2}},
+ ["POISON_TAIL"] = {["3"]={["native"]=209,["semantic"]=209}},
+ ["PRESENT"] = {["3"]={["native"]=122,["semantic"]=122}},
+ ["PURSUIT"] = {["3"]={["native"]=128,["semantic"]=128}},
+ ["RAGE_EFFECT"] = {["1"]={["native"]="RAGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAGE",["semantic"]=81},["3"]={["chance"]=0,["native"]=81,["semantic"]=81}},
+ ["RAPID_SPIN"] = {["3"]={["native"]=129,["semantic"]=129}},
+ ["RECOIL_EFFECT"] = {["1"]={["native"]="RECOIL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RECOIL_HIT",["semantic"]=48},["3"]={["chance"]=0,["native"]=48,["semantic"]=48}},
+ ["RECYCLE"] = {["3"]={["native"]=184,["semantic"]=184}},
+ ["REFLECT_EFFECT"] = {["1"]={["native"]="REFLECT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_REFLECT",["semantic"]=65},["3"]={["chance"]=0,["native"]=65,["semantic"]=65}},
+ ["REFRESH"] = {["3"]={["native"]=193,["semantic"]=193}},
+ ["REVENGE"] = {["3"]={["native"]=185,["semantic"]=185}},
+ ["ROLE_PLAY"] = {["3"]={["native"]=178,["semantic"]=178}},
+ ["SECRET_POWER"] = {["3"]={["native"]=197,["semantic"]=197}},
+ ["SKILL_SWAP"] = {["3"]={["native"]=191,["semantic"]=191}},
+ ["SKY_UPPERCUT"] = {["3"]={["native"]=207,["semantic"]=207}},
+ ["SLEEP"] = {["1"]={["native"]="SLEEP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SLEEP",["semantic"]=1},["3"]={["chance"]=0,["native"]=1,["semantic"]=1}},
+ ["SLEEP_EFFECT"] = {["1"]={["native"]="SLEEP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SLEEP",["semantic"]=1},["3"]={["chance"]=0,["native"]=1,["semantic"]=1}},
+ ["SMELLINGSALT"] = {["3"]={["native"]=171,["semantic"]=171}},
+ ["SNATCH"] = {["3"]={["native"]=195,["semantic"]=195}},
+ ["SOFTBOILED"] = {["3"]={["native"]=157,["semantic"]=157}},
+ ["SPECIAL_ATTACK_DOWN_HIT"] = {["3"]={["native"]=71,["semantic"]=71}},
+ ["SPECIAL_ATTACK_UP_2"] = {["3"]={["native"]=53,["semantic"]=53}},
+ ["SPECIAL_DAMAGE_EFFECT"] = {["1"]={["native"]="SPECIAL_DAMAGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEVEL_DAMAGE",["semantic"]=87},["3"]={["chance"]=0,["native"]=87,["semantic"]=87}},
+ ["SPECIAL_DOWN2_EFFECT"] = {["3"]={["chance"]=0,["native"]=62,["semantic"]=62}},
+ ["SPECIAL_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="SPECIAL_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_SP_DEF_DOWN_HIT",["semantic"]=72},["3"]={["chance"]=33,["native"]=72,["semantic"]=72}},
+ ["SPECIAL_UP1_EFFECT"] = {["1"]={["native"]="SPECIAL_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SP_ATK_UP",["semantic"]=13},["3"]={["chance"]=0,["native"]=13,["semantic"]=13}},
+ ["SPECIAL_UP2_EFFECT"] = {["1"]={["native"]="SPECIAL_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SP_DEF_UP_2",["semantic"]=54},["3"]={["chance"]=0,["native"]=54,["semantic"]=54}},
+ ["SPEED_DOWN1_EFFECT"] = {["1"]={["native"]="SPEED_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPEED_DOWN",["semantic"]=20},["3"]={["chance"]=0,["native"]=20,["semantic"]=20}},
+ ["SPEED_DOWN2_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_SPEED_DOWN_2",["semantic"]=60},["3"]={["chance"]=0,["native"]=60,["semantic"]=60}},
+ ["SPEED_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="SPEED_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_SPEED_DOWN_HIT",["semantic"]=70},["3"]={["chance"]=33,["native"]=70,["semantic"]=70}},
+ ["SPEED_UP2_EFFECT"] = {["1"]={["native"]="SPEED_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPEED_UP_2",["semantic"]=52},["3"]={["chance"]=0,["native"]=52,["semantic"]=52}},
+ ["SPIT_UP"] = {["3"]={["native"]=161,["semantic"]=161}},
+ ["SPLASH_EFFECT"] = {["1"]={["native"]="SPLASH_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPLASH",["semantic"]=85},["3"]={["chance"]=0,["native"]=85,["semantic"]=85}},
+ ["STOCKPILE"] = {["3"]={["native"]=160,["semantic"]=160}},
+ ["SUBSTITUTE_EFFECT"] = {["1"]={["native"]="SUBSTITUTE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SUBSTITUTE",["semantic"]=79},["3"]={["chance"]=0,["native"]=79,["semantic"]=79}},
+ ["SUPERPOWER"] = {["3"]={["native"]=182,["semantic"]=182}},
+ ["SUPER_FANG_EFFECT"] = {["1"]={["native"]="SUPER_FANG_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SUPER_FANG",["semantic"]=40},["3"]={["chance"]=0,["native"]=40,["semantic"]=40}},
+ ["SWAGGER"] = {["3"]={["native"]=118,["semantic"]=118}},
+ ["SWALLOW"] = {["3"]={["native"]=162,["semantic"]=162}},
+ ["SWIFT_EFFECT"] = {["1"]={["native"]="SWIFT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ALWAYS_HIT",["semantic"]=17},["3"]={["chance"]=0,["native"]=17,["semantic"]=17}},
+ ["SWITCH_AND_TELEPORT_EFFECT"] = {["1"]={["native"]="SWITCH_AND_TELEPORT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FORCE_SWITCH",["semantic"]=28},["3"]={["chance"]=0,["native"]=28,["semantic"]=28}},
+ ["TAUNT"] = {["3"]={["native"]=175,["semantic"]=175}},
+ ["TEETER_DANCE"] = {["3"]={["native"]=199,["semantic"]=199}},
+ ["THRASH_PETAL_DANCE_EFFECT"] = {["1"]={["native"]="THRASH_PETAL_DANCE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAMPAGE",["semantic"]=27},["3"]={["chance"]=0,["native"]=27,["semantic"]=27}},
+ ["THUNDER"] = {["3"]={["native"]=152,["semantic"]=152}},
+ ["TICKLE"] = {["3"]={["native"]=205,["semantic"]=205}},
+ ["TORMENT"] = {["3"]={["native"]=165,["semantic"]=165}},
+ ["TRANSFORM_EFFECT"] = {["1"]={["native"]="TRANSFORM_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_TRANSFORM",["semantic"]=57},["3"]={["chance"]=0,["native"]=57,["semantic"]=57}},
+ ["TRAPPING_EFFECT"] = {["1"]={["native"]="TRAPPING_EFFECT"},["2"]={["chance"]=100,["native"]="EFFECT_TRAP_TARGET",["semantic"]=42},["3"]={["chance"]=100,["native"]=42,["semantic"]=42}},
+ ["TRICK"] = {["3"]={["native"]=177,["semantic"]=177}},
+ ["TRI_ATTACK"] = {["3"]={["native"]=36,["semantic"]=36}},
+ ["TWINEEDLE_EFFECT"] = {["1"]={["native"]="TWINEEDLE_EFFECT"},["3"]={["chance"]=20,["native"]=77,["semantic"]=77}},
+ ["TWISTER"] = {["3"]={["native"]=146,["semantic"]=146}},
+ ["TWO_TO_FIVE_ATTACKS_EFFECT"] = {["1"]={["native"]="TWO_TO_FIVE_ATTACKS_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MULTI_HIT",["semantic"]=29},["3"]={["chance"]=0,["native"]=29,["semantic"]=29}},
+ ["UPROAR"] = {["3"]={["native"]=159,["semantic"]=159}},
+ ["VITAL_THROW"] = {["3"]={["native"]=78,["semantic"]=78}},
+ ["WATER_SPORT"] = {["3"]={["native"]=210,["semantic"]=210}},
+ ["WEATHER_BALL"] = {["3"]={["native"]=203,["semantic"]=203}},
+ ["WISH"] = {["3"]={["native"]=179,["semantic"]=179}},
+ ["YAWN"] = {["3"]={["native"]=187,["semantic"]=187}},
+ },
+ ["hostMapSha256"] = "640d77b2a86740d18458262bdf424070ad9c52164365dbdb4879f3af2ba038ee",
+ ["native3"] = {
+ ["0"] = {"NO_ADDITIONAL_EFFECT"},
+ ["1"] = {"SLEEP_EFFECT"},
+ ["10"] = {"ATTACK_UP1_EFFECT"},
+ ["100"] = {"SPITE_EFFECT"},
+ ["101"] = {"FALSE_SWIPE_EFFECT"},
+ ["102"] = {"HEAL_BELL_EFFECT"},
+ ["103"] = {"NO_ADDITIONAL_EFFECT"},
+ ["104"] = {"TRIPLE_KICK_EFFECT"},
+ ["105"] = {"THIEF_EFFECT"},
+ ["106"] = {"MEAN_LOOK_EFFECT"},
+ ["107"] = {"NIGHTMARE_EFFECT"},
+ ["108"] = {"EVASION_UP1_EFFECT"},
+ ["109"] = {"CURSE_EFFECT"},
+ ["11"] = {"DEFENSE_UP1_EFFECT"},
+ ["111"] = {"PROTECT_EFFECT"},
+ ["112"] = {"SPIKES_EFFECT"},
+ ["113"] = {"FORESIGHT_EFFECT"},
+ ["114"] = {"PERISH_SONG_EFFECT"},
+ ["115"] = {"SANDSTORM_EFFECT"},
+ ["116"] = {"ENDURE_EFFECT"},
+ ["117"] = {"ROLLOUT_EFFECT"},
+ ["118"] = {"SWAGGER_EFFECT"},
+ ["119"] = {"FURY_CUTTER_EFFECT"},
+ ["120"] = {"ATTRACT_EFFECT"},
+ ["121"] = {"RETURN_EFFECT"},
+ ["122"] = {"PRESENT_EFFECT"},
+ ["123"] = {"FRUSTRATION_EFFECT"},
+ ["124"] = {"SAFEGUARD_EFFECT"},
+ ["125"] = {"BURN_SIDE_EFFECT1","BURN_SIDE_EFFECT2"},
+ ["126"] = {"MAGNITUDE_EFFECT"},
+ ["127"] = {"BATON_PASS_EFFECT"},
+ ["128"] = {"PURSUIT_EFFECT"},
+ ["129"] = {"RAPID_SPIN_EFFECT"},
+ ["13"] = {"SP_ATK_UP1_EFFECT"},
+ ["130"] = {"SPECIAL_DAMAGE_EFFECT"},
+ ["132"] = {"MORNING_SUN_EFFECT"},
+ ["133"] = {"SYNTHESIS_EFFECT"},
+ ["134"] = {"MOONLIGHT_EFFECT"},
+ ["135"] = {"HIDDEN_POWER_EFFECT"},
+ ["136"] = {"RAIN_DANCE_EFFECT"},
+ ["137"] = {"SUNNY_DAY_EFFECT"},
+ ["138"] = {"DEFENSE_UP_HIT_EFFECT"},
+ ["139"] = {"ATTACK_UP_HIT_EFFECT"},
+ ["140"] = {"ALL_UP_HIT_EFFECT"},
+ ["142"] = {"BELLY_DRUM_EFFECT"},
+ ["143"] = {"PSYCH_UP_EFFECT"},
+ ["144"] = {"MIRROR_COAT_EFFECT"},
+ ["145"] = {"CHARGE_EFFECT"},
+ ["146"] = {"FLINCH_SIDE_EFFECT1"},
+ ["147"] = {"EARTHQUAKE_EFFECT"},
+ ["148"] = {"FUTURE_SIGHT_EFFECT"},
+ ["149"] = {"GUST_EFFECT"},
+ ["150"] = {"FLINCH_SIDE_EFFECT2"},
+ ["151"] = {"SOLARBEAM_EFFECT"},
+ ["152"] = {"THUNDER_EFFECT"},
+ ["153"] = {"SWITCH_AND_TELEPORT_EFFECT"},
+ ["154"] = {"BEAT_UP_EFFECT"},
+ ["155"] = {"FLY_EFFECT"},
+ ["156"] = {"DEFENSE_UP1_EFFECT"},
+ ["157"] = {"HEAL_EFFECT"},
+ ["158"] = {"FAKE_OUT_EFFECT"},
+ ["159"] = {"UPROAR_EFFECT"},
+ ["16"] = {"EVASION_UP1_EFFECT"},
+ ["160"] = {"STOCKPILE_EFFECT"},
+ ["161"] = {"SPIT_UP_EFFECT"},
+ ["162"] = {"SWALLOW_EFFECT"},
+ ["164"] = {"HAIL_EFFECT"},
+ ["165"] = {"TORMENT_EFFECT"},
+ ["166"] = {"FLATTER_EFFECT"},
+ ["167"] = {"WILL_O_WISP_EFFECT"},
+ ["168"] = {"MEMENTO_EFFECT"},
+ ["169"] = {"FACADE_EFFECT"},
+ ["17"] = {"SWIFT_EFFECT"},
+ ["170"] = {"FOCUS_PUNCH_EFFECT"},
+ ["171"] = {"SMELLINGSALT_EFFECT"},
+ ["172"] = {"FOLLOW_ME_EFFECT"},
+ ["173"] = {"NATURE_POWER_EFFECT"},
+ ["174"] = {"CHARGE_UP_EFFECT"},
+ ["175"] = {"TAUNT_EFFECT"},
+ ["176"] = {"HELPING_HAND_EFFECT"},
+ ["177"] = {"TRICK_EFFECT"},
+ ["178"] = {"ROLE_PLAY_EFFECT"},
+ ["179"] = {"WISH_EFFECT"},
+ ["18"] = {"ATTACK_DOWN1_EFFECT"},
+ ["180"] = {"ASSIST_EFFECT"},
+ ["181"] = {"INGRAIN_EFFECT"},
+ ["182"] = {"SUPERPOWER_EFFECT"},
+ ["183"] = {"MAGIC_COAT_EFFECT"},
+ ["184"] = {"RECYCLE_EFFECT"},
+ ["185"] = {"REVENGE_EFFECT"},
+ ["186"] = {"BRICK_BREAK_EFFECT"},
+ ["187"] = {"YAWN_EFFECT"},
+ ["188"] = {"KNOCK_OFF_EFFECT"},
+ ["189"] = {"ENDEAVOR_EFFECT"},
+ ["19"] = {"DEFENSE_DOWN1_EFFECT"},
+ ["190"] = {"ERUPTION_EFFECT"},
+ ["191"] = {"SKILL_SWAP_EFFECT"},
+ ["192"] = {"IMPRISON_EFFECT"},
+ ["193"] = {"REFRESH_EFFECT"},
+ ["194"] = {"GRUDGE_EFFECT"},
+ ["195"] = {"SNATCH_EFFECT"},
+ ["196"] = {"LOW_KICK_EFFECT"},
+ ["197"] = {"SECRET_POWER_EFFECT"},
+ ["198"] = {"RECOIL_EFFECT"},
+ ["199"] = {"TEETER_DANCE_EFFECT"},
+ ["2"] = {"POISON_SIDE_EFFECT2"},
+ ["20"] = {"SPEED_DOWN1_EFFECT"},
+ ["200"] = {"BLAZE_KICK_EFFECT"},
+ ["201"] = {"MUD_SPORT_EFFECT"},
+ ["202"] = {"POISON_FANG_EFFECT"},
+ ["203"] = {"WEATHER_BALL_EFFECT"},
+ ["204"] = {"OVERHEAT_EFFECT"},
+ ["205"] = {"TICKLE_EFFECT"},
+ ["206"] = {"COSMIC_POWER_EFFECT"},
+ ["207"] = {"SKY_UPPERCUT_EFFECT"},
+ ["208"] = {"BULK_UP_EFFECT"},
+ ["209"] = {"POISON_TAIL_EFFECT"},
+ ["210"] = {"WATER_SPORT_EFFECT"},
+ ["211"] = {"CALM_MIND_EFFECT"},
+ ["212"] = {"DRAGON_DANCE_EFFECT"},
+ ["213"] = {"CAMOUFLAGE_EFFECT"},
+ ["23"] = {"ACCURACY_DOWN1_EFFECT"},
+ ["24"] = {"EVASION_DOWN1_EFFECT"},
+ ["25"] = {"HAZE_EFFECT"},
+ ["26"] = {"BIDE_EFFECT"},
+ ["27"] = {"THRASH_PETAL_DANCE_EFFECT"},
+ ["28"] = {"SWITCH_AND_TELEPORT_EFFECT"},
+ ["29"] = {"TWO_TO_FIVE_ATTACKS_EFFECT"},
+ ["3"] = {"DRAIN_HP_EFFECT"},
+ ["30"] = {"CONVERSION_EFFECT"},
+ ["31"] = {"FLINCH_SIDE_EFFECT1","FLINCH_SIDE_EFFECT2"},
+ ["32"] = {"HEAL_EFFECT"},
+ ["33"] = {"POISON_EFFECT"},
+ ["34"] = {"PAY_DAY_EFFECT"},
+ ["35"] = {"LIGHT_SCREEN_EFFECT"},
+ ["36"] = {"TRI_ATTACK_EFFECT"},
+ ["37"] = {"HEAL_EFFECT"},
+ ["38"] = {"OHKO_EFFECT"},
+ ["39"] = {"CHARGE_EFFECT"},
+ ["4"] = {"BURN_SIDE_EFFECT1"},
+ ["40"] = {"SUPER_FANG_EFFECT"},
+ ["41"] = {"SPECIAL_DAMAGE_EFFECT"},
+ ["42"] = {"TRAPPING_EFFECT"},
+ ["43"] = {"HIGH_CRITICAL_EFFECT"},
+ ["44"] = {"ATTACK_TWICE_EFFECT"},
+ ["45"] = {"JUMP_KICK_EFFECT"},
+ ["46"] = {"MIST_EFFECT"},
+ ["47"] = {"FOCUS_ENERGY_EFFECT"},
+ ["48"] = {"RECOIL_EFFECT"},
+ ["49"] = {"CONFUSION_EFFECT"},
+ ["5"] = {"FREEZE_SIDE_EFFECT1"},
+ ["50"] = {"ATTACK_UP2_EFFECT"},
+ ["51"] = {"DEFENSE_UP2_EFFECT"},
+ ["52"] = {"SPEED_UP2_EFFECT"},
+ ["53"] = {"SP_ATK_UP2_EFFECT"},
+ ["54"] = {"SP_DEF_UP2_EFFECT"},
+ ["57"] = {"TRANSFORM_EFFECT"},
+ ["58"] = {"ATTACK_DOWN2_EFFECT"},
+ ["59"] = {"DEFENSE_DOWN2_EFFECT"},
+ ["6"] = {"PARALYZE_SIDE_EFFECT1","PARALYZE_SIDE_EFFECT2"},
+ ["60"] = {"SPEED_DOWN2_EFFECT"},
+ ["62"] = {"SP_DEF_DOWN2_EFFECT"},
+ ["65"] = {"REFLECT_EFFECT"},
+ ["66"] = {"POISON_EFFECT"},
+ ["67"] = {"PARALYZE_EFFECT"},
+ ["68"] = {"ATTACK_DOWN_SIDE_EFFECT"},
+ ["69"] = {"DEFENSE_DOWN_SIDE_EFFECT"},
+ ["7"] = {"EXPLODE_EFFECT"},
+ ["70"] = {"SPEED_DOWN_SIDE_EFFECT"},
+ ["71"] = {"SP_ATK_DOWN_HIT_EFFECT"},
+ ["72"] = {"SP_DEF_DOWN_SIDE_EFFECT"},
+ ["73"] = {"ACCURACY_DOWN_SIDE_EFFECT"},
+ ["75"] = {"CHARGE_EFFECT"},
+ ["76"] = {"CONFUSION_SIDE_EFFECT"},
+ ["77"] = {"TWINEEDLE_EFFECT"},
+ ["78"] = {"SWIFT_EFFECT"},
+ ["79"] = {"SUBSTITUTE_EFFECT"},
+ ["8"] = {"DREAM_EATER_EFFECT"},
+ ["80"] = {"HYPER_BEAM_EFFECT"},
+ ["81"] = {"RAGE_EFFECT"},
+ ["82"] = {"MIMIC_EFFECT"},
+ ["83"] = {"METRONOME_EFFECT"},
+ ["84"] = {"LEECH_SEED_EFFECT"},
+ ["85"] = {"SPLASH_EFFECT"},
+ ["86"] = {"DISABLE_EFFECT"},
+ ["87"] = {"SPECIAL_DAMAGE_EFFECT"},
+ ["88"] = {"SPECIAL_DAMAGE_EFFECT"},
+ ["89"] = {"COUNTER_EFFECT"},
+ ["9"] = {"MIRROR_MOVE_EFFECT"},
+ ["90"] = {"ENCORE_EFFECT"},
+ ["91"] = {"PAIN_SPLIT_EFFECT"},
+ ["92"] = {"SNORE_EFFECT"},
+ ["93"] = {"CONVERSION2_EFFECT"},
+ ["94"] = {"LOCK_ON_EFFECT"},
+ ["95"] = {"SKETCH_EFFECT"},
+ ["97"] = {"SLEEP_TALK_EFFECT"},
+ ["98"] = {"DESTINY_BOND_EFFECT"},
+ ["99"] = {"REVERSAL_EFFECT"},
+ },
+ ["read3"] = {
+ ["0"] = "NO_ADDITIONAL_EFFECT",
+ ["1"] = "SLEEP_EFFECT",
+ ["10"] = "ATTACK_UP1_EFFECT",
+ ["100"] = "EFFECT_SPITE",
+ ["101"] = "EFFECT_FALSE_SWIPE",
+ ["102"] = "HEAL_BELL",
+ ["103"] = "EFFECT_PRIORITY_HIT",
+ ["104"] = "EFFECT_TRIPLE_KICK",
+ ["105"] = "EFFECT_THIEF",
+ ["106"] = "EFFECT_MEAN_LOOK",
+ ["107"] = "EFFECT_NIGHTMARE",
+ ["108"] = "MINIMIZE",
+ ["109"] = "EFFECT_CURSE",
+ ["11"] = "DEFENSE_UP1_EFFECT",
+ ["110"] = "native:3:110",
+ ["111"] = "EFFECT_PROTECT",
+ ["112"] = "EFFECT_SPIKES",
+ ["113"] = "EFFECT_FORESIGHT",
+ ["114"] = "EFFECT_PERISH_SONG",
+ ["115"] = "EFFECT_SANDSTORM",
+ ["116"] = "EFFECT_ENDURE",
+ ["117"] = "EFFECT_ROLLOUT",
+ ["118"] = "SWAGGER",
+ ["119"] = "EFFECT_FURY_CUTTER",
+ ["12"] = "native:3:SPEED_UP",
+ ["120"] = "EFFECT_ATTRACT",
+ ["121"] = "EFFECT_RETURN",
+ ["122"] = "PRESENT",
+ ["123"] = "EFFECT_FRUSTRATION",
+ ["124"] = "EFFECT_SAFEGUARD",
+ ["125"] = "EFFECT_SACRED_FIRE",
+ ["126"] = "EFFECT_MAGNITUDE",
+ ["127"] = "EFFECT_BATON_PASS",
+ ["128"] = "PURSUIT",
+ ["129"] = "RAPID_SPIN",
+ ["13"] = "SPECIAL_UP1_EFFECT",
+ ["130"] = "native:3:SONICBOOM",
+ ["131"] = "native:3:131",
+ ["132"] = "EFFECT_MORNING_SUN",
+ ["133"] = "EFFECT_SYNTHESIS",
+ ["134"] = "EFFECT_MOONLIGHT",
+ ["135"] = "native:3:HIDDEN_POWER",
+ ["136"] = "EFFECT_RAIN_DANCE",
+ ["137"] = "EFFECT_SUNNY_DAY",
+ ["138"] = "EFFECT_DEFENSE_UP_HIT",
+ ["139"] = "EFFECT_ATTACK_UP_HIT",
+ ["14"] = "native:3:SPECIAL_DEFENSE_UP",
+ ["140"] = "EFFECT_ALL_UP_HIT",
+ ["141"] = "native:3:141",
+ ["142"] = "EFFECT_BELLY_DRUM",
+ ["143"] = "EFFECT_PSYCH_UP",
+ ["144"] = "EFFECT_MIRROR_COAT",
+ ["145"] = "native:3:SKULL_BASH",
+ ["146"] = "TWISTER",
+ ["147"] = "EARTHQUAKE",
+ ["148"] = "EFFECT_FUTURE_SIGHT",
+ ["149"] = "GUST",
+ ["15"] = "native:3:ACCURACY_UP",
+ ["150"] = "FLINCH_MINIMIZE_HIT",
+ ["151"] = "native:3:SOLAR_BEAM",
+ ["152"] = "THUNDER",
+ ["153"] = "native:3:TELEPORT",
+ ["154"] = "EFFECT_BEAT_UP",
+ ["155"] = "FLY_EFFECT",
+ ["156"] = "EFFECT_DEFENSE_CURL",
+ ["157"] = "SOFTBOILED",
+ ["158"] = "FAKE_OUT",
+ ["159"] = "UPROAR",
+ ["16"] = "EVASION_UP1_EFFECT",
+ ["160"] = "STOCKPILE",
+ ["161"] = "SPIT_UP",
+ ["162"] = "SWALLOW",
+ ["163"] = "native:3:163",
+ ["164"] = "HAIL",
+ ["165"] = "TORMENT",
+ ["166"] = "FLATTER",
+ ["167"] = "EFFECT_BURN",
+ ["168"] = "MEMENTO",
+ ["169"] = "FACADE",
+ ["17"] = "SWIFT_EFFECT",
+ ["170"] = "FOCUS_PUNCH",
+ ["171"] = "SMELLINGSALT",
+ ["172"] = "FOLLOW_ME",
+ ["173"] = "NATURE_POWER",
+ ["174"] = "CHARGE",
+ ["175"] = "TAUNT",
+ ["176"] = "HELPING_HAND",
+ ["177"] = "TRICK",
+ ["178"] = "ROLE_PLAY",
+ ["179"] = "WISH",
+ ["18"] = "ATTACK_DOWN1_EFFECT",
+ ["180"] = "ASSIST",
+ ["181"] = "INGRAIN",
+ ["182"] = "SUPERPOWER",
+ ["183"] = "MAGIC_COAT",
+ ["184"] = "RECYCLE",
+ ["185"] = "REVENGE",
+ ["186"] = "BRICK_BREAK",
+ ["187"] = "YAWN",
+ ["188"] = "KNOCK_OFF",
+ ["189"] = "ENDEAVOR",
+ ["19"] = "DEFENSE_DOWN1_EFFECT",
+ ["190"] = "ERUPTION",
+ ["191"] = "SKILL_SWAP",
+ ["192"] = "IMPRISON",
+ ["193"] = "REFRESH",
+ ["194"] = "GRUDGE",
+ ["195"] = "SNATCH",
+ ["196"] = "LOW_KICK",
+ ["197"] = "SECRET_POWER",
+ ["198"] = "DOUBLE_EDGE",
+ ["199"] = "TEETER_DANCE",
+ ["2"] = "POISON_SIDE_EFFECT1",
+ ["20"] = "SPEED_DOWN1_EFFECT",
+ ["200"] = "BLAZE_KICK",
+ ["201"] = "MUD_SPORT",
+ ["202"] = "POISON_FANG",
+ ["203"] = "WEATHER_BALL",
+ ["204"] = "OVERHEAT",
+ ["205"] = "TICKLE",
+ ["206"] = "COSMIC_POWER",
+ ["207"] = "SKY_UPPERCUT",
+ ["208"] = "BULK_UP",
+ ["209"] = "POISON_TAIL",
+ ["21"] = "native:3:SPECIAL_ATTACK_DOWN",
+ ["210"] = "WATER_SPORT",
+ ["211"] = "CALM_MIND",
+ ["212"] = "DRAGON_DANCE",
+ ["213"] = "CAMOUFLAGE",
+ ["214"] = "native:3:214",
+ ["215"] = "native:3:215",
+ ["216"] = "native:3:216",
+ ["217"] = "native:3:217",
+ ["218"] = "native:3:218",
+ ["219"] = "native:3:219",
+ ["22"] = "native:3:SPECIAL_DEFENSE_DOWN",
+ ["220"] = "native:3:220",
+ ["221"] = "native:3:221",
+ ["222"] = "native:3:222",
+ ["223"] = "native:3:223",
+ ["224"] = "native:3:224",
+ ["225"] = "native:3:225",
+ ["226"] = "native:3:226",
+ ["227"] = "native:3:227",
+ ["228"] = "native:3:228",
+ ["229"] = "native:3:229",
+ ["23"] = "ACCURACY_DOWN1_EFFECT",
+ ["230"] = "native:3:230",
+ ["231"] = "native:3:231",
+ ["232"] = "native:3:232",
+ ["233"] = "native:3:233",
+ ["234"] = "native:3:234",
+ ["235"] = "native:3:235",
+ ["236"] = "native:3:236",
+ ["237"] = "native:3:237",
+ ["238"] = "native:3:238",
+ ["239"] = "native:3:239",
+ ["24"] = "EVASION_DOWN1_EFFECT",
+ ["240"] = "native:3:240",
+ ["241"] = "native:3:241",
+ ["242"] = "native:3:242",
+ ["243"] = "native:3:243",
+ ["244"] = "native:3:244",
+ ["245"] = "native:3:245",
+ ["246"] = "native:3:246",
+ ["247"] = "native:3:247",
+ ["248"] = "native:3:248",
+ ["249"] = "native:3:249",
+ ["25"] = "HAZE_EFFECT",
+ ["250"] = "native:3:250",
+ ["251"] = "native:3:251",
+ ["252"] = "native:3:252",
+ ["253"] = "native:3:253",
+ ["254"] = "native:3:254",
+ ["255"] = "native:3:255",
+ ["26"] = "BIDE_EFFECT",
+ ["27"] = "THRASH_PETAL_DANCE_EFFECT",
+ ["28"] = "SWITCH_AND_TELEPORT_EFFECT",
+ ["29"] = "TWO_TO_FIVE_ATTACKS_EFFECT",
+ ["3"] = "DRAIN_HP_EFFECT",
+ ["30"] = "CONVERSION_EFFECT",
+ ["31"] = "FLINCH_SIDE_EFFECT1",
+ ["32"] = "HEAL_EFFECT",
+ ["33"] = "native:3:TOXIC",
+ ["34"] = "PAY_DAY_EFFECT",
+ ["35"] = "LIGHT_SCREEN_EFFECT",
+ ["36"] = "TRI_ATTACK",
+ ["37"] = "native:3:REST",
+ ["38"] = "OHKO_EFFECT",
+ ["39"] = "CHARGE_EFFECT",
+ ["4"] = "BURN_SIDE_EFFECT1",
+ ["40"] = "SUPER_FANG_EFFECT",
+ ["41"] = "native:3:DRAGON_RAGE",
+ ["42"] = "TRAPPING_EFFECT",
+ ["43"] = "native:3:HIGH_CRITICAL",
+ ["44"] = "ATTACK_TWICE_EFFECT",
+ ["45"] = "JUMP_KICK_EFFECT",
+ ["46"] = "MIST_EFFECT",
+ ["47"] = "FOCUS_ENERGY_EFFECT",
+ ["48"] = "RECOIL_EFFECT",
+ ["49"] = "CONFUSION_EFFECT",
+ ["5"] = "FREEZE_SIDE_EFFECT1",
+ ["50"] = "ATTACK_UP2_EFFECT",
+ ["51"] = "DEFENSE_UP2_EFFECT",
+ ["52"] = "SPEED_UP2_EFFECT",
+ ["53"] = "SPECIAL_ATTACK_UP_2",
+ ["54"] = "SPECIAL_UP2_EFFECT",
+ ["55"] = "native:3:ACCURACY_UP_2",
+ ["56"] = "native:3:EVASION_UP_2",
+ ["57"] = "TRANSFORM_EFFECT",
+ ["58"] = "ATTACK_DOWN2_EFFECT",
+ ["59"] = "DEFENSE_DOWN2_EFFECT",
+ ["6"] = "PARALYZE_SIDE_EFFECT1",
+ ["60"] = "SPEED_DOWN2_EFFECT",
+ ["61"] = "native:3:SPECIAL_ATTACK_DOWN_2",
+ ["62"] = "SPECIAL_DOWN2_EFFECT",
+ ["63"] = "native:3:ACCURACY_DOWN_2",
+ ["64"] = "native:3:EVASION_DOWN_2",
+ ["65"] = "REFLECT_EFFECT",
+ ["66"] = "POISON_EFFECT",
+ ["67"] = "PARALYZE_EFFECT",
+ ["68"] = "ATTACK_DOWN_SIDE_EFFECT",
+ ["69"] = "DEFENSE_DOWN_SIDE_EFFECT",
+ ["7"] = "EXPLODE_EFFECT",
+ ["70"] = "SPEED_DOWN_SIDE_EFFECT",
+ ["71"] = "SPECIAL_ATTACK_DOWN_HIT",
+ ["72"] = "SPECIAL_DOWN_SIDE_EFFECT",
+ ["73"] = "EFFECT_ACCURACY_DOWN_HIT",
+ ["74"] = "native:3:EVASION_DOWN_HIT",
+ ["75"] = "native:3:SKY_ATTACK",
+ ["76"] = "CONFUSION_SIDE_EFFECT",
+ ["77"] = "TWINEEDLE_EFFECT",
+ ["78"] = "VITAL_THROW",
+ ["79"] = "SUBSTITUTE_EFFECT",
+ ["8"] = "DREAM_EATER_EFFECT",
+ ["80"] = "HYPER_BEAM_EFFECT",
+ ["81"] = "RAGE_EFFECT",
+ ["82"] = "MIMIC_EFFECT",
+ ["83"] = "METRONOME_EFFECT",
+ ["84"] = "LEECH_SEED_EFFECT",
+ ["85"] = "SPLASH_EFFECT",
+ ["86"] = "DISABLE_EFFECT",
+ ["87"] = "SPECIAL_DAMAGE_EFFECT",
+ ["88"] = "native:3:PSYWAVE",
+ ["89"] = "native:3:COUNTER",
+ ["9"] = "MIRROR_MOVE_EFFECT",
+ ["90"] = "EFFECT_ENCORE",
+ ["91"] = "PAIN_SPLIT",
+ ["92"] = "EFFECT_SNORE",
+ ["93"] = "CONVERSION_2",
+ ["94"] = "EFFECT_LOCK_ON",
+ ["95"] = "EFFECT_SKETCH",
+ ["96"] = "native:3:96",
+ ["97"] = "EFFECT_SLEEP_TALK",
+ ["98"] = "DESTINY_BOND",
+ ["99"] = "FLAIL",
+ },
+ ["reference"] = {
+ ["commit"] = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36",
+ ["sha256"] = {["src/battle/g3u/EffectMap1.lua"]="15a66e3c011128a94f5d84689581c78e1f7f737da7c17d60d46b8cdf3ad22cfd",["src/battle/g3u/EffectMap2.lua"]="009ed83f5a76638050110b4304e66bc514b61ded28bbf4e4b75e5d1f448cb708",["src/core/game3/battle/effect_ids.lua"]="00ce6a4196c2b05d56c8b575ec3eb20d8e32d16a99646716f18f08624795f496",["src/mods/UnifiedEffects.lua"]="bde10a80bd8c64a71a279b8a143757a8f26f30848c095ce49b6a085690181676"},
+ },
+ ["reserved"] = {
+ ["ABSORB"] = true,
+ ["ACCURACY_DOWN"] = true,
+ ["ACCURACY_DOWN1_EFFECT"] = true,
+ ["ACCURACY_DOWN2_EFFECT"] = true,
+ ["ACCURACY_DOWN_2"] = true,
+ ["ACCURACY_DOWN_HIT"] = true,
+ ["ACCURACY_UP"] = true,
+ ["ACCURACY_UP1_EFFECT"] = true,
+ ["ACCURACY_UP2_EFFECT"] = true,
+ ["ACCURACY_UP_2"] = true,
+ ["AFTER_HIT"] = true,
+ ["ALL_STATS_UP_HIT"] = true,
+ ["ALWAYS_HIT"] = true,
+ ["ASSIST"] = true,
+ ["ATTACK_DOWN"] = true,
+ ["ATTACK_DOWN1_EFFECT"] = true,
+ ["ATTACK_DOWN2_EFFECT"] = true,
+ ["ATTACK_DOWN_2"] = true,
+ ["ATTACK_DOWN_HIT"] = true,
+ ["ATTACK_DOWN_SIDE_EFFECT"] = true,
+ ["ATTACK_TWICE_EFFECT"] = true,
+ ["ATTACK_UP"] = true,
+ ["ATTACK_UP1_EFFECT"] = true,
+ ["ATTACK_UP2_EFFECT"] = true,
+ ["ATTACK_UP_2"] = true,
+ ["ATTACK_UP_HIT"] = true,
+ ["ATTRACT"] = true,
+ ["BATON_PASS"] = true,
+ ["BEAT_UP"] = true,
+ ["BELLY_DRUM"] = true,
+ ["BIDE"] = true,
+ ["BIDE_EFFECT"] = true,
+ ["BLAZE_KICK"] = true,
+ ["BRICK_BREAK"] = true,
+ ["BULK_UP"] = true,
+ ["BURN_HIT"] = true,
+ ["BURN_SIDE_EFFECT1"] = true,
+ ["BURN_SIDE_EFFECT2"] = true,
+ ["CALM_MIND"] = true,
+ ["CAMOUFLAGE"] = true,
+ ["CHARGE"] = true,
+ ["CHARGE_EFFECT"] = true,
+ ["CONFUSE"] = true,
+ ["CONFUSE_HIT"] = true,
+ ["CONFUSION_EFFECT"] = true,
+ ["CONFUSION_SIDE_EFFECT"] = true,
+ ["CONVERSION"] = true,
+ ["CONVERSION_2"] = true,
+ ["CONVERSION_EFFECT"] = true,
+ ["COSMIC_POWER"] = true,
+ ["COUNTER"] = true,
+ ["CURSE"] = true,
+ ["DEFENSE_CURL"] = true,
+ ["DEFENSE_DOWN"] = true,
+ ["DEFENSE_DOWN1_EFFECT"] = true,
+ ["DEFENSE_DOWN2_EFFECT"] = true,
+ ["DEFENSE_DOWN_2"] = true,
+ ["DEFENSE_DOWN_HIT"] = true,
+ ["DEFENSE_DOWN_SIDE_EFFECT"] = true,
+ ["DEFENSE_UP"] = true,
+ ["DEFENSE_UP1_EFFECT"] = true,
+ ["DEFENSE_UP2_EFFECT"] = true,
+ ["DEFENSE_UP_2"] = true,
+ ["DEFENSE_UP_HIT"] = true,
+ ["DESTINY_BOND"] = true,
+ ["DISABLE"] = true,
+ ["DISABLE_EFFECT"] = true,
+ ["DOUBLE_EDGE"] = true,
+ ["DOUBLE_HIT"] = true,
+ ["DRAGON_DANCE"] = true,
+ ["DRAGON_RAGE"] = true,
+ ["DRAIN_HP_EFFECT"] = true,
+ ["DREAM_EATER"] = true,
+ ["DREAM_EATER_EFFECT"] = true,
+ ["EARTHQUAKE"] = true,
+ ["EFFECT_01"] = true,
+ ["EFFECT_1E"] = true,
+ ["EFFECT_ACCURACY_DOWN"] = true,
+ ["EFFECT_ACCURACY_DOWN_2"] = true,
+ ["EFFECT_ACCURACY_DOWN_HIT"] = true,
+ ["EFFECT_ACCURACY_UP"] = true,
+ ["EFFECT_ACCURACY_UP_2"] = true,
+ ["EFFECT_ALL_UP_HIT"] = true,
+ ["EFFECT_ALWAYS_HIT"] = true,
+ ["EFFECT_ATTACK_DOWN"] = true,
+ ["EFFECT_ATTACK_DOWN_2"] = true,
+ ["EFFECT_ATTACK_DOWN_HIT"] = true,
+ ["EFFECT_ATTACK_UP"] = true,
+ ["EFFECT_ATTACK_UP_2"] = true,
+ ["EFFECT_ATTACK_UP_HIT"] = true,
+ ["EFFECT_ATTRACT"] = true,
+ ["EFFECT_BATON_PASS"] = true,
+ ["EFFECT_BEAT_UP"] = true,
+ ["EFFECT_BELLY_DRUM"] = true,
+ ["EFFECT_BIDE"] = true,
+ ["EFFECT_BURN_HIT"] = true,
+ ["EFFECT_CONFUSE"] = true,
+ ["EFFECT_CONFUSE_HIT"] = true,
+ ["EFFECT_CONVERSION"] = true,
+ ["EFFECT_CONVERSION2"] = true,
+ ["EFFECT_COUNTER"] = true,
+ ["EFFECT_CURSE"] = true,
+ ["EFFECT_DEFENSE_CURL"] = true,
+ ["EFFECT_DEFENSE_DOWN"] = true,
+ ["EFFECT_DEFENSE_DOWN_2"] = true,
+ ["EFFECT_DEFENSE_DOWN_HIT"] = true,
+ ["EFFECT_DEFENSE_UP"] = true,
+ ["EFFECT_DEFENSE_UP_2"] = true,
+ ["EFFECT_DEFENSE_UP_HIT"] = true,
+ ["EFFECT_DEFROST_OPPONENT"] = true,
+ ["EFFECT_DESTINY_BOND"] = true,
+ ["EFFECT_DISABLE"] = true,
+ ["EFFECT_DOUBLE_HIT"] = true,
+ ["EFFECT_DREAM_EATER"] = true,
+ ["EFFECT_EARTHQUAKE"] = true,
+ ["EFFECT_ENCORE"] = true,
+ ["EFFECT_ENDURE"] = true,
+ ["EFFECT_EVASION_DOWN"] = true,
+ ["EFFECT_EVASION_DOWN_2"] = true,
+ ["EFFECT_EVASION_DOWN_HIT"] = true,
+ ["EFFECT_EVASION_UP"] = true,
+ ["EFFECT_EVASION_UP_2"] = true,
+ ["EFFECT_FAKE_OUT"] = true,
+ ["EFFECT_FALSE_SWIPE"] = true,
+ ["EFFECT_FLAME_WHEEL"] = true,
+ ["EFFECT_FLINCH_HIT"] = true,
+ ["EFFECT_FLY"] = true,
+ ["EFFECT_FOCUS_ENERGY"] = true,
+ ["EFFECT_FORCE_SWITCH"] = true,
+ ["EFFECT_FORESIGHT"] = true,
+ ["EFFECT_FREEZE_HIT"] = true,
+ ["EFFECT_FRUSTRATION"] = true,
+ ["EFFECT_FURY_CUTTER"] = true,
+ ["EFFECT_FUTURE_SIGHT"] = true,
+ ["EFFECT_GUST"] = true,
+ ["EFFECT_HEAL"] = true,
+ ["EFFECT_HEAL_BELL"] = true,
+ ["EFFECT_HIDDEN_POWER"] = true,
+ ["EFFECT_HYPER_BEAM"] = true,
+ ["EFFECT_JUMP_KICK"] = true,
+ ["EFFECT_LEECH_HIT"] = true,
+ ["EFFECT_LEECH_SEED"] = true,
+ ["EFFECT_LEVEL_DAMAGE"] = true,
+ ["EFFECT_LIGHT_SCREEN"] = true,
+ ["EFFECT_LOCK_ON"] = true,
+ ["EFFECT_MAGNITUDE"] = true,
+ ["EFFECT_MEAN_LOOK"] = true,
+ ["EFFECT_METRONOME"] = true,
+ ["EFFECT_MIMIC"] = true,
+ ["EFFECT_MIRROR_COAT"] = true,
+ ["EFFECT_MIRROR_MOVE"] = true,
+ ["EFFECT_MIST"] = true,
+ ["EFFECT_MOONLIGHT"] = true,
+ ["EFFECT_MORNING_SUN"] = true,
+ ["EFFECT_MULTI_HIT"] = true,
+ ["EFFECT_NIGHTMARE"] = true,
+ ["EFFECT_NORMAL_HIT"] = true,
+ ["EFFECT_OHKO"] = true,
+ ["EFFECT_PAIN_SPLIT"] = true,
+ ["EFFECT_PARALYZE"] = true,
+ ["EFFECT_PARALYZE_HIT"] = true,
+ ["EFFECT_PAY_DAY"] = true,
+ ["EFFECT_PERISH_SONG"] = true,
+ ["EFFECT_POISON"] = true,
+ ["EFFECT_POISON_HIT"] = true,
+ ["EFFECT_POISON_MULTI_HIT"] = true,
+ ["EFFECT_PRESENT"] = true,
+ ["EFFECT_PRIORITY_HIT"] = true,
+ ["EFFECT_PROTECT"] = true,
+ ["EFFECT_PSYCH_UP"] = true,
+ ["EFFECT_PSYWAVE"] = true,
+ ["EFFECT_PURSUIT"] = true,
+ ["EFFECT_RAGE"] = true,
+ ["EFFECT_RAIN_DANCE"] = true,
+ ["EFFECT_RAMPAGE"] = true,
+ ["EFFECT_RAPID_SPIN"] = true,
+ ["EFFECT_RAZOR_WIND"] = true,
+ ["EFFECT_RECOIL_HIT"] = true,
+ ["EFFECT_REFLECT"] = true,
+ ["EFFECT_RESET_STATS"] = true,
+ ["EFFECT_RETURN"] = true,
+ ["EFFECT_REVERSAL"] = true,
+ ["EFFECT_ROLLOUT"] = true,
+ ["EFFECT_SACRED_FIRE"] = true,
+ ["EFFECT_SAFEGUARD"] = true,
+ ["EFFECT_SANDSTORM"] = true,
+ ["EFFECT_SELFDESTRUCT"] = true,
+ ["EFFECT_SKETCH"] = true,
+ ["EFFECT_SKULL_BASH"] = true,
+ ["EFFECT_SKY_ATTACK"] = true,
+ ["EFFECT_SLEEP"] = true,
+ ["EFFECT_SLEEP_TALK"] = true,
+ ["EFFECT_SNORE"] = true,
+ ["EFFECT_SOLARBEAM"] = true,
+ ["EFFECT_SPEED_DOWN"] = true,
+ ["EFFECT_SPEED_DOWN_2"] = true,
+ ["EFFECT_SPEED_DOWN_HIT"] = true,
+ ["EFFECT_SPEED_UP"] = true,
+ ["EFFECT_SPEED_UP_2"] = true,
+ ["EFFECT_SPIKES"] = true,
+ ["EFFECT_SPITE"] = true,
+ ["EFFECT_SPLASH"] = true,
+ ["EFFECT_SP_ATK_DOWN"] = true,
+ ["EFFECT_SP_ATK_DOWN_2"] = true,
+ ["EFFECT_SP_ATK_DOWN_HIT"] = true,
+ ["EFFECT_SP_ATK_UP"] = true,
+ ["EFFECT_SP_ATK_UP_2"] = true,
+ ["EFFECT_SP_DEF_DOWN"] = true,
+ ["EFFECT_SP_DEF_DOWN_2"] = true,
+ ["EFFECT_SP_DEF_DOWN_HIT"] = true,
+ ["EFFECT_SP_DEF_UP"] = true,
+ ["EFFECT_SP_DEF_UP_2"] = true,
+ ["EFFECT_STATIC_DAMAGE"] = true,
+ ["EFFECT_STOMP"] = true,
+ ["EFFECT_SUBSTITUTE"] = true,
+ ["EFFECT_SUNNY_DAY"] = true,
+ ["EFFECT_SUPER_FANG"] = true,
+ ["EFFECT_SWAGGER"] = true,
+ ["EFFECT_SYNTHESIS"] = true,
+ ["EFFECT_TELEPORT"] = true,
+ ["EFFECT_THIEF"] = true,
+ ["EFFECT_THUNDER"] = true,
+ ["EFFECT_TOXIC"] = true,
+ ["EFFECT_TRANSFORM"] = true,
+ ["EFFECT_TRAP_TARGET"] = true,
+ ["EFFECT_TRIPLE_KICK"] = true,
+ ["EFFECT_TRI_ATTACK"] = true,
+ ["EFFECT_TWISTER"] = true,
+ ["EFFECT_UNUSED_25"] = true,
+ ["EFFECT_UNUSED_2B"] = true,
+ ["EFFECT_UNUSED_4E"] = true,
+ ["EFFECT_UNUSED_6E"] = true,
+ ["EFFECT_UNUSED_82"] = true,
+ ["EFFECT_UNUSED_83"] = true,
+ ["ENCORE"] = true,
+ ["ENDEAVOR"] = true,
+ ["ENDURE"] = true,
+ ["ERUPTION"] = true,
+ ["EVASION_DOWN"] = true,
+ ["EVASION_DOWN1_EFFECT"] = true,
+ ["EVASION_DOWN2_EFFECT"] = true,
+ ["EVASION_DOWN_2"] = true,
+ ["EVASION_DOWN_HIT"] = true,
+ ["EVASION_UP"] = true,
+ ["EVASION_UP1_EFFECT"] = true,
+ ["EVASION_UP2_EFFECT"] = true,
+ ["EVASION_UP_2"] = true,
+ ["EXPLODE_EFFECT"] = true,
+ ["EXPLOSION"] = true,
+ ["FACADE"] = true,
+ ["FAKE_OUT"] = true,
+ ["FALSE_SWIPE"] = true,
+ ["FLAIL"] = true,
+ ["FLATTER"] = true,
+ ["FLINCH_HIT"] = true,
+ ["FLINCH_MINIMIZE_HIT"] = true,
+ ["FLINCH_SIDE_EFFECT1"] = true,
+ ["FLINCH_SIDE_EFFECT2"] = true,
+ ["FLY"] = true,
+ ["FLY_EFFECT"] = true,
+ ["FOCUS_ENERGY"] = true,
+ ["FOCUS_ENERGY_EFFECT"] = true,
+ ["FOCUS_PUNCH"] = true,
+ ["FOLLOW_ME"] = true,
+ ["FORESIGHT"] = true,
+ ["FREEZE_HIT"] = true,
+ ["FREEZE_SIDE_EFFECT1"] = true,
+ ["FREEZE_SIDE_EFFECT2"] = true,
+ ["FRUSTRATION"] = true,
+ ["FURY_CUTTER"] = true,
+ ["FUTURE_SIGHT"] = true,
+ ["GRUDGE"] = true,
+ ["GUST"] = true,
+ ["HAIL"] = true,
+ ["HAZE"] = true,
+ ["HAZE_EFFECT"] = true,
+ ["HEAL_BELL"] = true,
+ ["HEAL_EFFECT"] = true,
+ ["HELPING_HAND"] = true,
+ ["HIDDEN_POWER"] = true,
+ ["HIGH_CRITICAL"] = true,
+ ["HIT"] = true,
+ ["HYPER_BEAM_EFFECT"] = true,
+ ["IMPRISON"] = true,
+ ["INGRAIN"] = true,
+ ["JUMP_KICK_EFFECT"] = true,
+ ["KNOCK_OFF"] = true,
+ ["LEECH_SEED"] = true,
+ ["LEECH_SEED_EFFECT"] = true,
+ ["LEVEL_DAMAGE"] = true,
+ ["LIGHT_SCREEN"] = true,
+ ["LIGHT_SCREEN_EFFECT"] = true,
+ ["LOCK_ON"] = true,
+ ["LOW_KICK"] = true,
+ ["MAGIC_COAT"] = true,
+ ["MAGNITUDE"] = true,
+ ["MEAN_LOOK"] = true,
+ ["MEMENTO"] = true,
+ ["METRONOME"] = true,
+ ["METRONOME_EFFECT"] = true,
+ ["MIMIC"] = true,
+ ["MIMIC_EFFECT"] = true,
+ ["MINIMIZE"] = true,
+ ["MIRROR_COAT"] = true,
+ ["MIRROR_MOVE"] = true,
+ ["MIRROR_MOVE_EFFECT"] = true,
+ ["MIST"] = true,
+ ["MIST_EFFECT"] = true,
+ ["MOONLIGHT"] = true,
+ ["MORNING_SUN"] = true,
+ ["MUD_SPORT"] = true,
+ ["MULTI_HIT"] = true,
+ ["NATURE_POWER"] = true,
+ ["NIGHTMARE"] = true,
+ ["NO_ADDITIONAL_EFFECT"] = true,
+ ["OHKO"] = true,
+ ["OHKO_EFFECT"] = true,
+ ["OVERHEAT"] = true,
+ ["PAIN_SPLIT"] = true,
+ ["PARALYZE"] = true,
+ ["PARALYZE_EFFECT"] = true,
+ ["PARALYZE_HIT"] = true,
+ ["PARALYZE_SIDE_EFFECT1"] = true,
+ ["PARALYZE_SIDE_EFFECT2"] = true,
+ ["PAY_DAY"] = true,
+ ["PAY_DAY_EFFECT"] = true,
+ ["PERISH_SONG"] = true,
+ ["POISON"] = true,
+ ["POISON_EFFECT"] = true,
+ ["POISON_FANG"] = true,
+ ["POISON_HIT"] = true,
+ ["POISON_SIDE_EFFECT1"] = true,
+ ["POISON_SIDE_EFFECT2"] = true,
+ ["POISON_TAIL"] = true,
+ ["PRESENT"] = true,
+ ["PROTECT"] = true,
+ ["PSYCH_UP"] = true,
+ ["PSYWAVE"] = true,
+ ["PURSUIT"] = true,
+ ["QUICK_ATTACK"] = true,
+ ["RAGE"] = true,
+ ["RAGE_EFFECT"] = true,
+ ["RAIN_DANCE"] = true,
+ ["RAMPAGE"] = true,
+ ["RAPID_SPIN"] = true,
+ ["RAZOR_WIND"] = true,
+ ["RECHARGE"] = true,
+ ["RECOIL"] = true,
+ ["RECOIL_25"] = true,
+ ["RECOIL_EFFECT"] = true,
+ ["RECOIL_IF_MISS"] = true,
+ ["RECOVER"] = true,
+ ["RECYCLE"] = true,
+ ["REFLECT"] = true,
+ ["REFLECT_EFFECT"] = true,
+ ["REFRESH"] = true,
+ ["REST"] = true,
+ ["RESTORE_HP"] = true,
+ ["RETURN"] = true,
+ ["REVENGE"] = true,
+ ["ROAR"] = true,
+ ["ROLE_PLAY"] = true,
+ ["ROLLOUT"] = true,
+ ["SAFEGUARD"] = true,
+ ["SANDSTORM"] = true,
+ ["SECONDARY"] = true,
+ ["SECRET_POWER"] = true,
+ ["SECRET_POWER_BY_TERRAIN"] = true,
+ ["SEMI_INVULNERABLE"] = true,
+ ["SKETCH"] = true,
+ ["SKILL_SWAP"] = true,
+ ["SKULL_BASH"] = true,
+ ["SKY_ATTACK"] = true,
+ ["SKY_UPPERCUT"] = true,
+ ["SLEEP"] = true,
+ ["SLEEP_EFFECT"] = true,
+ ["SLEEP_TALK"] = true,
+ ["SMELLINGSALT"] = true,
+ ["SNATCH"] = true,
+ ["SNORE"] = true,
+ ["SOFTBOILED"] = true,
+ ["SOLAR_BEAM"] = true,
+ ["SONICBOOM"] = true,
+ ["SPECIAL_ATTACK_DOWN"] = true,
+ ["SPECIAL_ATTACK_DOWN_2"] = true,
+ ["SPECIAL_ATTACK_DOWN_HIT"] = true,
+ ["SPECIAL_ATTACK_UP"] = true,
+ ["SPECIAL_ATTACK_UP_2"] = true,
+ ["SPECIAL_DAMAGE_EFFECT"] = true,
+ ["SPECIAL_DEFENSE_DOWN"] = true,
+ ["SPECIAL_DEFENSE_DOWN_2"] = true,
+ ["SPECIAL_DEFENSE_DOWN_HIT"] = true,
+ ["SPECIAL_DEFENSE_UP"] = true,
+ ["SPECIAL_DEFENSE_UP_2"] = true,
+ ["SPECIAL_DOWN1_EFFECT"] = true,
+ ["SPECIAL_DOWN2_EFFECT"] = true,
+ ["SPECIAL_DOWN_SIDE_EFFECT"] = true,
+ ["SPECIAL_UP1_EFFECT"] = true,
+ ["SPECIAL_UP2_EFFECT"] = true,
+ ["SPEED_DOWN"] = true,
+ ["SPEED_DOWN1_EFFECT"] = true,
+ ["SPEED_DOWN2_EFFECT"] = true,
+ ["SPEED_DOWN_2"] = true,
+ ["SPEED_DOWN_HIT"] = true,
+ ["SPEED_DOWN_SIDE_EFFECT"] = true,
+ ["SPEED_UP"] = true,
+ ["SPEED_UP1_EFFECT"] = true,
+ ["SPEED_UP2_EFFECT"] = true,
+ ["SPEED_UP_2"] = true,
+ ["SPIKES"] = true,
+ ["SPITE"] = true,
+ ["SPIT_UP"] = true,
+ ["SPLASH"] = true,
+ ["SPLASH_EFFECT"] = true,
+ ["STATUS_SETUP"] = true,
+ ["STAT_CHANGES"] = true,
+ ["STOCKPILE"] = true,
+ ["SUBSTITUTE"] = true,
+ ["SUBSTITUTE_EFFECT"] = true,
+ ["SUNNY_DAY"] = true,
+ ["SUPERPOWER"] = true,
+ ["SUPER_FANG"] = true,
+ ["SUPER_FANG_EFFECT"] = true,
+ ["SWAGGER"] = true,
+ ["SWALLOW"] = true,
+ ["SWIFT_EFFECT"] = true,
+ ["SWITCH_AND_TELEPORT_EFFECT"] = true,
+ ["SYNTHESIS"] = true,
+ ["TAUNT"] = true,
+ ["TEETER_DANCE"] = true,
+ ["TELEPORT"] = true,
+ ["THAW_HIT"] = true,
+ ["THIEF"] = true,
+ ["THRASH"] = true,
+ ["THRASH_PETAL_DANCE_EFFECT"] = true,
+ ["THUNDER"] = true,
+ ["TICKLE"] = true,
+ ["TORMENT"] = true,
+ ["TOXIC"] = true,
+ ["TRANSFORM"] = true,
+ ["TRANSFORM_EFFECT"] = true,
+ ["TRAP"] = true,
+ ["TRAPPING_EFFECT"] = true,
+ ["TRICK"] = true,
+ ["TRIPLE_KICK"] = true,
+ ["TRI_ATTACK"] = true,
+ ["TWINEEDLE"] = true,
+ ["TWINEEDLE_EFFECT"] = true,
+ ["TWISTER"] = true,
+ ["TWO_TO_FIVE_ATTACKS_EFFECT"] = true,
+ ["UPROAR"] = true,
+ ["VITAL_THROW"] = true,
+ ["WATER_SPORT"] = true,
+ ["WEATHER_BALL"] = true,
+ ["WILL_O_WISP"] = true,
+ ["WISH"] = true,
+ ["YAWN"] = true,
+ },
+ ["variants"] = {
+ ["ACCURACY_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=23},
+ ["ATTACK_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=18},
+ ["ATTACK_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=58},
+ ["ATTACK_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=68},
+ ["ATTACK_TWICE_EFFECT"] = {["chance"]=0,["semantic"]=44},
+ ["ATTACK_UP1_EFFECT"] = {["chance"]=0,["semantic"]=10},
+ ["ATTACK_UP2_EFFECT"] = {["chance"]=0,["semantic"]=50},
+ ["BIDE_EFFECT"] = {["chance"]=0,["semantic"]=26},
+ ["BURN_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=4},
+ ["BURN_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=4},
+ ["CHARGE_EFFECT"] = {["chance"]=0,["semantic"]=39},
+ ["CONFUSION_EFFECT"] = {["chance"]=0,["semantic"]=49},
+ ["CONFUSION_SIDE_EFFECT"] = {["chance"]=10,["semantic"]=76},
+ ["CONVERSION_EFFECT"] = {["chance"]=0,["semantic"]=30},
+ ["DEFENSE_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=19},
+ ["DEFENSE_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=59},
+ ["DEFENSE_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=69},
+ ["DEFENSE_UP1_EFFECT"] = {["chance"]=0,["semantic"]=11},
+ ["DEFENSE_UP2_EFFECT"] = {["chance"]=0,["semantic"]=51},
+ ["DISABLE_EFFECT"] = {["chance"]=0,["semantic"]=86},
+ ["DRAIN_HP_EFFECT"] = {["chance"]=0,["semantic"]=3},
+ ["DREAM_EATER_EFFECT"] = {["chance"]=0,["semantic"]=8},
+ ["EVASION_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=24},
+ ["EVASION_UP1_EFFECT"] = {["chance"]=0,["semantic"]=16},
+ ["EXPLODE_EFFECT"] = {["chance"]=0,["semantic"]=7},
+ ["FLINCH_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=31},
+ ["FLINCH_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=31},
+ ["FLY_EFFECT"] = {["chance"]=0,["semantic"]=155},
+ ["FOCUS_ENERGY_EFFECT"] = {["chance"]=0,["semantic"]=47},
+ ["FREEZE_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=5},
+ ["FREEZE_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=5},
+ ["HAZE_EFFECT"] = {["chance"]=0,["semantic"]=25},
+ ["HEAL_EFFECT"] = {["chance"]=0,["semantic"]=32},
+ ["HYPER_BEAM_EFFECT"] = {["chance"]=0,["semantic"]=80},
+ ["JUMP_KICK_EFFECT"] = {["chance"]=0,["semantic"]=45},
+ ["LEECH_SEED_EFFECT"] = {["chance"]=0,["semantic"]=84},
+ ["LIGHT_SCREEN_EFFECT"] = {["chance"]=0,["semantic"]=35},
+ ["METRONOME_EFFECT"] = {["chance"]=0,["semantic"]=83},
+ ["MIMIC_EFFECT"] = {["chance"]=0,["semantic"]=82},
+ ["MIRROR_MOVE_EFFECT"] = {["chance"]=0,["semantic"]=9},
+ ["MIST_EFFECT"] = {["chance"]=0,["semantic"]=46},
+ ["NO_ADDITIONAL_EFFECT"] = {["chance"]=0,["semantic"]=0},
+ ["OHKO_EFFECT"] = {["chance"]=0,["semantic"]=38},
+ ["PARALYZE_EFFECT"] = {["chance"]=0,["semantic"]=67},
+ ["PARALYZE_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=6},
+ ["PARALYZE_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=6},
+ ["PAY_DAY_EFFECT"] = {["chance"]=0,["semantic"]=34},
+ ["POISON_EFFECT"] = {["chance"]=0,["semantic"]=66},
+ ["POISON_SIDE_EFFECT1"] = {["chance"]=20,["semantic"]=2},
+ ["POISON_SIDE_EFFECT2"] = {["chance"]=40,["semantic"]=2},
+ ["RAGE_EFFECT"] = {["chance"]=0,["semantic"]=81},
+ ["RECOIL_EFFECT"] = {["chance"]=0,["semantic"]=48},
+ ["REFLECT_EFFECT"] = {["chance"]=0,["semantic"]=65},
+ ["SLEEP_EFFECT"] = {["chance"]=0,["semantic"]=1},
+ ["SPECIAL_DAMAGE_EFFECT"] = {["chance"]=0,["semantic"]=87},
+ ["SPECIAL_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=62},
+ ["SPECIAL_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=72},
+ ["SPECIAL_UP1_EFFECT"] = {["chance"]=0,["semantic"]=13},
+ ["SPECIAL_UP2_EFFECT"] = {["chance"]=0,["semantic"]=54},
+ ["SPEED_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=20},
+ ["SPEED_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=60},
+ ["SPEED_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=70},
+ ["SPEED_UP2_EFFECT"] = {["chance"]=0,["semantic"]=52},
+ ["SPLASH_EFFECT"] = {["chance"]=0,["semantic"]=85},
+ ["SUBSTITUTE_EFFECT"] = {["chance"]=0,["semantic"]=79},
+ ["SUPER_FANG_EFFECT"] = {["chance"]=0,["semantic"]=40},
+ ["SWIFT_EFFECT"] = {["chance"]=0,["semantic"]=17},
+ ["SWITCH_AND_TELEPORT_EFFECT"] = {["chance"]=0,["semantic"]=28},
+ ["THRASH_PETAL_DANCE_EFFECT"] = {["chance"]=0,["semantic"]=27},
+ ["TRANSFORM_EFFECT"] = {["chance"]=0,["semantic"]=57},
+ ["TRAPPING_EFFECT"] = {["chance"]=0,["semantic"]=42},
+ ["TWINEEDLE_EFFECT"] = {["chance"]=20,["semantic"]=77},
+ ["TWO_TO_FIVE_ATTACKS_EFFECT"] = {["chance"]=0,["semantic"]=29},
+ },
+}
diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua
new file mode 100644
index 000000000..72d855cec
--- /dev/null
+++ b/src/mods/PortableScripts.lua
@@ -0,0 +1,1314 @@
+-- Per-mod portable dialogue programs. An isolated native runner owns the
+-- foreground until completion, then returns the idle runner it borrowed.
+local Program = require("src.mods.ScriptProgram")
+local Runtime = require("src.mods.Runtime")
+local Flags = require("src.mods.Api3Flags")
+local State = require("src.mods.ScriptState")
+local Gifts = require("src.mods.ScriptGifts")
+local Audio = require("src.mods.ScriptAudio")
+local Fields = require("src.mods.ScriptFields")
+local DialogueAudio = require("src.mods.ScriptDialogueAudio")
+local Shops = require("src.mods.ScriptShops")
+local Battles = require("src.mods.ScriptBattles")
+local Travel = require("src.mods.ScriptTravel")
+local Movement = require("src.mods.ScriptMovement")
+local WorldEdits = require("src.mods.ScriptWorldEdits")
+local GameVersion = require("src.core.GameVersion")
+local Scripts = {}
+local runs = setmetatable({}, { __mode = "k" })
+local observers = setmetatable({}, { __mode = "k" })
+local contexts = setmetatable({}, { __mode = "k" })
+local draining = false
+local unpack = table.unpack or unpack
+local function pack(...)
+ return { n = select("#", ...), ... }
+end
+local function engine(fn, ...)
+ local owner = Runtime.currentMod
+ Runtime.currentMod = nil
+ local result = pack(pcall(fn, ...))
+ Runtime.currentMod = owner
+ return unpack(result, 1, result.n)
+end
+local function current(loader)
+ if loader.game then
+ return loader.game
+ end
+ local game = package.loaded["src.core.Game"]
+ return type(game) == "table" and game or nil
+end
+local function message(err)
+ local ok, text = pcall(tostring, err)
+ return ok and text or "unprintable error"
+end
+local function contains(stack, state)
+ if type(stack.states) ~= "table" then
+ return false
+ end
+ for _, value in ipairs(stack.states) do
+ if value == state then
+ return true
+ end
+ end
+ return false
+end
+local function remove(stack, state)
+ if type(stack.states) ~= "table" then
+ return
+ end
+ for i = #stack.states, 1, -1 do
+ if stack.states[i] == state then
+ table.remove(stack.states, i)
+ if Runtime.wants("screen.popped") then
+ Runtime.emit("screen.popped", { state = state })
+ end
+ return
+ end
+ end
+end
+local LOCKS = {
+ "transitioning",
+ "flyAnim",
+ "teleportOut",
+ "engaging",
+ "emote",
+ "pikaHop",
+ "healAnim",
+ "whirlSpin",
+ "gen4Waterfall",
+ "gen5SeamPending",
+ "gen3Decorate",
+ "gen3Locked",
+ "frameScriptThisTick",
+ "_modScriptLock",
+}
+local function ready(game)
+ if not (game and game.save) then
+ return nil, "no save"
+ end
+ local stack = game.stack
+ if not (stack and type(stack.states) == "table" and type(stack.top) == "function") then
+ return nil, "no overworld"
+ end
+ local ow = stack:top()
+ if not (ow and ow.isOverworld and ow.map and ow.player and ow.runner) then
+ return nil, "world is busy"
+ end
+ if
+ runs[game]
+ or game.overworld and game.overworld ~= ow
+ or game.linkSession
+ or game.linkNet and not game.linkNet.closed
+ then
+ return nil, "world is busy"
+ end
+ for _, key in ipairs(LOCKS) do
+ if ow[key] then
+ return nil, "world is busy"
+ end
+ end
+ for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do
+ if ow.player[key] then
+ return nil, "world is busy"
+ end
+ end
+ if
+ ow.runner:isRunning()
+ or #(ow.scriptMoves or {}) > 0
+ or #(ow.pendingScripts or {}) > 0
+ or #(ow.parallelRunners or {}) > 0
+ or #(ow.parallelQueue or {}) > 0
+ then
+ return nil, "world is busy"
+ end
+ return ow
+end
+local function observe(loader)
+ local installed = observers[loader]
+ if installed and installed[1] == loader.hooks then
+ return
+ end
+ -- Weak values too: the hook closure captures its loader, so retaining the
+ -- hooks here would otherwise keep every unloaded loader alive on LuaJIT.
+ observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" })
+ -- Keep one observer per loader: retiring a hook while its chain dispatches
+ -- would shift the following mod's index. Per-run cleanup never edits it.
+ loader.hooks:wrap("input.step", function(nextFn, game, dt)
+ if next(runs) == nil then
+ return nextFn(game, dt)
+ end
+ local pending = {}
+ for _, token in pairs(runs) do
+ if token.loader == loader then
+ pending[#pending + 1] = token
+ end
+ end
+ for _, token in ipairs(pending) do
+ local ok, live, why = engine(token.valid)
+ if not ok or not live then
+ token.finish(false, ok and why or "script context failed")
+ elseif token.waitingUI and not contains(token.stack, token.waitingUI) then
+ token.finish(false, "script UI was removed")
+ elseif token.shop then
+ token.shop.refresh()
+ elseif token.travel then
+ token.travel.tick()
+ end
+ end
+ return nextFn(game, dt)
+ end, math.huge, "engine:portable_scripts")
+end
+function Scripts.cancelAll(loader, modId, reason)
+ if loader and modId then
+ if contexts[loader] then
+ contexts[loader].owners[modId] = nil
+ end
+ elseif loader then
+ contexts[loader] = nil
+ else
+ for owner in pairs(contexts) do
+ contexts[owner] = nil
+ end
+ end
+ local wasDraining = draining
+ draining = true
+ local pending = {}
+ for _, token in pairs(runs) do
+ if (not loader or token.loader == loader) and (not modId or token.modId == modId) then
+ pending[#pending + 1] = token
+ end
+ end
+ for _, token in ipairs(pending) do
+ token.finish(false, reason or "script session ended")
+ end
+ draining = wasDraining
+end
+local function facade(loader, modId, native)
+ local context = contexts[loader]
+ if not context then
+ context = { owners = {} }
+ contexts[loader] = context
+ end
+ local lease = {}
+ context.owners[modId] = lease
+ local function licensed()
+ return not draining and contexts[loader] == context and context.owners[modId] == lease
+ end
+ -- Install before entry code can subscribe/queue from input.step. Sorting
+ -- a new observer into a live hook chain could replay its current wrapper.
+ observe(loader)
+ local world, active = {}, nil
+ local nativeGame, nativeWorld
+ local function battleWorld(game)
+ if nativeGame ~= game then
+ nativeWorld = require("src.world.WorldAPI").new(game, modId)
+ nativeGame = game
+ end
+ return nativeWorld
+ end
+ local function ownedBattle(game, ow)
+ if
+ active
+ and runs[game] == active
+ and not active.done
+ and active.world == ow
+ and ow.runner == active.runner
+ and game.stack:top() == ow
+ and active.valid()
+ then
+ return true
+ end
+ return nil, "portable script does not own this world"
+ end
+ local function provider()
+ return current(loader)
+ end
+ local startWild = require("src.mods.Api3WildBattle").new(
+ loader,
+ modId,
+ provider,
+ battleWorld,
+ licensed,
+ require,
+ ownedBattle
+ )
+ local startTrainer = require("src.mods.Api3WildBattle").new(
+ loader,
+ modId,
+ provider,
+ battleWorld,
+ licensed,
+ require,
+ ownedBattle,
+ Battles.nativeTrainer
+ )
+ local function queue(rows, options)
+ if not licensed() then
+ return nil, "script facade is retired"
+ end
+ local program, why = Program.compile(
+ rows,
+ { owner = modId, generation = GameVersion.generation(), version = GameVersion.get() }
+ )
+ if not program then
+ return nil, why
+ end
+ if options ~= nil then
+ if type(options) ~= "table" or getmetatable(options) ~= nil then
+ return nil, "options must be a plain table"
+ end
+ for key, value in pairs(options) do
+ if key ~= "onDone" or type(value) ~= "function" then
+ return nil, "only onDone callback is supported"
+ end
+ end
+ end
+ local onDone = options and options.onDone
+ local game = current(loader)
+ local ow
+ ow, why = ready(game)
+ if not ow then
+ return nil, why
+ end
+ local function flag(name, value, write)
+ return Flags.call(game, modId, GameVersion.generation(), name, value, write, licensed, function()
+ return ow
+ end)
+ end
+ -- Check stores for every row, including unreachable branches, before a prefix runs.
+ for i, row in ipairs(program) do
+ if
+ row[1] == "set_flag"
+ or row[1] == "clear_flag"
+ or row[1] == "check_flag"
+ or row[1] == "wait_flag"
+ then
+ local value, reason = flag(row[2])
+ if value == nil then
+ return nil, "row " .. i .. ": " .. reason
+ end
+ end
+ end
+ local fields, fieldsReason = Fields.prepare(game, modId, program)
+ if not fields then
+ return nil, fieldsReason
+ end
+ local dialogueAudio, dialogueReason = DialogueAudio.prepare(loader, game, program)
+ if not dialogueAudio then
+ return nil, dialogueReason
+ end
+ local audio, audioReason = Audio.prepare(game, program)
+ if not audio then
+ return nil, audioReason
+ end
+ local state, reason = State.prepare(loader, game, ow, program)
+ if not state then
+ return nil, reason
+ end
+ local gift, giftReason = Gifts.prepare(loader, game, ow, program)
+ if not gift then
+ return nil, giftReason
+ end
+ local battleOK, battleReason = Battles.prepare(loader, game, ow, program, flag)
+ if not battleOK then
+ return nil, battleReason
+ end
+ local travel, travelReason = Travel.prepare(game, ow, modId, program, loader)
+ if not travel then
+ return nil, travelReason
+ end
+ local edit, editReason = WorldEdits.prepare(game, ow, modId, program, true)
+ if not edit then
+ return nil, editReason
+ end
+ local movement, moveReason = Movement.prepare(ow, program, true)
+ if not movement then
+ return nil, moveReason
+ end
+ local Runner = require("src.script.ScriptRunner")
+ local runner = Runner.new(game, ow)
+ local token = {
+ loader = loader,
+ modId = modId,
+ game = game,
+ world = ow,
+ runner = runner,
+ original = ow.runner,
+ stack = game.stack,
+ states = game.stack.states,
+ mapId = ow.map.id,
+ x = ow.player.cellX,
+ y = ow.player.cellY,
+ save = game.save,
+ data = game.data,
+ map = ow.map,
+ player = ow.player,
+ version = GameVersion.get(),
+ ui = {},
+ ownedUI = {},
+ steps = 0,
+ }
+ function token.valid()
+ if token.done then
+ return nil, "script is finished"
+ end
+ if
+ not licensed()
+ or current(loader) ~= game
+ or game.save ~= token.save
+ or game.data ~= token.data
+ or game.stack ~= token.stack
+ or game.stack.states ~= token.states
+ or not contains(token.stack, ow)
+ or game.overworld and game.overworld ~= ow
+ or not token.travel and not token.mutating and ow.map ~= token.map
+ or not token.travel and ow.map.id ~= token.mapId
+ or ow.player ~= token.player
+ or not token.travel and (ow.player.cellX ~= token.x or ow.player.cellY ~= token.y) and not (token.move and token.move.position())
+ or not token.travel and not token.mutating and ow.runner ~= runner
+ or token.move and not token.move.live()
+ or token.shop and not token.shop.valid()
+ or GameVersion.get() ~= token.version
+ or Runtime.hooks ~= loader.hooks
+ or Runtime.events ~= loader.events
+ or game.linkSession
+ or game.linkNet and not game.linkNet.closed
+ or not token.travel and ow.transitioning
+ or token.travel and not token.travel.valid()
+ then
+ return nil, "script session changed"
+ end
+ return true
+ end
+ function token.finish(ok, reason)
+ if token.done then
+ return
+ end
+ token.done = true
+ if token.travel then
+ token.travel.retired = true
+ end
+ if token.move then
+ token.move.cancel()
+ end
+ runner.co, runner.waitingFrames, runner.waitingCheck = nil, nil, nil
+ -- Remove only this run's exact native screens, preserving foreign
+ -- overlays and a replacement world's runner/locks.
+ for i = #token.ui, 1, -1 do
+ remove(token.stack, token.ui[i])
+ end
+ if ow.runner == runner then
+ ow.runner = token.original
+ end
+ if runs[game] == token then
+ runs[game] = nil
+ end
+ if active == token then
+ active = nil
+ end
+ if Runtime.wants("script.ended") then
+ Runtime.emit("script.ended", { ctx = runner.ctx, completed = ok })
+ end
+ if onDone then
+ local called, err = pcall(onDone, ok, reason)
+ if not called then
+ Runtime.reportError(modId, "portable script completion failed: " .. message(err))
+ end
+ end
+ end
+ local function check()
+ local valid, reason = token.valid()
+ if not valid then
+ token.finish(false, reason)
+ end
+ return valid
+ end
+ local function mutate(operation)
+ if not check() then
+ return nil, "script session changed"
+ end
+ token.mutating = true
+ local called, value, reason = engine(operation)
+ if
+ not token.done
+ and current(loader) == game
+ and game.save == token.save
+ and game.data == token.data
+ and game.stack == token.stack
+ and ow.player == token.player
+ then
+ if
+ called
+ and value
+ and ow.map ~= token.map
+ and ow.map
+ and ow.map.id == token.mapId
+ and ow.runner ~= runner
+ then
+ token.original = ow.runner
+ ow.runner = runner
+ end
+ if called and value and ow.map and ow.map.id == token.mapId then
+ token.map = ow.map
+ end
+ end
+ token.mutating = nil
+ if not check() then
+ return nil, "script session changed"
+ end
+ if not called then
+ return nil, message(value)
+ end
+ return value, reason
+ end
+ local discover
+ local function snapshot()
+ local before = {}
+ for _, state in ipairs(game.stack.states) do
+ before[state] = true
+ end
+ return before
+ end
+ local function owned(state, step)
+ if token.ownedUI[state] then
+ return state
+ end
+ token.ownedUI[state] = true
+ token.ui[#token.ui + 1] = state
+ local update = state.update
+ function state:update(...)
+ if not check() or token.step ~= step or game.stack:top() ~= self then
+ return
+ end
+ local before = token.nativeStep == step and snapshot()
+ local previous, phase, priorBefore = token.executingUI, token.uiPhase, token.uiBefore
+ if token.shop and token.shop.step == step then
+ token.executingUI, token.uiPhase, token.uiBefore = self, self.asking, before
+ end
+ local ok, err = engine(update, self, ...)
+ if before then
+ discover(before, step)
+ end
+ if token.shop then
+ token.shop.refresh()
+ end
+ token.executingUI, token.uiPhase, token.uiBefore = previous, phase, priorBefore
+ if not ok then
+ token.finish(false, "native UI failed: " .. message(err))
+ end
+ end
+ if token.shop then
+ token.shop.attach(state)
+ end
+ return state
+ end
+ discover = function(before, step)
+ if token.done or token.step ~= step or token.travel or token.battling then
+ return
+ end
+ for _, state in ipairs(game.stack.states) do
+ if not before[state] then
+ owned(state, step)
+ if token.nativeStep == step and not token.shop then
+ token.waitingUI = state
+ end
+ end
+ end
+ end
+ local function nativeGift(ctx, row)
+ local step = token.step
+ local bound = setmetatable({
+ resume = function(_, ...)
+ if token.step == step and check() then
+ runner:resume(...)
+ end
+ end,
+ }, { __index = runner, __newindex = runner })
+ local revision = 0
+ local proxy = setmetatable({
+ portableCallback = function(callback)
+ local expected, fired = revision, false
+ return function(...)
+ if fired or expected ~= revision or token.step ~= step or not check() then
+ return
+ end
+ fired = true
+ revision = revision + 1
+ return callback(...)
+ end
+ end,
+ runner = bound,
+ portableAlive = function()
+ return token.step == step and check()
+ end,
+ }, { __index = ctx, __newindex = ctx })
+ token.nativeStep = step
+ local result = gift(proxy, row)
+ token.nativeStep = nil
+ token.waitingUI = nil
+ return result
+ end
+ local function dialog(ctx, text, asking, step)
+ local TextBox = require("src.render.TextBox")
+ local box, answered
+ local function answer(value)
+ if answered or token.step ~= step or not check() then
+ return
+ end
+ answered = true
+ token.waitingUI = nil
+ ctx.lastCheck = value
+ runner:resume()
+ end
+ local opts = dialogueAudio.options(ctx, asking, function()
+ return not answered
+ and token.step == step
+ and check()
+ and box ~= nil
+ and contains(token.stack, box)
+ and token.stack:top() == box
+ end)
+ box = TextBox.new(game, TextBox.fromProse(text), function()
+ if not asking and not contains(token.stack, box) then
+ answer(nil)
+ end
+ end, opts)
+ if asking then
+ local update, pushed, selected = box.update, false, nil
+ box.choice = function(yes)
+ if token.step ~= step or not check() or not selected or contains(token.stack, selected) then
+ return
+ end
+ remove(token.stack, box)
+ answer(yes)
+ end
+ function box:update(dt)
+ if self.done and (self.holdFrames or 0) <= 0 and not pushed and not self.auto then
+ pushed, self.choicePushed = true, true
+ local Choice = require("src.ui.ChoiceBox")
+ local geometry = self.choiceBox or {}
+ selected = owned(
+ Choice.new(
+ game,
+ self.choice,
+ { anchor = "bottom", tx = geometry.tx, ty = geometry.ty, tw = geometry.tw, th = geometry.th }
+ ),
+ step
+ )
+ token.waitingUI = selected
+ game.stack:push(selected)
+ else
+ update(self, dt)
+ end
+ end
+ end
+ owned(box, step)
+ token.waitingUI = box
+ game.stack:push(box)
+ runner:yield()
+ end
+ local function choice(ctx, labels, opts, step)
+ local Menu = require("src.ui.Menu")
+ local items, menu, answered = {}, nil, false
+ local function pick(index)
+ if answered or token.step ~= step or not check() or contains(token.stack, menu) then
+ return
+ end
+ answered = true
+ token.waitingUI = nil
+ ctx.lastCheck, ctx.lastChoice = index == 1, { index = index, label = labels[index] }
+ runner:resume()
+ end
+ for i, label in ipairs(labels) do
+ items[i] = {
+ label = label,
+ onSelect = function()
+ pick(i)
+ end,
+ }
+ end
+ menu = owned(
+ Menu.new(game, items, {
+ onCancel = function()
+ pick(opts.cancel)
+ end,
+ }),
+ step
+ )
+ menu.index = opts.default
+ token.waitingUI = menu
+ game.stack:push(menu)
+ runner:yield()
+ end
+ local function shop(ctx, row)
+ local stock, validate = Shops.plan(loader, game, ow, row)
+ if not stock then
+ error(validate, 0)
+ end
+ local step, complete, root = token.step, false, nil
+ local service = { step = step }
+ function service.valid()
+ if not complete and root and not contains(token.stack, root) and token.executingUI ~= root then
+ return false
+ end
+ return validate()
+ end
+ local invoke
+ service.attach = require("src.mods.ScriptShopUI").new({
+ alive = function(state)
+ return not complete
+ and token.shop == service
+ and token.step == step
+ and check()
+ and (
+ game.stack:top() == state
+ or token.executingUI == state
+ or token.executingUI
+ and token.ownedUI[token.executingUI]
+ and (contains(token.stack, state) or token.uiBefore and token.uiBefore[state])
+ )
+ end,
+ phase = function(state)
+ return token.executingUI == state and token.uiPhase or nil
+ end,
+ invoke = function(state, fn, ...)
+ return invoke(state, fn, ...)
+ end,
+ })
+ function service.refresh()
+ if token.done then
+ return
+ end
+ if not token.executingUI then
+ for i = #token.ui, 1, -1 do
+ if not contains(token.stack, token.ui[i]) then
+ token.ownedUI[token.ui[i]] = nil
+ table.remove(token.ui, i)
+ end
+ end
+ end
+ for _, state in ipairs(token.ui) do
+ if contains(token.stack, state) then
+ service.attach(state)
+ end
+ end
+ end
+ invoke = function(state, fn, ...)
+ local previous, phase, priorBefore = token.executingUI, token.uiPhase, token.uiBefore
+ local before = snapshot()
+ token.executingUI, token.uiPhase, token.uiBefore = state, state.asking, before
+ local result = pack(engine(fn, ...))
+ discover(before, step)
+ service.refresh()
+ token.executingUI, token.uiPhase, token.uiBefore = previous, phase, priorBefore
+ if not result[1] then
+ token.finish(false, "native shop callback failed: " .. message(result[2]))
+ return
+ end
+ return unpack(result, 2, result.n)
+ end
+ token.shop, token.nativeStep = service, step
+ local function done()
+ if not root or complete or token.step ~= step or not check() or contains(token.stack, root) then
+ return
+ end
+ complete = true
+ end
+ local called, state = engine(require("src.ui.Screens").push, game, "ShopMenu", stock, done)
+ if not called then
+ token.shop, token.nativeStep = nil, nil
+ error(state, 0)
+ end
+ root = state
+ if token.done then
+ remove(token.stack, root)
+ return
+ end
+ owned(root, step)
+ if not check() then
+ return
+ end
+ runner.waitingCheck = function()
+ return complete
+ end
+ runner:yield()
+ token.shop, token.nativeStep = nil, nil
+ end
+ local function battle(ctx, row)
+ local verb = row[1]
+ if verb == "check_battle_result" then
+ ctx.lastCheck = false
+ for i = 2, #row do
+ if row[i] == ctx.lastBattleResult then
+ ctx.lastCheck = true
+ end
+ end
+ return
+ end
+ local species, level = row[2], row[3]
+ if verb == "start_battle" then
+ species, level = row[3], row[4]
+ end
+ local step, complete, result = token.step, false, nil
+ token.battling = true
+ local start = verb == "start_battle" and row[2] == "trainer" and startTrainer or startWild
+ local ok, why = start(species, level, function(outcome, failure)
+ if complete or token.step ~= step or not check() then
+ return
+ end
+ if not Battles.outcomes[outcome] then
+ token.finish(false, failure or "invalid native battle result")
+ return
+ end
+ complete, result = true, outcome
+ end)
+ if not ok then
+ token.battling = nil
+ error(why, 0)
+ end
+ if not check() then
+ return
+ end
+ if not complete then
+ runner.waitingCheck = function()
+ return complete
+ end
+ runner:yield()
+ end
+ token.battling = nil
+ if not check() then
+ return
+ end
+ ctx.lastBattleResult, ctx.lastCheck = result, result == "win"
+ if verb == "static_battle" and result ~= "lose" then
+ if row[4] ~= nil then
+ local value, reason = flag(row[4], true, true)
+ if not value then
+ error(reason, 0)
+ end
+ end
+ if not check() then
+ return
+ end
+ local npc = movement.npc
+ local def = npc and npc.def
+ local ref = def and (def.name or def.localId or def.index or def.id)
+ if ref then
+ local value, reason = require("src.mods.ScriptWorldEdits").prepare(
+ game,
+ ow,
+ modId,
+ { { "hide_object", ow.map.id, ref } }
+ )
+ if not value then
+ error(reason, 0)
+ end
+ local accepted, detail = value({ "hide_object", ow.map.id, ref }, mutate)
+ if not check() then
+ return
+ end
+ if not accepted then
+ error(detail, 0)
+ end
+ end
+ end
+ end
+ local function warp(ctx, row)
+ local plan, reason = travel(row)
+ if not plan then
+ error(reason, 0)
+ end
+ if row[1] == "remember_outdoor" then
+ token.save.lastOutdoor = { id = plan.mapId, x = plan.x, y = plan.y }
+ if GameVersion.generation() == 3 then
+ token.save.escapeWarp = { map = plan.mapId, warpId = 255, x = plan.x, y = plan.y }
+ elseif ow.rememberOutdoor then
+ ow:rememberOutdoor(plan.mapId, plan.x, plan.y)
+ else
+ ow.lastOutdoor = token.save.lastOutdoor
+ end
+ return
+ end
+ local origin, step = ow.map, token.step
+ local accepted, pending, fired, complete = nil, false, false, false
+ local lease = { ui = {} }
+ local remaining = 3600
+ function lease.tick()
+ local top = token.stack:top()
+ if top == ow or lease.ui[top] then
+ remaining = remaining - 1
+ if remaining <= 0 then
+ token.finish(false, "native warp timed out")
+ end
+ end
+ end
+ function lease.valid()
+ if lease.retired then
+ return false
+ end
+ if
+ ow.map == origin
+ and ow.player.cellX == token.x
+ and ow.player.cellY == token.y
+ and ow.runner == runner
+ then
+ return true
+ end
+ return ow.map and ow.map.id == plan.mapId and ow.player.cellX == plan.x and ow.player.cellY == plan.y
+ end
+ local function done()
+ if fired or lease.retired then
+ return
+ end
+ if accepted == nil then
+ pending = true
+ return
+ end
+ fired = true
+ if not accepted or token.step ~= step or not check() then
+ return
+ end
+ if
+ not ow.map
+ or ow.map.id ~= plan.mapId
+ or ow.player.cellX ~= plan.x
+ or ow.player.cellY ~= plan.y
+ or ow.transitioning
+ then
+ token.finish(false, "native warp did not arrive")
+ return
+ end
+ if ow.runner ~= runner then
+ if not ow.runner or ow.runner:isRunning() then
+ token.finish(false, "destination script is busy")
+ return
+ end
+ token.original = ow.runner
+ ow.runner = runner
+ end
+ token.map, token.mapId, token.x, token.y = ow.map, plan.mapId, plan.x, plan.y
+ ctx.mapId, ctx.npc = plan.mapId, nil
+ movement.forgetNpc()
+ movement.npc = nil
+ complete = true
+ end
+ token.travel = lease
+ local statesBefore = snapshot()
+ local called, value, why =
+ engine(ow.startWarpTo, ow, plan.mapId, plan.x, plan.y, plan.facing, done, { via = "warp" })
+ for _, state in ipairs(game.stack.states) do
+ if not statesBefore[state] then
+ lease.ui[state] = true
+ end
+ end
+ accepted = called and value ~= false and not (value == nil and why ~= nil)
+ if not accepted then
+ lease.retired = true
+ token.travel = nil
+ error(called and (why or "native warp refused") or message(value), 0)
+ end
+ if pending then
+ done()
+ end
+ if not check() then
+ return
+ end
+ if not complete then
+ runner.waitingCheck = function()
+ return complete
+ end
+ runner:yield()
+ end
+ lease.retired = true
+ if token.travel == lease then
+ token.travel = nil
+ end
+ end
+ local function move(ctx, row)
+ local actor = movement.target(row)
+ if not actor then
+ error("native movement target unavailable", 0)
+ end
+ local verb, dirs = row[1], {}
+ if verb == "move_player" or verb == "move_npc" then
+ local at = verb == "move_player" and 2 or 3
+ for i = 1, row[at + 1] or 1 do
+ dirs[i] = row[at]
+ end
+ elseif verb == "move_npc_to" then
+ local reason
+ dirs, reason = Movement.path(ow, actor, row[3], row[4])
+ if not dirs then
+ error(reason, 0)
+ end
+ elseif verb == "place_npc" then
+ if actor.def and actor.def.index then
+ require("src.script.Commands").place_npc(ctx, actor.def.index, row[3], row[4], row[5])
+ return
+ end
+ actor.cellX, actor.cellY, actor.px, actor.py = row[3], row[4], row[3] * 16, row[4] * 16
+ actor.moving, actor.marching, actor.progress = false, false, 0
+ actor.targetX, actor.targetY = nil, nil
+ if row[5] then
+ actor.facing = row[5]
+ end
+ return
+ else
+ local dir = verb == "face_object" and row[3] or row[2]
+ if verb == "face_npc" or verb == "face_player" then
+ local other = verb == "face_npc" and movement.npc or ow.player
+ local dx, dy = other.cellX - actor.cellX, other.cellY - actor.cellY
+ dir = math.abs(dx) > math.abs(dy) and (dx > 0 and "right" or "left") or (dy > 0 and "down" or "up")
+ end
+ if actor.scriptFace then
+ actor:scriptFace(dir)
+ else
+ actor.facing = dir
+ end
+ if verb == "face_object" and row[4] and row[4].hold then
+ actor.timer = row[4].hold
+ end
+ return
+ end
+ for _, dir in ipairs(dirs) do
+ if not check() then
+ return
+ end
+ if actor ~= ow.player and movement.target(row) ~= actor then
+ error("movement target changed", 0)
+ end
+ local step, completed, accepted, fired, pending = token.step, false, nil, false, false
+ local sx, sy = actor.cellX, actor.cellY
+ local delta = Movement.directions[dir]
+ local tx, ty = sx + delta[1], sy + delta[2]
+ local entries, before = {}, {}
+ for _, entry in ipairs(ow.scriptMoves or {}) do
+ before[entry] = true
+ end
+ local lease = {}
+ function lease.live()
+ return movement.target(row) == actor
+ and (actor.cellX == sx and actor.cellY == sy or actor.cellX == tx and actor.cellY == ty)
+ end
+ function lease.position()
+ if actor ~= ow.player then
+ return false
+ end
+ local x, y = actor.cellX, actor.cellY
+ return (x == sx and y == sy or x == tx and y == ty)
+ end
+ function lease.cancel()
+ local removed = false
+ for i = #(ow.scriptMoves or {}), 1, -1 do
+ if entries[ow.scriptMoves[i]] then
+ table.remove(ow.scriptMoves, i)
+ removed = true
+ end
+ end
+ local foreign = false
+ for _, entry in ipairs(ow.scriptMoves or {}) do
+ if entry.entity == actor then
+ foreign = true
+ end
+ end
+ if removed and not foreign then
+ actor.moving, actor.marching = false, false
+ actor.targetX, actor.targetY = nil, nil
+ actor.px, actor.py = actor.cellX * 16, actor.cellY * 16
+ end
+ if token.move == lease then
+ token.move = nil
+ end
+ end
+ local function done()
+ if fired then
+ return
+ end
+ if accepted == nil then
+ pending = true
+ return
+ end
+ fired = true
+ if not accepted or token.step ~= step or not check() then
+ return
+ end
+ if actor.cellX ~= tx or actor.cellY ~= ty then
+ token.finish(false, "native movement did not arrive")
+ return
+ end
+ completed = true
+ if actor == ow.player then
+ token.x, token.y = tx, ty
+ end
+ lease.cancel()
+ -- Poll completion on the next foreground logic frame. A callback
+ -- beneath a foreign overlay cannot resume later script effects.
+ end
+ token.move = lease
+ local called, value, reason = engine(ow.scriptMove, ow, actor, dir, 1, done)
+ for _, entry in ipairs(ow.scriptMoves or {}) do
+ if not before[entry] and entry.entity == actor then
+ entries[entry] = true
+ end
+ end
+ if token.done then
+ lease.cancel()
+ return
+ end
+ accepted = called and value ~= false and not (value == nil and reason ~= nil)
+ if not accepted then
+ lease.cancel()
+ error(called and (reason or "native movement refused") or message(value), 0)
+ end
+ if pending then
+ done()
+ end
+ if not check() then
+ return
+ end
+ if not completed then
+ local remaining = 3600
+ runner.waitingCheck = function()
+ remaining = remaining - 1
+ if remaining <= 0 then
+ token.finish(false, "native movement timed out")
+ end
+ return completed
+ end
+ runner:yield()
+ end
+ end
+ end
+ local function waitAudio(poll, ceiling, continueOnTimeout)
+ if poll() then
+ return
+ end
+ local remaining = ceiling
+ runner.waitingCheck = function()
+ if not check() then
+ return false
+ end
+ if poll() then
+ return true
+ end
+ remaining = remaining - 1
+ if remaining <= 0 then
+ if continueOnTimeout then
+ return true
+ end
+ token.finish(false, "native audio timed out")
+ end
+ return false
+ end
+ runner:yield()
+ end
+ function runner:exec(compiled, ctx)
+ local pc, slice = 1, 0
+ while pc <= #compiled do
+ if not check() then
+ return
+ end
+ if slice >= Program.SLICE then
+ self.waitingFrames = 1
+ self:yield()
+ slice = 0
+ if not check() then
+ return
+ end
+ end
+ token.steps, slice = token.steps + 1, slice + 1
+ if token.steps > Program.MAX_STEPS then
+ error("portable script instruction budget exceeded", 0)
+ end
+ local row = compiled[pc]
+ local verb = row[1]
+ token.step = {}
+ self.lastRow = verb .. " (portable row " .. pc .. ")"
+ ctx.script, ctx.pc = compiled, pc
+ if Fields.ARITY[verb] then
+ fields(ctx, row)
+ elseif DialogueAudio.ARITY[verb] then
+ dialogueAudio.arm(ctx, row)
+ elseif Audio.ARITY[verb] then
+ audio(ctx, row, waitAudio, check)
+ elseif Shops.ARITY[verb] then
+ shop(ctx, row)
+ elseif Battles.ARITY[verb] then
+ battle(ctx, row)
+ elseif Travel.ARITY[verb] then
+ warp(ctx, row)
+ elseif Movement.ARITY[verb] then
+ move(ctx, row)
+ elseif WorldEdits.ARITY[verb] then
+ local value, reason = edit(row, mutate)
+ if not check() then
+ return
+ end
+ if not value then
+ error(reason, 0)
+ end
+ elseif Gifts.ARITY[verb] then
+ if nativeGift(ctx, row) == math.huge then
+ return
+ end
+ elseif State.ARITY[verb] then
+ state(ctx, row)
+ elseif verb == "set_flag" or verb == "clear_flag" then
+ local ok, reason = flag(row[2], verb == "set_flag", true)
+ if not ok then
+ error(reason, 0)
+ end
+ elseif verb == "check_flag" then
+ local value, reason = flag(row[2])
+ if value == nil then
+ error(reason, 0)
+ end
+ ctx.lastCheck = value
+ elseif verb == "wait_flag" then
+ local left = row[3]
+ self.waitingCheck = function()
+ if not check() then
+ return false
+ end
+ local value, reason = flag(row[2])
+ if value == nil then
+ error(reason, 0)
+ end
+ if value then
+ return true, true
+ end
+ if left then
+ left = left - 1
+ if left <= 0 then
+ return true, false
+ end
+ end
+ return false
+ end
+ ctx.lastCheck = self:yield()
+ elseif verb == "wait" then
+ self.waitingFrames = row[2]
+ self:yield()
+ elseif verb == "show_text" or verb == "ask" then
+ dialog(ctx, row[2], verb == "ask", token.step)
+ elseif verb == "choice" then
+ choice(ctx, row[2], row[3], token.step)
+ elseif verb == "end" then
+ return
+ elseif
+ verb == "jump"
+ or verb == "jump_if_true" and ctx.lastCheck
+ or verb == "jump_if_false" and not ctx.lastCheck
+ then
+ pc = row[2] - 1
+ end
+ pc = pc + 1
+ end
+ end
+ local update = Runner.update
+ function runner:resume(...)
+ if not check() or not self.co or coroutine.status(self.co) == "running" then
+ return
+ end
+ local before, step = snapshot(), token.step
+ token.waitingUI = nil
+ local ok, err = coroutine.resume(self.co, ...)
+ discover(before, token.step or step)
+ if not ok then
+ Runtime.reportError(modId, "portable script failed: " .. message(err))
+ token.finish(false, "script failed: " .. message(err))
+ elseif not token.done and coroutine.status(self.co) == "dead" then
+ token.finish(true)
+ end
+ end
+ function runner:update(...)
+ if not check() or game.stack:top() ~= ow then
+ return
+ end
+ local ok, err = engine(update, self, ...)
+ if not ok then
+ token.finish(false, "script update failed: " .. message(err))
+ end
+ end
+ function runner:run()
+ error("portable script owns this runner", 0)
+ end
+ -- Session/argument checks above do not mutate gameplay. Admission starts
+ -- here; subsequent native failures complete through onDone(false, reason).
+ active, runs[game], ow.runner = token, token, runner
+ runner.ctx = runner:makeContext({
+ source = { modId = modId, mapId = ow.map.id, hook = "queueScript", strict = true },
+ })
+ runner.co = coroutine.create(function()
+ runner:exec(program, runner.ctx)
+ end)
+ if Runtime.wants("script.started") then
+ Runtime.emit("script.started", { ctx = runner.ctx })
+ end
+ local ok, err = engine(runner.resume, runner)
+ if not ok then
+ token.finish(false, "script start failed: " .. message(err))
+ end
+ return true
+ end
+ function world:queueScript(rows, options)
+ local ok, result, reason = engine(queue, rows, options)
+ if not ok then
+ return nil, "portable script validation failed"
+ end
+ return result, reason
+ end
+ function world:cancelScript()
+ if not active or active.done then
+ return false, "no portable script"
+ end
+ active.finish(false, "script cancelled")
+ return true
+ end
+ return setmetatable(world, {
+ __index = function(_, key)
+ local value = native()
+ local member = value and value[key]
+ if type(member) == "function" then
+ return function(_, ...)
+ return member(value, ...)
+ end
+ end
+ return member
+ end,
+ })
+end
+function Scripts.wrap(mt, api, loader, manifest)
+ local format = manifest.script_format or "native"
+ api.scriptFormat = format
+ if format ~= "unified" then
+ return mt
+ end
+ local index = mt.__index
+ local world = facade(loader, manifest.id, function()
+ return index(api, "world")
+ end)
+ mt.__index = function(self, key)
+ if key == "world" then
+ return world
+ end
+ return index(self, key)
+ end
+ if api.supports then
+ local supports = api.supports
+ function api:supports(surface, name)
+ if surface == "scripts" then
+ if Program.supports(name) then
+ return true
+ end
+ return false, "unsupported portable script command"
+ end
+ if surface == "world" and (name == "queueScript" or name == "cancelScript") then
+ return true
+ end
+ return supports(self, surface, name)
+ end
+ end
+ return mt
+end
+return Scripts
diff --git a/src/mods/Registry.lua b/src/mods/Registry.lua
index 9719673ef..b1bcb8a8f 100644
--- a/src/mods/Registry.lua
+++ b/src/mods/Registry.lua
@@ -28,11 +28,21 @@ function Registry.new(name, spec)
}, Registry)
end
+local function validId(self,id)
+ return type(id)=='string' and id~=''
+ or self.numericIds and type(id)=='number' and id>=1 and id 0 then
out[#out + 1] = { registry = t.registry, ref = value, path = path }
end
return
@@ -353,7 +354,7 @@ function Schemas.crossValidate(loader, data)
end
end
for name, registry in pairs(loader.content) do
- local spec = registry.spec
+ local spec = registry.referenceSpec or registry.spec
for id in pairs(registry.ops) do
local value = registry:get(id)
if value ~= nil and registry.owners[id] ~= Schemas.ENGINE then
@@ -408,6 +409,8 @@ local R = {}
Schemas.REGISTRIES = R
R.pokemon = {
+ canonicalId=require("src.core.Gen2SpeciesNames").registryId,
+ write=require("src.core.Gen2SpeciesNames").write,
semantics = "record", target = "pokemon",
fields = {
id = f.str, name = f.str, dex = f.int(1),
@@ -415,7 +418,10 @@ R.pokemon = {
types = f.list(f.id("type_chart")),
baseStats = f.rec{ hp = f.int(1, 255), attack = f.int(1, 255),
defense = f.int(1, 255), speed = f.int(1, 255),
- special = f.int(1, 255) },
+ special = f.int(1, 255),
+ -- Native GSC/GBA split stats are valid registry fields.
+ spatk = f.opt(f.int(1, 255)),
+ spdef = f.opt(f.int(1, 255)) },
catchRate = f.int(0, 255), baseExp = f.int(0, 255),
level1Moves = f.list(f.id("moves")),
growthRate = f.id("growth_rates"),
diff --git a/src/mods/ScriptAudio.lua b/src/mods/ScriptAudio.lua
new file mode 100644
index 000000000..b02767bfd
--- /dev/null
+++ b/src/mods/ScriptAudio.lua
@@ -0,0 +1,152 @@
+-- Portable native audio cues, with pure data lookup and bounded waits.
+local Version = require("src.core.GameVersion")
+local A = {
+ ARITY = {
+ play_sound = 2,
+ wait_sound = 1,
+ play_music = 3,
+ stop_music = 1,
+ fade_music = 2,
+ play_once = 2,
+ play_default_music = 1,
+ },
+}
+local function integer(v, max)
+ return type(v) == "number" and v == v and v >= 0 and v <= max and v % 1 == 0
+end
+local function id(v)
+ return type(v) == "string" and #v > 0 and #v <= 256 and not v:find("%c") or integer(v, 65535)
+end
+function A.syntax(verb, row, context)
+ if (context.generation or Version.generation()) > 3 then
+ return nil, "portable audio unavailable for this generation"
+ end
+ local minimum = (verb == "play_music" and 2 or verb == "fade_music" and 1) or A.ARITY[verb]
+ if #row < minimum or #row > A.ARITY[verb] then
+ return nil, "invalid command arity"
+ end
+ local out = { row[1], row[2] }
+ if verb == "fade_music" then
+ if row[2] ~= nil and not integer(row[2], 255) then
+ return nil, "invalid music fade"
+ end
+ elseif verb == "play_sound" or verb == "play_music" or verb == "play_once" then
+ if not id(row[2]) then
+ return nil, "invalid audio id"
+ end
+ end
+ if verb == "play_music" and row[3] ~= nil then
+ if type(row[3]) ~= "table" or getmetatable(row[3]) ~= nil then
+ return nil, "invalid music options"
+ end
+ out[3] = {}
+ for k, v in pairs(row[3]) do
+ if
+ not (
+ k == "keep" and type(v) == "boolean"
+ or k == "reason" and type(v) == "string" and #v <= 256 and not v:find("%c")
+ )
+ then
+ return nil, "invalid music option"
+ end
+ out[3][k] = v
+ end
+ end
+ return out
+end
+function A.resolve(game, row)
+ local verb, ref = row[1], row[2]
+ if Version.generation() == 3 and verb == "play_music" and ref == 0 then
+ return { stop = true }
+ end
+ local audio = game.data.audio or {}
+ local sound = verb == "play_sound"
+ local rows = sound and audio.sfx or audio.songs
+ if rows and (type(rows[ref]) == "table" or type(rows[ref]) == "string") then
+ return { key = ref, sound = sound }
+ end
+ if Version.generation() == 3 and (sound or verb == "play_music" or verb == "play_once") then
+ if verb == "play_once" and ref == "Get_Item1" then
+ local def = audio.sfx and audio.sfx.Get_Item1
+ if type(def) == "table" then
+ ref = def.song or def.m4a or ref
+ end
+ end
+ local key, def = require("src.core.Sound").sequenceKey(game.data, ref)
+ if key then
+ return { key = key, sound = sound, sequence = sound }
+ end
+ if def then
+ return nil, def
+ end
+ end
+ return nil, "native audio unavailable: " .. tostring(row[2])
+end
+function A.prepare(game, rows)
+ for i, row in ipairs(rows) do
+ if row[1] == "play_sound" or row[1] == "play_music" or row[1] == "play_once" then
+ local plan, why = A.resolve(game, row)
+ if not plan then
+ return nil, "row " .. i .. ": " .. why
+ end
+ end
+ end
+ return function(ctx, row, wait, alive)
+ local verb = row[1]
+ local Sound, Music = require("src.core.Sound"), require("src.core.Music")
+ if verb == "stop_music" then
+ Music.stop()
+ elseif verb == "fade_music" then
+ Music.fadeOut(row[2] or 10)
+ elseif verb == "play_default_music" then
+ local ow = ctx.overworld
+ Music.playMap(game.data, ow.map.id, game.save.onBike, ow.player.surfing)
+ elseif verb == "wait_sound" then
+ local source = ctx.lastSfxSrc
+ ctx.lastSfxSrc = nil
+ local function busy()
+ if Sound.anyPlaying("sfx") then
+ return true
+ end
+ if not source then
+ return false
+ end
+ local ok, value = pcall(function()
+ return source:isPlaying()
+ end)
+ return ok and value == true
+ end
+ if busy() then
+ wait(function()
+ return not busy()
+ end, 600, true)
+ end
+ else
+ local plan, why = A.resolve(game, row)
+ if not plan then
+ error(why, 0)
+ end
+ if verb == "play_sound" then
+ if plan.sequence then
+ ctx.lastSfxSrc = Sound.playNamedEffect(game.data, plan.key)
+ else
+ ctx.lastSfxSrc = Sound.play(game.data, plan.key)
+ end
+ elseif verb == "play_music" then
+ if plan.stop then
+ Music.stop()
+ return
+ end
+ Music.play(game.data, plan.key, nil, row[3])
+ if alive() and row[3] and row[3].keep then
+ ctx.overworld.keepMusicOnce = true
+ end
+ elseif Music.playOnce(game.data, plan.key) then
+ wait(function()
+ return Music.current() ~= plan.key or not Music.oneShotPlaying()
+ end, 3600, false)
+ end
+ end
+ end
+end
+return A
diff --git a/src/mods/ScriptBattles.lua b/src/mods/ScriptBattles.lua
new file mode 100644
index 000000000..2dc34dcfa
--- /dev/null
+++ b/src/mods/ScriptBattles.lua
@@ -0,0 +1,190 @@
+-- Portable wild/static battle requests; native battle cleanup owns its lifetime.
+local Ids = require("src.mods.UnifiedIds")
+local Version = require("src.core.GameVersion")
+local B = { ARITY = { start_battle = 4, static_battle = 4, check_battle_result = 5 } }
+local outcomes = { win = true, lose = true, run = true, caught = true }
+local function integer(n, lo, hi)
+ return type(n) == "number" and n == n and n >= lo and n <= hi and n % 1 == 0
+end
+function B.trainer(game, reference, member)
+ local gen = Version.generation()
+ local rows = game.data.trainers or {}
+ local key, found
+ if type(reference) == "number" then
+ if gen == 3 then
+ key = game.data.constants
+ and game.data.constants.trainerOrder
+ and game.data.constants.trainerOrder[reference]
+ else
+ for id, row in pairs(rows) do
+ if type(row) == "table" and row.index == reference then
+ local candidateKey = row.id and rows[row.id] and row.id or id
+ local candidate = rows[candidateKey]
+ if candidate.index ~= reference then
+ return nil, "inconsistent trainer alias"
+ end
+ if found and found ~= candidate then
+ return nil, "ambiguous trainer class"
+ end
+ key, found = candidateKey, candidate
+ end
+ end
+ end
+ else
+ key = reference
+ end
+ local row = key and rows[key]
+ local party = member or 1
+ if gen == 3 and party ~= 1 then
+ return nil, "Gen3 trainer has one party"
+ end
+ local roster = row and row.parties and row.parties[party]
+ if type(roster) ~= "table" or #roster == 0 or #roster > 6 then
+ return nil, "trainer content unavailable: " .. tostring(reference)
+ end
+ for _, mon in ipairs(roster) do
+ if
+ type(mon) ~= "table"
+ or not integer(mon.level, 1, 100)
+ or not (game.data.pokemon and game.data.pokemon[mon.species])
+ then
+ return nil, "invalid native trainer party"
+ end
+ end
+ local nativeIndex = row and row.index
+ if gen == 3 then
+ local order = game.data.constants and game.data.constants.trainerOrder or {}
+ nativeIndex = nil
+ for index, id in pairs(order) do
+ if id == key then
+ if nativeIndex and nativeIndex ~= index then
+ return nil, "ambiguous native trainer id"
+ end
+ nativeIndex = index
+ end
+ end
+ local base = game.data.constants and game.data.constants.gen3TrainerFlagBase
+ if
+ not integer(nativeIndex, 1, 65535)
+ or not integer(base, 0, 65535 - nativeIndex)
+ or game.save.flags ~= nil and type(game.save.flags) ~= "table"
+ then
+ return nil, "native trainer flags unavailable"
+ end
+ end
+ return { key = key, member = party, index = nativeIndex }
+end
+B.nativeTrainer = {
+ resolve = B.trainer,
+ construct = function(game, plan)
+ return require("src.battle.BattleState").newTrainer(game, plan.key, plan.member)
+ end,
+ cleanup = function(game, world, plan, outcome)
+ if Version.generation() == 3 and outcome == "win" then
+ local ctx = { game = game, save = game.save, overworld = world }
+ local commands = require("src.script.Gen3Commands")
+ commands.markTrainerBeaten(ctx, plan.index)
+ commands.registerTrainerInMatchCall(ctx, plan.index)
+ end
+ end,
+}
+function B.syntax(verb, row, context)
+ if (context.generation or Version.generation()) > 3 then
+ return nil, "portable battles unavailable for this generation"
+ end
+ if verb == "check_battle_result" then
+ if #row < 2 or #row > 5 then
+ return nil, "invalid battle result list"
+ end
+ local out = { verb }
+ for i = 2, #row do
+ if not outcomes[row[i]] then
+ return nil, "invalid battle result"
+ end
+ out[i] = row[i]
+ end
+ return out
+ end
+ local species, level = row[2], row[3]
+ if verb == "start_battle" then
+ if row[2] == "trainer" then
+ if
+ #row < 3
+ or #row > 4
+ or not (type(row[3]) == "string" and #row[3] > 0 and #row[3] <= 256 and not row[3]:find("%c") or integer(
+ row[3],
+ 0,
+ 65535
+ ))
+ or row[4] ~= nil and not integer(row[4], 1, 255)
+ then
+ return nil, "invalid script trainer"
+ end
+ return { row[1], row[2], row[3], row[4] }
+ end
+ if #row ~= 4 or row[2] ~= "wild" then
+ return nil, "invalid script battle"
+ end
+ species, level = row[3], row[4]
+ elseif #row < 3 or #row > 4 then
+ return nil, "invalid command arity"
+ end
+ if type(species) ~= "string" or #species == 0 or #species > 256 or species:find("%c") then
+ return nil, "invalid species id"
+ end
+ if type(level) ~= "number" or level ~= level or level < 1 or level > 100 or level % 1 ~= 0 then
+ return nil, "invalid battle level"
+ end
+ return { row[1], row[2], row[3], row[4] }
+end
+function B.prepare(loader, game, world, rows, flag)
+ local ids = Ids.new(loader)
+ local talking = world.talkNpc or world.runner and world.runner.ctx and world.runner.ctx.npc
+ for i, row in ipairs(rows) do
+ local verb = row[1]
+ if verb == "warp" then
+ talking = nil
+ end
+ if verb == "start_battle" or verb == "static_battle" then
+ if verb == "start_battle" and row[2] == "trainer" then
+ local plan, why = B.trainer(game, row[3], row[4])
+ if not plan then
+ return nil, "row " .. i .. ": " .. why
+ end
+ else
+ local species = verb == "start_battle" and row[3] or row[2]
+ local group, why = ids:resolve("pokemon", species)
+ if not group or not (game.data.pokemon and game.data.pokemon[group.key]) then
+ return nil, "row " .. i .. ": " .. (why or "unknown species")
+ end
+ end
+ if type(world.pushBattle) ~= "function" or type(world.afterBattle) ~= "function" then
+ return nil, "battle runtime unavailable"
+ end
+ if verb == "static_battle" and talking then
+ local def = talking.def
+ local ref = def and (def.name or def.localId or def.index or def.id)
+ if ref then
+ local edit, reason = require("src.mods.ScriptWorldEdits").prepare(
+ game,
+ world,
+ "",
+ { { "hide_object", world.map.id, ref } }
+ )
+ if not edit then
+ return nil, "row " .. i .. ": " .. reason
+ end
+ end
+ end
+ if verb == "static_battle" and row[4] ~= nil then
+ local value, reason = flag(row[4])
+ if value == nil then
+ return nil, "row " .. i .. ": " .. reason
+ end
+ end
+ end
+ end
+ return true
+end
+B.outcomes = outcomes
+return B
diff --git a/src/mods/ScriptDialogueAudio.lua b/src/mods/ScriptDialogueAudio.lua
new file mode 100644
index 000000000..ce8fde2a1
--- /dev/null
+++ b/src/mods/ScriptDialogueAudio.lua
@@ -0,0 +1,165 @@
+-- Deferred, owned dialogue cues. Native TextBox supplies its 600-frame gate.
+local Audio = require("src.mods.ScriptAudio")
+local Version = require("src.core.GameVersion")
+local D = { ARITY = { play_cry = 3, text_sound = 2, engage_music = 2 } }
+function D.syntax(verb, row, context)
+ if (context.generation or Version.generation()) > 3 then
+ return nil, "portable dialogue audio unavailable for this generation"
+ end
+ if #row < 2 or #row > D.ARITY[verb] then
+ return nil, "invalid command arity"
+ end
+ if verb == "play_cry" then
+ if
+ type(row[2]) ~= "string"
+ or not row[2]:match("^[A-Z][A-Z0-9_]*$")
+ or #row[2] > 128
+ or row[3] ~= nil and type(row[3]) ~= "boolean"
+ then
+ return nil, "invalid cry"
+ end
+ return { verb, row[2], row[3] }
+ end
+ local out, why = Audio.syntax(
+ verb == "text_sound" and "play_sound" or "play_music",
+ { verb == "text_sound" and "play_sound" or "play_music", row[2] },
+ context
+ )
+ if not out then
+ return nil, why
+ end
+ return { verb, row[2] }
+end
+function D.prepare(loader, game, rows)
+ local ids = require("src.mods.UnifiedIds").new(loader)
+ local function resolve(row)
+ if row[1] ~= "play_cry" then
+ return Audio.resolve(game, { row[1] == "text_sound" and "play_sound" or "play_music", row[2] })
+ end
+ local group, why = ids:resolve("pokemon", row[2])
+ local mon = group and game.data.pokemon and game.data.pokemon[group.key]
+ if not mon then
+ return nil, why or "cry species unavailable"
+ end
+ local audio = game.data.audio or {}
+ local def = audio.cries
+ and (
+ audio.cries[group.key]
+ or type(group.key) == "number" and audio.cries[mon.translationSourceName or mon.name]
+ )
+ local pika = Version.get() == "yellow"
+ and group.key == "PIKACHU"
+ and type(audio.pikaCries) == "table"
+ and audio.pikaCries[1]
+ if type(def) ~= "table" and type(def) ~= "string" and not pika then
+ return nil, "native cry unavailable: " .. row[2]
+ end
+ return { key = group.key, cry = true }
+ end
+ for i, row in ipairs(rows) do
+ if D.ARITY[row[1]] then
+ local plan, why = resolve(row)
+ if not plan then
+ return nil, "row " .. i .. ": " .. why
+ end
+ end
+ end
+ local function checked(row)
+ local plan, why = resolve(row)
+ if not plan then
+ error(why, 0)
+ end
+ return plan
+ end
+ local function arm(ctx, row)
+ checked(row)
+ ctx.dialogueAudio = ctx.dialogueAudio or {}
+ ctx.dialogueAudio[row[1]] = row
+ end
+ local function options(ctx, asking, alive)
+ local cues = ctx.dialogueAudio
+ ctx.dialogueAudio = nil
+ if not cues then
+ return nil
+ end
+ for _, row in pairs(cues) do
+ checked(row)
+ end
+ local Sound, Music = require("src.core.Sound"), require("src.core.Music")
+ local sources, started, engaged, waited = {}, false, false, 0
+ -- Throw only after a callback entered alive: abort native update before it
+ -- can pop a foreign overlay installed by a reentrant audio hook.
+ local function stillAlive()
+ if not alive() then
+ error("dialogue audio owner retired", 0)
+ end
+ end
+ local function busy()
+ if not alive() then
+ return false
+ end
+ local playing = false
+ for _, src in ipairs(sources) do
+ local ok, value = pcall(function()
+ return src:isPlaying()
+ end)
+ stillAlive()
+ playing = playing or ok and value == true
+ end
+ return playing
+ end
+ local function sound()
+ if started or not alive() then
+ return nil
+ end
+ started = true
+ for _, verb in ipairs({ "play_cry", "text_sound" }) do
+ if cues[verb] then
+ local p = checked(cues[verb])
+ local src
+ if p.cry then
+ src = Sound.playCry(game.data, p.key)
+ elseif p.sequence then
+ src = Sound.playNamedEffect(game.data, p.key)
+ else
+ src = Sound.play(game.data, p.key)
+ end
+ stillAlive()
+ if src and p.sequence then
+ Music.duckForFanfare(src)
+ stillAlive()
+ end
+ if src then
+ sources[#sources + 1] = src
+ end
+ end
+ end
+ return { isPlaying = busy }
+ end
+ local function tick()
+ if engaged or not alive() then
+ return
+ end
+ waited = waited + 1
+ if busy() and waited < 600 then
+ return
+ end
+ engaged = true
+ if cues.engage_music then
+ local p = checked(cues.engage_music)
+ if p.stop then
+ Music.stop()
+ else
+ Music.play(game.data, p.key)
+ end
+ stillAlive()
+ end
+ end
+ local cry = cues.play_cry
+ return {
+ auto = { sound = sound, tick = tick, delay = 0, wait = asking or not cry or cry[3] == true },
+ }
+ end
+ return { arm = arm, options = options }
+end
+return D
diff --git a/src/mods/ScriptFields.lua b/src/mods/ScriptFields.lua
new file mode 100644
index 000000000..ade27470a
--- /dev/null
+++ b/src/mods/ScriptFields.lua
@@ -0,0 +1,127 @@
+-- Data-only field writes; no executable values or namespace escape.
+local F = { ARITY = { set_field = 3, set_option = 3 } }
+local function copy(value, state, depth)
+ state.nodes = state.nodes + 1
+ if state.nodes > 1024 or depth > 16 then
+ error("script field data limit", 0)
+ end
+ local kind = type(value)
+ if kind == "nil" or kind == "boolean" then
+ return value
+ end
+ if kind == "number" then
+ if value ~= value or value == math.huge or value == -math.huge then
+ error("invalid field number", 0)
+ end
+ return value
+ end
+ if kind == "string" then
+ state.bytes = state.bytes + #value
+ if state.bytes > 32768 then
+ error("script field string limit", 0)
+ end
+ return value
+ end
+ if kind ~= "table" or getmetatable(value) ~= nil or state.seen[value] then
+ error("script fields require acyclic plain data", 0)
+ end
+ state.seen[value] = true
+ local out = {}
+ for k, v in pairs(value) do
+ if type(k) ~= "string" and not (type(k) == "number" and k >= 1 and k % 1 == 0 and k < math.huge) then
+ error("invalid field data key", 0)
+ end
+ local key = copy(k, state, depth + 1)
+ out[key] = copy(v, state, depth + 1)
+ end
+ state.seen[value] = nil
+ return out
+end
+local function snapshot(value)
+ local state = { nodes = 0, bytes = 0, seen = {} }
+ return copy(value, state, 0), state
+end
+function F.syntax(verb, row, context)
+ if (context.generation or require("src.core.GameVersion").generation()) > 3 then
+ return nil, "portable fields unavailable for this generation"
+ end
+ local key = row[2]
+ if
+ #row < 2
+ or #row > 3
+ or type(key) ~= "string"
+ or #key == 0
+ or #key > 256
+ or key:find("%c")
+ or key:match("^mod%.")
+ or key == "mod:"
+ or key:find(":", 1, true) and not key:match("^mod:.+")
+ or verb == "set_field" and key == "modData"
+ then
+ return nil, "invalid or foreign script field"
+ end
+ local ok, value = pcall(snapshot, row[3])
+ if not ok then
+ return nil, value
+ end
+ return { verb, key, value }
+end
+local function plain(value)
+ return value == nil or type(value) == "table" and getmetatable(value) == nil
+end
+function F.prepare(game, owner, rows)
+ local bytes, nodes = 0, 0
+ local options = game.save.options
+ local function stores(row, plannedOptions)
+ if row[1] == "set_option" then
+ if not plain(plannedOptions) then
+ return nil, "invalid options store"
+ end
+ elseif row[2]:match("^mod:") then
+ local data = game.save.modData
+ if not plain(data) or data and not plain(data[owner]) then
+ return nil, "invalid mod save store"
+ end
+ end
+ return true
+ end
+ for i, row in ipairs(rows) do
+ if F.ARITY[row[1]] then
+ local _, size = snapshot(row[3])
+ bytes, nodes = bytes + size.bytes, nodes + size.nodes
+ if bytes > 131072 or nodes > 8192 then
+ return nil, "aggregate script field data limit"
+ end
+ local ok, why = stores(row, options)
+ if not ok then
+ return nil, "row " .. i .. ": " .. why
+ end
+ if row[1] == "set_field" and row[2] == "options" then
+ options = row[3]
+ end
+ end
+ end
+ return function(_, row)
+ local ok, why = stores(row, game.save.options)
+ if not ok then
+ error(why, 0)
+ end
+ -- Copy again on each dispatch: loops and post-run save mutations never
+ -- expose or mutate the compiled program's snapshot.
+ local value = snapshot(row[3])
+ if row[1] == "set_option" then
+ game.save.options = game.save.options or {}
+ game.save.options[row[2]] = value
+ else
+ local private = row[2]:match("^mod:(.+)$")
+ if private then
+ game.save.modData = game.save.modData or {}
+ game.save.modData[owner] = game.save.modData[owner] or {}
+ game.save.modData[owner][private] = value
+ else
+ game.save[row[2]] = value
+ end
+ end
+ end
+end
+return F
diff --git a/src/mods/ScriptGifts.lua b/src/mods/ScriptGifts.lua
new file mode 100644
index 000000000..63b4b535d
--- /dev/null
+++ b/src/mods/ScriptGifts.lua
@@ -0,0 +1,135 @@
+-- Closed portable gift rows; execute through native gift/bag/party/box commands.
+local Ids = require("src.mods.UnifiedIds")
+local G = { ARITY = { give_item = 5, give_pokemon = 5 } }
+local function integer(n, lo, hi)
+ return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi
+end
+local function id(s)
+ return type(s) == "string" and #s > 0 and #s <= 256 and not s:find("%c")
+end
+local function text(s)
+ return type(s) == "string" and #s <= 4096 and not s:find("%z")
+end
+function G.syntax(verb, row)
+ if not id(row[2]) then
+ return nil, "invalid gift id"
+ end
+ if verb == "give_item" then
+ if #row < 2 or #row > 5 then
+ return nil, "invalid command arity"
+ end
+ if row[3] ~= nil and not integer(row[3], 1, 999) then
+ return nil, "invalid item count"
+ end
+ if row[4] ~= nil and row[4] ~= false and not text(row[4]) then
+ return nil, "invalid gift text"
+ end
+ if row[5] ~= nil and not text(row[5]) then
+ return nil, "invalid bag full text"
+ end
+ else
+ if #row < 3 or #row > 5 then
+ return nil, "invalid command arity"
+ end
+ if not integer(row[3], 1, 100) then
+ return nil, "invalid gift level"
+ end
+ if row[4] ~= nil and type(row[4]) ~= "boolean" or row[5] ~= nil and type(row[5]) ~= "boolean" then
+ return nil, "invalid gift option"
+ end
+ end
+ return { row[1], row[2], row[3], row[4], row[5] }
+end
+function G.prepare(loader, game, world, rows)
+ local ids = Ids.new(loader)
+ local plans = {}
+ local function validate(row)
+ if G.ARITY[row[1]] then
+ local kind = row[1] == "give_item" and "items" or "pokemon"
+ local group, reason = ids:resolve(kind, row[2])
+ if not group or not (game.data[kind] and game.data[kind][group.key]) then
+ return nil, reason or "gift content unavailable"
+ end
+ local save = game.save
+ if row[1] == "give_item" then
+ if type(save.inventory) ~= "table" or save.bagOrder ~= nil and type(save.bagOrder) ~= "table" then
+ return nil, "invalid inventory store"
+ end
+ for _, count in pairs(save.inventory) do
+ if not integer(count, 0, 999999) then
+ return nil, "invalid inventory count"
+ end
+ end
+ elseif
+ type(save.party) ~= "table"
+ or type(save.pokedex) ~= "table"
+ or type(save.pokedex.seen) ~= "table"
+ or type(save.pokedex.owned) ~= "table"
+ then
+ return nil, "invalid gift party/dex store"
+ end
+ if row[1] == "give_pokemon" then
+ if type(save.player) ~= "table" then
+ return nil, "invalid gift player store"
+ end
+ if #save.party > 6 then
+ return nil, "invalid gift party"
+ end
+ for index, mon in pairs(save.party) do
+ if not integer(index, 1, #save.party) or type(mon) ~= "table" then
+ return nil, "invalid gift party"
+ end
+ end
+ for index = 1, #save.party do
+ if type(save.party[index]) ~= "table" then
+ return nil, "invalid gift party"
+ end
+ end
+ for _, name in ipairs({ "boxes", "box", "boxNames" }) do
+ if save[name] ~= nil and type(save[name]) ~= "table" then
+ return nil, "invalid box store"
+ end
+ end
+ if save.currentBox ~= nil and not integer(save.currentBox, 1, 65535) then
+ return nil, "invalid box index"
+ end
+ for _, box in pairs(save.boxes or {}) do
+ if type(box) ~= "table" then
+ return nil, "invalid box store"
+ end
+ end
+ end
+ return group.key
+ end
+ end
+ for i, row in ipairs(rows) do
+ if G.ARITY[row[1]] then
+ local key, reason = validate(row)
+ if not key then
+ return nil, "row " .. i .. ": " .. reason
+ end
+ plans[row] = key
+ end
+ end
+ return function(ctx, row)
+ local key = plans[row]
+ if not key then
+ error("unprepared gift", 0)
+ end
+ if not game.data[row[1] == "give_item" and "items" or "pokemon"][key] then
+ error("gift content unavailable", 0)
+ end
+ local ready, reason = validate(row)
+ if not ready then
+ error(reason, 0)
+ end
+ local Commands = require("src.script.Commands")
+ if row[1] == "give_item" then
+ ctx.portableBagFullText = row[5]
+ return Commands.give_item(ctx, key, row[3], row[4])
+ end
+ ctx.portableGiftText = row[5]
+ return Commands.give_pokemon(ctx, key, row[3], row[4])
+ end
+end
+return G
diff --git a/src/mods/ScriptMovement.lua b/src/mods/ScriptMovement.lua
new file mode 100644
index 000000000..a459f9ee7
--- /dev/null
+++ b/src/mods/ScriptMovement.lua
@@ -0,0 +1,184 @@
+-- Closed movement vocabulary over this host's native actor/track services.
+local Version = require("src.core.GameVersion")
+local unpack = table.unpack or unpack
+local M = {
+ ARITY = {
+ move_player = 3,
+ move_npc = 4,
+ move_npc_to = 4,
+ place_npc = 5,
+ face = 2,
+ face_object = 4,
+ face_npc = 1,
+ face_player = 1,
+ face_player_dir = 2,
+ },
+}
+local directions = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
+local function integer(v, max)
+ return type(v) == "number" and v == v and v >= 0 and v <= max and v % 1 == 0
+end
+local function ref(v)
+ return type(v) == "string" and #v > 0 and #v <= 256 and not v:find("%c") or integer(v, 1000000)
+end
+function M.syntax(verb, row, context)
+ if (context.generation or Version.generation()) > 3 then
+ return nil, "portable movement unavailable for this generation"
+ end
+ local n = #row
+ local minimum = (
+ verb == "move_player" and 2
+ or verb == "move_npc" and 3
+ or verb == "place_npc" and 4
+ or verb == "face_object" and 3
+ ) or M.ARITY[verb]
+ if n < minimum or n > M.ARITY[verb] then
+ return nil, "invalid command arity"
+ end
+ if verb == "move_player" or verb == "move_npc" then
+ local at = verb == "move_player" and 2 or 3
+ if
+ not directions[row[at]]
+ or row[at + 1] ~= nil and not integer(row[at + 1], 1024)
+ or at == 3 and not ref(row[2])
+ then
+ return nil, "invalid script movement"
+ end
+ elseif verb == "place_npc" or verb == "move_npc_to" then
+ if
+ not ref(row[2])
+ or not integer(row[3], 1000000)
+ or not integer(row[4], 1000000)
+ or row[5] ~= nil and not directions[row[5]]
+ then
+ return nil, "invalid NPC position"
+ end
+ elseif verb == "face_object" then
+ if not ref(row[2]) or not directions[row[3]] then
+ return nil, "invalid object facing"
+ end
+ elseif verb == "face" or verb == "face_player_dir" then
+ if not directions[row[2]] then
+ return nil, "invalid facing"
+ end
+ end
+ local out = { row[1], row[2], row[3], row[4], row[5] }
+ if verb == "face_object" and row[4] ~= nil then
+ if type(row[4]) ~= "table" or getmetatable(row[4]) ~= nil then
+ return nil, "invalid facing options"
+ end
+ out[4] = {}
+ for k, v in pairs(row[4]) do
+ if k ~= "hold" or not integer(v, 36000) then
+ return nil, "invalid facing options"
+ end
+ out[4][k] = v
+ end
+ end
+ return out
+end
+function M.actor(world, reference)
+ if reference == "player" then
+ return world.player
+ end
+ for _, actor in ipairs(world.npcs or {}) do
+ local def = actor.def or {}
+ if
+ actor.id == reference
+ or def.name == reference
+ or def.id == reference
+ or def.index == reference
+ or def.localId == reference
+ then
+ return actor
+ end
+ end
+end
+function M.prepare(world, rows, prevalidated)
+ local npc = world.talkNpc or world.runner and world.runner.ctx and world.runner.ctx.npc
+ local function target(row)
+ local verb = row[1]
+ if verb == "move_player" or verb == "face_player_dir" or verb == "face_npc" then
+ return world.player
+ end
+ if verb == "face" or verb == "face_player" then
+ return npc
+ end
+ return M.actor(world, row[2])
+ end
+ for i, row in ipairs(rows) do
+ if not prevalidated and M.ARITY[row[1]] then
+ local actor = target(row)
+ if not actor then
+ return nil, "row " .. i .. ": native movement target unavailable"
+ end
+ if (row[1] == "face_npc" or row[1] == "face_player") and not npc then
+ return nil, "row " .. i .. ": script NPC unavailable"
+ end
+ if not integer(actor.cellX, 1000000) or not integer(actor.cellY, 1000000) then
+ return nil, "invalid actor position"
+ end
+ if row[1]:match("^move_") and type(world.scriptMove) ~= "function" then
+ return nil, "native movement unavailable"
+ end
+ if
+ row[1] == "move_npc_to"
+ and (type(world.map.inBounds) ~= "function" or type(world.map.isWalkableCell) ~= "function")
+ then
+ return nil, "native path unavailable"
+ end
+ end
+ end
+ return {
+ target = target,
+ npc = npc,
+ forgetNpc = function()
+ npc = nil
+ end,
+ }
+end
+function M.path(world, actor, tx, ty)
+ -- The native Gen1 command routes around occupied cells; later generations
+ -- use their walkable grid. Bound the search independently of map dimensions.
+ local map, gen = world.map, Version.generation()
+ local function key(x, y)
+ return x .. ":" .. y
+ end
+ local queue, first = { { actor.cellX, actor.cellY } }, 1
+ local prev = { [key(actor.cellX, actor.cellY)] = false }
+ local dirs = { "up", "down", "left", "right" }
+ while queue[first] and first <= 16384 do
+ local x, y = unpack(queue[first])
+ first = first + 1
+ if x == tx and y == ty then
+ local path, at = {}, key(x, y)
+ while prev[at] do
+ table.insert(path, 1, prev[at][2])
+ at = prev[at][1]
+ end
+ return path
+ end
+ for _, dir in ipairs(dirs) do
+ local d = directions[dir]
+ local nx, ny = x + d[1], y + d[2]
+ local k = key(nx, ny)
+ local goal = nx == tx and ny == ty
+ if
+ prev[k] == nil
+ and map:inBounds(nx, ny)
+ and (map:isWalkableCell(nx, ny) or gen == 1 and goal)
+ and (
+ gen ~= 1
+ or goal
+ or not require("src.world.Collision").occupied(world.entities or {}, nx, ny, actor)
+ )
+ then
+ prev[k] = { key(x, y), dir }
+ queue[#queue + 1] = { nx, ny }
+ end
+ end
+ end
+ return nil, "native movement path unavailable"
+end
+M.directions = directions
+return M
diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua
new file mode 100644
index 000000000..0bcc7fdaf
--- /dev/null
+++ b/src/mods/ScriptProgram.lua
@@ -0,0 +1,320 @@
+-- Pure preflight for the bounded portable dialogue/control-flow language.
+local Program = {}
+local State = require("src.mods.ScriptState")
+local Gifts = require("src.mods.ScriptGifts")
+local Audio = require("src.mods.ScriptAudio")
+local Fields = require("src.mods.ScriptFields")
+local DialogueAudio = require("src.mods.ScriptDialogueAudio")
+local Shops = require("src.mods.ScriptShops")
+local Battles = require("src.mods.ScriptBattles")
+local Travel = require("src.mods.ScriptTravel")
+local Movement = require("src.mods.ScriptMovement")
+local WorldEdits = require("src.mods.ScriptWorldEdits")
+Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128
+local ARITY = {
+ set_flag = 2,
+ clear_flag = 2,
+ check_flag = 2,
+ wait_flag = 3,
+ show_text = 2,
+ ask = 2,
+ choice = 3,
+ wait = 2,
+ label = 2,
+ jump = 2,
+ jump_if_true = 2,
+ jump_if_false = 2,
+ ["end"] = 1,
+}
+for verb, arity in pairs(State.ARITY) do
+ ARITY[verb] = arity
+end
+for verb, arity in pairs(Gifts.ARITY) do
+ ARITY[verb] = arity
+end
+for verb, arity in pairs(WorldEdits.ARITY) do
+ ARITY[verb] = arity
+end
+for verb, arity in pairs(Movement.ARITY) do
+ ARITY[verb] = arity
+end
+for verb, arity in pairs(Travel.ARITY) do
+ ARITY[verb] = arity
+end
+for verb, arity in pairs(Battles.ARITY) do
+ ARITY[verb] = arity
+end
+for verb, arity in pairs(Shops.ARITY) do
+ ARITY[verb] = arity
+end
+for verb, arity in pairs(Audio.ARITY) do
+ ARITY[verb] = arity
+end
+for verb, arity in pairs(DialogueAudio.ARITY) do
+ ARITY[verb] = arity
+end
+for verb, arity in pairs(Fields.ARITY) do
+ ARITY[verb] = arity
+end
+local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" }
+function Program.supports(verb)
+ if type(verb) ~= "string" then
+ return false
+ end
+ if
+ (
+ WorldEdits.ARITY[verb]
+ or Movement.ARITY[verb]
+ or Travel.ARITY[verb]
+ or Battles.ARITY[verb]
+ or Shops.ARITY[verb]
+ or Audio.ARITY[verb]
+ or DialogueAudio.ARITY[verb]
+ or Fields.ARITY[verb]
+ ) and require("src.core.GameVersion").generation() > 3
+ then
+ return false
+ end
+ return ARITY[aliases[verb] or verb] ~= nil
+end
+local function integer(n, lo, hi)
+ return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi
+end
+local function array(t, max)
+ if type(t) ~= "table" or getmetatable(t) ~= nil then
+ return nil
+ end
+ local n = 0
+ for k in pairs(t) do
+ if not integer(k, 1, max) then
+ return nil
+ end
+ n = n + 1
+ end
+ for i = 1, n do
+ if rawget(t, i) == nil then
+ return nil
+ end
+ end
+ return n
+end
+local function label(s)
+ return type(s) == "string" and #s > 0 and #s <= 64 and s:match("^[%w_:%-]+$") ~= nil
+end
+local function text(value, max)
+ if type(value) ~= "string" or #value > max or value:find("%z") then
+ return false
+ end
+ local i = 1
+ while i <= #value do
+ local b = value:byte(i)
+ local n = b < 128 and 1
+ or b >= 194 and b <= 223 and 2
+ or b >= 224 and b <= 239 and 3
+ or b >= 240 and b <= 244 and 4
+ if not n or i + n - 1 > #value then
+ return false
+ end
+ for j = i + 1, i + n - 1 do
+ local c = value:byte(j)
+ if c < 128 or c > 191 then
+ return false
+ end
+ end
+ if n > 1 then
+ local c = value:byte(i + 1)
+ if b == 224 and c < 160 or b == 237 and c > 159 or b == 240 and c < 144 or b == 244 and c > 143 then
+ return false
+ end
+ end
+ i = i + n
+ end
+ return true
+end
+function Program.compile(raw, context)
+ context = context or {}
+ local n = array(raw, Program.MAX_ROWS)
+ if not n then
+ return nil, "script must be a dense plain array of at most 1024 rows"
+ end
+ local rows, labels, bytes = {}, {}, 0
+ local function fail(i, message)
+ return nil, "row " .. i .. ": " .. message
+ end
+ for i = 1, n do
+ local source, count = raw[i], array(raw[i], 5)
+ if not count or count == 0 then
+ return fail(i, "expected a dense plain command row")
+ end
+ local verb = aliases[source[1]] or source[1]
+ if type(verb) ~= "string" or not ARITY[verb] then
+ return fail(i, "unsupported portable command")
+ end
+ if
+ not State.ARITY[verb]
+ and not Gifts.ARITY[verb]
+ and not WorldEdits.ARITY[verb]
+ and not Movement.ARITY[verb]
+ and not Travel.ARITY[verb]
+ and not Battles.ARITY[verb]
+ and not Shops.ARITY[verb]
+ and not Audio.ARITY[verb]
+ and not DialogueAudio.ARITY[verb]
+ and not Fields.ARITY[verb]
+ and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb]))
+ then
+ return fail(i, "invalid command arity")
+ end
+ local row = { verb, source[2] }
+ if Fields.ARITY[verb] then
+ local value, reason = Fields.syntax(verb, source, context)
+ if not value then
+ return fail(i, reason)
+ end
+ row = value
+ elseif DialogueAudio.ARITY[verb] then
+ local value, reason = DialogueAudio.syntax(verb, source, context)
+ if not value then
+ return fail(i, reason)
+ end
+ row = value
+ elseif Audio.ARITY[verb] then
+ local value, reason = Audio.syntax(verb, source, context)
+ if not value then
+ return fail(i, reason)
+ end
+ row = value
+ elseif Shops.ARITY[verb] then
+ local value, reason = Shops.syntax(verb, source, context)
+ if not value then
+ return fail(i, reason)
+ end
+ row = value
+ elseif Battles.ARITY[verb] then
+ local value, reason = Battles.syntax(verb, source, context)
+ if not value then
+ return fail(i, reason)
+ end
+ row = value
+ elseif Travel.ARITY[verb] then
+ local value, reason = Travel.syntax(verb, source, context)
+ if not value then
+ return fail(i, reason)
+ end
+ row = value
+ elseif Movement.ARITY[verb] then
+ local value, reason = Movement.syntax(verb, source, context)
+ if not value then
+ return fail(i, reason)
+ end
+ row = value
+ elseif WorldEdits.ARITY[verb] then
+ local value, reason = WorldEdits.syntax(verb, source, context)
+ if not value then
+ return fail(i, reason)
+ end
+ row = value
+ elseif Gifts.ARITY[verb] then
+ local value, reason = Gifts.syntax(verb, source)
+ if not value then
+ return fail(i, reason)
+ end
+ row = value
+ for _, index in ipairs({ 4, 5 }) do
+ if type(row[index]) == "string" then
+ if not text(row[index], 4096) then
+ return fail(i, "gift text must be valid UTF-8 without NUL")
+ end
+ bytes = bytes + #row[index]
+ end
+ end
+ elseif State.ARITY[verb] then
+ local value, reason = State.syntax(verb, source)
+ if not value then
+ return fail(i, reason)
+ end
+ row = value
+ elseif verb == "set_flag" or verb == "clear_flag" or verb == "check_flag" or verb == "wait_flag" then
+ local Version = require("src.core.GameVersion")
+ local address, reason = require("src.mods.Api3Flags").address(
+ context.owner or "",
+ context.generation or Version.generation(),
+ context.version or Version.get(),
+ source[2]
+ )
+ if not address then
+ return fail(i, reason)
+ end
+ if verb == "wait_flag" then
+ if source[3] ~= nil and not integer(source[3], 0, 36000) then
+ return fail(i, "invalid flag timeout")
+ end
+ row[3] = source[3]
+ end
+ elseif verb == "show_text" or verb == "ask" then
+ if not text(source[2], 4096) then
+ return fail(i, "text must be valid UTF-8 without NUL, at most 4096 bytes")
+ end
+ bytes = bytes + #source[2]
+ elseif verb == "choice" then
+ local size = array(source[2], 16)
+ if not size or size == 0 then
+ return fail(i, "choice needs 1-16 labels")
+ end
+ row[2] = {}
+ for j = 1, size do
+ local value = source[2][j]
+ if not text(value, 128) or #value == 0 then
+ return fail(i, "invalid choice label")
+ end
+ row[2][j], bytes = value, bytes + #value
+ end
+ local opts = source[3]
+ row[3] = { default = 1, cancel = size }
+ if opts ~= nil then
+ if type(opts) ~= "table" or getmetatable(opts) ~= nil then
+ return fail(i, "choice options must be a plain table")
+ end
+ for key, value in pairs(opts) do
+ if (key ~= "default" and key ~= "cancel") or not integer(value, 1, size) then
+ return fail(i, "invalid choice option")
+ end
+ row[3][key] = value
+ end
+ end
+ elseif verb == "wait" then
+ if not integer(source[2], 0, 36000) then
+ return fail(i, "wait must be 0-36000 native logic frames")
+ end
+ elseif verb == "label" then
+ if not label(source[2]) or source[2] == "end" or labels[source[2]] then
+ return fail(i, "invalid or duplicate label")
+ end
+ labels[source[2]] = i
+ elseif verb ~= "end" then
+ if not label(source[2]) and not integer(source[2], 1, n) then
+ return fail(i, "invalid jump target")
+ end
+ end
+ if bytes > 131072 then
+ return fail(i, "script text exceeds 128 KiB")
+ end
+ rows[i] = row
+ end
+ for i, row in ipairs(rows) do
+ if row[1] == "jump" or row[1] == "jump_if_true" or row[1] == "jump_if_false" then
+ local target = row[2]
+ if type(target) == "string" then
+ if target == "end" then
+ row[2] = n + 1
+ elseif labels[target] then
+ row[2] = labels[target]
+ else
+ return fail(i, "jump to missing label")
+ end
+ end
+ end
+ end
+ return rows
+end
+return Program
diff --git a/src/mods/ScriptShopUI.lua b/src/mods/ScriptShopUI.lua
new file mode 100644
index 000000000..5ed099c32
--- /dev/null
+++ b/src/mods/ScriptShopUI.lua
@@ -0,0 +1,61 @@
+-- Callback leases for the native mart's menu, quantity and confirmation phases.
+-- Only explicit UI fields are visited, never a state's Game/save/data graph.
+local U = {}
+local methods = { "select", "choose", "cancel", "close", "buy", "sell", "askQuantity", "askQuantityInput" }
+local callbacks =
+ { onDone = true, onChoose = true, onCancel = true, onQuit = true, onSelect = true, choice = true }
+function U.new(control)
+ local wrapped = setmetatable({}, { __mode = "k" })
+ local function bind(state, holder, key, once, phase)
+ local original = holder[key]
+ if type(original) ~= "function" or wrapped[original] then
+ return
+ end
+ local fired = false
+ local guarded
+ guarded = function(...)
+ if fired or holder[key] ~= guarded or not control.alive(state) or phase and not phase() then
+ return
+ end
+ if once then
+ fired = true
+ end
+ return control.invoke(state, original, ...)
+ end
+ holder[key] = guarded
+ wrapped[guarded] = true
+ end
+ local function attach(state)
+ for key in pairs(callbacks) do
+ if rawget(state, key) ~= nil then
+ local once = key == "onDone"
+ or key == "choice"
+ or key == "onChoose" and getmetatable(state) == require("src.ui.ChoiceBox")
+ bind(state, state, key, once)
+ end
+ end
+ for _, key in ipairs(methods) do
+ bind(state, state, key, false)
+ end
+ for _, item in ipairs(state.items or {}) do
+ if type(item) == "table" then
+ bind(state, item, "onSelect", false, function()
+ for _, current in ipairs(state.items or {}) do
+ if current == item then
+ return true
+ end
+ end
+ return false
+ end)
+ end
+ end
+ local asking = state.asking
+ if type(asking) == "table" then
+ bind(state, asking, "onDone", true, function()
+ return state.asking == asking or control.phase(state) == asking
+ end)
+ end
+ end
+ return attach
+end
+return U
diff --git a/src/mods/ScriptShops.lua b/src/mods/ScriptShops.lua
new file mode 100644
index 000000000..f026c2cae
--- /dev/null
+++ b/src/mods/ScriptShops.lua
@@ -0,0 +1,121 @@
+-- Native mart content and save validation; no live UI is created by preflight.
+local Ids = require("src.mods.UnifiedIds")
+local Version = require("src.core.GameVersion")
+local S = { ARITY = { open_mart = 2 } }
+local function integer(n, max)
+ return type(n) == "number" and n == n and n >= 0 and n <= max and n % 1 == 0
+end
+function S.syntax(_, row, context)
+ if (context.generation or Version.generation()) > 3 then
+ return nil, "portable shops unavailable for this generation"
+ end
+ local ref = row[2]
+ if
+ #row ~= 2
+ or not (type(ref) == "string" and #ref > 0 and #ref <= 256 and not ref:find("%c") or integer(ref, 65535))
+ then
+ return nil, "invalid mart text constant"
+ end
+ return { row[1], ref }
+end
+local function stores(game)
+ local save = game.save
+ if
+ not integer(save.money, 9007199254740991)
+ or type(save.inventory) ~= "table"
+ or save.bagOrder ~= nil and type(save.bagOrder) ~= "table"
+ then
+ return nil, "invalid shop wallet/inventory"
+ end
+ local total, room = 0, 9007199254740991 - save.money
+ for id, count in pairs(save.inventory) do
+ total = total + 1
+ if total > 4096 then
+ return nil, "shop inventory too large"
+ end
+ if not integer(count, 999999) then
+ return nil, "invalid inventory count"
+ end
+ local item = game.data.items and game.data.items[id]
+ if
+ item
+ and not item.keyItem
+ and not require("src.inventory.ItemEffects").alias(id, item):find("^HM_")
+ then
+ if not integer(item.price, 999999) then
+ return nil, "invalid inventory item price"
+ end
+ local value = math.floor(item.price / 2) * count
+ if value > room then
+ return nil, "shop money overflow"
+ end
+ room = room - value
+ end
+ end
+ return true
+end
+function S.plan(loader, game, world, row)
+ local valid, why = stores(game)
+ if not valid then
+ return nil, why
+ end
+ local def = world.map and world.map.def
+ if not (def and type(def.label) == "string" and type(game.data.textEntry) == "function") then
+ return nil, "native mart content unavailable"
+ end
+ local entry = game.data:textEntry(def.label, row[2])
+ local list = entry and entry.mart
+ if type(list) ~= "table" or #list == 0 or #list > 99 then
+ return nil, "native mart content unavailable: " .. tostring(row[2])
+ end
+ for k in pairs(list) do
+ if not integer(k, #list) or k == 0 then
+ return nil, "invalid mart stock"
+ end
+ end
+ local ids = Ids.new(loader)
+ local stock, records = {}, {}
+ for i = 1, #list do
+ local public = ids:read("items", list[i])
+ local group, reason = ids:resolve("items", public)
+ local item = group and game.data.items and game.data.items[group.key]
+ if not item then
+ return nil, reason or "unknown mart item"
+ end
+ if not integer(item.price, 999999) or type(item.name) ~= "string" then
+ return nil, "invalid mart item price/name"
+ end
+ stock[i] = group.key
+ records[group.key] = { record = item, price = item.price }
+ end
+ local inventory = game.save.inventory
+ for id in pairs(inventory) do
+ local item = game.data.items[id]
+ if item then
+ records[id] = { record = item, price = item.price }
+ end
+ end
+ return stock,
+ function()
+ local ok, reason = stores(game)
+ if not ok then
+ return nil, reason
+ end
+ if game.save.inventory ~= inventory then
+ return nil, "shop inventory changed"
+ end
+ for id in pairs(inventory) do
+ local item = game.data.items[id]
+ if item and not records[id] then
+ records[id] = { record = item, price = item.price }
+ end
+ end
+ for id, expected in pairs(records) do
+ if game.data.items[id] ~= expected.record or expected.record.price ~= expected.price then
+ return nil, "shop item data changed"
+ end
+ end
+ return true
+ end
+end
+return S
diff --git a/src/mods/ScriptState.lua b/src/mods/ScriptState.lua
new file mode 100644
index 000000000..dbf73a6e5
--- /dev/null
+++ b/src/mods/ScriptState.lua
@@ -0,0 +1,193 @@
+-- Synchronous portable script actions over the host's native save/party/bag.
+local Ids = require("src.mods.UnifiedIds")
+local Version = require("src.core.GameVersion")
+local S = {}
+S.ARITY = {
+ give_money = 2,
+ take_money = 2,
+ check_money = 2,
+ take_coins = 2,
+ check_item = 2,
+ take_item = 3,
+ check_dex_owned = 2,
+ mark_seen = 2,
+ heal_party = 1,
+}
+local function integer(value, minimum, maximum)
+ return type(value) == "number"
+ and value == value
+ and value % 1 == 0
+ and value >= minimum
+ and value < math.huge
+ and (not maximum or value <= maximum)
+end
+local function identifier(value)
+ return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c")
+end
+function S.syntax(verb, source)
+ local n = S.ARITY[verb]
+ local minimum = (verb == "take_item" or verb == "check_dex_owned") and 2 or n
+ if verb == "check_dex_owned" then
+ minimum = 1
+ end
+ if #source < minimum or #source > n then
+ return nil, "invalid command arity"
+ end
+ if verb == "check_item" or verb == "take_item" or verb == "mark_seen" then
+ if not identifier(source[2]) then
+ return nil, "invalid content id"
+ end
+ if verb == "take_item" and source[3] ~= nil and not integer(source[3], 1, 999) then
+ return nil, "invalid item count"
+ end
+ elseif verb ~= "heal_party" then
+ if
+ not (verb == "check_dex_owned" and source[2] == nil) and not integer(source[2], 0, 9007199254740991)
+ then
+ return nil, "invalid script amount"
+ end
+ end
+ return { verb, source[2], source[3] }
+end
+local function tableOrNil(value)
+ return value == nil or type(value) == "table"
+end
+function S.prepare(loader, game, world, rows)
+ local ids = Ids.new(loader)
+ local plans = {}
+ local save = game.save
+ local function number(value)
+ return value == nil or integer(value, 0, 9007199254740991)
+ end
+ local function validate(row)
+ local verb = row[1]
+ if S.ARITY[verb] then
+ local reason, key
+ if verb == "give_money" or verb == "take_money" or verb == "check_money" then
+ if not number(save.money) then
+ reason = "invalid money store"
+ elseif
+ verb == "give_money"
+ and Version.generation() == 1
+ and not integer((save.money or 0) + row[2], 0, 9007199254740991)
+ then
+ reason = "money overflow"
+ end
+ elseif verb == "take_coins" then
+ if not number(save.coins) then
+ reason = "invalid coin store"
+ end
+ elseif verb == "check_item" or verb == "take_item" or verb == "mark_seen" then
+ local kind = verb == "mark_seen" and "pokemon" or "items"
+ local group, why = ids:resolve(kind, row[2])
+ if not group or not (game.data[kind] and game.data[kind][group.key]) then
+ reason = why or "content unavailable"
+ else
+ key = group.key
+ end
+ if not reason and kind == "items" then
+ if
+ type(save.inventory) ~= "table"
+ or not number(save.inventory[key])
+ or not tableOrNil(save.bagOrder)
+ then
+ reason = "invalid inventory store"
+ end
+ end
+ end
+ if verb == "mark_seen" or verb == "check_dex_owned" then
+ local dex = save.pokedex
+ if
+ not tableOrNil(dex)
+ or dex and (not tableOrNil(dex.seen) or not tableOrNil(dex.owned) or not tableOrNil(dex.caught))
+ then
+ reason = "invalid pokedex store"
+ end
+ elseif verb == "heal_party" then
+ if type(save.party) ~= "table" then
+ reason = "invalid party"
+ else
+ for _, mon in ipairs(save.party) do
+ if
+ type(mon) ~= "table"
+ or type(mon.stats) ~= "table"
+ or not integer(mon.stats.hp, 1, 65535)
+ or not tableOrNil(mon.moves)
+ then
+ reason = "invalid party"
+ break
+ end
+ for _, move in ipairs(mon.moves or {}) do
+ if type(move) ~= "table" or not (game.data.moves and game.data.moves[move.id]) then
+ reason = "invalid party move"
+ break
+ end
+ end
+ end
+ end
+ end
+ if reason then
+ return nil, reason
+ end
+ return { key = key }
+ end
+ end
+ for i, row in ipairs(rows) do
+ if S.ARITY[row[1]] then
+ local plan, reason = validate(row)
+ if not plan then
+ return nil, "row " .. i .. ": " .. reason
+ end
+ plans[row] = plan
+ end
+ end
+ return function(ctx, row)
+ local verb, a = row[1], row[2]
+ local plan = plans[row]
+ if not plan then
+ return false
+ end
+ -- Revalidate live stores too: another mod can mutate them while this script waits.
+ local checked, reason = validate(row)
+ if not checked then
+ error(reason, 0)
+ end
+ if verb == "give_money" or verb == "take_money" then
+ local value = math.max(0, (save.money or 0) + (verb == "give_money" and a or -a))
+ if Version.generation() ~= 1 then
+ value = math.min(999999, value)
+ end
+ save.money = value
+ elseif verb == "check_money" then
+ ctx.lastCheck = (save.money or 0) >= a
+ elseif verb == "take_coins" then
+ save.coins = math.max(0, (save.coins or 0) - a)
+ elseif verb == "check_item" then
+ ctx.lastCheck = (save.inventory[plan.key] or 0) > 0
+ elseif verb == "take_item" then
+ local n = math.min(row[3] or 1, save.inventory[plan.key] or 0)
+ if n > 0 then
+ require("src.inventory.Bag").remove(save, plan.key, n)
+ end
+ elseif verb == "mark_seen" then
+ save.pokedex = save.pokedex or {}
+ save.pokedex.seen = save.pokedex.seen or {}
+ save.pokedex.seen[plan.key] = true
+ elseif verb == "check_dex_owned" then
+ local dex = save.pokedex
+ local n = 0
+ for _, value in pairs(dex and (dex.owned or dex.caught) or {}) do
+ if value then
+ n = n + 1
+ end
+ end
+ ctx.lastCheck = n >= (a or 1)
+ elseif verb == "heal_party" then
+ for _, mon in ipairs(save.party) do
+ require("src.pokemon.Pokemon").heal(mon, game.data.moves)
+ end
+ end
+ return true
+ end
+end
+return S
diff --git a/src/mods/ScriptTravel.lua b/src/mods/ScriptTravel.lua
new file mode 100644
index 000000000..5a6ec345b
--- /dev/null
+++ b/src/mods/ScriptTravel.lua
@@ -0,0 +1,94 @@
+-- Destination preflight, including later world rows against destination data.
+local Shops = require("src.mods.ScriptShops")
+local Warp = require("src.mods.Api3Warp")
+local Movement = require("src.mods.ScriptMovement")
+local Edits = require("src.mods.ScriptWorldEdits")
+local Version = require("src.core.GameVersion")
+local copy = require("src.mods.Merge").deepCopy
+local T = { ARITY = { warp = 5, remember_outdoor = 4 } }
+function T.syntax(verb, row, context)
+ if (context.generation or Version.generation()) > 3 then
+ return nil, "portable travel unavailable for this generation"
+ end
+ if #row < 4 or #row > T.ARITY[verb] then
+ return nil, "invalid command arity"
+ end
+ -- Destination names, coordinates, dimensions and facing are validated with
+ -- the same pure planner as the public API once a save/world is available.
+ return { row[1], row[2], row[3], row[4], row[5] }
+end
+local function future(game, world, plan, cache)
+ local tiles = game.data.tilesets and game.data.tilesets[plan.def.tileset]
+ local map = cache[plan.mapId]
+ if map then
+ elseif tiles then
+ map = require("src.world.Map").new(copy(plan.def), copy(tiles))
+ else
+ map = { id = plan.mapId, def = plan.def }
+ end
+ map.renderer = { rebuild = function() end }
+ cache[plan.mapId] = map
+ local view = {
+ isOverworld = true,
+ map = map,
+ player = { cellX = plan.x, cellY = plan.y, facing = plan.facing },
+ npcs = {},
+ syncObjectVisibility = world.syncObjectVisibility,
+ scriptMove = world.scriptMove,
+ }
+ for _, def in ipairs(plan.def.objects or {}) do
+ view.npcs[#view.npcs + 1] =
+ { def = def, id = plan.mapId .. "_obj_" .. tostring(def.index), cellX = def.x, cellY = def.y }
+ end
+ local proxy = {
+ data = game.data,
+ save = game.save,
+ overworld = view,
+ stack = {
+ top = function()
+ return view
+ end,
+ states = { view },
+ },
+ }
+ return proxy, view
+end
+function T.prepare(game, world, owner, rows, loader)
+ local gen = Version.generation()
+ local model, modelGame = world, game
+ local cache = {}
+ for i, row in ipairs(rows) do
+ local verb = row[1]
+ if T.ARITY[verb] then
+ local plan, why = Warp.plan(game, model, gen, row[2], row[3], row[4], row[5])
+ if not plan then
+ return nil, "row " .. i .. ": " .. why
+ end
+ if verb == "warp" then
+ if type(world.startWarpTo) ~= "function" then
+ return nil, "native warp unavailable"
+ end
+ modelGame, model = future(game, world, plan, cache)
+ end
+ elseif Shops.ARITY[verb] then
+ local stock, why = Shops.plan(loader, modelGame, model, row)
+ if not stock then
+ return nil, "row " .. i .. ": " .. why
+ end
+ elseif Edits.ARITY[verb] then
+ local edit, why = Edits.prepare(modelGame, model, owner, { row })
+ if not edit then
+ return nil, "row " .. i .. ": " .. why
+ end
+ elseif Movement.ARITY[verb] then
+ local movement, why = Movement.prepare(model, { row })
+ if not movement then
+ return nil, "row " .. i .. ": " .. why
+ end
+ end
+ end
+ return function(row)
+ return Warp.plan(game, world, gen, row[2], row[3], row[4], row[5])
+ end
+end
+return T
diff --git a/src/mods/ScriptWorldEdits.lua b/src/mods/ScriptWorldEdits.lua
new file mode 100644
index 000000000..0c2c7ba9d
--- /dev/null
+++ b/src/mods/ScriptWorldEdits.lua
@@ -0,0 +1,85 @@
+-- Pure plans plus guarded native object/block effects inside an owned script.
+local E = require("src.mods.Api3WorldEdits")
+local Version = require("src.core.GameVersion")
+local W = { ARITY = { show_object = 3, hide_object = 3, replace_block = 4 } }
+local function id(value)
+ return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c")
+end
+local function integer(value)
+ return type(value) == "number" and value == value and value >= 0 and value < math.huge and value % 1 == 0
+end
+function W.syntax(verb, row, context)
+ if (context.generation or Version.generation()) > 3 then
+ return nil, "portable world edits unavailable for this generation"
+ end
+ if #row ~= W.ARITY[verb] then
+ return nil, "invalid command arity"
+ end
+ if verb == "replace_block" then
+ if not integer(row[2]) or not integer(row[3]) or not integer(row[4]) then
+ return nil, "invalid script block"
+ end
+ elseif not id(row[2]) or not (id(row[3]) or integer(row[3])) then
+ return nil, "invalid script object"
+ end
+ return { row[1], row[2], row[3], row[4] }
+end
+function W.prepare(game, world, owner, rows, prevalidated)
+ local needed = false
+ for _, row in ipairs(rows) do
+ needed = needed or W.ARITY[row[1]] ~= nil
+ end
+ if not needed then
+ return function() end
+ end
+ local gen = Version.generation()
+ local api = require("src.world.WorldAPI").new(game, owner)
+ local function plan(row)
+ if row[1] == "replace_block" then
+ return E.plan(game, api, gen, require, "replaceBlock", row[2], row[3], row[4])
+ end
+ local mapId = row[2]
+ local maps = game.data.maps or {}
+ if gen == 3 and not maps[mapId] then
+ local prefix = Version.get() == "emerald" and "EM_" or "FR_"
+ if maps[prefix .. mapId] then
+ mapId = prefix .. mapId
+ end
+ end
+ local def = maps[mapId]
+ local found = false
+ for _, object in ipairs(def and def.objects or {}) do
+ if
+ object.name == row[3]
+ or object.id == row[3]
+ or object.index == row[3]
+ or object.localId == row[3]
+ then
+ found = true
+ break
+ end
+ end
+ if not found then
+ return nil, "native object unavailable: " .. tostring(row[3])
+ end
+ return E.plan(game, api, gen, require, "toggleObject", mapId, row[3], row[1] == "show_object")
+ end
+ for i, row in ipairs(rows) do
+ if not prevalidated and W.ARITY[row[1]] then
+ local operation, reason = plan(row)
+ if not operation then
+ return nil, "row " .. i .. ": " .. reason
+ end
+ end
+ end
+ return function(row, mutate)
+ local operation, reason = plan(row)
+ if not operation then
+ return nil, reason
+ end
+ return mutate(function()
+ return E.run(game, operation)
+ end)
+ end
+end
+return W
diff --git a/src/mods/UIActions.lua b/src/mods/UIActions.lua
new file mode 100644
index 000000000..533730961
--- /dev/null
+++ b/src/mods/UIActions.lua
@@ -0,0 +1,241 @@
+-- Per-mod, detached UI snapshots and revision-bound native actions.
+local Commands = require("src.ui.UICommands")
+local Intents = require("src.mods.BattleIntents")
+local Ids = require("src.mods.UnifiedIds")
+local Runtime = require("src.mods.Runtime")
+local Actions = {}
+local function copy(v, seen)
+ if type(v) ~= "table" then
+ if type(v) == "string" or type(v) == "number" or type(v) == "boolean" then
+ return v
+ end
+ return nil
+ end
+ seen = seen or {}
+ if seen[v] then
+ return seen[v]
+ end
+ local out = {}
+ seen[v] = out
+ for k, value in pairs(v) do
+ if type(k) == "string" or type(k) == "number" then
+ out[k] = copy(value, seen)
+ end
+ end
+ return out
+end
+local function equal(a, b, seen)
+ if type(a) ~= type(b) then
+ return false
+ end
+ if type(a) ~= "table" then
+ return a == b or (a ~= a and b ~= b)
+ end
+ seen = seen or {}
+ if seen[a] then
+ return seen[a] == b
+ end
+ seen[a] = b
+ for k, v in pairs(a) do
+ if not equal(v, b[k], seen) then
+ return false
+ end
+ end
+ for k in pairs(b) do
+ if a[k] == nil then
+ return false
+ end
+ end
+ return true
+end
+local function engine(fn, ...)
+ local owner = Runtime.currentMod
+ Runtime.currentMod = nil
+ local ok, a, b, c = pcall(fn, ...)
+ Runtime.currentMod = owner
+ return ok, a, b, c
+end
+local function integer(n)
+ return type(n) == "number" and n >= 1 and n <= 9007199254740991 and n % 1 == 0
+end
+local function normalize(raw)
+ if type(raw) ~= "table" or getmetatable(raw) ~= nil then
+ return nil, "intent must be a plain table"
+ end
+ if not integer(raw.id) or not integer(raw.revision) then
+ return nil, "invalid UI id or revision"
+ end
+ local fields = ({ select = { index = true }, back = {}, advance = {}, pocket = { direction = true } })[raw.kind]
+ if not fields then
+ return nil, "unknown UI intent"
+ end
+ for k in pairs(raw) do
+ if k ~= "id" and k ~= "revision" and k ~= "kind" and not fields[k] then
+ return nil, "unknown UI intent field"
+ end
+ end
+ if raw.kind == "select" and not integer(raw.index) then
+ return nil, "invalid UI row"
+ end
+ if raw.kind == "pocket" and raw.direction ~= -1 and raw.direction ~= 1 then
+ return nil, "invalid pocket direction"
+ end
+ return {
+ id = raw.id,
+ revision = raw.revision,
+ kind = raw.kind,
+ index = raw.index,
+ direction = raw.direction,
+ }
+end
+function Actions.new(provider, loader, builders)
+ local ids = Ids.new(loader)
+ ids:entries("pokemon")
+ ids:entries("items")
+ local facade = setmetatable({}, { __index = builders })
+ local revision, consumed = 0, 0
+ local previous, oldRefs, oldControls, oldEpoch, offered
+ local function refresh()
+ local game = provider()
+ local states = game and game.stack and game.stack.states
+ local top = states and states[#states]
+ if not top then
+ previous, oldRefs, oldControls, oldEpoch, offered = nil, nil, nil, nil, nil
+ return nil, "no UI screen"
+ end
+ local ok, view, refs, kind = engine(Commands.describe, game, top, Ids.new(loader))
+ if not ok or not view then
+ previous, oldRefs, oldControls, oldEpoch, offered = nil, nil, nil, nil, nil
+ return nil, ok and refs or "UI snapshot failed"
+ end
+ refs.game = game
+ -- Capture live eligibility, target and inventory changes even when the
+ -- native screen has not rebuilt its labels yet. Never return this data.
+ local controls = copy({
+ party = type(top.party) == "table" and top.party or game.save and game.save.party,
+ inventory = game.save and game.save.inventory,
+ bagOrder = game.save and game.save.bagOrder,
+ select = top.select,
+ menu = top.menu,
+ submenu = top.submenu,
+ swapFrom = top.swapFrom,
+ switchFrom = top.switchFrom,
+ softboiledFrom = top.softboiledFrom,
+ hpTransfer = top.hpTransfer,
+ hpTransferFrom = top.hpTransferFrom,
+ forceSwitch = top.forceSwitch,
+ pickOnly = top.pickOnly,
+ keepOpen = top.keepOpen,
+ pick = top.pick,
+ store = top.store,
+ sell = top.sell,
+ registeredItem = game.save and game.save.registeredItem,
+ cur = top.cur,
+ mem = top.mem,
+ page = top.page,
+ top = top.top,
+ done = top.done,
+ waiting = top.waiting,
+ shown = top.shown,
+ contAdvance = top.contAdvance,
+ lineIndex = top.lineIndex,
+ pageIndex = top.pageIndex,
+ pending = top.pending,
+ trash = top.trash,
+ moving = top.moving,
+ lockPocket = top.lockPocket,
+ resultMessage = top.resultMessage,
+ message = top.message,
+ selectNotice = top.selectNotice,
+ })
+ local state = Intents.state(game)
+ if
+ not equal(view, previous)
+ or not Intents.sameRefs(refs, oldRefs)
+ or not equal(controls, oldControls)
+ or state.epoch ~= oldEpoch
+ then
+ revision = revision + 1
+ end
+ previous, oldRefs, oldControls, oldEpoch = view, refs, controls, state.epoch
+ return { game = game, top = top, view = view, kind = kind, state = state }
+ end
+ function facade:snapshot()
+ local ctx, err = refresh()
+ if not ctx then
+ return nil, err
+ end
+ local out = copy(ctx.view)
+ out.revision = revision
+ if ctx.state.busy then
+ out.locked, out.canBack, out.canAdvance, out.canPocket = true, false, false, false
+ end
+ offered = revision
+ return out
+ end
+ function facade:submit(raw)
+ local intent, err = normalize(raw)
+ if not intent then
+ return nil, err
+ end
+ if intent.id <= consumed then
+ return nil, "replayed UI intent"
+ end
+ local ctx
+ ctx, err = refresh()
+ if not ctx then
+ return nil, err
+ end
+ local state, view = ctx.state, ctx.view
+ if state.busy then
+ return nil, "UI action is already in progress"
+ end
+ if intent.revision ~= revision then
+ return nil, "stale UI context"
+ end
+ if offered ~= revision then
+ return nil, "fresh UI snapshot required"
+ end
+ if view.locked then
+ return nil, "UI is locked"
+ end
+ if intent.kind == "select" then
+ if not view.rows[intent.index] or not view.rows[intent.index].enabled then
+ return nil, "unavailable UI row"
+ end
+ elseif intent.kind == "back" and not view.canBack then
+ return nil, "UI cannot go back"
+ elseif intent.kind == "advance" and not view.canAdvance then
+ return nil, "UI cannot advance"
+ elseif intent.kind == "pocket" and not view.canPocket then
+ return nil, "UI cannot change pocket"
+ end
+ -- Native work can raise after partial effects. Consume before dispatch;
+ -- the shared epoch also expires snapshots held by battle/other UI facades.
+ consumed, offered = intent.id, nil
+ state.busy, state.epoch = true, state.epoch + 1
+ local ok, failure = engine(Commands.dispatch, ctx.top, ctx.kind, view, intent)
+ state.busy, state.epoch = false, state.epoch + 1
+ if not ok then
+ local printable, message = pcall(tostring, failure)
+ return nil, "UI action failed; intent consumed" .. (printable and ": " .. message or "")
+ end
+ return true
+ end
+ return facade
+end
+function Actions.withCapabilities(api)
+ if api.supports then
+ local supports = api.supports
+ function api:supports(surface, name)
+ if surface == "ui" then
+ if name == "snapshot" or name == "submit" then
+ return true
+ end
+ return false, "unknown UI capability: " .. tostring(name)
+ end
+ return supports(self, surface, name)
+ end
+ end
+end
+return Actions
diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua
new file mode 100644
index 000000000..f28caeadd
--- /dev/null
+++ b/src/mods/UnifiedCallbacks.lua
@@ -0,0 +1,222 @@
+local Api3Catalog = require("src.mods.Api3Catalog")
+local Records = require("src.mods.CallbackRecords")
+local Ids = require("src.mods.UnifiedIds")
+local C = {}
+local unpack = table.unpack or unpack
+local function pack(...)
+ return { n = select("#", ...), ... }
+end
+local function names(text)
+ local out = {}
+ for name in text:gmatch("%S+") do
+ out[name] = true
+ end
+ return out
+end
+-- This is an audited boundary catalog, not a claim that every game executes
+-- every seam. The source-catalog test requires a decision for each new seam.
+C.hooks = names([[
+ battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award
+ battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate
+ encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility
+ input.pointer input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed
+ music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud
+ render.letterbox render.output render.output_enabled render.viewport render.window render.zones save.new_game save.write script.command trainer.party transition.style
+ ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items
+ ui.title_menu.items warp.destination world.tod zoom.range
+]])
+C.events = names([[
+ assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended
+ battle.exp_gained battle.fainted battle.move_used battle.started battle.status_inflicted battle.turn_ended
+ battle.turn_started discord.join_requested flag.changed game.ready intro.birch_speech.finished
+ intro.birch_speech.started intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started
+ intro.oak_speech.step link.connected link.desync link.ended map.entered map.exited map.reloaded
+ mod.option_action mod.options_changed mods.loaded music.started music.stopped player.warped
+ pokemon.before_give pokemon.caught pokemon.egg_hatched pokemon.evolved pokemon.level_up
+ pokemon.move_learned pokemon.received save.created save.loaded save.loading save.writing screen.popped
+ screen.pushed script.ended script.started sound.played trade.completed world.blacked_out
+ world.block_replaced world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned
+ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged
+]])
+local positions = {
+ ["battle.accuracy"] = { "context" },
+ ["battle.damage"] = { "context" },
+ ["battle.crit"] = { "context" },
+ ["battle.run"] = { "context" },
+ ["battle.catch_exp"] = { "context" },
+ ["battle.low_health_alarm"] = { "context" },
+ ["battle.exp_award"] = { "context" },
+ ["exp.gain"] = { "context" },
+ ["battle.turn_order"] = { "battler", "move", "battler", "move", "context" },
+ ["battle.enemy_action"] = { false, "battler" },
+ ["catch.rate"] = { "items", "mon", "species", "context" },
+ ["trainer.party"] = { false, false, "trainerParty" },
+ ["evolution.check"] = { false, "mon", "context", "context" },
+ ["fieldmove.eligibility"] = { "moves", false },
+ ["encounter.fishing"] = { "items", false, "candidates" },
+ ["encounter.roll"] = { false, "context" },
+ ["encounter.species"] = { "encounter", "context" },
+ ["ui.party.submenu"] = { false, false, "mon", false },
+ ["pokemon.sprite"] = { false, "context" },
+ ["pokemon.icon"] = { false, "context" },
+}
+local results = {
+ ["fieldmove.eligibility"] = "mon",
+ ["battle.enemy_action"] = "action",
+ ["trainer.party"] = "trainerParty",
+ ["battle.turn_order"] = "battler",
+ ["encounter.fishing"] = "encounter",
+ ["encounter.roll"] = "encounter",
+ ["encounter.species"] = "encounter",
+}
+-- These payloads are engine handles, repair flows or UI objects, not record
+-- notifications. In particular, game.ready has a forwarding metatable.
+local nativeEvents = names([[
+ game.ready save.created save.loaded save.loading save.writing mods.loaded
+ screen.popped screen.pushed intro.birch_speech.finished intro.birch_speech.started
+ intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step
+ mod.option_action mod.options_changed
+]])
+local function custom(name)
+ return type(name) == "string" and name:match("^mod%.[%w_%-]+%..+$") ~= nil
+end
+function C.install(loader, api, owner, generation)
+ -- Capture pristine alias identities before Loader merges them into separate
+ -- records. Each call still builds a fresh index, including later mods' IDs.
+ local ids = Ids.new(loader)
+ local api3 = (api.manifest.api or 1) >= 3
+ local extra = api3 and Api3Catalog
+ for _, kind in ipairs({ "pokemon", "moves", "items" }) do
+ ids:entries(kind)
+ end
+ local function check(catalog, name)
+ assert(
+ type(name) == "string"
+ and (
+ catalog[name]
+ or (extra and (catalog == C.events and extra.events[name] or catalog == C.hooks and extra.hooks[name]))
+ or (catalog == C.events and custom(name))
+ ),
+ "unknown unified callback: " .. tostring(name)
+ )
+ end
+ local function subscribe(once, name, callback, priority)
+ check(C.events, name)
+ assert(type(callback) == "function", "event callback must be a function")
+ local wrapped = callback
+ if not custom(name) and not nativeEvents[name] then
+ wrapped = function(payload)
+ local boundary = Records.new(loader, name, generation, api3)
+ local public = boundary.view(payload, name == "pokemon.before_give" and "gift" or "context")
+ local values = pack(callback(public))
+ if name == "pokemon.before_give" then
+ boundary.commit()
+ end
+ return unpack(values, 1, values.n)
+ end
+ end
+ if once then
+ return loader.events:once(name, wrapped, priority, owner)
+ end
+ return loader.events:on(name, wrapped, priority, owner)
+ end
+ api.events = {
+ on = function(_, ...)
+ return subscribe(false, ...)
+ end,
+ once = function(_, ...)
+ return subscribe(true, ...)
+ end,
+ emit = function(_, name, payload)
+ local prefix = "mod." .. owner .. "."
+ assert(custom(name) and name:sub(1, #prefix) == prefix, "mods may only emit " .. prefix .. "* events")
+ return loader.events:emit(name, payload)
+ end,
+ }
+ api.hooks = {
+ wrap = function(_, name, callback, priority)
+ check(C.hooks, name)
+ assert(type(callback) == "function", "hook callback must be a function")
+ return loader.hooks:wrap(name, function(nextFn, ...)
+ local boundary = Records.new(loader, name, generation, api3)
+ local roles = positions[name] or {}
+ local function translate(args)
+ local out = { n = args.n }
+ for i = 1, args.n do
+ local role = roles[i]
+ local v = args[i]
+ if role == "items" or role == "moves" then
+ v = boundary.read(role, v)
+ elseif role then
+ v = boundary.view(v, role)
+ end
+ out[i] = v
+ end
+ return out
+ end
+ local original = pack(...)
+ local function rawArgs(args)
+ local out = { n = args.n }
+ for i = 1, args.n do
+ local role, v = roles[i], args[i]
+ if role == "items" or role == "moves" then
+ v = boundary.native(role, v, original[i])
+ elseif role then
+ v = boundary.restore(v, role)
+ end
+ out[i] = v
+ end
+ return out
+ end
+ local public = translate(original)
+ local called, downstream, publicResult = false, nil, nil
+ local active = true
+ local function nextPublic(...)
+ assert(active, "next is only valid during its hook callback")
+ assert(not called or downstream, "next is already running")
+ if not called then
+ local args = select("#", ...) == 0 and public or pack(...)
+ local raw = boundary.commit(function()
+ return rawArgs(args)
+ end)
+ called = true
+ downstream = pack(pcall(nextFn, unpack(raw, 1, raw.n)))
+ if downstream[1] then
+ boundary.refresh()
+ publicResult = pack(unpack(downstream, 2, downstream.n))
+ if results[name] then
+ publicResult[1] = boundary.view(publicResult[1], results[name])
+ end
+ end
+ end
+ if not downstream[1] then
+ error(downstream[2], 0)
+ end
+ return unpack(publicResult, 1, publicResult.n)
+ end
+ local invoked = pack(pcall(callback, nextPublic, unpack(public, 1, public.n)))
+ active = false
+ if not invoked[1] then
+ boundary.close()
+ error(invoked[2], 0)
+ end
+ local values = pack(unpack(invoked, 2, invoked.n))
+ if called and not downstream[1] then
+ boundary.close()
+ error(downstream[2], 0)
+ end
+ local ok, err = pcall(boundary.commit, function()
+ if results[name] then
+ values[1] = boundary.restore(values[1], results[name])
+ end
+ end)
+ boundary.close()
+ if not ok then
+ error(err, 0)
+ end
+ return unpack(values, 1, values.n)
+ end, priority, owner)
+ end,
+ }
+end
+return C
diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua
new file mode 100644
index 000000000..93a258afe
--- /dev/null
+++ b/src/mods/UnifiedContent.lua
@@ -0,0 +1,541 @@
+-- Explicit opt-in definition-record boundary. Native registries and gameplay
+-- stay authoritative; no save, callback, world or battle data is rewritten.
+local Merge = require("src.mods.Merge")
+local Schemas = require("src.mods.Schemas")
+local Shapes = require("src.mods.UnifiedSchemas")
+local Ids = require("src.mods.UnifiedIds")
+local Effects = require("src.mods.UnifiedEffects")
+local U = {}
+local copy, DELETE = Merge.deepCopy, Merge.DELETE
+local function unsupported(reason) error({ unsupported = reason }, 0) end
+local function check(ok, reason) if not ok then error("invalid unified content: " .. reason, 0) end end
+local function equal(a, b, seen)
+ if a == b then return true end
+ if type(a) ~= "table" or type(b) ~= "table" then return false end
+ seen = seen or {}; if seen[a] == b then return true end; seen[a] = b
+ for k, v in pairs(a) do if not equal(v, b[k], seen) then return false end end
+ for k in pairs(b) do if a[k] == nil then return false end end
+ return true
+end
+local function finite(v, seen)
+ if type(v) == "number" then check(v == v and math.abs(v) < math.huge, "non-finite number") end
+ if type(v) ~= "table" or v == DELETE then return end
+ seen = seen or {}; check(not seen[v], "cyclic record"); seen[v] = true
+ for k, sub in pairs(v) do finite(k, seen); finite(sub, seen) end
+ seen[v] = nil
+end
+local function list(value, old, convert)
+ if value == DELETE then return {} end
+ check(type(value) == "table", "expected list")
+ if Merge.isWrapper(value) then
+ for k in pairs(value) do check(k == "__prepend" or k == "__append", "unknown list extension") end
+ local out = {}
+ for _, source in ipairs({ value.__prepend or {}, old or {}, value.__append or {} }) do
+ for _, row in ipairs(list(source, nil, function(v) return copy(v) end)) do out[#out + 1] = row end
+ end
+ return list(out, nil, convert)
+ end
+ local count = 0
+ for k in pairs(value) do
+ check(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= #value, "expected contiguous list")
+ count = count + 1
+ end
+ check(count == #value, "expected contiguous list")
+ local out = {}
+ for i, row in ipairs(value) do out[i] = convert(row, i) end
+ return out
+end
+local function resolved(ctx, kind, id)
+ check(type(id) == "string" and id ~= "", "expected string " .. kind .. " ID")
+ local found, why = ctx.ids:resolve(kind, id)
+ if not found then unsupported(why) end
+ return found.key
+end
+local function readIds(ctx, kind, values)
+ local out = {}
+ for _, id in ipairs(values or {}) do
+ if id ~= 0 then out[#out + 1] = ctx.ids:read(kind, id) end
+ end
+ return out
+end
+local function writeIds(ctx, kind, values, old)
+ return list(values, old, function(id) return resolved(ctx, kind, id) end)
+end
+-- Gen 3's seven-byte names also produce these spellings (TypeChart's
+-- registrant deliberately keeps them authoritative rather than renaming Data).
+local TYPE_ALIASES = { PSYCHIC = "PSYCHIC_TYPE", PSYCHC = "PSYCHIC_TYPE",
+ MYSTERY = "CURSE_TYPE", FIGHT = "FIGHTING", ELECTR = "ELECTRIC" }
+local function typeRead(id) return TYPE_ALIASES[id] or id end
+local function typeWrite(ctx, id)
+ local registry = ctx.loader.content.type_chart
+ local found = registry:get(id) ~= nil and id or nil
+ for native, canonical in pairs(TYPE_ALIASES) do
+ if canonical == id and registry:get(native) ~= nil then
+ if found then unsupported("ambiguous type ID: " .. tostring(id)) end
+ found = native
+ end
+ end
+ if found then return found end
+ unsupported("unknown type ID: " .. tostring(id))
+end
+local function reference(ctx, kind, id)
+ if ctx.loader.content[kind]:get(id) == nil then unsupported("unknown " .. kind .. " ID: " .. tostring(id)) end
+ return id
+end
+
+local EVO = {
+ level = "LEVEL", useItem = "ITEM", trade = "TRADE", tradeWithItem = "TRADE",
+ friendship = "FRIENDSHIP", friendshipDay = "FRIENDSHIP", friendshipNight = "FRIENDSHIP",
+ HAPPINESS = "FRIENDSHIP", STAT = "LEVEL",
+ levelAtkGreater = "LEVEL", levelAtkLess = "LEVEL", levelAtkEqual = "LEVEL",
+}
+local function evolutionRead(ctx, row)
+ local method = EVO[row.method] or row.method
+ local out = { method = method, species = ctx.ids:read("pokemon", row.species or row.target) }
+ if method == "LEVEL" then out.level = row.level or row.param end
+ if method == "ITEM" then out.item = ctx.ids:read("items", row.item or row.param) end
+ if method == "TRADE" then out.item = ctx.ids:read("items", row.heldItem or (row.method == "tradeWithItem" and row.param)) end
+ if method == "FRIENDSHIP" then
+ out.time = row.timeOfDay or (row.method == "friendshipDay" and "MORNDAY")
+ or (row.method == "friendshipNight" and "NITE")
+ end
+ out.comparison = row.compare or ({ levelAtkGreater = "ATK_GT_DEF", levelAtkLess = "ATK_LT_DEF",
+ levelAtkEqual = "ATK_EQ_DEF" })[row.method]
+ return out
+end
+local function evolutionWrite(ctx, row, old)
+ if old and equal(row, evolutionRead(ctx, old)) then return copy(old) end
+ local method = row.method
+ if method ~= "LEVEL" and method ~= "ITEM" and method ~= "TRADE" and method ~= "FRIENDSHIP" then
+ unsupported("evolution method has no unified writer: " .. tostring(method))
+ end
+ if ctx.gen == 1 and (method == "FRIENDSHIP" or row.item and method == "TRADE"
+ or row.time or row.comparison) then unsupported("evolution condition has no Gen 1 representation") end
+ if ctx.gen >= 4 then
+ -- This engine implements the native lowercase level method. Other DS
+ -- evolution conditions retain cartridge parameters and lack a safe writer.
+ if method ~= "LEVEL" or row.comparison then unsupported("DS evolution condition has no unified writer") end
+ check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition")
+ return { method = "level", param = row.level, paramKind = "level", methodId = 4,
+ target = resolved(ctx, "pokemon", row.species) }
+ end
+ local out = { method = method, species = resolved(ctx, "pokemon", row.species) }
+ if method == "LEVEL" then
+ check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition")
+ out.level = row.level
+ if row.comparison then out.method, out.compare = "STAT", row.comparison end
+ elseif method == "ITEM" then
+ check(row.item ~= nil and row.level == nil and row.time == nil and row.comparison == nil, "invalid ITEM condition")
+ out.item = resolved(ctx, "items", row.item)
+ elseif method == "TRADE" then
+ check(row.level == nil and row.time == nil and row.comparison == nil, "invalid TRADE condition")
+ if row.item then out.heldItem = resolved(ctx, "items", row.item) end
+ else
+ check(row.level == nil and row.item == nil and row.comparison == nil, "invalid FRIENDSHIP condition")
+ out.method, out.timeOfDay = "HAPPINESS", row.time ~= "ANYTIME" and row.time or nil
+ end
+ return out
+end
+
+local readRecord
+readRecord = function(ctx, kind, id, raw)
+ local out = { id = id, native = copy(raw) }
+ for key in pairs(Shapes[kind].fields) do
+ if key ~= "native" and key ~= "id" then out[key] = copy(raw[key]) end
+ end
+ if kind == "pokemon" then
+ local stats = raw.baseStats or {}
+ out.baseStats = { hp = stats.hp, attack = stats.attack, defense = stats.defense, speed = stats.speed,
+ special = stats.special or stats.spatk or stats.spAttack,
+ specialAttack = ctx.gen > 1 and (stats.spatk or stats.spAttack) or nil,
+ specialDefense = ctx.gen > 1 and (stats.spdef or stats.spDefense) or nil }
+ out.types = {}; for i, value in ipairs(raw.types or {}) do out.types[i] = typeRead(value) end
+ for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do
+ if raw[key] then out[key] = readIds(ctx, "moves", raw[key]) end
+ end
+ out.level1Moves, out.learnset, out.evolutions = out.level1Moves or {}, {}, {}
+ for i, row in ipairs(raw.learnset or {}) do
+ out.learnset[i] = { level = row.level, move = ctx.ids:read("moves", row.move) }
+ end
+ for i, row in ipairs(raw.evolutions or {}) do out.evolutions[i] = evolutionRead(ctx, row) end
+ elseif kind == "moves" then
+ out.type = typeRead(raw.type)
+ out.category = raw.category or raw.class
+ out.effect = Effects.read(raw.effect, ctx.gen, raw)
+ out.secondaryChance = Effects.chance(raw, ctx.gen)
+ elseif kind == "items" then
+ if type(raw.effect) ~= "string" then out.effect = nil end
+ if raw.machine then out.machine.move = ctx.ids:read("moves", raw.machine.move) end
+ elseif kind == "trainers" then
+ out.parties = {}
+ for i, party in ipairs(raw.parties or (raw.party and { raw.party }) or {}) do
+ out.parties[i] = {}
+ for j, mon in ipairs(party) do
+ out.parties[i][j] = { species = ctx.ids:read("pokemon", mon.species), level = mon.level,
+ item = ctx.ids:read("items", mon.item), moves = mon.moves and readIds(ctx, "moves", mon.moves) }
+ end
+ end
+ elseif kind == "encounters" then
+ local function areaRead(area)
+ if not area then return nil end
+ local row = { rate = (area.rate or 0) * 256 / (area.rateMax or 256), slots = {} }
+ for i, slot in ipairs(area.slots or {}) do
+ row.slots[i] = { species = ctx.ids:read("pokemon", slot.species), level = slot.level,
+ minLevel = slot.min, maxLevel = slot.max }
+ end
+ if area.byTime then
+ row.times = { DAY = { rate = row.rate, slots = copy(row.slots) } }
+ for key, native in pairs({ MORN = "morn", NITE = "nite" }) do
+ if area.byTime[native] then row.times[key] = areaRead(area.byTime[native]) end
+ end
+ end
+ return row
+ end
+ out.grass, out.water = areaRead(raw.grass), areaRead(raw.water)
+ end
+ return out
+end
+
+local function writeRecord(ctx, kind, id, value, old, mode)
+ local previous = old and readRecord(ctx, kind, id, old) or {}
+ if value.native ~= nil and not equal(value.native, old or {}) then unsupported("native diagnostics are read-only") end
+ if value.id ~= nil then check(value.id == id, "record id differs from operation id") end
+ if value.native ~= nil and old then
+ -- Detached read/modify/write records carry inherited fields. Convert only
+ -- actual edits, so round trips retain native selectors and absent aliases.
+ value = copy(value)
+ for key, v in pairs(value) do
+ if key ~= "id" and key ~= "native" and equal(v, previous[key]) then value[key] = nil end
+ end
+ end
+ local out, replacements = {}, {}
+ local function replace(key, v) out[key] = v; replacements[#replacements + 1] = { key } end
+ for key, v in pairs(value) do
+ if key ~= "native" and key ~= "id" then out[key] = copy(v) end
+ end
+ if not old then out.id = id end
+ if kind == "pokemon" then
+ if value.baseStats and value.baseStats ~= DELETE then
+ local stats, native = value.baseStats, {}
+ for _, stat in pairs(stats) do check(stat ~= DELETE, "cannot delete a native base stat") end
+ for _, key in ipairs({ "hp", "attack", "defense", "speed" }) do native[key] = stats[key] end
+ local a, b = stats.specialAttack, stats.specialDefense
+ if ctx.gen == 1 then
+ a, b = a or stats.special or (previous.baseStats or {}).special,
+ b or stats.special or (previous.baseStats or {}).special
+ if (a or b) and a ~= b then unsupported("Gen 1 cannot split Special") end
+ if stats.special and a ~= stats.special then check(false, "conflicting Special values") end
+ native.special = stats.special or stats.specialAttack or stats.specialDefense
+ else
+ -- A patch to special intentionally writes both halves. On detached
+ -- read/modify/write records, unchanged split fields are inherited.
+ if stats.special ~= nil and (value.native == nil or stats.special ~= (previous.baseStats or {}).special) then
+ if value.native ~= nil then
+ if a == (previous.baseStats or {}).specialAttack then a = nil end
+ if b == (previous.baseStats or {}).specialDefense then b = nil end
+ end
+ check(a == nil or a == stats.special, "conflicting Special Attack values")
+ a, b = a or stats.special, b or stats.special
+ elseif stats.special ~= nil and not old then a, b = a or stats.special, b or stats.special end
+ native.spatk, native.spdef = a, b
+ if a ~= nil then native.special = a end
+ if old and old.baseStats and old.baseStats.spAttack ~= nil then native.spAttack = a end
+ if old and old.baseStats and old.baseStats.spDefense ~= nil then native.spDefense = b end
+ end
+ out.baseStats = native
+ end
+ check(value.types ~= DELETE, "species types cannot be deleted")
+ if value.types then
+ local types = list(value.types, previous.types, function(t) return typeWrite(ctx, t) end)
+ check(#types >= 1 and #types <= 2, "species needs one or two types")
+ replace("types", types)
+ out.type1, out.type2 = types[1], types[2] or types[1]
+ end
+ for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do
+ if value[key] ~= nil then replace(key, writeIds(ctx, "moves", value[key], previous[key])) end
+ end
+ if value.learnset ~= nil then
+ replace("learnset", list(value.learnset, previous.learnset, function(row)
+ return { level = row.level, move = resolved(ctx, "moves", row.move) }
+ end))
+ end
+ if value.evolutions ~= nil then
+ replace("evolutions", list(value.evolutions, previous.evolutions, function(row, i)
+ return evolutionWrite(ctx, row, old and old.evolutions and old.evolutions[i])
+ end))
+ end
+ if value.growthRate and value.growthRate ~= DELETE then reference(ctx, "growth_rates", value.growthRate) end
+ if ctx.gen == 1 and (value.eggMoves or value.genderRatio or value.eggCycles) then
+ unsupported("breeding fields have no Gen 1 representation")
+ end
+ if not old and ctx.gen >= 3 then unsupported("new species require native EV/ability/storage metadata; patch an existing species") end
+ elseif kind == "moves" then
+ if value.type and value.type ~= DELETE then
+ out.type = typeWrite(ctx, value.type)
+ if old and old.typeId and out.type ~= old.type then unsupported("changing a DS type requires native type metadata") end
+ end
+ local probability = value.secondaryChance
+ if value.effect and value.effect ~= DELETE then
+ local plan, changed = Effects.write(value.effect, ctx.gen, old, function(effect)
+ return ctx.loader.content.move_effects:get(effect) ~= nil
+ end)
+ if not plan then unsupported(changed) end
+ out.effect = plan.effect
+ if changed then
+ out._api3EffectId = plan._api3EffectId
+ if ctx.gen == 3 then out.gen3Effect = plan.gen3Effect or DELETE end
+ if ctx.gen > 1 and probability == nil then probability = plan.probability or DELETE end
+ end
+ end
+ out.secondaryChance = nil
+ if probability ~= nil then
+ if ctx.gen == 1 or ctx.gen > 3 then unsupported("secondaryChance requires a Gen 2/3 native adapter") end
+ if old and probability ~= DELETE and probability == Effects.chance(old, ctx.gen)
+ and (probability ~= 0 or old._api3EffectChance or value.native ~= nil) then
+ -- Detached records preserve the exact original native chance byte.
+ else
+ out.effectChance = probability == DELETE and DELETE or math.floor(probability * 256 / 100 + 0.5)
+ out._api3EffectChance = probability == DELETE and DELETE or true
+ if ctx.gen == 3 then out.secondaryChance = probability end
+ end
+ end
+ if value.category ~= nil and old and old.class ~= nil then
+ check(value.category ~= DELETE, "native move class cannot be deleted")
+ out.class = value.category
+ if old.classId ~= nil then out.classId = ({ physical = 0, special = 1, status = 2 })[value.category] end
+ end
+ elseif kind == "items" then
+ if value.machine and value.machine ~= DELETE then
+ out.machine.move = resolved(ctx, "moves", value.machine.move)
+ end
+ for key, registry in pairs({ effect = "item_effects", ball = "balls" }) do
+ if value[key] and value[key] ~= DELETE then
+ reference(ctx, registry, value[key])
+ if ctx.gen > 1 and (not old or value[key] ~= old[key]) then unsupported("changing item behavior requires native metadata") end
+ end
+ end
+ elseif kind == "trainers" and value.parties ~= nil then
+ local prior = old and (old.parties or (old.party and { old.party })) or {}
+ local parties = list(value.parties, previous.parties, function(party, i)
+ return list(party, nil, function(mon, j)
+ local native = copy(prior[i] and prior[i][j] or {})
+ native.species, native.level = resolved(ctx, "pokemon", mon.species), mon.level
+ if ctx.gen == 1 and mon.item then unsupported("held items have no Gen 1 representation") end
+ native.item = mon.item and resolved(ctx, "items", mon.item) or nil
+ native.moves = mon.moves and writeIds(ctx, "moves", mon.moves) or nil
+ return native
+ end)
+ end)
+ replace("parties", parties)
+ if old and old.party then
+ check(#parties == 1, "native trainer has exactly one party")
+ replace("party", parties[1])
+ end
+ elseif kind == "encounters" then
+ local function areaWrite(input, native, before, path)
+ if input == DELETE then return DELETE end
+ native, before = native or {}, before or {}
+ check(input.times ~= DELETE, "native time tables cannot be deleted")
+ if input.times then
+ input = copy(input)
+ if equal(input.rate, before.rate) then input.rate = nil end
+ if equal(input.slots, before.slots) then input.slots = nil end
+ end
+ local result = {}
+ if input.rate ~= nil then
+ check(input.rate ~= DELETE, "encounter rate cannot be deleted")
+ local rate = input.rate * (native.rateMax or 256) / 256
+ if math.abs(rate - math.floor(rate + 0.5)) > 1e-8 then unsupported("encounter rate is not representable in native units") end
+ result.rate = math.floor(rate + 0.5)
+ end
+ if input.slots ~= nil then
+ local rows = list(input.slots, before.slots, function(slot, i)
+ local row = copy((native.slots or {})[i] or {})
+ row.species = resolved(ctx, "pokemon", slot.species)
+ if slot.level then
+ check(slot.minLevel == nil and slot.maxLevel == nil, "conflicting encounter levels")
+ row.level, row.min, row.max = slot.level, nil, nil
+ else
+ check(slot.minLevel ~= nil and slot.maxLevel ~= nil and slot.minLevel <= slot.maxLevel, "invalid encounter level range")
+ row.level, row.min, row.max = nil, slot.minLevel, slot.maxLevel
+ end
+ if slot.chance then unsupported("changing encounter weights requires native buckets") end
+ return row
+ end)
+ if #rows ~= #(native.slots or {}) then unsupported("changing slot count requires native probability buckets") end
+ result.slots = rows; local p = copy(path); p[#p + 1] = "slots"; replacements[#replacements + 1] = p
+ end
+ if input.times then check(ctx.gen == 2 and native.byTime ~= nil, "time-specific encounters require a native time table") end
+ if native.byTime then
+ result.byTime = {}
+ for time, nativeTime in pairs({ MORN = "morn", DAY = "day", NITE = "nite" }) do
+ local shared = { rate = input.rate, slots = input.slots }
+ -- A full detached record contains inherited times: only explicit
+ -- changes override a shared rate/slot patch.
+ local specific = input.times and input.times[time]
+ if specific and before.times and equal(specific, before.times[time]) then specific = nil end
+ if specific then
+ for k, v in pairs(specific) do
+ if not before.times or not before.times[time] or not equal(v, before.times[time][k]) then shared[k] = v end
+ end
+ end
+ if nativeTime == "day" then
+ local d = areaWrite(shared, { rate = native.rate, rateMax = native.rateMax, slots = native.slots }, before, path)
+ if d.rate ~= nil then result.rate = d.rate end
+ if d.slots then result.slots = d.slots end
+ elseif native.byTime[nativeTime] then
+ local p = copy(path); p[#p + 1] = "byTime"; p[#p + 1] = nativeTime
+ result.byTime[nativeTime] = areaWrite(shared, native.byTime[nativeTime], before.times and before.times[time], p)
+ end
+ end
+ end
+ return result
+ end
+ for _, key in ipairs({ "grass", "water" }) do
+ if value[key] ~= nil then out[key] = areaWrite(value[key], old and old[key], previous[key], { key }) end
+ end
+ end
+ return out, replacements
+end
+
+local function supported(kind, gen)
+ if kind == "encounters" and gen >= 4 then
+ return false, "unsupported: DS encounters have seasonal/daily replacement tables; use native content"
+ end
+ return true
+end
+local function protect(fn)
+ local result = { pcall(fn) }
+ if not result[1] then
+ if type(result[2]) == "table" and result[2].unsupported then return nil, "unsupported: " .. result[2].unsupported end
+ error(result[2], 0)
+ end
+ return unpack(result, 2)
+end
+function U.new(loader, mod, native, gen)
+ local out = {}; for name, handle in pairs(native) do out[name] = handle end
+ for kind, shape in pairs(Shapes) do
+ local registry = loader.content[kind]
+ if gen >= 4 and kind ~= "encounters" then registry.numericIds = true end
+ local f = Schemas.f
+ registry.referenceSpec = copy(registry.spec)
+ if kind == "pokemon" then
+ registry.referenceSpec.fields.evolutions = f.list(f.rec {
+ species = f.opt(f.id("pokemon")), target = f.opt(f.id("pokemon")),
+ item = f.opt(f.id("items")), heldItem = f.opt(f.id("items")),
+ })
+ elseif kind == "trainers" then
+ local parties = f.list(f.list(f.rec { species = f.id("pokemon"),
+ item = f.opt(f.id("items")), moves = f.opt(f.list(f.id("moves"))) }))
+ registry.referenceSpec.fields.parties = parties
+ registry.referenceSpec.fields.party = parties.inner
+ elseif kind == "encounters" then
+ local slots = f.list(f.rec { species = f.id("pokemon") })
+ local area = f.rec { slots = slots, byTime = f.opt(f.map(f.str, f.rec { slots = slots })) }
+ registry.referenceSpec.fields.grass, registry.referenceSpec.fields.water = area, area
+ if gen >= 4 then
+ -- The DS writer is unavailable, but removing a numeric species must
+ -- still see native encounter references, including seasonal tables.
+ local fields = {}
+ for _, key in ipairs({ "grass", "darkGrass", "rustling" }) do fields[key] = slots end
+ for _, key in ipairs({ "surf", "oldRod", "goodRod", "superRod", "ripplingSurf", "ripplingFishing" }) do
+ fields[key] = f.rec { slots = slots }
+ end
+ fields.timed = f.map(f.str, f.list(f.id("pokemon")))
+ fields.radar = f.list(f.id("pokemon"))
+ fields.dualSlot = f.map(f.str, f.list(f.id("pokemon")))
+ fields.swarm = f.union { f.list(f.id("pokemon")), f.map(f.str, f.id("pokemon")) }
+ fields.seasons = f.map(f.str, f.rec(fields))
+ registry.referenceSpec.fields = fields
+ end
+ end
+ local available, why = supported(kind, gen)
+ local function context() return { loader = loader, gen = gen, ids = Ids.new(loader) } end
+ local function read(id)
+ if not available then return nil, why end
+ local ctx = context()
+ local found, err = ctx.ids:resolve(kind, id)
+ if not found then return nil, err end
+ local raw = registry:get(found.key)
+ return raw and readRecord(ctx, kind, id, raw) or nil
+ end
+ local handle = { available = available, supports = function() return available, why end,
+ get = function(_, id) return read(id) end,
+ has = function(_, id) return read(id) ~= nil end,
+ each = function()
+ if not available then return function() end, why end
+ local ctx, keys = context(), {}
+ for id, found in pairs(ctx.ids:entries(kind)) do
+ if found == false then error("ambiguous " .. kind .. " ID: " .. id, 0) end
+ keys[#keys + 1] = id
+ end
+ table.sort(keys)
+ local i = 0
+ return function()
+ i = i + 1; local id = keys[i]
+ if id == nil then return end
+ local found = ctx.ids:resolve(kind, id)
+ return id, readRecord(ctx, kind, id, registry:get(found.key))
+ end
+ end,
+ }
+ for _, verb in ipairs({ "register", "override", "patch", "remove" }) do
+ handle[verb] = function(_, id, value)
+ if not available then return nil, why end
+ return protect(function()
+ check(type(id) == "string" and id ~= "", "operation id must be a non-empty string")
+ local ctx = context()
+ local found, err = ctx.ids:resolve(kind, id)
+ if not found and err:match("^ambiguous") then unsupported(err) end
+ if not found then
+ if gen >= 4 then unsupported("new numeric records require native storage allocation") end
+ found = { key = id, keys = { id } }
+ end
+ local old = registry:get(found.key)
+ if verb == "register" then check(old == nil, kind .. " already registered: " .. id) end
+ if verb == "remove" then
+ check(not registry.frozen, kind .. ": content is frozen after load")
+ loader:_journal(kind)
+ for _, key in ipairs(found.keys) do registry:remove(key, mod.manifest.id) end
+ return true
+ end
+ finite(value)
+ check(type(value) == "table" and value ~= DELETE, "expected record")
+ for key in pairs(value) do check(shape.fields[key] ~= nil, "unknown " .. kind .. " field " .. tostring(key)) end
+ local function deletes(fields, record)
+ for key, entry in pairs(record) do
+ local field = fields[key]
+ if field and field.kind ~= "opt" then
+ check(entry ~= DELETE or field.kind == "list", "cannot delete required field " .. key)
+ if field.kind == "rec" and type(entry) == "table" then deletes(field.fields, entry) end
+ end
+ end
+ end
+ deletes(shape.fields, value)
+ local valid, validation = Schemas.check(shape, kind, id, value, verb)
+ check(valid, validation or "invalid record")
+ local payload, replacements = writeRecord(ctx, kind, id, value, old, verb)
+ if verb == "patch" and not old then unsupported("cannot patch absent " .. kind .. " ID: " .. id) end
+ check(not registry.frozen, kind .. ": content is frozen after load")
+ loader:_journal(kind)
+ for _, key in ipairs(found.keys) do
+ if verb == "patch" then registry:patch(key, payload, mod.manifest.id, replacements)
+ else
+ local record = Merge.deepMerge(copy(old or {}), payload)
+ for _, path in ipairs(replacements) do
+ local into, from = record, payload
+ for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end
+ into[path[#path]] = copy(from[path[#path]])
+ end
+ registry[verb](registry, key, record, mod.manifest.id)
+ end
+ end
+ return true
+ end)
+ end
+ end
+ out[kind] = handle
+ end
+ return out
+end
+return U
diff --git a/src/mods/UnifiedEffects.lua b/src/mods/UnifiedEffects.lua
new file mode 100644
index 000000000..2083f29a1
--- /dev/null
+++ b/src/mods/UnifiedEffects.lua
@@ -0,0 +1,135 @@
+-- Canonical effect IDs over this host's shared native battle handlers.
+local C = require("src.mods.MoveEffectContract")
+local E = {}
+local numbers, nativeNames, variants = {}, {}, {}
+for number in pairs(C.native3) do
+ numbers[#numbers + 1] = tonumber(number)
+end
+table.sort(numbers)
+for _, number in ipairs(numbers) do
+ for _, name in ipairs(C.native3[tostring(number)]) do
+ if nativeNames[name] == nil then
+ nativeNames[name] = number
+ end
+ end
+end
+for id in pairs(C.variants) do
+ variants[#variants + 1] = id
+end
+table.sort(variants)
+local function native(row, gen, chance)
+ if gen == 1 then
+ return row.native
+ end
+ local names = C.native3[tostring(row.semantic)]
+ if not names then
+ return nil
+ end
+ return names[2] and (chance or row.chance or 0) > 10 and names[2] or names[1]
+end
+local function opaque(value, gen)
+ return "native:" .. gen .. ":" .. tostring(value)
+end
+function E.chance(raw, gen)
+ if gen ~= 2 and gen ~= 3 then
+ return nil
+ end
+ if type(raw.effectChance) == "number" then
+ return math.max(0, math.min(100, math.floor(raw.effectChance * 100 / 256 + 0.5)))
+ end
+ return gen == 3 and raw.secondaryChance or nil
+end
+function E.read(value, gen, raw)
+ if value == nil then
+ return nil
+ end
+ if gen == 1 or gen > 3 then
+ return type(value) == "string" and value or nil
+ end
+ raw = raw or {}
+ local probability = E.chance(raw, gen)
+ local authored = raw._api3EffectId
+ local binding = type(authored) == "string" and C.bindings[authored]
+ if binding and binding[tostring(gen)] then
+ local row = binding[tostring(gen)]
+ for _, name in ipairs(C.native3[tostring(row.semantic)] or {}) do
+ if name == value then
+ return authored
+ end
+ end
+ end
+ if authored == value and not C.reserved[authored] then
+ return authored
+ end
+ local semantic = nativeNames[value]
+ if gen == 3 and type(raw.gen3Effect) == "number" then
+ local names = C.native3[tostring(raw.gen3Effect)]
+ for _, name in ipairs(names or {}) do
+ if name == value then
+ semantic = raw.gen3Effect
+ end
+ end
+ elseif value == "HEAL_EFFECT" and raw.id == "REST" then
+ semantic = 37
+ end
+ if not semantic then
+ return opaque(value, gen)
+ end
+ for _, id in ipairs(variants) do
+ local row = C.variants[id]
+ if row.semantic == semantic and row.chance == probability and C.bindings[id][tostring(gen)] then
+ return id
+ end
+ end
+ local id = C.read3[tostring(semantic)]
+ if id and C.bindings[id] and C.bindings[id][tostring(gen)] then
+ return id
+ end
+ return opaque(value, gen)
+end
+function E.write(id, gen, old, registered)
+ if type(id) ~= "string" or id == "" then
+ return nil, "move effect must be a string id"
+ end
+ id = ({ HIT = "NO_ADDITIONAL_EFFECT", SLEEP = "SLEEP_EFFECT" })[id] or id
+ if old and (id == E.read(old.effect, gen, old) or id == opaque(old.effect, gen)) then
+ return { effect = old.effect }, false
+ end
+ if gen > 3 then
+ return nil, "changing move effects has no native adapter for this generation"
+ end
+ if id:match("^native:") then
+ return nil, "native effect token requires its original record"
+ end
+ local binding = C.bindings[id]
+ local row = binding and binding[tostring(gen)]
+ if not row then
+ if binding or C.reserved[id] or not registered(id) then
+ return nil, "effect has no native handler: " .. id
+ end
+ return { effect = id, _api3EffectId = gen > 1 and id or nil }, true
+ end
+ if
+ gen > 1
+ and (
+ (row.semantic == 37 and (not old or old.id ~= "REST"))
+ or row.semantic == 32 and old and old.id == "REST"
+ or row.semantic == 130 and (not old or old.id ~= "SONICBOOM")
+ or row.semantic == 41 and old and old.id == "SONICBOOM"
+ )
+ then
+ return nil, "native effect depends on move identity: " .. id
+ end
+ local target = native(row, gen)
+ if not target or not registered(target) then
+ return nil, "effect has no native handler: " .. id
+ end
+ local out = { effect = target }
+ if gen > 1 then
+ out._api3EffectId = id
+ out.probability = row.chance
+ out.gen3Effect = gen == 3 and row.semantic or nil
+ end
+ return out, true
+end
+return E
diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua
new file mode 100644
index 000000000..50866a636
--- /dev/null
+++ b/src/mods/UnifiedIds.lua
@@ -0,0 +1,85 @@
+-- Resolve portable names through this boot's own data, never a hard-coded
+-- National Dex/item-number table (ROM hacks can assign entirely different IDs).
+local Ids = {}
+local function slug(name)
+ if type(name) ~= "string" then return nil end
+ name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M")
+ :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "")
+ name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2")
+ return name ~= "" and name or nil
+end
+local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" }
+local function canonical(key, row, kind)
+ if kind == "pokemon" or kind == "moves" or kind == "items" then
+ if type(key) ~= "string" or key:match("^SPECIES_%d+$")
+ or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then
+ return aliases[slug(row.name)] or slug(row.name)
+ end
+ return aliases[key] or key
+ end
+ -- Trainer classes, individual trainers and map IDs are game-specific.
+ return type(key) == "number" and tostring(key) or key
+end
+
+function Ids.new(loader)
+ local indexes = {}
+ local function index(kind)
+ if indexes[kind] then return indexes[kind] end
+ local out = { public = {}, native = {} }
+ indexes[kind] = out
+ local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind))
+ -- Preserve identity/name metadata before the loader merges alias keys
+ -- into separate result tables. Presentation-name patches cannot rename IDs.
+ loader._unifiedIdBase = loader._unifiedIdBase or {}
+ local base = loader._unifiedIdBase[kind]
+ if not base then
+ base = {}
+ for key, row in pairs(registry.base and registry.base() or {}) do
+ if type(row) == "table" then
+ base[key] = { identity = row, id = canonical(key, row, kind) }
+ end
+ end
+ loader._unifiedIdBase[kind] = base
+ end
+ local groups = {}
+ for key, row in registry:each() do
+ if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then
+ -- Aliases pointing at one pristine record form one identity even
+ -- after an earlier native mod patched only one of its keys.
+ local identity = base[key] and base[key].identity or row
+ local group = groups[identity]
+ if not group then
+ local id = base[key] and base[key].id or canonical(key, row, kind)
+ if id then
+ group = { id = id, key = key, keys = {} }
+ groups[identity] = group
+ local previous = out.public[id]
+ -- false is an ambiguity marker, never last-writer-wins.
+ if previous ~= nil then out.public[id] = false else out.public[id] = group end
+ end
+ end
+ if group then
+ group.keys[#group.keys + 1] = key
+ out.native[key] = group
+ if type(key) == "number" then group.key = key end
+ end
+ end
+ end
+ return out
+ end
+ return {
+ resolve = function(_, kind, id)
+ local found = index(kind).public[id]
+ if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end
+ if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end
+ return found
+ end,
+ read = function(_, kind, id)
+ if id == nil or id == false or id == 0 or id == "" then return nil end
+ local found = index(kind).native[id]
+ return found and found.id or tostring(id)
+ end,
+ entries = function(_, kind) return index(kind).public end,
+ }
+end
+return Ids
diff --git a/src/mods/UnifiedSchemas.lua b/src/mods/UnifiedSchemas.lua
new file mode 100644
index 000000000..aa9a91cbe
--- /dev/null
+++ b/src/mods/UnifiedSchemas.lua
@@ -0,0 +1,60 @@
+-- The opt-in content format is separate from the legacy/native schemas.
+-- These are definition records, not saved Pokemon or callback payloads.
+local Schemas = require("src.mods.Schemas")
+local f = Schemas.f
+local id = f.str
+local ids = f.list(id)
+local level = f.int(1, 100)
+local stats = f.rec {
+ hp = f.int(1, 255), attack = f.int(1, 255), defense = f.int(1, 255),
+ speed = f.int(1, 255), special = f.int(1, 255),
+ specialAttack = f.opt(f.int(1, 255)), specialDefense = f.opt(f.int(1, 255)),
+}
+local evolution = f.rec {
+ method = id, species = id, level = f.opt(level), item = f.opt(id),
+ time = f.opt(f.enum { "ANYTIME", "MORNDAY", "NITE" }),
+ comparison = f.opt(f.enum { "ATK_GT_DEF", "ATK_LT_DEF", "ATK_EQ_DEF" }),
+}
+local slot = f.rec {
+ species = id, level = f.opt(level), minLevel = f.opt(level), maxLevel = f.opt(level),
+ chance = f.opt(f.numRange(0, 100)),
+}
+local area = f.rec { rate = f.numRange(0, 256), slots = f.list(slot),
+ times = f.opt(f.map(f.enum { "MORN", "DAY", "NITE" },
+ f.rec { rate = f.opt(f.numRange(0, 256)), slots = f.opt(f.list(slot)) })) }
+local U = {
+ pokemon = { fields = {
+ id = id, name = f.str, dex = f.opt(f.int(1)), types = ids, baseStats = stats,
+ catchRate = f.int(0, 255), baseExp = f.int(0), growthRate = id,
+ level1Moves = ids, learnset = f.list(f.rec { level = level, move = id }),
+ evolutions = f.list(evolution), tmhm = f.opt(ids), eggMoves = f.opt(ids),
+ genderRatio = f.opt(f.int(0, 255)), eggCycles = f.opt(f.int(0, 255)),
+ spriteFront = f.opt(f.path), spriteBack = f.opt(f.path), frontSize = f.opt(f.int(1, 7)),
+ trueColor = f.opt(f.bool),
+ } },
+ moves = { fields = {
+ id = id, name = f.str, type = id, power = f.int(0, 255), accuracy = f.int(0, 100),
+ pp = f.int(0, 64), effect = f.opt(id), secondaryChance = f.opt(f.int(0, 100)),
+ category = f.opt(f.enum { "physical", "special", "status" }),
+ priority = f.opt(f.int(-7, 7)), description = f.opt(f.str),
+ } },
+ items = { fields = {
+ id = id, name = f.str, price = f.int(0),
+ machine = f.opt(f.rec { kind = f.str, move = id, number = f.int(0) }),
+ effect = f.opt(id), ball = f.opt(id), tossable = f.opt(f.bool),
+ needsTarget = f.opt(f.bool), description = f.opt(f.str),
+ } },
+ trainers = { fields = {
+ id = id, name = f.str, pic = f.opt(f.path), baseMoney = f.opt(f.int(0)),
+ battleTheme = f.opt(id), partyNames = f.opt(f.list(f.str)),
+ parties = f.list(f.list(f.rec {
+ species = id, level = level, item = f.opt(id), moves = f.opt(ids),
+ })),
+ } },
+ encounters = { fields = { id = f.opt(id), grass = f.opt(area), water = f.opt(area) } },
+}
+for _, spec in pairs(U) do
+ spec.fields.native = f.opt(f.any)
+ spec.semantics = "record"
+end
+return U
diff --git a/src/mods/WorldOverview.lua b/src/mods/WorldOverview.lua
new file mode 100644
index 000000000..491963d48
--- /dev/null
+++ b/src/mods/WorldOverview.lua
@@ -0,0 +1,153 @@
+-- Read-only world summaries. Do not call timeOfDay(), rollTable(), map loading,
+-- or object visibility refreshes here: each can change the live simulation.
+local Ids = require("src.mods.UnifiedIds")
+local GameVersion = require("src.core.GameVersion")
+local Overview = {}
+
+function Overview.context(loader)
+ Ids.new(loader):entries("pokemon")
+ return function()
+ return Ids.new(loader)
+ end
+end
+
+function Overview.install(api, loader)
+ local context = Overview.context(loader)
+ if api.supports then
+ local supports = api.supports
+ function api:supports(surface, name)
+ if surface == "world" and (name == "mapOverview" or name == "effectiveEncounters") then
+ return true
+ end
+ return supports(self, surface, name)
+ end
+ end
+ return context
+end
+
+local function dataIds(data)
+ local rows = data.pokemon or {}
+ return Ids.new({
+ content = {
+ pokemon = {
+ base = function()
+ return rows
+ end,
+ each = function()
+ return pairs(rows)
+ end,
+ },
+ },
+ })
+end
+
+local periods = { MORN = "MORNING", MORNING = "MORNING", DAY = "DAY", NITE = "NITE", NIGHT = "NITE" }
+local hours = { MORNING = 6, DAY = 12, NITE = 22 }
+
+function Overview.encounters(game, ow, context, mapId, terrain, opts)
+ if terrain ~= "grass" and terrain ~= "water" and terrain ~= "indoor" then
+ return nil, "invalid terrain: " .. tostring(terrain)
+ end
+ if opts ~= nil and type(opts) ~= "table" then
+ return nil, "opts must be a table"
+ end
+ local daytime = opts and opts.daytime
+ if daytime ~= nil then
+ daytime = type(daytime) == "string" and periods[daytime:upper()]
+ if not daytime then
+ return nil, "invalid daytime"
+ end
+ end
+ local data = game and game.data
+ if not (data and type(data.encounters) == "table") then
+ return nil, "encounters data not loaded"
+ end
+ local def = data.maps and data.maps[mapId]
+ if not def then
+ return nil, "unknown map: " .. tostring(mapId)
+ end
+ -- HGSS's Safari table depends on the player's position in a six-area matrix.
+ -- Encounter.forMap resolves it through the Game singleton and seeds Safari
+ -- save state. Refuse this exceptional table instead of querying another game.
+ if def.cameraFamily == "hgss" and require("src.world.HgssSafariZone").isSafariMap(def) then
+ return nil, "position-dependent Safari encounters unavailable"
+ end
+ if GameVersion.get() == "emerald" then
+ if
+ require("src.script.Gen3BattlePike").inWildRoom(mapId)
+ or require("src.script.Gen3BattlePyramid").onFloor(mapId)
+ then
+ return nil, "facility encounters unavailable"
+ end
+ end
+ local Encounter = require("src.world.Encounter")
+ local hour = daytime and hours[daytime]
+ local enc = Encounter.forMap(data, def, mapId, hour, game.save)
+ if not daytime then
+ -- A world.tod hook's most recent effective period is already on the world.
+ -- Calling it again would execute mod code and can emit/change world state.
+ local Runtime = require("src.mods.Runtime")
+ if Runtime.wantsHook("world.tod") and ow then
+ daytime = ow.tod
+ end
+ if GameVersion.isGen2() then
+ if not daytime then
+ hour = tonumber(os.date("%H")) or 12
+ daytime = hour < 4 and "NITE" or hour < 10 and "MORNING" or hour < 18 and "DAY" or "NITE"
+ end
+ else
+ daytime = daytime or (ow and ow.tod) or "DAY"
+ end
+ end
+ local slots = enc and enc[terrain == "indoor" and "grass" or terrain]
+ if terrain ~= "water" then
+ slots = Encounter.atTime(slots, daytime)
+ end
+ if not slots then
+ return { chance = 0, dist = {} }
+ end
+ local maximum = tonumber(slots.rateMax) or 256
+ local rate = tonumber(slots.rate) or 0
+ local span = tonumber(slots.bucketSpan) or 256
+ if
+ maximum <= 0
+ or maximum ~= math.floor(maximum)
+ or maximum == math.huge
+ or rate ~= rate
+ or rate == math.huge
+ or rate == -math.huge
+ or span <= 0
+ or span ~= math.floor(span)
+ or span == math.huge
+ then
+ return nil, "invalid encounter rate/span"
+ end
+ local dist, prev = {}, 0
+ local weights = slots.buckets or require("src.world.FieldDefaults").constant(data, "encounterBuckets")
+ if type(weights) ~= "table" or (slots.slots ~= nil and type(slots.slots) ~= "table") then
+ return nil, "invalid encounter buckets/slots"
+ end
+ local ids = context and context() or dataIds(data)
+ for i, threshold in ipairs(weights or {}) do
+ if
+ type(threshold) ~= "number"
+ or threshold ~= math.floor(threshold)
+ or threshold < prev
+ or threshold > span
+ then
+ return nil, "invalid encounter buckets"
+ end
+ local slot = slots.slots and slots.slots[i]
+ if rate > 0 and slot and slot.species and threshold > prev then
+ local id = ids:read("pokemon", slot.species)
+ if id then
+ dist[id] = (dist[id] or 0) + threshold - prev
+ end
+ end
+ prev = threshold
+ end
+ -- Same independent integer rate gate as rollTable, without drawing a slot.
+ return { chance = math.max(0, math.min(maximum, math.ceil(rate))) / maximum, dist = dist }
+end
+
+return Overview
diff --git a/src/mods/WorldReads.lua b/src/mods/WorldReads.lua
new file mode 100644
index 000000000..2ca8f2504
--- /dev/null
+++ b/src/mods/WorldReads.lua
@@ -0,0 +1,227 @@
+-- Data-only readers shared by mod.world. Nothing here constructs a Pokemon,
+-- loads a map, calls gameplay methods or advances either random generator.
+local Ids = require("src.mods.UnifiedIds")
+local Reads = {}
+local METHODS = { party = true, activeBlockAt = true, liveMaps = true }
+
+function Reads.supports(name)
+ return type(name) == "string" and METHODS[name] == true
+end
+
+-- Capture native alias identity/name metadata before the loader splits alias
+-- rows during merging. Fresh indexes at read time still include later mods.
+function Reads.context(loader)
+ local ids = Ids.new(loader)
+ for _, kind in ipairs({ "pokemon", "moves", "items" }) do
+ ids:entries(kind)
+ end
+ return {
+ ids = function()
+ return Ids.new(loader)
+ end,
+ }
+end
+
+-- Engine callers can construct WorldAPI without a mod loader too. The
+-- private registry facade only reads data; Ids' bookkeeping stays on it.
+local function dataIds(data)
+ local loader = { content = {} }
+ for _, kind in ipairs({ "pokemon", "moves", "items" }) do
+ local rows = type(data[kind]) == "table" and data[kind] or {}
+ loader.content[kind] = {
+ base = function()
+ return rows
+ end,
+ each = function()
+ return pairs(rows)
+ end,
+ }
+ end
+ return Ids.new(loader)
+end
+
+local function copy(value, seen)
+ local kind = type(value)
+ if kind ~= "table" then
+ if kind == "number" or kind == "string" or kind == "boolean" then
+ return value
+ end
+ return nil
+ end
+ seen = seen or {}
+ if seen[value] then
+ return seen[value]
+ end
+ local out = {}
+ seen[value] = out
+ for key, sub in pairs(value) do
+ if type(key) == "string" or type(key) == "number" then
+ out[key] = copy(sub, seen)
+ end
+ end
+ return out
+end
+local STATUS =
+ { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" }
+local STAT = {
+ hp = "hp",
+ attack = "attack",
+ defense = "defense",
+ speed = "speed",
+ special = "special",
+ specialAttack = "spatk",
+ specialDefense = "spdef",
+}
+
+-- The same common live-Pokemon shape as the unified callback boundary.
+-- Native diagnostics are data-only copies, including unknown save extensions.
+function Reads.party(game, context)
+ local save = game and game.save
+ if not save then
+ return nil, "no save"
+ end
+ local data = game.data or {}
+ local ids = context and context.ids() or dataIds(data)
+ local out = {}
+ for i, raw in ipairs(save.party or {}) do
+ local stats, moves = {}, {}
+ for key, native in pairs(STAT) do
+ stats[key] = (raw.stats or {})[native]
+ end
+ stats.specialAttack = stats.specialAttack or stats.special
+ stats.specialDefense = stats.specialDefense or stats.special
+ for slot, move in ipairs(raw.moves or {}) do
+ local id = type(move) == "table" and move.id or move
+ local def = (data.moves or {})[id]
+ local ups = type(move) == "table" and (move.ppUps or 0) or 0
+ moves[slot] = {
+ id = ids:read("moves", id),
+ pp = type(move) == "table" and move.pp or nil,
+ maxPp = def and ((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil,
+ ppUps = ups,
+ }
+ end
+ out[i] = {
+ species = ids:read("pokemon", raw.species),
+ item = ids:read("items", raw.item),
+ nickname = raw.nickname,
+ name = raw.name,
+ level = raw.level,
+ hp = raw.hp,
+ maxHp = raw.stats and raw.stats.hp,
+ status = STATUS[raw.status] or raw.status,
+ isEgg = raw.isEgg == true,
+ stats = stats,
+ moves = moves,
+ native = copy(raw),
+ }
+ end
+ return out
+end
+
+local function integer(value, low)
+ return type(value) == "number"
+ and value == value
+ and value < math.huge
+ and value > -math.huge
+ and value % 1 == 0
+ and value >= low
+end
+
+-- Public block coordinates are zero-based, not walk-cell coordinates. Derive
+-- geometry from this map's runtime tileset, not the selected game's number.
+function Reads.metadata(map)
+ local def = map and map.def
+ if
+ not def
+ or not integer(def.width, 1)
+ or not integer(def.height, 1)
+ or not integer(map.blockCells, 1)
+ or not integer(map.blockTiles, 1)
+ or map.blockTiles * 8 ~= map.blockCells * 16
+ then
+ return nil, "block geometry unavailable"
+ end
+ return {
+ tileSize = map.blockTiles * 8,
+ cellSize = 16,
+ coordinateBase = 0,
+ kind = (def.generation == 4 or def.generation == 5 or def.gen5) and "runtime_cell"
+ or map.blockCells == 1 and "metatile"
+ or "block",
+ width = def.width,
+ height = def.height,
+ }
+end
+
+function Reads.activeBlockAt(map, mapId, bx, by)
+ if map.id ~= mapId then
+ return nil, "map is not active"
+ end
+ if not integer(bx, 0) or not integer(by, 0) then
+ return nil, "invalid block coordinates"
+ end
+ local metadata, reason = Reads.metadata(map)
+ if not metadata then
+ return nil, reason
+ end
+ if bx >= metadata.width or by >= metadata.height then
+ return nil, "block coordinates out of bounds"
+ end
+ local index = by * metadata.width + bx + 1
+ local block = map.blockPatch and map.blockPatch[index]
+ if block == nil then
+ local blocks = map.def.blocks
+ if type(blocks) == "table" then
+ block = blocks[index]
+ elseif type(blocks) == "string" then
+ -- Respect a cache already used by Map:blockAt, without populating it.
+ if map.def._blockArray then
+ block = map.def._blockArray[index]
+ else
+ local lo, hi = blocks:byte(index * 2 - 1, index * 2)
+ if hi then
+ block = (lo + hi * 256) % 1024
+ end
+ end
+ end
+ end
+ if not integer(block, 0) then
+ return nil, "block unavailable"
+ end
+ return block, metadata
+end
+
+function Reads.liveMaps(world)
+ local active = world.map
+ local out = {}
+ local seen = {}
+ local function add(map, x, y, isActive)
+ if not map then
+ return
+ end
+ -- A connected layout can show one map at several different offsets.
+ -- Collapse duplicate placements only, retaining the spatial instances.
+ local key = tostring(map.id) .. ":" .. tostring(x) .. ":" .. tostring(y)
+ if seen[key] then
+ return
+ end
+ seen[key] = true
+ local metadata = Reads.metadata(map)
+ out[#out + 1] = {
+ mapId = map.id,
+ x = x,
+ y = y,
+ active = isActive,
+ width = map.widthCells,
+ height = map.heightCells,
+ block = metadata,
+ }
+ end
+ add(active, 0, 0, true)
+ for _, neighbor in ipairs(world.neighbors or {}) do
+ add(neighbor.map, neighbor.cx or 0, neighbor.cy or 0, false)
+ end
+ return out
+end
+return Reads
diff --git a/src/mods/flag_names/emerald.lua b/src/mods/flag_names/emerald.lua
new file mode 100644
index 000000000..815b89f80
--- /dev/null
+++ b/src/mods/flag_names/emerald.lua
@@ -0,0 +1,1545 @@
+-- Generated flag identifiers only; no engine implementation.
+-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36
+-- Regenerate with tools/flag-catalog-check/generate.py.
+return {
+ limit = 2400,
+ records = {
+ [1] = { "FLAG_TEMP_1", "FLAG_TEMP_SKIP_GABBY_INTERVIEW", "TEMP_1", "TEMP_SKIP_GABBY_INTERVIEW" },
+ [2] = { "FLAG_TEMP_2", "FLAG_TEMP_REGICE_PUZZLE_STARTED", "TEMP_2", "TEMP_REGICE_PUZZLE_STARTED" },
+ [3] = { "FLAG_TEMP_3", "FLAG_TEMP_REGICE_PUZZLE_FAILED", "TEMP_3", "TEMP_REGICE_PUZZLE_FAILED" },
+ [4] = { "FLAG_TEMP_4", "TEMP_4" },
+ [5] = { "FLAG_TEMP_5", "TEMP_5" },
+ [6] = { "FLAG_TEMP_6", "TEMP_6" },
+ [7] = { "FLAG_TEMP_7", "TEMP_7" },
+ [8] = { "FLAG_TEMP_8", "NUM_BADGES", "TEMP_8" },
+ [9] = { "FLAG_TEMP_9", "TEMP_9" },
+ [10] = { "FLAG_TEMP_A", "TEMP_A" },
+ [11] = { "FLAG_TEMP_B", "TEMP_B" },
+ [12] = { "FLAG_TEMP_C", "TEMP_C" },
+ [13] = { "FLAG_TEMP_D", "TEMP_D" },
+ [14] = { "FLAG_TEMP_E", "TEMP_E" },
+ [15] = { "FLAG_TEMP_F", "TEMP_F" },
+ [16] = { "FLAG_TEMP_10", "NUM_TEMP_VARS", "TEMP_10" },
+ [17] = { "FLAG_TEMP_11", "FLAG_TEMP_HIDE_MIRAGE_ISLAND_BERRY_TREE", "TEMP_11", "TEMP_HIDE_MIRAGE_ISLAND_BERRY_TREE" },
+ [18] = { "FLAG_TEMP_12", "TEMP_12" },
+ [19] = { "FLAG_TEMP_13", "TEMP_13" },
+ [20] = { "FLAG_TEMP_14", "NUM_WONDER_CARD_FLAGS", "TEMP_14" },
+ [21] = { "FLAG_TEMP_15", "TEMP_15" },
+ [22] = { "FLAG_TEMP_16", "TEMP_16" },
+ [23] = { "FLAG_TEMP_17", "TEMP_17" },
+ [24] = { "FLAG_TEMP_18", "TEMP_18" },
+ [25] = { "FLAG_TEMP_19", "TEMP_19" },
+ [26] = { "FLAG_TEMP_1A", "TEMP_1A" },
+ [27] = { "FLAG_TEMP_1B", "TEMP_1B" },
+ [28] = { "FLAG_TEMP_1C", "TEMP_1C" },
+ [29] = { "FLAG_TEMP_1D", "TEMP_1D" },
+ [30] = { "FLAG_TEMP_1E", "TEMP_1E" },
+ [31] = { "FLAG_TEMP_1F", "TEMP_1F", "TEMP_FLAGS_END" },
+ [32] = { "FLAG_UNUSED_0x020", "NUM_TEMP_FLAGS", "UNUSED_0x020" },
+ [33] = { "FLAG_UNUSED_0x021", "UNUSED_0x021" },
+ [34] = { "FLAG_UNUSED_0x022", "UNUSED_0x022" },
+ [35] = { "FLAG_UNUSED_0x023", "UNUSED_0x023" },
+ [36] = { "FLAG_UNUSED_0x024", "UNUSED_0x024" },
+ [37] = { "FLAG_UNUSED_0x025", "UNUSED_0x025" },
+ [38] = { "FLAG_UNUSED_0x026", "UNUSED_0x026" },
+ [39] = { "FLAG_UNUSED_0x027", "UNUSED_0x027" },
+ [40] = { "FLAG_UNUSED_0x028", "UNUSED_0x028" },
+ [41] = { "FLAG_UNUSED_0x029", "UNUSED_0x029" },
+ [42] = { "FLAG_UNUSED_0x02A", "UNUSED_0x02A" },
+ [43] = { "FLAG_UNUSED_0x02B", "UNUSED_0x02B" },
+ [44] = { "FLAG_UNUSED_0x02C", "UNUSED_0x02C" },
+ [45] = { "FLAG_UNUSED_0x02D", "UNUSED_0x02D" },
+ [46] = { "FLAG_UNUSED_0x02E", "UNUSED_0x02E" },
+ [47] = { "FLAG_UNUSED_0x02F", "UNUSED_0x02F" },
+ [48] = { "FLAG_UNUSED_0x030", "UNUSED_0x030" },
+ [49] = { "FLAG_UNUSED_0x031", "UNUSED_0x031" },
+ [50] = { "FLAG_UNUSED_0x032", "UNUSED_0x032" },
+ [51] = { "FLAG_UNUSED_0x033", "UNUSED_0x033" },
+ [52] = { "FLAG_UNUSED_0x034", "UNUSED_0x034" },
+ [53] = { "FLAG_UNUSED_0x035", "UNUSED_0x035" },
+ [54] = { "FLAG_UNUSED_0x036", "UNUSED_0x036" },
+ [55] = { "FLAG_UNUSED_0x037", "UNUSED_0x037" },
+ [56] = { "FLAG_UNUSED_0x038", "UNUSED_0x038" },
+ [57] = { "FLAG_UNUSED_0x039", "UNUSED_0x039" },
+ [58] = { "FLAG_UNUSED_0x03A", "UNUSED_0x03A" },
+ [59] = { "FLAG_UNUSED_0x03B", "UNUSED_0x03B" },
+ [60] = { "FLAG_UNUSED_0x03C", "UNUSED_0x03C" },
+ [61] = { "FLAG_UNUSED_0x03D", "UNUSED_0x03D" },
+ [62] = { "FLAG_UNUSED_0x03E", "UNUSED_0x03E" },
+ [63] = { "FLAG_UNUSED_0x03F", "UNUSED_0x03F" },
+ [64] = { "FLAG_UNUSED_0x040", "NUM_DAILY_FLAGS", "UNUSED_0x040" },
+ [65] = { "FLAG_UNUSED_0x041", "UNUSED_0x041" },
+ [66] = { "FLAG_UNUSED_0x042", "UNUSED_0x042" },
+ [67] = { "FLAG_UNUSED_0x043", "UNUSED_0x043" },
+ [68] = { "FLAG_UNUSED_0x044", "UNUSED_0x044" },
+ [69] = { "FLAG_UNUSED_0x045", "UNUSED_0x045" },
+ [70] = { "FLAG_UNUSED_0x046", "UNUSED_0x046" },
+ [71] = { "FLAG_UNUSED_0x047", "UNUSED_0x047" },
+ [72] = { "FLAG_UNUSED_0x048", "UNUSED_0x048" },
+ [73] = { "FLAG_UNUSED_0x049", "UNUSED_0x049" },
+ [74] = { "FLAG_UNUSED_0x04A", "UNUSED_0x04A" },
+ [75] = { "FLAG_UNUSED_0x04B", "UNUSED_0x04B" },
+ [76] = { "FLAG_UNUSED_0x04C", "UNUSED_0x04C" },
+ [77] = { "FLAG_UNUSED_0x04D", "UNUSED_0x04D" },
+ [78] = { "FLAG_UNUSED_0x04E", "UNUSED_0x04E" },
+ [79] = { "FLAG_UNUSED_0x04F", "UNUSED_0x04F" },
+ [80] = { "FLAG_HIDE_SKY_PILLAR_TOP_RAYQUAZA_STILL", "HIDE_SKY_PILLAR_TOP_RAYQUAZA_STILL" },
+ [81] = { "FLAG_SET_WALL_CLOCK", "SET_WALL_CLOCK" },
+ [82] = { "FLAG_RESCUED_BIRCH", "RESCUED_BIRCH" },
+ [83] = { "FLAG_LEGENDARIES_IN_SOOTOPOLIS", "LEGENDARIES_IN_SOOTOPOLIS" },
+ [84] = { "FLAG_UNUSED_0x054", "UNUSED_0x054" },
+ [85] = { "FLAG_UNUSED_0x055", "UNUSED_0x055" },
+ [86] = { "FLAG_HIDE_CONTEST_POKE_BALL", "HIDE_CONTEST_POKE_BALL" },
+ [87] = { "FLAG_MET_RIVAL_MOM", "MET_RIVAL_MOM" },
+ [88] = { "FLAG_BIRCH_AIDE_MET", "BIRCH_AIDE_MET" },
+ [89] = { "FLAG_DECLINED_BIKE", "DECLINED_BIKE" },
+ [90] = { "FLAG_RECEIVED_BIKE", "RECEIVED_BIKE" },
+ [91] = { "FLAG_WATTSON_REMATCH_AVAILABLE", "WATTSON_REMATCH_AVAILABLE" },
+ [92] = { "FLAG_COLLECTED_ALL_SILVER_SYMBOLS", "COLLECTED_ALL_SILVER_SYMBOLS" },
+ [93] = { "FLAG_GOOD_LUCK_SAFARI_ZONE", "GOOD_LUCK_SAFARI_ZONE" },
+ [94] = { "FLAG_RECEIVED_WAILMER_PAIL", "RECEIVED_WAILMER_PAIL" },
+ [95] = { "FLAG_RECEIVED_POKEBLOCK_CASE", "RECEIVED_POKEBLOCK_CASE" },
+ [96] = { "FLAG_RECEIVED_SECRET_POWER", "RECEIVED_SECRET_POWER" },
+ [97] = { "FLAG_MET_TEAM_AQUA_HARBOR", "MET_TEAM_AQUA_HARBOR" },
+ [98] = { "FLAG_TV_EXPLAINED", "TV_EXPLAINED" },
+ [99] = { "FLAG_MAUVILLE_GYM_BARRIERS_STATE", "MAUVILLE_GYM_BARRIERS_STATE" },
+ [100] = { "FLAG_MOSSDEEP_GYM_SWITCH_1", "MOSSDEEP_GYM_SWITCH_1" },
+ [101] = { "FLAG_MOSSDEEP_GYM_SWITCH_2", "MOSSDEEP_GYM_SWITCH_2" },
+ [102] = { "FLAG_MOSSDEEP_GYM_SWITCH_3", "MOSSDEEP_GYM_SWITCH_3" },
+ [103] = { "FLAG_MOSSDEEP_GYM_SWITCH_4", "MOSSDEEP_GYM_SWITCH_4" },
+ [104] = { "FLAG_UNUSED_0x068", "UNUSED_0x068" },
+ [105] = { "FLAG_OCEANIC_MUSEUM_MET_REPORTER", "OCEANIC_MUSEUM_MET_REPORTER" },
+ [106] = { "FLAG_RECEIVED_HM_STRENGTH", "RECEIVED_HM_STRENGTH" },
+ [107] = { "FLAG_RECEIVED_HM_ROCK_SMASH", "RECEIVED_HM_ROCK_SMASH" },
+ [108] = { "FLAG_WHITEOUT_TO_LAVARIDGE", "WHITEOUT_TO_LAVARIDGE" },
+ [109] = { "FLAG_RECEIVED_HM_FLASH", "RECEIVED_HM_FLASH" },
+ [110] = { "FLAG_RECEIVED_HM_FLY", "RECEIVED_HM_FLY" },
+ [111] = { "FLAG_GROUDON_AWAKENED_MAGMA_HIDEOUT", "GROUDON_AWAKENED_MAGMA_HIDEOUT" },
+ [112] = { "FLAG_TEAM_AQUA_ESCAPED_IN_SUBMARINE", "TEAM_AQUA_ESCAPED_IN_SUBMARINE" },
+ [113] = { "FLAG_UNUSED_RS_LEGENDARY_BATTLE_DONE", "UNUSED_RS_LEGENDARY_BATTLE_DONE" },
+ [114] = { "FLAG_SCOTT_CALL_BATTLE_FRONTIER", "SCOTT_CALL_BATTLE_FRONTIER" },
+ [115] = { "FLAG_RECEIVED_METEORITE", "RECEIVED_METEORITE" },
+ [116] = { "FLAG_ADVENTURE_STARTED", "ADVENTURE_STARTED" },
+ [117] = { "FLAG_DEFEATED_MAGMA_SPACE_CENTER", "DEFEATED_MAGMA_SPACE_CENTER" },
+ [118] = { "FLAG_MET_HIDDEN_POWER_GIVER", "MET_HIDDEN_POWER_GIVER" },
+ [119] = { "FLAG_CANCEL_BATTLE_ROOM_CHALLENGE", "CANCEL_BATTLE_ROOM_CHALLENGE" },
+ [120] = { "FLAG_LANDMARK_MIRAGE_TOWER", "LANDMARK_MIRAGE_TOWER" },
+ [121] = { "FLAG_RECEIVED_TM_BRICK_BREAK", "RECEIVED_TM_BRICK_BREAK" },
+ [122] = { "FLAG_RECEIVED_HM_SURF", "RECEIVED_HM_SURF" },
+ [123] = { "FLAG_RECEIVED_HM_DIVE", "RECEIVED_HM_DIVE" },
+ [124] = { "FLAG_REGISTER_RIVAL_POKENAV", "REGISTER_RIVAL_POKENAV" },
+ [125] = { "FLAG_DEFEATED_RIVAL_ROUTE_104", "DEFEATED_RIVAL_ROUTE_104" },
+ [126] = { "FLAG_DEFEATED_WALLY_VICTORY_ROAD", "DEFEATED_WALLY_VICTORY_ROAD" },
+ [127] = { "FLAG_MET_PRETTY_PETAL_SHOP_OWNER", "MET_PRETTY_PETAL_SHOP_OWNER" },
+ [128] = { "FLAG_ENABLE_ROXANNE_FIRST_CALL", "ENABLE_ROXANNE_FIRST_CALL", "NUM_SPECIAL_FLAGS" },
+ [129] = { "FLAG_KYOGRE_ESCAPED_SEAFLOOR_CAVERN", "KYOGRE_ESCAPED_SEAFLOOR_CAVERN" },
+ [130] = { "FLAG_DEFEATED_RIVAL_ROUTE103", "DEFEATED_RIVAL_ROUTE103" },
+ [131] = { "FLAG_RECEIVED_DOLL_LANETTE", "RECEIVED_DOLL_LANETTE" },
+ [132] = { "FLAG_RECEIVED_POTION_OLDALE", "RECEIVED_POTION_OLDALE" },
+ [133] = { "FLAG_RECEIVED_AMULET_COIN", "RECEIVED_AMULET_COIN" },
+ [134] = { "FLAG_PENDING_DAYCARE_EGG", "PENDING_DAYCARE_EGG" },
+ [135] = { "FLAG_THANKED_FOR_PLAYING_WITH_WALLY", "THANKED_FOR_PLAYING_WITH_WALLY" },
+ [136] = { "FLAG_ENABLE_FIRST_WALLY_POKENAV_CALL", "ENABLE_FIRST_WALLY_POKENAV_CALL" },
+ [137] = { "FLAG_RECEIVED_HM_CUT", "RECEIVED_HM_CUT" },
+ [138] = { "FLAG_SCOTT_CALL_FORTREE_GYM", "SCOTT_CALL_FORTREE_GYM" },
+ [139] = { "FLAG_DEFEATED_EVIL_TEAM_MT_CHIMNEY", "DEFEATED_EVIL_TEAM_MT_CHIMNEY" },
+ [140] = { "FLAG_RECEIVED_6_SODA_POP", "RECEIVED_6_SODA_POP" },
+ [141] = { "FLAG_DEFEATED_SEASHORE_HOUSE", "DEFEATED_SEASHORE_HOUSE" },
+ [142] = { "FLAG_DEVON_GOODS_STOLEN", "DEVON_GOODS_STOLEN" },
+ [143] = { "FLAG_RECOVERED_DEVON_GOODS", "RECOVERED_DEVON_GOODS" },
+ [144] = { "FLAG_RETURNED_DEVON_GOODS", "RETURNED_DEVON_GOODS" },
+ [145] = { "FLAG_CAUGHT_LUGIA", "CAUGHT_LUGIA" },
+ [146] = { "FLAG_CAUGHT_HO_OH", "CAUGHT_HO_OH" },
+ [147] = { "FLAG_MR_BRINEY_SAILING_INTRO", "MR_BRINEY_SAILING_INTRO" },
+ [148] = { "FLAG_DOCK_REJECTED_DEVON_GOODS", "DOCK_REJECTED_DEVON_GOODS" },
+ [149] = { "FLAG_DELIVERED_DEVON_GOODS", "DELIVERED_DEVON_GOODS" },
+ [150] = { "FLAG_RECEIVED_CONTEST_PASS", "RECEIVED_CONTEST_PASS" },
+ [151] = { "FLAG_RECEIVED_CASTFORM", "RECEIVED_CASTFORM" },
+ [152] = { "FLAG_RECEIVED_SUPER_ROD", "RECEIVED_SUPER_ROD" },
+ [153] = { "FLAG_RUSTBORO_NPC_TRADE_COMPLETED", "RUSTBORO_NPC_TRADE_COMPLETED" },
+ [154] = { "FLAG_PACIFIDLOG_NPC_TRADE_COMPLETED", "PACIFIDLOG_NPC_TRADE_COMPLETED" },
+ [155] = { "FLAG_FORTREE_NPC_TRADE_COMPLETED", "FORTREE_NPC_TRADE_COMPLETED" },
+ [156] = { "FLAG_BATTLE_FRONTIER_TRADE_DONE", "BATTLE_FRONTIER_TRADE_DONE" },
+ [157] = { "FLAG_FORCE_MIRAGE_TOWER_VISIBLE", "FORCE_MIRAGE_TOWER_VISIBLE" },
+ [158] = { "FLAG_SOOTOPOLIS_ARCHIE_MAXIE_LEAVE", "SOOTOPOLIS_ARCHIE_MAXIE_LEAVE" },
+ [159] = { "FLAG_INTERACTED_WITH_DEVON_EMPLOYEE_GOODS_STOLEN", "INTERACTED_WITH_DEVON_EMPLOYEE_GOODS_STOLEN" },
+ [160] = { "FLAG_COOL_PAINTING_MADE", "COOL_PAINTING_MADE" },
+ [161] = { "FLAG_BEAUTY_PAINTING_MADE", "BEAUTY_PAINTING_MADE" },
+ [162] = { "FLAG_CUTE_PAINTING_MADE", "CUTE_PAINTING_MADE" },
+ [163] = { "FLAG_SMART_PAINTING_MADE", "SMART_PAINTING_MADE" },
+ [164] = { "FLAG_TOUGH_PAINTING_MADE", "TOUGH_PAINTING_MADE" },
+ [165] = { "FLAG_RECEIVED_TM_ROCK_TOMB", "RECEIVED_TM_ROCK_TOMB" },
+ [166] = { "FLAG_RECEIVED_TM_BULK_UP", "RECEIVED_TM_BULK_UP" },
+ [167] = { "FLAG_RECEIVED_TM_SHOCK_WAVE", "RECEIVED_TM_SHOCK_WAVE" },
+ [168] = { "FLAG_RECEIVED_TM_OVERHEAT", "RECEIVED_TM_OVERHEAT" },
+ [169] = { "FLAG_RECEIVED_TM_FACADE", "RECEIVED_TM_FACADE" },
+ [170] = { "FLAG_RECEIVED_TM_AERIAL_ACE", "RECEIVED_TM_AERIAL_ACE" },
+ [171] = { "FLAG_RECEIVED_TM_CALM_MIND", "RECEIVED_TM_CALM_MIND" },
+ [172] = { "FLAG_RECEIVED_TM_WATER_PULSE", "RECEIVED_TM_WATER_PULSE" },
+ [173] = { "FLAG_HIDE_SECRET_BASE_TRAINER", "HIDE_SECRET_BASE_TRAINER" },
+ [174] = { "FLAG_DECORATION_1", "DECORATION_1" },
+ [175] = { "FLAG_DECORATION_2", "DECORATION_2" },
+ [176] = { "FLAG_DECORATION_3", "DECORATION_3" },
+ [177] = { "FLAG_DECORATION_4", "DECORATION_4" },
+ [178] = { "FLAG_DECORATION_5", "DECORATION_5" },
+ [179] = { "FLAG_DECORATION_6", "DECORATION_6" },
+ [180] = { "FLAG_DECORATION_7", "DECORATION_7" },
+ [181] = { "FLAG_DECORATION_8", "DECORATION_8" },
+ [182] = { "FLAG_DECORATION_9", "DECORATION_9" },
+ [183] = { "FLAG_DECORATION_10", "DECORATION_10" },
+ [184] = { "FLAG_DECORATION_11", "DECORATION_11" },
+ [185] = { "FLAG_DECORATION_12", "DECORATION_12" },
+ [186] = { "FLAG_DECORATION_13", "DECORATION_13" },
+ [187] = { "FLAG_DECORATION_14", "DECORATION_14" },
+ [188] = { "FLAG_RECEIVED_POKENAV", "RECEIVED_POKENAV" },
+ [189] = { "FLAG_DELIVERED_STEVEN_LETTER", "DELIVERED_STEVEN_LETTER" },
+ [190] = { "FLAG_DEFEATED_WALLY_MAUVILLE", "DEFEATED_WALLY_MAUVILLE" },
+ [191] = { "FLAG_DEFEATED_GRUNT_SPACE_CENTER_1F", "DEFEATED_GRUNT_SPACE_CENTER_1F" },
+ [192] = { "FLAG_RECEIVED_SUN_STONE_MOSSDEEP", "RECEIVED_SUN_STONE_MOSSDEEP" },
+ [193] = { "FLAG_WALLY_SPEECH", "WALLY_SPEECH" },
+ [194] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_1", "TRICK_HOUSE_PUZZLE_7_SWITCH_1" },
+ [195] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_2", "TRICK_HOUSE_PUZZLE_7_SWITCH_2" },
+ [196] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_3", "TRICK_HOUSE_PUZZLE_7_SWITCH_3" },
+ [197] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_4", "TRICK_HOUSE_PUZZLE_7_SWITCH_4" },
+ [198] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_5", "TRICK_HOUSE_PUZZLE_7_SWITCH_5" },
+ [199] = { "FLAG_RUSTURF_TUNNEL_OPENED", "RUSTURF_TUNNEL_OPENED" },
+ [200] = { "FLAG_RECEIVED_RED_SCARF", "RECEIVED_RED_SCARF" },
+ [201] = { "FLAG_RECEIVED_BLUE_SCARF", "RECEIVED_BLUE_SCARF" },
+ [202] = { "FLAG_RECEIVED_PINK_SCARF", "RECEIVED_PINK_SCARF" },
+ [203] = { "FLAG_RECEIVED_GREEN_SCARF", "RECEIVED_GREEN_SCARF" },
+ [204] = { "FLAG_RECEIVED_YELLOW_SCARF", "RECEIVED_YELLOW_SCARF" },
+ [205] = { "FLAG_INTERACTED_WITH_STEVEN_SPACE_CENTER", "INTERACTED_WITH_STEVEN_SPACE_CENTER" },
+ [206] = { "FLAG_ENCOUNTERED_LATIAS_OR_LATIOS", "ENCOUNTERED_LATIAS_OR_LATIOS" },
+ [207] = { "FLAG_MET_ARCHIE_METEOR_FALLS", "MET_ARCHIE_METEOR_FALLS" },
+ [208] = { "FLAG_GOT_BASEMENT_KEY_FROM_WATTSON", "GOT_BASEMENT_KEY_FROM_WATTSON" },
+ [209] = { "FLAG_GOT_TM_THUNDERBOLT_FROM_WATTSON", "GOT_TM_THUNDERBOLT_FROM_WATTSON" },
+ [210] = { "FLAG_FAN_CLUB_STRENGTH_SHARED", "FAN_CLUB_STRENGTH_SHARED" },
+ [211] = { "FLAG_DEFEATED_RIVAL_RUSTBORO", "DEFEATED_RIVAL_RUSTBORO" },
+ [212] = { "FLAG_RECEIVED_RED_OR_BLUE_ORB", "RECEIVED_RED_OR_BLUE_ORB" },
+ [213] = { "FLAG_RECEIVED_PREMIER_BALL_RUSTBORO", "RECEIVED_PREMIER_BALL_RUSTBORO" },
+ [214] = { "FLAG_ENABLE_WALLY_MATCH_CALL", "ENABLE_WALLY_MATCH_CALL" },
+ [215] = { "FLAG_ENABLE_SCOTT_MATCH_CALL", "ENABLE_SCOTT_MATCH_CALL" },
+ [216] = { "FLAG_ENABLE_MOM_MATCH_CALL", "ENABLE_MOM_MATCH_CALL" },
+ [217] = { "FLAG_MET_DIVING_TREASURE_HUNTER", "MET_DIVING_TREASURE_HUNTER" },
+ [218] = { "FLAG_MET_WAILMER_TRAINER", "MET_WAILMER_TRAINER" },
+ [219] = { "FLAG_EVIL_LEADER_PLEASE_STOP", "EVIL_LEADER_PLEASE_STOP" },
+ [220] = { "FLAG_NEVER_SET_0x0DC", "NEVER_SET_0x0DC" },
+ [221] = { "FLAG_RECEIVED_GO_GOGGLES", "RECEIVED_GO_GOGGLES" },
+ [222] = { "FLAG_WINGULL_SENT_ON_ERRAND", "WINGULL_SENT_ON_ERRAND" },
+ [223] = { "FLAG_RECEIVED_MENTAL_HERB", "RECEIVED_MENTAL_HERB" },
+ [224] = { "FLAG_WINGULL_DELIVERED_MAIL", "WINGULL_DELIVERED_MAIL" },
+ [225] = { "FLAG_RECEIVED_20_COINS", "RECEIVED_20_COINS" },
+ [226] = { "FLAG_RECEIVED_STARTER_DOLL", "RECEIVED_STARTER_DOLL" },
+ [227] = { "FLAG_RECEIVED_GOOD_ROD", "RECEIVED_GOOD_ROD" },
+ [228] = { "FLAG_REGI_DOORS_OPENED", "REGI_DOORS_OPENED" },
+ [229] = { "FLAG_RECEIVED_TM_RETURN", "RECEIVED_TM_RETURN" },
+ [230] = { "FLAG_RECEIVED_TM_SLUDGE_BOMB", "RECEIVED_TM_SLUDGE_BOMB" },
+ [231] = { "FLAG_RECEIVED_TM_ROAR", "RECEIVED_TM_ROAR" },
+ [232] = { "FLAG_RECEIVED_TM_GIGA_DRAIN", "RECEIVED_TM_GIGA_DRAIN" },
+ [233] = { "FLAG_UNUSED_0x0E9", "UNUSED_0x0E9" },
+ [234] = { "FLAG_RECEIVED_TM_REST", "RECEIVED_TM_REST" },
+ [235] = { "FLAG_RECEIVED_TM_ATTRACT", "RECEIVED_TM_ATTRACT" },
+ [236] = { "FLAG_RECEIVED_GLASS_ORNAMENT", "RECEIVED_GLASS_ORNAMENT" },
+ [237] = { "FLAG_RECEIVED_SILVER_SHIELD", "RECEIVED_SILVER_SHIELD" },
+ [238] = { "FLAG_RECEIVED_GOLD_SHIELD", "RECEIVED_GOLD_SHIELD" },
+ [239] = { "FLAG_USED_STORAGE_KEY", "USED_STORAGE_KEY" },
+ [240] = { "FLAG_USED_ROOM_1_KEY", "USED_ROOM_1_KEY" },
+ [241] = { "FLAG_USED_ROOM_2_KEY", "USED_ROOM_2_KEY" },
+ [242] = { "FLAG_USED_ROOM_4_KEY", "USED_ROOM_4_KEY" },
+ [243] = { "FLAG_USED_ROOM_6_KEY", "USED_ROOM_6_KEY" },
+ [244] = { "FLAG_MET_PROF_COZMO", "MET_PROF_COZMO" },
+ [245] = { "FLAG_RECEIVED_WAILMER_DOLL", "RECEIVED_WAILMER_DOLL" },
+ [246] = { "FLAG_RECEIVED_CHESTO_BERRY_ROUTE_104", "RECEIVED_CHESTO_BERRY_ROUTE_104" },
+ [247] = { "FLAG_DEFEATED_SS_TIDAL_TRAINERS", "DEFEATED_SS_TIDAL_TRAINERS" },
+ [248] = { "FLAG_RECEIVED_SPELON_BERRY", "RECEIVED_SPELON_BERRY" },
+ [249] = { "FLAG_RECEIVED_PAMTRE_BERRY", "RECEIVED_PAMTRE_BERRY" },
+ [250] = { "FLAG_RECEIVED_WATMEL_BERRY", "RECEIVED_WATMEL_BERRY" },
+ [251] = { "FLAG_RECEIVED_DURIN_BERRY", "RECEIVED_DURIN_BERRY" },
+ [252] = { "FLAG_RECEIVED_BELUE_BERRY", "RECEIVED_BELUE_BERRY" },
+ [253] = { "FLAG_ENABLE_RIVAL_MATCH_CALL", "ENABLE_RIVAL_MATCH_CALL" },
+ [254] = { "FLAG_RECEIVED_CHARCOAL", "RECEIVED_CHARCOAL" },
+ [255] = { "FLAG_LATIOS_OR_LATIAS_ROAMING", "LATIOS_OR_LATIAS_ROAMING" },
+ [256] = { "FLAG_RECEIVED_REPEAT_BALL", "RECEIVED_REPEAT_BALL", "VARS_COUNT" },
+ [257] = { "FLAG_RECEIVED_OLD_ROD", "RECEIVED_OLD_ROD" },
+ [258] = { "FLAG_RECEIVED_COIN_CASE", "RECEIVED_COIN_CASE" },
+ [259] = { "FLAG_RETURNED_RED_OR_BLUE_ORB", "RETURNED_RED_OR_BLUE_ORB" },
+ [260] = { "FLAG_RECEIVED_TM_SNATCH", "RECEIVED_TM_SNATCH" },
+ [261] = { "FLAG_RECEIVED_TM_DIG", "RECEIVED_TM_DIG" },
+ [262] = { "FLAG_RECEIVED_TM_BULLET_SEED", "RECEIVED_TM_BULLET_SEED" },
+ [263] = { "FLAG_ENTERED_ELITE_FOUR", "ENTERED_ELITE_FOUR" },
+ [264] = { "FLAG_RECEIVED_TM_HIDDEN_POWER", "RECEIVED_TM_HIDDEN_POWER" },
+ [265] = { "FLAG_RECEIVED_TM_TORMENT", "RECEIVED_TM_TORMENT" },
+ [266] = { "FLAG_RECEIVED_LAVARIDGE_EGG", "RECEIVED_LAVARIDGE_EGG" },
+ [267] = { "FLAG_RECEIVED_REVIVED_FOSSIL_MON", "RECEIVED_REVIVED_FOSSIL_MON" },
+ [268] = { "FLAG_SECRET_BASE_REGISTRY_ENABLED", "SECRET_BASE_REGISTRY_ENABLED" },
+ [269] = { "FLAG_RECEIVED_TM_THIEF", "RECEIVED_TM_THIEF" },
+ [270] = { "FLAG_CONTEST_SKETCH_CREATED", "CONTEST_SKETCH_CREATED" },
+ [271] = { "FLAG_EVIL_TEAM_ESCAPED_STERN_SPOKE", "EVIL_TEAM_ESCAPED_STERN_SPOKE" },
+ [272] = { "FLAG_RECEIVED_EXP_SHARE", "RECEIVED_EXP_SHARE" },
+ [273] = { "FLAG_POKERUS_EXPLAINED", "POKERUS_EXPLAINED" },
+ [274] = { "FLAG_RECEIVED_RUNNING_SHOES", "RECEIVED_RUNNING_SHOES" },
+ [275] = { "FLAG_RECEIVED_QUICK_CLAW", "RECEIVED_QUICK_CLAW" },
+ [276] = { "FLAG_RECEIVED_KINGS_ROCK", "RECEIVED_KINGS_ROCK" },
+ [277] = { "FLAG_RECEIVED_MACHO_BRACE", "RECEIVED_MACHO_BRACE" },
+ [278] = { "FLAG_RECEIVED_SOOTHE_BELL", "RECEIVED_SOOTHE_BELL" },
+ [279] = { "FLAG_RECEIVED_WHITE_HERB", "RECEIVED_WHITE_HERB" },
+ [280] = { "FLAG_RECEIVED_SOFT_SAND", "RECEIVED_SOFT_SAND" },
+ [281] = { "FLAG_ENABLE_PROF_BIRCH_MATCH_CALL", "ENABLE_PROF_BIRCH_MATCH_CALL" },
+ [282] = { "FLAG_RECEIVED_CLEANSE_TAG", "RECEIVED_CLEANSE_TAG" },
+ [283] = { "FLAG_RECEIVED_FOCUS_BAND", "RECEIVED_FOCUS_BAND" },
+ [284] = { "FLAG_DECLINED_WALLY_BATTLE_MAUVILLE", "DECLINED_WALLY_BATTLE_MAUVILLE" },
+ [285] = { "FLAG_RECEIVED_DEVON_SCOPE", "RECEIVED_DEVON_SCOPE" },
+ [286] = { "FLAG_DECLINED_RIVAL_BATTLE_LILYCOVE", "DECLINED_RIVAL_BATTLE_LILYCOVE" },
+ [287] = { "FLAG_MET_DEVON_EMPLOYEE", "MET_DEVON_EMPLOYEE" },
+ [288] = { "FLAG_MET_RIVAL_RUSTBORO", "MET_RIVAL_RUSTBORO" },
+ [289] = { "FLAG_RECEIVED_SILK_SCARF", "RECEIVED_SILK_SCARF" },
+ [290] = { "FLAG_NOT_READY_FOR_BATTLE_ROUTE_120", "NOT_READY_FOR_BATTLE_ROUTE_120" },
+ [291] = { "FLAG_RECEIVED_SS_TICKET", "RECEIVED_SS_TICKET" },
+ [292] = { "FLAG_MET_RIVAL_LILYCOVE", "MET_RIVAL_LILYCOVE" },
+ [293] = { "FLAG_MET_RIVAL_IN_HOUSE_AFTER_LILYCOVE", "MET_RIVAL_IN_HOUSE_AFTER_LILYCOVE" },
+ [294] = { "FLAG_EXCHANGED_SCANNER", "EXCHANGED_SCANNER" },
+ [295] = { "FLAG_KECLEON_FLED_FORTREE", "KECLEON_FLED_FORTREE" },
+ [296] = { "FLAG_PETALBURG_MART_EXPANDED_ITEMS", "PETALBURG_MART_EXPANDED_ITEMS" },
+ [297] = { "FLAG_RECEIVED_MIRACLE_SEED", "RECEIVED_MIRACLE_SEED" },
+ [298] = { "FLAG_RECEIVED_BELDUM", "RECEIVED_BELDUM" },
+ [299] = { "FLAG_RECEIVED_FANCLUB_TM_THIS_WEEK", "RECEIVED_FANCLUB_TM_THIS_WEEK" },
+ [300] = { "FLAG_MET_FANCLUB_YOUNGER_BROTHER", "MET_FANCLUB_YOUNGER_BROTHER" },
+ [301] = { "FLAG_RIVAL_LEFT_FOR_ROUTE103", "RIVAL_LEFT_FOR_ROUTE103" },
+ [302] = { "FLAG_OMIT_DIVE_FROM_STEVEN_LETTER", "OMIT_DIVE_FROM_STEVEN_LETTER" },
+ [303] = { "FLAG_HAS_MATCH_CALL", "HAS_MATCH_CALL" },
+ [304] = { "FLAG_ADDED_MATCH_CALL_TO_POKENAV", "ADDED_MATCH_CALL_TO_POKENAV" },
+ [305] = { "FLAG_REGISTERED_STEVEN_POKENAV", "REGISTERED_STEVEN_POKENAV" },
+ [306] = { "FLAG_ENABLE_NORMAN_MATCH_CALL", "ENABLE_NORMAN_MATCH_CALL" },
+ [307] = { "FLAG_STEVEN_GUIDES_TO_CAVE_OF_ORIGIN", "STEVEN_GUIDES_TO_CAVE_OF_ORIGIN" },
+ [308] = { "FLAG_MET_ARCHIE_SOOTOPOLIS", "MET_ARCHIE_SOOTOPOLIS" },
+ [309] = { "FLAG_MET_MAXIE_SOOTOPOLIS", "MET_MAXIE_SOOTOPOLIS" },
+ [310] = { "FLAG_MET_SCOTT_RUSTBORO", "MET_SCOTT_RUSTBORO" },
+ [311] = { "FLAG_WALLACE_GOES_TO_SKY_PILLAR", "WALLACE_GOES_TO_SKY_PILLAR" },
+ [312] = { "FLAG_RECEIVED_HM_WATERFALL", "RECEIVED_HM_WATERFALL" },
+ [313] = { "FLAG_BEAT_MAGMA_GRUNT_JAGGED_PASS", "BEAT_MAGMA_GRUNT_JAGGED_PASS" },
+ [314] = { "FLAG_RECEIVED_AURORA_TICKET", "RECEIVED_AURORA_TICKET" },
+ [315] = { "FLAG_RECEIVED_MYSTIC_TICKET", "RECEIVED_MYSTIC_TICKET" },
+ [316] = { "FLAG_RECEIVED_OLD_SEA_MAP", "RECEIVED_OLD_SEA_MAP" },
+ [317] = { "FLAG_WONDER_CARD_UNUSED_1", "WONDER_CARD_UNUSED_1" },
+ [318] = { "FLAG_WONDER_CARD_UNUSED_2", "WONDER_CARD_UNUSED_2" },
+ [319] = { "FLAG_WONDER_CARD_UNUSED_3", "WONDER_CARD_UNUSED_3" },
+ [320] = { "FLAG_WONDER_CARD_UNUSED_4", "WONDER_CARD_UNUSED_4" },
+ [321] = { "FLAG_WONDER_CARD_UNUSED_5", "WONDER_CARD_UNUSED_5" },
+ [322] = { "FLAG_WONDER_CARD_UNUSED_6", "WONDER_CARD_UNUSED_6" },
+ [323] = { "FLAG_WONDER_CARD_UNUSED_7", "WONDER_CARD_UNUSED_7" },
+ [324] = { "FLAG_WONDER_CARD_UNUSED_8", "WONDER_CARD_UNUSED_8" },
+ [325] = { "FLAG_WONDER_CARD_UNUSED_9", "WONDER_CARD_UNUSED_9" },
+ [326] = { "FLAG_WONDER_CARD_UNUSED_10", "WONDER_CARD_UNUSED_10" },
+ [327] = { "FLAG_WONDER_CARD_UNUSED_11", "WONDER_CARD_UNUSED_11" },
+ [328] = { "FLAG_WONDER_CARD_UNUSED_12", "WONDER_CARD_UNUSED_12" },
+ [329] = { "FLAG_WONDER_CARD_UNUSED_13", "WONDER_CARD_UNUSED_13" },
+ [330] = { "FLAG_WONDER_CARD_UNUSED_14", "WONDER_CARD_UNUSED_14" },
+ [331] = { "FLAG_WONDER_CARD_UNUSED_15", "WONDER_CARD_UNUSED_15" },
+ [332] = { "FLAG_WONDER_CARD_UNUSED_16", "WONDER_CARD_UNUSED_16" },
+ [333] = { "FLAG_WONDER_CARD_UNUSED_17", "WONDER_CARD_UNUSED_17" },
+ [334] = { "FLAG_MIRAGE_TOWER_VISIBLE", "MIRAGE_TOWER_VISIBLE" },
+ [335] = { "FLAG_CHOSE_ROOT_FOSSIL", "CHOSE_ROOT_FOSSIL" },
+ [336] = { "FLAG_CHOSE_CLAW_FOSSIL", "CHOSE_CLAW_FOSSIL" },
+ [337] = { "FLAG_RECEIVED_POWDER_JAR", "RECEIVED_POWDER_JAR" },
+ [338] = { "FLAG_CHOSEN_MULTI_BATTLE_NPC_PARTNER", "CHOSEN_MULTI_BATTLE_NPC_PARTNER" },
+ [339] = { "FLAG_MET_BATTLE_FRONTIER_BREEDER", "MET_BATTLE_FRONTIER_BREEDER" },
+ [340] = { "FLAG_MET_BATTLE_FRONTIER_MANIAC", "MET_BATTLE_FRONTIER_MANIAC" },
+ [341] = { "FLAG_ENTERED_CONTEST", "ENTERED_CONTEST" },
+ [342] = { "FLAG_MET_SLATEPORT_FANCLUB_CHAIRMAN", "MET_SLATEPORT_FANCLUB_CHAIRMAN" },
+ [343] = { "FLAG_MET_BATTLE_FRONTIER_GAMBLER", "MET_BATTLE_FRONTIER_GAMBLER" },
+ [344] = { "FLAG_ENABLE_MR_STONE_POKENAV", "ENABLE_MR_STONE_POKENAV" },
+ [345] = { "FLAG_NURSE_MENTIONS_GOLD_CARD", "NURSE_MENTIONS_GOLD_CARD" },
+ [346] = { "FLAG_MET_FRONTIER_BEAUTY_MOVE_TUTOR", "MET_FRONTIER_BEAUTY_MOVE_TUTOR" },
+ [347] = { "FLAG_MET_FRONTIER_SWIMMER_MOVE_TUTOR", "MET_FRONTIER_SWIMMER_MOVE_TUTOR" },
+ [348] = { "FLAG_REGISTERED_ROSE", "REGISTERED_ROSE", "TRAINER_REGISTERED_FLAGS_START" },
+ [349] = { "FLAG_REGISTERED_ANDRES", "REGISTERED_ANDRES" },
+ [350] = { "FLAG_REGISTERED_DUSTY", "REGISTERED_DUSTY" },
+ [351] = { "FLAG_REGISTERED_LOLA", "REGISTERED_LOLA" },
+ [352] = { "FLAG_REGISTERED_RICKY", "REGISTERED_RICKY" },
+ [353] = { "FLAG_REGISTERED_LILA_AND_ROY", "REGISTERED_LILA_AND_ROY" },
+ [354] = { "FLAG_REGISTERED_CRISTIN", "REGISTERED_CRISTIN" },
+ [355] = { "FLAG_REGISTERED_BROOKE", "REGISTERED_BROOKE" },
+ [356] = { "FLAG_REGISTERED_WILTON", "REGISTERED_WILTON" },
+ [357] = { "FLAG_REGISTERED_VALERIE", "REGISTERED_VALERIE" },
+ [358] = { "FLAG_REGISTERED_CINDY", "REGISTERED_CINDY" },
+ [359] = { "FLAG_REGISTERED_THALIA", "REGISTERED_THALIA" },
+ [360] = { "FLAG_REGISTERED_JESSICA", "REGISTERED_JESSICA" },
+ [361] = { "FLAG_REGISTERED_WINSTON", "REGISTERED_WINSTON" },
+ [362] = { "FLAG_REGISTERED_STEVE", "REGISTERED_STEVE" },
+ [363] = { "FLAG_REGISTERED_TONY", "REGISTERED_TONY" },
+ [364] = { "FLAG_REGISTERED_NOB", "REGISTERED_NOB" },
+ [365] = { "FLAG_REGISTERED_KOJI", "REGISTERED_KOJI" },
+ [366] = { "FLAG_REGISTERED_FERNANDO", "REGISTERED_FERNANDO" },
+ [367] = { "FLAG_REGISTERED_DALTON", "REGISTERED_DALTON" },
+ [368] = { "FLAG_REGISTERED_BERNIE", "REGISTERED_BERNIE" },
+ [369] = { "FLAG_REGISTERED_ETHAN", "REGISTERED_ETHAN" },
+ [370] = { "FLAG_REGISTERED_JOHN_AND_JAY", "REGISTERED_JOHN_AND_JAY" },
+ [371] = { "FLAG_REGISTERED_JEFFREY", "REGISTERED_JEFFREY" },
+ [372] = { "FLAG_REGISTERED_CAMERON", "REGISTERED_CAMERON" },
+ [373] = { "FLAG_REGISTERED_JACKI", "REGISTERED_JACKI" },
+ [374] = { "FLAG_REGISTERED_WALTER", "REGISTERED_WALTER" },
+ [375] = { "FLAG_REGISTERED_KAREN", "REGISTERED_KAREN" },
+ [376] = { "FLAG_REGISTERED_JERRY", "REGISTERED_JERRY" },
+ [377] = { "FLAG_REGISTERED_ANNA_AND_MEG", "REGISTERED_ANNA_AND_MEG" },
+ [378] = { "FLAG_REGISTERED_ISABEL", "REGISTERED_ISABEL" },
+ [379] = { "FLAG_REGISTERED_MIGUEL", "REGISTERED_MIGUEL" },
+ [380] = { "FLAG_REGISTERED_TIMOTHY", "REGISTERED_TIMOTHY" },
+ [381] = { "FLAG_REGISTERED_SHELBY", "REGISTERED_SHELBY" },
+ [382] = { "FLAG_REGISTERED_CALVIN", "REGISTERED_CALVIN" },
+ [383] = { "FLAG_REGISTERED_ELLIOT", "REGISTERED_ELLIOT" },
+ [384] = { "FLAG_REGISTERED_ISAIAH", "REGISTERED_ISAIAH" },
+ [385] = { "FLAG_REGISTERED_MARIA", "REGISTERED_MARIA" },
+ [386] = { "FLAG_REGISTERED_ABIGAIL", "REGISTERED_ABIGAIL" },
+ [387] = { "FLAG_REGISTERED_DYLAN", "REGISTERED_DYLAN" },
+ [388] = { "FLAG_REGISTERED_KATELYN", "REGISTERED_KATELYN" },
+ [389] = { "FLAG_REGISTERED_BENJAMIN", "REGISTERED_BENJAMIN" },
+ [390] = { "FLAG_REGISTERED_PABLO", "REGISTERED_PABLO" },
+ [391] = { "FLAG_REGISTERED_NICOLAS", "REGISTERED_NICOLAS" },
+ [392] = { "FLAG_REGISTERED_ROBERT", "REGISTERED_ROBERT" },
+ [393] = { "FLAG_REGISTERED_LAO", "REGISTERED_LAO" },
+ [394] = { "FLAG_REGISTERED_CYNDY", "REGISTERED_CYNDY" },
+ [395] = { "FLAG_REGISTERED_MADELINE", "REGISTERED_MADELINE" },
+ [396] = { "FLAG_REGISTERED_JENNY", "REGISTERED_JENNY" },
+ [397] = { "FLAG_REGISTERED_DIANA", "REGISTERED_DIANA" },
+ [398] = { "FLAG_REGISTERED_AMY_AND_LIV", "REGISTERED_AMY_AND_LIV" },
+ [399] = { "FLAG_REGISTERED_ERNEST", "REGISTERED_ERNEST" },
+ [400] = { "FLAG_REGISTERED_CORY", "REGISTERED_CORY" },
+ [401] = { "FLAG_REGISTERED_EDWIN", "REGISTERED_EDWIN" },
+ [402] = { "FLAG_REGISTERED_LYDIA", "REGISTERED_LYDIA" },
+ [403] = { "FLAG_REGISTERED_ISAAC", "REGISTERED_ISAAC" },
+ [404] = { "FLAG_REGISTERED_GABRIELLE", "REGISTERED_GABRIELLE" },
+ [405] = { "FLAG_REGISTERED_CATHERINE", "REGISTERED_CATHERINE" },
+ [406] = { "FLAG_REGISTERED_JACKSON", "REGISTERED_JACKSON" },
+ [407] = { "FLAG_REGISTERED_HALEY", "REGISTERED_HALEY" },
+ [408] = { "FLAG_REGISTERED_JAMES", "REGISTERED_JAMES" },
+ [409] = { "FLAG_REGISTERED_TRENT", "REGISTERED_TRENT" },
+ [410] = { "FLAG_REGISTERED_SAWYER", "REGISTERED_SAWYER" },
+ [411] = { "FLAG_REGISTERED_KIRA_AND_DAN", "REGISTERED_KIRA_AND_DAN" },
+ [412] = { "FLAG_REGISTERED_WALLY", "REGISTERED_WALLY" },
+ [413] = { "FLAG_REGISTERED_ROXANNE", "REGISTERED_ROXANNE" },
+ [414] = { "FLAG_REGISTERED_BRAWLY", "REGISTERED_BRAWLY" },
+ [415] = { "FLAG_REGISTERED_WATTSON", "REGISTERED_WATTSON" },
+ [416] = { "FLAG_REGISTERED_FLANNERY", "REGISTERED_FLANNERY" },
+ [417] = { "FLAG_REGISTERED_NORMAN", "REGISTERED_NORMAN" },
+ [418] = { "FLAG_REGISTERED_WINONA", "REGISTERED_WINONA" },
+ [419] = { "FLAG_REGISTERED_TATE_AND_LIZA", "REGISTERED_TATE_AND_LIZA" },
+ [420] = { "FLAG_REGISTERED_JUAN", "REGISTERED_JUAN" },
+ [421] = { "FLAG_REGISTERED_SIDNEY", "REGISTERED_SIDNEY" },
+ [422] = { "FLAG_REGISTERED_PHOEBE", "REGISTERED_PHOEBE" },
+ [423] = { "FLAG_REGISTERED_GLACIA", "REGISTERED_GLACIA" },
+ [424] = { "FLAG_REGISTERED_DRAKE", "REGISTERED_DRAKE" },
+ [425] = { "FLAG_REGISTERED_WALLACE", "REGISTERED_WALLACE" },
+ [426] = { "FLAG_UNUSED_0x1AA", "UNUSED_0x1AA" },
+ [427] = { "FLAG_UNUSED_0x1AB", "UNUSED_0x1AB" },
+ [428] = { "FLAG_DEFEATED_DEOXYS", "DEFEATED_DEOXYS" },
+ [429] = { "FLAG_BATTLED_DEOXYS", "BATTLED_DEOXYS" },
+ [430] = { "FLAG_SHOWN_EON_TICKET", "SHOWN_EON_TICKET" },
+ [431] = { "FLAG_SHOWN_AURORA_TICKET", "SHOWN_AURORA_TICKET" },
+ [432] = { "FLAG_SHOWN_OLD_SEA_MAP", "SHOWN_OLD_SEA_MAP" },
+ [433] = { "FLAG_MOVE_TUTOR_TAUGHT_SWAGGER", "MOVE_TUTOR_TAUGHT_SWAGGER" },
+ [434] = { "FLAG_MOVE_TUTOR_TAUGHT_ROLLOUT", "MOVE_TUTOR_TAUGHT_ROLLOUT" },
+ [435] = { "FLAG_MOVE_TUTOR_TAUGHT_FURY_CUTTER", "MOVE_TUTOR_TAUGHT_FURY_CUTTER" },
+ [436] = { "FLAG_MOVE_TUTOR_TAUGHT_MIMIC", "MOVE_TUTOR_TAUGHT_MIMIC" },
+ [437] = { "FLAG_MOVE_TUTOR_TAUGHT_METRONOME", "MOVE_TUTOR_TAUGHT_METRONOME" },
+ [438] = { "FLAG_MOVE_TUTOR_TAUGHT_SLEEP_TALK", "MOVE_TUTOR_TAUGHT_SLEEP_TALK" },
+ [439] = { "FLAG_MOVE_TUTOR_TAUGHT_SUBSTITUTE", "MOVE_TUTOR_TAUGHT_SUBSTITUTE" },
+ [440] = { "FLAG_MOVE_TUTOR_TAUGHT_DYNAMICPUNCH", "MOVE_TUTOR_TAUGHT_DYNAMICPUNCH" },
+ [441] = { "FLAG_MOVE_TUTOR_TAUGHT_DOUBLE_EDGE", "MOVE_TUTOR_TAUGHT_DOUBLE_EDGE" },
+ [442] = { "FLAG_MOVE_TUTOR_TAUGHT_EXPLOSION", "MOVE_TUTOR_TAUGHT_EXPLOSION" },
+ [443] = { "FLAG_DEFEATED_REGIROCK", "DEFEATED_REGIROCK" },
+ [444] = { "FLAG_DEFEATED_REGICE", "DEFEATED_REGICE" },
+ [445] = { "FLAG_DEFEATED_REGISTEEL", "DEFEATED_REGISTEEL" },
+ [446] = { "FLAG_DEFEATED_KYOGRE", "DEFEATED_KYOGRE" },
+ [447] = { "FLAG_DEFEATED_GROUDON", "DEFEATED_GROUDON" },
+ [448] = { "FLAG_DEFEATED_RAYQUAZA", "DEFEATED_RAYQUAZA" },
+ [449] = { "FLAG_DEFEATED_VOLTORB_1_NEW_MAUVILLE", "DEFEATED_VOLTORB_1_NEW_MAUVILLE" },
+ [450] = { "FLAG_DEFEATED_VOLTORB_2_NEW_MAUVILLE", "DEFEATED_VOLTORB_2_NEW_MAUVILLE" },
+ [451] = { "FLAG_DEFEATED_VOLTORB_3_NEW_MAUVILLE", "DEFEATED_VOLTORB_3_NEW_MAUVILLE" },
+ [452] = { "FLAG_DEFEATED_ELECTRODE_1_AQUA_HIDEOUT", "DEFEATED_ELECTRODE_1_AQUA_HIDEOUT" },
+ [453] = { "FLAG_DEFEATED_ELECTRODE_2_AQUA_HIDEOUT", "DEFEATED_ELECTRODE_2_AQUA_HIDEOUT" },
+ [454] = { "FLAG_DEFEATED_SUDOWOODO", "DEFEATED_SUDOWOODO" },
+ [455] = { "FLAG_DEFEATED_MEW", "DEFEATED_MEW" },
+ [456] = { "FLAG_DEFEATED_LATIAS_OR_LATIOS", "DEFEATED_LATIAS_OR_LATIOS" },
+ [457] = { "FLAG_CAUGHT_LATIAS_OR_LATIOS", "CAUGHT_LATIAS_OR_LATIOS" },
+ [458] = { "FLAG_CAUGHT_MEW", "CAUGHT_MEW" },
+ [459] = { "FLAG_MET_SCOTT_AFTER_OBTAINING_STONE_BADGE", "MET_SCOTT_AFTER_OBTAINING_STONE_BADGE" },
+ [460] = { "FLAG_MET_SCOTT_IN_VERDANTURF", "MET_SCOTT_IN_VERDANTURF" },
+ [461] = { "FLAG_MET_SCOTT_IN_FALLARBOR", "MET_SCOTT_IN_FALLARBOR" },
+ [462] = { "FLAG_MET_SCOTT_IN_LILYCOVE", "MET_SCOTT_IN_LILYCOVE" },
+ [463] = { "FLAG_MET_SCOTT_IN_EVERGRANDE", "MET_SCOTT_IN_EVERGRANDE" },
+ [464] = { "FLAG_MET_SCOTT_ON_SS_TIDAL", "MET_SCOTT_ON_SS_TIDAL" },
+ [465] = { "FLAG_SCOTT_GIVES_BATTLE_POINTS", "SCOTT_GIVES_BATTLE_POINTS" },
+ [466] = { "FLAG_COLLECTED_ALL_GOLD_SYMBOLS", "COLLECTED_ALL_GOLD_SYMBOLS" },
+ [467] = { "FLAG_ENABLE_ROXANNE_MATCH_CALL", "ENABLE_ROXANNE_MATCH_CALL" },
+ [468] = { "FLAG_ENABLE_BRAWLY_MATCH_CALL", "ENABLE_BRAWLY_MATCH_CALL" },
+ [469] = { "FLAG_ENABLE_WATTSON_MATCH_CALL", "ENABLE_WATTSON_MATCH_CALL" },
+ [470] = { "FLAG_ENABLE_FLANNERY_MATCH_CALL", "ENABLE_FLANNERY_MATCH_CALL" },
+ [471] = { "FLAG_ENABLE_WINONA_MATCH_CALL", "ENABLE_WINONA_MATCH_CALL" },
+ [472] = { "FLAG_ENABLE_TATE_AND_LIZA_MATCH_CALL", "ENABLE_TATE_AND_LIZA_MATCH_CALL" },
+ [473] = { "FLAG_ENABLE_JUAN_MATCH_CALL", "ENABLE_JUAN_MATCH_CALL" },
+ [474] = { "FLAG_UNUSED_0x1DA", "UNUSED_0x1DA" },
+ [475] = { "FLAG_SHOWN_MYSTIC_TICKET", "SHOWN_MYSTIC_TICKET" },
+ [476] = { "FLAG_DEFEATED_HO_OH", "DEFEATED_HO_OH" },
+ [477] = { "FLAG_DEFEATED_LUGIA", "DEFEATED_LUGIA" },
+ [478] = { "FLAG_UNUSED_0x1DE", "UNUSED_0x1DE" },
+ [479] = { "FLAG_UNUSED_0x1DF", "UNUSED_0x1DF" },
+ [480] = { "FLAG_UNUSED_0x1E0", "UNUSED_0x1E0" },
+ [481] = { "FLAG_UNUSED_0x1E1", "UNUSED_0x1E1" },
+ [482] = { "FLAG_UNUSED_0x1E2", "UNUSED_0x1E2" },
+ [483] = { "FLAG_UNUSED_0x1E3", "UNUSED_0x1E3" },
+ [484] = { "FLAG_MYSTERY_GIFT_DONE", "MYSTERY_GIFT_DONE" },
+ [485] = { "FLAG_MYSTERY_GIFT_1", "MYSTERY_GIFT_1" },
+ [486] = { "FLAG_MYSTERY_GIFT_2", "MYSTERY_GIFT_2" },
+ [487] = { "FLAG_MYSTERY_GIFT_3", "MYSTERY_GIFT_3" },
+ [488] = { "FLAG_MYSTERY_GIFT_4", "MYSTERY_GIFT_4" },
+ [489] = { "FLAG_MYSTERY_GIFT_5", "MYSTERY_GIFT_5" },
+ [490] = { "FLAG_MYSTERY_GIFT_6", "MYSTERY_GIFT_6" },
+ [491] = { "FLAG_MYSTERY_GIFT_7", "MYSTERY_GIFT_7" },
+ [492] = { "FLAG_MYSTERY_GIFT_8", "MYSTERY_GIFT_8" },
+ [493] = { "FLAG_MYSTERY_GIFT_9", "MYSTERY_GIFT_9" },
+ [494] = { "FLAG_MYSTERY_GIFT_10", "MYSTERY_GIFT_10" },
+ [495] = { "FLAG_MYSTERY_GIFT_11", "MYSTERY_GIFT_11" },
+ [496] = { "FLAG_MYSTERY_GIFT_12", "MYSTERY_GIFT_12" },
+ [497] = { "FLAG_MYSTERY_GIFT_13", "MYSTERY_GIFT_13" },
+ [498] = { "FLAG_MYSTERY_GIFT_14", "MYSTERY_GIFT_14" },
+ [499] = { "FLAG_MYSTERY_GIFT_15", "MYSTERY_GIFT_15" },
+ [500] = { "FLAG_HIDDEN_ITEM_LAVARIDGE_TOWN_ICE_HEAL", "FLAG_HIDDEN_ITEMS_START", "HIDDEN_ITEMS_START", "HIDDEN_ITEM_LAVARIDGE_TOWN_ICE_HEAL" },
+ [501] = { "FLAG_HIDDEN_ITEM_TRICK_HOUSE_NUGGET", "HIDDEN_ITEM_TRICK_HOUSE_NUGGET" },
+ [502] = { "FLAG_HIDDEN_ITEM_ROUTE_111_STARDUST", "HIDDEN_ITEM_ROUTE_111_STARDUST" },
+ [503] = { "FLAG_HIDDEN_ITEM_ROUTE_113_ETHER", "HIDDEN_ITEM_ROUTE_113_ETHER" },
+ [504] = { "FLAG_HIDDEN_ITEM_ROUTE_114_CARBOS", "HIDDEN_ITEM_ROUTE_114_CARBOS" },
+ [505] = { "FLAG_HIDDEN_ITEM_ROUTE_119_CALCIUM", "HIDDEN_ITEM_ROUTE_119_CALCIUM" },
+ [506] = { "FLAG_HIDDEN_ITEM_ROUTE_119_ULTRA_BALL", "HIDDEN_ITEM_ROUTE_119_ULTRA_BALL" },
+ [507] = { "FLAG_HIDDEN_ITEM_ROUTE_123_SUPER_REPEL", "HIDDEN_ITEM_ROUTE_123_SUPER_REPEL" },
+ [508] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_CARBOS", "HIDDEN_ITEM_UNDERWATER_124_CARBOS" },
+ [509] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_GREEN_SHARD", "HIDDEN_ITEM_UNDERWATER_124_GREEN_SHARD" },
+ [510] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_PEARL", "HIDDEN_ITEM_UNDERWATER_124_PEARL" },
+ [511] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_BIG_PEARL", "HIDDEN_ITEM_UNDERWATER_124_BIG_PEARL" },
+ [512] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_BLUE_SHARD", "HIDDEN_ITEM_UNDERWATER_126_BLUE_SHARD" },
+ [513] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_1", "HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_1" },
+ [514] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_HEART_SCALE", "HIDDEN_ITEM_UNDERWATER_126_HEART_SCALE" },
+ [515] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_ULTRA_BALL", "HIDDEN_ITEM_UNDERWATER_126_ULTRA_BALL" },
+ [516] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_STARDUST", "HIDDEN_ITEM_UNDERWATER_126_STARDUST" },
+ [517] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_PEARL", "HIDDEN_ITEM_UNDERWATER_126_PEARL" },
+ [518] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_YELLOW_SHARD", "HIDDEN_ITEM_UNDERWATER_126_YELLOW_SHARD" },
+ [519] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_IRON", "HIDDEN_ITEM_UNDERWATER_126_IRON" },
+ [520] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_BIG_PEARL", "HIDDEN_ITEM_UNDERWATER_126_BIG_PEARL" },
+ [521] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_STAR_PIECE", "HIDDEN_ITEM_UNDERWATER_127_STAR_PIECE" },
+ [522] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_HP_UP", "HIDDEN_ITEM_UNDERWATER_127_HP_UP" },
+ [523] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_HEART_SCALE", "HIDDEN_ITEM_UNDERWATER_127_HEART_SCALE" },
+ [524] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_RED_SHARD", "HIDDEN_ITEM_UNDERWATER_127_RED_SHARD" },
+ [525] = { "FLAG_HIDDEN_ITEM_UNDERWATER_128_PROTEIN", "HIDDEN_ITEM_UNDERWATER_128_PROTEIN" },
+ [526] = { "FLAG_HIDDEN_ITEM_UNDERWATER_128_PEARL", "HIDDEN_ITEM_UNDERWATER_128_PEARL" },
+ [527] = { "FLAG_HIDDEN_ITEM_LILYCOVE_CITY_HEART_SCALE", "HIDDEN_ITEM_LILYCOVE_CITY_HEART_SCALE" },
+ [528] = { "FLAG_HIDDEN_ITEM_FALLARBOR_TOWN_NUGGET", "HIDDEN_ITEM_FALLARBOR_TOWN_NUGGET" },
+ [529] = { "FLAG_HIDDEN_ITEM_MT_PYRE_EXTERIOR_ULTRA_BALL", "HIDDEN_ITEM_MT_PYRE_EXTERIOR_ULTRA_BALL" },
+ [530] = { "FLAG_HIDDEN_ITEM_ROUTE_113_TM_DOUBLE_TEAM", "HIDDEN_ITEM_ROUTE_113_TM_DOUBLE_TEAM" },
+ [531] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_1_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_1_KEY" },
+ [532] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_2_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_2_KEY" },
+ [533] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_4_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_4_KEY" },
+ [534] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_6_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_6_KEY" },
+ [535] = { "FLAG_HIDDEN_ITEM_SS_TIDAL_LOWER_DECK_LEFTOVERS", "HIDDEN_ITEM_SS_TIDAL_LOWER_DECK_LEFTOVERS" },
+ [536] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_CALCIUM", "HIDDEN_ITEM_UNDERWATER_124_CALCIUM" },
+ [537] = { "FLAG_HIDDEN_ITEM_ROUTE_104_POTION", "HIDDEN_ITEM_ROUTE_104_POTION" },
+ [538] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_2", "HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_2" },
+ [539] = { "FLAG_HIDDEN_ITEM_ROUTE_121_HP_UP", "HIDDEN_ITEM_ROUTE_121_HP_UP" },
+ [540] = { "FLAG_HIDDEN_ITEM_ROUTE_121_NUGGET", "HIDDEN_ITEM_ROUTE_121_NUGGET" },
+ [541] = { "FLAG_HIDDEN_ITEM_ROUTE_123_REVIVE", "HIDDEN_ITEM_ROUTE_123_REVIVE" },
+ [542] = { "FLAG_HIDDEN_ITEM_ROUTE_114_REVIVE", "HIDDEN_ITEM_ROUTE_114_REVIVE" },
+ [543] = { "FLAG_HIDDEN_ITEM_LILYCOVE_CITY_PP_UP", "HIDDEN_ITEM_LILYCOVE_CITY_PP_UP" },
+ [544] = { "FLAG_HIDDEN_ITEM_ROUTE_104_SUPER_POTION", "HIDDEN_ITEM_ROUTE_104_SUPER_POTION" },
+ [545] = { "FLAG_HIDDEN_ITEM_ROUTE_116_SUPER_POTION", "HIDDEN_ITEM_ROUTE_116_SUPER_POTION" },
+ [546] = { "FLAG_HIDDEN_ITEM_ROUTE_106_STARDUST", "HIDDEN_ITEM_ROUTE_106_STARDUST" },
+ [547] = { "FLAG_HIDDEN_ITEM_ROUTE_106_HEART_SCALE", "HIDDEN_ITEM_ROUTE_106_HEART_SCALE" },
+ [548] = { "FLAG_HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_1", "HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_1" },
+ [549] = { "FLAG_HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_2", "HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_2" },
+ [550] = { "FLAG_HIDDEN_ITEM_ROUTE_109_REVIVE", "HIDDEN_ITEM_ROUTE_109_REVIVE" },
+ [551] = { "FLAG_HIDDEN_ITEM_ROUTE_109_GREAT_BALL", "HIDDEN_ITEM_ROUTE_109_GREAT_BALL" },
+ [552] = { "FLAG_HIDDEN_ITEM_ROUTE_109_HEART_SCALE_1", "HIDDEN_ITEM_ROUTE_109_HEART_SCALE_1" },
+ [553] = { "FLAG_HIDDEN_ITEM_ROUTE_110_GREAT_BALL", "HIDDEN_ITEM_ROUTE_110_GREAT_BALL" },
+ [554] = { "FLAG_HIDDEN_ITEM_ROUTE_110_REVIVE", "HIDDEN_ITEM_ROUTE_110_REVIVE" },
+ [555] = { "FLAG_HIDDEN_ITEM_ROUTE_110_FULL_HEAL", "HIDDEN_ITEM_ROUTE_110_FULL_HEAL" },
+ [556] = { "FLAG_HIDDEN_ITEM_ROUTE_111_PROTEIN", "HIDDEN_ITEM_ROUTE_111_PROTEIN" },
+ [557] = { "FLAG_HIDDEN_ITEM_ROUTE_111_RARE_CANDY", "HIDDEN_ITEM_ROUTE_111_RARE_CANDY" },
+ [558] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_POTION", "HIDDEN_ITEM_PETALBURG_WOODS_POTION" },
+ [559] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_1", "HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_1" },
+ [560] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_2", "HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_2" },
+ [561] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_POKE_BALL", "HIDDEN_ITEM_PETALBURG_WOODS_POKE_BALL" },
+ [562] = { "FLAG_HIDDEN_ITEM_ROUTE_104_POKE_BALL", "HIDDEN_ITEM_ROUTE_104_POKE_BALL" },
+ [563] = { "FLAG_HIDDEN_ITEM_ROUTE_106_POKE_BALL", "HIDDEN_ITEM_ROUTE_106_POKE_BALL" },
+ [564] = { "FLAG_HIDDEN_ITEM_ROUTE_109_ETHER", "HIDDEN_ITEM_ROUTE_109_ETHER" },
+ [565] = { "FLAG_HIDDEN_ITEM_ROUTE_110_POKE_BALL", "HIDDEN_ITEM_ROUTE_110_POKE_BALL" },
+ [566] = { "FLAG_HIDDEN_ITEM_ROUTE_118_HEART_SCALE", "HIDDEN_ITEM_ROUTE_118_HEART_SCALE" },
+ [567] = { "FLAG_HIDDEN_ITEM_ROUTE_118_IRON", "HIDDEN_ITEM_ROUTE_118_IRON" },
+ [568] = { "FLAG_HIDDEN_ITEM_ROUTE_119_FULL_HEAL", "HIDDEN_ITEM_ROUTE_119_FULL_HEAL" },
+ [569] = { "FLAG_HIDDEN_ITEM_ROUTE_120_RARE_CANDY_2", "HIDDEN_ITEM_ROUTE_120_RARE_CANDY_2" },
+ [570] = { "FLAG_HIDDEN_ITEM_ROUTE_120_ZINC", "HIDDEN_ITEM_ROUTE_120_ZINC" },
+ [571] = { "FLAG_HIDDEN_ITEM_ROUTE_120_RARE_CANDY_1", "HIDDEN_ITEM_ROUTE_120_RARE_CANDY_1" },
+ [572] = { "FLAG_HIDDEN_ITEM_ROUTE_117_REPEL", "HIDDEN_ITEM_ROUTE_117_REPEL" },
+ [573] = { "FLAG_HIDDEN_ITEM_ROUTE_121_FULL_HEAL", "HIDDEN_ITEM_ROUTE_121_FULL_HEAL" },
+ [574] = { "FLAG_HIDDEN_ITEM_ROUTE_123_HYPER_POTION", "HIDDEN_ITEM_ROUTE_123_HYPER_POTION" },
+ [575] = { "FLAG_HIDDEN_ITEM_LILYCOVE_CITY_POKE_BALL", "HIDDEN_ITEM_LILYCOVE_CITY_POKE_BALL" },
+ [576] = { "FLAG_HIDDEN_ITEM_JAGGED_PASS_GREAT_BALL", "HIDDEN_ITEM_JAGGED_PASS_GREAT_BALL" },
+ [577] = { "FLAG_HIDDEN_ITEM_JAGGED_PASS_FULL_HEAL", "HIDDEN_ITEM_JAGGED_PASS_FULL_HEAL" },
+ [578] = { "FLAG_HIDDEN_ITEM_MT_PYRE_EXTERIOR_MAX_ETHER", "HIDDEN_ITEM_MT_PYRE_EXTERIOR_MAX_ETHER" },
+ [579] = { "FLAG_HIDDEN_ITEM_MT_PYRE_SUMMIT_ZINC", "HIDDEN_ITEM_MT_PYRE_SUMMIT_ZINC" },
+ [580] = { "FLAG_HIDDEN_ITEM_MT_PYRE_SUMMIT_RARE_CANDY", "HIDDEN_ITEM_MT_PYRE_SUMMIT_RARE_CANDY" },
+ [581] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL", "HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL" },
+ [582] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_B2F_ELIXIR", "HIDDEN_ITEM_VICTORY_ROAD_B2F_ELIXIR" },
+ [583] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_B2F_MAX_REPEL", "HIDDEN_ITEM_VICTORY_ROAD_B2F_MAX_REPEL" },
+ [584] = { "FLAG_HIDDEN_ITEM_ROUTE_120_REVIVE", "HIDDEN_ITEM_ROUTE_120_REVIVE" },
+ [585] = { "FLAG_HIDDEN_ITEM_ROUTE_104_ANTIDOTE", "HIDDEN_ITEM_ROUTE_104_ANTIDOTE" },
+ [586] = { "FLAG_HIDDEN_ITEM_ROUTE_108_RARE_CANDY", "HIDDEN_ITEM_ROUTE_108_RARE_CANDY" },
+ [587] = { "FLAG_HIDDEN_ITEM_ROUTE_119_MAX_ETHER", "HIDDEN_ITEM_ROUTE_119_MAX_ETHER" },
+ [588] = { "FLAG_HIDDEN_ITEM_ROUTE_104_HEART_SCALE", "HIDDEN_ITEM_ROUTE_104_HEART_SCALE" },
+ [589] = { "FLAG_HIDDEN_ITEM_ROUTE_105_HEART_SCALE", "HIDDEN_ITEM_ROUTE_105_HEART_SCALE" },
+ [590] = { "FLAG_HIDDEN_ITEM_ROUTE_109_HEART_SCALE_2", "HIDDEN_ITEM_ROUTE_109_HEART_SCALE_2" },
+ [591] = { "FLAG_HIDDEN_ITEM_ROUTE_109_HEART_SCALE_3", "HIDDEN_ITEM_ROUTE_109_HEART_SCALE_3" },
+ [592] = { "FLAG_HIDDEN_ITEM_ROUTE_128_HEART_SCALE_1", "HIDDEN_ITEM_ROUTE_128_HEART_SCALE_1" },
+ [593] = { "FLAG_HIDDEN_ITEM_ROUTE_128_HEART_SCALE_2", "HIDDEN_ITEM_ROUTE_128_HEART_SCALE_2" },
+ [594] = { "FLAG_HIDDEN_ITEM_ROUTE_128_HEART_SCALE_3", "HIDDEN_ITEM_ROUTE_128_HEART_SCALE_3" },
+ [595] = { "FLAG_HIDDEN_ITEM_PETALBURG_CITY_RARE_CANDY", "HIDDEN_ITEM_PETALBURG_CITY_RARE_CANDY" },
+ [596] = { "FLAG_HIDDEN_ITEM_ROUTE_116_BLACK_GLASSES", "HIDDEN_ITEM_ROUTE_116_BLACK_GLASSES" },
+ [597] = { "FLAG_HIDDEN_ITEM_ROUTE_115_HEART_SCALE", "HIDDEN_ITEM_ROUTE_115_HEART_SCALE" },
+ [598] = { "FLAG_HIDDEN_ITEM_ROUTE_113_NUGGET", "HIDDEN_ITEM_ROUTE_113_NUGGET" },
+ [599] = { "FLAG_HIDDEN_ITEM_ROUTE_123_PP_UP", "HIDDEN_ITEM_ROUTE_123_PP_UP" },
+ [600] = { "FLAG_HIDDEN_ITEM_ROUTE_121_MAX_REVIVE", "HIDDEN_ITEM_ROUTE_121_MAX_REVIVE" },
+ [601] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_CALCIUM", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_CALCIUM" },
+ [602] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_ZINC", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_ZINC" },
+ [603] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_PROTEIN", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_PROTEIN" },
+ [604] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_IRON", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_IRON" },
+ [605] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_FULL_RESTORE", "HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_FULL_RESTORE" },
+ [606] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_RARE_CANDY", "HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_RARE_CANDY" },
+ [607] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_ZINC", "HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_ZINC" },
+ [608] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_PP_UP", "HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_PP_UP" },
+ [609] = { "FLAG_HIDDEN_ITEM_NAVEL_ROCK_TOP_SACRED_ASH", "HIDDEN_ITEM_NAVEL_ROCK_TOP_SACRED_ASH" },
+ [610] = { "FLAG_HIDDEN_ITEM_ROUTE_123_RARE_CANDY", "HIDDEN_ITEM_ROUTE_123_RARE_CANDY" },
+ [611] = { "FLAG_HIDDEN_ITEM_ROUTE_105_BIG_PEARL", "HIDDEN_ITEM_ROUTE_105_BIG_PEARL" },
+ [612] = { "FLAG_UNUSED_0x264", "UNUSED_0x264" },
+ [613] = { "FLAG_UNUSED_0x265", "UNUSED_0x265" },
+ [614] = { "FLAG_UNUSED_0x266", "UNUSED_0x266" },
+ [615] = { "FLAG_UNUSED_0x267", "UNUSED_0x267" },
+ [616] = { "FLAG_UNUSED_0x268", "UNUSED_0x268" },
+ [617] = { "FLAG_UNUSED_0x269", "UNUSED_0x269" },
+ [618] = { "FLAG_UNUSED_0x26A", "UNUSED_0x26A" },
+ [619] = { "FLAG_UNUSED_0x26B", "UNUSED_0x26B" },
+ [620] = { "FLAG_UNUSED_0x26C", "UNUSED_0x26C" },
+ [621] = { "FLAG_UNUSED_0x26D", "UNUSED_0x26D" },
+ [622] = { "FLAG_UNUSED_0x26E", "UNUSED_0x26E" },
+ [623] = { "FLAG_UNUSED_0x26F", "UNUSED_0x26F" },
+ [624] = { "FLAG_UNUSED_0x270", "UNUSED_0x270" },
+ [625] = { "FLAG_UNUSED_0x271", "UNUSED_0x271" },
+ [626] = { "FLAG_UNUSED_0x272", "UNUSED_0x272" },
+ [627] = { "FLAG_UNUSED_0x273", "UNUSED_0x273" },
+ [628] = { "FLAG_UNUSED_0x274", "UNUSED_0x274" },
+ [629] = { "FLAG_UNUSED_0x275", "UNUSED_0x275" },
+ [630] = { "FLAG_UNUSED_0x276", "UNUSED_0x276" },
+ [631] = { "FLAG_UNUSED_0x277", "UNUSED_0x277" },
+ [632] = { "FLAG_UNUSED_0x278", "UNUSED_0x278" },
+ [633] = { "FLAG_UNUSED_0x279", "UNUSED_0x279" },
+ [634] = { "FLAG_UNUSED_0x27A", "UNUSED_0x27A" },
+ [635] = { "FLAG_UNUSED_0x27B", "UNUSED_0x27B" },
+ [636] = { "FLAG_UNUSED_0x27C", "UNUSED_0x27C" },
+ [637] = { "FLAG_UNUSED_0x27D", "UNUSED_0x27D" },
+ [638] = { "FLAG_UNUSED_0x27E", "UNUSED_0x27E" },
+ [639] = { "FLAG_UNUSED_0x27F", "UNUSED_0x27F" },
+ [640] = { "FLAG_UNUSED_0x280", "UNUSED_0x280" },
+ [641] = { "FLAG_UNUSED_0x281", "UNUSED_0x281" },
+ [642] = { "FLAG_UNUSED_0x282", "UNUSED_0x282" },
+ [643] = { "FLAG_UNUSED_0x283", "UNUSED_0x283" },
+ [644] = { "FLAG_UNUSED_0x284", "UNUSED_0x284" },
+ [645] = { "FLAG_UNUSED_0x285", "UNUSED_0x285" },
+ [646] = { "FLAG_UNUSED_0x286", "UNUSED_0x286" },
+ [647] = { "FLAG_UNUSED_0x287", "UNUSED_0x287" },
+ [648] = { "FLAG_UNUSED_0x288", "UNUSED_0x288" },
+ [649] = { "FLAG_UNUSED_0x289", "UNUSED_0x289" },
+ [650] = { "FLAG_UNUSED_0x28A", "UNUSED_0x28A" },
+ [651] = { "FLAG_UNUSED_0x28B", "UNUSED_0x28B" },
+ [652] = { "FLAG_UNUSED_0x28C", "UNUSED_0x28C" },
+ [653] = { "FLAG_UNUSED_0x28D", "UNUSED_0x28D" },
+ [654] = { "FLAG_UNUSED_0x28E", "UNUSED_0x28E" },
+ [655] = { "FLAG_UNUSED_0x28F", "UNUSED_0x28F" },
+ [656] = { "FLAG_UNUSED_0x290", "UNUSED_0x290" },
+ [657] = { "FLAG_UNUSED_0x291", "UNUSED_0x291" },
+ [658] = { "FLAG_UNUSED_0x292", "UNUSED_0x292" },
+ [659] = { "FLAG_UNUSED_0x293", "UNUSED_0x293" },
+ [660] = { "FLAG_UNUSED_0x294", "UNUSED_0x294" },
+ [661] = { "FLAG_UNUSED_0x295", "UNUSED_0x295" },
+ [662] = { "FLAG_UNUSED_0x296", "UNUSED_0x296" },
+ [663] = { "FLAG_UNUSED_0x297", "UNUSED_0x297" },
+ [664] = { "FLAG_UNUSED_0x298", "UNUSED_0x298" },
+ [665] = { "FLAG_UNUSED_0x299", "UNUSED_0x299" },
+ [666] = { "FLAG_UNUSED_0x29A", "UNUSED_0x29A" },
+ [667] = { "FLAG_UNUSED_0x29B", "UNUSED_0x29B" },
+ [668] = { "FLAG_UNUSED_0x29C", "UNUSED_0x29C" },
+ [669] = { "FLAG_UNUSED_0x29D", "UNUSED_0x29D" },
+ [670] = { "FLAG_UNUSED_0x29E", "UNUSED_0x29E" },
+ [671] = { "FLAG_UNUSED_0x29F", "UNUSED_0x29F" },
+ [672] = { "FLAG_UNUSED_0x2A0", "UNUSED_0x2A0" },
+ [673] = { "FLAG_UNUSED_0x2A1", "UNUSED_0x2A1" },
+ [674] = { "FLAG_UNUSED_0x2A2", "UNUSED_0x2A2" },
+ [675] = { "FLAG_UNUSED_0x2A3", "UNUSED_0x2A3" },
+ [676] = { "FLAG_UNUSED_0x2A4", "UNUSED_0x2A4" },
+ [677] = { "FLAG_UNUSED_0x2A5", "UNUSED_0x2A5" },
+ [678] = { "FLAG_UNUSED_0x2A6", "UNUSED_0x2A6" },
+ [679] = { "FLAG_UNUSED_0x2A7", "UNUSED_0x2A7" },
+ [680] = { "FLAG_UNUSED_0x2A8", "UNUSED_0x2A8" },
+ [681] = { "FLAG_UNUSED_0x2A9", "UNUSED_0x2A9" },
+ [682] = { "FLAG_UNUSED_0x2AA", "UNUSED_0x2AA" },
+ [683] = { "FLAG_UNUSED_0x2AB", "UNUSED_0x2AB" },
+ [684] = { "FLAG_UNUSED_0x2AC", "UNUSED_0x2AC" },
+ [685] = { "FLAG_UNUSED_0x2AD", "UNUSED_0x2AD" },
+ [686] = { "FLAG_UNUSED_0x2AE", "UNUSED_0x2AE" },
+ [687] = { "FLAG_UNUSED_0x2AF", "UNUSED_0x2AF" },
+ [688] = { "FLAG_UNUSED_0x2B0", "UNUSED_0x2B0" },
+ [689] = { "FLAG_UNUSED_0x2B1", "UNUSED_0x2B1" },
+ [690] = { "FLAG_UNUSED_0x2B2", "UNUSED_0x2B2" },
+ [691] = { "FLAG_UNUSED_0x2B3", "UNUSED_0x2B3" },
+ [692] = { "FLAG_UNUSED_0x2B4", "UNUSED_0x2B4" },
+ [693] = { "FLAG_UNUSED_0x2B5", "UNUSED_0x2B5" },
+ [694] = { "FLAG_UNUSED_0x2B6", "UNUSED_0x2B6" },
+ [695] = { "FLAG_UNUSED_0x2B7", "UNUSED_0x2B7" },
+ [696] = { "FLAG_UNUSED_0x2B8", "UNUSED_0x2B8" },
+ [697] = { "FLAG_UNUSED_0x2B9", "UNUSED_0x2B9" },
+ [698] = { "FLAG_UNUSED_0x2BA", "UNUSED_0x2BA" },
+ [699] = { "FLAG_UNUSED_0x2BB", "UNUSED_0x2BB" },
+ [700] = { "FLAG_HIDE_ROUTE_101_BIRCH_STARTERS_BAG", "HIDE_ROUTE_101_BIRCH_STARTERS_BAG" },
+ [701] = { "FLAG_HIDE_APPRENTICE", "HIDE_APPRENTICE" },
+ [702] = { "FLAG_HIDE_POKEMON_CENTER_2F_MYSTERY_GIFT_MAN", "HIDE_POKEMON_CENTER_2F_MYSTERY_GIFT_MAN" },
+ [703] = { "FLAG_HIDE_UNION_ROOM_PLAYER_1", "HIDE_UNION_ROOM_PLAYER_1" },
+ [704] = { "FLAG_HIDE_UNION_ROOM_PLAYER_2", "HIDE_UNION_ROOM_PLAYER_2" },
+ [705] = { "FLAG_HIDE_UNION_ROOM_PLAYER_3", "HIDE_UNION_ROOM_PLAYER_3" },
+ [706] = { "FLAG_HIDE_UNION_ROOM_PLAYER_4", "HIDE_UNION_ROOM_PLAYER_4" },
+ [707] = { "FLAG_HIDE_UNION_ROOM_PLAYER_5", "HIDE_UNION_ROOM_PLAYER_5" },
+ [708] = { "FLAG_HIDE_UNION_ROOM_PLAYER_6", "HIDE_UNION_ROOM_PLAYER_6" },
+ [709] = { "FLAG_HIDE_UNION_ROOM_PLAYER_7", "HIDE_UNION_ROOM_PLAYER_7" },
+ [710] = { "FLAG_HIDE_UNION_ROOM_PLAYER_8", "HIDE_UNION_ROOM_PLAYER_8" },
+ [711] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_1", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_1" },
+ [712] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_2", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_2" },
+ [713] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_3", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_3" },
+ [714] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_4", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_4" },
+ [715] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_5", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_5" },
+ [716] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_6", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_6" },
+ [717] = { "FLAG_HIDE_SAFARI_ZONE_SOUTH_CONSTRUCTION_WORKERS", "HIDE_SAFARI_ZONE_SOUTH_CONSTRUCTION_WORKERS" },
+ [718] = { "FLAG_HIDE_MEW", "HIDE_MEW" },
+ [719] = { "FLAG_HIDE_ROUTE_104_RIVAL", "HIDE_ROUTE_104_RIVAL" },
+ [720] = { "FLAG_HIDE_ROUTE_101_BIRCH_ZIGZAGOON_BATTLE", "HIDE_ROUTE_101_BIRCH_ZIGZAGOON_BATTLE" },
+ [721] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_BIRCH", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_BIRCH" },
+ [722] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_BEDROOM", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_BEDROOM" },
+ [723] = { "FLAG_HIDE_ROUTE_103_RIVAL", "HIDE_ROUTE_103_RIVAL" },
+ [724] = { "FLAG_HIDE_PETALBURG_WOODS_DEVON_EMPLOYEE", "HIDE_PETALBURG_WOODS_DEVON_EMPLOYEE" },
+ [725] = { "FLAG_HIDE_PETALBURG_WOODS_AQUA_GRUNT", "HIDE_PETALBURG_WOODS_AQUA_GRUNT" },
+ [726] = { "FLAG_HIDE_PETALBURG_CITY_WALLY", "HIDE_PETALBURG_CITY_WALLY" },
+ [727] = { "FLAG_HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_INVISIBLE_NINJA_BOY", "HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_INVISIBLE_NINJA_BOY" },
+ [728] = { "FLAG_HIDE_PETALBURG_CITY_WALLYS_MOM", "HIDE_PETALBURG_CITY_WALLYS_MOM" },
+ [729] = { "FLAG_UNUSED_0x2D9", "UNUSED_0x2D9" },
+ [730] = { "FLAG_HIDE_LILYCOVE_FAN_CLUB_INTERVIEWER", "HIDE_LILYCOVE_FAN_CLUB_INTERVIEWER" },
+ [731] = { "FLAG_HIDE_RUSTBORO_CITY_AQUA_GRUNT", "HIDE_RUSTBORO_CITY_AQUA_GRUNT" },
+ [732] = { "FLAG_HIDE_RUSTBORO_CITY_DEVON_EMPLOYEE_1", "HIDE_RUSTBORO_CITY_DEVON_EMPLOYEE_1" },
+ [733] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE_ASLEEP", "HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE_ASLEEP" },
+ [734] = { "FLAG_HIDE_PLAYERS_HOUSE_DAD", "HIDE_PLAYERS_HOUSE_DAD" },
+ [735] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_SIBLING", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_SIBLING" },
+ [736] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_SIBLING", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_SIBLING" },
+ [737] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_MAGMA_NOTE", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_MAGMA_NOTE" },
+ [738] = { "FLAG_HIDE_ROUTE_104_MR_BRINEY", "HIDE_ROUTE_104_MR_BRINEY" },
+ [739] = { "FLAG_HIDE_BRINEYS_HOUSE_MR_BRINEY", "HIDE_BRINEYS_HOUSE_MR_BRINEY" },
+ [740] = { "FLAG_HIDE_MR_BRINEY_DEWFORD_TOWN", "HIDE_MR_BRINEY_DEWFORD_TOWN" },
+ [741] = { "FLAG_HIDE_ROUTE_109_MR_BRINEY", "HIDE_ROUTE_109_MR_BRINEY" },
+ [742] = { "FLAG_HIDE_ROUTE_104_MR_BRINEY_BOAT", "HIDE_ROUTE_104_MR_BRINEY_BOAT" },
+ [743] = { "FLAG_HIDE_MR_BRINEY_BOAT_DEWFORD_TOWN", "HIDE_MR_BRINEY_BOAT_DEWFORD_TOWN" },
+ [744] = { "FLAG_HIDE_ROUTE_109_MR_BRINEY_BOAT", "HIDE_ROUTE_109_MR_BRINEY_BOAT" },
+ [745] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_BRENDAN", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_BRENDAN" },
+ [746] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MAY", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MAY" },
+ [747] = { "FLAG_HIDE_SAFARI_ZONE_SOUTH_EAST_EXPANSION", "HIDE_SAFARI_ZONE_SOUTH_EAST_EXPANSION" },
+ [748] = { "FLAG_HIDE_LILYCOVE_HARBOR_EVENT_TICKET_TAKER", "HIDE_LILYCOVE_HARBOR_EVENT_TICKET_TAKER" },
+ [749] = { "FLAG_HIDE_SLATEPORT_CITY_SCOTT", "HIDE_SLATEPORT_CITY_SCOTT" },
+ [750] = { "FLAG_HIDE_ROUTE_101_ZIGZAGOON", "HIDE_ROUTE_101_ZIGZAGOON" },
+ [751] = { "FLAG_HIDE_VICTORY_ROAD_EXIT_WALLY", "HIDE_VICTORY_ROAD_EXIT_WALLY" },
+ [752] = { "FLAG_HIDE_LITTLEROOT_TOWN_MOM_OUTSIDE", "HIDE_LITTLEROOT_TOWN_MOM_OUTSIDE" },
+ [753] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_STEVEN", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_STEVEN" },
+ [754] = { "FLAG_HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_1", "HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_1" },
+ [755] = { "FLAG_HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_2", "HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_2" },
+ [756] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_TEAM_MAGMA", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_TEAM_MAGMA" },
+ [757] = { "FLAG_HIDE_LITTLEROOT_TOWN_PLAYERS_BEDROOM_MOM", "HIDE_LITTLEROOT_TOWN_PLAYERS_BEDROOM_MOM" },
+ [758] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_MOM", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_MOM" },
+ [759] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MOM", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MOM" },
+ [760] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_BEDROOM", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_BEDROOM" },
+ [761] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_TRUCK", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_TRUCK" },
+ [762] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_TRUCK", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_TRUCK" },
+ [763] = { "FLAG_HIDE_DEOXYS", "HIDE_DEOXYS" },
+ [764] = { "FLAG_HIDE_BIRTH_ISLAND_DEOXYS_TRIANGLE", "HIDE_BIRTH_ISLAND_DEOXYS_TRIANGLE" },
+ [765] = { "FLAG_HIDE_MAUVILLE_CITY_SCOTT", "HIDE_MAUVILLE_CITY_SCOTT" },
+ [766] = { "FLAG_HIDE_VERDANTURF_TOWN_SCOTT", "HIDE_VERDANTURF_TOWN_SCOTT" },
+ [767] = { "FLAG_HIDE_FALLARBOR_TOWN_BATTLE_TENT_SCOTT", "HIDE_FALLARBOR_TOWN_BATTLE_TENT_SCOTT" },
+ [768] = { "FLAG_HIDE_ROUTE_111_VICTOR_WINSTRATE", "HIDE_ROUTE_111_VICTOR_WINSTRATE" },
+ [769] = { "FLAG_HIDE_ROUTE_111_VICTORIA_WINSTRATE", "HIDE_ROUTE_111_VICTORIA_WINSTRATE" },
+ [770] = { "FLAG_HIDE_ROUTE_111_VIVI_WINSTRATE", "HIDE_ROUTE_111_VIVI_WINSTRATE" },
+ [771] = { "FLAG_HIDE_ROUTE_111_VICKY_WINSTRATE", "HIDE_ROUTE_111_VICKY_WINSTRATE" },
+ [772] = { "FLAG_HIDE_PETALBURG_GYM_NORMAN", "HIDE_PETALBURG_GYM_NORMAN" },
+ [773] = { "FLAG_HIDE_SKY_PILLAR_TOP_RAYQUAZA", "HIDE_SKY_PILLAR_TOP_RAYQUAZA" },
+ [774] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_1", "HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_1" },
+ [775] = { "FLAG_HIDE_LILYCOVE_MUSEUM_CURATOR", "HIDE_LILYCOVE_MUSEUM_CURATOR" },
+ [776] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_1", "HIDE_LILYCOVE_MUSEUM_PATRON_1" },
+ [777] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_2", "HIDE_LILYCOVE_MUSEUM_PATRON_2" },
+ [778] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_3", "HIDE_LILYCOVE_MUSEUM_PATRON_3" },
+ [779] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_4", "HIDE_LILYCOVE_MUSEUM_PATRON_4" },
+ [780] = { "FLAG_HIDE_LILYCOVE_MUSEUM_TOURISTS", "HIDE_LILYCOVE_MUSEUM_TOURISTS" },
+ [781] = { "FLAG_HIDE_PETALBURG_GYM_GREETER", "HIDE_PETALBURG_GYM_GREETER" },
+ [782] = { "FLAG_HIDE_MARINE_CAVE_KYOGRE", "HIDE_MARINE_CAVE_KYOGRE" },
+ [783] = { "FLAG_HIDE_TERRA_CAVE_GROUDON", "HIDE_TERRA_CAVE_GROUDON" },
+ [784] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_MOM", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_MOM" },
+ [785] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_MOM", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_MOM" },
+ [786] = { "FLAG_HIDE_ROUTE_119_SCOTT", "HIDE_ROUTE_119_SCOTT" },
+ [787] = { "FLAG_HIDE_LILYCOVE_MOTEL_SCOTT", "HIDE_LILYCOVE_MOTEL_SCOTT" },
+ [788] = { "FLAG_HIDE_MOSSDEEP_CITY_SCOTT", "HIDE_MOSSDEEP_CITY_SCOTT" },
+ [789] = { "FLAG_HIDE_FANCLUB_OLD_LADY", "HIDE_FANCLUB_OLD_LADY" },
+ [790] = { "FLAG_HIDE_FANCLUB_BOY", "HIDE_FANCLUB_BOY" },
+ [791] = { "FLAG_HIDE_FANCLUB_LITTLE_BOY", "HIDE_FANCLUB_LITTLE_BOY" },
+ [792] = { "FLAG_HIDE_FANCLUB_LADY", "HIDE_FANCLUB_LADY" },
+ [793] = { "FLAG_HIDE_EVER_GRANDE_POKEMON_CENTER_1F_SCOTT", "HIDE_EVER_GRANDE_POKEMON_CENTER_1F_SCOTT" },
+ [794] = { "FLAG_HIDE_LITTLEROOT_TOWN_RIVAL", "HIDE_LITTLEROOT_TOWN_RIVAL" },
+ [795] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCH", "HIDE_LITTLEROOT_TOWN_BIRCH" },
+ [796] = { "FLAG_HIDE_ROUTE_111_GABBY_AND_TY_1", "HIDE_ROUTE_111_GABBY_AND_TY_1" },
+ [797] = { "FLAG_HIDE_ROUTE_118_GABBY_AND_TY_1", "HIDE_ROUTE_118_GABBY_AND_TY_1" },
+ [798] = { "FLAG_HIDE_ROUTE_120_GABBY_AND_TY_1", "HIDE_ROUTE_120_GABBY_AND_TY_1" },
+ [799] = { "FLAG_HIDE_ROUTE_111_GABBY_AND_TY_2", "HIDE_ROUTE_111_GABBY_AND_TY_2" },
+ [800] = { "FLAG_HIDE_LUGIA", "HIDE_LUGIA" },
+ [801] = { "FLAG_HIDE_HO_OH", "HIDE_HO_OH" },
+ [802] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_REPORTER", "HIDE_LILYCOVE_CONTEST_HALL_REPORTER" },
+ [803] = { "FLAG_HIDE_SLATEPORT_CITY_CONTEST_REPORTER", "HIDE_SLATEPORT_CITY_CONTEST_REPORTER" },
+ [804] = { "FLAG_HIDE_MAUVILLE_CITY_WALLY", "HIDE_MAUVILLE_CITY_WALLY" },
+ [805] = { "FLAG_HIDE_MAUVILLE_CITY_WALLYS_UNCLE", "HIDE_MAUVILLE_CITY_WALLYS_UNCLE" },
+ [806] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLY", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLY" },
+ [807] = { "FLAG_HIDE_RUSTURF_TUNNEL_WANDAS_BOYFRIEND", "HIDE_RUSTURF_TUNNEL_WANDAS_BOYFRIEND" },
+ [808] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDAS_BOYFRIEND", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDAS_BOYFRIEND" },
+ [809] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLYS_UNCLE", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLYS_UNCLE" },
+ [810] = { "FLAG_HIDE_SS_TIDAL_CORRIDOR_SCOTT", "HIDE_SS_TIDAL_CORRIDOR_SCOTT" },
+ [811] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CYNDAQUIL", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CYNDAQUIL" },
+ [812] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_TOTODILE", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_TOTODILE" },
+ [813] = { "FLAG_HIDE_ROUTE_116_DROPPED_GLASSES_MAN", "HIDE_ROUTE_116_DROPPED_GLASSES_MAN" },
+ [814] = { "FLAG_HIDE_RUSTBORO_CITY_RIVAL", "HIDE_RUSTBORO_CITY_RIVAL" },
+ [815] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_SWABLU_DOLL", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_SWABLU_DOLL" },
+ [816] = { "FLAG_HIDE_SOOTOPOLIS_CITY_WALLACE", "HIDE_SOOTOPOLIS_CITY_WALLACE" },
+ [817] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_POKE_BALL", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_POKE_BALL" },
+ [818] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_POKE_BALL", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_POKE_BALL" },
+ [819] = { "FLAG_HIDE_ROUTE_112_TEAM_MAGMA", "HIDE_ROUTE_112_TEAM_MAGMA" },
+ [820] = { "FLAG_HIDE_CAVE_OF_ORIGIN_B1F_WALLACE", "HIDE_CAVE_OF_ORIGIN_B1F_WALLACE" },
+ [821] = { "FLAG_HIDE_AQUA_HIDEOUT_1F_GRUNT_1_BLOCKING_ENTRANCE", "HIDE_AQUA_HIDEOUT_1F_GRUNT_1_BLOCKING_ENTRANCE" },
+ [822] = { "FLAG_HIDE_AQUA_HIDEOUT_1F_GRUNT_2_BLOCKING_ENTRANCE", "HIDE_AQUA_HIDEOUT_1F_GRUNT_2_BLOCKING_ENTRANCE" },
+ [823] = { "FLAG_HIDE_MOSSDEEP_CITY_TEAM_MAGMA", "HIDE_MOSSDEEP_CITY_TEAM_MAGMA" },
+ [824] = { "FLAG_HIDE_PETALBURG_GYM_WALLYS_DAD", "HIDE_PETALBURG_GYM_WALLYS_DAD" },
+ [825] = { "FLAG_HIDE_LEGEND_MON_CAVE_OF_ORIGIN", "HIDE_LEGEND_MON_CAVE_OF_ORIGIN" },
+ [826] = { "FLAG_HIDE_SOOTOPOLIS_CITY_ARCHIE", "HIDE_SOOTOPOLIS_CITY_ARCHIE" },
+ [827] = { "FLAG_HIDE_SOOTOPOLIS_CITY_MAXIE", "HIDE_SOOTOPOLIS_CITY_MAXIE" },
+ [828] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_ARCHIE", "HIDE_SEAFLOOR_CAVERN_ROOM_9_ARCHIE" },
+ [829] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_MAXIE", "HIDE_SEAFLOOR_CAVERN_ROOM_9_MAXIE" },
+ [830] = { "FLAG_HIDE_PETALBURG_CITY_WALLYS_DAD", "HIDE_PETALBURG_CITY_WALLYS_DAD" },
+ [831] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_MAGMA_GRUNTS", "HIDE_SEAFLOOR_CAVERN_ROOM_9_MAGMA_GRUNTS" },
+ [832] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER", "HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER" },
+ [833] = { "FLAG_HIDE_GRANITE_CAVE_STEVEN", "HIDE_GRANITE_CAVE_STEVEN" },
+ [834] = { "FLAG_HIDE_ROUTE_128_STEVEN", "HIDE_ROUTE_128_STEVEN" },
+ [835] = { "FLAG_HIDE_SLATEPORT_CITY_GABBY_AND_TY", "HIDE_SLATEPORT_CITY_GABBY_AND_TY" },
+ [836] = { "FLAG_HIDE_BATTLE_FRONTIER_RECEPTION_GATE_SCOTT", "HIDE_BATTLE_FRONTIER_RECEPTION_GATE_SCOTT" },
+ [837] = { "FLAG_HIDE_ROUTE_110_BIRCH", "HIDE_ROUTE_110_BIRCH" },
+ [838] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CHIKORITA", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CHIKORITA" },
+ [839] = { "FLAG_HIDE_SOOTOPOLIS_CITY_MAN_1", "HIDE_SOOTOPOLIS_CITY_MAN_1" },
+ [840] = { "FLAG_HIDE_SLATEPORT_CITY_CAPTAIN_STERN", "HIDE_SLATEPORT_CITY_CAPTAIN_STERN" },
+ [841] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_CAPTAIN_STERN", "HIDE_SLATEPORT_CITY_HARBOR_CAPTAIN_STERN" },
+ [842] = { "FLAG_HIDE_BATTLE_FRONTIER_SUDOWOODO", "HIDE_BATTLE_FRONTIER_SUDOWOODO" },
+ [843] = { "FLAG_HIDE_ROUTE_111_ROCK_SMASH_TIP_GUY", "HIDE_ROUTE_111_ROCK_SMASH_TIP_GUY" },
+ [844] = { "FLAG_HIDE_RUSTBORO_CITY_SCIENTIST", "HIDE_RUSTBORO_CITY_SCIENTIST" },
+ [845] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_AQUA_GRUNT", "HIDE_SLATEPORT_CITY_HARBOR_AQUA_GRUNT" },
+ [846] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_ARCHIE", "HIDE_SLATEPORT_CITY_HARBOR_ARCHIE" },
+ [847] = { "FLAG_HIDE_JAGGED_PASS_MAGMA_GUARD", "HIDE_JAGGED_PASS_MAGMA_GUARD" },
+ [848] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_SUBMARINE_SHADOW", "HIDE_SLATEPORT_CITY_HARBOR_SUBMARINE_SHADOW" },
+ [849] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_PICHU_DOLL", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_PICHU_DOLL" },
+ [850] = { "FLAG_HIDE_MAGMA_HIDEOUT_4F_GROUDON_ASLEEP", "HIDE_MAGMA_HIDEOUT_4F_GROUDON_ASLEEP" },
+ [851] = { "FLAG_HIDE_ROUTE_119_RIVAL", "HIDE_ROUTE_119_RIVAL" },
+ [852] = { "FLAG_HIDE_LILYCOVE_CITY_AQUA_GRUNTS", "HIDE_LILYCOVE_CITY_AQUA_GRUNTS" },
+ [853] = { "FLAG_HIDE_MAGMA_HIDEOUT_4F_GROUDON", "HIDE_MAGMA_HIDEOUT_4F_GROUDON" },
+ [854] = { "FLAG_HIDE_SOOTOPOLIS_CITY_RESIDENTS", "HIDE_SOOTOPOLIS_CITY_RESIDENTS" },
+ [855] = { "FLAG_HIDE_SKY_PILLAR_WALLACE", "HIDE_SKY_PILLAR_WALLACE" },
+ [856] = { "FLAG_HIDE_MT_PYRE_SUMMIT_MAXIE", "HIDE_MT_PYRE_SUMMIT_MAXIE" },
+ [857] = { "FLAG_HIDE_MAGMA_HIDEOUT_GRUNTS", "HIDE_MAGMA_HIDEOUT_GRUNTS" },
+ [858] = { "FLAG_HIDE_VICTORY_ROAD_ENTRANCE_WALLY", "HIDE_VICTORY_ROAD_ENTRANCE_WALLY" },
+ [859] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE", "HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE" },
+ [860] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_SS_TIDAL", "HIDE_SLATEPORT_CITY_HARBOR_SS_TIDAL" },
+ [861] = { "FLAG_HIDE_LILYCOVE_HARBOR_SSTIDAL", "HIDE_LILYCOVE_HARBOR_SSTIDAL" },
+ [862] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_TEAM_MAGMA", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_TEAM_MAGMA" },
+ [863] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_STEVEN", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_STEVEN" },
+ [864] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_1", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_1" },
+ [865] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_2", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_2" },
+ [866] = { "FLAG_HIDE_PETALBURG_GYM_WALLY", "HIDE_PETALBURG_GYM_WALLY" },
+ [867] = { "FLAG_UNKNOWN_0x363", "UNKNOWN_0x363" },
+ [868] = { "FLAG_HIDE_LITTLEROOT_TOWN_FAT_MAN", "HIDE_LITTLEROOT_TOWN_FAT_MAN" },
+ [869] = { "FLAG_HIDE_SLATEPORT_CITY_STERNS_SHIPYARD_MR_BRINEY", "HIDE_SLATEPORT_CITY_STERNS_SHIPYARD_MR_BRINEY" },
+ [870] = { "FLAG_HIDE_LANETTES_HOUSE_LANETTE", "HIDE_LANETTES_HOUSE_LANETTE" },
+ [871] = { "FLAG_HIDE_FALLARBOR_POKEMON_CENTER_LANETTE", "HIDE_FALLARBOR_POKEMON_CENTER_LANETTE" },
+ [872] = { "FLAG_HIDE_TRICK_HOUSE_ENTRANCE_MAN", "HIDE_TRICK_HOUSE_ENTRANCE_MAN" },
+ [873] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER_REPLACEMENT", "HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER_REPLACEMENT" },
+ [874] = { "FLAG_HIDE_DESERT_UNDERPASS_FOSSIL", "HIDE_DESERT_UNDERPASS_FOSSIL" },
+ [875] = { "FLAG_HIDE_ROUTE_111_PLAYER_DESCENT", "HIDE_ROUTE_111_PLAYER_DESCENT" },
+ [876] = { "FLAG_HIDE_ROUTE_111_DESERT_FOSSIL", "HIDE_ROUTE_111_DESERT_FOSSIL" },
+ [877] = { "FLAG_HIDE_MT_CHIMNEY_TRAINERS", "HIDE_MT_CHIMNEY_TRAINERS" },
+ [878] = { "FLAG_HIDE_RUSTURF_TUNNEL_AQUA_GRUNT", "HIDE_RUSTURF_TUNNEL_AQUA_GRUNT" },
+ [879] = { "FLAG_HIDE_RUSTURF_TUNNEL_BRINEY", "HIDE_RUSTURF_TUNNEL_BRINEY" },
+ [880] = { "FLAG_HIDE_RUSTURF_TUNNEL_PEEKO", "HIDE_RUSTURF_TUNNEL_PEEKO" },
+ [881] = { "FLAG_HIDE_BRINEYS_HOUSE_PEEKO", "HIDE_BRINEYS_HOUSE_PEEKO" },
+ [882] = { "FLAG_HIDE_SLATEPORT_CITY_TEAM_AQUA", "HIDE_SLATEPORT_CITY_TEAM_AQUA" },
+ [883] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_AQUA_GRUNTS", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_AQUA_GRUNTS" },
+ [884] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_1", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_1" },
+ [885] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_2", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_2" },
+ [886] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_ARCHIE", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_ARCHIE" },
+ [887] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_CAPTAIN_STERN", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_CAPTAIN_STERN" },
+ [888] = { "FLAG_HIDE_BATTLE_TOWER_OPPONENT", "HIDE_BATTLE_TOWER_OPPONENT" },
+ [889] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_RIVAL", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_RIVAL" },
+ [890] = { "FLAG_HIDE_ROUTE_119_TEAM_AQUA", "HIDE_ROUTE_119_TEAM_AQUA" },
+ [891] = { "FLAG_HIDE_ROUTE_116_MR_BRINEY", "HIDE_ROUTE_116_MR_BRINEY" },
+ [892] = { "FLAG_HIDE_WEATHER_INSTITUTE_1F_WORKERS", "HIDE_WEATHER_INSTITUTE_1F_WORKERS" },
+ [893] = { "FLAG_HIDE_WEATHER_INSTITUTE_2F_WORKERS", "HIDE_WEATHER_INSTITUTE_2F_WORKERS" },
+ [894] = { "FLAG_HIDE_ROUTE_116_WANDAS_BOYFRIEND", "HIDE_ROUTE_116_WANDAS_BOYFRIEND" },
+ [895] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_2", "HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_2" },
+ [896] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_UNKNOWN_0x380", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_UNKNOWN_0x380" },
+ [897] = { "FLAG_HIDE_ROUTE_101_BIRCH", "HIDE_ROUTE_101_BIRCH" },
+ [898] = { "FLAG_HIDE_ROUTE_103_BIRCH", "HIDE_ROUTE_103_BIRCH" },
+ [899] = { "FLAG_HIDE_TRICK_HOUSE_END_MAN", "HIDE_TRICK_HOUSE_END_MAN" },
+ [900] = { "FLAG_HIDE_ROUTE_110_TEAM_AQUA", "HIDE_ROUTE_110_TEAM_AQUA" },
+ [901] = { "FLAG_HIDE_ROUTE_118_GABBY_AND_TY_2", "HIDE_ROUTE_118_GABBY_AND_TY_2" },
+ [902] = { "FLAG_HIDE_ROUTE_120_GABBY_AND_TY_2", "HIDE_ROUTE_120_GABBY_AND_TY_2" },
+ [903] = { "FLAG_HIDE_ROUTE_111_GABBY_AND_TY_3", "HIDE_ROUTE_111_GABBY_AND_TY_3" },
+ [904] = { "FLAG_HIDE_ROUTE_118_GABBY_AND_TY_3", "HIDE_ROUTE_118_GABBY_AND_TY_3" },
+ [905] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_PATRONS", "HIDE_SLATEPORT_CITY_HARBOR_PATRONS" },
+ [906] = { "FLAG_HIDE_ROUTE_104_WHITE_HERB_FLORIST", "HIDE_ROUTE_104_WHITE_HERB_FLORIST" },
+ [907] = { "FLAG_HIDE_FALLARBOR_AZURILL", "HIDE_FALLARBOR_AZURILL" },
+ [908] = { "FLAG_HIDE_LILYCOVE_HARBOR_FERRY_ATTENDANT", "HIDE_LILYCOVE_HARBOR_FERRY_ATTENDANT" },
+ [909] = { "FLAG_HIDE_LILYCOVE_HARBOR_FERRY_SAILOR", "HIDE_LILYCOVE_HARBOR_FERRY_SAILOR" },
+ [910] = { "FLAG_HIDE_SOUTHERN_ISLAND_EON_STONE", "HIDE_SOUTHERN_ISLAND_EON_STONE" },
+ [911] = { "FLAG_HIDE_SOUTHERN_ISLAND_UNCHOSEN_EON_DUO_MON", "HIDE_SOUTHERN_ISLAND_UNCHOSEN_EON_DUO_MON" },
+ [912] = { "FLAG_HIDE_MAUVILLE_CITY_WATTSON", "HIDE_MAUVILLE_CITY_WATTSON" },
+ [913] = { "FLAG_HIDE_MAUVILLE_GYM_WATTSON", "HIDE_MAUVILLE_GYM_WATTSON" },
+ [914] = { "FLAG_HIDE_ROUTE_121_TEAM_AQUA_GRUNTS", "HIDE_ROUTE_121_TEAM_AQUA_GRUNTS" },
+ [915] = { "FLAG_UNKNOWN_0x393", "UNKNOWN_0x393" },
+ [916] = { "FLAG_HIDE_MT_PYRE_SUMMIT_ARCHIE", "HIDE_MT_PYRE_SUMMIT_ARCHIE" },
+ [917] = { "FLAG_HIDE_MT_PYRE_SUMMIT_TEAM_AQUA", "HIDE_MT_PYRE_SUMMIT_TEAM_AQUA" },
+ [918] = { "FLAG_HIDE_BATTLE_TOWER_REPORTER", "HIDE_BATTLE_TOWER_REPORTER" },
+ [919] = { "FLAG_HIDE_ROUTE_110_RIVAL", "HIDE_ROUTE_110_RIVAL" },
+ [920] = { "FLAG_HIDE_CHAMPIONS_ROOM_RIVAL", "HIDE_CHAMPIONS_ROOM_RIVAL" },
+ [921] = { "FLAG_HIDE_CHAMPIONS_ROOM_BIRCH", "HIDE_CHAMPIONS_ROOM_BIRCH" },
+ [922] = { "FLAG_HIDE_ROUTE_110_RIVAL_ON_BIKE", "HIDE_ROUTE_110_RIVAL_ON_BIKE" },
+ [923] = { "FLAG_HIDE_ROUTE_119_RIVAL_ON_BIKE", "HIDE_ROUTE_119_RIVAL_ON_BIKE" },
+ [924] = { "FLAG_HIDE_AQUA_HIDEOUT_GRUNTS", "HIDE_AQUA_HIDEOUT_GRUNTS" },
+ [925] = { "FLAG_HIDE_LILYCOVE_MOTEL_GAME_DESIGNERS", "HIDE_LILYCOVE_MOTEL_GAME_DESIGNERS" },
+ [926] = { "FLAG_HIDE_MT_CHIMNEY_TEAM_AQUA", "HIDE_MT_CHIMNEY_TEAM_AQUA" },
+ [927] = { "FLAG_HIDE_MT_CHIMNEY_TEAM_MAGMA", "HIDE_MT_CHIMNEY_TEAM_MAGMA" },
+ [928] = { "FLAG_HIDE_FALLARBOR_HOUSE_PROF_COZMO", "HIDE_FALLARBOR_HOUSE_PROF_COZMO" },
+ [929] = { "FLAG_HIDE_LAVARIDGE_TOWN_RIVAL", "HIDE_LAVARIDGE_TOWN_RIVAL" },
+ [930] = { "FLAG_HIDE_LAVARIDGE_TOWN_RIVAL_ON_BIKE", "HIDE_LAVARIDGE_TOWN_RIVAL_ON_BIKE" },
+ [931] = { "FLAG_HIDE_RUSTURF_TUNNEL_ROCK_1", "HIDE_RUSTURF_TUNNEL_ROCK_1" },
+ [932] = { "FLAG_HIDE_RUSTURF_TUNNEL_ROCK_2", "HIDE_RUSTURF_TUNNEL_ROCK_2" },
+ [933] = { "FLAG_HIDE_FORTREE_CITY_HOUSE_4_WINGULL", "HIDE_FORTREE_CITY_HOUSE_4_WINGULL" },
+ [934] = { "FLAG_HIDE_MOSSDEEP_CITY_HOUSE_2_WINGULL", "HIDE_MOSSDEEP_CITY_HOUSE_2_WINGULL" },
+ [935] = { "FLAG_HIDE_REGIROCK", "HIDE_REGIROCK" },
+ [936] = { "FLAG_HIDE_REGICE", "HIDE_REGICE" },
+ [937] = { "FLAG_HIDE_REGISTEEL", "HIDE_REGISTEEL" },
+ [938] = { "FLAG_HIDE_METEOR_FALLS_TEAM_AQUA", "HIDE_METEOR_FALLS_TEAM_AQUA" },
+ [939] = { "FLAG_HIDE_METEOR_FALLS_TEAM_MAGMA", "HIDE_METEOR_FALLS_TEAM_MAGMA" },
+ [940] = { "FLAG_HIDE_DEWFORD_HALL_SLUDGE_BOMB_MAN", "HIDE_DEWFORD_HALL_SLUDGE_BOMB_MAN" },
+ [941] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ENTRANCE_AQUA_GRUNT", "HIDE_SEAFLOOR_CAVERN_ENTRANCE_AQUA_GRUNT" },
+ [942] = { "FLAG_HIDE_METEOR_FALLS_1F_1R_COZMO", "HIDE_METEOR_FALLS_1F_1R_COZMO" },
+ [943] = { "FLAG_HIDE_AQUA_HIDEOUT_B2F_SUBMARINE_SHADOW", "HIDE_AQUA_HIDEOUT_B2F_SUBMARINE_SHADOW" },
+ [944] = { "FLAG_HIDE_ROUTE_128_ARCHIE", "HIDE_ROUTE_128_ARCHIE" },
+ [945] = { "FLAG_HIDE_ROUTE_128_MAXIE", "HIDE_ROUTE_128_MAXIE" },
+ [946] = { "FLAG_HIDE_SEAFLOOR_CAVERN_AQUA_GRUNTS", "HIDE_SEAFLOOR_CAVERN_AQUA_GRUNTS" },
+ [947] = { "FLAG_HIDE_ROUTE_116_DEVON_EMPLOYEE", "HIDE_ROUTE_116_DEVON_EMPLOYEE" },
+ [948] = { "FLAG_HIDE_SLATEPORT_CITY_TM_SALESMAN", "HIDE_SLATEPORT_CITY_TM_SALESMAN" },
+ [949] = { "FLAG_HIDE_RUSTBORO_CITY_DEVON_CORP_3F_EMPLOYEE", "HIDE_RUSTBORO_CITY_DEVON_CORP_3F_EMPLOYEE" },
+ [950] = { "FLAG_HIDE_SS_TIDAL_CORRIDOR_MR_BRINEY", "HIDE_SS_TIDAL_CORRIDOR_MR_BRINEY" },
+ [951] = { "FLAG_HIDE_SS_TIDAL_ROOMS_SNATCH_GIVER", "HIDE_SS_TIDAL_ROOMS_SNATCH_GIVER" },
+ [952] = { "FLAG_RECEIVED_SHOAL_SALT_1", "RECEIVED_SHOAL_SALT_1" },
+ [953] = { "FLAG_RECEIVED_SHOAL_SALT_2", "RECEIVED_SHOAL_SALT_2" },
+ [954] = { "FLAG_RECEIVED_SHOAL_SALT_3", "RECEIVED_SHOAL_SALT_3" },
+ [955] = { "FLAG_RECEIVED_SHOAL_SALT_4", "RECEIVED_SHOAL_SALT_4" },
+ [956] = { "FLAG_RECEIVED_SHOAL_SHELL_1", "RECEIVED_SHOAL_SHELL_1" },
+ [957] = { "FLAG_RECEIVED_SHOAL_SHELL_2", "RECEIVED_SHOAL_SHELL_2" },
+ [958] = { "FLAG_RECEIVED_SHOAL_SHELL_3", "RECEIVED_SHOAL_SHELL_3" },
+ [959] = { "FLAG_RECEIVED_SHOAL_SHELL_4", "RECEIVED_SHOAL_SHELL_4" },
+ [960] = { "FLAG_HIDE_ROUTE_111_SECRET_POWER_MAN", "HIDE_ROUTE_111_SECRET_POWER_MAN" },
+ [961] = { "FLAG_HIDE_SLATEPORT_MUSEUM_POPULATION", "HIDE_SLATEPORT_MUSEUM_POPULATION" },
+ [962] = { "FLAG_HIDE_LILYCOVE_DEPARTMENT_STORE_ROOFTOP_SALE_WOMAN", "HIDE_LILYCOVE_DEPARTMENT_STORE_ROOFTOP_SALE_WOMAN" },
+ [963] = { "FLAG_HIDE_MIRAGE_TOWER_ROOT_FOSSIL", "HIDE_MIRAGE_TOWER_ROOT_FOSSIL" },
+ [964] = { "FLAG_HIDE_MIRAGE_TOWER_CLAW_FOSSIL", "HIDE_MIRAGE_TOWER_CLAW_FOSSIL" },
+ [965] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_FAMILIAR_AQUA_GRUNT", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_FAMILIAR_AQUA_GRUNT" },
+ [966] = { "FLAG_HIDE_ROUTE_118_STEVEN", "HIDE_ROUTE_118_STEVEN" },
+ [967] = { "FLAG_HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_STEVEN", "HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_STEVEN" },
+ [968] = { "FLAG_HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_BELDUM_POKEBALL", "HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_BELDUM_POKEBALL" },
+ [969] = { "FLAG_HIDE_FORTREE_CITY_KECLEON", "HIDE_FORTREE_CITY_KECLEON" },
+ [970] = { "FLAG_HIDE_ROUTE_120_KECLEON_BRIDGE", "HIDE_ROUTE_120_KECLEON_BRIDGE" },
+ [971] = { "FLAG_HIDE_LILYCOVE_CITY_RIVAL", "HIDE_LILYCOVE_CITY_RIVAL" },
+ [972] = { "FLAG_HIDE_ROUTE_120_STEVEN", "HIDE_ROUTE_120_STEVEN" },
+ [973] = { "FLAG_HIDE_SOOTOPOLIS_CITY_STEVEN", "HIDE_SOOTOPOLIS_CITY_STEVEN" },
+ [974] = { "FLAG_HIDE_NEW_MAUVILLE_VOLTORB_1", "HIDE_NEW_MAUVILLE_VOLTORB_1" },
+ [975] = { "FLAG_HIDE_NEW_MAUVILLE_VOLTORB_2", "HIDE_NEW_MAUVILLE_VOLTORB_2" },
+ [976] = { "FLAG_HIDE_NEW_MAUVILLE_VOLTORB_3", "HIDE_NEW_MAUVILLE_VOLTORB_3" },
+ [977] = { "FLAG_HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_1", "HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_1" },
+ [978] = { "FLAG_HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_2", "HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_2" },
+ [979] = { "FLAG_HIDE_OLDALE_TOWN_RIVAL", "HIDE_OLDALE_TOWN_RIVAL" },
+ [980] = { "FLAG_HIDE_UNDERWATER_SEA_FLOOR_CAVERN_STOLEN_SUBMARINE", "HIDE_UNDERWATER_SEA_FLOOR_CAVERN_STOLEN_SUBMARINE" },
+ [981] = { "FLAG_HIDE_ROUTE_120_KECLEON_BRIDGE_SHADOW", "HIDE_ROUTE_120_KECLEON_BRIDGE_SHADOW" },
+ [982] = { "FLAG_HIDE_ROUTE_120_KECLEON_1", "HIDE_ROUTE_120_KECLEON_1" },
+ [983] = { "FLAG_HIDE_RUSTURF_TUNNEL_WANDA", "HIDE_RUSTURF_TUNNEL_WANDA" },
+ [984] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDA", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDA" },
+ [985] = { "FLAG_HIDE_ROUTE_120_KECLEON_2", "HIDE_ROUTE_120_KECLEON_2" },
+ [986] = { "FLAG_HIDE_ROUTE_120_KECLEON_3", "HIDE_ROUTE_120_KECLEON_3" },
+ [987] = { "FLAG_HIDE_ROUTE_120_KECLEON_4", "HIDE_ROUTE_120_KECLEON_4" },
+ [988] = { "FLAG_HIDE_ROUTE_120_KECLEON_5", "HIDE_ROUTE_120_KECLEON_5" },
+ [989] = { "FLAG_HIDE_ROUTE_119_KECLEON_1", "HIDE_ROUTE_119_KECLEON_1" },
+ [990] = { "FLAG_HIDE_ROUTE_119_KECLEON_2", "HIDE_ROUTE_119_KECLEON_2" },
+ [991] = { "FLAG_HIDE_ROUTE_101_BOY", "HIDE_ROUTE_101_BOY" },
+ [992] = { "FLAG_HIDE_WEATHER_INSTITUTE_2F_AQUA_GRUNT_M", "HIDE_WEATHER_INSTITUTE_2F_AQUA_GRUNT_M" },
+ [993] = { "FLAG_HIDE_LILYCOVE_POKEMON_CENTER_CONTEST_LADY_MON", "HIDE_LILYCOVE_POKEMON_CENTER_CONTEST_LADY_MON" },
+ [994] = { "FLAG_HIDE_MT_CHIMNEY_LAVA_COOKIE_LADY", "HIDE_MT_CHIMNEY_LAVA_COOKIE_LADY" },
+ [995] = { "FLAG_HIDE_PETALBURG_CITY_SCOTT", "HIDE_PETALBURG_CITY_SCOTT" },
+ [996] = { "FLAG_HIDE_SOOTOPOLIS_CITY_RAYQUAZA", "HIDE_SOOTOPOLIS_CITY_RAYQUAZA" },
+ [997] = { "FLAG_HIDE_SOOTOPOLIS_CITY_KYOGRE", "HIDE_SOOTOPOLIS_CITY_KYOGRE" },
+ [998] = { "FLAG_HIDE_SOOTOPOLIS_CITY_GROUDON", "HIDE_SOOTOPOLIS_CITY_GROUDON" },
+ [999] = { "FLAG_HIDE_RUSTBORO_CITY_POKEMON_SCHOOL_SCOTT", "HIDE_RUSTBORO_CITY_POKEMON_SCHOOL_SCOTT" },
+ [1000] = { "FLAG_ITEM_ROUTE_102_POTION", "ITEM_ROUTE_102_POTION" },
+ [1001] = { "FLAG_ITEM_ROUTE_116_X_SPECIAL", "ITEM_ROUTE_116_X_SPECIAL" },
+ [1002] = { "FLAG_ITEM_ROUTE_104_PP_UP", "ITEM_ROUTE_104_PP_UP" },
+ [1003] = { "FLAG_ITEM_ROUTE_105_IRON", "ITEM_ROUTE_105_IRON" },
+ [1004] = { "FLAG_ITEM_ROUTE_106_PROTEIN", "ITEM_ROUTE_106_PROTEIN" },
+ [1005] = { "FLAG_ITEM_ROUTE_109_PP_UP", "ITEM_ROUTE_109_PP_UP" },
+ [1006] = { "FLAG_ITEM_ROUTE_110_RARE_CANDY", "ITEM_ROUTE_110_RARE_CANDY" },
+ [1007] = { "FLAG_ITEM_ROUTE_110_DIRE_HIT", "ITEM_ROUTE_110_DIRE_HIT" },
+ [1008] = { "FLAG_ITEM_ROUTE_111_TM_SANDSTORM", "ITEM_ROUTE_111_TM_SANDSTORM" },
+ [1009] = { "FLAG_ITEM_ROUTE_111_STARDUST", "ITEM_ROUTE_111_STARDUST" },
+ [1010] = { "FLAG_ITEM_ROUTE_111_HP_UP", "ITEM_ROUTE_111_HP_UP" },
+ [1011] = { "FLAG_ITEM_ROUTE_112_NUGGET", "ITEM_ROUTE_112_NUGGET" },
+ [1012] = { "FLAG_ITEM_ROUTE_113_MAX_ETHER", "ITEM_ROUTE_113_MAX_ETHER" },
+ [1013] = { "FLAG_ITEM_ROUTE_113_SUPER_REPEL", "ITEM_ROUTE_113_SUPER_REPEL" },
+ [1014] = { "FLAG_ITEM_ROUTE_114_RARE_CANDY", "ITEM_ROUTE_114_RARE_CANDY" },
+ [1015] = { "FLAG_ITEM_ROUTE_114_PROTEIN", "ITEM_ROUTE_114_PROTEIN" },
+ [1016] = { "FLAG_ITEM_ROUTE_115_SUPER_POTION", "ITEM_ROUTE_115_SUPER_POTION" },
+ [1017] = { "FLAG_ITEM_ROUTE_115_TM_FOCUS_PUNCH", "ITEM_ROUTE_115_TM_FOCUS_PUNCH" },
+ [1018] = { "FLAG_ITEM_ROUTE_115_IRON", "ITEM_ROUTE_115_IRON" },
+ [1019] = { "FLAG_ITEM_ROUTE_116_ETHER", "ITEM_ROUTE_116_ETHER" },
+ [1020] = { "FLAG_ITEM_ROUTE_116_REPEL", "ITEM_ROUTE_116_REPEL" },
+ [1021] = { "FLAG_ITEM_ROUTE_116_HP_UP", "ITEM_ROUTE_116_HP_UP" },
+ [1022] = { "FLAG_ITEM_ROUTE_117_GREAT_BALL", "ITEM_ROUTE_117_GREAT_BALL" },
+ [1023] = { "FLAG_ITEM_ROUTE_117_REVIVE", "ITEM_ROUTE_117_REVIVE" },
+ [1024] = { "FLAG_ITEM_ROUTE_119_SUPER_REPEL", "ITEM_ROUTE_119_SUPER_REPEL" },
+ [1025] = { "FLAG_ITEM_ROUTE_119_ZINC", "ITEM_ROUTE_119_ZINC" },
+ [1026] = { "FLAG_ITEM_ROUTE_119_ELIXIR_1", "ITEM_ROUTE_119_ELIXIR_1" },
+ [1027] = { "FLAG_ITEM_ROUTE_119_LEAF_STONE", "ITEM_ROUTE_119_LEAF_STONE" },
+ [1028] = { "FLAG_ITEM_ROUTE_119_RARE_CANDY", "ITEM_ROUTE_119_RARE_CANDY" },
+ [1029] = { "FLAG_ITEM_ROUTE_119_HYPER_POTION_1", "ITEM_ROUTE_119_HYPER_POTION_1" },
+ [1030] = { "FLAG_ITEM_ROUTE_120_NUGGET", "ITEM_ROUTE_120_NUGGET" },
+ [1031] = { "FLAG_ITEM_ROUTE_120_FULL_HEAL", "ITEM_ROUTE_120_FULL_HEAL" },
+ [1032] = { "FLAG_ITEM_ROUTE_123_CALCIUM", "ITEM_ROUTE_123_CALCIUM" },
+ [1033] = { "FLAG_ITEM_ROUTE_123_RARE_CANDY", "ITEM_ROUTE_123_RARE_CANDY" },
+ [1034] = { "FLAG_ITEM_ROUTE_127_ZINC", "ITEM_ROUTE_127_ZINC" },
+ [1035] = { "FLAG_ITEM_ROUTE_127_CARBOS", "ITEM_ROUTE_127_CARBOS" },
+ [1036] = { "FLAG_ITEM_ROUTE_132_RARE_CANDY", "ITEM_ROUTE_132_RARE_CANDY" },
+ [1037] = { "FLAG_ITEM_ROUTE_133_BIG_PEARL", "ITEM_ROUTE_133_BIG_PEARL" },
+ [1038] = { "FLAG_ITEM_ROUTE_133_STAR_PIECE", "ITEM_ROUTE_133_STAR_PIECE" },
+ [1039] = { "FLAG_ITEM_PETALBURG_CITY_MAX_REVIVE", "ITEM_PETALBURG_CITY_MAX_REVIVE" },
+ [1040] = { "FLAG_ITEM_PETALBURG_CITY_ETHER", "ITEM_PETALBURG_CITY_ETHER" },
+ [1041] = { "FLAG_ITEM_RUSTBORO_CITY_X_DEFEND", "ITEM_RUSTBORO_CITY_X_DEFEND" },
+ [1042] = { "FLAG_ITEM_LILYCOVE_CITY_MAX_REPEL", "ITEM_LILYCOVE_CITY_MAX_REPEL" },
+ [1043] = { "FLAG_ITEM_MOSSDEEP_CITY_NET_BALL", "ITEM_MOSSDEEP_CITY_NET_BALL" },
+ [1044] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_TM_IRON_TAIL", "ITEM_METEOR_FALLS_1F_1R_TM_IRON_TAIL" },
+ [1045] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_FULL_HEAL", "ITEM_METEOR_FALLS_1F_1R_FULL_HEAL" },
+ [1046] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_MOON_STONE", "ITEM_METEOR_FALLS_1F_1R_MOON_STONE" },
+ [1047] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_PP_UP", "ITEM_METEOR_FALLS_1F_1R_PP_UP" },
+ [1048] = { "FLAG_ITEM_RUSTURF_TUNNEL_POKE_BALL", "ITEM_RUSTURF_TUNNEL_POKE_BALL" },
+ [1049] = { "FLAG_ITEM_RUSTURF_TUNNEL_MAX_ETHER", "ITEM_RUSTURF_TUNNEL_MAX_ETHER" },
+ [1050] = { "FLAG_ITEM_GRANITE_CAVE_1F_ESCAPE_ROPE", "ITEM_GRANITE_CAVE_1F_ESCAPE_ROPE" },
+ [1051] = { "FLAG_ITEM_GRANITE_CAVE_B1F_POKE_BALL", "ITEM_GRANITE_CAVE_B1F_POKE_BALL" },
+ [1052] = { "FLAG_ITEM_MT_PYRE_5F_LAX_INCENSE", "ITEM_MT_PYRE_5F_LAX_INCENSE" },
+ [1053] = { "FLAG_ITEM_GRANITE_CAVE_B2F_REPEL", "ITEM_GRANITE_CAVE_B2F_REPEL" },
+ [1054] = { "FLAG_ITEM_GRANITE_CAVE_B2F_RARE_CANDY", "ITEM_GRANITE_CAVE_B2F_RARE_CANDY" },
+ [1055] = { "FLAG_ITEM_PETALBURG_WOODS_X_ATTACK", "ITEM_PETALBURG_WOODS_X_ATTACK" },
+ [1056] = { "FLAG_ITEM_PETALBURG_WOODS_GREAT_BALL", "ITEM_PETALBURG_WOODS_GREAT_BALL" },
+ [1057] = { "FLAG_ITEM_ROUTE_104_POKE_BALL", "ITEM_ROUTE_104_POKE_BALL" },
+ [1058] = { "FLAG_ITEM_PETALBURG_WOODS_ETHER", "ITEM_PETALBURG_WOODS_ETHER" },
+ [1059] = { "FLAG_ITEM_MAGMA_HIDEOUT_3F_3R_ECAPE_ROPE", "ITEM_MAGMA_HIDEOUT_3F_3R_ECAPE_ROPE" },
+ [1060] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_1_ORANGE_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_1_ORANGE_MAIL" },
+ [1061] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_2_HARBOR_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_2_HARBOR_MAIL" },
+ [1062] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_2_WAVE_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_2_WAVE_MAIL" },
+ [1063] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_3_SHADOW_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_3_SHADOW_MAIL" },
+ [1064] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_3_WOOD_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_3_WOOD_MAIL" },
+ [1065] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_4_MECH_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_4_MECH_MAIL" },
+ [1066] = { "FLAG_ITEM_ROUTE_124_YELLOW_SHARD", "ITEM_ROUTE_124_YELLOW_SHARD" },
+ [1067] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_6_GLITTER_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_6_GLITTER_MAIL" },
+ [1068] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_7_TROPIC_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_7_TROPIC_MAIL" },
+ [1069] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_8_BEAD_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_8_BEAD_MAIL" },
+ [1070] = { "FLAG_ITEM_JAGGED_PASS_BURN_HEAL", "ITEM_JAGGED_PASS_BURN_HEAL" },
+ [1071] = { "FLAG_ITEM_AQUA_HIDEOUT_B1F_MAX_ELIXIR", "ITEM_AQUA_HIDEOUT_B1F_MAX_ELIXIR" },
+ [1072] = { "FLAG_ITEM_AQUA_HIDEOUT_B2F_NEST_BALL", "ITEM_AQUA_HIDEOUT_B2F_NEST_BALL" },
+ [1073] = { "FLAG_ITEM_MT_PYRE_EXTERIOR_MAX_POTION", "ITEM_MT_PYRE_EXTERIOR_MAX_POTION" },
+ [1074] = { "FLAG_ITEM_MT_PYRE_EXTERIOR_TM_SKILL_SWAP", "ITEM_MT_PYRE_EXTERIOR_TM_SKILL_SWAP" },
+ [1075] = { "FLAG_ITEM_NEW_MAUVILLE_ULTRA_BALL", "ITEM_NEW_MAUVILLE_ULTRA_BALL" },
+ [1076] = { "FLAG_ITEM_NEW_MAUVILLE_ESCAPE_ROPE", "ITEM_NEW_MAUVILLE_ESCAPE_ROPE" },
+ [1077] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_6_LUXURY_BALL", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_6_LUXURY_BALL" },
+ [1078] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_2_SCANNER", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_2_SCANNER" },
+ [1079] = { "FLAG_ITEM_SCORCHED_SLAB_TM_SUNNY_DAY", "ITEM_SCORCHED_SLAB_TM_SUNNY_DAY" },
+ [1080] = { "FLAG_ITEM_METEOR_FALLS_B1F_2R_TM_DRAGON_CLAW", "ITEM_METEOR_FALLS_B1F_2R_TM_DRAGON_CLAW" },
+ [1081] = { "FLAG_ITEM_SHOAL_CAVE_ENTRANCE_BIG_PEARL", "ITEM_SHOAL_CAVE_ENTRANCE_BIG_PEARL" },
+ [1082] = { "FLAG_ITEM_SHOAL_CAVE_INNER_ROOM_RARE_CANDY", "ITEM_SHOAL_CAVE_INNER_ROOM_RARE_CANDY" },
+ [1083] = { "FLAG_ITEM_SHOAL_CAVE_STAIRS_ROOM_ICE_HEAL", "ITEM_SHOAL_CAVE_STAIRS_ROOM_ICE_HEAL" },
+ [1084] = { "FLAG_ITEM_VICTORY_ROAD_1F_MAX_ELIXIR", "ITEM_VICTORY_ROAD_1F_MAX_ELIXIR" },
+ [1085] = { "FLAG_ITEM_VICTORY_ROAD_1F_PP_UP", "ITEM_VICTORY_ROAD_1F_PP_UP" },
+ [1086] = { "FLAG_ITEM_VICTORY_ROAD_B1F_TM_PSYCHIC", "ITEM_VICTORY_ROAD_B1F_TM_PSYCHIC" },
+ [1087] = { "FLAG_ITEM_VICTORY_ROAD_B1F_FULL_RESTORE", "ITEM_VICTORY_ROAD_B1F_FULL_RESTORE" },
+ [1088] = { "FLAG_ITEM_VICTORY_ROAD_B2F_FULL_HEAL", "ITEM_VICTORY_ROAD_B2F_FULL_HEAL" },
+ [1089] = { "FLAG_ITEM_MT_PYRE_6F_TM_SHADOW_BALL", "ITEM_MT_PYRE_6F_TM_SHADOW_BALL" },
+ [1090] = { "FLAG_ITEM_SEAFLOOR_CAVERN_ROOM_9_TM_EARTHQUAKE", "ITEM_SEAFLOOR_CAVERN_ROOM_9_TM_EARTHQUAKE" },
+ [1091] = { "FLAG_ITEM_FIERY_PATH_TM_TOXIC", "ITEM_FIERY_PATH_TM_TOXIC" },
+ [1092] = { "FLAG_ITEM_ROUTE_124_RED_SHARD", "ITEM_ROUTE_124_RED_SHARD" },
+ [1093] = { "FLAG_ITEM_ROUTE_124_BLUE_SHARD", "ITEM_ROUTE_124_BLUE_SHARD" },
+ [1094] = { "FLAG_ITEM_SAFARI_ZONE_NORTH_WEST_TM_SOLAR_BEAM", "ITEM_SAFARI_ZONE_NORTH_WEST_TM_SOLAR_BEAM" },
+ [1095] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_1F_HARBOR_MAIL", "ITEM_ABANDONED_SHIP_ROOMS_1F_HARBOR_MAIL" },
+ [1096] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_B1F_ESCAPE_ROPE", "ITEM_ABANDONED_SHIP_ROOMS_B1F_ESCAPE_ROPE" },
+ [1097] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_2_B1F_DIVE_BALL", "ITEM_ABANDONED_SHIP_ROOMS_2_B1F_DIVE_BALL" },
+ [1098] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_B1F_TM_ICE_BEAM", "ITEM_ABANDONED_SHIP_ROOMS_B1F_TM_ICE_BEAM" },
+ [1099] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_2_1F_REVIVE", "ITEM_ABANDONED_SHIP_ROOMS_2_1F_REVIVE" },
+ [1100] = { "FLAG_ITEM_ABANDONED_SHIP_CAPTAINS_OFFICE_STORAGE_KEY", "ITEM_ABANDONED_SHIP_CAPTAINS_OFFICE_STORAGE_KEY" },
+ [1101] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_3_WATER_STONE", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_3_WATER_STONE" },
+ [1102] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_1_TM_RAIN_DANCE", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_1_TM_RAIN_DANCE" },
+ [1103] = { "FLAG_ITEM_ROUTE_121_CARBOS", "ITEM_ROUTE_121_CARBOS" },
+ [1104] = { "FLAG_ITEM_ROUTE_123_ULTRA_BALL", "ITEM_ROUTE_123_ULTRA_BALL" },
+ [1105] = { "FLAG_ITEM_ROUTE_126_GREEN_SHARD", "ITEM_ROUTE_126_GREEN_SHARD" },
+ [1106] = { "FLAG_ITEM_ROUTE_119_HYPER_POTION_2", "ITEM_ROUTE_119_HYPER_POTION_2" },
+ [1107] = { "FLAG_ITEM_ROUTE_120_HYPER_POTION", "ITEM_ROUTE_120_HYPER_POTION" },
+ [1108] = { "FLAG_ITEM_ROUTE_120_NEST_BALL", "ITEM_ROUTE_120_NEST_BALL" },
+ [1109] = { "FLAG_ITEM_ROUTE_123_ELIXIR", "ITEM_ROUTE_123_ELIXIR" },
+ [1110] = { "FLAG_ITEM_NEW_MAUVILLE_THUNDER_STONE", "ITEM_NEW_MAUVILLE_THUNDER_STONE" },
+ [1111] = { "FLAG_ITEM_FIERY_PATH_FIRE_STONE", "ITEM_FIERY_PATH_FIRE_STONE" },
+ [1112] = { "FLAG_ITEM_SHOAL_CAVE_ICE_ROOM_TM_HAIL", "ITEM_SHOAL_CAVE_ICE_ROOM_TM_HAIL" },
+ [1113] = { "FLAG_ITEM_SHOAL_CAVE_ICE_ROOM_NEVER_MELT_ICE", "ITEM_SHOAL_CAVE_ICE_ROOM_NEVER_MELT_ICE" },
+ [1114] = { "FLAG_ITEM_ROUTE_103_GUARD_SPEC", "ITEM_ROUTE_103_GUARD_SPEC" },
+ [1115] = { "FLAG_ITEM_ROUTE_104_X_ACCURACY", "ITEM_ROUTE_104_X_ACCURACY" },
+ [1116] = { "FLAG_ITEM_MAUVILLE_CITY_X_SPEED", "ITEM_MAUVILLE_CITY_X_SPEED" },
+ [1117] = { "FLAG_ITEM_PETALBURG_WOODS_PARALYZE_HEAL", "ITEM_PETALBURG_WOODS_PARALYZE_HEAL" },
+ [1118] = { "FLAG_ITEM_ROUTE_115_GREAT_BALL", "ITEM_ROUTE_115_GREAT_BALL" },
+ [1119] = { "FLAG_ITEM_SAFARI_ZONE_NORTH_CALCIUM", "ITEM_SAFARI_ZONE_NORTH_CALCIUM" },
+ [1120] = { "FLAG_ITEM_MT_PYRE_3F_SUPER_REPEL", "ITEM_MT_PYRE_3F_SUPER_REPEL" },
+ [1121] = { "FLAG_ITEM_ROUTE_118_HYPER_POTION", "ITEM_ROUTE_118_HYPER_POTION" },
+ [1122] = { "FLAG_ITEM_NEW_MAUVILLE_FULL_HEAL", "ITEM_NEW_MAUVILLE_FULL_HEAL" },
+ [1123] = { "FLAG_ITEM_NEW_MAUVILLE_PARALYZE_HEAL", "ITEM_NEW_MAUVILLE_PARALYZE_HEAL" },
+ [1124] = { "FLAG_ITEM_AQUA_HIDEOUT_B1F_MASTER_BALL", "ITEM_AQUA_HIDEOUT_B1F_MASTER_BALL" },
+ [1125] = { "FLAG_ITEM_OLD_MAGMA_HIDEOUT_B1F_MASTER_BALL", "ITEM_OLD_MAGMA_HIDEOUT_B1F_MASTER_BALL" },
+ [1126] = { "FLAG_ITEM_OLD_MAGMA_HIDEOUT_B1F_MAX_ELIXIR", "ITEM_OLD_MAGMA_HIDEOUT_B1F_MAX_ELIXIR" },
+ [1127] = { "FLAG_ITEM_OLD_MAGMA_HIDEOUT_B2F_NEST_BALL", "ITEM_OLD_MAGMA_HIDEOUT_B2F_NEST_BALL" },
+ [1128] = { "FLAG_UNUSED_0x468", "UNUSED_0x468" },
+ [1129] = { "FLAG_ITEM_MT_PYRE_2F_ULTRA_BALL", "ITEM_MT_PYRE_2F_ULTRA_BALL" },
+ [1130] = { "FLAG_ITEM_MT_PYRE_4F_SEA_INCENSE", "ITEM_MT_PYRE_4F_SEA_INCENSE" },
+ [1131] = { "FLAG_ITEM_SAFARI_ZONE_SOUTH_WEST_MAX_REVIVE", "ITEM_SAFARI_ZONE_SOUTH_WEST_MAX_REVIVE" },
+ [1132] = { "FLAG_ITEM_AQUA_HIDEOUT_B1F_NUGGET", "ITEM_AQUA_HIDEOUT_B1F_NUGGET" },
+ [1133] = { "FLAG_ITEM_MOSSDEEP_STEVENS_HOUSE_HM08", "ITEM_MOSSDEEP_STEVENS_HOUSE_HM08" },
+ [1134] = { "FLAG_ITEM_ROUTE_119_NUGGET", "ITEM_ROUTE_119_NUGGET" },
+ [1135] = { "FLAG_ITEM_ROUTE_104_POTION", "ITEM_ROUTE_104_POTION" },
+ [1136] = { "FLAG_UNUSED_0x470", "UNUSED_0x470" },
+ [1137] = { "FLAG_ITEM_ROUTE_103_PP_UP", "ITEM_ROUTE_103_PP_UP" },
+ [1138] = { "FLAG_UNUSED_0x472", "UNUSED_0x472" },
+ [1139] = { "FLAG_ITEM_ROUTE_108_STAR_PIECE", "ITEM_ROUTE_108_STAR_PIECE" },
+ [1140] = { "FLAG_ITEM_ROUTE_109_POTION", "ITEM_ROUTE_109_POTION" },
+ [1141] = { "FLAG_ITEM_ROUTE_110_ELIXIR", "ITEM_ROUTE_110_ELIXIR" },
+ [1142] = { "FLAG_ITEM_ROUTE_111_ELIXIR", "ITEM_ROUTE_111_ELIXIR" },
+ [1143] = { "FLAG_ITEM_ROUTE_113_HYPER_POTION", "ITEM_ROUTE_113_HYPER_POTION" },
+ [1144] = { "FLAG_ITEM_ROUTE_115_HEAL_POWDER", "ITEM_ROUTE_115_HEAL_POWDER" },
+ [1145] = { "FLAG_UNUSED_0x479", "UNUSED_0x479" },
+ [1146] = { "FLAG_ITEM_ROUTE_116_POTION", "ITEM_ROUTE_116_POTION" },
+ [1147] = { "FLAG_ITEM_ROUTE_119_ELIXIR_2", "ITEM_ROUTE_119_ELIXIR_2" },
+ [1148] = { "FLAG_ITEM_ROUTE_120_REVIVE", "ITEM_ROUTE_120_REVIVE" },
+ [1149] = { "FLAG_ITEM_ROUTE_121_REVIVE", "ITEM_ROUTE_121_REVIVE" },
+ [1150] = { "FLAG_ITEM_ROUTE_121_ZINC", "ITEM_ROUTE_121_ZINC" },
+ [1151] = { "FLAG_ITEM_MAGMA_HIDEOUT_1F_RARE_CANDY", "ITEM_MAGMA_HIDEOUT_1F_RARE_CANDY" },
+ [1152] = { "FLAG_ITEM_ROUTE_123_PP_UP", "ITEM_ROUTE_123_PP_UP" },
+ [1153] = { "FLAG_ITEM_ROUTE_123_REVIVAL_HERB", "ITEM_ROUTE_123_REVIVAL_HERB" },
+ [1154] = { "FLAG_ITEM_ROUTE_125_BIG_PEARL", "ITEM_ROUTE_125_BIG_PEARL" },
+ [1155] = { "FLAG_ITEM_ROUTE_127_RARE_CANDY", "ITEM_ROUTE_127_RARE_CANDY" },
+ [1156] = { "FLAG_ITEM_ROUTE_132_PROTEIN", "ITEM_ROUTE_132_PROTEIN" },
+ [1157] = { "FLAG_ITEM_ROUTE_133_MAX_REVIVE", "ITEM_ROUTE_133_MAX_REVIVE" },
+ [1158] = { "FLAG_ITEM_ROUTE_134_CARBOS", "ITEM_ROUTE_134_CARBOS" },
+ [1159] = { "FLAG_ITEM_ROUTE_134_STAR_PIECE", "ITEM_ROUTE_134_STAR_PIECE" },
+ [1160] = { "FLAG_ITEM_ROUTE_114_ENERGY_POWDER", "ITEM_ROUTE_114_ENERGY_POWDER" },
+ [1161] = { "FLAG_ITEM_ROUTE_115_PP_UP", "ITEM_ROUTE_115_PP_UP" },
+ [1162] = { "FLAG_ITEM_ARTISAN_CAVE_B1F_HP_UP", "ITEM_ARTISAN_CAVE_B1F_HP_UP" },
+ [1163] = { "FLAG_ITEM_ARTISAN_CAVE_1F_CARBOS", "ITEM_ARTISAN_CAVE_1F_CARBOS" },
+ [1164] = { "FLAG_ITEM_MAGMA_HIDEOUT_2F_2R_MAX_ELIXIR", "ITEM_MAGMA_HIDEOUT_2F_2R_MAX_ELIXIR" },
+ [1165] = { "FLAG_ITEM_MAGMA_HIDEOUT_2F_2R_FULL_RESTORE", "ITEM_MAGMA_HIDEOUT_2F_2R_FULL_RESTORE" },
+ [1166] = { "FLAG_ITEM_MAGMA_HIDEOUT_3F_1R_NUGGET", "ITEM_MAGMA_HIDEOUT_3F_1R_NUGGET" },
+ [1167] = { "FLAG_ITEM_MAGMA_HIDEOUT_3F_2R_PP_MAX", "ITEM_MAGMA_HIDEOUT_3F_2R_PP_MAX" },
+ [1168] = { "FLAG_ITEM_MAGMA_HIDEOUT_4F_MAX_REVIVE", "ITEM_MAGMA_HIDEOUT_4F_MAX_REVIVE" },
+ [1169] = { "FLAG_ITEM_SAFARI_ZONE_NORTH_EAST_NUGGET", "ITEM_SAFARI_ZONE_NORTH_EAST_NUGGET" },
+ [1170] = { "FLAG_ITEM_SAFARI_ZONE_SOUTH_EAST_BIG_PEARL", "ITEM_SAFARI_ZONE_SOUTH_EAST_BIG_PEARL" },
+ [1171] = { "FLAG_UNUSED_0x493", "UNUSED_0x493" },
+ [1172] = { "FLAG_UNUSED_0x494", "UNUSED_0x494" },
+ [1173] = { "FLAG_UNUSED_0x495", "UNUSED_0x495" },
+ [1174] = { "FLAG_UNUSED_0x496", "UNUSED_0x496" },
+ [1175] = { "FLAG_UNUSED_0x497", "UNUSED_0x497" },
+ [1176] = { "FLAG_UNUSED_0x498", "UNUSED_0x498" },
+ [1177] = { "FLAG_UNUSED_0x499", "UNUSED_0x499" },
+ [1178] = { "FLAG_UNUSED_0x49A", "UNUSED_0x49A" },
+ [1179] = { "FLAG_UNUSED_0x49B", "UNUSED_0x49B" },
+ [1180] = { "FLAG_UNUSED_0x49C", "UNUSED_0x49C" },
+ [1181] = { "FLAG_UNUSED_0x49D", "UNUSED_0x49D" },
+ [1182] = { "FLAG_UNUSED_0x49E", "UNUSED_0x49E" },
+ [1183] = { "FLAG_UNUSED_0x49F", "UNUSED_0x49F" },
+ [1184] = { "FLAG_UNUSED_0x4A0", "UNUSED_0x4A0" },
+ [1185] = { "FLAG_UNUSED_0x4A1", "UNUSED_0x4A1" },
+ [1186] = { "FLAG_UNUSED_0x4A2", "UNUSED_0x4A2" },
+ [1187] = { "FLAG_UNUSED_0x4A3", "UNUSED_0x4A3" },
+ [1188] = { "FLAG_UNUSED_0x4A4", "UNUSED_0x4A4" },
+ [1189] = { "FLAG_UNUSED_0x4A5", "UNUSED_0x4A5" },
+ [1190] = { "FLAG_UNUSED_0x4A6", "UNUSED_0x4A6" },
+ [1191] = { "FLAG_UNUSED_0x4A7", "UNUSED_0x4A7" },
+ [1192] = { "FLAG_UNUSED_0x4A8", "UNUSED_0x4A8" },
+ [1193] = { "FLAG_UNUSED_0x4A9", "UNUSED_0x4A9" },
+ [1194] = { "FLAG_UNUSED_0x4AA", "UNUSED_0x4AA" },
+ [1195] = { "FLAG_UNUSED_0x4AB", "UNUSED_0x4AB" },
+ [1196] = { "FLAG_UNUSED_0x4AC", "UNUSED_0x4AC" },
+ [1197] = { "FLAG_UNUSED_0x4AD", "UNUSED_0x4AD" },
+ [1198] = { "FLAG_UNUSED_0x4AE", "UNUSED_0x4AE" },
+ [1199] = { "FLAG_UNUSED_0x4AF", "UNUSED_0x4AF" },
+ [1200] = { "FLAG_UNUSED_0x4B0", "UNUSED_0x4B0" },
+ [1201] = { "FLAG_UNUSED_0x4B1", "UNUSED_0x4B1" },
+ [1202] = { "FLAG_UNUSED_0x4B2", "UNUSED_0x4B2" },
+ [1203] = { "FLAG_UNUSED_0x4B3", "UNUSED_0x4B3" },
+ [1204] = { "FLAG_UNUSED_0x4B4", "UNUSED_0x4B4" },
+ [1205] = { "FLAG_UNUSED_0x4B5", "UNUSED_0x4B5" },
+ [1206] = { "FLAG_UNUSED_0x4B6", "UNUSED_0x4B6" },
+ [1207] = { "FLAG_UNUSED_0x4B7", "UNUSED_0x4B7" },
+ [1208] = { "FLAG_UNUSED_0x4B8", "UNUSED_0x4B8" },
+ [1209] = { "FLAG_UNUSED_0x4B9", "UNUSED_0x4B9" },
+ [1210] = { "FLAG_UNUSED_0x4BA", "UNUSED_0x4BA" },
+ [1211] = { "FLAG_UNUSED_0x4BB", "UNUSED_0x4BB" },
+ [1212] = { "FLAG_UNUSED_0x4BC", "UNUSED_0x4BC" },
+ [1213] = { "FLAG_UNUSED_0x4BD", "UNUSED_0x4BD" },
+ [1214] = { "FLAG_UNUSED_0x4BE", "UNUSED_0x4BE" },
+ [1215] = { "FLAG_UNUSED_0x4BF", "UNUSED_0x4BF" },
+ [1216] = { "FLAG_UNUSED_0x4C0", "UNUSED_0x4C0" },
+ [1217] = { "FLAG_UNUSED_0x4C1", "UNUSED_0x4C1" },
+ [1218] = { "FLAG_UNUSED_0x4C2", "UNUSED_0x4C2" },
+ [1219] = { "FLAG_UNUSED_0x4C3", "UNUSED_0x4C3" },
+ [1220] = { "FLAG_UNUSED_0x4C4", "UNUSED_0x4C4" },
+ [1221] = { "FLAG_UNUSED_0x4C5", "UNUSED_0x4C5" },
+ [1222] = { "FLAG_UNUSED_0x4C6", "UNUSED_0x4C6" },
+ [1223] = { "FLAG_UNUSED_0x4C7", "UNUSED_0x4C7" },
+ [1224] = { "FLAG_UNUSED_0x4C8", "UNUSED_0x4C8" },
+ [1225] = { "FLAG_UNUSED_0x4C9", "UNUSED_0x4C9" },
+ [1226] = { "FLAG_UNUSED_0x4CA", "UNUSED_0x4CA" },
+ [1227] = { "FLAG_UNUSED_0x4CB", "UNUSED_0x4CB" },
+ [1228] = { "FLAG_UNUSED_0x4CC", "UNUSED_0x4CC" },
+ [1229] = { "FLAG_UNUSED_0x4CD", "UNUSED_0x4CD" },
+ [1230] = { "FLAG_UNUSED_0x4CE", "UNUSED_0x4CE" },
+ [1231] = { "FLAG_UNUSED_0x4CF", "UNUSED_0x4CF" },
+ [1232] = { "FLAG_UNUSED_0x4D0", "UNUSED_0x4D0" },
+ [1233] = { "FLAG_UNUSED_0x4D1", "UNUSED_0x4D1" },
+ [1234] = { "FLAG_UNUSED_0x4D2", "UNUSED_0x4D2" },
+ [1235] = { "FLAG_UNUSED_0x4D3", "UNUSED_0x4D3" },
+ [1236] = { "FLAG_UNUSED_0x4D4", "UNUSED_0x4D4" },
+ [1237] = { "FLAG_UNUSED_0x4D5", "UNUSED_0x4D5" },
+ [1238] = { "FLAG_UNUSED_0x4D6", "UNUSED_0x4D6" },
+ [1239] = { "FLAG_UNUSED_0x4D7", "UNUSED_0x4D7" },
+ [1240] = { "FLAG_UNUSED_0x4D8", "UNUSED_0x4D8" },
+ [1241] = { "FLAG_UNUSED_0x4D9", "UNUSED_0x4D9" },
+ [1242] = { "FLAG_UNUSED_0x4DA", "UNUSED_0x4DA" },
+ [1243] = { "FLAG_UNUSED_0x4DB", "UNUSED_0x4DB" },
+ [1244] = { "FLAG_UNUSED_0x4DC", "UNUSED_0x4DC" },
+ [1245] = { "FLAG_UNUSED_0x4DD", "UNUSED_0x4DD" },
+ [1246] = { "FLAG_UNUSED_0x4DE", "UNUSED_0x4DE" },
+ [1247] = { "FLAG_UNUSED_0x4DF", "UNUSED_0x4DF" },
+ [1248] = { "FLAG_UNUSED_0x4E0", "UNUSED_0x4E0" },
+ [1249] = { "FLAG_UNUSED_0x4E1", "UNUSED_0x4E1" },
+ [1250] = { "FLAG_UNUSED_0x4E2", "UNUSED_0x4E2" },
+ [1251] = { "FLAG_UNUSED_0x4E3", "UNUSED_0x4E3" },
+ [1252] = { "FLAG_UNUSED_0x4E4", "UNUSED_0x4E4" },
+ [1253] = { "FLAG_UNUSED_0x4E5", "UNUSED_0x4E5" },
+ [1254] = { "FLAG_UNUSED_0x4E6", "UNUSED_0x4E6" },
+ [1255] = { "FLAG_UNUSED_0x4E7", "UNUSED_0x4E7" },
+ [1256] = { "FLAG_UNUSED_0x4E8", "UNUSED_0x4E8" },
+ [1257] = { "FLAG_UNUSED_0x4E9", "UNUSED_0x4E9" },
+ [1258] = { "FLAG_UNUSED_0x4EA", "UNUSED_0x4EA" },
+ [1259] = { "FLAG_UNUSED_0x4EB", "UNUSED_0x4EB" },
+ [1260] = { "FLAG_UNUSED_0x4EC", "UNUSED_0x4EC" },
+ [1261] = { "FLAG_UNUSED_0x4ED", "UNUSED_0x4ED" },
+ [1262] = { "FLAG_UNUSED_0x4EE", "UNUSED_0x4EE" },
+ [1263] = { "FLAG_UNUSED_0x4EF", "UNUSED_0x4EF" },
+ [1264] = { "FLAG_DEFEATED_RUSTBORO_GYM", "DEFEATED_RUSTBORO_GYM" },
+ [1265] = { "FLAG_DEFEATED_DEWFORD_GYM", "DEFEATED_DEWFORD_GYM" },
+ [1266] = { "FLAG_DEFEATED_MAUVILLE_GYM", "DEFEATED_MAUVILLE_GYM" },
+ [1267] = { "FLAG_DEFEATED_LAVARIDGE_GYM", "DEFEATED_LAVARIDGE_GYM" },
+ [1268] = { "FLAG_DEFEATED_PETALBURG_GYM", "DEFEATED_PETALBURG_GYM" },
+ [1269] = { "FLAG_DEFEATED_FORTREE_GYM", "DEFEATED_FORTREE_GYM" },
+ [1270] = { "FLAG_DEFEATED_MOSSDEEP_GYM", "DEFEATED_MOSSDEEP_GYM" },
+ [1271] = { "FLAG_DEFEATED_SOOTOPOLIS_GYM", "DEFEATED_SOOTOPOLIS_GYM" },
+ [1272] = { "FLAG_DEFEATED_METEOR_FALLS_STEVEN", "DEFEATED_METEOR_FALLS_STEVEN" },
+ [1273] = { "FLAG_UNUSED_0x4F9", "UNUSED_0x4F9" },
+ [1274] = { "FLAG_UNUSED_0x4FA", "UNUSED_0x4FA" },
+ [1275] = { "FLAG_DEFEATED_ELITE_4_SIDNEY", "DEFEATED_ELITE_4_SIDNEY" },
+ [1276] = { "FLAG_DEFEATED_ELITE_4_PHOEBE", "DEFEATED_ELITE_4_PHOEBE" },
+ [1277] = { "FLAG_DEFEATED_ELITE_4_GLACIA", "DEFEATED_ELITE_4_GLACIA" },
+ [1278] = { "FLAG_DEFEATED_ELITE_4_DRAKE", "DEFEATED_ELITE_4_DRAKE" },
+ [1279] = { "FLAG_UNUSED_0x4FF", "UNUSED_0x4FF" },
+ [1280] = { false, "TRAINER_FLAGS_START" },
+ [2143] = { false, "TRAINER_FLAGS_END" },
+ [2144] = { "FLAG_SYS_POKEMON_GET", "SYSTEM_FLAGS", "SYS_POKEMON_GET" },
+ [2145] = { "FLAG_SYS_POKEDEX_GET", "SYS_POKEDEX_GET" },
+ [2146] = { "FLAG_SYS_POKENAV_GET", "SYS_POKENAV_GET" },
+ [2147] = { "FLAG_UNUSED_0x863", "UNUSED_0x863" },
+ [2148] = { "FLAG_SYS_GAME_CLEAR", "SYS_GAME_CLEAR" },
+ [2149] = { "FLAG_SYS_CHAT_USED", "SYS_CHAT_USED" },
+ [2150] = { "FLAG_UNLOCKED_TRENDY_SAYINGS", "UNLOCKED_TRENDY_SAYINGS" },
+ [2151] = { "FLAG_BADGE01_GET", "BADGE01_GET" },
+ [2152] = { "FLAG_BADGE02_GET", "BADGE02_GET" },
+ [2153] = { "FLAG_BADGE03_GET", "BADGE03_GET" },
+ [2154] = { "FLAG_BADGE04_GET", "BADGE04_GET" },
+ [2155] = { "FLAG_BADGE05_GET", "BADGE05_GET" },
+ [2156] = { "FLAG_BADGE06_GET", "BADGE06_GET" },
+ [2157] = { "FLAG_BADGE07_GET", "BADGE07_GET" },
+ [2158] = { "FLAG_BADGE08_GET", "BADGE08_GET" },
+ [2159] = { "FLAG_VISITED_LITTLEROOT_TOWN", "VISITED_LITTLEROOT_TOWN" },
+ [2160] = { "FLAG_VISITED_OLDALE_TOWN", "VISITED_OLDALE_TOWN" },
+ [2161] = { "FLAG_VISITED_DEWFORD_TOWN", "VISITED_DEWFORD_TOWN" },
+ [2162] = { "FLAG_VISITED_LAVARIDGE_TOWN", "VISITED_LAVARIDGE_TOWN" },
+ [2163] = { "FLAG_VISITED_FALLARBOR_TOWN", "VISITED_FALLARBOR_TOWN" },
+ [2164] = { "FLAG_VISITED_VERDANTURF_TOWN", "VISITED_VERDANTURF_TOWN" },
+ [2165] = { "FLAG_VISITED_PACIFIDLOG_TOWN", "VISITED_PACIFIDLOG_TOWN" },
+ [2166] = { "FLAG_VISITED_PETALBURG_CITY", "VISITED_PETALBURG_CITY" },
+ [2167] = { "FLAG_VISITED_SLATEPORT_CITY", "VISITED_SLATEPORT_CITY" },
+ [2168] = { "FLAG_VISITED_MAUVILLE_CITY", "VISITED_MAUVILLE_CITY" },
+ [2169] = { "FLAG_VISITED_RUSTBORO_CITY", "VISITED_RUSTBORO_CITY" },
+ [2170] = { "FLAG_VISITED_FORTREE_CITY", "VISITED_FORTREE_CITY" },
+ [2171] = { "FLAG_VISITED_LILYCOVE_CITY", "VISITED_LILYCOVE_CITY" },
+ [2172] = { "FLAG_VISITED_MOSSDEEP_CITY", "VISITED_MOSSDEEP_CITY" },
+ [2173] = { "FLAG_VISITED_SOOTOPOLIS_CITY", "VISITED_SOOTOPOLIS_CITY" },
+ [2174] = { "FLAG_VISITED_EVER_GRANDE_CITY", "VISITED_EVER_GRANDE_CITY" },
+ [2175] = { "FLAG_IS_CHAMPION", "IS_CHAMPION" },
+ [2176] = { "FLAG_NURSE_UNION_ROOM_REMINDER", "NURSE_UNION_ROOM_REMINDER" },
+ [2177] = { "FLAG_UNUSED_0x881", "UNUSED_0x881" },
+ [2178] = { "FLAG_UNUSED_0x882", "UNUSED_0x882" },
+ [2179] = { "FLAG_UNUSED_0x883", "UNUSED_0x883" },
+ [2180] = { "FLAG_UNUSED_0x884", "UNUSED_0x884" },
+ [2181] = { "FLAG_UNUSED_0x885", "UNUSED_0x885" },
+ [2182] = { "FLAG_UNUSED_0x886", "UNUSED_0x886" },
+ [2183] = { "FLAG_UNUSED_0x887", "UNUSED_0x887" },
+ [2184] = { "FLAG_SYS_USE_FLASH", "SYS_USE_FLASH" },
+ [2185] = { "FLAG_SYS_USE_STRENGTH", "SYS_USE_STRENGTH" },
+ [2186] = { "FLAG_SYS_WEATHER_CTRL", "SYS_WEATHER_CTRL" },
+ [2187] = { "FLAG_SYS_CYCLING_ROAD", "SYS_CYCLING_ROAD" },
+ [2188] = { "FLAG_SYS_SAFARI_MODE", "SYS_SAFARI_MODE" },
+ [2189] = { "FLAG_SYS_CRUISE_MODE", "SYS_CRUISE_MODE" },
+ [2190] = { "FLAG_UNUSED_0x88E", "UNUSED_0x88E" },
+ [2191] = { "FLAG_UNUSED_0x88F", "UNUSED_0x88F" },
+ [2192] = { "FLAG_SYS_TV_HOME", "SYS_TV_HOME" },
+ [2193] = { "FLAG_SYS_TV_WATCH", "SYS_TV_WATCH" },
+ [2194] = { false, "FLAG_SYS_TV_START", "SYS_TV_START" },
+ [2195] = { "FLAG_SYS_CHANGED_DEWFORD_TREND", "SYS_CHANGED_DEWFORD_TREND" },
+ [2196] = { "FLAG_SYS_MIX_RECORD", "SYS_MIX_RECORD" },
+ [2197] = { "FLAG_SYS_CLOCK_SET", "SYS_CLOCK_SET" },
+ [2198] = { "FLAG_SYS_NATIONAL_DEX", "SYS_NATIONAL_DEX" },
+ [2199] = { "FLAG_SYS_CAVE_SHIP", "SYS_CAVE_SHIP" },
+ [2200] = { "FLAG_SYS_CAVE_WONDER", "SYS_CAVE_WONDER" },
+ [2201] = { "FLAG_SYS_CAVE_BATTLE", "SYS_CAVE_BATTLE" },
+ [2202] = { "FLAG_SYS_SHOAL_TIDE", "SYS_SHOAL_TIDE" },
+ [2203] = { "FLAG_SYS_RIBBON_GET", "SYS_RIBBON_GET" },
+ [2204] = { "FLAG_LANDMARK_FLOWER_SHOP", "LANDMARK_FLOWER_SHOP" },
+ [2205] = { "FLAG_LANDMARK_MR_BRINEY_HOUSE", "LANDMARK_MR_BRINEY_HOUSE" },
+ [2206] = { "FLAG_LANDMARK_ABANDONED_SHIP", "LANDMARK_ABANDONED_SHIP" },
+ [2207] = { "FLAG_LANDMARK_SEASHORE_HOUSE", "LANDMARK_SEASHORE_HOUSE" },
+ [2208] = { "FLAG_LANDMARK_NEW_MAUVILLE", "LANDMARK_NEW_MAUVILLE" },
+ [2209] = { "FLAG_LANDMARK_OLD_LADY_REST_SHOP", "LANDMARK_OLD_LADY_REST_SHOP" },
+ [2210] = { "FLAG_LANDMARK_TRICK_HOUSE", "LANDMARK_TRICK_HOUSE" },
+ [2211] = { "FLAG_LANDMARK_WINSTRATE_FAMILY", "LANDMARK_WINSTRATE_FAMILY" },
+ [2212] = { "FLAG_LANDMARK_GLASS_WORKSHOP", "LANDMARK_GLASS_WORKSHOP" },
+ [2213] = { "FLAG_LANDMARK_LANETTES_HOUSE", "LANDMARK_LANETTES_HOUSE" },
+ [2214] = { "FLAG_LANDMARK_POKEMON_DAYCARE", "LANDMARK_POKEMON_DAYCARE" },
+ [2215] = { "FLAG_LANDMARK_SEAFLOOR_CAVERN", "LANDMARK_SEAFLOOR_CAVERN" },
+ [2216] = { "FLAG_LANDMARK_BATTLE_FRONTIER", "LANDMARK_BATTLE_FRONTIER" },
+ [2217] = { "FLAG_LANDMARK_SOUTHERN_ISLAND", "LANDMARK_SOUTHERN_ISLAND" },
+ [2218] = { "FLAG_LANDMARK_FIERY_PATH", "LANDMARK_FIERY_PATH" },
+ [2219] = { "FLAG_SYS_PC_LANETTE", "SYS_PC_LANETTE" },
+ [2220] = { "FLAG_SYS_MYSTERY_EVENT_ENABLE", "SYS_MYSTERY_EVENT_ENABLE" },
+ [2221] = { "FLAG_SYS_ENC_UP_ITEM", "SYS_ENC_UP_ITEM" },
+ [2222] = { "FLAG_SYS_ENC_DOWN_ITEM", "SYS_ENC_DOWN_ITEM" },
+ [2223] = { "FLAG_SYS_BRAILLE_DIG", "SYS_BRAILLE_DIG" },
+ [2224] = { "FLAG_SYS_REGIROCK_PUZZLE_COMPLETED", "SYS_REGIROCK_PUZZLE_COMPLETED" },
+ [2225] = { "FLAG_SYS_BRAILLE_REGICE_COMPLETED", "SYS_BRAILLE_REGICE_COMPLETED" },
+ [2226] = { "FLAG_SYS_REGISTEEL_PUZZLE_COMPLETED", "SYS_REGISTEEL_PUZZLE_COMPLETED" },
+ [2227] = { "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", "ENABLE_SHIP_SOUTHERN_ISLAND" },
+ [2228] = { "FLAG_LANDMARK_POKEMON_LEAGUE", "LANDMARK_POKEMON_LEAGUE" },
+ [2229] = { "FLAG_LANDMARK_ISLAND_CAVE", "LANDMARK_ISLAND_CAVE" },
+ [2230] = { "FLAG_LANDMARK_DESERT_RUINS", "LANDMARK_DESERT_RUINS" },
+ [2231] = { "FLAG_LANDMARK_FOSSIL_MANIACS_HOUSE", "LANDMARK_FOSSIL_MANIACS_HOUSE" },
+ [2232] = { "FLAG_LANDMARK_SCORCHED_SLAB", "LANDMARK_SCORCHED_SLAB" },
+ [2233] = { "FLAG_LANDMARK_ANCIENT_TOMB", "LANDMARK_ANCIENT_TOMB" },
+ [2234] = { "FLAG_LANDMARK_TUNNELERS_REST_HOUSE", "LANDMARK_TUNNELERS_REST_HOUSE" },
+ [2235] = { "FLAG_LANDMARK_HUNTERS_HOUSE", "LANDMARK_HUNTERS_HOUSE" },
+ [2236] = { "FLAG_LANDMARK_SEALED_CHAMBER", "LANDMARK_SEALED_CHAMBER" },
+ [2237] = { "FLAG_SYS_TV_LATIAS_LATIOS", "SYS_TV_LATIAS_LATIOS" },
+ [2238] = { "FLAG_LANDMARK_SKY_PILLAR", "LANDMARK_SKY_PILLAR" },
+ [2239] = { "FLAG_SYS_SHOAL_ITEM", "SYS_SHOAL_ITEM" },
+ [2240] = { "FLAG_SYS_B_DASH", "SYS_B_DASH" },
+ [2241] = { "FLAG_SYS_CTRL_OBJ_DELETE", "SYS_CTRL_OBJ_DELETE" },
+ [2242] = { "FLAG_SYS_RESET_RTC_ENABLE", "SYS_RESET_RTC_ENABLE" },
+ [2243] = { "FLAG_LANDMARK_BERRY_MASTERS_HOUSE", "LANDMARK_BERRY_MASTERS_HOUSE" },
+ [2244] = { "FLAG_SYS_TOWER_SILVER", "SYS_TOWER_SILVER" },
+ [2245] = { "FLAG_SYS_TOWER_GOLD", "SYS_TOWER_GOLD" },
+ [2246] = { "FLAG_SYS_DOME_SILVER", "SYS_DOME_SILVER" },
+ [2247] = { "FLAG_SYS_DOME_GOLD", "SYS_DOME_GOLD" },
+ [2248] = { "FLAG_SYS_PALACE_SILVER", "SYS_PALACE_SILVER" },
+ [2249] = { "FLAG_SYS_PALACE_GOLD", "SYS_PALACE_GOLD" },
+ [2250] = { "FLAG_SYS_ARENA_SILVER", "SYS_ARENA_SILVER" },
+ [2251] = { "FLAG_SYS_ARENA_GOLD", "SYS_ARENA_GOLD" },
+ [2252] = { "FLAG_SYS_FACTORY_SILVER", "SYS_FACTORY_SILVER" },
+ [2253] = { "FLAG_SYS_FACTORY_GOLD", "SYS_FACTORY_GOLD" },
+ [2254] = { "FLAG_SYS_PIKE_SILVER", "SYS_PIKE_SILVER" },
+ [2255] = { "FLAG_SYS_PIKE_GOLD", "SYS_PIKE_GOLD" },
+ [2256] = { "FLAG_SYS_PYRAMID_SILVER", "SYS_PYRAMID_SILVER" },
+ [2257] = { "FLAG_SYS_PYRAMID_GOLD", "SYS_PYRAMID_GOLD" },
+ [2258] = { "FLAG_SYS_FRONTIER_PASS", "SYS_FRONTIER_PASS" },
+ [2259] = { "FLAG_MAP_SCRIPT_CHECKED_DEOXYS", "MAP_SCRIPT_CHECKED_DEOXYS" },
+ [2260] = { "FLAG_DEOXYS_ROCK_COMPLETE", "DEOXYS_ROCK_COMPLETE" },
+ [2261] = { "FLAG_ENABLE_SHIP_BIRTH_ISLAND", "ENABLE_SHIP_BIRTH_ISLAND" },
+ [2262] = { "FLAG_ENABLE_SHIP_FARAWAY_ISLAND", "ENABLE_SHIP_FARAWAY_ISLAND" },
+ [2263] = { "FLAG_SHOWN_BOX_WAS_FULL_MESSAGE", "SHOWN_BOX_WAS_FULL_MESSAGE" },
+ [2264] = { "FLAG_ARRIVED_ON_FARAWAY_ISLAND", "ARRIVED_ON_FARAWAY_ISLAND" },
+ [2265] = { "FLAG_ARRIVED_AT_MARINE_CAVE_EMERGE_SPOT", "ARRIVED_AT_MARINE_CAVE_EMERGE_SPOT" },
+ [2266] = { "FLAG_ARRIVED_AT_TERRA_CAVE_ENTRANCE", "ARRIVED_AT_TERRA_CAVE_ENTRANCE" },
+ [2267] = { "FLAG_SYS_MYSTERY_GIFT_ENABLE", "SYS_MYSTERY_GIFT_ENABLE" },
+ [2268] = { "FLAG_ENTERED_MIRAGE_TOWER", "ENTERED_MIRAGE_TOWER" },
+ [2269] = { "FLAG_LANDMARK_ALTERING_CAVE", "LANDMARK_ALTERING_CAVE" },
+ [2270] = { "FLAG_LANDMARK_DESERT_UNDERPASS", "LANDMARK_DESERT_UNDERPASS" },
+ [2271] = { "FLAG_LANDMARK_ARTISAN_CAVE", "LANDMARK_ARTISAN_CAVE" },
+ [2272] = { "FLAG_ENABLE_SHIP_NAVEL_ROCK", "ENABLE_SHIP_NAVEL_ROCK" },
+ [2273] = { "FLAG_ARRIVED_AT_NAVEL_ROCK", "ARRIVED_AT_NAVEL_ROCK" },
+ [2274] = { "FLAG_LANDMARK_TRAINER_HILL", "LANDMARK_TRAINER_HILL" },
+ [2275] = { "FLAG_UNUSED_0x8E3", "UNUSED_0x8E3" },
+ [2276] = { "FLAG_RECEIVED_POKEDEX_FROM_BIRCH", "RECEIVED_POKEDEX_FROM_BIRCH" },
+ [2277] = { "FLAG_UNUSED_0x8E5", "UNUSED_0x8E5" },
+ [2278] = { "FLAG_UNUSED_0x8E6", "UNUSED_0x8E6" },
+ [2279] = { "FLAG_UNUSED_0x8E7", "UNUSED_0x8E7" },
+ [2280] = { "FLAG_UNUSED_0x8E8", "UNUSED_0x8E8" },
+ [2281] = { "FLAG_UNUSED_0x8E9", "UNUSED_0x8E9" },
+ [2282] = { "FLAG_UNUSED_0x8EA", "UNUSED_0x8EA" },
+ [2283] = { "FLAG_UNUSED_0x8EB", "UNUSED_0x8EB" },
+ [2284] = { "FLAG_UNUSED_0x8EC", "UNUSED_0x8EC" },
+ [2285] = { "FLAG_UNUSED_0x8ED", "UNUSED_0x8ED" },
+ [2286] = { "FLAG_UNUSED_0x8EE", "UNUSED_0x8EE" },
+ [2287] = { "FLAG_UNUSED_0x8EF", "UNUSED_0x8EF" },
+ [2288] = { "FLAG_UNUSED_0x8F0", "UNUSED_0x8F0" },
+ [2289] = { "FLAG_UNUSED_0x8F1", "UNUSED_0x8F1" },
+ [2290] = { "FLAG_UNUSED_0x8F2", "UNUSED_0x8F2" },
+ [2291] = { "FLAG_UNUSED_0x8F3", "UNUSED_0x8F3" },
+ [2292] = { "FLAG_UNUSED_0x8F4", "UNUSED_0x8F4" },
+ [2293] = { "FLAG_UNUSED_0x8F5", "UNUSED_0x8F5" },
+ [2294] = { "FLAG_UNUSED_0x8F6", "UNUSED_0x8F6" },
+ [2295] = { "FLAG_UNUSED_0x8F7", "UNUSED_0x8F7" },
+ [2296] = { "FLAG_UNUSED_0x8F8", "UNUSED_0x8F8" },
+ [2297] = { "FLAG_UNUSED_0x8F9", "UNUSED_0x8F9" },
+ [2298] = { "FLAG_UNUSED_0x8FA", "UNUSED_0x8FA" },
+ [2299] = { "FLAG_UNUSED_0x8FB", "UNUSED_0x8FB" },
+ [2300] = { "FLAG_UNUSED_0x8FC", "UNUSED_0x8FC" },
+ [2301] = { "FLAG_UNUSED_0x8FD", "UNUSED_0x8FD" },
+ [2302] = { "FLAG_UNUSED_0x8FE", "UNUSED_0x8FE" },
+ [2303] = { "FLAG_UNUSED_0x8FF", "UNUSED_0x8FF" },
+ [2304] = { "FLAG_UNUSED_0x900", "UNUSED_0x900" },
+ [2305] = { "FLAG_UNUSED_0x901", "UNUSED_0x901" },
+ [2306] = { "FLAG_UNUSED_0x902", "UNUSED_0x902" },
+ [2307] = { "FLAG_UNUSED_0x903", "UNUSED_0x903" },
+ [2308] = { "FLAG_UNUSED_0x904", "UNUSED_0x904" },
+ [2309] = { "FLAG_UNUSED_0x905", "UNUSED_0x905" },
+ [2310] = { "FLAG_UNUSED_0x906", "UNUSED_0x906" },
+ [2311] = { "FLAG_UNUSED_0x907", "UNUSED_0x907" },
+ [2312] = { "FLAG_UNUSED_0x908", "UNUSED_0x908" },
+ [2313] = { "FLAG_UNUSED_0x909", "UNUSED_0x909" },
+ [2314] = { "FLAG_UNUSED_0x90A", "UNUSED_0x90A" },
+ [2315] = { "FLAG_UNUSED_0x90B", "UNUSED_0x90B" },
+ [2316] = { "FLAG_UNUSED_0x90C", "UNUSED_0x90C" },
+ [2317] = { "FLAG_UNUSED_0x90D", "UNUSED_0x90D" },
+ [2318] = { "FLAG_UNUSED_0x90E", "UNUSED_0x90E" },
+ [2319] = { "FLAG_UNUSED_0x90F", "UNUSED_0x90F" },
+ [2320] = { "FLAG_UNUSED_0x910", "UNUSED_0x910" },
+ [2321] = { "FLAG_UNUSED_0x911", "UNUSED_0x911" },
+ [2322] = { "FLAG_UNUSED_0x912", "UNUSED_0x912" },
+ [2323] = { "FLAG_UNUSED_0x913", "UNUSED_0x913" },
+ [2324] = { "FLAG_UNUSED_0x914", "UNUSED_0x914" },
+ [2325] = { "FLAG_UNUSED_0x915", "UNUSED_0x915" },
+ [2326] = { "FLAG_UNUSED_0x916", "UNUSED_0x916" },
+ [2327] = { "FLAG_UNUSED_0x917", "UNUSED_0x917" },
+ [2328] = { "FLAG_UNUSED_0x918", "UNUSED_0x918" },
+ [2329] = { "FLAG_UNUSED_0x919", "UNUSED_0x919" },
+ [2330] = { "FLAG_UNUSED_0x91A", "UNUSED_0x91A" },
+ [2331] = { "FLAG_UNUSED_0x91B", "UNUSED_0x91B" },
+ [2332] = { "FLAG_UNUSED_0x91C", "UNUSED_0x91C" },
+ [2333] = { "FLAG_UNUSED_0x91D", "UNUSED_0x91D" },
+ [2334] = { "FLAG_UNUSED_0x91E", "UNUSED_0x91E" },
+ [2335] = { "FLAG_UNUSED_0x91F", "UNUSED_0x91F" },
+ [2336] = { "FLAG_UNUSED_0x920", "DAILY_FLAGS_START", "UNUSED_0x920" },
+ [2337] = { "FLAG_DAILY_CONTEST_LOBBY_RECEIVED_BERRY", "DAILY_CONTEST_LOBBY_RECEIVED_BERRY" },
+ [2338] = { "FLAG_DAILY_SECRET_BASE", "DAILY_SECRET_BASE" },
+ [2339] = { "FLAG_UNUSED_0x923", "UNUSED_0x923" },
+ [2340] = { "FLAG_UNUSED_0x924", "UNUSED_0x924" },
+ [2341] = { "FLAG_UNUSED_0x925", "UNUSED_0x925" },
+ [2342] = { "FLAG_UNUSED_0x926", "UNUSED_0x926" },
+ [2343] = { "FLAG_UNUSED_0x927", "UNUSED_0x927" },
+ [2344] = { "FLAG_UNUSED_0x928", "UNUSED_0x928" },
+ [2345] = { "FLAG_UNUSED_0x929", "UNUSED_0x929" },
+ [2346] = { "FLAG_DAILY_PICKED_LOTO_TICKET", "DAILY_PICKED_LOTO_TICKET" },
+ [2347] = { "FLAG_DAILY_ROUTE_114_RECEIVED_BERRY", "DAILY_ROUTE_114_RECEIVED_BERRY" },
+ [2348] = { "FLAG_DAILY_ROUTE_111_RECEIVED_BERRY", "DAILY_ROUTE_111_RECEIVED_BERRY" },
+ [2349] = { "FLAG_DAILY_BERRY_MASTER_RECEIVED_BERRY", "DAILY_BERRY_MASTER_RECEIVED_BERRY" },
+ [2350] = { "FLAG_DAILY_ROUTE_120_RECEIVED_BERRY", "DAILY_ROUTE_120_RECEIVED_BERRY" },
+ [2351] = { "FLAG_DAILY_LILYCOVE_RECEIVED_BERRY", "DAILY_LILYCOVE_RECEIVED_BERRY" },
+ [2352] = { "FLAG_DAILY_FLOWER_SHOP_RECEIVED_BERRY", "DAILY_FLOWER_SHOP_RECEIVED_BERRY" },
+ [2353] = { "FLAG_DAILY_BERRY_MASTERS_WIFE", "DAILY_BERRY_MASTERS_WIFE" },
+ [2354] = { "FLAG_DAILY_SOOTOPOLIS_RECEIVED_BERRY", "DAILY_SOOTOPOLIS_RECEIVED_BERRY" },
+ [2355] = { "FLAG_UNUSED_0x933", "UNUSED_0x933" },
+ [2356] = { "FLAG_DAILY_APPRENTICE_LEAVES", "DAILY_APPRENTICE_LEAVES" },
+ [2357] = { "FLAG_UNUSED_0x935", "UNUSED_0x935" },
+ [2358] = { "FLAG_UNUSED_0x936", "UNUSED_0x936" },
+ [2359] = { "FLAG_UNUSED_0x937", "UNUSED_0x937" },
+ [2360] = { "FLAG_UNUSED_0x938", "UNUSED_0x938" },
+ [2361] = { "FLAG_UNUSED_0x939", "UNUSED_0x939" },
+ [2362] = { "FLAG_UNUSED_0x93A", "UNUSED_0x93A" },
+ [2363] = { "FLAG_UNUSED_0x93B", "UNUSED_0x93B" },
+ [2364] = { "FLAG_UNUSED_0x93C", "UNUSED_0x93C" },
+ [2365] = { "FLAG_UNUSED_0x93D", "UNUSED_0x93D" },
+ [2366] = { "FLAG_UNUSED_0x93E", "UNUSED_0x93E" },
+ [2367] = { "FLAG_UNUSED_0x93F", "UNUSED_0x93F" },
+ [2368] = { "FLAG_UNUSED_0x940", "UNUSED_0x940" },
+ [2369] = { "FLAG_UNUSED_0x941", "UNUSED_0x941" },
+ [2370] = { "FLAG_UNUSED_0x942", "UNUSED_0x942" },
+ [2371] = { "FLAG_UNUSED_0x943", "UNUSED_0x943" },
+ [2372] = { "FLAG_UNUSED_0x944", "UNUSED_0x944" },
+ [2373] = { "FLAG_UNUSED_0x945", "UNUSED_0x945" },
+ [2374] = { "FLAG_UNUSED_0x946", "UNUSED_0x946" },
+ [2375] = { "FLAG_UNUSED_0x947", "UNUSED_0x947" },
+ [2376] = { "FLAG_UNUSED_0x948", "UNUSED_0x948" },
+ [2377] = { "FLAG_UNUSED_0x949", "UNUSED_0x949" },
+ [2378] = { "FLAG_UNUSED_0x94A", "UNUSED_0x94A" },
+ [2379] = { "FLAG_UNUSED_0x94B", "UNUSED_0x94B" },
+ [2380] = { "FLAG_UNUSED_0x94C", "UNUSED_0x94C" },
+ [2381] = { "FLAG_UNUSED_0x94D", "UNUSED_0x94D" },
+ [2382] = { "FLAG_UNUSED_0x94E", "UNUSED_0x94E" },
+ [2383] = { "FLAG_UNUSED_0x94F", "UNUSED_0x94F" },
+ [2384] = { "FLAG_UNUSED_0x950", "UNUSED_0x950" },
+ [2385] = { "FLAG_UNUSED_0x951", "UNUSED_0x951" },
+ [2386] = { "FLAG_UNUSED_0x952", "UNUSED_0x952" },
+ [2387] = { "FLAG_UNUSED_0x953", "UNUSED_0x953" },
+ [2388] = { "FLAG_UNUSED_0x954", "UNUSED_0x954" },
+ [2389] = { "FLAG_UNUSED_0x955", "UNUSED_0x955" },
+ [2390] = { "FLAG_UNUSED_0x956", "UNUSED_0x956" },
+ [2391] = { "FLAG_UNUSED_0x957", "UNUSED_0x957" },
+ [2392] = { "FLAG_UNUSED_0x958", "UNUSED_0x958" },
+ [2393] = { "FLAG_UNUSED_0x959", "UNUSED_0x959" },
+ [2394] = { "FLAG_UNUSED_0x95A", "UNUSED_0x95A" },
+ [2395] = { "FLAG_UNUSED_0x95B", "UNUSED_0x95B" },
+ [2396] = { "FLAG_UNUSED_0x95C", "UNUSED_0x95C" },
+ [2397] = { "FLAG_UNUSED_0x95D", "UNUSED_0x95D" },
+ [2398] = { "FLAG_UNUSED_0x95E", "UNUSED_0x95E" },
+ [2399] = { "FLAG_UNUSED_0x95F", "DAILY_FLAGS_END", "UNUSED_0x95F" },
+ },
+}
diff --git a/src/mods/flag_names/firered.lua b/src/mods/flag_names/firered.lua
new file mode 100644
index 000000000..4123b7412
--- /dev/null
+++ b/src/mods/flag_names/firered.lua
@@ -0,0 +1,1477 @@
+-- Generated flag identifiers only; no engine implementation.
+-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36
+-- Regenerate with tools/flag-catalog-check/generate.py.
+return {
+ limit = 2304,
+ records = {
+ [1] = { "FLAG_TEMP_1", "TEMP_1" },
+ [2] = { "FLAG_TEMP_2", "TEMP_2" },
+ [3] = { "FLAG_TEMP_3", "TEMP_3" },
+ [4] = { "FLAG_TEMP_4", "TEMP_4" },
+ [5] = { "FLAG_TEMP_5", "TEMP_5" },
+ [6] = { "FLAG_TEMP_6", "TEMP_6" },
+ [7] = { "FLAG_TEMP_7", "TEMP_7" },
+ [8] = { "FLAG_TEMP_8", "NUM_BADGES", "TEMP_8" },
+ [9] = { "FLAG_TEMP_9", "TEMP_9" },
+ [10] = { "FLAG_TEMP_A", "TEMP_A" },
+ [11] = { "FLAG_TEMP_B", "TEMP_B" },
+ [12] = { "FLAG_TEMP_C", "TEMP_C" },
+ [13] = { "FLAG_TEMP_D", "TEMP_D" },
+ [14] = { "FLAG_TEMP_E", "TEMP_E" },
+ [15] = { "FLAG_TEMP_F", "TEMP_F" },
+ [16] = { "FLAG_TEMP_10", "TEMP_10" },
+ [17] = { "FLAG_TEMP_11", "TEMP_11" },
+ [18] = { "FLAG_TEMP_12", "TEMP_12" },
+ [19] = { "FLAG_TEMP_13", "TEMP_13" },
+ [20] = { "FLAG_TEMP_14", "NUM_WONDER_CARD_FLAGS", "TEMP_14" },
+ [21] = { "FLAG_TEMP_15", "TEMP_15" },
+ [22] = { "FLAG_TEMP_16", "TEMP_16" },
+ [23] = { "FLAG_TEMP_17", "TEMP_17" },
+ [24] = { "FLAG_TEMP_18", "TEMP_18" },
+ [25] = { "FLAG_TEMP_19", "TEMP_19" },
+ [26] = { "FLAG_TEMP_1A", "TEMP_1A" },
+ [27] = { "FLAG_TEMP_1B", "TEMP_1B" },
+ [28] = { "FLAG_TEMP_1C", "TEMP_1C" },
+ [29] = { "FLAG_TEMP_1D", "TEMP_1D" },
+ [30] = { "FLAG_TEMP_1E", "TEMP_1E" },
+ [31] = { "FLAG_TEMP_1F", "TEMP_1F", "TEMP_FLAGS_END" },
+ [32] = { "FLAG_0x020", "0x020" },
+ [33] = { "FLAG_0x021", "0x021" },
+ [34] = { "FLAG_0x022", "0x022" },
+ [35] = { "FLAG_0x023", "0x023" },
+ [36] = { "FLAG_0x024", "0x024" },
+ [37] = { "FLAG_0x025", "0x025" },
+ [38] = { "FLAG_0x026", "0x026" },
+ [39] = { "FLAG_0x027", "0x027" },
+ [40] = { "FLAG_HIDE_BULBASAUR_BALL", "HIDE_BULBASAUR_BALL" },
+ [41] = { "FLAG_HIDE_SQUIRTLE_BALL", "HIDE_SQUIRTLE_BALL" },
+ [42] = { "FLAG_HIDE_CHARMANDER_BALL", "HIDE_CHARMANDER_BALL" },
+ [43] = { "FLAG_HIDE_OAK_IN_HIS_LAB", "HIDE_OAK_IN_HIS_LAB" },
+ [44] = { "FLAG_HIDE_OAK_IN_PALLET_TOWN", "HIDE_OAK_IN_PALLET_TOWN" },
+ [45] = { "FLAG_HIDE_RIVAL_IN_LAB", "HIDE_RIVAL_IN_LAB" },
+ [46] = { "FLAG_HIDE_PEWTER_CITY_GYM_GUIDE", "HIDE_PEWTER_CITY_GYM_GUIDE" },
+ [47] = { "FLAG_HIDE_DOME_FOSSIL", "HIDE_DOME_FOSSIL" },
+ [48] = { "FLAG_HIDE_HELIX_FOSSIL", "HIDE_HELIX_FOSSIL" },
+ [49] = { "FLAG_HIDE_NUGGET_BRIDGE_ROCKET", "HIDE_NUGGET_BRIDGE_ROCKET" },
+ [50] = { "FLAG_HIDE_BILL_CLEFAIRY", "HIDE_BILL_CLEFAIRY" },
+ [51] = { "FLAG_HIDE_BILL_HUMAN_SEA_COTTAGE", "HIDE_BILL_HUMAN_SEA_COTTAGE" },
+ [52] = { "FLAG_HIDE_TOWER_FUJI", "HIDE_TOWER_FUJI" },
+ [53] = { "FLAG_HIDE_POKEHOUSE_FUJI", "HIDE_POKEHOUSE_FUJI" },
+ [54] = { "FLAG_HIDE_LIFT_KEY", "HIDE_LIFT_KEY" },
+ [55] = { "FLAG_HIDE_SILPH_SCOPE", "HIDE_SILPH_SCOPE" },
+ [56] = { "FLAG_HIDE_HIDEOUT_GIOVANNI", "HIDE_HIDEOUT_GIOVANNI" },
+ [57] = { "FLAG_HIDE_TOWN_MAP", "HIDE_TOWN_MAP" },
+ [58] = { "FLAG_HIDE_POKEDEX", "HIDE_POKEDEX" },
+ [59] = { "FLAG_HIDE_CERULEAN_ROCKET", "HIDE_CERULEAN_ROCKET" },
+ [60] = { "FLAG_HIDE_CERULEAN_RIVAL", "HIDE_CERULEAN_RIVAL" },
+ [61] = { "FLAG_HIDE_SS_ANNE_RIVAL", "HIDE_SS_ANNE_RIVAL" },
+ [62] = { "FLAG_HIDE_SAFFRON_ROCKETS", "HIDE_SAFFRON_ROCKETS" },
+ [63] = { "FLAG_HIDE_SAFFRON_CIVILIANS", "HIDE_SAFFRON_CIVILIANS" },
+ [64] = { "FLAG_HIDE_SEAFOAM_1F_BOULDER_1", "HIDE_SEAFOAM_1F_BOULDER_1" },
+ [65] = { "FLAG_HIDE_SEAFOAM_1F_BOULDER_2", "HIDE_SEAFOAM_1F_BOULDER_2" },
+ [66] = { "FLAG_HIDE_SEAFOAM_B1F_BOULDER_1", "HIDE_SEAFOAM_B1F_BOULDER_1" },
+ [67] = { "FLAG_HIDE_SEAFOAM_B1F_BOULDER_2", "HIDE_SEAFOAM_B1F_BOULDER_2" },
+ [68] = { "FLAG_HIDE_SEAFOAM_B2F_BOULDER_1", "HIDE_SEAFOAM_B2F_BOULDER_1" },
+ [69] = { "FLAG_HIDE_SEAFOAM_B2F_BOULDER_2", "HIDE_SEAFOAM_B2F_BOULDER_2" },
+ [70] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_1", "HIDE_SEAFOAM_B3F_BOULDER_1" },
+ [71] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_2", "HIDE_SEAFOAM_B3F_BOULDER_2" },
+ [72] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_3", "HIDE_SEAFOAM_B3F_BOULDER_3" },
+ [73] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_4", "HIDE_SEAFOAM_B3F_BOULDER_4" },
+ [74] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_5", "HIDE_SEAFOAM_B3F_BOULDER_5" },
+ [75] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_6", "HIDE_SEAFOAM_B3F_BOULDER_6" },
+ [76] = { "FLAG_HIDE_SEAFOAM_B4F_BOULDER_1", "HIDE_SEAFOAM_B4F_BOULDER_1" },
+ [77] = { "FLAG_HIDE_SEAFOAM_B4F_BOULDER_2", "HIDE_SEAFOAM_B4F_BOULDER_2" },
+ [78] = { "FLAG_HIDE_SILPH_RIVAL", "HIDE_SILPH_RIVAL" },
+ [79] = { "FLAG_HIDE_ROUTE_22_RIVAL", "HIDE_ROUTE_22_RIVAL" },
+ [80] = { "FLAG_HIDE_PEWTER_MUSEUM_GUIDE", "HIDE_PEWTER_MUSEUM_GUIDE" },
+ [81] = { "FLAG_HIDE_TOWER_RIVAL", "HIDE_TOWER_RIVAL" },
+ [82] = { "FLAG_HIDE_MOLTRES", "HIDE_MOLTRES" },
+ [83] = { "FLAG_HIDE_SILPH_ROCKETS", "HIDE_SILPH_ROCKETS" },
+ [84] = { "FLAG_HIDE_ROUTE_12_SNORLAX", "HIDE_ROUTE_12_SNORLAX" },
+ [85] = { "FLAG_HIDE_VIRIDIAN_GIOVANNI", "HIDE_VIRIDIAN_GIOVANNI" },
+ [86] = { "FLAG_HIDE_OLD_AMBER", "HIDE_OLD_AMBER" },
+ [87] = { "FLAG_HIDE_EEVEE_BALL", "HIDE_EEVEE_BALL" },
+ [88] = { "FLAG_HIDE_VICTORY_ROAD_2F_BOULDER", "HIDE_VICTORY_ROAD_2F_BOULDER" },
+ [89] = { "FLAG_HIDE_VICTORY_ROAD_3F_BOULDER", "HIDE_VICTORY_ROAD_3F_BOULDER" },
+ [90] = { "FLAG_HIDE_OAK_IN_CHAMP_ROOM", "HIDE_OAK_IN_CHAMP_ROOM" },
+ [91] = { "FLAG_HIDE_GAME_CORNER_ROCKET", "HIDE_GAME_CORNER_ROCKET" },
+ [92] = { "FLAG_HIDE_CERULEAN_CAVE_GUARD", "HIDE_CERULEAN_CAVE_GUARD" },
+ [93] = { "FLAG_HIDE_ZAPDOS", "HIDE_ZAPDOS" },
+ [94] = { "FLAG_HIDE_TOWER_ROCKET_1", "HIDE_TOWER_ROCKET_1" },
+ [95] = { "FLAG_HIDE_CELADON_ROCKETS", "HIDE_CELADON_ROCKETS" },
+ [96] = { "FLAG_HIDE_DOJO_HITMONLEE_BALL", "HIDE_DOJO_HITMONLEE_BALL" },
+ [97] = { "FLAG_HIDE_DOJO_HITMONCHAN_BALL", "HIDE_DOJO_HITMONCHAN_BALL" },
+ [98] = { "FLAG_HIDE_CINNABAR_BILL", "HIDE_CINNABAR_BILL" },
+ [99] = { "FLAG_HIDE_UNION_ROOM_PLAYER_1", "HIDE_UNION_ROOM_PLAYER_1" },
+ [100] = { "FLAG_HIDE_UNION_ROOM_PLAYER_2", "HIDE_UNION_ROOM_PLAYER_2" },
+ [101] = { "FLAG_HIDE_UNION_ROOM_PLAYER_3", "HIDE_UNION_ROOM_PLAYER_3" },
+ [102] = { "FLAG_HIDE_UNION_ROOM_PLAYER_4", "HIDE_UNION_ROOM_PLAYER_4" },
+ [103] = { "FLAG_HIDE_UNION_ROOM_PLAYER_5", "HIDE_UNION_ROOM_PLAYER_5" },
+ [104] = { "FLAG_HIDE_UNION_ROOM_PLAYER_6", "HIDE_UNION_ROOM_PLAYER_6" },
+ [105] = { "FLAG_HIDE_UNION_ROOM_PLAYER_7", "HIDE_UNION_ROOM_PLAYER_7" },
+ [106] = { "FLAG_HIDE_UNION_ROOM_PLAYER_8", "HIDE_UNION_ROOM_PLAYER_8" },
+ [107] = { "FLAG_HIDE_CINNABAR_SEAGALLOP", "HIDE_CINNABAR_SEAGALLOP" },
+ [108] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_BLACK_BELT", "HIDE_SAFFRON_FAN_CLUB_BLACK_BELT" },
+ [109] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_ROCKER", "HIDE_SAFFRON_FAN_CLUB_ROCKER" },
+ [110] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_WOMAN", "HIDE_SAFFRON_FAN_CLUB_WOMAN" },
+ [111] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_BEAUTY", "HIDE_SAFFRON_FAN_CLUB_BEAUTY" },
+ [112] = { "FLAG_HIDE_MG_DELIVERYMEN", "HIDE_MG_DELIVERYMEN" },
+ [113] = { "FLAG_HIDE_ONE_ISLAND_BILL", "HIDE_ONE_ISLAND_BILL" },
+ [114] = { "FLAG_HIDE_ONE_ISLAND_POKECENTER_BILL", "HIDE_ONE_ISLAND_POKECENTER_BILL" },
+ [115] = { "FLAG_HIDE_ONE_ISLAND_POKECENTER_CELIO", "HIDE_ONE_ISLAND_POKECENTER_CELIO" },
+ [116] = { "FLAG_HIDE_TWO_ISLAND_GAME_CORNER_BIKER", "HIDE_TWO_ISLAND_GAME_CORNER_BIKER" },
+ [117] = { "FLAG_HIDE_TWO_ISLAND_GAME_CORNER_LOSTELLE", "HIDE_TWO_ISLAND_GAME_CORNER_LOSTELLE" },
+ [118] = { "FLAG_HIDE_LOSTELLE_IN_HER_HOME", "HIDE_LOSTELLE_IN_HER_HOME" },
+ [119] = { "FLAG_0x077", "0x077" },
+ [120] = { "FLAG_0x078", "0x078" },
+ [121] = { "FLAG_HIDE_THREE_ISLAND_BIKERS", "HIDE_THREE_ISLAND_BIKERS" },
+ [122] = { "FLAG_HIDE_LOSTELLE_IN_BERRY_FOREST", "HIDE_LOSTELLE_IN_BERRY_FOREST" },
+ [123] = { "FLAG_HIDE_TWO_ISLAND_WOMAN", "HIDE_TWO_ISLAND_WOMAN" },
+ [124] = { "FLAG_HIDE_TWO_ISLAND_BEAUTY", "HIDE_TWO_ISLAND_BEAUTY" },
+ [125] = { "FLAG_HIDE_TWO_ISLAND_POKE_MANIAC", "HIDE_TWO_ISLAND_POKE_MANIAC" },
+ [126] = { "FLAG_HIDE_THREE_ISLAND_ANTIBIKERS", "HIDE_THREE_ISLAND_ANTIBIKERS" },
+ [127] = { "FLAG_0x07F", "0x07F" },
+ [128] = { "FLAG_HIDE_ROUTE_16_SNORLAX", "HIDE_ROUTE_16_SNORLAX" },
+ [129] = { "FLAG_HIDE_MEWTWO", "HIDE_MEWTWO" },
+ [130] = { "FLAG_HIDE_ARTICUNO", "HIDE_ARTICUNO" },
+ [131] = { "FLAG_HIDE_TOWER_ROCKET_2", "HIDE_TOWER_ROCKET_2" },
+ [132] = { "FLAG_HIDE_TOWER_ROCKET_3", "HIDE_TOWER_ROCKET_3" },
+ [133] = { "FLAG_HIDE_POWER_PLANT_ELECTRODE_1", "HIDE_POWER_PLANT_ELECTRODE_1" },
+ [134] = { "FLAG_HIDE_POWER_PLANT_ELECTRODE_2", "HIDE_POWER_PLANT_ELECTRODE_2" },
+ [135] = { "FLAG_HIDE_SS_ANNE", "HIDE_SS_ANNE" },
+ [136] = { "FLAG_HIDE_FIVE_ISLAND_ROCKETS", "HIDE_FIVE_ISLAND_ROCKETS" },
+ [137] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_ROCKETS", "HIDE_MT_EMBER_EXTERIOR_ROCKETS" },
+ [138] = { "FLAG_HIDE_RUBY", "HIDE_RUBY" },
+ [139] = { "FLAG_HIDE_ICEFALL_CAVE_LORELEI", "HIDE_ICEFALL_CAVE_LORELEI" },
+ [140] = { "FLAG_HIDE_LORELEI_IN_HER_HOUSE", "HIDE_LORELEI_IN_HER_HOUSE" },
+ [141] = { "FLAG_HIDE_ICEFALL_CAVE_ROCKETS", "HIDE_ICEFALL_CAVE_ROCKETS" },
+ [142] = { "FLAG_HIDE_RUIN_VALLEY_SCIENTIST", "HIDE_RUIN_VALLEY_SCIENTIST" },
+ [143] = { "FLAG_HIDE_SAPPHIRE", "HIDE_SAPPHIRE" },
+ [144] = { "FLAG_HIDE_DOTTED_HOLE_SCIENTIST", "HIDE_DOTTED_HOLE_SCIENTIST" },
+ [145] = { "FLAG_HIDE_THREE_ISLAND_LONE_BIKER", "HIDE_THREE_ISLAND_LONE_BIKER" },
+ [146] = { "FLAG_HIDE_PEWTER_CITY_RUNNING_SHOES_GUY", "HIDE_PEWTER_CITY_RUNNING_SHOES_GUY" },
+ [147] = { "FLAG_HIDE_LOST_CAVE_SELPHY", "HIDE_LOST_CAVE_SELPHY" },
+ [148] = { "FLAG_HIDE_RESORT_GORGEOUS_SELPHY", "HIDE_RESORT_GORGEOUS_SELPHY" },
+ [149] = { "FLAG_HIDE_RESORT_GORGEOUS_INSIDE_SELPHY", "HIDE_RESORT_GORGEOUS_INSIDE_SELPHY" },
+ [150] = { "FLAG_HIDE_SELPHYS_BUTLER", "HIDE_SELPHYS_BUTLER" },
+ [151] = { "FLAG_HIDE_FOUR_ISLAND_RIVAL", "HIDE_FOUR_ISLAND_RIVAL" },
+ [152] = { "FLAG_HIDE_SIX_ISLAND_POKECENTER_RIVAL", "HIDE_SIX_ISLAND_POKECENTER_RIVAL" },
+ [153] = { "FLAG_HIDE_DEOXYS", "HIDE_DEOXYS" },
+ [154] = { "FLAG_HIDE_BIRTH_ISLAND_METEORITE", "HIDE_BIRTH_ISLAND_METEORITE" },
+ [155] = { "FLAG_HIDE_LUGIA", "HIDE_LUGIA" },
+ [156] = { "FLAG_HIDE_HO_OH", "HIDE_HO_OH" },
+ [157] = { "FLAG_HIDE_POSTGAME_GOSSIPERS", "HIDE_POSTGAME_GOSSIPERS" },
+ [158] = { "FLAG_HIDE_FAME_CHECKER_ERIKA_JOURNALS", "HIDE_FAME_CHECKER_ERIKA_JOURNALS" },
+ [159] = { "FLAG_HIDE_FAME_CHECKER_KOGA_JOURNAL", "HIDE_FAME_CHECKER_KOGA_JOURNAL" },
+ [160] = { "FLAG_HIDE_FAME_CHECKER_LT_SURGE_JOURNAL", "HIDE_FAME_CHECKER_LT_SURGE_JOURNAL" },
+ [161] = { "FLAG_HIDE_VERMILION_CITY_OAKS_AIDE", "HIDE_VERMILION_CITY_OAKS_AIDE" },
+ [162] = { "FLAG_HIDE_CINNABAR_POKECENTER_BILL", "HIDE_CINNABAR_POKECENTER_BILL" },
+ [163] = { "FLAG_HIDE_CREDITS_RIVAL", "HIDE_CREDITS_RIVAL" },
+ [164] = { "FLAG_HIDE_CREDITS_OAK", "HIDE_CREDITS_OAK" },
+ [165] = { "FLAG_HIDE_LORELEI_HOUSE_MEOWTH_DOLL", "HIDE_LORELEI_HOUSE_MEOWTH_DOLL" },
+ [166] = { "FLAG_HIDE_LORELEI_HOUSE_CHANSEY_DOLL", "HIDE_LORELEI_HOUSE_CHANSEY_DOLL" },
+ [167] = { "FLAG_HIDE_LORELEIS_HOUSE_NIDORAN_F_DOLL", "HIDE_LORELEIS_HOUSE_NIDORAN_F_DOLL" },
+ [168] = { "FLAG_HIDE_LORELEI_HOUSE_JIGGLYPUFF_DOLL", "HIDE_LORELEI_HOUSE_JIGGLYPUFF_DOLL" },
+ [169] = { "FLAG_HIDE_LORELEIS_HOUSE_NIDORAN_M_DOLL", "HIDE_LORELEIS_HOUSE_NIDORAN_M_DOLL" },
+ [170] = { "FLAG_HIDE_LORELEIS_HOUSE_FEAROW_DOLL", "HIDE_LORELEIS_HOUSE_FEAROW_DOLL" },
+ [171] = { "FLAG_HIDE_LORELEIS_HOUSE_PIDGEOT_DOLL", "HIDE_LORELEIS_HOUSE_PIDGEOT_DOLL" },
+ [172] = { "FLAG_HIDE_LORELEIS_HOUSE_LAPRAS_DOLL", "HIDE_LORELEIS_HOUSE_LAPRAS_DOLL" },
+ [173] = { "FLAG_HIDE_MISC_KANTO_ROCKETS", "HIDE_MISC_KANTO_ROCKETS" },
+ [174] = { "FLAG_HIDE_SAFFRON_CITY_POKECENTER_SABRINA_JOURNALS", "HIDE_SAFFRON_CITY_POKECENTER_SABRINA_JOURNALS" },
+ [175] = { "FLAG_0x0AF", "0x0AF" },
+ [176] = { "FLAG_0x0B0", "0x0B0" },
+ [177] = { "FLAG_0x0B1", "0x0B1" },
+ [178] = { "FLAG_0x0B2", "0x0B2" },
+ [179] = { "FLAG_0x0B3", "0x0B3" },
+ [180] = { "FLAG_0x0B4", "0x0B4" },
+ [181] = { "FLAG_0x0B5", "0x0B5" },
+ [182] = { "FLAG_0x0B6", "0x0B6" },
+ [183] = { "FLAG_0x0B7", "0x0B7" },
+ [184] = { "FLAG_0x0B8", "0x0B8" },
+ [185] = { "FLAG_0x0B9", "0x0B9" },
+ [186] = { "FLAG_0x0BA", "0x0BA" },
+ [187] = { "FLAG_0x0BB", "0x0BB" },
+ [188] = { "FLAG_0x0BC", "0x0BC" },
+ [189] = { "FLAG_0x0BD", "0x0BD" },
+ [190] = { "FLAG_0x0BE", "0x0BE" },
+ [191] = { "FLAG_0x0BF", "0x0BF" },
+ [192] = { "FLAG_0x0C0", "0x0C0" },
+ [193] = { "FLAG_0x0C1", "0x0C1" },
+ [194] = { "FLAG_0x0C2", "0x0C2" },
+ [195] = { "FLAG_0x0C3", "0x0C3" },
+ [196] = { "FLAG_0x0C4", "0x0C4" },
+ [197] = { "FLAG_0x0C5", "0x0C5" },
+ [198] = { "FLAG_0x0C6", "0x0C6" },
+ [199] = { "FLAG_0x0C7", "0x0C7" },
+ [200] = { "FLAG_0x0C8", "0x0C8" },
+ [201] = { "FLAG_0x0C9", "0x0C9" },
+ [202] = { "FLAG_0x0CA", "0x0CA" },
+ [203] = { "FLAG_0x0CB", "0x0CB" },
+ [204] = { "FLAG_0x0CC", "0x0CC" },
+ [205] = { "FLAG_0x0CD", "0x0CD" },
+ [206] = { "FLAG_0x0CE", "0x0CE" },
+ [207] = { "FLAG_0x0CF", "0x0CF" },
+ [208] = { "FLAG_0x0D0", "0x0D0" },
+ [209] = { "FLAG_0x0D1", "0x0D1" },
+ [210] = { "FLAG_0x0D2", "0x0D2" },
+ [211] = { "FLAG_0x0D3", "0x0D3" },
+ [212] = { "FLAG_0x0D4", "0x0D4" },
+ [213] = { "FLAG_0x0D5", "0x0D5" },
+ [214] = { "FLAG_0x0D6", "0x0D6" },
+ [215] = { "FLAG_0x0D7", "0x0D7" },
+ [216] = { "FLAG_0x0D8", "0x0D8" },
+ [217] = { "FLAG_0x0D9", "0x0D9" },
+ [218] = { "FLAG_0x0DA", "0x0DA" },
+ [219] = { "FLAG_0x0DB", "0x0DB" },
+ [220] = { "FLAG_0x0DC", "0x0DC" },
+ [221] = { "FLAG_0x0DD", "0x0DD" },
+ [222] = { "FLAG_0x0DE", "0x0DE" },
+ [223] = { "FLAG_0x0DF", "0x0DF" },
+ [224] = { "FLAG_0x0E0", "0x0E0" },
+ [225] = { "FLAG_0x0E1", "0x0E1" },
+ [226] = { "FLAG_0x0E2", "0x0E2" },
+ [227] = { "FLAG_0x0E3", "0x0E3" },
+ [228] = { "FLAG_0x0E4", "0x0E4" },
+ [229] = { "FLAG_0x0E5", "0x0E5" },
+ [230] = { "FLAG_0x0E6", "0x0E6" },
+ [231] = { "FLAG_0x0E7", "0x0E7" },
+ [232] = { "FLAG_0x0E8", "0x0E8" },
+ [233] = { "FLAG_0x0E9", "0x0E9" },
+ [234] = { "FLAG_0x0EA", "0x0EA" },
+ [235] = { "FLAG_0x0EB", "0x0EB" },
+ [236] = { "FLAG_0x0EC", "0x0EC" },
+ [237] = { "FLAG_0x0ED", "0x0ED" },
+ [238] = { "FLAG_0x0EE", "0x0EE" },
+ [239] = { "FLAG_0x0EF", "0x0EF" },
+ [240] = { "FLAG_0x0F0", "0x0F0" },
+ [241] = { "FLAG_0x0F1", "0x0F1" },
+ [242] = { "FLAG_0x0F2", "0x0F2" },
+ [243] = { "FLAG_0x0F3", "0x0F3" },
+ [244] = { "FLAG_0x0F4", "0x0F4" },
+ [245] = { "FLAG_0x0F5", "0x0F5" },
+ [246] = { "FLAG_0x0F6", "0x0F6" },
+ [247] = { "FLAG_0x0F7", "0x0F7" },
+ [248] = { "FLAG_0x0F8", "0x0F8" },
+ [249] = { "FLAG_0x0F9", "0x0F9" },
+ [250] = { "FLAG_0x0FA", "0x0FA" },
+ [251] = { "FLAG_0x0FB", "0x0FB" },
+ [252] = { "FLAG_0x0FC", "0x0FC" },
+ [253] = { "FLAG_0x0FD", "0x0FD" },
+ [254] = { "FLAG_0x0FE", "0x0FE" },
+ [255] = { "FLAG_0x0FF", "0x0FF" },
+ [256] = { "FLAG_0x100", "0x100", "VARS_COUNT" },
+ [257] = { "FLAG_0x101", "0x101" },
+ [258] = { "FLAG_0x102", "0x102" },
+ [259] = { "FLAG_0x103", "0x103" },
+ [260] = { "FLAG_0x104", "0x104" },
+ [261] = { "FLAG_0x105", "0x105" },
+ [262] = { "FLAG_0x106", "0x106" },
+ [263] = { "FLAG_0x107", "0x107" },
+ [264] = { "FLAG_0x108", "0x108" },
+ [265] = { "FLAG_0x109", "0x109" },
+ [266] = { "FLAG_0x10A", "0x10A" },
+ [267] = { "FLAG_0x10B", "0x10B" },
+ [268] = { "FLAG_0x10C", "0x10C" },
+ [269] = { "FLAG_0x10D", "0x10D" },
+ [270] = { "FLAG_0x10E", "0x10E" },
+ [271] = { "FLAG_0x10F", "0x10F" },
+ [272] = { "FLAG_0x110", "0x110" },
+ [273] = { "FLAG_0x111", "0x111" },
+ [274] = { "FLAG_0x112", "0x112" },
+ [275] = { "FLAG_0x113", "0x113" },
+ [276] = { "FLAG_0x114", "0x114" },
+ [277] = { "FLAG_0x115", "0x115" },
+ [278] = { "FLAG_0x116", "0x116" },
+ [279] = { "FLAG_0x117", "0x117" },
+ [280] = { "FLAG_0x118", "0x118" },
+ [281] = { "FLAG_0x119", "0x119" },
+ [282] = { "FLAG_0x11A", "0x11A" },
+ [283] = { "FLAG_0x11B", "0x11B" },
+ [284] = { "FLAG_0x11C", "0x11C" },
+ [285] = { "FLAG_0x11D", "0x11D" },
+ [286] = { "FLAG_0x11E", "0x11E" },
+ [287] = { "FLAG_0x11F", "0x11F" },
+ [288] = { "FLAG_0x120", "0x120" },
+ [289] = { "FLAG_0x121", "0x121" },
+ [290] = { "FLAG_0x122", "0x122" },
+ [291] = { "FLAG_0x123", "0x123" },
+ [292] = { "FLAG_0x124", "0x124" },
+ [293] = { "FLAG_0x125", "0x125" },
+ [294] = { "FLAG_0x126", "0x126" },
+ [295] = { "FLAG_0x127", "0x127" },
+ [296] = { "FLAG_0x128", "0x128" },
+ [297] = { "FLAG_0x129", "0x129" },
+ [298] = { "FLAG_0x12A", "0x12A" },
+ [299] = { "FLAG_0x12B", "0x12B" },
+ [300] = { "FLAG_0x12C", "0x12C" },
+ [301] = { "FLAG_0x12D", "0x12D" },
+ [302] = { "FLAG_0x12E", "0x12E" },
+ [303] = { "FLAG_0x12F", "0x12F" },
+ [304] = { "FLAG_0x130", "0x130" },
+ [305] = { "FLAG_0x131", "0x131" },
+ [306] = { "FLAG_0x132", "0x132" },
+ [307] = { "FLAG_0x133", "0x133" },
+ [308] = { "FLAG_0x134", "0x134" },
+ [309] = { "FLAG_0x135", "0x135" },
+ [310] = { "FLAG_0x136", "0x136" },
+ [311] = { "FLAG_0x137", "0x137" },
+ [312] = { "FLAG_0x138", "0x138" },
+ [313] = { "FLAG_0x139", "0x139" },
+ [314] = { "FLAG_0x13A", "0x13A" },
+ [315] = { "FLAG_0x13B", "0x13B" },
+ [316] = { "FLAG_0x13C", "0x13C" },
+ [317] = { "FLAG_0x13D", "0x13D" },
+ [318] = { "FLAG_0x13E", "0x13E" },
+ [319] = { "FLAG_0x13F", "0x13F" },
+ [320] = { "FLAG_0x140", "0x140" },
+ [321] = { "FLAG_0x141", "0x141" },
+ [322] = { "FLAG_0x142", "0x142" },
+ [323] = { "FLAG_0x143", "0x143" },
+ [324] = { "FLAG_0x144", "0x144" },
+ [325] = { "FLAG_0x145", "0x145" },
+ [326] = { "FLAG_0x146", "0x146" },
+ [327] = { "FLAG_0x147", "0x147" },
+ [328] = { "FLAG_0x148", "0x148" },
+ [329] = { "FLAG_0x149", "0x149" },
+ [330] = { "FLAG_0x14A", "0x14A" },
+ [331] = { "FLAG_0x14B", "0x14B" },
+ [332] = { "FLAG_0x14C", "0x14C" },
+ [333] = { "FLAG_0x14D", "0x14D" },
+ [334] = { "FLAG_0x14E", "0x14E" },
+ [335] = { "FLAG_0x14F", "0x14F" },
+ [336] = { "FLAG_0x150", "0x150" },
+ [337] = { "FLAG_0x151", "0x151" },
+ [338] = { "FLAG_0x152", "0x152" },
+ [339] = { "FLAG_0x153", "0x153" },
+ [340] = { "FLAG_HIDE_ROUTE2_ETHER", "HIDE_ROUTE2_ETHER" },
+ [341] = { "FLAG_HIDE_ROUTE2_PARALYZE_HEAL", "HIDE_ROUTE2_PARALYZE_HEAL" },
+ [342] = { "FLAG_HIDE_VIRIDIAN_FOREST_POKE_BALL", "HIDE_VIRIDIAN_FOREST_POKE_BALL" },
+ [343] = { "FLAG_HIDE_VIRIDIAN_FOREST_ANTIDOTE", "HIDE_VIRIDIAN_FOREST_ANTIDOTE" },
+ [344] = { "FLAG_HIDE_VIRIDIAN_FOREST_POTION", "HIDE_VIRIDIAN_FOREST_POTION" },
+ [345] = { "FLAG_HIDE_MT_MOON_1F_PARALYZE_HEAL", "HIDE_MT_MOON_1F_PARALYZE_HEAL" },
+ [346] = { "FLAG_HIDE_MT_MOON_1F_TM09", "HIDE_MT_MOON_1F_TM09" },
+ [347] = { "FLAG_HIDE_MT_MOON_1F_POTION", "HIDE_MT_MOON_1F_POTION" },
+ [348] = { "FLAG_HIDE_MT_MOON_1F_RARE_CANDY", "HIDE_MT_MOON_1F_RARE_CANDY" },
+ [349] = { "FLAG_HIDE_MT_MOON_1F_ESCAPE_ROPE", "HIDE_MT_MOON_1F_ESCAPE_ROPE" },
+ [350] = { "FLAG_HIDE_MT_MOON_1F_MOON_STONE", "HIDE_MT_MOON_1F_MOON_STONE" },
+ [351] = { "FLAG_HIDE_MT_MOON_B2F_STAR_PIECE", "HIDE_MT_MOON_B2F_STAR_PIECE" },
+ [352] = { "FLAG_HIDE_MT_MOON_B2F_TM46", "HIDE_MT_MOON_B2F_TM46" },
+ [353] = { "FLAG_HIDE_ROUTE4_TM05", "HIDE_ROUTE4_TM05" },
+ [354] = { "FLAG_HIDE_ROUTE24_TM45", "HIDE_ROUTE24_TM45" },
+ [355] = { "FLAG_HIDE_ROUTE25_TM43", "HIDE_ROUTE25_TM43" },
+ [356] = { "FLAG_HIDE_SSANNE_1F_ROOM2_TM31", "HIDE_SSANNE_1F_ROOM2_TM31" },
+ [357] = { "FLAG_HIDE_SSANNE_2F_ROOM2_STARDUST", "HIDE_SSANNE_2F_ROOM2_STARDUST" },
+ [358] = { "FLAG_HIDE_SSANNE_2F_ROOM4_X_ATTACK", "HIDE_SSANNE_2F_ROOM4_X_ATTACK" },
+ [359] = { "FLAG_HIDE_SSANNE_B1F_ROOM2_TM44", "HIDE_SSANNE_B1F_ROOM2_TM44" },
+ [360] = { "FLAG_HIDE_SSANNE_B1F_ROOM3_ETHER", "HIDE_SSANNE_B1F_ROOM3_ETHER" },
+ [361] = { "FLAG_HIDE_SSANNE_B1F_ROOM5_SUPER_POTION", "HIDE_SSANNE_B1F_ROOM5_SUPER_POTION" },
+ [362] = { "FLAG_HIDE_SSANNE_KITCHEN_GREAT_BALL", "HIDE_SSANNE_KITCHEN_GREAT_BALL" },
+ [363] = { "FLAG_HIDE_ROUTE9_TM40", "HIDE_ROUTE9_TM40" },
+ [364] = { "FLAG_HIDE_ROCKET_HIDEOUT_B1F_ESCAPE_ROPE", "HIDE_ROCKET_HIDEOUT_B1F_ESCAPE_ROPE" },
+ [365] = { "FLAG_HIDE_ROCKET_HIDEOUT_B1F_HYPER_POTION", "HIDE_ROCKET_HIDEOUT_B1F_HYPER_POTION" },
+ [366] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_X_SPEED", "HIDE_ROCKET_HIDEOUT_B2F_X_SPEED" },
+ [367] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_MOON_STONE", "HIDE_ROCKET_HIDEOUT_B2F_MOON_STONE" },
+ [368] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_TM12", "HIDE_ROCKET_HIDEOUT_B2F_TM12" },
+ [369] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_SUPER_POTION", "HIDE_ROCKET_HIDEOUT_B2F_SUPER_POTION" },
+ [370] = { "FLAG_HIDE_ROCKET_HIDEOUT_B3F_RARE_CANDY", "HIDE_ROCKET_HIDEOUT_B3F_RARE_CANDY" },
+ [371] = { "FLAG_HIDE_ROCKET_HIDEOUT_B3F_TM21", "HIDE_ROCKET_HIDEOUT_B3F_TM21" },
+ [372] = { "FLAG_HIDE_ROCKET_HIDEOUT_B4F_TM49", "HIDE_ROCKET_HIDEOUT_B4F_TM49" },
+ [373] = { "FLAG_HIDE_ROCKET_HIDEOUT_B4F_MAX_ETHER", "HIDE_ROCKET_HIDEOUT_B4F_MAX_ETHER" },
+ [374] = { "FLAG_HIDE_ROCKET_HIDEOUT_B4F_CALCIUM", "HIDE_ROCKET_HIDEOUT_B4F_CALCIUM" },
+ [375] = { "FLAG_HIDE_POKEMON_TOWER_3F_ESCAPE_ROPE", "HIDE_POKEMON_TOWER_3F_ESCAPE_ROPE" },
+ [376] = { "FLAG_HIDE_POKEMON_TOWER_4F_ELIXIR", "HIDE_POKEMON_TOWER_4F_ELIXIR" },
+ [377] = { "FLAG_HIDE_POKEMON_TOWER_4F_AWAKENING", "HIDE_POKEMON_TOWER_4F_AWAKENING" },
+ [378] = { "FLAG_HIDE_POKEMON_TOWER_4F_GREAT_BALL", "HIDE_POKEMON_TOWER_4F_GREAT_BALL" },
+ [379] = { "FLAG_HIDE_POKEMON_TOWER_5F_NUGGET", "HIDE_POKEMON_TOWER_5F_NUGGET" },
+ [380] = { "FLAG_HIDE_POKEMON_TOWER_6F_RARE_CANDY", "HIDE_POKEMON_TOWER_6F_RARE_CANDY" },
+ [381] = { "FLAG_HIDE_POKEMON_TOWER_6F_X_ACCURACY", "HIDE_POKEMON_TOWER_6F_X_ACCURACY" },
+ [382] = { "FLAG_HIDE_ROUTE12_TM48", "HIDE_ROUTE12_TM48" },
+ [383] = { "FLAG_HIDE_ROUTE12_IRON", "HIDE_ROUTE12_IRON" },
+ [384] = { "FLAG_HIDE_ROUTE15_TM18", "HIDE_ROUTE15_TM18" },
+ [385] = { "FLAG_HIDE_SAFARI_ZONE_CENTER_NUGGET", "HIDE_SAFARI_ZONE_CENTER_NUGGET" },
+ [386] = { "FLAG_HIDE_SAFARI_ZONE_EAST_MAX_POTION", "HIDE_SAFARI_ZONE_EAST_MAX_POTION" },
+ [387] = { "FLAG_HIDE_SAFARI_ZONE_EAST_FULL_RESTORE", "HIDE_SAFARI_ZONE_EAST_FULL_RESTORE" },
+ [388] = { "FLAG_HIDE_SAFARI_ZONE_EAST_TM11", "HIDE_SAFARI_ZONE_EAST_TM11" },
+ [389] = { "FLAG_HIDE_SAFARI_ZONE_EAST_LEAF_STONE", "HIDE_SAFARI_ZONE_EAST_LEAF_STONE" },
+ [390] = { "FLAG_HIDE_SAFARI_ZONE_NORTH_PROTEIN", "HIDE_SAFARI_ZONE_NORTH_PROTEIN" },
+ [391] = { "FLAG_HIDE_SAFARI_ZONE_NORTH_TM47", "HIDE_SAFARI_ZONE_NORTH_TM47" },
+ [392] = { "FLAG_HIDE_SAFARI_ZONE_WEST_TM32", "HIDE_SAFARI_ZONE_WEST_TM32" },
+ [393] = { "FLAG_HIDE_SAFARI_ZONE_WEST_GOLD_TEETH", "HIDE_SAFARI_ZONE_WEST_GOLD_TEETH" },
+ [394] = { "FLAG_HIDE_SAFARI_ZONE_WEST_MAX_POTION", "HIDE_SAFARI_ZONE_WEST_MAX_POTION" },
+ [395] = { "FLAG_HIDE_SAFARI_ZONE_WEST_MAX_REVIVE", "HIDE_SAFARI_ZONE_WEST_MAX_REVIVE" },
+ [396] = { "FLAG_HIDE_SILPH_CO_3F_HYPER_POTION", "HIDE_SILPH_CO_3F_HYPER_POTION" },
+ [397] = { "FLAG_HIDE_SILPH_CO_4F_MAX_REVIVE", "HIDE_SILPH_CO_4F_MAX_REVIVE" },
+ [398] = { "FLAG_HIDE_SILPH_CO_4F_ESCAPE_ROPE", "HIDE_SILPH_CO_4F_ESCAPE_ROPE" },
+ [399] = { "FLAG_HIDE_SILPH_CO_4F_FULL_HEAL", "HIDE_SILPH_CO_4F_FULL_HEAL" },
+ [400] = { "FLAG_HIDE_SILPH_CO_5F_PROTEIN", "HIDE_SILPH_CO_5F_PROTEIN" },
+ [401] = { "FLAG_HIDE_SILPH_CO_5F_TM01", "HIDE_SILPH_CO_5F_TM01" },
+ [402] = { "FLAG_HIDE_SILPH_CO_5F_CARD_KEY", "HIDE_SILPH_CO_5F_CARD_KEY" },
+ [403] = { "FLAG_HIDE_SILPH_CO_6F_HP_UP", "HIDE_SILPH_CO_6F_HP_UP" },
+ [404] = { "FLAG_HIDE_SILPH_CO_6F_X_SPECIAL", "HIDE_SILPH_CO_6F_X_SPECIAL" },
+ [405] = { "FLAG_HIDE_SILPH_CO_7F_CALCIUM", "HIDE_SILPH_CO_7F_CALCIUM" },
+ [406] = { "FLAG_HIDE_SILPH_CO_7F_TM08", "HIDE_SILPH_CO_7F_TM08" },
+ [407] = { "FLAG_HIDE_SILPH_CO_10F_CARBOS", "HIDE_SILPH_CO_10F_CARBOS" },
+ [408] = { "FLAG_HIDE_SILPH_CO_10F_ULTRA_BALL", "HIDE_SILPH_CO_10F_ULTRA_BALL" },
+ [409] = { "FLAG_HIDE_SILPH_CO_10F_RARE_CANDY", "HIDE_SILPH_CO_10F_RARE_CANDY" },
+ [410] = { "FLAG_HIDE_POWER_PLANT_MAX_POTION", "HIDE_POWER_PLANT_MAX_POTION" },
+ [411] = { "FLAG_HIDE_POWER_PLANT_TM17", "HIDE_POWER_PLANT_TM17" },
+ [412] = { "FLAG_HIDE_POWER_PLANT_TM25", "HIDE_POWER_PLANT_TM25" },
+ [413] = { "FLAG_HIDE_POWER_PLANT_THUNDER_STONE", "HIDE_POWER_PLANT_THUNDER_STONE" },
+ [414] = { "FLAG_HIDE_POWER_PLANT_ELIXIR", "HIDE_POWER_PLANT_ELIXIR" },
+ [415] = { "FLAG_HIDE_POKEMON_MANSION_1F_CARBOS", "HIDE_POKEMON_MANSION_1F_CARBOS" },
+ [416] = { "FLAG_HIDE_POKEMON_MANSION_1F_ESCAPE_ROPE", "HIDE_POKEMON_MANSION_1F_ESCAPE_ROPE" },
+ [417] = { "FLAG_HIDE_POKEMON_MANSION_2F_CALCIUM", "HIDE_POKEMON_MANSION_2F_CALCIUM" },
+ [418] = { "FLAG_HIDE_POKEMON_MANSION_3F_MAX_POTION", "HIDE_POKEMON_MANSION_3F_MAX_POTION" },
+ [419] = { "FLAG_HIDE_POKEMON_MANSION_3F_IRON", "HIDE_POKEMON_MANSION_3F_IRON" },
+ [420] = { "FLAG_HIDE_POKEMON_MANSION_B1F_TM14", "HIDE_POKEMON_MANSION_B1F_TM14" },
+ [421] = { "FLAG_HIDE_POKEMON_MANSION_B1F_FULL_RESTORE", "HIDE_POKEMON_MANSION_B1F_FULL_RESTORE" },
+ [422] = { "FLAG_0x1A6", "0x1A6" },
+ [423] = { "FLAG_HIDE_POKEMON_MANSION_B1F_TM22", "HIDE_POKEMON_MANSION_B1F_TM22" },
+ [424] = { "FLAG_HIDE_POKEMON_MANSION_B1F_SECRET_KEY", "HIDE_POKEMON_MANSION_B1F_SECRET_KEY" },
+ [425] = { "FLAG_HIDE_VICTORY_ROAD_1F_RARE_CANDY", "HIDE_VICTORY_ROAD_1F_RARE_CANDY" },
+ [426] = { "FLAG_HIDE_VICTORY_ROAD_1F_TM02", "HIDE_VICTORY_ROAD_1F_TM02" },
+ [427] = { "FLAG_HIDE_VICTORY_ROAD_2F_GUARD_SPEC", "HIDE_VICTORY_ROAD_2F_GUARD_SPEC" },
+ [428] = { "FLAG_HIDE_VICTORY_ROAD_2F_TM07", "HIDE_VICTORY_ROAD_2F_TM07" },
+ [429] = { "FLAG_HIDE_VICTORY_ROAD_2F_FULL_HEAL", "HIDE_VICTORY_ROAD_2F_FULL_HEAL" },
+ [430] = { "FLAG_HIDE_VICTORY_ROAD_2F_TM37", "HIDE_VICTORY_ROAD_2F_TM37" },
+ [431] = { "FLAG_HIDE_VICTORY_ROAD_3F_MAX_REVIVE", "HIDE_VICTORY_ROAD_3F_MAX_REVIVE" },
+ [432] = { "FLAG_HIDE_VICTORY_ROAD_3F_TM50", "HIDE_VICTORY_ROAD_3F_TM50" },
+ [433] = { "FLAG_HIDE_CERULEAN_CAVE_1F_MAX_ELIXIR", "HIDE_CERULEAN_CAVE_1F_MAX_ELIXIR" },
+ [434] = { "FLAG_HIDE_CERULEAN_CAVE_1F_NUGGET", "HIDE_CERULEAN_CAVE_1F_NUGGET" },
+ [435] = { "FLAG_HIDE_CERULEAN_CAVE_1F_FULL_RESTORE", "HIDE_CERULEAN_CAVE_1F_FULL_RESTORE" },
+ [436] = { "FLAG_HIDE_CERULEAN_CAVE_2F_FULL_RESTORE", "HIDE_CERULEAN_CAVE_2F_FULL_RESTORE" },
+ [437] = { "FLAG_HIDE_CERULEAN_CAVE_2F_PP_UP", "HIDE_CERULEAN_CAVE_2F_PP_UP" },
+ [438] = { "FLAG_HIDE_CERULEAN_CAVE_2F_ULTRA_BALL", "HIDE_CERULEAN_CAVE_2F_ULTRA_BALL" },
+ [439] = { "FLAG_HIDE_CERULEAN_CAVE_B1F_MAX_REVIVE", "HIDE_CERULEAN_CAVE_B1F_MAX_REVIVE" },
+ [440] = { "FLAG_HIDE_CERULEAN_CAVE_B1F_ULTRA_BALL", "HIDE_CERULEAN_CAVE_B1F_ULTRA_BALL" },
+ [441] = { "FLAG_HIDE_FUCHSIA_CITY_WARDENS_HOUSE_RARE_CANDY", "HIDE_FUCHSIA_CITY_WARDENS_HOUSE_RARE_CANDY" },
+ [442] = { "FLAG_HIDE_TWO_ISLAND_REVIVE", "HIDE_TWO_ISLAND_REVIVE" },
+ [443] = { "FLAG_HIDE_THREE_ISLAND_ZINC", "HIDE_THREE_ISLAND_ZINC" },
+ [444] = { "FLAG_0x1BC", "0x1BC" },
+ [445] = { "FLAG_0x1BD", "0x1BD" },
+ [446] = { "FLAG_HIDE_VIRIDIAN_FOREST_POTION_2", "HIDE_VIRIDIAN_FOREST_POTION_2" },
+ [447] = { "FLAG_HIDE_MT_MOON_B2F_REVIVE", "HIDE_MT_MOON_B2F_REVIVE" },
+ [448] = { "FLAG_HIDE_MT_MOON_B2F_ANTIDOTE", "HIDE_MT_MOON_B2F_ANTIDOTE" },
+ [449] = { "FLAG_HIDE_ROUTE11_X_DEFEND", "HIDE_ROUTE11_X_DEFEND" },
+ [450] = { "FLAG_HIDE_ROUTE9_BURN_HEAL", "HIDE_ROUTE9_BURN_HEAL" },
+ [451] = { "FLAG_HIDE_ROCK_TUNNEL_1F_REPEL", "HIDE_ROCK_TUNNEL_1F_REPEL" },
+ [452] = { "FLAG_HIDE_ROCK_TUNNEL_1F_PEARL", "HIDE_ROCK_TUNNEL_1F_PEARL" },
+ [453] = { "FLAG_HIDE_ROCK_TUNNEL_1F_ESCAPE_ROPE", "HIDE_ROCK_TUNNEL_1F_ESCAPE_ROPE" },
+ [454] = { "FLAG_HIDE_ROCK_TUNNEL_B1F_REVIVE", "HIDE_ROCK_TUNNEL_B1F_REVIVE" },
+ [455] = { "FLAG_HIDE_ROCK_TUNNEL_B1F_MAX_ETHER", "HIDE_ROCK_TUNNEL_B1F_MAX_ETHER" },
+ [456] = { "FLAG_HIDE_SILPH_CO_8F_IRON", "HIDE_SILPH_CO_8F_IRON" },
+ [457] = { "FLAG_HIDE_SILPH_CO_11F_ZINC", "HIDE_SILPH_CO_11F_ZINC" },
+ [458] = { "FLAG_HIDE_POKEMON_MANSION_1F_PROTEIN", "HIDE_POKEMON_MANSION_1F_PROTEIN" },
+ [459] = { "FLAG_HIDE_POKEMON_MANSION_2F_ZINC", "HIDE_POKEMON_MANSION_2F_ZINC" },
+ [460] = { "FLAG_HIDE_POKEMON_MANSION_2F_HP_UP", "HIDE_POKEMON_MANSION_2F_HP_UP" },
+ [461] = { "FLAG_HIDE_VIRIDIAN_CITY_POTION", "HIDE_VIRIDIAN_CITY_POTION" },
+ [462] = { "FLAG_HIDE_ROUTE11_GREAT_BALL", "HIDE_ROUTE11_GREAT_BALL" },
+ [463] = { "FLAG_HIDE_ROUTE11_AWAKENING", "HIDE_ROUTE11_AWAKENING" },
+ [464] = { "FLAG_HIDE_POKEMON_TOWER_5F_CLEANSE_TAG", "HIDE_POKEMON_TOWER_5F_CLEANSE_TAG" },
+ [465] = { "FLAG_HIDE_CELADON_CITY_ETHER", "HIDE_CELADON_CITY_ETHER" },
+ [466] = { "FLAG_HIDE_ROCKET_HIDEOUT_B3F_BLACK_GLASSES", "HIDE_ROCKET_HIDEOUT_B3F_BLACK_GLASSES" },
+ [467] = { "FLAG_HIDE_SAFARI_ZONE_NORTH_QUICK_CLAW", "HIDE_SAFARI_ZONE_NORTH_QUICK_CLAW" },
+ [468] = { "FLAG_HIDE_SEAFOAM_ISLANDS_1F_ICE_HEAL", "HIDE_SEAFOAM_ISLANDS_1F_ICE_HEAL" },
+ [469] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B1F_WATER_STONE", "HIDE_SEAFOAM_ISLANDS_B1F_WATER_STONE" },
+ [470] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B1F_REVIVE", "HIDE_SEAFOAM_ISLANDS_B1F_REVIVE" },
+ [471] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B2F_BIG_PEARL", "HIDE_SEAFOAM_ISLANDS_B2F_BIG_PEARL" },
+ [472] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B4F_ULTRA_BALL", "HIDE_SEAFOAM_ISLANDS_B4F_ULTRA_BALL" },
+ [473] = { "FLAG_HIDE_FOUR_ISLAND_STAR_PIECE", "HIDE_FOUR_ISLAND_STAR_PIECE" },
+ [474] = { "FLAG_HIDE_FOUR_ISLAND_STARDUST", "HIDE_FOUR_ISLAND_STARDUST" },
+ [475] = { "FLAG_HIDE_ONE_ISLAND_KINDLE_ROAD_ETHER", "HIDE_ONE_ISLAND_KINDLE_ROAD_ETHER" },
+ [476] = { "FLAG_HIDE_ONE_ISLAND_KINDLE_ROAD_MAX_REPEL", "HIDE_ONE_ISLAND_KINDLE_ROAD_MAX_REPEL" },
+ [477] = { "FLAG_HIDE_ONE_ISLAND_KINDLE_ROAD_CARBOS", "HIDE_ONE_ISLAND_KINDLE_ROAD_CARBOS" },
+ [478] = { "FLAG_HIDE_FIVE_ISLAND_MEADOW_MAX_POTION", "HIDE_FIVE_ISLAND_MEADOW_MAX_POTION" },
+ [479] = { "FLAG_HIDE_FIVE_ISLAND_MEADOW_PP_UP", "HIDE_FIVE_ISLAND_MEADOW_PP_UP" },
+ [480] = { "FLAG_HIDE_FIVE_ISLAND_MEMORIAL_PILLAR_METAL_COAT", "HIDE_FIVE_ISLAND_MEMORIAL_PILLAR_METAL_COAT" },
+ [481] = { "FLAG_HIDE_SIX_ISLAND_OUTCAST_ISLAND_PP_UP", "HIDE_SIX_ISLAND_OUTCAST_ISLAND_PP_UP" },
+ [482] = { "FLAG_HIDE_SIX_ISLAND_WATER_PATH_ELIXIR", "HIDE_SIX_ISLAND_WATER_PATH_ELIXIR" },
+ [483] = { "FLAG_HIDE_SIX_ISLAND_WATER_PATH_DRAGON_SCALE", "HIDE_SIX_ISLAND_WATER_PATH_DRAGON_SCALE" },
+ [484] = { "FLAG_HIDE_SIX_ISLAND_RUIN_VALLEY_FULL_RESTORE", "HIDE_SIX_ISLAND_RUIN_VALLEY_FULL_RESTORE" },
+ [485] = { "FLAG_HIDE_SIX_ISLAND_RUIN_VALLEY_HP_UP", "HIDE_SIX_ISLAND_RUIN_VALLEY_HP_UP" },
+ [486] = { "FLAG_HIDE_SIX_ISLAND_RUIN_VALLEY_SUN_STONE", "HIDE_SIX_ISLAND_RUIN_VALLEY_SUN_STONE" },
+ [487] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_KINGS_ROCK", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_KINGS_ROCK" },
+ [488] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_MAX_ELIXIR", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_MAX_ELIXIR" },
+ [489] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_NUGGET", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_NUGGET" },
+ [490] = { "FLAG_HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ETHER", "HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ETHER" },
+ [491] = { "FLAG_HIDE_THREE_ISLAND_BERRY_FOREST_FULL_HEAL", "HIDE_THREE_ISLAND_BERRY_FOREST_FULL_HEAL" },
+ [492] = { "FLAG_HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ELIXIR", "HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ELIXIR" },
+ [493] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_ULTRA_BALL", "HIDE_MT_EMBER_EXTERIOR_ULTRA_BALL" },
+ [494] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_FIRE_STONE", "HIDE_MT_EMBER_EXTERIOR_FIRE_STONE" },
+ [495] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_DIRE_HIT", "HIDE_MT_EMBER_EXTERIOR_DIRE_HIT" },
+ [496] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_ULTRA_BALL", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_ULTRA_BALL" },
+ [497] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_HM07", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_HM07" },
+ [498] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_FULL_RESTORE", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_FULL_RESTORE" },
+ [499] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_NEVER_MELT_ICE", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_NEVER_MELT_ICE" },
+ [500] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_BIG_PEARL", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_BIG_PEARL" },
+ [501] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_TM36", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_TM36" },
+ [502] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_PEARL", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_PEARL" },
+ [503] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_UP_GRADE", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_UP_GRADE" },
+ [504] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM10_SILK_SCARF", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM10_SILK_SCARF" },
+ [505] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM11_LAX_INCENSE", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM11_LAX_INCENSE" },
+ [506] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM12_SEA_INCENSE", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM12_SEA_INCENSE" },
+ [507] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM13_MAX_REVIVE", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM13_MAX_REVIVE" },
+ [508] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM14_RARE_CANDY", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM14_RARE_CANDY" },
+ [509] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_HOUSE_LUCKY_PUNCH", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_HOUSE_LUCKY_PUNCH" },
+ [510] = { "FLAG_HIDE_SILPH_CO_4F_TM41", "HIDE_SILPH_CO_4F_TM41" },
+ [511] = { "FLAG_0x1FF", "0x1FF" },
+ [512] = { "FLAG_0x200", "0x200" },
+ [513] = { "FLAG_0x201", "0x201" },
+ [514] = { "FLAG_0x202", "0x202" },
+ [515] = { "FLAG_0x203", "0x203" },
+ [516] = { "FLAG_0x204", "0x204" },
+ [517] = { "FLAG_0x205", "0x205" },
+ [518] = { "FLAG_0x206", "0x206" },
+ [519] = { "FLAG_0x207", "0x207" },
+ [520] = { "FLAG_0x208", "0x208" },
+ [521] = { "FLAG_0x209", "0x209" },
+ [522] = { "FLAG_0x20A", "0x20A" },
+ [523] = { "FLAG_0x20B", "0x20B" },
+ [524] = { "FLAG_0x20C", "0x20C" },
+ [525] = { "FLAG_0x20D", "0x20D" },
+ [526] = { "FLAG_0x20E", "0x20E" },
+ [527] = { "FLAG_0x20F", "0x20F" },
+ [528] = { "FLAG_0x210", "0x210" },
+ [529] = { "FLAG_0x211", "0x211" },
+ [530] = { "FLAG_0x212", "0x212" },
+ [531] = { "FLAG_0x213", "0x213" },
+ [532] = { "FLAG_0x214", "0x214" },
+ [533] = { "FLAG_0x215", "0x215" },
+ [534] = { "FLAG_0x216", "0x216" },
+ [535] = { "FLAG_0x217", "0x217" },
+ [536] = { "FLAG_0x218", "0x218" },
+ [537] = { "FLAG_0x219", "0x219" },
+ [538] = { "FLAG_0x21A", "0x21A" },
+ [539] = { "FLAG_0x21B", "0x21B" },
+ [540] = { "FLAG_0x21C", "0x21C" },
+ [541] = { "FLAG_0x21D", "0x21D" },
+ [542] = { "FLAG_0x21E", "0x21E" },
+ [543] = { "FLAG_0x21F", "0x21F" },
+ [544] = { "FLAG_0x220", "0x220" },
+ [545] = { "FLAG_0x221", "0x221" },
+ [546] = { "FLAG_0x222", "0x222" },
+ [547] = { "FLAG_0x223", "0x223" },
+ [548] = { "FLAG_0x224", "0x224" },
+ [549] = { "FLAG_0x225", "0x225" },
+ [550] = { "FLAG_0x226", "0x226" },
+ [551] = { "FLAG_0x227", "0x227" },
+ [552] = { "FLAG_0x228", "0x228" },
+ [553] = { "FLAG_0x229", "0x229" },
+ [554] = { "FLAG_0x22A", "0x22A" },
+ [555] = { "FLAG_0x22B", "0x22B" },
+ [556] = { "FLAG_0x22C", "0x22C" },
+ [557] = { "FLAG_0x22D", "0x22D" },
+ [558] = { "FLAG_0x22E", "0x22E" },
+ [559] = { "FLAG_0x22F", "0x22F" },
+ [560] = { "FLAG_GOT_POTION_ON_ROUTE_1", "GOT_POTION_ON_ROUTE_1", "STORY_FLAGS_START" },
+ [561] = { "FLAG_GOT_TM34_FROM_SURGE", "GOT_TM34_FROM_SURGE" },
+ [562] = { "FLAG_GOT_FOSSIL_FROM_MT_MOON", "GOT_FOSSIL_FROM_MT_MOON" },
+ [563] = { "FLAG_HELPED_BILL_IN_SEA_COTTAGE", "HELPED_BILL_IN_SEA_COTTAGE" },
+ [564] = { "FLAG_GOT_SS_TICKET", "GOT_SS_TICKET" },
+ [565] = { "FLAG_GOT_SS_TICKET_DUP", "GOT_SS_TICKET_DUP" },
+ [566] = { "FLAG_GOT_TM42_AT_MEMORIAL_PILLAR", "GOT_TM42_AT_MEMORIAL_PILLAR" },
+ [567] = { "FLAG_GOT_HM01", "GOT_HM01" },
+ [568] = { "FLAG_GOT_HM02", "GOT_HM02" },
+ [569] = { "FLAG_GOT_HM03", "GOT_HM03" },
+ [570] = { "FLAG_GOT_HM04", "GOT_HM04" },
+ [571] = { "FLAG_GOT_HM05", "GOT_HM05" },
+ [572] = { "FLAG_RESCUED_MR_FUJI", "RESCUED_MR_FUJI" },
+ [573] = { "FLAG_GOT_POKE_FLUTE", "GOT_POKE_FLUTE" },
+ [574] = { "FLAG_0x23E", "0x23E" },
+ [575] = { "FLAG_GOT_TM28_FROM_ROCKET", "GOT_TM28_FROM_ROCKET" },
+ [576] = { "FLAG_GOT_OLD_ROD", "GOT_OLD_ROD" },
+ [577] = { "FLAG_GOT_BIKE_VOUCHER", "GOT_BIKE_VOUCHER" },
+ [578] = { "FLAG_0x242", "0x242" },
+ [579] = { "FLAG_GOT_COIN_CASE", "GOT_COIN_CASE" },
+ [580] = { "FLAG_GOT_GOOD_ROD", "GOT_GOOD_ROD" },
+ [581] = { "FLAG_GOT_TM29_FROM_MR_PSYCHIC", "GOT_TM29_FROM_MR_PSYCHIC" },
+ [582] = { "FLAG_GOT_LAPRAS_FROM_SILPH", "GOT_LAPRAS_FROM_SILPH" },
+ [583] = { "FLAG_GOT_POKEBALLS_FROM_OAK_AFTER_22_RIVAL", "GOT_POKEBALLS_FROM_OAK_AFTER_22_RIVAL" },
+ [584] = { "FLAG_DID_MIMIEN_TRADE", "DID_MIMIEN_TRADE" },
+ [585] = { "FLAG_BOUGHT_MAGIKARP", "BOUGHT_MAGIKARP" },
+ [586] = { "FLAG_DID_ZYNX_TRADE", "DID_ZYNX_TRADE" },
+ [587] = { "FLAG_DID_MS_NIDO_TRADE", "DID_MS_NIDO_TRADE" },
+ [588] = { "FLAG_0x24C", "0x24C" },
+ [589] = { "FLAG_DID_CH_DING_TRADE", "DID_CH_DING_TRADE" },
+ [590] = { "FLAG_GOT_TM38_FROM_BLAINE", "GOT_TM38_FROM_BLAINE" },
+ [591] = { "FLAG_OAK_SKIP_22_RIVAL_CHECK", "OAK_SKIP_22_RIVAL_CHECK" },
+ [592] = { "FLAG_GOT_MASTER_BALL_FROM_SILPH", "GOT_MASTER_BALL_FROM_SILPH" },
+ [593] = { "FLAG_DID_NINA_TRADE", "DID_NINA_TRADE" },
+ [594] = { "FLAG_GOT_ITEMFINDER", "GOT_ITEMFINDER" },
+ [595] = { "FLAG_WOKE_UP_ROUTE_12_SNORLAX", "WOKE_UP_ROUTE_12_SNORLAX" },
+ [596] = { "FLAG_GOT_TM39_FROM_BROCK", "GOT_TM39_FROM_BROCK" },
+ [597] = { "FLAG_GOT_SUPER_ROD", "GOT_SUPER_ROD" },
+ [598] = { "FLAG_GOT_EXP_SHARE_FROM_OAKS_AIDE", "GOT_EXP_SHARE_FROM_OAKS_AIDE" },
+ [599] = { "FLAG_DID_MARC_TRADE", "DID_MARC_TRADE" },
+ [600] = { "FLAG_BEAT_RIVAL_IN_OAKS_LAB", "BEAT_RIVAL_IN_OAKS_LAB" },
+ [601] = { "FLAG_GOT_TM06_FROM_KOGA", "GOT_TM06_FROM_KOGA" },
+ [602] = { "FLAG_0x25A", "0x25A" },
+ [603] = { "FLAG_GOT_TM27", "GOT_TM27" },
+ [604] = { "FLAG_0x25C", "0x25C" },
+ [605] = { "FLAG_0x25D", "0x25D" },
+ [606] = { "FLAG_GOT_OLD_AMBER", "GOT_OLD_AMBER" },
+ [607] = { "FLAG_0x25F", "0x25F" },
+ [608] = { "FLAG_0x260", "0x260" },
+ [609] = { "FLAG_0x261", "0x261" },
+ [610] = { "FLAG_0x262", "0x262" },
+ [611] = { "FLAG_GOT_EEVEE", "GOT_EEVEE" },
+ [612] = { "FLAG_FOUND_BOTH_VERMILION_GYM_SWITCHES", "FOUND_BOTH_VERMILION_GYM_SWITCHES" },
+ [613] = { "FLAG_CINNABAR_GYM_QUIZ_1", "CINNABAR_GYM_QUIZ_1" },
+ [614] = { "FLAG_PENDING_DAYCARE_EGG", "PENDING_DAYCARE_EGG" },
+ [615] = { "FLAG_CINNABAR_GYM_QUIZ_2", "CINNABAR_GYM_QUIZ_2" },
+ [616] = { "FLAG_CINNABAR_GYM_QUIZ_3", "CINNABAR_GYM_QUIZ_3" },
+ [617] = { "FLAG_CINNABAR_GYM_QUIZ_4", "CINNABAR_GYM_QUIZ_4" },
+ [618] = { "FLAG_CINNABAR_GYM_QUIZ_5", "CINNABAR_GYM_QUIZ_5" },
+ [619] = { "FLAG_CINNABAR_GYM_QUIZ_6", "CINNABAR_GYM_QUIZ_6" },
+ [620] = { "FLAG_POKEMON_MANSION_SWITCH_STATE", "POKEMON_MANSION_SWITCH_STATE" },
+ [621] = { "FLAG_OPENED_ROCKET_HIDEOUT", "OPENED_ROCKET_HIDEOUT" },
+ [622] = { "FLAG_GOT_10_COINS_FROM_GAMBLER", "GOT_10_COINS_FROM_GAMBLER" },
+ [623] = { "FLAG_GOT_20_COINS_FROM_GAMBLER", "GOT_20_COINS_FROM_GAMBLER" },
+ [624] = { "FLAG_GOT_20_COINS_FROM_GAMBLER_2", "GOT_20_COINS_FROM_GAMBLER_2" },
+ [625] = { "FLAG_GOT_BICYCLE", "GOT_BICYCLE" },
+ [626] = { "FLAG_GOT_DOME_FOSSIL", "GOT_DOME_FOSSIL" },
+ [627] = { "FLAG_GOT_HELIX_FOSSIL", "GOT_HELIX_FOSSIL" },
+ [628] = { "FLAG_DID_ESPHERE_TRADE", "DID_ESPHERE_TRADE" },
+ [629] = { "FLAG_DID_TANGENY_TRADE", "DID_TANGENY_TRADE" },
+ [630] = { "FLAG_DID_SEELOR_TRADE", "DID_SEELOR_TRADE" },
+ [631] = { "FLAG_0x277", "0x277" },
+ [632] = { "FLAG_GOT_HITMON_FROM_DOJO", "GOT_HITMON_FROM_DOJO" },
+ [633] = { "FLAG_0x279", "0x279" },
+ [634] = { "FLAG_SILPH_2F_DOOR_1", "SILPH_2F_DOOR_1" },
+ [635] = { "FLAG_SILPH_2F_DOOR_2", "SILPH_2F_DOOR_2" },
+ [636] = { "FLAG_SILPH_3F_DOOR_1", "SILPH_3F_DOOR_1" },
+ [637] = { "FLAG_SILPH_3F_DOOR_2", "SILPH_3F_DOOR_2" },
+ [638] = { "FLAG_SILPH_4F_DOOR_1", "SILPH_4F_DOOR_1" },
+ [639] = { "FLAG_SILPH_4F_DOOR_2", "SILPH_4F_DOOR_2" },
+ [640] = { "FLAG_SILPH_5F_DOOR_1", "SILPH_5F_DOOR_1" },
+ [641] = { "FLAG_SILPH_5F_DOOR_2", "SILPH_5F_DOOR_2" },
+ [642] = { "FLAG_SILPH_5F_DOOR_3", "SILPH_5F_DOOR_3" },
+ [643] = { "FLAG_SILPH_6F_DOOR", "SILPH_6F_DOOR" },
+ [644] = { "FLAG_SILPH_7F_DOOR_1", "SILPH_7F_DOOR_1" },
+ [645] = { "FLAG_SILPH_7F_DOOR_2", "SILPH_7F_DOOR_2" },
+ [646] = { "FLAG_SILPH_7F_DOOR_3", "SILPH_7F_DOOR_3" },
+ [647] = { "FLAG_SILPH_8F_DOOR", "SILPH_8F_DOOR" },
+ [648] = { "FLAG_SILPH_9F_DOOR_1", "SILPH_9F_DOOR_1" },
+ [649] = { "FLAG_SILPH_9F_DOOR_2", "SILPH_9F_DOOR_2" },
+ [650] = { "FLAG_SILPH_9F_DOOR_3", "SILPH_9F_DOOR_3" },
+ [651] = { "FLAG_SILPH_9F_DOOR_4", "SILPH_9F_DOOR_4" },
+ [652] = { "FLAG_SILPH_10F_DOOR", "SILPH_10F_DOOR" },
+ [653] = { "FLAG_SILPH_11F_DOOR", "SILPH_11F_DOOR" },
+ [654] = { "FLAG_0x28E", "0x28E" },
+ [655] = { "FLAG_0x28F", "0x28F" },
+ [656] = { "FLAG_MET_STICKER_MAN", "MET_STICKER_MAN" },
+ [657] = { "FLAG_PALLET_LADY_NOT_BLOCKING_SIGN", "PALLET_LADY_NOT_BLOCKING_SIGN" },
+ [658] = { "FLAG_GOT_VS_SEEKER", "GOT_VS_SEEKER" },
+ [659] = { "FLAG_GOT_TM19_FROM_ERIKA", "GOT_TM19_FROM_ERIKA" },
+ [660] = { "FLAG_GOT_TM33_FROM_THIRSTY_GIRL", "GOT_TM33_FROM_THIRSTY_GIRL" },
+ [661] = { "FLAG_GOT_TM20_FROM_THIRSTY_GIRL", "GOT_TM20_FROM_THIRSTY_GIRL" },
+ [662] = { "FLAG_GOT_TM16_FROM_THIRSTY_GIRL", "GOT_TM16_FROM_THIRSTY_GIRL" },
+ [663] = { "FLAG_GOT_TM03_FROM_MISTY", "GOT_TM03_FROM_MISTY" },
+ [664] = { "FLAG_GOT_TM26_FROM_GIOVANNI", "GOT_TM26_FROM_GIOVANNI" },
+ [665] = { "FLAG_0x299", "0x299" },
+ [666] = { "FLAG_GOT_TM04_FROM_SABRINA", "GOT_TM04_FROM_SABRINA" },
+ [667] = { "FLAG_GOT_FAME_CHECKER", "GOT_FAME_CHECKER" },
+ [668] = { "FLAG_GOT_RECORD_SETTING_MAGIKARP", "GOT_RECORD_SETTING_MAGIKARP" },
+ [669] = { "FLAG_TWO_ISLAND_SHOP_INTRODUCED", "TWO_ISLAND_SHOP_INTRODUCED" },
+ [670] = { "FLAG_TWO_ISLAND_SHOP_EXPANDED_1", "TWO_ISLAND_SHOP_EXPANDED_1" },
+ [671] = { "FLAG_TWO_ISLAND_SHOP_EXPANDED_2", "TWO_ISLAND_SHOP_EXPANDED_2" },
+ [672] = { "FLAG_TWO_ISLAND_SHOP_EXPANDED_3", "TWO_ISLAND_SHOP_EXPANDED_3" },
+ [673] = { "FLAG_SEVII_DETOUR_FINISHED", "SEVII_DETOUR_FINISHED" },
+ [674] = { "FLAG_VISITED_TWO_ISLAND", "VISITED_TWO_ISLAND" },
+ [675] = { "FLAG_RESCUED_LOSTELLE", "RESCUED_LOSTELLE" },
+ [676] = { "FLAG_0x2A4", "0x2A4" },
+ [677] = { "FLAG_CAN_USE_ROCKET_HIDEOUT_LIFT", "CAN_USE_ROCKET_HIDEOUT_LIFT" },
+ [678] = { "FLAG_GOT_TEA", "GOT_TEA" },
+ [679] = { "FLAG_RECEIVED_AURORA_TICKET", "RECEIVED_AURORA_TICKET" },
+ [680] = { "FLAG_RECEIVED_MYSTIC_TICKET", "RECEIVED_MYSTIC_TICKET" },
+ [681] = { "FLAG_RECEIVED_OLD_SEA_MAP", "RECEIVED_OLD_SEA_MAP" },
+ [682] = { "FLAG_WONDER_CARD_UNUSED_1", "WONDER_CARD_UNUSED_1" },
+ [683] = { "FLAG_WONDER_CARD_UNUSED_2", "WONDER_CARD_UNUSED_2" },
+ [684] = { "FLAG_WONDER_CARD_UNUSED_3", "WONDER_CARD_UNUSED_3" },
+ [685] = { "FLAG_WONDER_CARD_UNUSED_4", "WONDER_CARD_UNUSED_4" },
+ [686] = { "FLAG_WONDER_CARD_UNUSED_5", "WONDER_CARD_UNUSED_5" },
+ [687] = { "FLAG_WONDER_CARD_UNUSED_6", "WONDER_CARD_UNUSED_6" },
+ [688] = { "FLAG_WONDER_CARD_UNUSED_7", "WONDER_CARD_UNUSED_7" },
+ [689] = { "FLAG_WONDER_CARD_UNUSED_8", "WONDER_CARD_UNUSED_8" },
+ [690] = { "FLAG_WONDER_CARD_UNUSED_9", "WONDER_CARD_UNUSED_9" },
+ [691] = { "FLAG_WONDER_CARD_UNUSED_10", "WONDER_CARD_UNUSED_10" },
+ [692] = { "FLAG_WONDER_CARD_UNUSED_11", "WONDER_CARD_UNUSED_11" },
+ [693] = { "FLAG_WONDER_CARD_UNUSED_12", "WONDER_CARD_UNUSED_12" },
+ [694] = { "FLAG_WONDER_CARD_UNUSED_13", "WONDER_CARD_UNUSED_13" },
+ [695] = { "FLAG_WONDER_CARD_UNUSED_14", "WONDER_CARD_UNUSED_14" },
+ [696] = { "FLAG_WONDER_CARD_UNUSED_15", "WONDER_CARD_UNUSED_15" },
+ [697] = { "FLAG_WONDER_CARD_UNUSED_16", "WONDER_CARD_UNUSED_16" },
+ [698] = { "FLAG_WONDER_CARD_UNUSED_17", "WONDER_CARD_UNUSED_17" },
+ [699] = { "FLAG_GOT_POWDER_JAR", "GOT_POWDER_JAR" },
+ [700] = { "FLAG_FOUGHT_MEWTWO", "FOUGHT_MEWTWO" },
+ [701] = { "FLAG_FOUGHT_MOLTRES", "FOUGHT_MOLTRES" },
+ [702] = { "FLAG_FOUGHT_ARTICUNO", "FOUGHT_ARTICUNO" },
+ [703] = { "FLAG_FOUGHT_ZAPDOS", "FOUGHT_ZAPDOS" },
+ [704] = { "FLAG_TUTOR_DOUBLE_EDGE", "TUTOR_DOUBLE_EDGE" },
+ [705] = { "FLAG_TUTOR_THUNDER_WAVE", "TUTOR_THUNDER_WAVE" },
+ [706] = { "FLAG_TUTOR_ROCK_SLIDE", "TUTOR_ROCK_SLIDE" },
+ [707] = { "FLAG_TUTOR_EXPLOSION", "TUTOR_EXPLOSION" },
+ [708] = { "FLAG_TUTOR_MEGA_PUNCH", "TUTOR_MEGA_PUNCH" },
+ [709] = { "FLAG_TUTOR_MEGA_KICK", "TUTOR_MEGA_KICK" },
+ [710] = { "FLAG_TUTOR_DREAM_EATER", "TUTOR_DREAM_EATER" },
+ [711] = { "FLAG_TUTOR_SOFT_BOILED", "TUTOR_SOFT_BOILED" },
+ [712] = { "FLAG_TUTOR_SUBSTITUTE", "TUTOR_SUBSTITUTE" },
+ [713] = { "FLAG_TUTOR_SWORDS_DANCE", "TUTOR_SWORDS_DANCE" },
+ [714] = { "FLAG_TUTOR_SEISMIC_TOSS", "TUTOR_SEISMIC_TOSS" },
+ [715] = { "FLAG_TUTOR_COUNTER", "TUTOR_COUNTER" },
+ [716] = { "FLAG_TUTOR_METRONOME", "TUTOR_METRONOME" },
+ [717] = { "FLAG_TUTOR_MIMIC", "TUTOR_MIMIC" },
+ [718] = { "FLAG_TUTOR_BODY_SLAM", "TUTOR_BODY_SLAM" },
+ [719] = { "FLAG_VISITED_OAKS_LAB", "VISITED_OAKS_LAB" },
+ [720] = { "FLAG_FOUGHT_POWER_PLANT_ELECTRODE_1", "FOUGHT_POWER_PLANT_ELECTRODE_1" },
+ [721] = { "FLAG_FOUGHT_POWER_PLANT_ELECTRODE_2", "FOUGHT_POWER_PLANT_ELECTRODE_2" },
+ [722] = { "FLAG_STOPPED_SEAFOAM_B3F_CURRENT", "STOPPED_SEAFOAM_B3F_CURRENT" },
+ [723] = { "FLAG_STOPPED_SEAFOAM_B4F_CURRENT", "STOPPED_SEAFOAM_B4F_CURRENT" },
+ [724] = { "FLAG_TALKED_TO_LORELEI_AFTER_WAREHOUSE", "TALKED_TO_LORELEI_AFTER_WAREHOUSE" },
+ [725] = { "FLAG_DEFEATED_ROCKETS_IN_WAREHOUSE", "DEFEATED_ROCKETS_IN_WAREHOUSE" },
+ [726] = { "FLAG_UNLOCKED_ROCKET_WAREHOUSE", "UNLOCKED_ROCKET_WAREHOUSE" },
+ [727] = { "FLAG_NO_ROOM_FOR_TM42_AT_MEMORIAL_PILLAR", "NO_ROOM_FOR_TM42_AT_MEMORIAL_PILLAR" },
+ [728] = { "FLAG_LEARNED_YES_NAH_CHANSEY", "LEARNED_YES_NAH_CHANSEY" },
+ [729] = { "FLAG_GOT_NEST_BALL_FROM_WATER_PATH_HOUSE_1", "GOT_NEST_BALL_FROM_WATER_PATH_HOUSE_1" },
+ [730] = { "FLAG_GOT_TOGEPI_EGG", "GOT_TOGEPI_EGG" },
+ [731] = { "FLAG_NO_ROOM_FOR_TOGEPI_EGG", "NO_ROOM_FOR_TOGEPI_EGG" },
+ [732] = { "FLAG_RECOVERED_SAPPHIRE", "RECOVERED_SAPPHIRE" },
+ [733] = { "FLAG_GOT_RUBY", "GOT_RUBY" },
+ [734] = { "FLAG_TUTOR_FRENZY_PLANT", "TUTOR_FRENZY_PLANT" },
+ [735] = { "FLAG_TUTOR_BLAST_BURN", "TUTOR_BLAST_BURN" },
+ [736] = { "FLAG_TUTOR_HYDRO_CANNON", "TUTOR_HYDRO_CANNON" },
+ [737] = { "FLAG_LEARNED_ALL_MOVES_AT_CAPE_BRINK", "LEARNED_ALL_MOVES_AT_CAPE_BRINK" },
+ [738] = { "FLAG_GOT_NUGGET_FROM_DUNSPARCE_TUNNEL", "GOT_NUGGET_FROM_DUNSPARCE_TUNNEL" },
+ [739] = { "FLAG_USED_CUT_ON_RUIN_VALLEY_BRAILLE", "USED_CUT_ON_RUIN_VALLEY_BRAILLE" },
+ [740] = { "FLAG_FOUGHT_DEOXYS", "FOUGHT_DEOXYS" },
+ [741] = { "FLAG_0x2E5", "0x2E5" },
+ [742] = { "FLAG_0x2E6", "0x2E6" },
+ [743] = { "FLAG_0x2E7", "0x2E7" },
+ [744] = { "FLAG_0x2E8", "0x2E8" },
+ [745] = { "FLAG_0x2E9", "0x2E9" },
+ [746] = { "FLAG_0x2EA", "0x2EA" },
+ [747] = { "FLAG_0x2EB", "0x2EB" },
+ [748] = { "FLAG_REVIVED_DOME", "REVIVED_DOME" },
+ [749] = { "FLAG_REVIVED_HELIX", "REVIVED_HELIX" },
+ [750] = { "FLAG_REVIVED_AMBER", "REVIVED_AMBER" },
+ [751] = { "FLAG_GOT_HM06", "GOT_HM06" },
+ [752] = { "FLAG_SHOWN_MYSTIC_TICKET", "SHOWN_MYSTIC_TICKET" },
+ [753] = { "FLAG_SHOWN_AURORA_TICKET", "SHOWN_AURORA_TICKET" },
+ [754] = { "FLAG_FOUGHT_LUGIA", "FOUGHT_LUGIA" },
+ [755] = { "FLAG_FOUGHT_HO_OH", "FOUGHT_HO_OH" },
+ [756] = { "FLAG_OAK_SAW_DEX_COMPLETION", "OAK_SAW_DEX_COMPLETION" },
+ [757] = { "FLAG_LUGIA_FLEW_AWAY", "LUGIA_FLEW_AWAY" },
+ [758] = { "FLAG_HO_OH_FLEW_AWAY", "HO_OH_FLEW_AWAY" },
+ [759] = { "FLAG_DEOXYS_FLEW_AWAY", "DEOXYS_FLEW_AWAY" },
+ [760] = { "FLAG_TALKED_TO_TEA_LADY_AFTER_HOF", "TALKED_TO_TEA_LADY_AFTER_HOF" },
+ [761] = { "FLAG_TALKED_TO_OAKS_AIDE_IN_VERMILION", "TALKED_TO_OAKS_AIDE_IN_VERMILION" },
+ [762] = { "FLAG_GOT_EVERSTONE_FROM_OAKS_AIDE", "GOT_EVERSTONE_FROM_OAKS_AIDE" },
+ [763] = { "FLAG_GOT_MOON_STONE_FROM_JOYFUL_GAME_CORNER", "GOT_MOON_STONE_FROM_JOYFUL_GAME_CORNER" },
+ [764] = { "FLAG_GOT_FULL_RESTORE_FROM_THREE_ISLAND_DEFENDER", "GOT_FULL_RESTORE_FROM_THREE_ISLAND_DEFENDER" },
+ [765] = { "FLAG_GOT_AMULET_COIN_FROM_OAKS_AIDE", "GOT_AMULET_COIN_FROM_OAKS_AIDE" },
+ [766] = { "FLAG_NO_ROOM_FOR_JOYFUL_GAME_CORNER_MOON_STONE", "NO_ROOM_FOR_JOYFUL_GAME_CORNER_MOON_STONE" },
+ [767] = { "FLAG_OAKS_RATING_IS_VIA_PC", "OAKS_RATING_IS_VIA_PC" },
+ [768] = { "FLAG_0x300", "0x300" },
+ [769] = { "FLAG_0x301", "0x301" },
+ [770] = { "FLAG_0x302", "0x302" },
+ [771] = { "FLAG_0x303", "0x303" },
+ [772] = { "FLAG_0x304", "0x304" },
+ [773] = { "FLAG_0x305", "0x305" },
+ [774] = { "FLAG_0x306", "0x306" },
+ [775] = { "FLAG_0x307", "0x307" },
+ [776] = { "FLAG_0x308", "0x308" },
+ [777] = { "FLAG_0x309", "0x309" },
+ [778] = { "FLAG_0x30A", "0x30A" },
+ [779] = { "FLAG_0x30B", "0x30B" },
+ [780] = { "FLAG_0x30C", "0x30C" },
+ [781] = { "FLAG_0x30D", "0x30D" },
+ [782] = { "FLAG_0x30E", "0x30E" },
+ [783] = { "FLAG_0x30F", "0x30F" },
+ [784] = { "FLAG_0x310", "0x310" },
+ [785] = { "FLAG_0x311", "0x311" },
+ [786] = { "FLAG_0x312", "0x312" },
+ [787] = { "FLAG_0x313", "0x313" },
+ [788] = { "FLAG_0x314", "0x314" },
+ [789] = { "FLAG_0x315", "0x315" },
+ [790] = { "FLAG_0x316", "0x316" },
+ [791] = { "FLAG_0x317", "0x317" },
+ [792] = { "FLAG_0x318", "0x318" },
+ [793] = { "FLAG_0x319", "0x319" },
+ [794] = { "FLAG_0x31A", "0x31A" },
+ [795] = { "FLAG_0x31B", "0x31B" },
+ [796] = { "FLAG_0x31C", "0x31C" },
+ [797] = { "FLAG_0x31D", "0x31D" },
+ [798] = { "FLAG_0x31E", "0x31E" },
+ [799] = { "FLAG_0x31F", "0x31F" },
+ [800] = { "FLAG_0x320", "0x320" },
+ [801] = { "FLAG_0x321", "0x321" },
+ [802] = { "FLAG_0x322", "0x322" },
+ [803] = { "FLAG_0x323", "0x323" },
+ [804] = { "FLAG_0x324", "0x324" },
+ [805] = { "FLAG_0x325", "0x325" },
+ [806] = { "FLAG_0x326", "0x326" },
+ [807] = { "FLAG_0x327", "0x327" },
+ [808] = { "FLAG_0x328", "0x328" },
+ [809] = { "FLAG_0x329", "0x329" },
+ [810] = { "FLAG_0x32A", "0x32A" },
+ [811] = { "FLAG_0x32B", "0x32B" },
+ [812] = { "FLAG_0x32C", "0x32C" },
+ [813] = { "FLAG_0x32D", "0x32D" },
+ [814] = { "FLAG_0x32E", "0x32E" },
+ [815] = { "FLAG_0x32F", "0x32F" },
+ [816] = { "FLAG_0x330", "0x330" },
+ [817] = { "FLAG_0x331", "0x331" },
+ [818] = { "FLAG_0x332", "0x332" },
+ [819] = { "FLAG_0x333", "0x333" },
+ [820] = { "FLAG_0x334", "0x334" },
+ [821] = { "FLAG_0x335", "0x335" },
+ [822] = { "FLAG_0x336", "0x336" },
+ [823] = { "FLAG_0x337", "0x337" },
+ [824] = { "FLAG_0x338", "0x338" },
+ [825] = { "FLAG_0x339", "0x339" },
+ [826] = { "FLAG_0x33A", "0x33A" },
+ [827] = { "FLAG_0x33B", "0x33B" },
+ [828] = { "FLAG_0x33C", "0x33C" },
+ [829] = { "FLAG_0x33D", "0x33D" },
+ [830] = { "FLAG_0x33E", "0x33E" },
+ [831] = { "FLAG_0x33F", "0x33F" },
+ [832] = { "FLAG_0x340", "0x340" },
+ [833] = { "FLAG_0x341", "0x341" },
+ [834] = { "FLAG_0x342", "0x342" },
+ [835] = { "FLAG_0x343", "0x343" },
+ [836] = { "FLAG_0x344", "0x344" },
+ [837] = { "FLAG_0x345", "0x345" },
+ [838] = { "FLAG_0x346", "0x346" },
+ [839] = { "FLAG_0x347", "0x347" },
+ [840] = { "FLAG_0x348", "0x348" },
+ [841] = { "FLAG_0x349", "0x349" },
+ [842] = { "FLAG_0x34A", "0x34A" },
+ [843] = { "FLAG_0x34B", "0x34B" },
+ [844] = { "FLAG_0x34C", "0x34C" },
+ [845] = { "FLAG_0x34D", "0x34D" },
+ [846] = { "FLAG_0x34E", "0x34E" },
+ [847] = { "FLAG_0x34F", "0x34F" },
+ [848] = { "FLAG_0x350", "0x350" },
+ [849] = { "FLAG_0x351", "0x351" },
+ [850] = { "FLAG_0x352", "0x352" },
+ [851] = { "FLAG_0x353", "0x353" },
+ [852] = { "FLAG_0x354", "0x354" },
+ [853] = { "FLAG_0x355", "0x355" },
+ [854] = { "FLAG_0x356", "0x356" },
+ [855] = { "FLAG_0x357", "0x357" },
+ [856] = { "FLAG_0x358", "0x358" },
+ [857] = { "FLAG_0x359", "0x359" },
+ [858] = { "FLAG_0x35A", "0x35A" },
+ [859] = { "FLAG_0x35B", "0x35B" },
+ [860] = { "FLAG_0x35C", "0x35C" },
+ [861] = { "FLAG_0x35D", "0x35D" },
+ [862] = { "FLAG_0x35E", "0x35E" },
+ [863] = { "FLAG_0x35F", "0x35F" },
+ [864] = { "FLAG_0x360", "0x360" },
+ [865] = { "FLAG_0x361", "0x361" },
+ [866] = { "FLAG_0x362", "0x362" },
+ [867] = { "FLAG_0x363", "0x363" },
+ [868] = { "FLAG_0x364", "0x364" },
+ [869] = { "FLAG_0x365", "0x365" },
+ [870] = { "FLAG_0x366", "0x366" },
+ [871] = { "FLAG_0x367", "0x367" },
+ [872] = { "FLAG_0x368", "0x368" },
+ [873] = { "FLAG_0x369", "0x369" },
+ [874] = { "FLAG_0x36A", "0x36A" },
+ [875] = { "FLAG_0x36B", "0x36B" },
+ [876] = { "FLAG_0x36C", "0x36C" },
+ [877] = { "FLAG_0x36D", "0x36D" },
+ [878] = { "FLAG_0x36E", "0x36E" },
+ [879] = { "FLAG_0x36F", "0x36F" },
+ [880] = { "FLAG_0x370", "0x370" },
+ [881] = { "FLAG_0x371", "0x371" },
+ [882] = { "FLAG_0x372", "0x372" },
+ [883] = { "FLAG_0x373", "0x373" },
+ [884] = { "FLAG_0x374", "0x374" },
+ [885] = { "FLAG_0x375", "0x375" },
+ [886] = { "FLAG_0x376", "0x376" },
+ [887] = { "FLAG_0x377", "0x377" },
+ [888] = { "FLAG_0x378", "0x378" },
+ [889] = { "FLAG_0x379", "0x379" },
+ [890] = { "FLAG_0x37A", "0x37A" },
+ [891] = { "FLAG_0x37B", "0x37B" },
+ [892] = { "FLAG_0x37C", "0x37C" },
+ [893] = { "FLAG_0x37D", "0x37D" },
+ [894] = { "FLAG_0x37E", "0x37E" },
+ [895] = { "FLAG_0x37F", "0x37F" },
+ [896] = { "FLAG_0x380", "0x380" },
+ [897] = { "FLAG_0x381", "0x381" },
+ [898] = { "FLAG_0x382", "0x382" },
+ [899] = { "FLAG_0x383", "0x383" },
+ [900] = { "FLAG_0x384", "0x384" },
+ [901] = { "FLAG_0x385", "0x385" },
+ [902] = { "FLAG_0x386", "0x386" },
+ [903] = { "FLAG_0x387", "0x387" },
+ [904] = { "FLAG_0x388", "0x388" },
+ [905] = { "FLAG_0x389", "0x389" },
+ [906] = { "FLAG_0x38A", "0x38A" },
+ [907] = { "FLAG_0x38B", "0x38B" },
+ [908] = { "FLAG_0x38C", "0x38C" },
+ [909] = { "FLAG_0x38D", "0x38D" },
+ [910] = { "FLAG_0x38E", "0x38E" },
+ [911] = { "FLAG_0x38F", "0x38F" },
+ [912] = { "FLAG_0x390", "0x390" },
+ [913] = { "FLAG_0x391", "0x391" },
+ [914] = { "FLAG_0x392", "0x392" },
+ [915] = { "FLAG_0x393", "0x393" },
+ [916] = { "FLAG_0x394", "0x394" },
+ [917] = { "FLAG_0x395", "0x395" },
+ [918] = { "FLAG_0x396", "0x396" },
+ [919] = { "FLAG_0x397", "0x397" },
+ [920] = { "FLAG_0x398", "0x398" },
+ [921] = { "FLAG_0x399", "0x399" },
+ [922] = { "FLAG_0x39A", "0x39A" },
+ [923] = { "FLAG_0x39B", "0x39B" },
+ [924] = { "FLAG_0x39C", "0x39C" },
+ [925] = { "FLAG_0x39D", "0x39D" },
+ [926] = { "FLAG_0x39E", "0x39E" },
+ [927] = { "FLAG_0x39F", "0x39F" },
+ [928] = { "FLAG_0x3A0", "0x3A0" },
+ [929] = { "FLAG_0x3A1", "0x3A1" },
+ [930] = { "FLAG_0x3A2", "0x3A2" },
+ [931] = { "FLAG_0x3A3", "0x3A3" },
+ [932] = { "FLAG_0x3A4", "0x3A4" },
+ [933] = { "FLAG_0x3A5", "0x3A5" },
+ [934] = { "FLAG_0x3A6", "0x3A6" },
+ [935] = { "FLAG_0x3A7", "0x3A7" },
+ [936] = { "FLAG_0x3A8", "0x3A8" },
+ [937] = { "FLAG_0x3A9", "0x3A9" },
+ [938] = { "FLAG_0x3AA", "0x3AA" },
+ [939] = { "FLAG_0x3AB", "0x3AB" },
+ [940] = { "FLAG_0x3AC", "0x3AC" },
+ [941] = { "FLAG_0x3AD", "0x3AD" },
+ [942] = { "FLAG_0x3AE", "0x3AE" },
+ [943] = { "FLAG_0x3AF", "0x3AF" },
+ [944] = { "FLAG_0x3B0", "0x3B0" },
+ [945] = { "FLAG_0x3B1", "0x3B1" },
+ [946] = { "FLAG_0x3B2", "0x3B2" },
+ [947] = { "FLAG_0x3B3", "0x3B3" },
+ [948] = { "FLAG_0x3B4", "0x3B4" },
+ [949] = { "FLAG_0x3B5", "0x3B5" },
+ [950] = { "FLAG_0x3B6", "0x3B6" },
+ [951] = { "FLAG_0x3B7", "0x3B7" },
+ [952] = { "FLAG_0x3B8", "0x3B8" },
+ [953] = { "FLAG_0x3B9", "0x3B9" },
+ [954] = { "FLAG_0x3BA", "0x3BA" },
+ [955] = { "FLAG_0x3BB", "0x3BB" },
+ [956] = { "FLAG_0x3BC", "0x3BC" },
+ [957] = { "FLAG_0x3BD", "0x3BD" },
+ [958] = { "FLAG_0x3BE", "0x3BE" },
+ [959] = { "FLAG_0x3BF", "0x3BF" },
+ [960] = { "FLAG_0x3C0", "0x3C0" },
+ [961] = { "FLAG_0x3C1", "0x3C1" },
+ [962] = { "FLAG_0x3C2", "0x3C2" },
+ [963] = { "FLAG_0x3C3", "0x3C3" },
+ [964] = { "FLAG_0x3C4", "0x3C4" },
+ [965] = { "FLAG_0x3C5", "0x3C5" },
+ [966] = { "FLAG_0x3C6", "0x3C6" },
+ [967] = { "FLAG_0x3C7", "0x3C7" },
+ [968] = { "FLAG_0x3C8", "0x3C8" },
+ [969] = { "FLAG_0x3C9", "0x3C9" },
+ [970] = { "FLAG_0x3CA", "0x3CA" },
+ [971] = { "FLAG_0x3CB", "0x3CB" },
+ [972] = { "FLAG_0x3CC", "0x3CC" },
+ [973] = { "FLAG_0x3CD", "0x3CD" },
+ [974] = { "FLAG_0x3CE", "0x3CE" },
+ [975] = { "FLAG_0x3CF", "0x3CF" },
+ [976] = { "FLAG_0x3D0", "0x3D0" },
+ [977] = { "FLAG_0x3D1", "0x3D1" },
+ [978] = { "FLAG_0x3D2", "0x3D2" },
+ [979] = { "FLAG_0x3D3", "0x3D3" },
+ [980] = { "FLAG_0x3D4", "0x3D4" },
+ [981] = { "FLAG_0x3D5", "0x3D5" },
+ [982] = { "FLAG_0x3D6", "0x3D6" },
+ [983] = { "FLAG_0x3D7", "0x3D7" },
+ [984] = { "FLAG_MYSTERY_GIFT_DONE", "MYSTERY_GIFT_DONE" },
+ [985] = { "FLAG_MYSTERY_GIFT_1", "MYSTERY_GIFT_1" },
+ [986] = { "FLAG_MYSTERY_GIFT_2", "MYSTERY_GIFT_2" },
+ [987] = { "FLAG_MYSTERY_GIFT_3", "MYSTERY_GIFT_3" },
+ [988] = { "FLAG_MYSTERY_GIFT_4", "MYSTERY_GIFT_4" },
+ [989] = { "FLAG_MYSTERY_GIFT_5", "MYSTERY_GIFT_5" },
+ [990] = { "FLAG_MYSTERY_GIFT_6", "MYSTERY_GIFT_6" },
+ [991] = { "FLAG_MYSTERY_GIFT_7", "MYSTERY_GIFT_7" },
+ [992] = { "FLAG_MYSTERY_GIFT_8", "MYSTERY_GIFT_8" },
+ [993] = { "FLAG_MYSTERY_GIFT_9", "MYSTERY_GIFT_9" },
+ [994] = { "FLAG_MYSTERY_GIFT_10", "MYSTERY_GIFT_10" },
+ [995] = { "FLAG_MYSTERY_GIFT_11", "MYSTERY_GIFT_11" },
+ [996] = { "FLAG_MYSTERY_GIFT_12", "MYSTERY_GIFT_12" },
+ [997] = { "FLAG_MYSTERY_GIFT_13", "MYSTERY_GIFT_13" },
+ [998] = { "FLAG_MYSTERY_GIFT_14", "MYSTERY_GIFT_14" },
+ [999] = { "FLAG_MYSTERY_GIFT_15", "MYSTERY_GIFT_15" },
+ [1000] = { "FLAG_HIDDEN_ITEMS_START", "FLAG_HIDDEN_ITEM_VIRIDIAN_FOREST_POTION", "HIDDEN_ITEMS_START", "HIDDEN_ITEM_VIRIDIAN_FOREST_POTION" },
+ [1001] = { "FLAG_HIDDEN_ITEM_VIRIDIAN_FOREST_ANTIDOTE", "HIDDEN_ITEM_VIRIDIAN_FOREST_ANTIDOTE" },
+ [1002] = { "FLAG_HIDDEN_ITEM_MT_MOON_B2F_MOON_STONE", "HIDDEN_ITEM_MT_MOON_B2F_MOON_STONE" },
+ [1003] = { "FLAG_HIDDEN_ITEM_MT_MOON_B2F_ETHER", "HIDDEN_ITEM_MT_MOON_B2F_ETHER" },
+ [1004] = { "FLAG_HIDDEN_ITEM_ROUTE25_ELIXIR", "HIDDEN_ITEM_ROUTE25_ELIXIR" },
+ [1005] = { "FLAG_HIDDEN_ITEM_ROUTE25_ETHER", "HIDDEN_ITEM_ROUTE25_ETHER" },
+ [1006] = { "FLAG_HIDDEN_ITEM_ROUTE9_ETHER", "HIDDEN_ITEM_ROUTE9_ETHER" },
+ [1007] = { "FLAG_HIDDEN_ITEM_UNUSED_0x07", "HIDDEN_ITEM_UNUSED_0x07" },
+ [1008] = { "FLAG_HIDDEN_ITEM_SSANNE_B1F_CORRIDOR_HYPER_POTION", "HIDDEN_ITEM_SSANNE_B1F_CORRIDOR_HYPER_POTION" },
+ [1009] = { "FLAG_HIDDEN_ITEM_ROUTE10_SUPER_POTION", "HIDDEN_ITEM_ROUTE10_SUPER_POTION" },
+ [1010] = { "FLAG_HIDDEN_ITEM_ROUTE10_MAX_ETHER", "HIDDEN_ITEM_ROUTE10_MAX_ETHER" },
+ [1011] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B1F_PP_UP", "HIDDEN_ITEM_ROCKET_HIDEOUT_B1F_PP_UP" },
+ [1012] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B3F_NUGGET", "HIDDEN_ITEM_ROCKET_HIDEOUT_B3F_NUGGET" },
+ [1013] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NEST_BALL", "HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NEST_BALL" },
+ [1014] = { "FLAG_HIDDEN_ITEM_POKEMON_TOWER_5F_BIG_MUSHROOM", "HIDDEN_ITEM_POKEMON_TOWER_5F_BIG_MUSHROOM" },
+ [1015] = { "FLAG_HIDDEN_ITEM_ROUTE13_PP_UP", "HIDDEN_ITEM_ROUTE13_PP_UP" },
+ [1016] = { "FLAG_HIDDEN_ITEM_UNUSED_0x10", "HIDDEN_ITEM_UNUSED_0x10" },
+ [1017] = { "FLAG_HIDDEN_ITEM_ROUTE17_RARE_CANDY", "HIDDEN_ITEM_ROUTE17_RARE_CANDY" },
+ [1018] = { "FLAG_HIDDEN_ITEM_ROUTE17_FULL_RESTORE", "HIDDEN_ITEM_ROUTE17_FULL_RESTORE" },
+ [1019] = { "FLAG_HIDDEN_ITEM_ROUTE17_PP_UP", "HIDDEN_ITEM_ROUTE17_PP_UP" },
+ [1020] = { "FLAG_HIDDEN_ITEM_ROUTE17_MAX_REVIVE", "HIDDEN_ITEM_ROUTE17_MAX_REVIVE" },
+ [1021] = { "FLAG_HIDDEN_ITEM_ROUTE17_MAX_ELIXIR", "HIDDEN_ITEM_ROUTE17_MAX_ELIXIR" },
+ [1022] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_CENTER_LEAF_STONE", "HIDDEN_ITEM_SAFARI_ZONE_CENTER_LEAF_STONE" },
+ [1023] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_WEST_REVIVE", "HIDDEN_ITEM_SAFARI_ZONE_WEST_REVIVE" },
+ [1024] = { "FLAG_HIDDEN_ITEM_SILPH_CO_5F_ELIXIR", "HIDDEN_ITEM_SILPH_CO_5F_ELIXIR" },
+ [1025] = { "FLAG_HIDDEN_ITEM_SILPH_CO_9F_MAX_POTION", "HIDDEN_ITEM_SILPH_CO_9F_MAX_POTION" },
+ [1026] = { "FLAG_HIDDEN_ITEM_SAFFRON_CITY_COPYCATS_HOUSE_2F_NUGGET", "HIDDEN_ITEM_SAFFRON_CITY_COPYCATS_HOUSE_2F_NUGGET" },
+ [1027] = { "FLAG_HIDDEN_ITEM_POWER_PLANT_MAX_ELIXIR", "HIDDEN_ITEM_POWER_PLANT_MAX_ELIXIR" },
+ [1028] = { "FLAG_HIDDEN_ITEM_POWER_PLANT_THUNDER_STONE", "HIDDEN_ITEM_POWER_PLANT_THUNDER_STONE" },
+ [1029] = { "FLAG_HIDDEN_ITEM_SEAFOAM_ISLANDS_B3F_NUGGET", "HIDDEN_ITEM_SEAFOAM_ISLANDS_B3F_NUGGET" },
+ [1030] = { "FLAG_HIDDEN_ITEM_SEAFOAM_ISLANDS_B4F_WATER_STONE", "HIDDEN_ITEM_SEAFOAM_ISLANDS_B4F_WATER_STONE" },
+ [1031] = { "FLAG_HIDDEN_ITEM_POKEMON_MANSION_1F_MOON_STONE", "HIDDEN_ITEM_POKEMON_MANSION_1F_MOON_STONE" },
+ [1032] = { "FLAG_HIDDEN_ITEM_POKEMON_MANSION_3F_RARE_CANDY", "HIDDEN_ITEM_POKEMON_MANSION_3F_RARE_CANDY" },
+ [1033] = { "FLAG_HIDDEN_ITEM_POKEMON_MANSION_B1F_ELIXIR", "HIDDEN_ITEM_POKEMON_MANSION_B1F_ELIXIR" },
+ [1034] = { "FLAG_HIDDEN_ITEM_ROUTE23_FULL_RESTORE", "HIDDEN_ITEM_ROUTE23_FULL_RESTORE" },
+ [1035] = { "FLAG_HIDDEN_ITEM_ROUTE23_ULTRA_BALL", "HIDDEN_ITEM_ROUTE23_ULTRA_BALL" },
+ [1036] = { "FLAG_HIDDEN_ITEM_ROUTE23_MAX_ETHER", "HIDDEN_ITEM_ROUTE23_MAX_ETHER" },
+ [1037] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL", "HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL" },
+ [1038] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_1F_FULL_RESTORE", "HIDDEN_ITEM_VICTORY_ROAD_1F_FULL_RESTORE" },
+ [1039] = { "FLAG_HIDDEN_ITEM_CERULEAN_CAVE_1F_ULTRA_BALL", "HIDDEN_ITEM_CERULEAN_CAVE_1F_ULTRA_BALL" },
+ [1040] = { "FLAG_HIDDEN_ITEM_UNUSED_0x28", "HIDDEN_ITEM_UNUSED_0x28" },
+ [1041] = { "FLAG_HIDDEN_ITEM_ROUTE11_ESCAPE_ROPE", "HIDDEN_ITEM_ROUTE11_ESCAPE_ROPE" },
+ [1042] = { "FLAG_HIDDEN_ITEM_ROUTE12_HYPER_POTION", "HIDDEN_ITEM_ROUTE12_HYPER_POTION" },
+ [1043] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2B", "HIDDEN_ITEM_UNUSED_0x2B" },
+ [1044] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2C", "HIDDEN_ITEM_UNUSED_0x2C" },
+ [1045] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2D", "HIDDEN_ITEM_UNUSED_0x2D" },
+ [1046] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2E", "HIDDEN_ITEM_UNUSED_0x2E" },
+ [1047] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_PP_UP", "HIDDEN_ITEM_CELADON_CITY_PP_UP" },
+ [1048] = { "FLAG_HIDDEN_ITEM_VERMILION_CITY_MAX_ETHER", "HIDDEN_ITEM_VERMILION_CITY_MAX_ETHER" },
+ [1049] = { "FLAG_HIDDEN_ITEM_CERULEAN_CITY_RARE_CANDY", "HIDDEN_ITEM_CERULEAN_CITY_RARE_CANDY" },
+ [1050] = { "FLAG_HIDDEN_ITEM_ROUTE4_GREAT_BALL", "HIDDEN_ITEM_ROUTE4_GREAT_BALL" },
+ [1051] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS" },
+ [1052] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_2", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_2" },
+ [1053] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_3", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_3" },
+ [1054] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_4", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_4" },
+ [1055] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_5", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_5" },
+ [1056] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_6", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_6" },
+ [1057] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_7", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_7" },
+ [1058] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_8", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_8" },
+ [1059] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_9", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_9" },
+ [1060] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_10", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_10" },
+ [1061] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_11", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_11" },
+ [1062] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_12", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_12" },
+ [1063] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_CHERI_BERRY", "HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_CHERI_BERRY" },
+ [1064] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_4", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_4" },
+ [1065] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE" },
+ [1066] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_2", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_2" },
+ [1067] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_3", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_3" },
+ [1068] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NEST_BALL", "HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NEST_BALL" },
+ [1069] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NET_BALL", "HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NET_BALL" },
+ [1070] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_POTION", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_POTION" },
+ [1071] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ANTIDOTE", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ANTIDOTE" },
+ [1072] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_PARALYZE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_PARALYZE_HEAL" },
+ [1073] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_AWAKENING", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_AWAKENING" },
+ [1074] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_BURN_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_BURN_HEAL" },
+ [1075] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ICE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ICE_HEAL" },
+ [1076] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ETHER", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ETHER" },
+ [1077] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_POTION", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_POTION" },
+ [1078] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ANTIDOTE", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ANTIDOTE" },
+ [1079] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_PARALYZE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_PARALYZE_HEAL" },
+ [1080] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_AWAKENING", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_AWAKENING" },
+ [1081] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_BURN_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_BURN_HEAL" },
+ [1082] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ICE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ICE_HEAL" },
+ [1083] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ETHER", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ETHER" },
+ [1084] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM", "HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM" },
+ [1085] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_2", "HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_2" },
+ [1086] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_3", "HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_3" },
+ [1087] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM", "HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM" },
+ [1088] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_2", "HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_2" },
+ [1089] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_3", "HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_3" },
+ [1090] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAZZ_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAZZ_BERRY" },
+ [1091] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_BLUK_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_BLUK_BERRY" },
+ [1092] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_NANAB_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_NANAB_BERRY" },
+ [1093] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_WEPEAR_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_WEPEAR_BERRY" },
+ [1094] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ORAN_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ORAN_BERRY" },
+ [1095] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHERI_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHERI_BERRY" },
+ [1096] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHESTO_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHESTO_BERRY" },
+ [1097] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PECHA_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PECHA_BERRY" },
+ [1098] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAWST_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAWST_BERRY" },
+ [1099] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ASPEAR_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ASPEAR_BERRY" },
+ [1100] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PERSIM_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PERSIM_BERRY" },
+ [1101] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PINAP_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PINAP_BERRY" },
+ [1102] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_LUM_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_LUM_BERRY" },
+ [1103] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST" },
+ [1104] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST_2", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST_2" },
+ [1105] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL" },
+ [1106] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL_2", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL_2" },
+ [1107] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL" },
+ [1108] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2" },
+ [1109] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STAR_PIECE", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STAR_PIECE" },
+ [1110] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_BIG_PEARL", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_BIG_PEARL" },
+ [1111] = { "FLAG_HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_RARE_CANDY", "HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_RARE_CANDY" },
+ [1112] = { "FLAG_HIDDEN_ITEM_PEWTER_CITY_POKE_BALL", "HIDDEN_ITEM_PEWTER_CITY_POKE_BALL" },
+ [1113] = { "FLAG_HIDDEN_ITEM_ROUTE3_ORAN_BERRY", "HIDDEN_ITEM_ROUTE3_ORAN_BERRY" },
+ [1114] = { "FLAG_HIDDEN_ITEM_ROUTE4_PERSIM_BERRY", "HIDDEN_ITEM_ROUTE4_PERSIM_BERRY" },
+ [1115] = { "FLAG_HIDDEN_ITEM_ROUTE24_PECHA_BERRY", "HIDDEN_ITEM_ROUTE24_PECHA_BERRY" },
+ [1116] = { "FLAG_HIDDEN_ITEM_ROUTE25_ORAN_BERRY", "HIDDEN_ITEM_ROUTE25_ORAN_BERRY" },
+ [1117] = { "FLAG_HIDDEN_ITEM_ROUTE25_BLUK_BERRY", "HIDDEN_ITEM_ROUTE25_BLUK_BERRY" },
+ [1118] = { "FLAG_HIDDEN_ITEM_ROUTE6_SITRUS_BERRY", "HIDDEN_ITEM_ROUTE6_SITRUS_BERRY" },
+ [1119] = { "FLAG_HIDDEN_ITEM_ROUTE6_RARE_CANDY", "HIDDEN_ITEM_ROUTE6_RARE_CANDY" },
+ [1120] = { "FLAG_HIDDEN_ITEM_SSANNE_KITCHEN_PECHA_BERRY", "HIDDEN_ITEM_SSANNE_KITCHEN_PECHA_BERRY" },
+ [1121] = { "FLAG_HIDDEN_ITEM_SSANNE_KITCHEN_CHERI_BERRY", "HIDDEN_ITEM_SSANNE_KITCHEN_CHERI_BERRY" },
+ [1122] = { "FLAG_HIDDEN_ITEM_SSANNE_KITCHEN_CHESTO_BERRY", "HIDDEN_ITEM_SSANNE_KITCHEN_CHESTO_BERRY" },
+ [1123] = { "FLAG_HIDDEN_ITEM_ROUTE9_RARE_CANDY", "HIDDEN_ITEM_ROUTE9_RARE_CANDY" },
+ [1124] = { "FLAG_HIDDEN_ITEM_UNUSED_0x7C", "HIDDEN_ITEM_UNUSED_0x7C" },
+ [1125] = { "FLAG_HIDDEN_ITEM_ROUTE10_PERSIM_BERRY", "HIDDEN_ITEM_ROUTE10_PERSIM_BERRY" },
+ [1126] = { "FLAG_HIDDEN_ITEM_ROUTE10_CHERI_BERRY", "HIDDEN_ITEM_ROUTE10_CHERI_BERRY" },
+ [1127] = { "FLAG_HIDDEN_ITEM_ROUTE8_RAWST_BERRY", "HIDDEN_ITEM_ROUTE8_RAWST_BERRY" },
+ [1128] = { "FLAG_HIDDEN_ITEM_ROUTE8_LUM_BERRY", "HIDDEN_ITEM_ROUTE8_LUM_BERRY" },
+ [1129] = { "FLAG_HIDDEN_ITEM_ROUTE8_LEPPA_BERRY", "HIDDEN_ITEM_ROUTE8_LEPPA_BERRY" },
+ [1130] = { "FLAG_HIDDEN_ITEM_ROUTE12_RARE_CANDY", "HIDDEN_ITEM_ROUTE12_RARE_CANDY" },
+ [1131] = { "FLAG_HIDDEN_ITEM_ROUTE12_LEFTOVERS", "HIDDEN_ITEM_ROUTE12_LEFTOVERS" },
+ [1132] = { "FLAG_HIDDEN_ITEM_ROUTE16_LEFTOVERS", "HIDDEN_ITEM_ROUTE16_LEFTOVERS" },
+ [1133] = { "FLAG_HIDDEN_ITEM_FUCHSIA_CITY_MAX_REVIVE", "HIDDEN_ITEM_FUCHSIA_CITY_MAX_REVIVE" },
+ [1134] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NET_BALL", "HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NET_BALL" },
+ [1135] = { "FLAG_HIDDEN_ITEM_SILPH_CO_2F_ULTRA_BALL", "HIDDEN_ITEM_SILPH_CO_2F_ULTRA_BALL" },
+ [1136] = { "FLAG_HIDDEN_ITEM_SILPH_CO_3F_PROTEIN", "HIDDEN_ITEM_SILPH_CO_3F_PROTEIN" },
+ [1137] = { "FLAG_HIDDEN_ITEM_SILPH_CO_4F_IRON", "HIDDEN_ITEM_SILPH_CO_4F_IRON" },
+ [1138] = { "FLAG_HIDDEN_ITEM_SILPH_CO_5F_PP_UP", "HIDDEN_ITEM_SILPH_CO_5F_PP_UP" },
+ [1139] = { "FLAG_HIDDEN_ITEM_SILPH_CO_6F_CARBOS", "HIDDEN_ITEM_SILPH_CO_6F_CARBOS" },
+ [1140] = { "FLAG_HIDDEN_ITEM_SILPH_CO_7F_ZINC", "HIDDEN_ITEM_SILPH_CO_7F_ZINC" },
+ [1141] = { "FLAG_HIDDEN_ITEM_SILPH_CO_8F_NUGGET", "HIDDEN_ITEM_SILPH_CO_8F_NUGGET" },
+ [1142] = { "FLAG_HIDDEN_ITEM_SILPH_CO_9F_CALCIUM", "HIDDEN_ITEM_SILPH_CO_9F_CALCIUM" },
+ [1143] = { "FLAG_HIDDEN_ITEM_SILPH_CO_10F_HP_UP", "HIDDEN_ITEM_SILPH_CO_10F_HP_UP" },
+ [1144] = { "FLAG_HIDDEN_ITEM_SILPH_CO_11F_REVIVE", "HIDDEN_ITEM_SILPH_CO_11F_REVIVE" },
+ [1145] = { "FLAG_HIDDEN_ITEM_ROUTE23_LUM_BERRY", "HIDDEN_ITEM_ROUTE23_LUM_BERRY" },
+ [1146] = { "FLAG_HIDDEN_ITEM_ROUTE23_SITRUS_BERRY", "HIDDEN_ITEM_ROUTE23_SITRUS_BERRY" },
+ [1147] = { "FLAG_HIDDEN_ITEM_ROUTE23_ASPEAR_BERRY", "HIDDEN_ITEM_ROUTE23_ASPEAR_BERRY" },
+ [1148] = { "FLAG_HIDDEN_ITEM_ROUTE23_LEPPA_BERRY", "HIDDEN_ITEM_ROUTE23_LEPPA_BERRY" },
+ [1149] = { "FLAG_HIDDEN_ITEM_ROUTE14_ZINC", "HIDDEN_ITEM_ROUTE14_ZINC" },
+ [1150] = { "FLAG_HIDDEN_ITEM_ROUTE9_CHESTO_BERRY", "HIDDEN_ITEM_ROUTE9_CHESTO_BERRY" },
+ [1151] = { "FLAG_HIDDEN_ITEM_ROUTE10_NANAB_BERRY", "HIDDEN_ITEM_ROUTE10_NANAB_BERRY" },
+ [1152] = { "FLAG_HIDDEN_ITEM_ROUTE7_WEPEAR_BERRY", "HIDDEN_ITEM_ROUTE7_WEPEAR_BERRY" },
+ [1153] = { "FLAG_HIDDEN_ITEM_ROUTE20_STARDUST", "HIDDEN_ITEM_ROUTE20_STARDUST" },
+ [1154] = { "FLAG_HIDDEN_ITEM_ROUTE21_NORTH_PEARL", "HIDDEN_ITEM_ROUTE21_NORTH_PEARL" },
+ [1155] = { "FLAG_HIDDEN_ITEM_ROUTE23_MAX_ELIXIR", "HIDDEN_ITEM_ROUTE23_MAX_ELIXIR" },
+ [1156] = { "FLAG_HIDDEN_ITEM_ROUTE4_RAZZ_BERRY", "HIDDEN_ITEM_ROUTE4_RAZZ_BERRY" },
+ [1157] = { "FLAG_HIDDEN_ITEM_ROUTE14_PINAP_BERRY", "HIDDEN_ITEM_ROUTE14_PINAP_BERRY" },
+ [1158] = { "FLAG_HIDDEN_ITEM_MT_EMBER_EXTERIOR_FIRE_STONE", "HIDDEN_ITEM_MT_EMBER_EXTERIOR_FIRE_STONE" },
+ [1159] = { "FLAG_HIDDEN_ITEM_POKEMON_TOWER_7F_SOOTHE_BELL", "HIDDEN_ITEM_POKEMON_TOWER_7F_SOOTHE_BELL" },
+ [1160] = { "FLAG_HIDDEN_ITEM_NAVEL_ROCK_SUMMIT_SACRED_ASH", "HIDDEN_ITEM_NAVEL_ROCK_SUMMIT_SACRED_ASH" },
+ [1161] = { "FLAG_HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_PP_MAX", "HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_PP_MAX" },
+ [1162] = { "FLAG_HIDDEN_ITEM_MT_EMBER_EXTERIOR_ULTRA_BALL", "HIDDEN_ITEM_MT_EMBER_EXTERIOR_ULTRA_BALL" },
+ [1163] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_DUNSPARCE_TUNNEL_NUGGET", "HIDDEN_ITEM_THREE_ISLAND_DUNSPARCE_TUNNEL_NUGGET" },
+ [1164] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_PP_UP", "HIDDEN_ITEM_THREE_ISLAND_PP_UP" },
+ [1165] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_MAX_REPEL", "HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_MAX_REPEL" },
+ [1166] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_PEARL", "HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_PEARL" },
+ [1167] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_STARDUST", "HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_STARDUST" },
+ [1168] = { "FLAG_HIDDEN_ITEM_FOUR_ISLAND_PEARL", "HIDDEN_ITEM_FOUR_ISLAND_PEARL" },
+ [1169] = { "FLAG_HIDDEN_ITEM_FOUR_ISLAND_ULTRA_BALL", "HIDDEN_ITEM_FOUR_ISLAND_ULTRA_BALL" },
+ [1170] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BIG_PEARL", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BIG_PEARL" },
+ [1171] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_RAZZ_BERRY", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_RAZZ_BERRY" },
+ [1172] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_SITRUS_BERRY", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_SITRUS_BERRY" },
+ [1173] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BLUK_BERRY", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BLUK_BERRY" },
+ [1174] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_NEST_BALL", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_NEST_BALL" },
+ [1175] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST" },
+ [1176] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STAR_PIECE", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STAR_PIECE" },
+ [1177] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST_2", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST_2" },
+ [1178] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_STAR_PIECE", "HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_STAR_PIECE" },
+ [1179] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_NET_BALL", "HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_NET_BALL" },
+ [1180] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_GREEN_PATH_ULTRA_BALL", "HIDDEN_ITEM_SIX_ISLAND_GREEN_PATH_ULTRA_BALL" },
+ [1181] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ASPEAR_BERRY", "HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ASPEAR_BERRY" },
+ [1182] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ORAN_BERRY", "HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ORAN_BERRY" },
+ [1183] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_PINAP_BERRY", "HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_PINAP_BERRY" },
+ [1184] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_LEPPA_BERRY", "HIDDEN_ITEM_SIX_ISLAND_LEPPA_BERRY" },
+ [1185] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_BIG_PEARL", "HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_BIG_PEARL" },
+ [1186] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_PEARL", "HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_PEARL" },
+ [1187] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_NANAB_BERRY", "HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_NANAB_BERRY" },
+ [1188] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_ENTRANCE_RAWST_BERRY", "HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_ENTRANCE_RAWST_BERRY" },
+ [1189] = { "FLAG_HIDDEN_ITEM_VIRIDIAN_CITY_GYM_MACHO_BRACE", "HIDDEN_ITEM_VIRIDIAN_CITY_GYM_MACHO_BRACE" },
+ [1190] = { "FLAG_HIDDEN_ITEM_SSANNE_EXTERIOR_LAVA_COOKIE", "HIDDEN_ITEM_SSANNE_EXTERIOR_LAVA_COOKIE" },
+ [1191] = { "FLAG_UNUSED_0x4A7", "UNUSED_0x4A7" },
+ [1192] = { "FLAG_UNUSED_0x4A8", "UNUSED_0x4A8" },
+ [1193] = { "FLAG_UNUSED_0x4A9", "UNUSED_0x4A9" },
+ [1194] = { "FLAG_UNUSED_0x4AA", "UNUSED_0x4AA" },
+ [1195] = { "FLAG_UNUSED_0x4AB", "UNUSED_0x4AB" },
+ [1196] = { "FLAG_UNUSED_0x4AC", "UNUSED_0x4AC" },
+ [1197] = { "FLAG_UNUSED_0x4AD", "UNUSED_0x4AD" },
+ [1198] = { "FLAG_UNUSED_0x4AE", "UNUSED_0x4AE" },
+ [1199] = { "FLAG_UNUSED_0x4AF", "UNUSED_0x4AF" },
+ [1200] = { "FLAG_DEFEATED_BROCK", "DEFEATED_BROCK" },
+ [1201] = { "FLAG_DEFEATED_MISTY", "DEFEATED_MISTY" },
+ [1202] = { "FLAG_DEFEATED_LT_SURGE", "DEFEATED_LT_SURGE" },
+ [1203] = { "FLAG_DEFEATED_ERIKA", "DEFEATED_ERIKA" },
+ [1204] = { "FLAG_DEFEATED_KOGA", "DEFEATED_KOGA" },
+ [1205] = { "FLAG_DEFEATED_SABRINA", "DEFEATED_SABRINA" },
+ [1206] = { "FLAG_DEFEATED_BLAINE", "DEFEATED_BLAINE" },
+ [1207] = { "FLAG_DEFEATED_LEADER_GIOVANNI", "DEFEATED_LEADER_GIOVANNI" },
+ [1208] = { "FLAG_DEFEATED_LORELEI", "DEFEATED_LORELEI" },
+ [1209] = { "FLAG_DEFEATED_BRUNO", "DEFEATED_BRUNO" },
+ [1210] = { "FLAG_DEFEATED_AGATHA", "DEFEATED_AGATHA" },
+ [1211] = { "FLAG_DEFEATED_LANCE", "DEFEATED_LANCE" },
+ [1212] = { "FLAG_DEFEATED_CHAMP", "DEFEATED_CHAMP" },
+ [1213] = { "FLAG_0x4BD", "0x4BD" },
+ [1214] = { "FLAG_0x4BE", "0x4BE" },
+ [1215] = { "FLAG_0x4BF", "0x4BF" },
+ [1216] = { "FLAG_0x4C0", "0x4C0" },
+ [1217] = { "FLAG_0x4C1", "0x4C1" },
+ [1218] = { "FLAG_0x4C2", "0x4C2" },
+ [1219] = { "FLAG_0x4C3", "0x4C3" },
+ [1220] = { "FLAG_0x4C4", "0x4C4" },
+ [1221] = { "FLAG_0x4C5", "0x4C5" },
+ [1222] = { "FLAG_0x4C6", "0x4C6" },
+ [1223] = { "FLAG_0x4C7", "0x4C7" },
+ [1224] = { "FLAG_0x4C8", "0x4C8" },
+ [1225] = { "FLAG_0x4C9", "0x4C9" },
+ [1226] = { "FLAG_0x4CA", "0x4CA" },
+ [1227] = { "FLAG_0x4CB", "0x4CB" },
+ [1228] = { "FLAG_0x4CC", "0x4CC" },
+ [1229] = { "FLAG_0x4CD", "0x4CD" },
+ [1230] = { "FLAG_0x4CE", "0x4CE" },
+ [1231] = { "FLAG_0x4CF", "0x4CF" },
+ [1232] = { "FLAG_0x4D0", "0x4D0" },
+ [1233] = { "FLAG_0x4D1", "0x4D1" },
+ [1234] = { "FLAG_0x4D2", "0x4D2" },
+ [1235] = { "FLAG_0x4D3", "0x4D3" },
+ [1236] = { "FLAG_0x4D4", "0x4D4" },
+ [1237] = { "FLAG_0x4D5", "0x4D5" },
+ [1238] = { "FLAG_0x4D6", "0x4D6" },
+ [1239] = { "FLAG_0x4D7", "0x4D7" },
+ [1240] = { "FLAG_0x4D8", "0x4D8" },
+ [1241] = { "FLAG_0x4D9", "0x4D9" },
+ [1242] = { "FLAG_0x4DA", "0x4DA" },
+ [1243] = { "FLAG_0x4DB", "0x4DB" },
+ [1244] = { "FLAG_0x4DC", "0x4DC" },
+ [1245] = { "FLAG_0x4DD", "0x4DD" },
+ [1246] = { "FLAG_0x4DE", "0x4DE" },
+ [1247] = { "FLAG_0x4DF", "0x4DF" },
+ [1248] = { "FLAG_0x4E0", "0x4E0" },
+ [1249] = { "FLAG_0x4E1", "0x4E1" },
+ [1250] = { "FLAG_0x4E2", "0x4E2" },
+ [1251] = { "FLAG_0x4E3", "0x4E3" },
+ [1252] = { "FLAG_0x4E4", "0x4E4" },
+ [1253] = { "FLAG_0x4E5", "0x4E5" },
+ [1254] = { "FLAG_0x4E6", "0x4E6" },
+ [1255] = { "FLAG_0x4E7", "0x4E7" },
+ [1256] = { "FLAG_0x4E8", "0x4E8" },
+ [1257] = { "FLAG_0x4E9", "0x4E9" },
+ [1258] = { "FLAG_0x4EA", "0x4EA" },
+ [1259] = { "FLAG_0x4EB", "0x4EB" },
+ [1260] = { "FLAG_0x4EC", "0x4EC" },
+ [1261] = { "FLAG_0x4ED", "0x4ED" },
+ [1262] = { "FLAG_0x4EE", "0x4EE" },
+ [1263] = { "FLAG_0x4EF", "0x4EF" },
+ [1264] = { "FLAG_0x4F0", "0x4F0" },
+ [1265] = { "FLAG_0x4F1", "0x4F1" },
+ [1266] = { "FLAG_0x4F2", "0x4F2" },
+ [1267] = { "FLAG_0x4F3", "0x4F3" },
+ [1268] = { "FLAG_0x4F4", "0x4F4" },
+ [1269] = { "FLAG_0x4F5", "0x4F5" },
+ [1270] = { "FLAG_0x4F6", "0x4F6" },
+ [1271] = { "FLAG_0x4F7", "0x4F7" },
+ [1272] = { "FLAG_0x4F8", "0x4F8" },
+ [1273] = { "FLAG_0x4F9", "0x4F9" },
+ [1274] = { "FLAG_0x4FA", "0x4FA" },
+ [1275] = { "FLAG_0x4FB", "0x4FB" },
+ [1276] = { "FLAG_0x4FC", "0x4FC" },
+ [1277] = { "FLAG_0x4FD", "0x4FD" },
+ [1278] = { "FLAG_0x4FE", "0x4FE" },
+ [1279] = { "FLAG_0x4FF", "0x4FF" },
+ [1280] = { false, "TRAINER_FLAGS_START" },
+ [2047] = { false, "TRAINER_FLAGS_END" },
+ [2048] = { "FLAG_SYS_SAFARI_MODE", "SYS_FLAGS", "SYS_SAFARI_MODE" },
+ [2049] = { "FLAG_SYS_VS_SEEKER_CHARGING", "SYS_VS_SEEKER_CHARGING" },
+ [2050] = { "FLAG_SYS_CRUISE_MODE", "SYS_CRUISE_MODE" },
+ [2051] = { "FLAG_SYS_WHITE_FLUTE_ACTIVE", "SYS_WHITE_FLUTE_ACTIVE" },
+ [2052] = { "FLAG_SYS_BLACK_FLUTE_ACTIVE", "SYS_BLACK_FLUTE_ACTIVE" },
+ [2053] = { "FLAG_SYS_USE_STRENGTH", "SYS_USE_STRENGTH" },
+ [2054] = { "FLAG_SYS_FLASH_ACTIVE", "SYS_FLASH_ACTIVE" },
+ [2055] = { "FLAG_SYS_SPECIAL_WILD_BATTLE", "SYS_SPECIAL_WILD_BATTLE" },
+ [2056] = { "FLAG_SYS_QL_DEPARTED", "SYS_QL_DEPARTED" },
+ [2057] = { "FLAG_0x809", "0x809" },
+ [2058] = { "FLAG_0x80A", "0x80A" },
+ [2059] = { "FLAG_0x80B", "0x80B" },
+ [2060] = { "FLAG_0x80C", "0x80C" },
+ [2061] = { "FLAG_0x80D", "0x80D" },
+ [2062] = { "FLAG_0x80E", "0x80E" },
+ [2063] = { "FLAG_0x80F", "0x80F" },
+ [2064] = { "FLAG_0x810", "0x810" },
+ [2065] = { "FLAG_0x811", "0x811" },
+ [2066] = { "FLAG_0x812", "0x812" },
+ [2067] = { "FLAG_0x813", "0x813" },
+ [2068] = { "FLAG_0x814", "0x814" },
+ [2069] = { "FLAG_0x815", "0x815" },
+ [2070] = { "FLAG_0x816", "0x816" },
+ [2071] = { "FLAG_0x817", "0x817" },
+ [2072] = { "FLAG_0x818", "0x818" },
+ [2073] = { "FLAG_0x819", "0x819" },
+ [2074] = { "FLAG_0x81A", "0x81A" },
+ [2075] = { "FLAG_0x81B", "0x81B" },
+ [2076] = { "FLAG_0x81C", "0x81C" },
+ [2077] = { "FLAG_0x81D", "0x81D" },
+ [2078] = { "FLAG_0x81E", "0x81E" },
+ [2079] = { "FLAG_0x81F", "0x81F" },
+ [2080] = { "FLAG_BADGE01_GET", "BADGE01_GET", "PERMA_SYS_FLAGS_START" },
+ [2081] = { "FLAG_BADGE02_GET", "BADGE02_GET" },
+ [2082] = { "FLAG_BADGE03_GET", "BADGE03_GET" },
+ [2083] = { "FLAG_BADGE04_GET", "BADGE04_GET" },
+ [2084] = { "FLAG_BADGE05_GET", "BADGE05_GET" },
+ [2085] = { "FLAG_BADGE06_GET", "BADGE06_GET" },
+ [2086] = { "FLAG_BADGE07_GET", "BADGE07_GET" },
+ [2087] = { "FLAG_BADGE08_GET", "BADGE08_GET" },
+ [2088] = { "FLAG_SYS_POKEMON_GET", "SYS_POKEMON_GET" },
+ [2089] = { "FLAG_SYS_POKEDEX_GET", "SYS_POKEDEX_GET" },
+ [2090] = { "FLAG_0x82A", "0x82A" },
+ [2091] = { "FLAG_0x82B", "0x82B" },
+ [2092] = { "FLAG_SYS_GAME_CLEAR", "SYS_GAME_CLEAR" },
+ [2093] = { "FLAG_SYS_SET_TRAINER_CARD_PROFILE", "SYS_SET_TRAINER_CARD_PROFILE" },
+ [2094] = { "FLAG_0x82E", "0x82E" },
+ [2095] = { "FLAG_SYS_B_DASH", "SYS_B_DASH" },
+ [2096] = { "FLAG_SYS_ON_CYCLING_ROAD", "SYS_ON_CYCLING_ROAD" },
+ [2097] = { "FLAG_0x831", "0x831" },
+ [2098] = { "FLAG_0x832", "0x832" },
+ [2099] = { "FLAG_0x833", "0x833" },
+ [2100] = { "FLAG_SYS_NOT_SOMEONES_PC", "SYS_NOT_SOMEONES_PC" },
+ [2101] = { "FLAG_0x835", "0x835" },
+ [2102] = { "FLAG_0x836", "0x836" },
+ [2103] = { "FLAG_SYS_RESET_RTC_ENABLE", "SYS_RESET_RTC_ENABLE" },
+ [2104] = { "FLAG_0x838", "0x838" },
+ [2105] = { "FLAG_SYS_MYSTERY_GIFT_ENABLED", "SYS_MYSTERY_GIFT_ENABLED" },
+ [2106] = { "FLAG_0x83A", "0x83A" },
+ [2107] = { "FLAG_SYS_RIBBON_GET", "SYS_RIBBON_GET" },
+ [2108] = { "FLAG_SYS_SAW_HELP_SYSTEM_INTRO", "SYS_SAW_HELP_SYSTEM_INTRO" },
+ [2109] = { "FLAG_0x83D", "0x83D" },
+ [2110] = { "FLAG_OPENED_START_MENU", "OPENED_START_MENU" },
+ [2111] = { "FLAG_0x83F", "0x83F" },
+ [2112] = { "FLAG_SYS_NATIONAL_DEX", "SYS_NATIONAL_DEX" },
+ [2113] = { "FLAG_SYS_PC_STORAGE_DISABLED", "SYS_PC_STORAGE_DISABLED" },
+ [2114] = { "FLAG_SYS_INFORMED_OF_LOCAL_WIRELESS_PLAYER", "SYS_INFORMED_OF_LOCAL_WIRELESS_PLAYER" },
+ [2115] = { "FLAG_SHOWN_BOX_WAS_FULL_MESSAGE", "SHOWN_BOX_WAS_FULL_MESSAGE" },
+ [2116] = { "FLAG_SYS_CAN_LINK_WITH_RS", "SYS_CAN_LINK_WITH_RS" },
+ [2117] = { "FLAG_SYS_SEVII_MAP_123", "SYS_SEVII_MAP_123" },
+ [2118] = { "FLAG_SYS_SEVII_MAP_4567", "SYS_SEVII_MAP_4567" },
+ [2119] = { "FLAG_SYS_GOT_BERRY_POUCH", "SYS_GOT_BERRY_POUCH" },
+ [2120] = { "FLAG_SYS_DEOXYS_AWAKENED", "SYS_DEOXYS_AWAKENED" },
+ [2121] = { "FLAG_SYS_UNLOCKED_TANOBY_RUINS", "SYS_UNLOCKED_TANOBY_RUINS" },
+ [2122] = { "FLAG_ENABLE_SHIP_NAVEL_ROCK", "ENABLE_SHIP_NAVEL_ROCK" },
+ [2123] = { "FLAG_ENABLE_SHIP_BIRTH_ISLAND", "ENABLE_SHIP_BIRTH_ISLAND" },
+ [2192] = { "FLAG_WORLD_MAP_PALLET_TOWN", "WORLD_MAP_PALLET_TOWN" },
+ [2193] = { "FLAG_WORLD_MAP_VIRIDIAN_CITY", "WORLD_MAP_VIRIDIAN_CITY" },
+ [2194] = { "FLAG_WORLD_MAP_PEWTER_CITY", "WORLD_MAP_PEWTER_CITY" },
+ [2195] = { "FLAG_WORLD_MAP_CERULEAN_CITY", "WORLD_MAP_CERULEAN_CITY" },
+ [2196] = { "FLAG_WORLD_MAP_LAVENDER_TOWN", "WORLD_MAP_LAVENDER_TOWN" },
+ [2197] = { "FLAG_WORLD_MAP_VERMILION_CITY", "WORLD_MAP_VERMILION_CITY" },
+ [2198] = { "FLAG_WORLD_MAP_CELADON_CITY", "WORLD_MAP_CELADON_CITY" },
+ [2199] = { "FLAG_WORLD_MAP_FUCHSIA_CITY", "WORLD_MAP_FUCHSIA_CITY" },
+ [2200] = { "FLAG_WORLD_MAP_CINNABAR_ISLAND", "WORLD_MAP_CINNABAR_ISLAND" },
+ [2201] = { "FLAG_WORLD_MAP_INDIGO_PLATEAU_EXTERIOR", "WORLD_MAP_INDIGO_PLATEAU_EXTERIOR" },
+ [2202] = { "FLAG_WORLD_MAP_SAFFRON_CITY", "WORLD_MAP_SAFFRON_CITY" },
+ [2203] = { "FLAG_WORLD_MAP_ONE_ISLAND", "WORLD_MAP_ONE_ISLAND" },
+ [2204] = { "FLAG_WORLD_MAP_TWO_ISLAND", "WORLD_MAP_TWO_ISLAND" },
+ [2205] = { "FLAG_WORLD_MAP_THREE_ISLAND", "WORLD_MAP_THREE_ISLAND" },
+ [2206] = { "FLAG_WORLD_MAP_FOUR_ISLAND", "WORLD_MAP_FOUR_ISLAND" },
+ [2207] = { "FLAG_WORLD_MAP_FIVE_ISLAND", "WORLD_MAP_FIVE_ISLAND" },
+ [2208] = { "FLAG_WORLD_MAP_SEVEN_ISLAND", "WORLD_MAP_SEVEN_ISLAND" },
+ [2209] = { "FLAG_WORLD_MAP_SIX_ISLAND", "WORLD_MAP_SIX_ISLAND" },
+ [2210] = { "FLAG_WORLD_MAP_ROUTE4_POKEMON_CENTER_1F", "WORLD_MAP_ROUTE4_POKEMON_CENTER_1F" },
+ [2211] = { "FLAG_WORLD_MAP_ROUTE10_POKEMON_CENTER_1F", "WORLD_MAP_ROUTE10_POKEMON_CENTER_1F" },
+ [2212] = { "FLAG_WORLD_MAP_VIRIDIAN_FOREST", "WORLD_MAP_VIRIDIAN_FOREST" },
+ [2213] = { "FLAG_WORLD_MAP_MT_MOON_1F", "WORLD_MAP_MT_MOON_1F" },
+ [2214] = { "FLAG_WORLD_MAP_SSANNE_EXTERIOR", "WORLD_MAP_SSANNE_EXTERIOR" },
+ [2215] = { "FLAG_WORLD_MAP_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL", "WORLD_MAP_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL" },
+ [2216] = { "FLAG_WORLD_MAP_UNDERGROUND_PATH_EAST_WEST_TUNNEL", "WORLD_MAP_UNDERGROUND_PATH_EAST_WEST_TUNNEL" },
+ [2217] = { "FLAG_WORLD_MAP_DIGLETTS_CAVE_B1F", "WORLD_MAP_DIGLETTS_CAVE_B1F" },
+ [2218] = { "FLAG_WORLD_MAP_VICTORY_ROAD_1F", "WORLD_MAP_VICTORY_ROAD_1F" },
+ [2219] = { "FLAG_WORLD_MAP_ROCKET_HIDEOUT_B1F", "WORLD_MAP_ROCKET_HIDEOUT_B1F" },
+ [2220] = { "FLAG_WORLD_MAP_SILPH_CO_1F", "WORLD_MAP_SILPH_CO_1F" },
+ [2221] = { "FLAG_WORLD_MAP_POKEMON_MANSION_1F", "WORLD_MAP_POKEMON_MANSION_1F" },
+ [2222] = { "FLAG_WORLD_MAP_SAFARI_ZONE_CENTER", "WORLD_MAP_SAFARI_ZONE_CENTER" },
+ [2223] = { "FLAG_WORLD_MAP_POKEMON_LEAGUE_LORELEIS_ROOM", "WORLD_MAP_POKEMON_LEAGUE_LORELEIS_ROOM" },
+ [2224] = { "FLAG_WORLD_MAP_ROCK_TUNNEL_1F", "WORLD_MAP_ROCK_TUNNEL_1F" },
+ [2225] = { "FLAG_WORLD_MAP_SEAFOAM_ISLANDS_1F", "WORLD_MAP_SEAFOAM_ISLANDS_1F" },
+ [2226] = { "FLAG_WORLD_MAP_POKEMON_TOWER_1F", "WORLD_MAP_POKEMON_TOWER_1F" },
+ [2227] = { "FLAG_WORLD_MAP_CERULEAN_CAVE_1F", "WORLD_MAP_CERULEAN_CAVE_1F" },
+ [2228] = { "FLAG_WORLD_MAP_POWER_PLANT", "WORLD_MAP_POWER_PLANT" },
+ [2229] = { "FLAG_WORLD_MAP_NAVEL_ROCK_EXTERIOR", "WORLD_MAP_NAVEL_ROCK_EXTERIOR" },
+ [2230] = { "FLAG_WORLD_MAP_MT_EMBER_EXTERIOR", "WORLD_MAP_MT_EMBER_EXTERIOR" },
+ [2231] = { "FLAG_WORLD_MAP_THREE_ISLAND_BERRY_FOREST", "WORLD_MAP_THREE_ISLAND_BERRY_FOREST" },
+ [2232] = { "FLAG_WORLD_MAP_FOUR_ISLAND_ICEFALL_CAVE_ENTRANCE", "WORLD_MAP_FOUR_ISLAND_ICEFALL_CAVE_ENTRANCE" },
+ [2233] = { "FLAG_WORLD_MAP_FIVE_ISLAND_ROCKET_WAREHOUSE", "WORLD_MAP_FIVE_ISLAND_ROCKET_WAREHOUSE" },
+ [2234] = { "FLAG_WORLD_MAP_TRAINER_TOWER_LOBBY", "WORLD_MAP_TRAINER_TOWER_LOBBY" },
+ [2235] = { "FLAG_WORLD_MAP_SIX_ISLAND_DOTTED_HOLE_1F", "WORLD_MAP_SIX_ISLAND_DOTTED_HOLE_1F" },
+ [2236] = { "FLAG_WORLD_MAP_FIVE_ISLAND_LOST_CAVE_ENTRANCE", "WORLD_MAP_FIVE_ISLAND_LOST_CAVE_ENTRANCE" },
+ [2237] = { "FLAG_WORLD_MAP_SIX_ISLAND_PATTERN_BUSH", "WORLD_MAP_SIX_ISLAND_PATTERN_BUSH" },
+ [2238] = { "FLAG_WORLD_MAP_SIX_ISLAND_ALTERING_CAVE", "WORLD_MAP_SIX_ISLAND_ALTERING_CAVE" },
+ [2239] = { "FLAG_WORLD_MAP_SEVEN_ISLAND_TANOBY_RUINS_MONEAN_CHAMBER", "WORLD_MAP_SEVEN_ISLAND_TANOBY_RUINS_MONEAN_CHAMBER" },
+ [2240] = { "FLAG_WORLD_MAP_THREE_ISLAND_DUNSPARCE_TUNNEL", "WORLD_MAP_THREE_ISLAND_DUNSPARCE_TUNNEL" },
+ [2241] = { "FLAG_WORLD_MAP_SEVEN_ISLAND_SEVAULT_CANYON_TANOBY_KEY", "WORLD_MAP_SEVEN_ISLAND_SEVAULT_CANYON_TANOBY_KEY" },
+ [2242] = { "FLAG_WORLD_MAP_BIRTH_ISLAND_EXTERIOR", "WORLD_MAP_BIRTH_ISLAND_EXTERIOR" },
+ [2243] = { "FLAG_0x8C3", "0x8C3" },
+ [2244] = { "FLAG_0x8C4", "0x8C4" },
+ [2245] = { "FLAG_0x8C5", "0x8C5" },
+ [2246] = { "FLAG_0x8C6", "0x8C6" },
+ [2247] = { "FLAG_0x8C7", "0x8C7" },
+ [2248] = { "FLAG_0x8C8", "0x8C8" },
+ [2249] = { "FLAG_0x8C9", "0x8C9" },
+ [2250] = { "FLAG_0x8CA", "0x8CA" },
+ [2251] = { "FLAG_0x8CB", "0x8CB" },
+ [2252] = { "FLAG_0x8CC", "0x8CC" },
+ [2253] = { "FLAG_0x8CD", "0x8CD" },
+ [2254] = { "FLAG_0x8CE", "0x8CE" },
+ [2255] = { "FLAG_0x8CF", "0x8CF" },
+ [2256] = { "FLAG_0x8D0", "0x8D0" },
+ [2257] = { "FLAG_0x8D1", "0x8D1" },
+ [2258] = { "FLAG_0x8D2", "0x8D2" },
+ [2259] = { "FLAG_0x8D3", "0x8D3" },
+ [2260] = { "FLAG_0x8D4", "0x8D4" },
+ [2261] = { "FLAG_0x8D5", "0x8D5" },
+ [2262] = { "FLAG_0x8D6", "0x8D6" },
+ [2263] = { "FLAG_0x8D7", "0x8D7" },
+ [2264] = { "FLAG_0x8D8", "0x8D8" },
+ [2265] = { "FLAG_0x8D9", "0x8D9" },
+ [2266] = { "FLAG_0x8DA", "0x8DA" },
+ [2267] = { "FLAG_0x8DB", "0x8DB" },
+ [2268] = { "FLAG_0x8DC", "0x8DC" },
+ [2269] = { "FLAG_0x8DD", "0x8DD" },
+ [2270] = { "FLAG_0x8DE", "0x8DE" },
+ [2271] = { "FLAG_0x8DF", "0x8DF" },
+ [2272] = { "FLAG_0x8E0", "0x8E0" },
+ [2273] = { "FLAG_0x8E1", "0x8E1" },
+ [2274] = { "FLAG_0x8E2", "0x8E2" },
+ [2275] = { "FLAG_0x8E3", "0x8E3" },
+ [2276] = { "FLAG_0x8E4", "0x8E4" },
+ [2277] = { "FLAG_0x8E5", "0x8E5" },
+ [2278] = { "FLAG_0x8E6", "0x8E6" },
+ [2279] = { "FLAG_0x8E7", "0x8E7" },
+ [2280] = { "FLAG_0x8E8", "0x8E8" },
+ [2281] = { "FLAG_0x8E9", "0x8E9" },
+ [2282] = { "FLAG_0x8EA", "0x8EA" },
+ [2283] = { "FLAG_0x8EB", "0x8EB" },
+ [2284] = { "FLAG_0x8EC", "0x8EC" },
+ [2285] = { "FLAG_0x8ED", "0x8ED" },
+ [2286] = { "FLAG_0x8EE", "0x8EE" },
+ [2287] = { "FLAG_0x8EF", "0x8EF" },
+ [2288] = { "FLAG_0x8F0", "0x8F0" },
+ [2289] = { "FLAG_0x8F1", "0x8F1" },
+ [2290] = { "FLAG_0x8F2", "0x8F2" },
+ [2291] = { "FLAG_0x8F3", "0x8F3" },
+ [2292] = { "FLAG_0x8F4", "0x8F4" },
+ [2293] = { "FLAG_0x8F5", "0x8F5" },
+ [2294] = { "FLAG_0x8F6", "0x8F6" },
+ [2295] = { "FLAG_0x8F7", "0x8F7" },
+ [2296] = { "FLAG_0x8F8", "0x8F8" },
+ [2297] = { "FLAG_0x8F9", "0x8F9" },
+ [2298] = { "FLAG_0x8FA", "0x8FA" },
+ [2299] = { "FLAG_0x8FB", "0x8FB" },
+ [2300] = { "FLAG_0x8FC", "0x8FC" },
+ [2301] = { "FLAG_0x8FD", "0x8FD" },
+ [2302] = { "FLAG_0x8FE", "0x8FE" },
+ [2303] = { "FLAG_0x8FF", "0x8FF" },
+ },
+}
diff --git a/src/mods/flag_names/red.lua b/src/mods/flag_names/red.lua
new file mode 100644
index 000000000..14899c835
--- /dev/null
+++ b/src/mods/flag_names/red.lua
@@ -0,0 +1,514 @@
+-- Generated flag identifiers only; no engine implementation.
+-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36
+-- Regenerate with tools/flag-catalog-check/generate.py.
+return {
+ records = {
+ [0] = { "EVENT_FOLLOWED_OAK_INTO_LAB" },
+ [3] = { "EVENT_HALL_OF_FAME_DEX_RATING" },
+ [6] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
+ [24] = { "EVENT_GOT_TOWN_MAP" },
+ [25] = { "EVENT_ENTERED_BLUES_HOUSE" },
+ [26] = { "EVENT_DAISY_WALKING" },
+ [32] = { "EVENT_FOLLOWED_OAK_INTO_LAB_2" },
+ [33] = { "EVENT_OAK_ASKED_TO_CHOOSE_MON" },
+ [34] = { "EVENT_GOT_STARTER" },
+ [35] = { "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
+ [36] = { "EVENT_GOT_POKEBALLS_FROM_OAK" },
+ [37] = { "EVENT_GOT_POKEDEX" },
+ [38] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS_2" },
+ [39] = { "EVENT_OAK_APPEARED_IN_PALLET" },
+ [40] = { "EVENT_VIRIDIAN_GYM_OPEN" },
+ [41] = { "EVENT_GOT_TM42" },
+ [56] = { "EVENT_OAK_GOT_PARCEL" },
+ [57] = { "EVENT_GOT_OAKS_PARCEL" },
+ [80] = { "EVENT_GOT_TM27" },
+ [81] = { "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI" },
+ [82] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0" },
+ [83] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1" },
+ [84] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2" },
+ [85] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3" },
+ [86] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4" },
+ [87] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5" },
+ [88] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6" },
+ [89] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7" },
+ [104] = { "EVENT_BOUGHT_MUSEUM_TICKET" },
+ [105] = { "EVENT_GOT_OLD_AMBER" },
+ [114] = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
+ [118] = { "EVENT_GOT_TM34" },
+ [119] = { "EVENT_BEAT_BROCK" },
+ [152] = { "EVENT_BEAT_CERULEAN_RIVAL" },
+ [167] = { "EVENT_BEAT_CERULEAN_ROCKET_THIEF" },
+ [186] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_0" },
+ [187] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_1" },
+ [190] = { "EVENT_GOT_TM11" },
+ [191] = { "EVENT_BEAT_MISTY" },
+ [192] = { "EVENT_GOT_BICYCLE" },
+ [238] = { "EVENT_POKEMON_TOWER_RIVAL_ON_LEFT" },
+ [239] = { "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
+ [241] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_0" },
+ [242] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_1" },
+ [243] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_2" },
+ [249] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_0" },
+ [250] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_1" },
+ [251] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_2" },
+ [258] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_0" },
+ [259] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_1" },
+ [260] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_2" },
+ [261] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_3" },
+ [263] = { "EVENT_IN_PURIFIED_ZONE" },
+ [265] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_0" },
+ [266] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_1" },
+ [267] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_2" },
+ [271] = { "EVENT_BEAT_GHOST_MAROWAK" },
+ [273] = { "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0" },
+ [274] = { "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1" },
+ [275] = { "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2" },
+ [279] = { "EVENT_RESCUED_MR_FUJI_2" },
+ [296] = { "EVENT_GOT_POKE_FLUTE" },
+ [337] = { "EVENT_GOT_BIKE_VOUCHER" },
+ [342] = { "EVENT_SEEL_FAN_BOAST" },
+ [343] = { "EVENT_PIKACHU_FAN_BOAST" },
+ [352] = { "EVENT_2ND_LOCK_OPENED" },
+ [353] = { "EVENT_1ST_LOCK_OPENED" },
+ [354] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_0" },
+ [355] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_1" },
+ [356] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_2" },
+ [358] = { "EVENT_GOT_TM24" },
+ [359] = { "EVENT_BEAT_LT_SURGE" },
+ [384] = { "EVENT_GOT_TM41" },
+ [396] = { "EVENT_GOT_TM13" },
+ [397] = { "EVENT_GOT_TM48" },
+ [398] = { "EVENT_GOT_TM49" },
+ [399] = { "EVENT_GOT_TM18" },
+ [424] = { "EVENT_GOT_TM21" },
+ [425] = { "EVENT_BEAT_ERIKA" },
+ [426] = { "EVENT_BEAT_CELADON_GYM_TRAINER_0" },
+ [427] = { "EVENT_BEAT_CELADON_GYM_TRAINER_1" },
+ [428] = { "EVENT_BEAT_CELADON_GYM_TRAINER_2" },
+ [429] = { "EVENT_BEAT_CELADON_GYM_TRAINER_3" },
+ [430] = { "EVENT_BEAT_CELADON_GYM_TRAINER_4" },
+ [431] = { "EVENT_BEAT_CELADON_GYM_TRAINER_5" },
+ [432] = { "EVENT_BEAT_CELADON_GYM_TRAINER_6" },
+ [440] = { "EVENT_1B8" },
+ [441] = { "EVENT_FOUND_ROCKET_HIDEOUT" },
+ [442] = { "EVENT_GOT_10_COINS" },
+ [443] = { "EVENT_GOT_20_COINS" },
+ [444] = { "EVENT_GOT_20_COINS_2" },
+ [447] = { "EVENT_1BF" },
+ [480] = { "EVENT_GOT_COIN_CASE" },
+ [568] = { "EVENT_GOT_HM04" },
+ [569] = { "EVENT_GAVE_GOLD_TEETH" },
+ [590] = { "EVENT_SAFARI_GAME_OVER" },
+ [591] = { "EVENT_IN_SAFARI_ZONE" },
+ [600] = { "EVENT_GOT_TM06" },
+ [601] = { "EVENT_BEAT_KOGA" },
+ [602] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_0" },
+ [603] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_1" },
+ [604] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_2" },
+ [605] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_3" },
+ [606] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_4" },
+ [607] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_5" },
+ [632] = { "EVENT_MANSION_SWITCH_ON" },
+ [649] = { "EVENT_BEAT_MANSION_1_TRAINER_0" },
+ [664] = { "EVENT_GOT_TM38" },
+ [665] = { "EVENT_BEAT_BLAINE" },
+ [666] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_0" },
+ [667] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_1" },
+ [668] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_2" },
+ [669] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_3" },
+ [670] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_4" },
+ [671] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_5" },
+ [672] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_6" },
+ [679] = { "EVENT_2A7" },
+ [680] = { "EVENT_CINNABAR_GYM_GATE0_UNLOCKED" },
+ [681] = { "EVENT_CINNABAR_GYM_GATE1_UNLOCKED" },
+ [682] = { "EVENT_CINNABAR_GYM_GATE2_UNLOCKED" },
+ [683] = { "EVENT_CINNABAR_GYM_GATE3_UNLOCKED" },
+ [684] = { "EVENT_CINNABAR_GYM_GATE4_UNLOCKED" },
+ [685] = { "EVENT_CINNABAR_GYM_GATE5_UNLOCKED" },
+ [686] = { "EVENT_CINNABAR_GYM_GATE6_UNLOCKED" },
+ [727] = { "EVENT_GOT_TM35" },
+ [736] = { "EVENT_GAVE_FOSSIL_TO_LAB" },
+ [737] = { "EVENT_LAB_STILL_REVIVING_FOSSIL" },
+ [738] = { "EVENT_LAB_HANDING_OVER_FOSSIL_MON" },
+ [832] = { "EVENT_GOT_TM31" },
+ [848] = { "EVENT_DEFEATED_FIGHTING_DOJO" },
+ [849] = { "EVENT_BEAT_KARATE_MASTER" },
+ [850] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_0" },
+ [851] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_1" },
+ [852] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_2" },
+ [853] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_3" },
+ [854] = { "EVENT_GOT_HITMONLEE" },
+ [855] = { "EVENT_GOT_HITMONCHAN" },
+ [864] = { "EVENT_GOT_TM46" },
+ [865] = { "EVENT_BEAT_SABRINA" },
+ [866] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_0" },
+ [867] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_1" },
+ [868] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_2" },
+ [869] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_3" },
+ [870] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_4" },
+ [871] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_5" },
+ [872] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_6" },
+ [919] = { "EVENT_SILPH_CO_RECEPTIONIST_AT_DESK" },
+ [944] = { "EVENT_GOT_TM29" },
+ [960] = { "EVENT_GOT_POTION_SAMPLE" },
+ [984] = { "EVENT_GOT_HM05" },
+ [994] = { "EVENT_BEAT_ROUTE_3_TRAINER_0" },
+ [995] = { "EVENT_BEAT_ROUTE_3_TRAINER_1" },
+ [996] = { "EVENT_BEAT_ROUTE_3_TRAINER_2" },
+ [997] = { "EVENT_BEAT_ROUTE_3_TRAINER_3" },
+ [998] = { "EVENT_BEAT_ROUTE_3_TRAINER_4" },
+ [999] = { "EVENT_BEAT_ROUTE_3_TRAINER_5" },
+ [1000] = { "EVENT_BEAT_ROUTE_3_TRAINER_6" },
+ [1001] = { "EVENT_BEAT_ROUTE_3_TRAINER_7" },
+ [1010] = { "EVENT_BEAT_ROUTE_4_TRAINER_0" },
+ [1023] = { "EVENT_BOUGHT_MAGIKARP" },
+ [1041] = { "EVENT_BEAT_ROUTE_6_TRAINER_0" },
+ [1042] = { "EVENT_BEAT_ROUTE_6_TRAINER_1" },
+ [1043] = { "EVENT_BEAT_ROUTE_6_TRAINER_2" },
+ [1044] = { "EVENT_BEAT_ROUTE_6_TRAINER_3" },
+ [1045] = { "EVENT_BEAT_ROUTE_6_TRAINER_4" },
+ [1046] = { "EVENT_BEAT_ROUTE_6_TRAINER_5" },
+ [1073] = { "EVENT_BEAT_ROUTE_8_TRAINER_0" },
+ [1074] = { "EVENT_BEAT_ROUTE_8_TRAINER_1" },
+ [1075] = { "EVENT_BEAT_ROUTE_8_TRAINER_2" },
+ [1076] = { "EVENT_BEAT_ROUTE_8_TRAINER_3" },
+ [1077] = { "EVENT_BEAT_ROUTE_8_TRAINER_4" },
+ [1078] = { "EVENT_BEAT_ROUTE_8_TRAINER_5" },
+ [1079] = { "EVENT_BEAT_ROUTE_8_TRAINER_6" },
+ [1080] = { "EVENT_BEAT_ROUTE_8_TRAINER_7" },
+ [1081] = { "EVENT_BEAT_ROUTE_8_TRAINER_8" },
+ [1089] = { "EVENT_BEAT_ROUTE_9_TRAINER_0" },
+ [1090] = { "EVENT_BEAT_ROUTE_9_TRAINER_1" },
+ [1091] = { "EVENT_BEAT_ROUTE_9_TRAINER_2" },
+ [1092] = { "EVENT_BEAT_ROUTE_9_TRAINER_3" },
+ [1093] = { "EVENT_BEAT_ROUTE_9_TRAINER_4" },
+ [1094] = { "EVENT_BEAT_ROUTE_9_TRAINER_5" },
+ [1095] = { "EVENT_BEAT_ROUTE_9_TRAINER_6" },
+ [1096] = { "EVENT_BEAT_ROUTE_9_TRAINER_7" },
+ [1097] = { "EVENT_BEAT_ROUTE_9_TRAINER_8" },
+ [1105] = { "EVENT_BEAT_ROUTE_10_TRAINER_0" },
+ [1106] = { "EVENT_BEAT_ROUTE_10_TRAINER_1" },
+ [1107] = { "EVENT_BEAT_ROUTE_10_TRAINER_2" },
+ [1108] = { "EVENT_BEAT_ROUTE_10_TRAINER_3" },
+ [1109] = { "EVENT_BEAT_ROUTE_10_TRAINER_4" },
+ [1110] = { "EVENT_BEAT_ROUTE_10_TRAINER_5" },
+ [1113] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0" },
+ [1114] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1" },
+ [1115] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2" },
+ [1116] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3" },
+ [1117] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4" },
+ [1118] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5" },
+ [1119] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6" },
+ [1121] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_0" },
+ [1122] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_1" },
+ [1123] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_2" },
+ [1124] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_3" },
+ [1125] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_4" },
+ [1126] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_5" },
+ [1127] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_6" },
+ [1128] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_7" },
+ [1129] = { "EVENT_BEAT_ZAPDOS" },
+ [1137] = { "EVENT_BEAT_ROUTE_11_TRAINER_0" },
+ [1138] = { "EVENT_BEAT_ROUTE_11_TRAINER_1" },
+ [1139] = { "EVENT_BEAT_ROUTE_11_TRAINER_2" },
+ [1140] = { "EVENT_BEAT_ROUTE_11_TRAINER_3" },
+ [1141] = { "EVENT_BEAT_ROUTE_11_TRAINER_4" },
+ [1142] = { "EVENT_BEAT_ROUTE_11_TRAINER_5" },
+ [1143] = { "EVENT_BEAT_ROUTE_11_TRAINER_6" },
+ [1144] = { "EVENT_BEAT_ROUTE_11_TRAINER_7" },
+ [1145] = { "EVENT_BEAT_ROUTE_11_TRAINER_8" },
+ [1146] = { "EVENT_BEAT_ROUTE_11_TRAINER_9" },
+ [1151] = { "EVENT_GOT_ITEMFINDER" },
+ [1152] = { "EVENT_GOT_TM39" },
+ [1154] = { "EVENT_BEAT_ROUTE_12_TRAINER_0" },
+ [1155] = { "EVENT_BEAT_ROUTE_12_TRAINER_1" },
+ [1156] = { "EVENT_BEAT_ROUTE_12_TRAINER_2" },
+ [1157] = { "EVENT_BEAT_ROUTE_12_TRAINER_3" },
+ [1158] = { "EVENT_BEAT_ROUTE_12_TRAINER_4" },
+ [1159] = { "EVENT_BEAT_ROUTE_12_TRAINER_5" },
+ [1160] = { "EVENT_BEAT_ROUTE_12_TRAINER_6" },
+ [1166] = { "EVENT_FIGHT_ROUTE12_SNORLAX" },
+ [1167] = { "EVENT_BEAT_ROUTE12_SNORLAX" },
+ [1169] = { "EVENT_BEAT_ROUTE_13_TRAINER_0" },
+ [1170] = { "EVENT_BEAT_ROUTE_13_TRAINER_1" },
+ [1171] = { "EVENT_BEAT_ROUTE_13_TRAINER_2" },
+ [1172] = { "EVENT_BEAT_ROUTE_13_TRAINER_3" },
+ [1173] = { "EVENT_BEAT_ROUTE_13_TRAINER_4" },
+ [1174] = { "EVENT_BEAT_ROUTE_13_TRAINER_5" },
+ [1175] = { "EVENT_BEAT_ROUTE_13_TRAINER_6" },
+ [1176] = { "EVENT_BEAT_ROUTE_13_TRAINER_7" },
+ [1177] = { "EVENT_BEAT_ROUTE_13_TRAINER_8" },
+ [1178] = { "EVENT_BEAT_ROUTE_13_TRAINER_9" },
+ [1185] = { "EVENT_BEAT_ROUTE_14_TRAINER_0" },
+ [1186] = { "EVENT_BEAT_ROUTE_14_TRAINER_1" },
+ [1187] = { "EVENT_BEAT_ROUTE_14_TRAINER_2" },
+ [1188] = { "EVENT_BEAT_ROUTE_14_TRAINER_3" },
+ [1189] = { "EVENT_BEAT_ROUTE_14_TRAINER_4" },
+ [1190] = { "EVENT_BEAT_ROUTE_14_TRAINER_5" },
+ [1191] = { "EVENT_BEAT_ROUTE_14_TRAINER_6" },
+ [1192] = { "EVENT_BEAT_ROUTE_14_TRAINER_7" },
+ [1193] = { "EVENT_BEAT_ROUTE_14_TRAINER_8" },
+ [1194] = { "EVENT_BEAT_ROUTE_14_TRAINER_9" },
+ [1200] = { "EVENT_GOT_EXP_ALL" },
+ [1201] = { "EVENT_BEAT_ROUTE_15_TRAINER_0" },
+ [1202] = { "EVENT_BEAT_ROUTE_15_TRAINER_1" },
+ [1203] = { "EVENT_BEAT_ROUTE_15_TRAINER_2" },
+ [1204] = { "EVENT_BEAT_ROUTE_15_TRAINER_3" },
+ [1205] = { "EVENT_BEAT_ROUTE_15_TRAINER_4" },
+ [1206] = { "EVENT_BEAT_ROUTE_15_TRAINER_5" },
+ [1207] = { "EVENT_BEAT_ROUTE_15_TRAINER_6" },
+ [1208] = { "EVENT_BEAT_ROUTE_15_TRAINER_7" },
+ [1209] = { "EVENT_BEAT_ROUTE_15_TRAINER_8" },
+ [1210] = { "EVENT_BEAT_ROUTE_15_TRAINER_9" },
+ [1217] = { "EVENT_BEAT_ROUTE_16_TRAINER_0" },
+ [1218] = { "EVENT_BEAT_ROUTE_16_TRAINER_1" },
+ [1219] = { "EVENT_BEAT_ROUTE_16_TRAINER_2" },
+ [1220] = { "EVENT_BEAT_ROUTE_16_TRAINER_3" },
+ [1221] = { "EVENT_BEAT_ROUTE_16_TRAINER_4" },
+ [1222] = { "EVENT_BEAT_ROUTE_16_TRAINER_5" },
+ [1224] = { "EVENT_FIGHT_ROUTE16_SNORLAX" },
+ [1225] = { "EVENT_BEAT_ROUTE16_SNORLAX" },
+ [1230] = { "EVENT_GOT_HM02" },
+ [1231] = { "EVENT_RESCUED_MR_FUJI" },
+ [1233] = { "EVENT_BEAT_ROUTE_17_TRAINER_0" },
+ [1234] = { "EVENT_BEAT_ROUTE_17_TRAINER_1" },
+ [1235] = { "EVENT_BEAT_ROUTE_17_TRAINER_2" },
+ [1236] = { "EVENT_BEAT_ROUTE_17_TRAINER_3" },
+ [1237] = { "EVENT_BEAT_ROUTE_17_TRAINER_4" },
+ [1238] = { "EVENT_BEAT_ROUTE_17_TRAINER_5" },
+ [1239] = { "EVENT_BEAT_ROUTE_17_TRAINER_6" },
+ [1240] = { "EVENT_BEAT_ROUTE_17_TRAINER_7" },
+ [1241] = { "EVENT_BEAT_ROUTE_17_TRAINER_8" },
+ [1242] = { "EVENT_BEAT_ROUTE_17_TRAINER_9" },
+ [1249] = { "EVENT_BEAT_ROUTE_18_TRAINER_0" },
+ [1250] = { "EVENT_BEAT_ROUTE_18_TRAINER_1" },
+ [1251] = { "EVENT_BEAT_ROUTE_18_TRAINER_2" },
+ [1265] = { "EVENT_BEAT_ROUTE_19_TRAINER_0" },
+ [1266] = { "EVENT_BEAT_ROUTE_19_TRAINER_1" },
+ [1267] = { "EVENT_BEAT_ROUTE_19_TRAINER_2" },
+ [1268] = { "EVENT_BEAT_ROUTE_19_TRAINER_3" },
+ [1269] = { "EVENT_BEAT_ROUTE_19_TRAINER_4" },
+ [1270] = { "EVENT_BEAT_ROUTE_19_TRAINER_5" },
+ [1271] = { "EVENT_BEAT_ROUTE_19_TRAINER_6" },
+ [1272] = { "EVENT_BEAT_ROUTE_19_TRAINER_7" },
+ [1273] = { "EVENT_BEAT_ROUTE_19_TRAINER_8" },
+ [1274] = { "EVENT_BEAT_ROUTE_19_TRAINER_9" },
+ [1280] = { "EVENT_IN_SEAFOAM_ISLANDS" },
+ [1281] = { "EVENT_BEAT_ROUTE_20_TRAINER_0" },
+ [1282] = { "EVENT_BEAT_ROUTE_20_TRAINER_1" },
+ [1283] = { "EVENT_BEAT_ROUTE_20_TRAINER_2" },
+ [1284] = { "EVENT_BEAT_ROUTE_20_TRAINER_3" },
+ [1285] = { "EVENT_BEAT_ROUTE_20_TRAINER_4" },
+ [1286] = { "EVENT_BEAT_ROUTE_20_TRAINER_5" },
+ [1287] = { "EVENT_BEAT_ROUTE_20_TRAINER_6" },
+ [1288] = { "EVENT_BEAT_ROUTE_20_TRAINER_7" },
+ [1289] = { "EVENT_BEAT_ROUTE_20_TRAINER_8" },
+ [1290] = { "EVENT_BEAT_ROUTE_20_TRAINER_9" },
+ [1294] = { "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE" },
+ [1295] = { "EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE" },
+ [1297] = { "EVENT_BEAT_ROUTE_21_TRAINER_0" },
+ [1298] = { "EVENT_BEAT_ROUTE_21_TRAINER_1" },
+ [1299] = { "EVENT_BEAT_ROUTE_21_TRAINER_2" },
+ [1300] = { "EVENT_BEAT_ROUTE_21_TRAINER_3" },
+ [1301] = { "EVENT_BEAT_ROUTE_21_TRAINER_4" },
+ [1302] = { "EVENT_BEAT_ROUTE_21_TRAINER_5" },
+ [1303] = { "EVENT_BEAT_ROUTE_21_TRAINER_6" },
+ [1304] = { "EVENT_BEAT_ROUTE_21_TRAINER_7" },
+ [1305] = { "EVENT_BEAT_ROUTE_21_TRAINER_8" },
+ [1312] = { "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
+ [1313] = { "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
+ [1317] = { "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
+ [1318] = { "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE" },
+ [1319] = { "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
+ [1328] = { "EVENT_PASSED_CASCADEBADGE_CHECK" },
+ [1329] = { "EVENT_PASSED_THUNDERBADGE_CHECK" },
+ [1330] = { "EVENT_PASSED_RAINBOWBADGE_CHECK" },
+ [1331] = { "EVENT_PASSED_SOULBADGE_CHECK" },
+ [1332] = { "EVENT_PASSED_MARSHBADGE_CHECK" },
+ [1333] = { "EVENT_PASSED_VOLCANOBADGE_CHECK" },
+ [1334] = { "EVENT_PASSED_EARTHBADGE_CHECK" },
+ [1336] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1" },
+ [1337] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0" },
+ [1338] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1" },
+ [1339] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2" },
+ [1340] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3" },
+ [1341] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4" },
+ [1342] = { "EVENT_BEAT_MOLTRES" },
+ [1343] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2" },
+ [1344] = { "EVENT_GOT_NUGGET" },
+ [1345] = { "EVENT_BEAT_ROUTE24_ROCKET" },
+ [1346] = { "EVENT_BEAT_ROUTE_24_TRAINER_0" },
+ [1347] = { "EVENT_BEAT_ROUTE_24_TRAINER_1" },
+ [1348] = { "EVENT_BEAT_ROUTE_24_TRAINER_2" },
+ [1349] = { "EVENT_BEAT_ROUTE_24_TRAINER_3" },
+ [1350] = { "EVENT_BEAT_ROUTE_24_TRAINER_4" },
+ [1351] = { "EVENT_BEAT_ROUTE_24_TRAINER_5" },
+ [1353] = { "EVENT_NUGGET_REWARD_AVAILABLE" },
+ [1360] = { "EVENT_MET_BILL" },
+ [1361] = { "EVENT_BEAT_ROUTE_25_TRAINER_0" },
+ [1362] = { "EVENT_BEAT_ROUTE_25_TRAINER_1" },
+ [1363] = { "EVENT_BEAT_ROUTE_25_TRAINER_2" },
+ [1364] = { "EVENT_BEAT_ROUTE_25_TRAINER_3" },
+ [1365] = { "EVENT_BEAT_ROUTE_25_TRAINER_4" },
+ [1366] = { "EVENT_BEAT_ROUTE_25_TRAINER_5" },
+ [1367] = { "EVENT_BEAT_ROUTE_25_TRAINER_6" },
+ [1368] = { "EVENT_BEAT_ROUTE_25_TRAINER_7" },
+ [1369] = { "EVENT_BEAT_ROUTE_25_TRAINER_8" },
+ [1371] = { "EVENT_USED_CELL_SEPARATOR_ON_BILL" },
+ [1372] = { "EVENT_GOT_SS_TICKET" },
+ [1373] = { "EVENT_MET_BILL_2" },
+ [1374] = { "EVENT_BILL_SAID_USE_CELL_SEPARATOR" },
+ [1375] = { "EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING" },
+ [1378] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0" },
+ [1379] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1" },
+ [1380] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2" },
+ [1393] = { "EVENT_BEAT_MT_MOON_1_TRAINER_0" },
+ [1394] = { "EVENT_BEAT_MT_MOON_1_TRAINER_1" },
+ [1395] = { "EVENT_BEAT_MT_MOON_1_TRAINER_2" },
+ [1396] = { "EVENT_BEAT_MT_MOON_1_TRAINER_3" },
+ [1397] = { "EVENT_BEAT_MT_MOON_1_TRAINER_4" },
+ [1398] = { "EVENT_BEAT_MT_MOON_1_TRAINER_5" },
+ [1399] = { "EVENT_BEAT_MT_MOON_1_TRAINER_6" },
+ [1401] = { "EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD" },
+ [1402] = { "EVENT_BEAT_MT_MOON_3_TRAINER_0" },
+ [1403] = { "EVENT_BEAT_MT_MOON_3_TRAINER_1" },
+ [1404] = { "EVENT_BEAT_MT_MOON_3_TRAINER_2" },
+ [1405] = { "EVENT_BEAT_MT_MOON_3_TRAINER_3" },
+ [1406] = { "EVENT_GOT_DOME_FOSSIL" },
+ [1407] = { "EVENT_GOT_HELIX_FOSSIL" },
+ [1476] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_0" },
+ [1477] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_1" },
+ [1504] = { "EVENT_GOT_HM01" },
+ [1505] = { "EVENT_RUBBED_CAPTAINS_BACK" },
+ [1506] = { "EVENT_SS_ANNE_LEFT" },
+ [1507] = { "EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT" },
+ [1508] = { "EVENT_STARTED_WALKING_OUT_OF_DOCK" },
+ [1509] = { "EVENT_WALKED_OUT_OF_DOCK" },
+ [1521] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_0" },
+ [1522] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_1" },
+ [1523] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_2" },
+ [1524] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_3" },
+ [1537] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_0" },
+ [1538] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_1" },
+ [1539] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_2" },
+ [1540] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_3" },
+ [1553] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_0" },
+ [1554] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_1" },
+ [1555] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_2" },
+ [1556] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_3" },
+ [1557] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_4" },
+ [1558] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_5" },
+ [1632] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1" },
+ [1633] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0" },
+ [1634] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1" },
+ [1635] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2" },
+ [1636] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3" },
+ [1638] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2" },
+ [1649] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0" },
+ [1650] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1" },
+ [1651] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2" },
+ [1652] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3" },
+ [1653] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" },
+ [1655] = { "EVENT_ENTERED_ROCKET_HIDEOUT" },
+ [1663] = { "EVENT_67F" },
+ [1665] = { "EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0" },
+ [1681] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0" },
+ [1682] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1" },
+ [1698] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0" },
+ [1699] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" },
+ [1700] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2" },
+ [1701] = { "EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED" },
+ [1702] = { "EVENT_ROCKET_DROPPED_LIFT_KEY" },
+ [1703] = { "EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI" },
+ [1778] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_0" },
+ [1779] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_1" },
+ [1780] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_2" },
+ [1781] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_3" },
+ [1789] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR1" },
+ [1790] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR2" },
+ [1791] = { "EVENT_GOT_TM36" },
+ [1794] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_0" },
+ [1795] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_1" },
+ [1800] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR1" },
+ [1801] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR2" },
+ [1810] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_0" },
+ [1811] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_1" },
+ [1812] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_2" },
+ [1816] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR1" },
+ [1817] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR2" },
+ [1826] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_0" },
+ [1827] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_1" },
+ [1828] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_2" },
+ [1829] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_3" },
+ [1832] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR1" },
+ [1833] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR2" },
+ [1834] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR3" },
+ [1846] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_0" },
+ [1847] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_1" },
+ [1848] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_2" },
+ [1855] = { "EVENT_SILPH_CO_6_UNLOCKED_DOOR" },
+ [1856] = { "EVENT_BEAT_SILPH_CO_RIVAL" },
+ [1861] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_0" },
+ [1862] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_1" },
+ [1863] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_2" },
+ [1864] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_3" },
+ [1868] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR1" },
+ [1869] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR2" },
+ [1870] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR3" },
+ [1874] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_0" },
+ [1875] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_1" },
+ [1876] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_2" },
+ [1880] = { "EVENT_SILPH_CO_8_UNLOCKED_DOOR" },
+ [1890] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_0" },
+ [1891] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_1" },
+ [1892] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_2" },
+ [1896] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR1" },
+ [1897] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR2" },
+ [1898] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR3" },
+ [1899] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR4" },
+ [1905] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_0" },
+ [1906] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_1" },
+ [1912] = { "EVENT_SILPH_CO_10_UNLOCKED_DOOR" },
+ [1924] = { "EVENT_BEAT_SILPH_CO_11F_TRAINER_0" },
+ [1925] = { "EVENT_BEAT_SILPH_CO_11F_TRAINER_1" },
+ [1928] = { "EVENT_SILPH_CO_11_UNLOCKED_DOOR" },
+ [1933] = { "EVENT_GOT_MASTER_BALL" },
+ [1935] = { "EVENT_BEAT_SILPH_CO_GIOVANNI" },
+ [2049] = { "EVENT_BEAT_MANSION_2_TRAINER_0" },
+ [2065] = { "EVENT_BEAT_MANSION_3_TRAINER_0" },
+ [2066] = { "EVENT_BEAT_MANSION_3_TRAINER_1" },
+ [2081] = { "EVENT_BEAT_MANSION_4_TRAINER_0" },
+ [2082] = { "EVENT_BEAT_MANSION_4_TRAINER_1" },
+ [2176] = { "EVENT_GOT_HM03" },
+ [2241] = { "EVENT_BEAT_MEWTWO" },
+ [2273] = { "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0" },
+ [2278] = { "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM" },
+ [2281] = { "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0" },
+ [2286] = { "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM" },
+ [2289] = { "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0" },
+ [2294] = { "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM" },
+ [2297] = { "EVENT_BEAT_LANCES_ROOM_TRAINER_0" },
+ [2302] = { "EVENT_BEAT_LANCE" },
+ [2303] = { "EVENT_LANCES_ROOM_LOCK_DOOR" },
+ [2305] = { "EVENT_BEAT_CHAMPION_RIVAL" },
+ [2321] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0" },
+ [2322] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1" },
+ [2327] = { "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH" },
+ [2481] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0" },
+ [2482] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1" },
+ [2483] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2" },
+ [2484] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3" },
+ [2485] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4" },
+ [2486] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5" },
+ [2487] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6" },
+ [2488] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7" },
+ [2496] = { "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE" },
+ [2497] = { "EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE" },
+ [2504] = { "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE" },
+ [2505] = { "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE" },
+ [2512] = { "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE" },
+ [2513] = { "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE" },
+ [2522] = { "EVENT_BEAT_ARTICUNO" },
+ },
+}
diff --git a/src/mods/flag_names/yellow.lua b/src/mods/flag_names/yellow.lua
new file mode 100644
index 000000000..21cbb4acd
--- /dev/null
+++ b/src/mods/flag_names/yellow.lua
@@ -0,0 +1,529 @@
+-- Generated flag identifiers only; no engine implementation.
+-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36
+-- Regenerate with tools/flag-catalog-check/generate.py.
+return {
+ records = {
+ [0] = { "EVENT_FOLLOWED_OAK_INTO_LAB" },
+ [3] = { "EVENT_HALL_OF_FAME_DEX_RATING" },
+ [5] = { "EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN" },
+ [6] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
+ [24] = { "EVENT_GOT_TOWN_MAP" },
+ [25] = { "EVENT_ENTERED_BLUES_HOUSE" },
+ [26] = { "EVENT_DAISY_WALKING" },
+ [32] = { "EVENT_FOLLOWED_OAK_INTO_LAB_2" },
+ [33] = { "EVENT_OAK_ASKED_TO_CHOOSE_MON" },
+ [34] = { "EVENT_GOT_STARTER" },
+ [35] = { "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
+ [36] = { "EVENT_GOT_POKEBALLS_FROM_OAK" },
+ [37] = { "EVENT_GOT_POKEDEX" },
+ [38] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS_2" },
+ [39] = { "EVENT_OAK_APPEARED_IN_PALLET" },
+ [40] = { "EVENT_VIRIDIAN_GYM_OPEN" },
+ [41] = { "EVENT_GOT_TM42" },
+ [44] = { "EVENT_SPAWNED_OLD_MAN_1" },
+ [45] = { "EVENT_COMPLETED_CATCH_TRAINING" },
+ [46] = { "EVENT_COMPLETED_CATCH_TRAINING_AGAIN" },
+ [47] = { "EVENT_INITIAL_CATCH_TRAINING" },
+ [56] = { "EVENT_OAK_GOT_PARCEL" },
+ [57] = { "EVENT_GOT_OAKS_PARCEL" },
+ [80] = { "EVENT_GOT_TM27" },
+ [81] = { "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI" },
+ [82] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0" },
+ [83] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1" },
+ [84] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2" },
+ [85] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3" },
+ [86] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4" },
+ [87] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5" },
+ [88] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6" },
+ [89] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7" },
+ [104] = { "EVENT_BOUGHT_MUSEUM_TICKET" },
+ [105] = { "EVENT_GOT_OLD_AMBER" },
+ [114] = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
+ [118] = { "EVENT_GOT_TM34" },
+ [119] = { "EVENT_BEAT_BROCK" },
+ [152] = { "EVENT_BEAT_CERULEAN_RIVAL" },
+ [167] = { "EVENT_BEAT_CERULEAN_ROCKET_THIEF" },
+ [168] = { "EVENT_GOT_BULBASAUR_IN_CERULEAN" },
+ [186] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_0" },
+ [187] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_1" },
+ [190] = { "EVENT_GOT_TM11" },
+ [191] = { "EVENT_BEAT_MISTY" },
+ [192] = { "EVENT_GOT_BICYCLE" },
+ [238] = { "EVENT_POKEMON_TOWER_RIVAL_ON_LEFT" },
+ [239] = { "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
+ [241] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_0" },
+ [242] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_1" },
+ [243] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_2" },
+ [249] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_0" },
+ [250] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_1" },
+ [251] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_2" },
+ [258] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_0" },
+ [259] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_1" },
+ [260] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_2" },
+ [261] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_3" },
+ [263] = { "EVENT_IN_PURIFIED_ZONE" },
+ [265] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_0" },
+ [266] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_1" },
+ [267] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_2" },
+ [271] = { "EVENT_BEAT_GHOST_MAROWAK" },
+ [273] = { "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES" },
+ [274] = { "EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT" },
+ [279] = { "EVENT_RESCUED_MR_FUJI_2" },
+ [296] = { "EVENT_GOT_POKE_FLUTE" },
+ [327] = { "EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" },
+ [337] = { "EVENT_GOT_BIKE_VOUCHER" },
+ [338] = { "EVENT_LEFT_FANCLUB_AFTER_BIKE_VOUCHER" },
+ [342] = { "EVENT_SEEL_FAN_BOAST" },
+ [343] = { "EVENT_PIKACHU_FAN_BOAST" },
+ [352] = { "EVENT_2ND_LOCK_OPENED" },
+ [353] = { "EVENT_1ST_LOCK_OPENED" },
+ [354] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_0" },
+ [355] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_1" },
+ [356] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_2" },
+ [358] = { "EVENT_GOT_TM24" },
+ [359] = { "EVENT_BEAT_LT_SURGE" },
+ [384] = { "EVENT_GOT_TM41" },
+ [396] = { "EVENT_GOT_TM13" },
+ [397] = { "EVENT_GOT_TM48" },
+ [398] = { "EVENT_GOT_TM49" },
+ [399] = { "EVENT_GOT_TM18" },
+ [424] = { "EVENT_GOT_TM21" },
+ [425] = { "EVENT_BEAT_ERIKA" },
+ [426] = { "EVENT_BEAT_CELADON_GYM_TRAINER_0" },
+ [427] = { "EVENT_BEAT_CELADON_GYM_TRAINER_1" },
+ [428] = { "EVENT_BEAT_CELADON_GYM_TRAINER_2" },
+ [429] = { "EVENT_BEAT_CELADON_GYM_TRAINER_3" },
+ [430] = { "EVENT_BEAT_CELADON_GYM_TRAINER_4" },
+ [431] = { "EVENT_BEAT_CELADON_GYM_TRAINER_5" },
+ [432] = { "EVENT_BEAT_CELADON_GYM_TRAINER_6" },
+ [440] = { "EVENT_1B8" },
+ [441] = { "EVENT_FOUND_ROCKET_HIDEOUT" },
+ [442] = { "EVENT_GOT_10_COINS" },
+ [443] = { "EVENT_GOT_20_COINS" },
+ [444] = { "EVENT_GOT_20_COINS_2" },
+ [447] = { "EVENT_1BF" },
+ [480] = { "EVENT_GOT_COIN_CASE" },
+ [568] = { "EVENT_GOT_HM04" },
+ [569] = { "EVENT_GAVE_GOLD_TEETH" },
+ [590] = { "EVENT_SAFARI_GAME_OVER" },
+ [591] = { "EVENT_IN_SAFARI_ZONE" },
+ [600] = { "EVENT_GOT_TM06" },
+ [601] = { "EVENT_BEAT_KOGA" },
+ [602] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_0" },
+ [603] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_1" },
+ [604] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_2" },
+ [605] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_3" },
+ [606] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_4" },
+ [607] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_5" },
+ [632] = { "EVENT_MANSION_SWITCH_ON" },
+ [649] = { "EVENT_BEAT_MANSION_1_TRAINER_0" },
+ [664] = { "EVENT_GOT_TM38" },
+ [665] = { "EVENT_BEAT_BLAINE" },
+ [666] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_0" },
+ [667] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_1" },
+ [668] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_2" },
+ [669] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_3" },
+ [670] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_4" },
+ [671] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_5" },
+ [672] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_6" },
+ [679] = { "EVENT_2A7" },
+ [680] = { "EVENT_CINNABAR_GYM_GATE0_UNLOCKED" },
+ [681] = { "EVENT_CINNABAR_GYM_GATE1_UNLOCKED" },
+ [682] = { "EVENT_CINNABAR_GYM_GATE2_UNLOCKED" },
+ [683] = { "EVENT_CINNABAR_GYM_GATE3_UNLOCKED" },
+ [684] = { "EVENT_CINNABAR_GYM_GATE4_UNLOCKED" },
+ [685] = { "EVENT_CINNABAR_GYM_GATE5_UNLOCKED" },
+ [686] = { "EVENT_CINNABAR_GYM_GATE6_UNLOCKED" },
+ [727] = { "EVENT_GOT_TM35" },
+ [736] = { "EVENT_GAVE_FOSSIL_TO_LAB" },
+ [737] = { "EVENT_LAB_STILL_REVIVING_FOSSIL" },
+ [738] = { "EVENT_LAB_HANDING_OVER_FOSSIL_MON" },
+ [832] = { "EVENT_GOT_TM31" },
+ [848] = { "EVENT_DEFEATED_FIGHTING_DOJO" },
+ [849] = { "EVENT_BEAT_KARATE_MASTER" },
+ [850] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_0" },
+ [851] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_1" },
+ [852] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_2" },
+ [853] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_3" },
+ [854] = { "EVENT_GOT_HITMONLEE" },
+ [855] = { "EVENT_GOT_HITMONCHAN" },
+ [864] = { "EVENT_GOT_TM46" },
+ [865] = { "EVENT_BEAT_SABRINA" },
+ [866] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_0" },
+ [867] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_1" },
+ [868] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_2" },
+ [869] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_3" },
+ [870] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_4" },
+ [871] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_5" },
+ [872] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_6" },
+ [919] = { "EVENT_SILPH_CO_RECEPTIONIST_AT_DESK" },
+ [944] = { "EVENT_GOT_TM29" },
+ [960] = { "EVENT_GOT_POTION_SAMPLE" },
+ [984] = { "EVENT_GOT_HM05" },
+ [994] = { "EVENT_BEAT_ROUTE_3_TRAINER_0" },
+ [995] = { "EVENT_BEAT_ROUTE_3_TRAINER_1" },
+ [996] = { "EVENT_BEAT_ROUTE_3_TRAINER_2" },
+ [997] = { "EVENT_BEAT_ROUTE_3_TRAINER_3" },
+ [998] = { "EVENT_BEAT_ROUTE_3_TRAINER_4" },
+ [999] = { "EVENT_BEAT_ROUTE_3_TRAINER_5" },
+ [1000] = { "EVENT_BEAT_ROUTE_3_TRAINER_6" },
+ [1001] = { "EVENT_BEAT_ROUTE_3_TRAINER_7" },
+ [1010] = { "EVENT_BEAT_ROUTE_4_TRAINER_0" },
+ [1023] = { "EVENT_BOUGHT_MAGIKARP" },
+ [1041] = { "EVENT_BEAT_ROUTE_6_TRAINER_0" },
+ [1042] = { "EVENT_BEAT_ROUTE_6_TRAINER_1" },
+ [1043] = { "EVENT_BEAT_ROUTE_6_TRAINER_2" },
+ [1044] = { "EVENT_BEAT_ROUTE_6_TRAINER_3" },
+ [1045] = { "EVENT_BEAT_ROUTE_6_TRAINER_4" },
+ [1046] = { "EVENT_BEAT_ROUTE_6_TRAINER_5" },
+ [1073] = { "EVENT_BEAT_ROUTE_8_TRAINER_0" },
+ [1074] = { "EVENT_BEAT_ROUTE_8_TRAINER_1" },
+ [1075] = { "EVENT_BEAT_ROUTE_8_TRAINER_2" },
+ [1076] = { "EVENT_BEAT_ROUTE_8_TRAINER_3" },
+ [1077] = { "EVENT_BEAT_ROUTE_8_TRAINER_4" },
+ [1078] = { "EVENT_BEAT_ROUTE_8_TRAINER_5" },
+ [1079] = { "EVENT_BEAT_ROUTE_8_TRAINER_6" },
+ [1080] = { "EVENT_BEAT_ROUTE_8_TRAINER_7" },
+ [1081] = { "EVENT_BEAT_ROUTE_8_TRAINER_8" },
+ [1089] = { "EVENT_BEAT_ROUTE_9_TRAINER_0" },
+ [1090] = { "EVENT_BEAT_ROUTE_9_TRAINER_1" },
+ [1091] = { "EVENT_BEAT_ROUTE_9_TRAINER_2" },
+ [1092] = { "EVENT_BEAT_ROUTE_9_TRAINER_3" },
+ [1093] = { "EVENT_BEAT_ROUTE_9_TRAINER_4" },
+ [1094] = { "EVENT_BEAT_ROUTE_9_TRAINER_5" },
+ [1095] = { "EVENT_BEAT_ROUTE_9_TRAINER_6" },
+ [1096] = { "EVENT_BEAT_ROUTE_9_TRAINER_7" },
+ [1097] = { "EVENT_BEAT_ROUTE_9_TRAINER_8" },
+ [1105] = { "EVENT_BEAT_ROUTE_10_TRAINER_0" },
+ [1106] = { "EVENT_BEAT_ROUTE_10_TRAINER_1" },
+ [1107] = { "EVENT_BEAT_ROUTE_10_TRAINER_2" },
+ [1108] = { "EVENT_BEAT_ROUTE_10_TRAINER_3" },
+ [1109] = { "EVENT_BEAT_ROUTE_10_TRAINER_4" },
+ [1110] = { "EVENT_BEAT_ROUTE_10_TRAINER_5" },
+ [1113] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0" },
+ [1114] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1" },
+ [1115] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2" },
+ [1116] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3" },
+ [1117] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4" },
+ [1118] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5" },
+ [1119] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6" },
+ [1121] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_0" },
+ [1122] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_1" },
+ [1123] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_2" },
+ [1124] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_3" },
+ [1125] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_4" },
+ [1126] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_5" },
+ [1127] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_6" },
+ [1128] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_7" },
+ [1129] = { "EVENT_BEAT_ZAPDOS" },
+ [1137] = { "EVENT_BEAT_ROUTE_11_TRAINER_0" },
+ [1138] = { "EVENT_BEAT_ROUTE_11_TRAINER_1" },
+ [1139] = { "EVENT_BEAT_ROUTE_11_TRAINER_2" },
+ [1140] = { "EVENT_BEAT_ROUTE_11_TRAINER_3" },
+ [1141] = { "EVENT_BEAT_ROUTE_11_TRAINER_4" },
+ [1142] = { "EVENT_BEAT_ROUTE_11_TRAINER_5" },
+ [1143] = { "EVENT_BEAT_ROUTE_11_TRAINER_6" },
+ [1144] = { "EVENT_BEAT_ROUTE_11_TRAINER_7" },
+ [1145] = { "EVENT_BEAT_ROUTE_11_TRAINER_8" },
+ [1146] = { "EVENT_BEAT_ROUTE_11_TRAINER_9" },
+ [1151] = { "EVENT_GOT_ITEMFINDER" },
+ [1152] = { "EVENT_GOT_TM39" },
+ [1154] = { "EVENT_BEAT_ROUTE_12_TRAINER_0" },
+ [1155] = { "EVENT_BEAT_ROUTE_12_TRAINER_1" },
+ [1156] = { "EVENT_BEAT_ROUTE_12_TRAINER_2" },
+ [1157] = { "EVENT_BEAT_ROUTE_12_TRAINER_3" },
+ [1158] = { "EVENT_BEAT_ROUTE_12_TRAINER_4" },
+ [1159] = { "EVENT_BEAT_ROUTE_12_TRAINER_5" },
+ [1160] = { "EVENT_BEAT_ROUTE_12_TRAINER_6" },
+ [1166] = { "EVENT_FIGHT_ROUTE12_SNORLAX" },
+ [1167] = { "EVENT_BEAT_ROUTE12_SNORLAX" },
+ [1169] = { "EVENT_BEAT_ROUTE_13_TRAINER_0" },
+ [1170] = { "EVENT_BEAT_ROUTE_13_TRAINER_1" },
+ [1171] = { "EVENT_BEAT_ROUTE_13_TRAINER_2" },
+ [1172] = { "EVENT_BEAT_ROUTE_13_TRAINER_3" },
+ [1173] = { "EVENT_BEAT_ROUTE_13_TRAINER_4" },
+ [1174] = { "EVENT_BEAT_ROUTE_13_TRAINER_5" },
+ [1175] = { "EVENT_BEAT_ROUTE_13_TRAINER_6" },
+ [1176] = { "EVENT_BEAT_ROUTE_13_TRAINER_7" },
+ [1177] = { "EVENT_BEAT_ROUTE_13_TRAINER_8" },
+ [1178] = { "EVENT_BEAT_ROUTE_13_TRAINER_9" },
+ [1185] = { "EVENT_BEAT_ROUTE_14_TRAINER_0" },
+ [1186] = { "EVENT_BEAT_ROUTE_14_TRAINER_1" },
+ [1187] = { "EVENT_BEAT_ROUTE_14_TRAINER_2" },
+ [1188] = { "EVENT_BEAT_ROUTE_14_TRAINER_3" },
+ [1189] = { "EVENT_BEAT_ROUTE_14_TRAINER_4" },
+ [1190] = { "EVENT_BEAT_ROUTE_14_TRAINER_5" },
+ [1191] = { "EVENT_BEAT_ROUTE_14_TRAINER_6" },
+ [1192] = { "EVENT_BEAT_ROUTE_14_TRAINER_7" },
+ [1193] = { "EVENT_BEAT_ROUTE_14_TRAINER_8" },
+ [1194] = { "EVENT_BEAT_ROUTE_14_TRAINER_9" },
+ [1200] = { "EVENT_GOT_EXP_ALL" },
+ [1201] = { "EVENT_BEAT_ROUTE_15_TRAINER_0" },
+ [1202] = { "EVENT_BEAT_ROUTE_15_TRAINER_1" },
+ [1203] = { "EVENT_BEAT_ROUTE_15_TRAINER_2" },
+ [1204] = { "EVENT_BEAT_ROUTE_15_TRAINER_3" },
+ [1205] = { "EVENT_BEAT_ROUTE_15_TRAINER_4" },
+ [1206] = { "EVENT_BEAT_ROUTE_15_TRAINER_5" },
+ [1207] = { "EVENT_BEAT_ROUTE_15_TRAINER_6" },
+ [1208] = { "EVENT_BEAT_ROUTE_15_TRAINER_7" },
+ [1209] = { "EVENT_BEAT_ROUTE_15_TRAINER_8" },
+ [1210] = { "EVENT_BEAT_ROUTE_15_TRAINER_9" },
+ [1217] = { "EVENT_BEAT_ROUTE_16_TRAINER_0" },
+ [1218] = { "EVENT_BEAT_ROUTE_16_TRAINER_1" },
+ [1219] = { "EVENT_BEAT_ROUTE_16_TRAINER_2" },
+ [1220] = { "EVENT_BEAT_ROUTE_16_TRAINER_3" },
+ [1221] = { "EVENT_BEAT_ROUTE_16_TRAINER_4" },
+ [1222] = { "EVENT_BEAT_ROUTE_16_TRAINER_5" },
+ [1224] = { "EVENT_FIGHT_ROUTE16_SNORLAX" },
+ [1225] = { "EVENT_BEAT_ROUTE16_SNORLAX" },
+ [1230] = { "EVENT_GOT_HM02" },
+ [1231] = { "EVENT_RESCUED_MR_FUJI" },
+ [1233] = { "EVENT_BEAT_ROUTE_17_TRAINER_0" },
+ [1234] = { "EVENT_BEAT_ROUTE_17_TRAINER_1" },
+ [1235] = { "EVENT_BEAT_ROUTE_17_TRAINER_2" },
+ [1236] = { "EVENT_BEAT_ROUTE_17_TRAINER_3" },
+ [1237] = { "EVENT_BEAT_ROUTE_17_TRAINER_4" },
+ [1238] = { "EVENT_BEAT_ROUTE_17_TRAINER_5" },
+ [1239] = { "EVENT_BEAT_ROUTE_17_TRAINER_6" },
+ [1240] = { "EVENT_BEAT_ROUTE_17_TRAINER_7" },
+ [1241] = { "EVENT_BEAT_ROUTE_17_TRAINER_8" },
+ [1242] = { "EVENT_BEAT_ROUTE_17_TRAINER_9" },
+ [1249] = { "EVENT_BEAT_ROUTE_18_TRAINER_0" },
+ [1250] = { "EVENT_BEAT_ROUTE_18_TRAINER_1" },
+ [1251] = { "EVENT_BEAT_ROUTE_18_TRAINER_2" },
+ [1265] = { "EVENT_BEAT_ROUTE_19_TRAINER_0" },
+ [1266] = { "EVENT_BEAT_ROUTE_19_TRAINER_1" },
+ [1267] = { "EVENT_BEAT_ROUTE_19_TRAINER_2" },
+ [1268] = { "EVENT_BEAT_ROUTE_19_TRAINER_3" },
+ [1269] = { "EVENT_BEAT_ROUTE_19_TRAINER_4" },
+ [1270] = { "EVENT_BEAT_ROUTE_19_TRAINER_5" },
+ [1271] = { "EVENT_BEAT_ROUTE_19_TRAINER_6" },
+ [1272] = { "EVENT_BEAT_ROUTE_19_TRAINER_7" },
+ [1273] = { "EVENT_BEAT_ROUTE_19_TRAINER_8" },
+ [1274] = { "EVENT_BEAT_ROUTE_19_TRAINER_9" },
+ [1280] = { "EVENT_IN_SEAFOAM_ISLANDS" },
+ [1281] = { "EVENT_BEAT_ROUTE_20_TRAINER_0" },
+ [1282] = { "EVENT_BEAT_ROUTE_20_TRAINER_1" },
+ [1283] = { "EVENT_BEAT_ROUTE_20_TRAINER_2" },
+ [1284] = { "EVENT_BEAT_ROUTE_20_TRAINER_3" },
+ [1285] = { "EVENT_BEAT_ROUTE_20_TRAINER_4" },
+ [1286] = { "EVENT_BEAT_ROUTE_20_TRAINER_5" },
+ [1287] = { "EVENT_BEAT_ROUTE_20_TRAINER_6" },
+ [1288] = { "EVENT_BEAT_ROUTE_20_TRAINER_7" },
+ [1289] = { "EVENT_BEAT_ROUTE_20_TRAINER_8" },
+ [1290] = { "EVENT_BEAT_ROUTE_20_TRAINER_9" },
+ [1294] = { "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE" },
+ [1295] = { "EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE" },
+ [1297] = { "EVENT_BEAT_ROUTE_21_TRAINER_0" },
+ [1298] = { "EVENT_BEAT_ROUTE_21_TRAINER_1" },
+ [1299] = { "EVENT_BEAT_ROUTE_21_TRAINER_2" },
+ [1300] = { "EVENT_BEAT_ROUTE_21_TRAINER_3" },
+ [1301] = { "EVENT_BEAT_ROUTE_21_TRAINER_4" },
+ [1302] = { "EVENT_BEAT_ROUTE_21_TRAINER_5" },
+ [1303] = { "EVENT_BEAT_ROUTE_21_TRAINER_6" },
+ [1304] = { "EVENT_BEAT_ROUTE_21_TRAINER_7" },
+ [1305] = { "EVENT_BEAT_ROUTE_21_TRAINER_8" },
+ [1312] = { "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
+ [1313] = { "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
+ [1317] = { "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
+ [1318] = { "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE" },
+ [1319] = { "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
+ [1328] = { "EVENT_PASSED_CASCADEBADGE_CHECK" },
+ [1329] = { "EVENT_PASSED_THUNDERBADGE_CHECK" },
+ [1330] = { "EVENT_PASSED_RAINBOWBADGE_CHECK" },
+ [1331] = { "EVENT_PASSED_SOULBADGE_CHECK" },
+ [1332] = { "EVENT_PASSED_MARSHBADGE_CHECK" },
+ [1333] = { "EVENT_PASSED_VOLCANOBADGE_CHECK" },
+ [1334] = { "EVENT_PASSED_EARTHBADGE_CHECK" },
+ [1336] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1" },
+ [1337] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0" },
+ [1338] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1" },
+ [1339] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2" },
+ [1340] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3" },
+ [1341] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4" },
+ [1342] = { "EVENT_BEAT_MOLTRES" },
+ [1343] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2" },
+ [1344] = { "EVENT_GOT_NUGGET" },
+ [1345] = { "EVENT_BEAT_ROUTE24_ROCKET" },
+ [1346] = { "EVENT_BEAT_ROUTE_24_TRAINER_0" },
+ [1347] = { "EVENT_BEAT_ROUTE_24_TRAINER_1" },
+ [1348] = { "EVENT_BEAT_ROUTE_24_TRAINER_2" },
+ [1349] = { "EVENT_BEAT_ROUTE_24_TRAINER_3" },
+ [1350] = { "EVENT_BEAT_ROUTE_24_TRAINER_4" },
+ [1351] = { "EVENT_BEAT_ROUTE_24_TRAINER_5" },
+ [1353] = { "EVENT_NUGGET_REWARD_AVAILABLE" },
+ [1359] = { "EVENT_54F" },
+ [1360] = { "EVENT_MET_BILL" },
+ [1361] = { "EVENT_BEAT_ROUTE_25_TRAINER_0" },
+ [1362] = { "EVENT_BEAT_ROUTE_25_TRAINER_1" },
+ [1363] = { "EVENT_BEAT_ROUTE_25_TRAINER_2" },
+ [1364] = { "EVENT_BEAT_ROUTE_25_TRAINER_3" },
+ [1365] = { "EVENT_BEAT_ROUTE_25_TRAINER_4" },
+ [1366] = { "EVENT_BEAT_ROUTE_25_TRAINER_5" },
+ [1367] = { "EVENT_BEAT_ROUTE_25_TRAINER_6" },
+ [1368] = { "EVENT_BEAT_ROUTE_25_TRAINER_7" },
+ [1369] = { "EVENT_BEAT_ROUTE_25_TRAINER_8" },
+ [1371] = { "EVENT_USED_CELL_SEPARATOR_ON_BILL" },
+ [1372] = { "EVENT_GOT_SS_TICKET" },
+ [1373] = { "EVENT_MET_BILL_2" },
+ [1374] = { "EVENT_BILL_SAID_USE_CELL_SEPARATOR" },
+ [1375] = { "EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING" },
+ [1378] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0" },
+ [1379] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1" },
+ [1380] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2" },
+ [1381] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_3" },
+ [1382] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_4" },
+ [1393] = { "EVENT_BEAT_MT_MOON_1_TRAINER_0" },
+ [1394] = { "EVENT_BEAT_MT_MOON_1_TRAINER_1" },
+ [1395] = { "EVENT_BEAT_MT_MOON_1_TRAINER_2" },
+ [1396] = { "EVENT_BEAT_MT_MOON_1_TRAINER_3" },
+ [1397] = { "EVENT_BEAT_MT_MOON_1_TRAINER_4" },
+ [1398] = { "EVENT_BEAT_MT_MOON_1_TRAINER_5" },
+ [1399] = { "EVENT_BEAT_MT_MOON_1_TRAINER_6" },
+ [1400] = { "EVENT_GOT_DOME_FOSSIL" },
+ [1401] = { "EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD" },
+ [1402] = { "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
+ [1403] = { "EVENT_BEAT_MT_MOON_3_TRAINER_0" },
+ [1404] = { "EVENT_BEAT_MT_MOON_3_TRAINER_1" },
+ [1405] = { "EVENT_BEAT_MT_MOON_3_TRAINER_2" },
+ [1406] = { "EVENT_57E" },
+ [1407] = { "EVENT_GOT_HELIX_FOSSIL" },
+ [1476] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_0" },
+ [1477] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_1" },
+ [1504] = { "EVENT_GOT_HM01" },
+ [1505] = { "EVENT_RUBBED_CAPTAINS_BACK" },
+ [1506] = { "EVENT_SS_ANNE_LEFT" },
+ [1507] = { "EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT" },
+ [1508] = { "EVENT_STARTED_WALKING_OUT_OF_DOCK" },
+ [1509] = { "EVENT_WALKED_OUT_OF_DOCK" },
+ [1521] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_0" },
+ [1522] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_1" },
+ [1523] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_2" },
+ [1524] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_3" },
+ [1537] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_0" },
+ [1538] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_1" },
+ [1539] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_2" },
+ [1540] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_3" },
+ [1553] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_0" },
+ [1554] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_1" },
+ [1555] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_2" },
+ [1556] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_3" },
+ [1557] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_4" },
+ [1558] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_5" },
+ [1632] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1" },
+ [1633] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0" },
+ [1634] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1" },
+ [1635] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2" },
+ [1636] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3" },
+ [1638] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2" },
+ [1649] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0" },
+ [1650] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1" },
+ [1651] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2" },
+ [1652] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3" },
+ [1653] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" },
+ [1655] = { "EVENT_ENTERED_ROCKET_HIDEOUT" },
+ [1663] = { "EVENT_67F" },
+ [1665] = { "EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0" },
+ [1681] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0" },
+ [1682] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1" },
+ [1696] = { "EVENT_6A0" },
+ [1698] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES" },
+ [1699] = { "EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT" },
+ [1700] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2" },
+ [1701] = { "EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED" },
+ [1702] = { "EVENT_ROCKET_DROPPED_LIFT_KEY" },
+ [1703] = { "EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI" },
+ [1778] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_0" },
+ [1779] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_1" },
+ [1780] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_2" },
+ [1781] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_3" },
+ [1789] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR1" },
+ [1790] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR2" },
+ [1791] = { "EVENT_GOT_TM36" },
+ [1794] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_0" },
+ [1795] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_1" },
+ [1800] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR1" },
+ [1801] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR2" },
+ [1810] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_0" },
+ [1811] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_1" },
+ [1812] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_2" },
+ [1816] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR1" },
+ [1817] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR2" },
+ [1826] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_0" },
+ [1827] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_1" },
+ [1828] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_2" },
+ [1829] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_3" },
+ [1832] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR1" },
+ [1833] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR2" },
+ [1834] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR3" },
+ [1846] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_0" },
+ [1847] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_1" },
+ [1848] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_2" },
+ [1855] = { "EVENT_SILPH_CO_6_UNLOCKED_DOOR" },
+ [1856] = { "EVENT_BEAT_SILPH_CO_RIVAL" },
+ [1861] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_0" },
+ [1862] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_1" },
+ [1863] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_2" },
+ [1864] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_3" },
+ [1868] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR1" },
+ [1869] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR2" },
+ [1870] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR3" },
+ [1874] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_0" },
+ [1875] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_1" },
+ [1876] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_2" },
+ [1880] = { "EVENT_SILPH_CO_8_UNLOCKED_DOOR" },
+ [1890] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_0" },
+ [1891] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_1" },
+ [1892] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_2" },
+ [1896] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR1" },
+ [1897] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR2" },
+ [1898] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR3" },
+ [1899] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR4" },
+ [1905] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_0" },
+ [1906] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_1" },
+ [1912] = { "EVENT_SILPH_CO_10_UNLOCKED_DOOR" },
+ [1920] = { "EVENT_780" },
+ [1921] = { "EVENT_781" },
+ [1922] = { "EVENT_782" },
+ [1924] = { "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES" },
+ [1925] = { "EVENT_BEAT_SILPH_CO_11F_TRAINER_0" },
+ [1928] = { "EVENT_SILPH_CO_11_UNLOCKED_DOOR" },
+ [1933] = { "EVENT_GOT_MASTER_BALL" },
+ [1935] = { "EVENT_BEAT_SILPH_CO_GIOVANNI" },
+ [2049] = { "EVENT_BEAT_MANSION_2_TRAINER_0" },
+ [2065] = { "EVENT_BEAT_MANSION_3_TRAINER_0" },
+ [2066] = { "EVENT_BEAT_MANSION_3_TRAINER_1" },
+ [2081] = { "EVENT_BEAT_MANSION_4_TRAINER_0" },
+ [2082] = { "EVENT_BEAT_MANSION_4_TRAINER_1" },
+ [2176] = { "EVENT_GOT_HM03" },
+ [2241] = { "EVENT_BEAT_MEWTWO" },
+ [2273] = { "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0" },
+ [2278] = { "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM" },
+ [2281] = { "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0" },
+ [2286] = { "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM" },
+ [2289] = { "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0" },
+ [2294] = { "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM" },
+ [2297] = { "EVENT_BEAT_LANCES_ROOM_TRAINER_0" },
+ [2302] = { "EVENT_BEAT_LANCE" },
+ [2303] = { "EVENT_LANCES_ROOM_LOCK_DOOR" },
+ [2305] = { "EVENT_BEAT_CHAMPION_RIVAL" },
+ [2321] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0" },
+ [2322] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1" },
+ [2327] = { "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH" },
+ [2481] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0" },
+ [2482] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1" },
+ [2483] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2" },
+ [2484] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3" },
+ [2485] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4" },
+ [2486] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5" },
+ [2487] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6" },
+ [2488] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7" },
+ [2496] = { "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE" },
+ [2497] = { "EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE" },
+ [2504] = { "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE" },
+ [2505] = { "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE" },
+ [2512] = { "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE" },
+ [2513] = { "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE" },
+ [2522] = { "EVENT_BEAT_ARTICUNO" },
+ },
+}
diff --git a/src/pokemon/DayCare.lua b/src/pokemon/DayCare.lua
index e5a1c46c6..6c26e4b36 100644
--- a/src/pokemon/DayCare.lua
+++ b/src/pokemon/DayCare.lua
@@ -226,6 +226,7 @@ end
-- ---------------------------------------------------------------------------
local DITTO = "SPECIES_132"
+local Species=require('src.core.Gen2SpeciesNames')
-- GetGender (03:$50C6). The base_stats byte is the threshold the attack DV
-- is compared against, scaled by 16: below it the mon is female.
@@ -279,7 +280,7 @@ function DayCare.pair(data, save)
local a, b = DayCare.mon(save, DayCare.MAN), DayCare.mon(save, DayCare.LADY)
if not (a and b) then return nil end
if a.isEgg or b.isEgg then return nil end
- local ditto = (a.species == DITTO and 1 or 0) + (b.species == DITTO and 1 or 0)
+ local ditto = (Species.same(data,a.species,DITTO) and 1 or 0) + (Species.same(data,b.species,DITTO) and 1 or 0)
if ditto == 2 then return nil end -- .ditto_and_ditto
local ga, gb = eggGroupsOf(data, a), eggGroupsOf(data, b)
if not (breeds(ga) and breeds(gb)) then return nil end
@@ -404,18 +405,18 @@ function DayCare.eggSpecies(data, save)
local a, b = DayCare.pair(data, save)
if not a then return nil end
local mother = b
- if b.species == DITTO then
+ if Species.same(data,b.species,DITTO) then
mother = a
- elseif a.species ~= DITTO then
+ elseif not Species.same(data,a.species,DITTO) then
if DayCare.gender(data, a) == "female" then mother = a end
end
local species = baseForm(data, mother.species)
if require("src.core.GameVersion").get()=="emerald" then
species=require("src.pokemon.Gen3Breeding").species(species,a,b)
end
- if species == "SPECIES_029" and require("src.pokemon.Gen2Breeding").isVanilla() then
+ if Species.same(data,species,'SPECIES_029')and require("src.pokemon.Gen2Breeding").isVanilla() then
-- InitBreeding: random byte < 128 gives Nidoran F, otherwise Nidoran M.
- return math.random(0,255) < 128 and species or "SPECIES_032"
+ return math.random(0,255) < 128 and species or Species.fromNumber(data,32)
end
return species
end
@@ -453,8 +454,8 @@ function DayCare.parents(data, save)
if DayCare.gender(data, other) == "female" then return other, ditto end
return ditto, other
end
- if a.species == DITTO then return withDitto(b, a) end
- if b.species == DITTO then return withDitto(a, b) end
+ if Species.same(data,a.species,DITTO) then return withDitto(b, a) end
+ if Species.same(data,b.species,DITTO) then return withDitto(a, b) end
if DayCare.gender(data, a) == "female" then return a, b end
if DayCare.gender(data, b) == "female" then return b, a end
-- no gender data at all (a Gen 1 import): the LADY's slot stands in as the
@@ -592,11 +593,15 @@ function DayCare.step(data, save, expPerStep)
local species = DayCare.eggSpecies(data, save)
if not species then return false end
local Pokemon = require("src.pokemon.Pokemon")
- local egg = Pokemon.new(data, species, 5)
+ local eggDvs
+ if vanillaGen2 then
+ eggDvs=require('src.pokemon.Stats').randomDVs()
+ require('src.pokemon.Gen2Breeding').inheritedDVs(data,save,species,eggDvs)
+ end
+ local egg = Pokemon.new(data, species, 5, nil, nil, eggDvs and {dvs=eggDvs} or nil)
if version=="emerald" then require("src.pokemon.Gen3Breeding").apply(data,save,egg) end
if vanillaGen2 then
local Breeding = require("src.pokemon.Gen2Breeding")
- Breeding.inheritDVs(data, save, egg)
Breeding.setOwner(save, egg)
egg.hp = 0 -- DayCare_GiveEgg initializes current HP to zero.
end
diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua
index 321387210..a2040bf2a 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
@@ -384,6 +385,7 @@ function Evolution.apply(game, mon, newSpecies, via, evo)
mon.stats = Stats.calc(newDef, mon.level, mon.dvs, mon.statExp)
end
mon.hp = math.max(1, mon.stats.hp - hpLost)
+ require('src.pokemon.Pokemon').syncIdentity(game.data,mon)
if game.save.pokedex then
game.save.pokedex.seen[newSpecies] = true
game.save.pokedex.owned[newSpecies] = true
diff --git a/src/pokemon/Gen2Breeding.lua b/src/pokemon/Gen2Breeding.lua
index 1990bff8e..a7567c20f 100644
--- a/src/pokemon/Gen2Breeding.lua
+++ b/src/pokemon/Gen2Breeding.lua
@@ -6,25 +6,31 @@ function Breeding.isVanilla()
return version == "gold" or version == "silver" or version == "crystal"
end
-function Breeding.inheritDVs(data, save, egg)
+-- Finalize the owned birth genes before Pokemon.new publishes appearance.
+function Breeding.inheritedDVs(data, save, species, dvs)
local DC = require("src.pokemon.DayCare")
local a, b = DC.mon(save,1), DC.mon(save,2)
- if not (a and b and egg.dvs) then return false end
+ if not (a and b and dvs) then return false end
local parent
if DC.isDitto(data,a) then parent=a
elseif DC.isDitto(data,b) then parent=b
else
local mother, father = DC.parents(data,save)
- local gender = DC.gender(data,egg)
+ local gender = DC.gender(data,{species=species,dvs=dvs})
if gender == "male" then parent=mother
elseif gender == "female" then parent=father end
end
if not (parent and parent.dvs and parent.dvs.defense and parent.dvs.special) then return false end
- local dvs = egg.dvs
dvs.defense = parent.dvs.defense
dvs.special = math.floor(dvs.special / 8) * 8 + parent.dvs.special % 8
dvs.hp = dvs.attack % 2 * 8 + dvs.defense % 2 * 4 + dvs.speed % 2 * 2 + dvs.special % 2
- egg.stats = require("src.pokemon.Stats").calc(data.pokemon[egg.species],egg.level,dvs,egg.statExp)
+ return true
+end
+
+function Breeding.inheritDVs(data, save, egg)
+ if not Breeding.inheritedDVs(data,save,egg.species,egg.dvs) then return false end
+ egg.stats = require("src.pokemon.Stats").calc(data.pokemon[egg.species],egg.level,egg.dvs,egg.statExp)
+ require('src.pokemon.Pokemon').syncIdentity(data,egg)
return true
end
@@ -37,11 +43,12 @@ function Breeding.hatch(game, mon)
Breeding.setOwner(game.save,mon)
local def=game.data.pokemon[mon.species]
if def then mon.stats=require("src.pokemon.Stats").calc(def,mon.level,mon.dvs,mon.statExp) end
+ require('src.pokemon.Pokemon').syncIdentity(game.data,mon)
local dex=game.save.pokedex or {}
game.save.pokedex=dex
dex.seen,dex.owned=dex.seen or {},dex.owned or {}
dex.seen[mon.species],dex.owned[mon.species]=true,true
- if mon.species=="SPECIES_175" then
+ if require('src.core.Gen2SpeciesNames').same(game.data,mon.species,'SPECIES_175')then
game.save.flags=game.save.flags or {}
game.save.flags[require("src.script.Gen2Flags").eventFlag(0x54)]=true
end
diff --git a/src/pokemon/Gen2Friendship.lua b/src/pokemon/Gen2Friendship.lua
index 7ef888c15..6d58ab815 100644
--- a/src/pokemon/Gen2Friendship.lua
+++ b/src/pokemon/Gen2Friendship.lua
@@ -11,6 +11,21 @@ local changes = {
LEVELUPATHOME = {10,6,4},
}
+local events={LEVELUP=1,VITAMIN=2,XITEM=3,GYMBATTLE=4,LEARNMOVE=5,
+ FAINT=6,POISONFAINT=7,STRONGFOE=8,BITTERPOWDER=15,ENERGYROOT=16,REVIVALHERB=17,LEVELUPATHOME=19}
+local sourceEvents={LEVELUP='GAINLEVEL',VITAMIN='USEDITEM',XITEM='USEDXITEM',
+ FAINT='FAINTED',STRONGFOE='BEATENBYSTRONGFOE',LEVELUPATHOME='GAINLEVELATHOME'}
+local function changed(mon,from,event,reason)
+ local Runtime=require('src.mods.Runtime')
+ -- Applied source events remain observable even at byte saturation;
+ -- unchanged walking steps do not report a change.
+ if (event~=nil or from~=mon.happiness) and Runtime.wants("happiness.changed")then
+ Runtime.emit("happiness.changed",{mon=mon,event=event and (sourceEvents[event]or event),
+ eventId=events[event],reason=reason,
+ from=from,to=mon.happiness,delta=mon.happiness-from})
+ end
+end
+
function Friendship.isVanilla()
return require("src.pokemon.Gen2Breeding").isVanilla()
end
@@ -23,6 +38,7 @@ function Friendship.change(mon, reason)
local h = mon.happiness
local band = h < 100 and 1 or h < 200 and 2 or 3
mon.happiness = math.max(0, math.min(255, h + row[band]))
+ changed(mon,h,reason,"event")
return true
end
@@ -34,7 +50,9 @@ function Friendship.stepCycle(save)
if save.g2HappinessStepCount ~= 0 then return end
for _, mon in ipairs(save.party or {}) do
if not require("src.pokemon.Party").isEgg(mon) and mon.happiness ~= nil then
+ local from=mon.happiness
mon.happiness = math.min(255, mon.happiness + 1)
+ changed(mon,from,nil,"step")
end
end
end
diff --git a/src/pokemon/Gen2MonFields.lua b/src/pokemon/Gen2MonFields.lua
new file mode 100644
index 000000000..1a56757d4
--- /dev/null
+++ b/src/pokemon/Gen2MonFields.lua
@@ -0,0 +1,167 @@
+-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+-- Source GSC field names on the actual native Pokemon/stat tables. Derived
+-- aliases are never duplicated into saves and keep observing native writes.
+local Fields = {}
+local installed = setmetatable({}, {__mode='k'})
+local dataKey, baseKey, signatureKey = {}, {}, {}
+-- Both entries are weak: a value retaining its dataset must not root that
+-- weak key on LuaJIT. Live Pokemon retain their own dataset via the metatable.
+local monPlain = setmetatable({}, {__mode='kv'})
+local cached = {
+ stats=setmetatable({}, {__mode='kv'}),
+}
+local plain = {}
+local statNames = {specialAttack='spatk',specialDefense='spdef'}
+
+local function read(index, object, key)
+ if type(index)=='function' then return index(object,key) end
+ if type(index)=='table' then return index[key] end
+end
+local function write(index, object, key, value)
+ if rawget(object,key)~=nil then rawset(object,key,value);return end
+ if type(index)=='function' then return index(object,key,value) end
+ if type(index)=='table' then index[key]=value;return end
+ rawset(object,key,value)
+end
+
+local function getter(kind, object, key, previous, data)
+ if kind=='stats' then
+ local native=statNames[key]
+ if native then
+ local value=rawget(object,native)
+ if value~=nil then return value end
+ return read(previous,object,native)
+ end
+ elseif key=='maxHp' then
+ local stats=rawget(object,'stats')
+ return type(stats)=='table' and stats.hp or nil
+ elseif key=='name' or key=='types' then
+ local def=data and data.pokemon and data.pokemon[object.species]
+ if def then return key=='name' and (def.name or object.species) or def.types end
+ end
+end
+
+local function metatableFor(kind, previous, data)
+ local found
+ if kind=='mon' then
+ if not previous then
+ if data then found=monPlain[data] else found=plain.mon end
+ end
+ elseif previous then found=cached[kind][previous]
+ else found=plain[kind] end
+ if found then return found end
+ local meta={}
+ for key,value in pairs(previous or {})do meta[key]=value end
+ meta[baseKey]=previous or false
+ if kind=='mon' then meta[dataKey]=data end
+ local oldIndex,oldWrite=meta.__index,meta.__newindex
+ meta.__index=function(object,key)
+ local value=getter(kind,object,key,oldIndex,data)
+ if value~=nil then return value end
+ return read(oldIndex,object,key)
+ end
+ meta.__newindex=function(object,key,value)
+ if kind=='stats' and statNames[key] then
+ return write(oldWrite,object,statNames[key],value)
+ elseif kind=='mon' and key=='maxHp' and type(rawget(object,'stats'))=='table' then
+ object.stats.hp=value;return
+ end
+ return write(oldWrite,object,key,value)
+ end
+ -- Keep authentication with the live metatable, not in a global value
+ -- that could retain its dataset/Pokemon cycle on LuaJIT.
+ local signature={}
+ for key,value in next,meta do signature[key]=value end
+ meta[signatureKey]=signature
+ installed[meta]=kind
+ if kind=='mon' then
+ if not previous then
+ if data then monPlain[data]=meta else plain.mon=meta end
+ end
+ elseif previous then cached[kind][previous]=meta else plain[kind]=meta end
+ return meta
+end
+
+local function attach(object, kind, data)
+ if type(object)~='table' then return false end
+ local meta=getmetatable(object)
+ if installed[meta]==kind then
+ if kind~='mon' or rawget(meta,dataKey)==data then return true end
+ meta=rawget(meta,baseKey) or nil
+ end
+ -- A mod owns a protected custom metatable. Do not break its authority.
+ if meta~=nil and type(meta)~='table' then return false end
+ if meta and rawget(meta,'__metatable')~=nil then return false end
+ setmetatable(object,metatableFor(kind,meta,data))
+ return true
+end
+
+-- The checkpoint codecs store only raw record entries, never these
+-- derived aliases or their data-owner closure. Authenticate our unchanged
+-- plain wrappers; custom, protected, copied or edited metatables stay opaque.
+function Fields.plainKind(object)
+ if type(object)~='table' then return nil end
+ local meta=getmetatable(object)
+ local kind=installed[meta]
+ if not kind or getmetatable(meta)~=nil or rawget(meta,baseKey)~=false then return nil end
+ local signature=rawget(meta,signatureKey)
+ if type(signature)~='table' then return nil end
+ for key,value in next,signature do if rawget(meta,key)~=value then return nil end end
+ for key,value in next,meta do
+ if key~=signatureKey and rawget(signature,key)~=value then return nil end
+ end
+ return kind
+end
+
+function Fields.stats(stats)
+ attach(stats,'stats')
+ return stats
+end
+
+function Fields.bind(data, mon)
+ if type(mon)~='table' then return mon,false end
+ local attached=attach(mon,'mon',data)
+ local stats=rawget(mon,'stats')
+ if type(stats)=='table' then Fields.stats(stats) end
+ return mon,attached
+end
+
+-- Semantic world reconstruction restores raw saved records after map
+-- entry. Bind only real native Pokemon slots; do not walk arbitrary modData,
+-- repair progress, roll identity, calculate stats, emit events or write files.
+function Fields.bindSave(data, save)
+ if not require('src.core.GameVersion').isGen2() or type(save)~='table' then return save end
+ local function mon(value)
+ if type(value)=='table' and data and data.pokemon and data.pokemon[value.species] then
+ Fields.bind(data,value)
+ end
+ end
+ for _,value in pairs(save.party or {})do mon(value)end
+ for _,box in pairs(save.boxes or {})do
+ if type(box)=='table' then for _,value in pairs(box.mons or box)do mon(value)end end
+ end
+ local daycare=save.daycare
+ if type(daycare)=='table' then
+ mon(daycare.mon)
+ local breed=daycare.breed
+ if type(breed)=='table' then
+ for _,index in ipairs{1,2}do
+ local entry=breed[index];if type(entry)=='table' then mon(entry.mon)end
+ end
+ mon(breed.egg)
+ end
+ end
+ return save
+end
+
+function Fields.publish(data)
+ if not require('src.core.GameVersion').isGen2() then return false end
+ for _,def in pairs(data and data.pokemon or {})do
+ if type(def)=='table' and type(def.baseStats)=='table' then
+ Fields.stats(def.baseStats)
+ end
+ end
+ return true
+end
+
+return Fields
diff --git a/src/pokemon/Gen2Unown.lua b/src/pokemon/Gen2Unown.lua
new file mode 100644
index 000000000..a1b37adf6
--- /dev/null
+++ b/src/pokemon/Gen2Unown.lua
@@ -0,0 +1,125 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- Shared native GSC form identity and catch-order state. Words and pictures
+-- remain in the imported dataset, never in a second cartridge catalogue.
+local M = {NUM_UNOWN = 26, ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"}
+function M.supported()
+ local version = require("src.core.GameVersion").get()
+ return version == "gold" or version == "silver" or version == "crystal"
+end
+function M.index(letter)
+ if type(letter) == "string" and #letter == 1 then
+ return M.ALPHABET:find(letter:upper(), 1, true)
+ end
+ if type(letter) == "number" and letter % 1 == 0 and letter >= 1 and letter <= 26 then
+ return letter
+ end
+end
+function M.name(letter)
+ local index = M.index(letter)
+ return index and M.ALPHABET:sub(index, index) or nil
+end
+local function middle(dv)
+ dv = dv or 0
+ if type(dv) ~= "number" or dv ~= dv or dv < 0 or dv > 15 then return nil end
+ return math.floor(dv / 2) % 4
+end
+function M.letterFromDVs(dvs)
+ if type(dvs) ~= "table" then return nil end
+ local a,b,c,d = middle(dvs.attack), middle(dvs.defense), middle(dvs.speed), middle(dvs.special)
+ if not (a and b and c and d) then return nil end
+ return math.floor((a*64+b*16+c*4+d) / 10) + 1
+end
+function M.dvsForLetter(letter)
+ local index = M.index(letter)
+ if not index then return nil end
+ local packed = (index - 1) * 10
+ return {attack = math.floor(packed / 64) % 4 * 2,
+ defense = math.floor(packed / 16) % 4 * 2,
+ speed = math.floor(packed / 4) % 4 * 2, special = packed % 4 * 2}
+end
+function M.liveData(data)
+ if type(data) == "table" then return data end
+ local game = package.loaded["src.core.Game"]
+ return type(game) == "table" and type(game.data) == "table" and game.data or nil
+end
+function M.definition(data)
+ data = M.liveData(data)
+ if not (data and type(data.pokemon) == "table") then return nil end
+ local known = data.pokemon.UNOWN
+ if type(known) == "table" then return known end
+ for _, def in pairs(data.pokemon) do
+ if type(def) == "table" and (def.translationSourceName or def.name) == "UNOWN" then
+ return def
+ end
+ end
+end
+function M.word(letter, data)
+ local index = M.index(letter)
+ data = M.liveData(data)
+ local record = data and data.unown_dex
+ local words = type(record) == "table" and record.words
+ local word = index and type(words) == "table" and words[index]
+ return type(word) == "string" and word or nil
+end
+function M.forms(pokemon)
+ local def = M.definition({pokemon = pokemon})
+ return def and def.forms or nil
+end
+function M.formSprite(pokemon, letter, back)
+ local index, def = M.index(letter), M.definition({pokemon = pokemon})
+ if not (index and def) then return nil end
+ local form = def.forms and def.forms[index]
+ if back then return form and form.spriteBack or def.spriteBack end
+ return form and form.spriteFront or def.spriteFront
+end
+function M.monLetter(mon, data)
+ if type(mon) ~= "table" or require("src.pokemon.Party").isEgg(mon) then return nil end
+ data = M.liveData(data)
+ local def = data and data.pokemon and data.pokemon[mon.species]
+ if def then
+ if def ~= M.definition(data) then return nil end
+ elseif mon.species ~= "UNOWN" then return nil end
+ return M.index(mon.unownLetter) or M.letterFromDVs(mon.dvs)
+end
+function M.letters(save)
+ local dex = type(save) == "table" and save.pokedex
+ if type(dex) ~= "table" then return {} end
+ local out, seen = {}, {}
+ local function add(letter)
+ local index = M.index(letter)
+ if index and not seen[index] then seen[index] = true; out[#out + 1] = index end
+ end
+ for _, letter in ipairs(type(dex.unownDex) == "table" and dex.unownDex or {}) do add(letter) end
+ local forms = type(dex.unownForms) == "table" and dex.unownForms or {}
+ for i = 1, 26 do if forms[i] then add(i) end end
+ return out
+end
+function M.count(save) return #M.letters(save) end
+function M.caught(save, letter)
+ local index = M.index(letter)
+ if not index then return false end
+ for _, present in ipairs(M.letters(save)) do if present == index then return true end end
+ return false
+end
+function M.updateDex(save, letter, data)
+ local index = M.index(letter)
+ if not (M.supported() and type(save) == "table" and index) or M.caught(save, index) then return false end
+ if save.pokedex ~= nil and type(save.pokedex) ~= "table" then return false end
+ if save.pokedex and save.pokedex.unownForms ~= nil and type(save.pokedex.unownForms) ~= "table" then return false end
+ local list = M.letters(save)
+ list[#list + 1] = index
+ save.pokedex = save.pokedex or {}
+ local dex = save.pokedex
+ dex.unownDex, dex.unownForms = list, dex.unownForms or {}
+ for _, value in ipairs(list) do dex.unownForms[value] = true end
+ local Runtime = require("src.mods.Runtime")
+ if Runtime.wants("unown.unlocked") then
+ Runtime.emit("unown.unlocked", {letter=index, name=M.name(index), word=M.word(index, data), count=#list})
+ end
+ return true
+end
+function M.registerCatch(save, mon, data)
+ local letter = M.monLetter(mon, data)
+ return letter and M.updateDex(save, letter, data) or false
+end
+return M
diff --git a/src/pokemon/IdentityRolls.lua b/src/pokemon/IdentityRolls.lua
new file mode 100644
index 000000000..5c8d13967
--- /dev/null
+++ b/src/pokemon/IdentityRolls.lua
@@ -0,0 +1,107 @@
+-- Shared hook results, with each generation's actual DV/PID predicate.
+-- Native predicates draw no RNG; explicit Gen-2 context edits retain native
+-- write-back rather than silently changing the saved identity on an override.
+local Runtime = require("src.mods.Runtime")
+local Stats = require("src.pokemon.Stats")
+local bit = require("bit")
+local R = {}
+local GENDERS = { male = true, female = true, unknown = true }
+
+function R.data(data)
+ return data or Runtime.hooks.data
+end
+
+local function definition(data, species, generation)
+ local def = data and data.pokemon and data.pokemon[species]
+ if generation ~= 3 or type(def) ~= "table" then
+ return def
+ end
+ -- GBA's species_hook_def is a call-local metadata record. Threshold
+ -- steering changes this evaluation, not the mounted species table.
+ local out = {}
+ for k, v in pairs(def) do
+ out[k] = v
+ end
+ out.id = species
+ return out
+end
+
+function R.nativeGender(def, dvs, personality, generation)
+ local ratio = def and def.genderRatio
+ if ratio == nil or ratio == 255 then
+ return "unknown"
+ end
+ if ratio == 0 then
+ return "male"
+ end
+ if ratio == 254 then
+ return "female"
+ end
+ if generation == 3 then
+ return ratio > ((tonumber(personality) or 0) % 256) and "female" or "male"
+ end
+ local value = (dvs and dvs.attack or 0) * 16 + (dvs and dvs.speed or 0)
+ return value <= ratio and "female" or "male"
+end
+
+function R.gender(data, mon, generation, level)
+ data = R.data(data)
+ local def = definition(data, mon.species, generation)
+ local fallback = R.nativeGender(def, mon.dvs, mon.personality, generation)
+ local function vanilla(c)
+ return R.nativeGender(c.def, c.dvs, c.personality, generation)
+ end
+ local ctx = { species = mon.species, def = def, ratio = def and def.genderRatio }
+ if generation == 2 then
+ ctx.dvs, ctx.level = mon.dvs, level
+ else
+ ctx.personality = mon.personality
+ end
+ local result = Runtime.call("gender.roll", vanilla, ctx)
+ if not GENDERS[result] then
+ return generation == 3 and fallback or R.nativeGender(def, mon.dvs, mon.personality, generation)
+ end
+ return result
+end
+
+function R.gen2Shiny(mon, def)
+ local result = Runtime.call("shiny.roll", function(c)
+ return Stats.isShiny(c.dvs)
+ end, { dvs = mon.dvs, species = mon.species, def = def, level = mon.level })
+ return not not result
+end
+
+local function nativeShiny(personality, otId, otSecretId)
+ local p = (tonumber(personality) or 0) % 4294967296
+ local full = (tonumber(otId) or 0) % 4294967296
+ local tid = full % 65536
+ local sid = (tonumber(otSecretId) or math.floor(full / 65536)) % 65536
+ return bit.bxor(tid, sid, math.floor(p / 65536), p % 65536) < 8
+end
+R.nativeGen3Shiny = nativeShiny
+
+function R.gen3Shiny(mon, data)
+ data = R.data(data)
+ local def = definition(data, mon.species, 3)
+ local ivs
+ if type(mon.ivs) == "table" then
+ ivs = {}
+ for k, v in pairs(mon.ivs) do
+ ivs[k] = v
+ end
+ end
+ local result = Runtime.call("shiny.roll", function(c)
+ return nativeShiny(c.personality, c.otId, c.otSecretId)
+ end, {
+ species = mon.species,
+ def = def,
+ level = mon.level,
+ personality = mon.personality,
+ ivs = ivs,
+ otId = mon.otId or mon.trainerId,
+ otSecretId = mon.otSecretId,
+ })
+ return not not result
+end
+
+return R
diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua
index 17c5685c0..c88e630ee 100644
--- a/src/pokemon/Pokemon.lua
+++ b/src/pokemon/Pokemon.lua
@@ -173,7 +173,9 @@ function Pokemon.applySeed(data, mon, seed)
return true
end
-function Pokemon.new(data, species, level, rng, form)
+function Pokemon.new(data, species, level, rng, form, opts)
+ species=require("src.core.Gen2SpeciesNames").registryId(data.pokemon,species)
+ local generation=require("src.core.GameVersion").generation()
local nativeForm=(data.constants or {}).gen==4 and (form or 0) or nil
local speciesDef=(data.pokemon or {})[species]
if require("src.core.GameVersion").get()=="polishedcrystal" and speciesDef and speciesDef.forms then
@@ -182,7 +184,20 @@ function Pokemon.new(data, species, level, rng, form)
local def = require('src.pokemon.Gen4Forms').definition(data,
{species=species,form=nativeForm})
assert(def, "unknown species " .. tostring(species))
- local dvs = Stats.randomDVs(rng)
+ local dvs
+ if generation == 2 and opts and type(opts.dvs) == "table" then
+ -- Trainer/trade/egg genes are already decided. Own a copy before any
+ -- identity hook can edit it; do not spend a random birth roll first.
+ dvs = {}
+ for _,key in ipairs({'attack','defense','speed','special'})do
+ local n=opts.dvs[key]
+ assert(type(n)=='number'and n%1==0 and n>=0 and n<=15,'invalid native Gen2 DV: '..key)
+ dvs[key]=n
+ end
+ dvs.hp=dvs.attack%2*8+dvs.defense%2*4+dvs.speed%2*2+dvs.special%2
+ else
+ dvs = Stats.randomDVs(rng)
+ end
local seed = Stats.isGen3(def) and gen3Seed(data, def, rng) or nil
local stats = seed
and Stats.calc(def, level, seed.ivs, nil, seed.evs, seed.nature)
@@ -242,7 +257,75 @@ function Pokemon.new(data, species, level, rng, form)
-- meaning "in tall grass only". At the default setting shinyOddsOf answers
-- nil and this returns immediately, leaving the cartridge's own 1/8192
-- exactly as it was.
- Pokemon.rollShiny(data, nil, mon, rng)
+ if generation==2 and opts and opts.shiny then mon.shiny=true
+ else Pokemon.rollShiny(data, nil, mon, rng) end
+ if generation==2 then
+ local Rolls=require('src.pokemon.IdentityRolls')
+ mon.shiny=mon.shiny or Rolls.gen2Shiny(mon,def)
+ mon.gender=Rolls.gender(data,mon,2,level)
+ end
+ if generation==2 then require('src.pokemon.Gen2MonFields').bind(data,mon) end
+ return mon
+end
+
+-- Old native saves predate the public appearance fields. Populate only absent
+-- fields at validation, on the actual Pokemon, without a random re-roll or
+-- replacing explicit saved identity. Normal refresh remains the owner below.
+function Pokemon.ensureIdentity(data,mon)
+ if require('src.core.GameVersion').isGen2() then require('src.pokemon.Gen2MonFields').bind(data,mon)end
+ if type(mon)~='table'or type(mon.dvs)~='table'
+ or require('src.core.GameVersion').generation()~=2 then return mon end
+ local def=data and data.pokemon and data.pokemon[mon.species]
+ if not def then return mon end
+ local Rolls=require('src.pokemon.IdentityRolls')
+ if mon.gender==nil then mon.gender=Rolls.gender(data,mon,2,mon.level)end
+ if mon.shiny==nil then mon.shiny=Rolls.gen2Shiny(mon,def)end
+ return mon
+end
+
+-- Gen 2 refreshes identity at its summary/stat-refresh boundary. A true
+-- shiny stays authoritative; a cached false and gender are read again.
+function Pokemon.syncIdentity(data, mon)
+ local Version=require('src.core.GameVersion')
+ if Version.generation()~=2 or type(mon)~='table' then return mon end
+ require('src.pokemon.Gen2MonFields').bind(data,mon)
+ local def = data and data.pokemon and data.pokemon[mon.species]
+ if not def then return mon end
+ -- Derived native names/types stay unstored. An older explicitly stored
+ -- display copy must be refreshed too, as on the source summary boundary.
+ if rawget(mon,'name')~=nil then mon.name=def.name or mon.species end
+ if def.types and rawget(mon,'types')~=nil then mon.types=def.types end
+ if type(mon.dvs)~='table' then return mon end
+ local Rolls = require('src.pokemon.IdentityRolls')
+ mon.gender = Rolls.gender(data, mon, 2, mon.level)
+ mon.shiny = mon.shiny or Rolls.gen2Shiny(mon, def)
+ local Unown=require('src.pokemon.Gen2Unown')
+ if Unown.supported() then
+ mon.unownLetter=def==Unown.definition(data) and Unown.letterFromDVs(mon.dvs) or nil
+ end
+ return mon
+end
+
+-- GSC summary/stat refresh recalculates an existing block; Stats.ensure is
+-- deliberately different (it preserves an already loaded party stat block).
+-- No current-HP refill, identity re-roll at birth or second Pokemon is made.
+function Pokemon.refreshStats(data, mon)
+ if require('src.core.GameVersion').generation()~=2 or type(mon)~='table' then return mon end
+ local def=data and data.pokemon and data.pokemon[mon.species]
+ if not(def and def.baseStats) then return mon end
+ Pokemon.syncIdentity(data,mon)
+ local calculated
+ if Stats.isGen3(def) then
+ -- A GSC-hosted extension can explicitly use the native later stat model.
+ -- Preserve that structural owner, its six IVs, EVs and nature too.
+ calculated=Stats.calc(def,mon.level or 1,mon.ivs or mon.dvs,mon.statExp,mon.evs,mon.nature)
+ else
+ calculated=Stats.calcGen2(def.baseStats,mon.dvs,mon.level or 1,mon.statExp)
+ end
+ Stats.replace(mon,calculated)
+ require('src.pokemon.Gen2MonFields').bind(data,mon)
+ mon.maxHp=mon.stats.hp
+ if mon.hp==nil or mon.hp>mon.stats.hp then mon.hp=mon.stats.hp end
return mon
end
@@ -310,6 +393,7 @@ function Pokemon.forceShiny(data, mon, rng, otId)
mon.hp = (full and mon.hp and mon.hp < full) and math.min(mon.hp, mon.stats.hp)
or mon.stats.hp
end
+ Pokemon.syncIdentity(data,mon)
return mon
end
@@ -395,6 +479,9 @@ function Pokemon.isShiny(mon)
-- before its OT was stamped
return mon.shiny == true
end
+ if require('src.core.GameVersion').generation()==2 and mon.shiny~=nil then
+ return mon.shiny==true
+ end
if type(mon.dvs) == "table" then return Stats.isShiny(mon.dvs) end
return mon.shiny == true
end
@@ -468,10 +555,10 @@ function Pokemon.restorePP(mon, moves)
end
end
-function Pokemon.heal(mon)
+function Pokemon.heal(mon, moves)
mon.hp = mon.stats.hp
mon.status = nil
- Pokemon.restorePP(mon)
+ Pokemon.restorePP(mon, moves)
end
function Pokemon.isFainted(mon)
diff --git a/src/pokemon/Sprites.lua b/src/pokemon/Sprites.lua
index 245e67151..9c52bea60 100644
--- a/src/pokemon/Sprites.lua
+++ b/src/pokemon/Sprites.lua
@@ -14,9 +14,6 @@ local Sprites = {}
local function samePath(path) return path end
--- (dv & 6) >> 1 -- the middle two bits GetUnownLetter (20:$5749) keeps
-local function mid2(dv) return math.floor((dv or 0) / 2) % 4 end
-
-- GetUnownLetter packs the middle two bits of the atk/def/spd/spc DVs into
-- one byte and divides by 10, giving letter 1..26. A species record only
-- carries `forms` when the ROM has per-form pics (UNOWN).
@@ -28,10 +25,9 @@ function Sprites.formIndex(def, mon)
return mon.form
end
local dvs = mon.dvs
- if type(dvs) ~= "table" then return nil end
- local packed = mid2(dvs.attack) * 64 + mid2(dvs.defense) * 16
- + mid2(dvs.speed) * 4 + mid2(dvs.special)
- local index = math.floor(packed / 10) + 1
+ local Unown = require("src.pokemon.Gen2Unown")
+ local index = (Unown.supported() and Unown.index(mon.unownLetter)) or Unown.letterFromDVs(dvs)
+ if not index then return nil end
if not forms[index] then return nil end
return index
end
diff --git a/src/pokemon/Stats.lua b/src/pokemon/Stats.lua
index 25dd213b1..d1a050f0e 100644
--- a/src/pokemon/Stats.lua
+++ b/src/pokemon/Stats.lua
@@ -4,6 +4,22 @@
-- The HP DV is derived from the low bits of the other four DVs.
local Stats = {}
+-- Live native wrappers share party stats until Transform replaces their own
+-- block. Weak keys retain neither finished battles nor Pokemon datasets.
+local statOwners=setmetatable({}, {__mode='k'})
+function Stats.trackBattler(battler)
+ statOwners[battler]=true
+ return battler
+end
+function Stats.replace(mon, stats)
+ local previous=mon.stats
+ mon.stats=stats
+ for owner in pairs(statOwners)do
+ if owner.mon==mon and owner.curStats==previous then owner.curStats=stats end
+ end
+ return stats
+end
+
local ORDER = { "hp", "attack", "defense", "speed", "special" }
Stats.ORDER = ORDER
@@ -50,6 +66,15 @@ function Stats.isGen3(speciesDef)
and speciesDef.baseStats.spdef ~= nil
end
+-- GSC stores only the four genes. A stale saved HP DV must not override
+-- their low bits when a mod/editor changes a gene and refreshes the stats.
+function Stats.hpDV(dvs, special)
+ dvs=dvs or {}
+ if special==nil then special=dvs.special end
+ return ((dvs.attack or 0)%2)*8+((dvs.defense or 0)%2)*4
+ +((dvs.speed or 0)%2)*2+(special or 0)%2
+end
+
function Stats.randomDVs(rng)
rng = rng or love.math.random
local dvs = {
@@ -58,8 +83,7 @@ function Stats.randomDVs(rng)
speed = rng(0, 15),
special = rng(0, 15),
}
- dvs.hp = (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 +
- (dvs.speed % 2) * 2 + (dvs.special % 2)
+ dvs.hp = Stats.hpDV(dvs)
return dvs
end
@@ -123,6 +147,28 @@ function Stats.calcGen3(speciesDef, level, ivs, evs, nature)
return out
end
+-- One actual GSC formula for native recalculation and the public source
+-- Mon.stats API. Accept source split names at this input boundary, but keep
+-- native canonical output fields and live source aliases on that same table.
+function Stats.calcGen2(base, dvs, level, statExp)
+ base,dvs,statExp=base or {},dvs or {},statExp or {}
+ local special=dvs.special
+ if special==nil then special=dvs.specialAttack or dvs.specialDefense end
+ local function value(stat,dv,effort,hp)
+ return calcOne(stat or 1,dv or 0,math.max(0,effort or 0),level,hp)
+ end
+ local out={
+ hp=value(base.hp,Stats.hpDV(dvs,special),statExp.hp,true),
+ attack=value(base.attack,dvs.attack,statExp.attack),
+ defense=value(base.defense,dvs.defense,statExp.defense),
+ speed=value(base.speed,dvs.speed,statExp.speed),
+ spatk=value(base.spatk or base.specialAttack,special,statExp.special or statExp.specialAttack),
+ spdef=value(base.spdef or base.specialDefense,special,statExp.special or statExp.specialDefense),
+ }
+ out.special=out.spatk
+ return require('src.pokemon.Gen2MonFields').stats(out)
+end
+
function Stats.calc(speciesDef, level, dvs, statExp, evs, nature)
if Stats.isGen3(speciesDef) then
-- a Gen 3 mon carries ivs/evs; the dvs/statExp parameters are what the
@@ -131,22 +177,14 @@ function Stats.calc(speciesDef, level, dvs, statExp, evs, nature)
end
statExp = statExp or {}
local base = speciesDef.baseStats
+ if (base.spatk and base.spdef) or (base.specialAttack and base.specialDefense) then
+ return Stats.calcGen2(base,dvs,level,statExp)
+ end
local out = {}
for _, key in ipairs(ORDER) do
out[key] = calcOne(base[key], dvs[key] or 0,
statExp[key], level, key == "hp")
end
- -- Gen 2 split Special into two base stats but kept ONE Special DV and
- -- ONE Special stat-exp slot (CalcMonStats runs the same CalcStat over
- -- six base values, reading wDVs' low nibble for both). A Gen 1 species
- -- record has no spatk/spdef, so it keeps the single `special`.
- if base.spatk and base.spdef then
- out.spatk = calcOne(base.spatk, dvs.special or 0,
- statExp.special, level, false)
- out.spdef = calcOne(base.spdef, dvs.special or 0,
- statExp.special, level, false)
- out.special = out.spatk
- end
return out
end
@@ -182,6 +220,9 @@ function Stats.ensure(speciesDef, mon)
mon.statExp)
mon.stats.spatk, mon.stats.spdef = full.spatk, full.spdef
end
+ if base.spatk and base.spdef then
+ require('src.pokemon.Gen2MonFields').stats(mon.stats)
+ end
return mon
end
if Stats.isGen3(speciesDef) then
diff --git a/src/render/GameViewport.lua b/src/render/GameViewport.lua
new file mode 100644
index 000000000..14c0bdada
--- /dev/null
+++ b/src/render/GameViewport.lua
@@ -0,0 +1,253 @@
+-- Optional game rectangle inside the OS window. No canvas on the default path.
+local Runtime = require("src.mods.Runtime")
+local Scope = require("src.render.GraphicsScope")
+local Logger = require("src.core.Logger")
+local V = {}
+local function finite(v)
+ return type(v) == "number" and v == v and math.abs(v) < math.huge
+end
+local function release()
+ if V.canvas then
+ if love and love.graphics and love.graphics.getCanvas and love.graphics.getCanvas() == V.canvas then
+ love.graphics.setCanvas()
+ end
+ if V.canvas.release then
+ pcall(V.canvas.release, V.canvas)
+ end
+ end
+ V.canvas = nil
+end
+function V.reset()
+ if V.inFrame then
+ V.retiring = true
+ return
+ end
+ release()
+ V.skinFill = false
+ V.rect = nil
+ V.full = nil
+ V.generation = nil
+ V.frameActive = false
+ V.retiring = nil
+ V.failedSize = nil
+end
+local function rect(value, w, h)
+ if type(value) ~= "table" or getmetatable(value) ~= nil then
+ value = {}
+ end
+ local function field(k, default)
+ local v = rawget(value, k)
+ return finite(v) and math.floor(v) or default
+ end
+ local x, y = field("x", 0), field("y", 0)
+ x = math.max(0, math.min(x, w - 1))
+ y = math.max(0, math.min(y, h - 1))
+ return {
+ x = x,
+ y = y,
+ width = math.max(1, math.min(field("width", w), w - x)),
+ height = math.max(1, math.min(field("height", h), h - y)),
+ },
+ rawget(value, "capture") == true
+end
+function V.begin(generation, allowed)
+ -- A previous frame can have been interrupted by a state error or launcher exit.
+ V.inFrame = false
+ if V.retiring then
+ V.reset()
+ end
+ local G = love.graphics
+ local w, h = G.getDimensions()
+ w = finite(w) and math.max(1, w) or 1
+ h = finite(h) and math.max(1, h) or 1
+ local pw, ph = w, h
+ if G.getPixelDimensions then
+ pw, ph = G.getPixelDimensions()
+ end
+ pw = finite(pw) and math.max(1, pw) or w
+ ph = finite(ph) and math.max(1, ph) or h
+ local context =
+ { width = w, height = h, pixelWidth = pw, pixelHeight = ph, dpiX = pw / w, dpiY = ph / h, generation = generation }
+ local requested, skinFill
+ if allowed ~= false then
+ local Skin = require("src.core.TouchSkin")
+ local ok, x, y, sw, sh, fill = pcall(Skin.viewport, w, h)
+ if ok and finite(x) and finite(y) and finite(sw) and finite(sh) and sw > 0 and sh > 0 then
+ requested = { x = x, y = y, width = sw, height = sh }
+ skinFill = fill == true
+ end
+ end
+ local skinRequest = requested
+ if allowed ~= false and Runtime.wantsHook("render.viewport") then
+ local ok, value = Scope.call(function()
+ return Runtime.call("render.viewport", function()
+ return skinRequest or { x = 0, y = 0, width = w, height = h }
+ end, context)
+ end)
+ if ok then
+ requested = value
+ else
+ Logger.warn("render viewport failed: %s", tostring(value))
+ end
+ end
+ -- Retain real metrics independently of mutations to the hook argument.
+ V.full =
+ { width = w, height = h, pixelWidth = pw, pixelHeight = ph, dpiX = pw / w, dpiY = ph / h, generation = generation }
+ local r, capture = rect(requested, w, h)
+ local active = capture or r.x ~= 0 or r.y ~= 0 or r.width ~= w or r.height ~= h
+ V.rect, V.generation, V.frameActive = r, generation, active
+ V.skinFill = skinFill == true and requested == skinRequest
+ if active then
+ local key = table.concat({ r.width, r.height, pw / w, ph / h }, ":")
+ if V.failedSize ~= key then
+ V.failedSize = nil
+ end
+ if V.failedSize == key then
+ active = false
+ elseif
+ not V.canvas
+ or V.canvas:getWidth() ~= r.width
+ or V.canvas:getHeight() ~= r.height
+ or V.canvasDpiX ~= pw / w
+ or V.canvasDpiY ~= ph / h
+ then
+ release()
+ local ok, canvas = pcall(G.newCanvas, r.width, r.height)
+ if ok and canvas then
+ V.canvas = canvas
+ canvas:setFilter("nearest", "nearest")
+ V.canvasDpiX, V.canvasDpiY = pw / w, ph / h
+ else
+ V.failedSize = key
+ active = false
+ Logger.warn("render viewport unavailable: %s", tostring(canvas))
+ end
+ end
+ else
+ release()
+ V.failedSize = nil
+ end
+ if not active then
+ V.frameActive = false
+ V.skinFill = false
+ V.rect = { x = 0, y = 0, width = w, height = h }
+ end
+ V.inFrame = true
+ return V.rect
+end
+function V.active()
+ return V.frameActive == true and V.canvas ~= nil
+end
+function V.dimensions()
+ if V.active() then
+ return V.rect.width, V.rect.height
+ end
+ return love.graphics.getDimensions()
+end
+function V.pixelDimensions()
+ if V.active() then
+ if V.canvas.getPixelDimensions then
+ return V.canvas:getPixelDimensions()
+ end
+ return math.max(1, math.floor(V.rect.width * V.full.dpiX)),
+ math.max(1, math.floor(V.rect.height * V.full.dpiY))
+ end
+ if love.graphics.getPixelDimensions then
+ return love.graphics.getPixelDimensions()
+ end
+ return love.graphics.getDimensions()
+end
+function V.fullDimensions()
+ if V.full then
+ return V.full.width, V.full.height
+ end
+ return love.graphics.getDimensions()
+end
+function V.target()
+ return V.active() and V.canvas or nil
+end
+function V.setTarget()
+ love.graphics.setCanvas(V.target())
+end
+function V.toLocal(x, y)
+ local r = V.rect
+ if not r then
+ return x, y, true
+ end
+ local lx, ly = x - r.x, y - r.y
+ return lx, ly, lx >= 0 and ly >= 0 and lx < r.width and ly < r.height
+end
+function V.localSafeRect(x, y, w, h)
+ if not V.active() then
+ return x, y, w, h
+ end
+ local r = V.rect
+ local x1, y1 = math.max(x, r.x), math.max(y, r.y)
+ local x2, y2 = math.min(x + w, r.x + r.width), math.min(y + h, r.y + r.height)
+ if x2 <= x1 or y2 <= y1 then
+ return 0, 0, r.width, r.height
+ end
+ return x1 - r.x, y1 - r.y, x2 - x1, y2 - y1
+end
+local function prepare()
+ local G = love.graphics
+ G.setCanvas()
+ G.origin()
+ G.setScissor()
+ G.setShader()
+ G.setBlendMode("alpha")
+ G.setColor(1, 1, 1, 1)
+end
+function V.finish(game)
+ if V.active() then
+ local G = love.graphics
+ local r, full = V.rect, V.full
+ local context = {
+ canvas = V.canvas,
+ x = r.x,
+ y = r.y,
+ width = r.width,
+ height = r.height,
+ windowWidth = full.width,
+ windowHeight = full.height,
+ dpiX = full.dpiX,
+ dpiY = full.dpiY,
+ generation = V.generation,
+ }
+ G.setCanvas()
+ local function vanilla(_, ctx)
+ -- A failing outer link must not taint the default draw reached by next.
+ local canvas = type(ctx) == "table" and rawget(ctx, "canvas")
+ local x = type(ctx) == "table" and rawget(ctx, "x")
+ local y = type(ctx) == "table" and rawget(ctx, "y")
+ if not canvas or not finite(x) or not finite(y) then
+ error("invalid render.window context", 0)
+ end
+ local ok, why = Scope.call(function()
+ prepare()
+ G.draw(canvas, x, y)
+ end)
+ if not ok then
+ error(why, 0)
+ end
+ end
+ local ok, why = Scope.call(function()
+ prepare()
+ G.clear(0, 0, 0, 1)
+ return Runtime.call("render.window", vanilla, game, context)
+ end)
+ if not ok then
+ Logger.warn("render window failed: %s", tostring(why))
+ Scope.call(function()
+ prepare()
+ G.clear(0, 0, 0, 1)
+ G.draw(V.canvas, r.x, r.y)
+ end)
+ end
+ end
+ V.inFrame = false
+ if V.retiring then
+ V.reset()
+ end
+end
+return V
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/GraphicsScope.lua b/src/render/GraphicsScope.lua
new file mode 100644
index 000000000..931fe14c6
--- /dev/null
+++ b/src/render/GraphicsScope.lua
@@ -0,0 +1,50 @@
+-- Fence a native render seam, including leaked pushes and canvas changes.
+local S = {}
+local unpack = table.unpack or unpack
+local function pack(...)
+ return { n = select("#", ...), ... }
+end
+function S.call(fn, ...)
+ local G = love.graphics
+ local canvas = pack(G.getCanvas())
+ local realPush, realPop = G.push, G.pop
+ local before = G.getStackDepth and G.getStackDepth()
+ local pushed, why = pcall(realPush, "all")
+ if not pushed then
+ return false, why
+ end
+ local depth = 0
+ G.push = function(...)
+ local result = pack(realPush(...))
+ depth = depth + 1
+ return unpack(result, 1, result.n)
+ end
+ G.pop = function(...)
+ if depth <= 0 then
+ error("render callback cannot pop caller graphics state", 0)
+ end
+ local result = pack(realPop(...))
+ depth = depth - 1
+ return unpack(result, 1, result.n)
+ end
+ local out = pack(pcall(fn, ...))
+ G.push, G.pop = realPush, realPop
+ if before and G.getStackDepth then
+ local current = G.getStackDepth()
+ if current < before + 1 then
+ G.setCanvas(unpack(canvas, 1, canvas.n))
+ return false, "render callback removed its graphics guard"
+ end
+ depth = current - before - 1
+ end
+ for _ = 1, depth do
+ pcall(realPop)
+ end
+ local restored, err = pcall(realPop)
+ G.setCanvas(unpack(canvas, 1, canvas.n))
+ if not restored then
+ return false, err
+ end
+ return unpack(out, 1, out.n)
+end
+return S
diff --git a/src/render/Output.lua b/src/render/Output.lua
new file mode 100644
index 000000000..16a9237a0
--- /dev/null
+++ b/src/render/Output.lua
@@ -0,0 +1,37 @@
+-- Final-output ownership comes after composition and before display effects.
+local Runtime = require("src.mods.Runtime")
+local Scope = require("src.render.GraphicsScope")
+local Logger = require("src.core.Logger")
+local O = {}
+function O.enabled()
+ if not Runtime.wantsHook("render.output") then
+ return false
+ end
+ local ok, value = Scope.call(function()
+ return Runtime.call("render.output_enabled", function()
+ return false
+ end)
+ end)
+ if not ok then
+ Logger.warn("render output gate failed: %s", tostring(value))
+ end
+ return ok and value == true
+end
+function O.draw(ctx)
+ local ok, value = Scope.call(function()
+ local G = love.graphics
+ G.origin()
+ G.setShader()
+ G.setScissor()
+ G.setBlendMode("alpha")
+ G.setColor(1, 1, 1, 1)
+ return Runtime.call("render.output", function()
+ return false
+ end, ctx)
+ end)
+ if not ok then
+ Logger.warn("render output failed: %s", tostring(value))
+ end
+ return ok and value == true
+end
+return O
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/PixelFit.lua b/src/render/PixelFit.lua
new file mode 100644
index 000000000..f9e2206bb
--- /dev/null
+++ b/src/render/PixelFit.lua
@@ -0,0 +1,16 @@
+-- Native framebuffer fitting, extracted from Renderer.
+-- Existing engine authors' rights/licences remain effective (see the original engine licences).
+-- Integration contributions by Roxas2712; see
+-- https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+local Fit = {}
+
+function Fit.scale(pixelW, pixelH, surfaceW, surfaceH)
+ return math.max(1, math.floor(math.min(pixelW / surfaceW, pixelH / surfaceH)))
+end
+
+function Fit.origin(pixelW, pixelH, surfaceW, surfaceH, scale)
+ return math.floor((pixelW - surfaceW * scale) / 2),
+ math.floor((pixelH - surfaceH * scale) / 2)
+end
+
+return Fit
diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua
index 4d8143548..19d557795 100644
--- a/src/render/Renderer.lua
+++ b/src/render/Renderer.lua
@@ -11,7 +11,12 @@ local Tilt = require("src.render.Tilt")
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas")
+local PixelFit = require("src.render.PixelFit")
+local ScreenPosition = require("src.core.ScreenPosition")
local Runtime = require("src.mods.Runtime")
+local Output = require("src.render.Output")
+local GameViewport = require("src.render.GameViewport")
+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
@@ -85,10 +90,10 @@ Renderer.UPRIGHT_MARGIN = 160
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
-- on BOTH axes (square).
local function displayMetrics()
- local ww, wh = love.graphics.getDimensions()
+ local ww, wh = GameViewport.dimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
- pw, ph = love.graphics.getPixelDimensions()
+ pw, ph = GameViewport.pixelDimensions()
end
local dpiX, dpiY = 1, 1
if ww > 0 and pw > 0 then dpiX = pw / ww end
@@ -109,6 +114,8 @@ local function displayMetrics()
end
function Renderer:init()
+ GameViewport.reset()
+ 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.
@@ -147,7 +154,7 @@ end
function Renderer:fitScale()
local _, _, pw, ph = displayMetrics()
local w, h = self:uiSize()
- return math.max(1, math.floor(math.min(pw / w, ph / h)))
+ return PixelFit.scale(pw, ph, w, h)
end
-- Integer framebuffer pixels per GB pixel for the UI pass.
@@ -274,9 +281,15 @@ function Renderer:setWorldBounds(w, h)
end
function Renderer:worldViewSize()
- local _, _, pw, ph = displayMetrics()
- local sp = Zoom.scale(self:fitScale())
+ local ww, wh, pw, ph, _, dpiY = displayMetrics()
+ local fitted = self:fitScale()
+ local sp = Zoom.scale(fitted)
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
+ local _, uih = self:uiSize()
+ local lift = not self.splitViewport and ScreenPosition.pixelLift(ww, wh, ph,
+ uih * fitted, dpiY) or 0
+ -- Preserve full world coverage below the raised camera.
+ if lift > 0 then vh = vh + 2 * math.ceil(lift / sp) end
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end
@@ -320,6 +333,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 +730,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
@@ -845,7 +864,7 @@ end
-- When GBC FX is active the composite is drawn into presentCanvas and
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
- love.graphics.setCanvas()
+ GameViewport.setTarget()
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
@@ -854,8 +873,11 @@ function Renderer:endFrame(zones, worldZones)
local uiw, uih = self:uiSize()
local vpw, vph = uiw * Sx, uih * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
- local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
- local oy = math.floor((ph - uih * Sp) / 2) / dpiY
+ local pixelX, pixelY = PixelFit.origin(pw, ph, uiw, uih, Sp)
+ local lift = not self.splitViewport and ScreenPosition.pixelLift(ww, wh, ph,
+ uih * Sp, dpiY) or 0
+ pixelY = pixelY - lift
+ local ox, oy = pixelX / dpiX, pixelY / dpiY
-- The UI has its own scale: it steps down as the survey zoom goes out (see
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
-- are identical to Sp/ox/oy and every rect below is what it always was.
@@ -866,13 +888,14 @@ function Renderer:endFrame(zones, worldZones)
-- GB pixel stops being a whole number of screen pixels, which is the trade
-- the setting exists to offer. Clamped on the horizontal too, so a narrow
-- window scales to fit instead of overflowing off both sides.
- if self.uiFill or self.splitViewport then
+ if self.uiFill or self.splitViewport or GameViewport.skinFill then
Up = math.min(ph / uih, pw / uiw)
end
local Ux, Uy = Up / dpiX, Up / dpiY
local uvpw, uvph = uiw * Ux, uih * Uy
- local uox = math.floor((pw - uiw * Up) / 2) / dpiX
- local uoy = math.floor((ph - uih * Up) / 2) / dpiY
+ local uiPixelX, uiPixelY = PixelFit.origin(pw, ph, uiw, uih, Up)
+ if lift > 0 then uiPixelY = math.max(0, uiPixelY - lift) end
+ local uox, uoy = uiPixelX / dpiX, uiPixelY / dpiY
self.uiPresentation = {x=uox,y=uoy,w=uvpw,h=uvph,scaleX=Ux,scaleY=Uy}
local GBCFX = gbcFX()
-- Forced mono/Classic modes still need a whole-screen zone when a state
@@ -892,7 +915,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,
@@ -947,8 +970,12 @@ function Renderer:endFrame(zones, worldZones)
and (self.battleWipe.style == "frlg_swirl"
or self.battleWipe.needsSource)
and self.battleWipe.screenDraw
+ local hasOutputHook = Output.enabled()
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
- or battleNeedsSource
+ or battleNeedsSource or hasOutputHook
+ 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
@@ -961,6 +988,7 @@ function Renderer:endFrame(zones, worldZones)
-- what remains here is the dpiX vs dpiY truncation gap (well under 1%,
-- one seam across the window) that a single scalar dpiscale cannot
-- express.
+ if self.presentCanvas and self.presentCanvas.release then self.presentCanvas:release() end
self.presentCanvas = love.graphics.newCanvas(ww, wh)
-- NEAREST, like every other surface in this pipeline.
--
@@ -1131,7 +1159,7 @@ function Renderer:endFrame(zones, worldZones)
sp = self:worldPresentationScale(sp, pw, ph, wvw, wvh)
sx, sy = sp / dpiX, sp / dpiY
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
- local woy = math.floor((ph - wvh * sp) / 2) / dpiY
+ local woy = (math.floor((ph - wvh * sp) / 2) - lift) / dpiY
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -1389,6 +1417,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
@@ -1431,22 +1463,32 @@ function Renderer:endFrame(zones, worldZones)
end
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)
+ GameViewport.setTarget()
+ 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
+ local outputHandled=hasOutputHook and Output.draw({
+ canvas=composed,width=ww,height=wh,gameX=ox,gameY=oy,gameWidth=vpw,gameHeight=vph,
+ scale=Sp,dpiX=dpiX,dpiY=dpiY,generation=require("src.core.GameVersion").generation(),
+ })
+ if not outputHandled then
+ 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
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/TextBox.lua b/src/render/TextBox.lua
index 07f605295..359964018 100644
--- a/src/render/TextBox.lua
+++ b/src/render/TextBox.lua
@@ -420,6 +420,44 @@ function TextBox:beginLine()
table.insert(self.shown, {})
end
+-- Only the manual input branches; typing, automatic text and holds still
+-- belong to update(), including the delay after a page/line advance.
+function TextBox:advance()
+ if (self.holdFrames or 0) > 0 or (self.preWait or 0) > 0 then
+ return
+ end
+ if self.done then
+ if self.stay or self.auto or self.choice then
+ return
+ end
+ require("src.core.Sound").play(self.game.data, "Press_AB")
+ self.game.stack:pop()
+ if self.onDone then
+ self.onDone()
+ end
+ elseif self.waiting then
+ require("src.core.Sound").play(self.game.data, "Press_AB")
+ self.waiting = false
+ if self.contAdvance then
+ -- ContText / ManualTextScroll: keep the box, scroll one line
+ self.contAdvance = false
+ self.lineIndex = self.lineIndex + 1
+ self:beginLine()
+ -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment
+ -- says, is "always called twice in a row" (home/text.asm:280-305)
+ self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR
+ else
+ self.shown = {}
+ self.pageIndex = self.pageIndex + 1
+ self.lineIndex = 1
+ self:beginLine()
+ -- ClearScreenArea then DelayFrames 20: the box sits empty before the
+ -- next page starts typing (home/text.asm:236-240)
+ self.holdFrames = Timing.TEXT_PAGE_CLEAR
+ end
+ end
+end
+
function TextBox:update(dt)
local input = self.game.input
self.blink = (self.blink + 1) % 60
@@ -519,9 +557,7 @@ function TextBox:update(dt)
return
end
if input:wasPressed("a") or input:wasPressed("b") then
- require("src.core.Sound").play(self.game.data, "Press_AB")
- self.game.stack:pop()
- if self.onDone then self.onDone() end
+ self:advance()
end
return
end
@@ -534,25 +570,7 @@ function TextBox:update(dt)
return
end
if input:wasPressed("a") or input:wasPressed("b") then
- require("src.core.Sound").play(self.game.data, "Press_AB")
- self.waiting = false
- if self.contAdvance then
- -- ContText / ManualTextScroll: keep the box, scroll one line
- self.contAdvance = false
- self.lineIndex = self.lineIndex + 1
- self:beginLine()
- -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment
- -- says, is "always called twice in a row" (home/text.asm:280-305)
- self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR
- else
- self.shown = {}
- self.pageIndex = self.pageIndex + 1
- self.lineIndex = 1
- self:beginLine()
- -- ClearScreenArea then DelayFrames 20: the box sits empty before the
- -- next page starts typing (home/text.asm:236-240)
- self.holdFrames = Timing.TEXT_PAGE_CLEAR
- end
+ self:advance()
end
return
end
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/Commands.lua b/src/script/Commands.lua
index 53cef91fa..8796c0aa9 100644
--- a/src/script/Commands.lua
+++ b/src/script/Commands.lua
@@ -370,9 +370,9 @@ function Commands.show_text(ctx, textId, subs, extraOpts)
-- ctx.lastCheck untouched -- the prompt never appears and the script reads
-- the stale value as the answer.
if opts and opts.choice then opts.auto = nil end
- ctx.game.stack:push(TextBox.new(ctx.game, text, function()
- runner:resume()
- end, opts))
+ local onDone = function() runner:resume() end
+ if ctx.portableCallback then onDone = ctx.portableCallback(onDone) end
+ ctx.game.stack:push(TextBox.new(ctx.game, text, onDone, opts))
runner:yield()
end
@@ -570,7 +570,7 @@ function Commands.give_item(ctx, itemId, count, gotText)
if not require("src.inventory.Bag").add(
ctx.save, itemId, count or 1, ctx.game.data) then
ctx.lastCheck = false
- Commands.show_text(ctx, ctx.game.data.text
+ Commands.show_text(ctx, ctx.portableBagFullText or ctx.game.data.text
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
return math.huge
end
@@ -1245,21 +1245,24 @@ local function askNickname(ctx, mon)
-- opts.choice (TextBox.lua): the YES/NO box rides over the still-visible
-- question instead of a bare ChoiceBox popping up after an A press
-- already cleared it (same fix as Commands.ask).
- Commands.show_text(ctx, textId, subs, { choice = function(yes)
+ local choice = function(yes)
if not yes then
ctx.lastCheck = success
runner:resume()
return
end
+ local onDone = function(nick)
+ if nick and #nick > 0 then mon.nickname = nick end
+ ctx.lastCheck = success
+ runner:resume()
+ end
+ if ctx.portableCallback then onDone = ctx.portableCallback(onDone) end
Screens.push(ctx.game, "NamingScreen", {
- title = Strings("NICKNAME?"), maxLen = 10,
- onDone = function(nick)
- if nick and #nick > 0 then mon.nickname = nick end
- ctx.lastCheck = success
- runner:resume()
- end,
+ title = Strings("NICKNAME?"), maxLen = 10, onDone = onDone,
})
- end })
+ end
+ if ctx.portableCallback then choice = ctx.portableCallback(choice) end
+ Commands.show_text(ctx, textId, subs, { choice = choice })
end
-- give_pokemon : _GivePokemon (engine/events/
@@ -1289,6 +1292,7 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts)
ctx.game.mods.events:emit("pokemon.before_give", gift)
species, level = gift.species, gift.level
end
+ if ctx.portableAlive and not ctx.portableAlive() then return end
local Pokemon = require("src.pokemon.Pokemon")
local Party = require("src.pokemon.Party")
local Boxes = require("src.pokemon.Boxes")
@@ -1333,11 +1337,24 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts)
dex.seen[species] = true
dex.owned[species] = true
end
+ local Unown = require("src.pokemon.Gen2Unown")
+ if Unown.supported() then Unown.registerCatch(ctx.save, mon, ctx.game.data) end
require('src.pokemon.Gen4Forms').record(ctx.game,species,mon)
ctx.lastCheck = true
ctx.addedToParty = addedToParty
require('src.pokemon.Gen4PoketchState').remember(ctx.game, mon)
ctx.boxNum = boxNum
+ if ctx.portableGiftText and ctx.runner then
+ local wait = true
+ if not gift.nickname and not skipNickname then wait = false end
+ ctx.textOpts = ctx.textOpts or {}
+ ctx.textOpts.auto = {
+ sound = function() return require("src.core.Sound").play(ctx.game.data, "Get_Item1") end,
+ wait = wait,
+ }
+ Commands.show_text(ctx, ctx.game.data.text and ctx.game.data.text._GotMonText
+ or Strings("{PLAYER} received\n%s!", ctx.game.stringBuffer))
+ end
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
-- and callback-style callers with no script runner to yield on.
if not gift.nickname and not skipNickname and ctx.runner then
diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua
index a62fa0e17..80a044850 100644
--- a/src/script/Gen2Commands.lua
+++ b/src/script/Gen2Commands.lua
@@ -22,6 +22,7 @@ local Logger = require("src.core.Logger")
local Strings = require("src.core.Strings")
local Gen2Commands = {}
+local Species=require("src.core.Gen2SpeciesNames")
local function scriptVar(ctx)
return ctx.g2Var or 0
@@ -398,7 +399,7 @@ local CUSTOM_ARG_DISPATCH = {
function Commands.g2_give_poke(ctx,species,level,item)
species=tonumber(species) or 0
if species==0 then species=scriptVar(ctx) end
- local id=("SPECIES_%03d"):format(species)
+ local id=Species.fromNumber(ctx.game.data,species)
if not ctx.game.data.pokemon[id] then setScriptVar(ctx,2);return end
if #(ctx.save.party or {})>=6 then
local Boxes=require('src.pokemon.Boxes')
@@ -1098,6 +1099,12 @@ function Commands.g2_object(ctx, index, visible)
obj, mapId = objectByIndex(ctx, index)
end
if not obj or not mapId then return end
+ return Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible)
+end
+
+-- Shared native effect after resolving a map object. Public API adapters can
+-- supply the resolved template without depending on cached script-ID numbering.
+function Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible)
local OverworldState = require("src.world.OverworldController")
local name = OverworldState.objectToggleKey(obj)
-- The PERSISTENT half, which this used to leave to a `setevent` the ROM
@@ -1815,7 +1822,7 @@ function Commands.g2_cry(ctx, species)
-- a species of nil is Prism's GetScriptByteOrVar zero: "whatever the script
-- variable holds", which is how the six Prism starter balls share one script
if not species then
- species = string.format("SPECIES_%03d", math.floor(scriptVar(ctx)) % 256)
+ species = Species.fromNumber(ctx.game.data,math.floor(scriptVar(ctx))%256)
end
require("src.core.Sound").playCry(ctx.game.data, species)
end
@@ -1834,7 +1841,7 @@ function Commands.g2_pokepic(ctx, species)
if not (ow and ctx.game) then return end
-- `pokepic 0` reads wScriptVar instead of a literal
if species == nil and type(ctx.g2Var) == "number" and ctx.g2Var > 0 then
- species = string.format("SPECIES_%03d", ctx.g2Var)
+ species = Species.fromNumber(ctx.game.data,ctx.g2Var)
end
if not species then return end
local path, trueColor =
@@ -1985,7 +1992,7 @@ end
function Commands.g2_check_poke(ctx, species)
for _, mon in ipairs(ctx.save.party or {}) do
- if mon.species == species then
+ if Species.same(ctx.game.data,mon.species,species)then
ctx.lastCheck = true
ctx.g2Var = 1
return
@@ -2382,13 +2389,13 @@ end
-- no handler it always answered no, so walking into the lab with the hatched
-- TOGEPI got Elm's old "how is the EGG doing" line for the rest of the game.
function Commands.g2_find_party_species_own(ctx)
- local want = string.format("SPECIES_%03d", math.floor(scriptVar(ctx)) % 256)
+ local want = Species.fromNumber(ctx.game.data,math.floor(scriptVar(ctx))%256)
local player = ctx.save.player or {}
local found = false
for _, mon in ipairs(ctx.save.party or {}) do
-- an EGG does not count: the ROM reads the species out of the party slot
-- and an egg's slot species is the hatchling's, so the check has to skip it
- if mon.species == want and not mon.isEgg then
+ if Species.same(ctx.game.data,mon.species,want)and not mon.isEgg then
local ownName = mon.ot == nil or mon.ot == player.name
local ownId = mon.otId == nil or mon.otId == player.id
if ownName and ownId then found = true break end
@@ -2415,21 +2422,18 @@ function Commands.g2_give_odd_egg(ctx)
end
local row = eggs[pick] or eggs[1]
local Pokemon = require("src.pokemon.Pokemon")
- local mon = Pokemon.new(data, row.species, row.level or 5)
+ local eggDvs
if type(row.dvs) == "table" and row.dvs[1] and row.dvs[2] then
local one, two = row.dvs[1], row.dvs[2]
local atk, dfn = math.floor(one / 16) % 16, one % 16
local spd, spc = math.floor(two / 16) % 16, two % 16
- mon.dvs = {
+ eggDvs = {
attack = atk, defense = dfn, speed = spd, special = spc,
hp = (atk % 2) * 8 + (dfn % 2) * 4 + (spd % 2) * 2 + (spc % 2),
}
- pcall(function()
- mon.stats = require("src.pokemon.Stats")
- .calc(data.pokemon[row.species], mon.level, mon.dvs, mon.statExp)
- mon.hp = mon.stats.hp
- end)
end
+ local mon = Pokemon.new(data, row.species, row.level or 5, nil, nil,
+ eggDvs and {dvs=eggDvs} or nil)
if row.moves then
mon.moves = {}
for _, id in ipairs(row.moves) do
@@ -2628,12 +2632,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")
@@ -2666,6 +2667,12 @@ function Commands.g2_readvar(ctx, var)
-- sampled by `special SampleKenjiBreakCountdown`
ctx.g2Var = ctx.save.g2KenjiBreak or 0
elseif var == 14 then
+ local Unown = require("src.pokemon.Gen2Unown")
+ if Unown.supported() then
+ ctx.g2Var = Unown.count(ctx.save)
+ ctx.lastCheck = ctx.g2Var ~= 0
+ return
+ end
-- UnownCaught (engine/events/unown_walls.asm): how many distinct UNOWN
-- letters are in the dex. The port has no per-letter model, so a caught
-- UNOWN counts once -- enough for the Ruins researcher's `ifgreater 2`
@@ -2882,44 +2889,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
@@ -3125,7 +3109,7 @@ function Commands.g2_return_nobus_aggron(ctx)
runner:yield()
local function result(n) ctx.g2Var, ctx.lastCheck = n, n ~= 0 end
if not picked then return result(1) end
- if picked.species ~= "SPECIES_045" or picked.otId ~= NOBU_OT_ID
+ if not Species.same(ctx.game.data,picked.species,"SPECIES_045")or picked.otId ~= NOBU_OT_ID
or picked.ot ~= NOBU_OT then
return result(0)
end
@@ -3169,7 +3153,7 @@ function Commands.g2_select_mon_from_party(ctx)
ctx.g2Var, ctx.lastCheck = 0xFF, true
return
end
- local index = tonumber(tostring(picked.species or ""):match("(%d+)$")) or 0
+ local index=Species.number(ctx.game.data,picked)
ctx.g2Var, ctx.lastCheck = index, index ~= 0
-- the name the following text splices in
ctx.g2MonName = picked.nickname or picked.species
@@ -3510,7 +3494,7 @@ function Commands.g2_seen_mon(ctx)
local n = ctx.g2Var or 0
if n < 1 then return end
local dex = ctx.save.pokedex
- if dex and dex.seen then dex.seen[string.format("SPECIES_%03d", n)] = true end
+ if dex and dex.seen then dex.seen[Species.fromNumber(ctx.game.data,n)] = true end
end
-- `GetFirstPokemonHappiness` (engine/happiness.asm): walks past EGGs to the
@@ -3664,7 +3648,7 @@ function Commands.g2_trade(ctx, index)
if not picked then
return say("declined", Strings("You don't want to\ntrade? Aww…"))
end
- if picked.species ~= trade.give then
+ if not Species.same(ctx.game.data,picked.species,trade.give)then
return say("wrongMon", Strings("Huh? That's not\n%s.", giveName))
end
-- CheckTradeGender (3F:$4C23): only DORIS the DODRIO asks, and she asks for
@@ -3693,10 +3677,7 @@ function Commands.g2_trade(ctx, index)
-- recalculates its stats from those DVs at that level).
local Pokemon = require("src.pokemon.Pokemon")
local sent = party[slot]
- local newMon = Pokemon.new(data, trade.get, sent.level)
- if trade.nickname and trade.nickname ~= "" then
- newMon.nickname = trade.nickname
- end
+ local tradeDvs
-- The table's two DV bytes are the usual packed nibble pairs -- attack and
-- defense in the first, speed and special in the second -- and the HP DV is
-- the four low bits, exactly as Stats.randomDVs derives it.
@@ -3704,17 +3685,15 @@ function Commands.g2_trade(ctx, index)
local one, two = trade.dvs[1], trade.dvs[2]
local atk, dfn = math.floor(one / 16) % 16, one % 16
local spd, spc = math.floor(two / 16) % 16, two % 16
- local dvs = {
+ tradeDvs = {
attack = atk, defense = dfn, speed = spd, special = spc,
hp = (atk % 2) * 8 + (dfn % 2) * 4 + (spd % 2) * 2 + (spc % 2),
}
- newMon.dvs = dvs
- pcall(function()
- local speciesDef = data.pokemon[trade.get]
- newMon.stats = require("src.pokemon.Stats")
- .calc(speciesDef, newMon.level, dvs, newMon.statExp)
- newMon.hp = newMon.stats.hp
- end)
+ end
+ local newMon = Pokemon.new(data, trade.get, sent.level, nil, nil,
+ tradeDvs and {dvs=tradeDvs} or nil)
+ if trade.nickname and trade.nickname ~= "" then
+ newMon.nickname = trade.nickname
end
newMon.traded = true -- boosted exp, and the Name Rater refuses it
newMon.ot = (trade.ot ~= "" and trade.ot) or "TRAINER"
@@ -3769,7 +3748,7 @@ function Commands.g2_return_shuckie(ctx)
ctx.g2Var, ctx.lastCheck = n, n ~= 0
end
if not picked then return result(1) end
- if picked.species ~= "SPECIES_213" or picked.otId ~= SHUCKIE_OT_ID
+ if not Species.same(ctx.game.data,picked.species,"SPECIES_213")or picked.otId ~= SHUCKIE_OT_ID
or picked.ot ~= SHUCKIE_OT then
return result(0)
end
@@ -4666,11 +4645,8 @@ local function pickPartyMon(ctx)
return picked
end
-local function speciesNumber(mon)
- if not mon then return 0 end
- if type(mon.species) == "number" then return mon.species end
- local n = tostring(mon.species or ""):match("(%d+)$")
- return tonumber(n) or 0
+local function speciesNumber(ctx,mon)
+ return mon and Species.number(ctx.game.data,mon)or 0
end
-- `special BillsGrandfather` ($4C). Party menu; wScriptVar = species id.
@@ -4681,7 +4657,7 @@ function Commands.g2_bills_grandfather(ctx)
ctx.lastCheck = false
return
end
- ctx.g2Var = speciesNumber(picked)
+ ctx.g2Var = speciesNumber(ctx,picked)
ctx.lastCheck = ctx.g2Var ~= 0
end
@@ -5085,7 +5061,7 @@ function Commands.g2_init_roam_mons(ctx)
if roamsInThisVersion(name) then
ctx.save.g2Roam[name] = {
mapId = startMap,
- speciesId = ROAM_SPECIES_ID[name],
+ speciesId=Species.registryId(ctx.game.data.pokemon,ROAM_SPECIES_ID[name]),
landmark = mapToLandmark(ctx.game, startMap) or LANDMARK_BY_MAP[startMap],
active = true,
-- InitRoamMons: `ld a, 40 / ld [wRoamMon1Level], a` for every beast,
@@ -5175,7 +5151,7 @@ function Commands.g2_ensure_roam_landmarks(ctx)
if not roamsInThisVersion(name) then
roam[name] = nil
elseif type(info) == "table" and info.mapId then
- info.speciesId = info.speciesId or ROAM_SPECIES_ID[name]
+ info.speciesId=Species.registryId(ctx.game.data.pokemon,info.speciesId or ROAM_SPECIES_ID[name])
-- Saves released before the beasts could actually be MET have a slot
-- with no level and no HP in it. Fill those in rather than leaving a
-- beast that cannot be turned into an encounter.
@@ -5450,7 +5426,7 @@ function Commands.g2_rock_encounter(ctx)
pick = pick - row.chance
if pick < 0 then
ctx.g2Wild = {species = row.species, level = row.level}
- species = tonumber(row.species:match("(%d+)$")) or 0
+ species=Species.number(ctx.game.data,row.species)
break
end
end
@@ -5849,7 +5825,7 @@ end
-- extractor can see a zero on: `pokenamemem 0, 0` and `checkitem 0`
function Commands.g2_getmonname_var(ctx, buffer)
Commands.g2_getmonname(ctx,
- string.format("SPECIES_%03d", math.floor(scriptVar(ctx)) % 256), buffer)
+ Species.fromNumber(ctx.game.data,math.floor(scriptVar(ctx))%256), buffer)
end
function Commands.g2_check_item_var(ctx)
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/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua
index 0d5c51cfe..3f6a7a3b0 100644
--- a/src/script/Gen2ScriptVM.lua
+++ b/src/script/Gen2ScriptVM.lua
@@ -574,7 +574,7 @@ L.getmonname = function(ir, s)
if (ir[2] or 0) == 0 then
emit(s, { "g2_getmonname_var", ir[3] })
else
- emit(s, { "g2_getmonname", string.format("SPECIES_%03d", ir[2]), ir[3] })
+ emit(s, { "g2_getmonname", require('src.core.Gen2SpeciesNames').fromNumber(s.data,ir[2]), ir[3] })
end
end
L.takeitem = function(ir, s) emit(s, { "take_item", itemId(ir[2]), ir[3] }) end
@@ -604,13 +604,13 @@ L.checkmoney = function(ir, s) emit(s, { "g2_check_money", ir[3] }) end
-- verbosegiveitem already handled, so only the bag-full test carries weight
L.pocketisfull = function(_, s) emit(s, { "g2_pocket_full" }) end
L.checkpoke = function(ir, s)
- emit(s, { "g2_check_poke", string.format("SPECIES_%03d", ir[2] or 0) })
+ emit(s, { "g2_check_poke", require('src.core.Gen2SpeciesNames').fromNumber(s.data,ir[2]or 0) })
end
-- `giveegg species, level`: an EGG party member. Elm's aide hands one over
-- outside the Violet gym and the script `iftrue`s on the carry, so a missing
-- lowering left the whole delivery scene dead.
L.giveegg = function(ir, s)
- emit(s, { "g2_give_egg", string.format("SPECIES_%03d", ir[2] or 0), ir[3] })
+ emit(s, { "g2_give_egg", require('src.core.Gen2SpeciesNames').fromNumber(s.data,ir[2]or 0), ir[3] })
end
L.givepoke = function(ir, s)
emit(s, { "g2_give_poke", ir[2], ir[3], ir[4] })
@@ -625,7 +625,7 @@ L.cry = function(ir, s)
-- zero operand, which is how one starter script cries six different mons.
-- Lowered literally it asked for SPECIES_000 and nothing sounded.
emit(s, { "g2_cry",
- (ir[2] or 0) ~= 0 and string.format("SPECIES_%03d", ir[2]) or nil })
+ (ir[2] or 0) ~= 0 and require('src.core.Gen2SpeciesNames').fromNumber(s.data,ir[2]) or nil })
end
-- `pokepic ` opens the framed front pic and keeps running;
@@ -636,7 +636,7 @@ L.pokepic = function(ir, s)
local species = ir[2]
emit(s, { "g2_pokepic",
(species and species > 0)
- and string.format("SPECIES_%03d", species) or nil })
+ and require('src.core.Gen2SpeciesNames').fromNumber(s.data,species) or nil })
end
L.closepokepic = function(_, s) emit(s, { "g2_close_pokepic" }) end
@@ -693,7 +693,7 @@ L.loadtrainer = function(ir, s) emit(s, { "g2_load_trainer", ir[2], ir[3] }) end
-- Sharing loadtrainer's lowering meant Sudowoodo and the Red Gyarados asked
-- for trainer class 185, found nothing, and silently skipped the battle.
L.loadwildmon = function(ir, s)
- emit(s, { "g2_load_wild", string.format("SPECIES_%03d", ir[2] or 0), ir[3] })
+ emit(s, { "g2_load_wild", require('src.core.Gen2SpeciesNames').fromNumber(s.data,ir[2]or 0), ir[3] })
end
L.winlosstext = function(ir, s) emit(s, { "g2_winloss", ir[2], ir[3] }) end
L.startbattle = function(_, s) emit(s, { "g2_start_battle" }) end
@@ -1796,7 +1796,7 @@ end
L.showcrytext = function(ir, s)
local species = ir[3]
emit(s, { "g2_cry", (species and species > 0)
- and string.format("SPECIES_%03d", species) or nil })
+ and require('src.core.Gen2SpeciesNames').fromNumber(s.data,species) or nil })
s.lastText = ir[2]
showText(s, ir[2])
s.lastTextRow = #s.out
@@ -2378,6 +2378,7 @@ function Gen2ScriptVM.compile(data, entry)
local out, queued, order = {}, { [entry] = true }, { entry }
local counter = 0
local state = {
+ data = data,
out = out,
stds = pool.stds,
has = function(label)
diff --git a/src/script/Gen2Specials.lua b/src/script/Gen2Specials.lua
index b554b340e..5516c3fd3 100644
--- a/src/script/Gen2Specials.lua
+++ b/src/script/Gen2Specials.lua
@@ -39,14 +39,13 @@ local function answer(ctx, value)
-- restarts the script), so even `return answer(...)` must return nil.
end
-local function speciesKey(n)
- return string.format("SPECIES_%03d", math.floor(n or 0) % 256)
+local function speciesKey(ctx,n)
+ return require('src.core.Gen2SpeciesNames').fromNumber(ctx.game.data,math.floor(n or 0)%256)
end
-local function speciesNumber(mon)
+local function speciesNumber(ctx,mon)
if not mon then return 0 end
- if type(mon.species) == "number" then return mon.species end
- return tonumber(tostring(mon.species or ""):match("(%d+)$")) or 0
+ return require('src.core.Gen2SpeciesNames').number(ctx.game.data,mon)
end
local function speciesName(ctx, species)
@@ -155,7 +154,7 @@ local function ownsAnywhere(ctx, key)
local Party = require("src.pokemon.Party")
local player = save.player or {}
local function match(mon)
- if not mon or mon.species ~= key then return false end
+ if not mon or not require('src.core.Gen2SpeciesNames').same(ctx.game.data,mon.species,key)then return false end
if Party.isEgg(mon) then return false end
local ownName = mon.ot == nil or mon.ot == player.name
local ownId = mon.otId == nil or mon.otId == player.id
@@ -210,7 +209,7 @@ end
-- in wScriptVar, anywhere, raised themselves? Seven call sites, all of them
-- NPCs who react to a particular mon.
function Commands.g2_mon_check(ctx)
- answer(ctx, ownsAnywhere(ctx, speciesKey(ctx.g2Var)) and 1 or 0)
+ answer(ctx, ownsAnywhere(ctx, speciesKey(ctx,ctx.g2Var)) and 1 or 0)
end
-- BeastsCheck: Raikou, Entei AND Suicune, all three, same ownership rule.
@@ -307,9 +306,10 @@ end
-- already has that as g2_find_party_species_own.
function Commands.g2_find_party_species(ctx)
local Party = require("src.pokemon.Party")
- local want = speciesKey(ctx.g2Var)
+ local want = speciesKey(ctx,ctx.g2Var)
for _, mon in ipairs(ctx.save.party or {}) do
- if mon.species == want and not Party.isEgg(mon) then return answer(ctx, 1) end
+ if require('src.core.Gen2SpeciesNames').same(ctx.game.data,mon.species,want)
+ and not Party.isEgg(mon)then return answer(ctx, 1)end
end
answer(ctx, 0)
end
@@ -341,7 +341,7 @@ end
-- the mon is already in the party, so the dex marks are the point.
-- Fifteen call sites -- every prize in both game corners.
function Commands.g2_prize_mon_dex(ctx)
- local key = speciesKey(ctx.g2Var)
+ local key = speciesKey(ctx,ctx.g2Var)
local dex = ctx.save.pokedex
if not dex then return end
dex.seen, dex.owned = dex.seen or {}, dex.owned or {}
@@ -437,7 +437,7 @@ function Commands.g2_give_dratini(ctx)
local moves = ctx.game.data.moves or {}
for index = #(ctx.save.party or {}), 1, -1 do
local mon = ctx.save.party[index]
- if mon and speciesNumber(mon) == 147 then -- DRATINI
+ if mon and speciesNumber(ctx,mon) == 147 then -- DRATINI
mon.moves = {}
for _, id in ipairs(set) do
local def = moves[id]
@@ -525,7 +525,7 @@ end
function Commands.g2_magikarp_length(ctx)
local picked = pickMon(ctx)
if not picked then return answer(ctx, 1) end
- if speciesNumber(picked) ~= 129 then return answer(ctx, 0) end
+ if speciesNumber(ctx,picked) ~= 129 then return answer(ctx, 0) end
local feet, inches = magikarpLength(picked,
picked.otId or (ctx.save.player and ctx.save.player.id) or 0)
local shown = lengthText(feet, inches)
@@ -870,7 +870,7 @@ function Commands.g2_happiness_tutor(ctx)
if (picked.happiness or 0) < 200 then
return say(ctx, nil, Strings("%s doesn't\nlike you enough\nyet.", monName(ctx, picked)))
end
- local name = HAPPINESS_MOVES[speciesNumber(picked)]
+ local name = HAPPINESS_MOVES[speciesNumber(ctx,picked)]
local id = name and moveKey(ctx.game.data, name)
if not id then
return say(ctx, nil, Strings("There's nothing I\ncan teach it."))
diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua
index 13566a36a..8fee26e5a 100644
--- a/src/ui/BagMenu.lua
+++ b/src/ui/BagMenu.lua
@@ -1244,11 +1244,14 @@ function BagMenu.new(game, opts)
options[#options + 1] = { label = Strings("QUIT"), onSelect = function() end }
end
local th = #options * 2 + 1
- game.stack:push(Menu.new(game, options, gen2
+ local actions = Menu.new(game, options, gen2
and { tx = 0, ty = 12 - th, tw = 7, th = th }
- or { tx = 13, ty = 10, tw = 7, th = th }))
+ or { tx = 13, ty = 10, tw = 7, th = th })
+ actions.publicUIKind = "bag_actions"
+ game.stack:push(actions)
end,
})
+ list.publicUIKind, list.publicUIBattle = "bag", battle
if gen2 then list.sgbPalettes = packPalettes end
return list
end
diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua
index 1f9f41f81..ba7edbdd0 100644
--- a/src/ui/ChoiceBox.lua
+++ b/src/ui/ChoiceBox.lua
@@ -30,6 +30,18 @@ function ChoiceBox.new(game, onChoose, opts)
return self
end
+function ChoiceBox:choose(index)
+ if self.pending ~= nil then
+ return
+ end
+ if not self.noSound then
+ require("src.core.Sound").play(self.game.data, "Press_AB")
+ end
+ self.index = index
+ self.pending = index == 1
+ self.holdFrames = Timing.YES_NO_ANSWER
+end
+
function ChoiceBox:update(dt)
local input = self.game.input
-- Both branches of DisplayTwoOptionMenu hold 15 frames with the menu still
@@ -48,21 +60,9 @@ function ChoiceBox:update(dt)
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("a") then
- -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on A and B alike
- if not self.noSound then
- require("src.core.Sound").play(self.game.data, "Press_AB")
- end
- self.pending = (self.index == 1)
- self.holdFrames = Timing.YES_NO_ANSWER
+ self:choose(self.index)
elseif input:wasPressed("b") then
- if not self.noSound then
- require("src.core.Sound").play(self.game.data, "Press_AB")
- end
- -- .choseSecondMenuItem writes wCurrentMenuItem = 1 before the hold, so
- -- the cursor visibly snaps to NO for those 15 frames
- self.index = 2
- self.pending = false
- self.holdFrames = Timing.YES_NO_ANSWER
+ self:choose(2)
end
end
diff --git a/src/ui/Gen2Chrome.lua b/src/ui/Gen2Chrome.lua
new file mode 100644
index 000000000..df557ba62
--- /dev/null
+++ b/src/ui/Gen2Chrome.lua
@@ -0,0 +1,664 @@
+-- Ported from Gen1Recomp 535a5ec0; palette, font and DPI use this engine.
+-- Shared Gen 2 menu chrome: boxes, tile-grid text, and the scrolling cursor
+-- list that nearly every Gold screen is built out of.
+--
+-- Gold draws its menus through one routine (engine/menus/menu.asm SetUpMenu +
+-- GetScrollingMenuJoypad), so the behaviour every screen inherits -- wrap at
+-- the ends, a ▶ cursor in a one-tile gutter, B to back out, START as an
+-- accept on some menus -- lives here once instead of in each screen.
+--
+-- Everything is in 8px tile coordinates on the 20x18 GB grid, because that is
+-- how the ASM addresses the screen (hlcoord x, y) and it makes a layout
+-- transcribed from pokegold land on the same pixels.
+
+local Font = require("src.render.Font")
+local PaletteFX = require("src.render.PaletteFX")
+-- A view of the native palette renderer, with no extra mode or BGP state.
+local Colors = {}
+function Colors.available() return PaletteFX.shader() ~= nil end
+function Colors.resolve(colors) return PaletteFX.effectiveColors(colors) end
+function Colors.useRaw(colors)
+ local shader = PaletteFX.shader()
+ if not shader then return end
+ for i = 1, 4 do
+ local c = colors[i]
+ shader:send("c" .. (i - 1), { c[1] / 255, c[2] / 255, c[3] / 255 })
+ end
+ love.graphics.setShader(shader)
+end
+function Colors.use(colors) Colors.useRaw(PaletteFX.effectiveColors(colors)) end
+
+local function drawGlyphCode(code, x, y, ink)
+ if code >= 0 then return Font.drawCode(code, x, y) end
+ -- This engine's UnicodeFont encodes rasterized glyphs as negative codes.
+ ink = ink or { 0, 0, 0 }
+ Font.pushStyle({ text = { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 } })
+ local ok, err = pcall(Font.drawCode, code, x, y)
+ Font.popStyle()
+ if not ok then error(err, 0) end
+end
+
+local WHITE = { 255, 255, 255 }
+local BLACK = { 0, 0, 0 }
+
+local Chrome = {}
+
+-- charmap.asm: ▶ is the menu cursor, ▷ its hollow "held" form, ▼ the
+-- text-advance arrow.
+Chrome.CURSOR = 0xED
+Chrome.CURSOR_HOLLOW = 0xEC
+Chrome.DOWN_ARROW = 0xEE
+
+Chrome.SCREEN_W = 20
+Chrome.SCREEN_H = 18
+
+-- charmap.asm "¥", the money field's own prefix tile.
+local YEN = "\xc2\xa5"
+
+function Chrome.paletteFill(px, py, pw, ph, palette)
+ palette = palette or Chrome.DEFAULT_BOX_PALETTE
+ local G = love.graphics
+ G.setColor(1, 1, 1, 1)
+ if Colors.available() then
+ -- Colors.with without the closure: set, draw, restore.
+ local previous = G.getShader and G.getShader() or nil
+ Colors.use(palette)
+ G.rectangle("fill", px, py, pw, ph)
+ G.setShader(previous)
+ else
+ G.rectangle("fill", px, py, pw, ph)
+ end
+ G.setColor(0, 0, 0, 1)
+end
+
+-- Every drawWidescreen paints the window around its 160x144 panel through
+-- here, so UI LETTERBOX has exactly one place to override. r/g/b is what the
+-- screen was authored with and is what AUTO keeps.
+Chrome.worldSurround = false
+
+function Chrome.letterbox(winW, winH, r, g, b)
+ if Chrome.worldSurround then return end
+ love.graphics.setColor(r or 1, g or 1, b or 1, 1)
+ love.graphics.rectangle("fill", 0, 0, winW, winH)
+end
+
+function Chrome.clear()
+ Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
+end
+
+-- The blit scale every `drawWidescreen` paints its 160x144 panel at.
+--
+-- One GB pixel has to cover a WHOLE number of window pixels or the 8x8 grid
+-- everything here is laid out on stops landing on tile boundaries: at a
+-- fractional scale some rows of a tile get one device pixel and their
+-- neighbours get two, which is what breaks a box border into steps and eats
+-- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua
+-- fitScale applies to the Gen 1 UI canvas; the surround a widescreen screen
+-- paints still fills the window, the PANEL is what stays on the grid.
+local function playfieldRect(winW, winH)
+ local ok, Playfield = pcall(require, "src.render.Playfield")
+ if ok and Playfield.rect then
+ local okv, x, y, w, h = pcall(Playfield.rect, winW, winH)
+ if okv and w and w >= 1 and h and h >= 1 then
+ return x, y, w, h
+ end
+ end
+ return 0, 0, winW or 0, winH or 0
+end
+
+Chrome.playfieldRect = playfieldRect
+
+local function pixelRatio()
+ local get = love.graphics.getDPIScale or (love.window and love.window.getDPIScale)
+ local d = get and get() or 1
+ return type(d) == "number" and d > 0 and d or 1
+end
+
+Chrome.pixelRatio = pixelRatio
+
+function Chrome.fitScaleFor(winW, winH, tilesW, tilesH)
+ local _, _, w, h = playfieldRect(winW, winH)
+ local d = pixelRatio()
+ return math.max(1, math.floor(math.min(w * d / (tilesW * 8),
+ h * d / (tilesH * 8)) + 1e-6)) / d
+end
+
+function Chrome.fitOriginFor(winW, winH, scale, tilesW, tilesH)
+ scale = scale or Chrome.fitScaleFor(winW, winH, tilesW, tilesH)
+ local x, y, w, h = playfieldRect(winW, winH)
+ local d = pixelRatio()
+ return x + math.floor((w - tilesW * 8 * scale) * d / 2 + 1e-6) / d,
+ y + math.floor((h - tilesH * 8 * scale) * d / 2 + 1e-6) / d
+ - Chrome.positionLift(winW, winH, scale)
+end
+
+function Chrome.fitScale(winW, winH)
+ return Chrome.fitScaleFor(winW, winH, Chrome.SCREEN_W, Chrome.SCREEN_H)
+end
+
+-- The centred origin that goes with it, so a caller does not re-derive it.
+function Chrome.fitOrigin(winW, winH, scale)
+ scale = scale or Chrome.fitScale(winW, winH)
+ return Chrome.fitOriginFor(winW, winH, scale, Chrome.SCREEN_W,
+ Chrome.SCREEN_H)
+end
+
+function Chrome.positionLift(winW, winH, scale)
+ local ok, ScreenPosition = pcall(require, "src.core.ScreenPosition")
+ if not ok or ScreenPosition.skinActive(winW, winH) then return 0 end
+ local _, _, _, h = playfieldRect(winW, winH)
+ local d = pixelRatio()
+ return ScreenPosition.lift(h * d, Chrome.SCREEN_H * 8 * (scale
+ or Chrome.fitScale(winW, winH)) * d, ScreenPosition.safeTop() * d) / d
+end
+
+-- pokegold engine/battle/core.asm:8646, engine/events/halloffame.asm:270
+local function 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
+
+Chrome.clipTo = clipTo
+
+function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale)
+ local G = love.graphics
+ Chrome.letterbox(winW, winH, r, g, b)
+ scale = scale or Chrome.fitScale(winW, winH)
+ local ox, oy = Chrome.fitOrigin(winW, winH, scale)
+ G.push("all")
+ clipTo(ox, oy, Chrome.SCREEN_W * 8 * scale, Chrome.SCREEN_H * 8 * scale)
+ G.translate(ox, oy)
+ G.scale(scale, scale)
+ local ok, err = pcall(drawFn)
+ G.pop()
+ if not ok then error(err, 0) end
+end
+
+function Chrome.withClip(drawFn)
+ local G = love.graphics
+ G.push("all")
+ clipTo(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
+ local ok, err = pcall(drawFn)
+ G.pop()
+ if not ok then error(err, 0) end
+end
+
+Chrome.DEFAULT_BOX_PALETTE = {
+ { 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 },
+}
+
+function Chrome.paletteBox(tx, ty, tw, th, palette)
+ palette = palette or Chrome.DEFAULT_BOX_PALETTE
+ if Colors.available() then
+ local G = love.graphics
+ G.setColor(1, 1, 1, 1)
+ -- Colors.with, restoring the caller's shader even if the box throws.
+ local previous = G.getShader and G.getShader() or nil
+ Colors.use(palette)
+ local ok, err = pcall(Font.drawBox, tx, ty, tw, th)
+ G.setShader(previous)
+ if not ok then error(err, 0) end
+ else
+ Font.drawBox(tx, ty, tw, th)
+ end
+ love.graphics.setColor(0, 0, 0, 1)
+end
+
+-- A bordered box, tile coords. Leaves the draw color black for text.
+function Chrome.box(tx, ty, tw, th)
+ Chrome.paletteBox(tx, ty, tw, th)
+end
+
+-- Gold's Textbox helper takes an interior width/height and draws the border
+-- around it; b/c in the ASM are interior rows/columns, so a `lb bc, 4, 13`
+-- box is 6 rows by 15 columns on screen.
+function Chrome.textbox(tx, ty, interiorW, interiorH)
+ Chrome.box(tx, ty, interiorW + 2, interiorH + 2)
+end
+
+local function flatPrint(text, tx, ty)
+ love.graphics.setColor(0, 0, 0, 1)
+ return Font.draw(text, tx * 8, ty * 8)
+end
+
+function Chrome.print(text, tx, ty)
+ if not Colors.available() then return flatPrint(text, tx, ty) end
+ return Chrome.printThrough(text, tx, ty, Chrome.DEFAULT_BOX_PALETTE)
+end
+
+-- Prints a string the way a tilemap screen does: the glyph tiles replace the
+-- cells they land on, and both their ink and their blank pixels go through
+-- that screen's own BG palette.
+--
+-- The font sheets here are black ink (shade 3) on transparent, and setColor
+-- multiplies -- black times anything is still black -- so the ink colour
+-- cannot come from a tint. Drawing through GbcPalette maps shade 3 to the
+-- palette's colour 3, and painting colour 0 behind the string first supplies
+-- the cell background the sheet does not carry. Without a shader this
+-- degrades to the ordinary black print rather than drawing nothing.
+--
+-- The palette printThrough actually draws with: COLOR mode resolved,
+-- inverted if asked, and the active rBGP byte folded in last -- the same
+-- order Colors.use follows. Split out so the fold can be checked
+-- without a real shader (Colors.available() is false in a driverless
+-- test harness, which used to hide this from every suite).
+--
+-- The substitution has to happen before the reversal or an inverted string
+-- would come back through an un-reversed grey ramp and print black on white,
+-- and the rBGP fold has to come after both: it REORDERS the four entries
+-- this palette already settled on (colour 0 in the low bits, so
+-- `dc 3,2,1,0` packs to $e4), it does not tint them, so folding it before
+-- the invert reversal would permute the wrong four colours.
+function Chrome.throughPalette(palette, invert)
+ return PaletteFX.effectiveColors(palette, invert)
+end
+
+function Chrome.rawPalette(palette, invert)
+ if invert then return { palette[4], palette[3], palette[2], palette[1] } end
+ return palette
+end
+
+-- The per-glyph shader/tint switch printThrough needs, factored out so a
+-- caller that positions its own glyphs (TextBox's typewriter/scroll
+-- animation) gets the same shaded ink without re-deriving the TTF-vs-tile
+-- split.
+--
+-- Returns (pal, drawGlyph, finish):
+-- pal the resolved ramp, or nil if there's no shader to draw
+-- through; a caller that also draws a paper rect should skip
+-- it and take the plain Chrome.print degrade instead.
+-- drawGlyph (code, x, y): draws one glyph, shaded through `pal` for a
+-- tile glyph or tinted with pal[4] for a TTF one (see the
+-- inline comment below, gen1recomp#1642).
+-- finish call once after the last glyph to restore the previous
+-- shader and draw colour.
+function Chrome.paletteGlyphs(palette, invert, raw)
+ if not (palette and Colors.available()) then
+ return nil, Font.drawCode, function() end
+ end
+ -- `pal` is used below and never touches the caller's palette again --
+ -- including useRaw for the draw, since useRaw skips the fold Colors.use
+ -- would otherwise apply and folding it a second time would undo this.
+ local pal = raw and Chrome.rawPalette(palette, invert)
+ or Chrome.throughPalette(palette, invert)
+ local ink = pal[4] or { 0, 0, 0 }
+ local previous = love.graphics.getShader()
+ local shaded = false
+ local function drawGlyph(code, x, y)
+ if code < 0 then
+ -- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not.
+ -- LÖVE's font rasterizer stores glyph coverage as alpha over a plain
+ -- white texture, which this shader reads back as shade 0 no matter how
+ -- solid the glyph looks, painting the character the SAME colour as the
+ -- paper rect just drawn above it: invisible (reported against a real
+ -- Gold build with a TTF translation mod active, gen1recomp#1642). A TTF
+ -- glyph has no discrete shade to recover in the first place, so skip
+ -- the shader for it and tint it with the palette's own ink colour
+ -- (shade 3, the same entry `rgb = pal3` would have mapped a black tile
+ -- pixel to) directly.
+ if shaded then love.graphics.setShader(previous); shaded = false end
+ love.graphics.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1)
+ elseif not shaded then
+ love.graphics.setColor(1, 1, 1, 1)
+ Colors.useRaw(pal)
+ shaded = true
+ end
+ drawGlyphCode(code, x, y, ink)
+ end
+ local function finish()
+ if shaded then love.graphics.setShader(previous) end
+ love.graphics.setColor(0, 0, 0, 1)
+ end
+ return pal, drawGlyph, finish
+end
+
+-- printThrough's resolved palette, or nil when it has to take the plain
+-- degrade: paletteGlyphs' own test and fold, without its two closures.
+local function glyphPalette(palette, invert, raw)
+ if not (palette and Colors.available()) then return nil end
+ return raw and Chrome.rawPalette(palette, invert)
+ or Chrome.throughPalette(palette, invert)
+end
+
+-- The string's glyph codes and its width, off one encode: the width is the
+-- sum of the same advances the pen walks, which is all Font.width is.
+local function measure(text)
+ local codes = Font.encode(text)
+ local width = 0
+ for i = 1, #codes do width = width + Font.advanceOf(codes[i]) end
+ return codes, width
+end
+
+-- paper rect, then every glyph through `pal` -- paletteGlyphs' drawGlyph and
+-- finish inlined (see there for the TTF split, gen1recomp#1642).
+local function drawThrough(codes, width, x, y, pal)
+ local G = love.graphics
+ local paper = pal[1] or WHITE
+ local ink = pal[4] or BLACK
+ local previous = G.getShader()
+ G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
+ G.rectangle("fill", x, y, width, 8)
+ local shaded = false
+ local pen = x
+ for i = 1, #codes do
+ local code = codes[i]
+ if code < 0 then
+ if shaded then G.setShader(previous); shaded = false end
+ G.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1)
+ elseif not shaded then
+ G.setColor(1, 1, 1, 1)
+ Colors.useRaw(pal)
+ shaded = true
+ end
+ drawGlyphCode(code, pen, y, ink)
+ pen = pen + Font.advanceOf(code)
+ end
+ if shaded then G.setShader(previous) end
+ G.setColor(0, 0, 0, 1)
+end
+
+function Chrome.printThrough(text, tx, ty, palette, invert, raw)
+ local pal = glyphPalette(palette, invert, raw)
+ if not pal then return Chrome.print(text, tx, ty) end
+ local codes, width = measure(text)
+ drawThrough(codes, width, tx * 8, ty * 8, pal)
+ return width
+end
+
+-- The same, through an *inverted* font page. Pokedex_LoadInvertedFont xors
+-- both bitplanes of the standard font, so a glyph pixel of shade s becomes
+-- shade 3 - s before the palette sees it -- which is exactly the palette read
+-- backwards, and is what makes the dex white on black.
+function Chrome.printInverted(text, tx, ty, palette)
+ if not palette then return Chrome.print(text, tx, ty) end
+ return Chrome.printThrough(text, tx, ty, palette, true)
+end
+
+-- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is
+-- how Gold prints numbers (PrintNum fills from the right).
+local function flatPrintRight(text, txEnd, ty)
+ local width = Font.width(text)
+ love.graphics.setColor(0, 0, 0, 1)
+ return Font.draw(text, txEnd * 8 - width, ty * 8)
+end
+
+function Chrome.printRight(text, txEnd, ty)
+ if not Colors.available() then return flatPrintRight(text, txEnd, ty) end
+ return Chrome.printRightThrough(text, txEnd, ty, Chrome.DEFAULT_BOX_PALETTE)
+end
+
+function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw)
+ local pal = glyphPalette(palette, invert, raw)
+ if not pal then return Chrome.printRight(text, txEnd, ty) end
+ local codes, width = measure(text)
+ drawThrough(codes, width, txEnd * 8 - width, ty * 8, pal)
+ return width
+end
+
+-- Wrap text to `width` tiles, measuring with the real font so a proportional
+-- page wraps where it actually overflows. Nothing on a 160px screen may print
+-- past tile 20: text that does is drawn outside the GB frame entirely.
+-- A "\n" in the text is the cart's OWN line break (data/text's `line` / `next`
+-- control byte, e.g. "\nused !"), so it is a HARD break: each
+-- segment wraps on its own and the break survives, rather than being eaten by
+-- the %S+ tokenizer and re-wrapped by width.
+function Chrome.wrap(text, width)
+ local budget = (width or Chrome.SCREEN_W) * 8
+ local lines = {}
+ for segment in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do
+ local line = nil
+ for word in segment:gmatch("%S+") do
+ local candidate = line and (line .. " " .. word) or word
+ if line and Font.width(candidate) > budget then
+ lines[#lines + 1] = line
+ line = word
+ else
+ line = candidate
+ end
+ end
+ if line then lines[#lines + 1] = line end
+ end
+ return lines
+end
+
+-- Print wrapped text from (tx, ty) downward, at most `rows` lines.
+-- ../pokecrystal/home/text.asm:473
+function Chrome.printWrapped(text, tx, ty, width, rows, step, palette)
+ step = step or 1
+ local lines = Chrome.wrap(text, width)
+ for i = 1, math.min(#lines, rows or #lines) do
+ if palette then
+ Chrome.printThrough(lines[i], tx, ty + (i - 1) * step, palette)
+ else
+ Chrome.print(lines[i], tx, ty + (i - 1) * step)
+ end
+ end
+ return #lines
+end
+
+local function flatCursor(tx, ty, hollow)
+ love.graphics.setColor(0, 0, 0, 1)
+ Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8)
+end
+
+function Chrome.cursor(tx, ty, hollow)
+ if not Colors.available() then return flatCursor(tx, ty, hollow) end
+ return Chrome.cursorThrough(tx, ty, Chrome.DEFAULT_BOX_PALETTE, nil, hollow)
+end
+
+-- The cursor glyph through a palette, the way Chrome.printThrough draws text.
+-- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont
+-- xors both bitplanes, so it runs white on black) needs its cursor inverted
+-- with everything else -- Chrome.cursor's flat black is invisible against that
+-- ground, which is what made the dex's action arrow look absent.
+function Chrome.cursorThrough(tx, ty, palette, invert, hollow, raw)
+ if not (palette and Colors.available()) then
+ return Chrome.cursor(tx, ty, hollow)
+ end
+ local pal = raw and Chrome.rawPalette(palette, invert)
+ or Chrome.throughPalette(palette, invert)
+ local paper = pal[1] or { 255, 255, 255 }
+ love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
+ love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8)
+ love.graphics.setColor(1, 1, 1, 1)
+ local previous = love.graphics.getShader()
+ Colors.useRaw(pal)
+ Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8)
+ love.graphics.setShader(previous)
+ love.graphics.setColor(0, 0, 0, 1)
+end
+
+-- Gold pads a numeric field with spaces, not zeroes, unless
+-- PRINTNUM_LEADINGZEROS is set.
+function Chrome.number(value, width, leadingZeros)
+ local text = tostring(math.floor(value or 0))
+ local pad = math.max(0, (width or 0) - #text)
+ return (leadingZeros and ("0"):rep(pad) or (" "):rep(pad)) .. text
+end
+
+-- PrintNum with PRINTNUM_MONEY and without PRINTNUM_LEADINGZEROS
+-- (home/print_num.asm .PrintYen): the ¥ is emitted just before the FIRST
+-- significant digit rather than at a fixed column, and the field is six digits
+-- wide, so the string is always seven tiles and the yen sign floats.
+function Chrome.money(amount)
+ local digits = ("%06d"):format(math.max(0, math.floor(amount or 0)))
+ local first = digits:find("[1-9]") or #digits
+ return (" "):rep(first - 1) .. YEN .. digits:sub(first)
+end
+
+-- ------------------------------------------------------- balance boxes
+--
+-- engine/menus/menu_2.asm. These three are the boxes a SCRIPT puts up with a
+-- `special` and then leaves standing: every one of them is immediately
+-- followed by `loadmenu`, so the box is on screen for the whole of the static
+-- menu that answers. They live here rather than in one menu module because
+-- three different screens print them (the Game Corner prize counters and the
+-- coin vendor through src/ui/gen2/ScriptMenu.lua, the prize counter screen
+-- itself) and all three are laid out by the same two routines.
+--
+-- DisplayCoinCaseBalance: Textbox at (11,0) with a 7x1 interior, "COIN" at
+-- (12,0) -- yes, in the border row -- and the four-digit count at (13,1) with
+-- PRINTNUM_LEADINGZEROS.
+function Chrome.coinBalanceBox(coins)
+ Chrome.textbox(11, 0, 7, 1)
+ Chrome.printThrough("COIN", 12, 0, Chrome.DEFAULT_BOX_PALETTE)
+ Chrome.printThrough(Chrome.number(coins, 4, true), 13, 1, Chrome.DEFAULT_BOX_PALETTE)
+end
+
+-- DisplayMoneyAndCoinBalance: one Textbox at (5,0) with a 13x3 interior
+-- holding both fields, "MONEY" at (6,1) with the yen field at (12,1) and
+-- "COIN" at (6,3) with the four-digit count at (15,3).
+function Chrome.moneyAndCoinBalanceBox(money, coins)
+ Chrome.textbox(5, 0, 13, 3)
+ Chrome.printThrough("MONEY", 6, 1, Chrome.DEFAULT_BOX_PALETTE)
+ Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE)
+ Chrome.printThrough("COIN", 6, 3, Chrome.DEFAULT_BOX_PALETTE)
+ Chrome.printThrough(Chrome.number(coins, 4, true), 15, 3, Chrome.DEFAULT_BOX_PALETTE)
+end
+
+-- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0,
+-- SCREEN_WIDTH - 1, 2`, a MenuBox rather than a Textbox, and PlaceMoneyTextbox
+-- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1).
+function Chrome.moneyBalanceBox(money)
+ Chrome.box(11, 0, 9, 3)
+ Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE)
+end
+
+-- A vertical cursor list.
+--
+-- opts:
+-- items array of strings, or of { label = , value = , disabled = }
+-- x, y tile coords of the first row's *label* (cursor sits at x - 1)
+-- spacing tile rows between entries (Gold uses 2 for most menus)
+-- rows visible rows; a longer list scrolls (default: all)
+-- wrap wrap past the ends (STATICMENU_WRAP)
+-- startAccepts treat START like A (STATICMENU_ENABLE_START)
+-- onChoose(value, index)
+-- onCancel()
+-- onMove(value, index) -- for the start menu's description box
+local List = {}
+List.__index = List
+Chrome.List = List
+
+function List.new(opts)
+ local self = setmetatable({}, List)
+ self.items = {}
+ for i, entry in ipairs(opts.items or {}) do
+ if type(entry) == "table" then
+ self.items[i] = entry
+ else
+ self.items[i] = { label = tostring(entry), value = entry }
+ end
+ end
+ self.x = opts.x or 1
+ self.y = opts.y or 1
+ self.spacing = opts.spacing or 2
+ self.rows = math.min(opts.rows or #self.items, #self.items)
+ self.wrap = opts.wrap ~= false
+ self.startAccepts = opts.startAccepts or false
+ self.onChoose = opts.onChoose
+ self.onCancel = opts.onCancel
+ self.onMove = opts.onMove
+ self.palette = opts.palette == nil and Chrome.DEFAULT_BOX_PALETTE or opts.palette
+ self.index = math.max(1, math.min(opts.index or 1, math.max(1, #self.items)))
+ self.scroll = 0
+ self:ensureVisible()
+ return self
+end
+
+function List:current()
+ return self.items[self.index]
+end
+
+function List:ensureVisible()
+ if self.rows <= 0 then return end
+ if self.index <= self.scroll then
+ self.scroll = self.index - 1
+ elseif self.index > self.scroll + self.rows then
+ self.scroll = self.index - self.rows
+ end
+ self.scroll = math.max(0, math.min(self.scroll, #self.items - self.rows))
+end
+
+function List:move(delta)
+ if #self.items == 0 then return end
+ local next_ = self.index + delta
+ if next_ < 1 then
+ if not self.wrap then return end
+ next_ = #self.items
+ elseif next_ > #self.items then
+ if not self.wrap then return end
+ next_ = 1
+ end
+ self.index = next_
+ self:ensureVisible()
+ if self.onMove then self.onMove(self:current() and self:current().value, self.index) end
+end
+
+-- Returns true when the press was consumed, so a screen can layer its own
+-- handling (left/right on the options rows) behind this.
+function List:update(input)
+ if not input then return false end
+ if input:wasPressed("up") then
+ self:move(-1)
+ return true
+ elseif input:wasPressed("down") then
+ self:move(1)
+ return true
+ elseif input:wasPressed("a")
+ or (self.startAccepts and input:wasPressed("start")) then
+ local item = self:current()
+ if item and not item.disabled and self.onChoose then
+ self.onChoose(item.value, self.index)
+ end
+ return true
+ elseif input:wasPressed("b") then
+ if self.onCancel then self.onCancel() end
+ return true
+ end
+ return false
+end
+
+function List:draw()
+ for row = 1, self.rows do
+ local i = row + self.scroll
+ local item = self.items[i]
+ if item then
+ local ty = self.y + (row - 1) * self.spacing
+ if i == self.index then
+ if self.palette then
+ Chrome.cursorThrough(self.x - 1, ty, self.palette)
+ else
+ flatCursor(self.x - 1, ty)
+ end
+ end
+ if self.palette then
+ Chrome.printThrough(item.label, self.x, ty, self.palette)
+ else
+ flatPrint(item.label, self.x, ty)
+ end
+ end
+ end
+ -- Scrolling lists get the ▼ hint Gold shows when there is more below.
+ if self.rows < #self.items and self.scroll + self.rows < #self.items then
+ if self.palette then
+ local _, drawGlyph, finish = Chrome.paletteGlyphs(self.palette)
+ drawGlyph(Chrome.DOWN_ARROW, (self.x - 1) * 8,
+ (self.y + self.rows * self.spacing - 1) * 8)
+ finish()
+ else
+ love.graphics.setColor(0, 0, 0, 1)
+ Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8,
+ (self.y + self.rows * self.spacing - 1) * 8)
+ end
+ end
+end
+
+return Chrome
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/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua
index 4f3f78b39..b6e997d82 100644
--- a/src/ui/Gen3PartyMenu.lua
+++ b/src/ui/Gen3PartyMenu.lua
@@ -168,7 +168,9 @@ function Gen3PartyMenu.new(game, opts)
return self
end
-function Gen3PartyMenu:party() return self.game.save.party or {} end
+function Gen3PartyMenu:party()
+ return (self.battle and self.battle.playerParty) or self.game.save.party or {}
+end
-- slots 1..6, then the CANCEL button as slot #party+1
function Gen3PartyMenu:slots() return #self:party() + 1 end
@@ -1015,6 +1017,38 @@ function Gen3PartyMenu:animateTo(mon, fromHP, onDone)
self.heal = { mon = mon, from = from, shown = from, onDone = onDone }
end
+function Gen3PartyMenu:confirm()
+ if self.index > #self:party() then
+ if self.hpTransferFrom then
+ self.hpTransferFrom = nil
+ return
+ end
+ -- the CANCEL button VALIDATES when a team is being chosen; everywhere
+ -- else it is the way out
+ if self.chooseOrder then
+ return self:confirmOrder()
+ end
+ return self:close()
+ end
+ self:choose()
+end
+
+function Gen3PartyMenu:cancel()
+ if self.hpTransferFrom then
+ self.hpTransferFrom = nil
+ return
+ end
+ -- B drops a SWITCH that was half-made before it closes the screen
+ if self.switchFrom then
+ self.switchFrom = nil
+ return
+ end
+ if self.chooseOrder then
+ return self:handOrder(nil)
+ end
+ self:close()
+end
+
function Gen3PartyMenu:update(dt)
-- RECONSTRUCTED: every icon bounces, all the time, on the same beat. On
-- the cartridge each one is an OBJ with its own callback; here it is one
@@ -1073,20 +1107,9 @@ function Gen3PartyMenu:update(dt)
self.index = 1
end
elseif input:wasPressed("a") then
- if self.index > #self:party() then
- if self.hpTransferFrom then self.hpTransferFrom=nil;return end
- -- the CANCEL button VALIDATES when a team is being chosen; everywhere
- -- else it is the way out
- if self.chooseOrder then return self:confirmOrder() end
- return self:close()
- end
- self:choose()
+ self:confirm()
elseif input:wasPressed("b") then
- if self.hpTransferFrom then self.hpTransferFrom=nil;return end
- -- B drops a SWITCH that was half-made before it closes the screen
- if self.switchFrom then self.switchFrom = nil return end
- if self.chooseOrder then return self:handOrder(nil) end
- self:close()
+ self:cancel()
end
end
diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua
index 670908c8c..36e38bcd6 100644
--- a/src/ui/Gen4PartyMenu.lua
+++ b/src/ui/Gen4PartyMenu.lua
@@ -261,7 +261,9 @@ function Gen4PartyMenu:drawAt(key, x, y)
return true
end
-function Gen4PartyMenu:party() return self.game.save.party or {} end
+function Gen4PartyMenu:party()
+ return (self.battle and self.battle.playerParty) or self.game.save.party or {}
+end
-- Slots 1..#party, then CANCEL.
function Gen4PartyMenu:count() return #self:party() + (self.select and 2 or 1) end
@@ -886,6 +888,28 @@ function Gen4PartyMenu:step(dir)
end
end
+function Gen4PartyMenu:cancel()
+ if self.switchFrom then
+ self.switchFrom = nil
+ elseif self.hpTransfer then
+ self.hpTransfer = nil
+ -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING".
+ --
+ -- Out of a battle, handing the answer back as nil is how a caller learns
+ -- the player cancelled, and that is right. IN a battle it crashed: the
+ -- battle's own onSwitch is written for a Pokemon and went straight into
+ -- `mon.hp`. The battle already knows what to do when this screen simply
+ -- closes, so closing is both the safe answer and the correct one.
+ elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) and not self.battle then
+ self:handOff(nil)
+ elseif self.select then
+ self.index = self:count()
+ self:choose()
+ else
+ self:close()
+ end
+end
+
function Gen4PartyMenu:update(dt)
self.t = (self.t or 0) + (dt or 1 / 60)
local input = self.game.input
@@ -918,23 +942,7 @@ function Gen4PartyMenu:update(dt)
elseif input:wasPressed("a") then
self:choose()
elseif input:wasPressed("b") or input:wasPressed("start") then
- if self.switchFrom then self.switchFrom = nil
- elseif self.hpTransfer then self.hpTransfer = nil
- -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING".
- --
- -- Out of a battle, handing the answer back as nil is how a caller learns
- -- the player cancelled, and that is right. IN a battle it crashed: the
- -- battle's own onSwitch is written for a Pokemon and went straight into
- -- `mon.hp`. The battle already knows what to do when this screen simply
- -- closes, so closing is both the safe answer and the correct one.
- elseif self.onSwitch and not (self.pickOnly or self.forceSwitch)
- and not self.battle then
- self:handOff(nil)
- elseif self.select then
- self.index = self:count(); self:choose()
- else
- self:close()
- end
+ self:cancel()
end
end
diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua
index e93098790..cf42a71f2 100644
--- a/src/ui/ListMenu.lua
+++ b/src/ui/ListMenu.lua
@@ -172,6 +172,25 @@ local function navPressed(self, dir)
return true
end
+function ListMenu:choose()
+ if #self.items == 0 then
+ return self:cancel()
+ end
+ beep(self)
+ local item = self.items[self.index]
+ if self.onChoose then
+ self.onChoose(item, self)
+ end
+end
+
+function ListMenu:cancel()
+ beep(self)
+ self.game.stack:pop()
+ if self.onCancel then
+ self.onCancel()
+ end
+end
+
function ListMenu:update(dt)
if self.script then
self.script(self)
@@ -211,16 +230,10 @@ function ListMenu:update(dt)
elseif self.onSelectKey and input:wasPressed("select") then
self.onSelectKey(self.items[self.index], self)
elseif input:wasPressed("b") then
- beep(self)
- self.game.stack:pop()
- if self.onCancel then self.onCancel() end
+ self:cancel()
return
elseif input:wasPressed("a") then
- beep(self)
- local item = self.items[self.index]
- if self.onChoose then
- self.onChoose(item, self)
- end
+ self:choose()
return
end
diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua
index 2415d798b..24b36069d 100644
--- a/src/ui/Menu.lua
+++ b/src/ui/Menu.lua
@@ -120,6 +120,35 @@ function Menu:clampScroll()
end
end
+function Menu:choose()
+ -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press
+ if not self.noSound then
+ require("src.core.Sound").play(self.game.data, "Press_AB")
+ end
+ local item = self.items[self.index]
+ -- keepOpen entries run without closing the menu (e.g. the
+ -- Pokédex CRY option keeps the side menu up)
+ if not item.keepOpen then
+ self.game.stack:pop()
+ end
+ if item.onSelect then
+ item.onSelect()
+ end
+end
+
+function Menu:cancel()
+ if not self.cancelable then
+ return
+ end
+ if not self.noSound then
+ require("src.core.Sound").play(self.game.data, "Press_AB")
+ end
+ self.game.stack:pop()
+ if self.onCancel then
+ self.onCancel()
+ end
+end
+
function Menu:update(dt)
local input = self.game.input
local wasIndex = self.index
@@ -128,15 +157,7 @@ function Menu:update(dt)
elseif input:wasPressed("down") then
self.index = self.index < #self.items and self.index + 1 or 1
elseif input:wasPressed("a") then
- -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press
- if not self.noSound then
- require("src.core.Sound").play(self.game.data, "Press_AB")
- end
- local item = self.items[self.index]
- -- keepOpen entries run without closing the menu (e.g. the
- -- Pokédex CRY option keeps the side menu up)
- if not item.keepOpen then self.game.stack:pop() end
- if item.onSelect then item.onSelect() end
+ self:choose()
elseif self.cancelable and (input:wasPressed("b")
or (self.startCloses and input:wasPressed("start"))) then
-- HandleMenuInput_ returns for any watched key, but only replays
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..47fc504f5 100644
--- a/src/ui/OptionsMenu.lua
+++ b/src/ui/OptionsMenu.lua
@@ -20,6 +20,7 @@ local GameSpeed = require("src.core.GameSpeed")
local GameVersion = require("src.core.GameVersion")
local VideoMode = require("src.core.VideoMode")
local FaithfulRes = require("src.core.FaithfulRes")
+local ScreenPosition = require("src.core.ScreenPosition")
local FrameCap = require("src.core.FrameCap")
local Performance = require("src.core.Performance")
local Logger = require("src.core.Logger")
@@ -104,8 +105,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 +371,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"),
@@ -462,6 +466,16 @@ local function buildRows(game)
FaithfulRes.apply(o.faithfulRes)
return true
end },
+ { id = "screenPos", label = Strings("SCREEN POS"),
+ value = function(g)
+ return Strings(ScreenPosition.label(g.save.options.screenPos))
+ end,
+ step = function(g, dir)
+ local o = g.save.options
+ o.screenPos = ScreenPosition.cycle(o.screenPos, dir)
+ ScreenPosition.setMode(o.screenPos)
+ return true
+ end },
-- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only.
@@ -522,6 +536,41 @@ local function buildRows(game)
return true
end },
}
+ do
+ local osName = love.system and love.system.getOS and love.system.getOS()
+ if osName == 'Android' or osName == 'iOS' or os.getenv('POKEPORT_TOUCH') == '1' then
+ rows[#rows + 1] = { id = 'haptics', label = Strings('HAPTICS'),
+ value = function(g) return require('src.core.TouchControls').hapticLabel(g.save.options.haptics) end,
+ step = function(g, dir)
+ local Controls = require('src.core.TouchControls')
+ g.save.options.haptics = Controls.cycleHaptics(g.save.options.haptics, dir or 1)
+ Controls:applyOptions(g.save.options)
+ Controls.buzz(g.save.options.haptics)
+ return true
+ end }
+ end
+ end
+ -- The same installed library is usable as mobile controls or a desktop bezel.
+ local skins = require('src.core.TouchSkin').list()
+ if #skins > 0 then
+ rows[#rows + 1] = { id = 'touchSkin', label = Strings('TOUCH SKIN'),
+ value = function(g)
+ local tc = g.save.options.touchControls
+ return type(tc) == 'table' and tc.skin or Strings('DEFAULT')
+ end,
+ step = function(g, dir)
+ local tc = g.save.options.touchControls
+ tc = type(tc) == 'table' and tc or {}
+ local index = 0
+ for i, skin in ipairs(skins) do if skin.id == tc.skin then index = i; break end end
+ index = (index + (dir or 1)) % (#skins + 1)
+ tc.skin = index > 0 and skins[index].id or nil
+ if tc.skin then tc.enabled = true end
+ g.save.options.touchControls = tc
+ require('src.core.TouchControls'):applyOptions(g.save.options)
+ return true
+ end }
+ end
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
if not GBCFX.isSupported() then
local filtered = {}
diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua
index e59d7173b..7b907a617 100644
--- a/src/ui/PartyMenu.lua
+++ b/src/ui/PartyMenu.lua
@@ -438,7 +438,7 @@ function PartyMenu.new(game, opts)
self.tmhm = opts.tmhm
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle
- self.party = opts.party -- link battles pass their clamped copies
+ self.party = opts.party or (opts.battle and opts.battle.playerParty) -- link copies or trainer selection
self.swapFrom = nil
self.submenu = nil
self.subIndex = 1
@@ -472,6 +472,399 @@ function PartyMenu:close()
if self.game.stack:top() == self then self.game.stack:pop() end
end
+-- Native confirmation shared by input and the public UI facade.
+function PartyMenu:confirm()
+ local party = self.party or self.game.save.party
+ if self.submenu then
+ local mon = party[self.index]
+ local entry = self.subItems[self.subIndex]
+ local action = entry.action
+ if not action and entry.onSelect then
+ -- hook-injected entries carry a callback instead of an action id
+ entry.onSelect(mon, self.game)
+ elseif action == "stats" then
+ -- battle and field alike return to the party list afterwards
+ -- (core.asm .partyMenuWasSelected)
+ Screens.push(self.game, "SummaryMenu", mon, {
+ mons = party,
+ index = self.index,
+ onMonChange = function(index) self.index = index end,
+ })
+ elseif action == "battle_switch" then
+ self.game.stack:pop()
+ self.onSwitch(mon)
+ return
+ elseif action == "cancel" then
+ self.game.stack:pop()
+ if self.onCancel then self.onCancel() end
+ return
+ elseif action == "switch" then
+ self.swapFrom = self.index
+ elseif action == "item" then
+ self:openItemMenu(mon)
+ elseif action == "quit" then
+ self.submenu = nil
+ elseif action == "fly" then
+ -- FLY opens the TOWN MAP with a cursor over the visited fly towns,
+ -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly).
+ -- flyTo (OverworldController) validates the fly-warp + runs the
+ -- departure/warp, so we just hand it the chosen mapId (#195).
+ local ow = self.game.overworld
+ self.game.stack:pop() -- close the party menu
+ Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
+ if ow then ow:flyTo(mapId) end
+ end })
+ return
+ elseif action == "flash" then -- FLASH lights dark tunnels
+ -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs
+ -- with the party menu still on screen, and only then
+ -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads
+ -- over the menu, and the cave is lit when the blink hands the
+ -- screen back, never under the text (#385).
+ local ow = self.game.overworld
+ local TextBox = require("src.render.TextBox")
+ local Transition = require("src.render.Transition")
+ -- The other half of CheckUseFlash: using it in the Aerodactyl chamber
+ -- opens the wall. The chamber's scene script plays the opening the
+ -- next time the map loads, exactly as the escape-rope one does.
+ require("src.script.RuinsOfAlph").aerodactylChamber(self.game)
+ self.game.save.flashLit = true
+ self.game.stack:push(TextBox.new(self.game,
+ self.game.data.text._FlashLightsAreaText
+ or Strings("A blinding FLASH\nlights the area!"), function()
+ self:close()
+ -- setDark, not a bare field write: ADVANCED carries the darkness
+ -- in a baked atlas, so lighting the cave drops every resident map
+ -- and rebakes this one (#383). It runs HERE, before the blink,
+ -- because start_sub_menus.asm .flash clears wMapPalOffset before
+ -- PrintText and blinks last of all: the cave is already lit by the
+ -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the
+ -- blink's completion instead left that rebuild's whole cost --
+ -- seconds of per-pixel atlas baking on a phone -- on screen as a
+ -- solid white frame with nothing under it, which reads as a
+ -- lockup (#610).
+ ow:setDark(false)
+ self.game.stack:push(Transition.whiteFlash(self.game))
+ end))
+ return
+ elseif action == "surf" then
+ -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time
+ -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F
+ -- current refusals, both of which loop back to the submenu), then
+ -- ItemUseSurfboard: while surfing it tries to dismount instead;
+ -- otherwise it mounts only if the FACING tile is water, else
+ -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the
+ -- submenu. useSurfFieldMove reports which; trySurf does the mount.
+ local ow = self.game.overworld
+ local reason = ow:useSurfFieldMove()
+ local Transition = require("src.render.Transition")
+ if reason == "ok" then
+ -- UseItem prints _SurfingGotOnText with the party menu still up;
+ -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so
+ -- trySurf closes this menu when its text does (#385)
+ local fx, fy = ow.player:facingCell()
+ ow:trySurf(fx, fy, function() self:close() end)
+ return
+ end
+ if reason == "dismount" then
+ -- ItemUseSurfboard .stopSurfing: no text -- the walking state
+ -- and music return first (PlayDefaultMusic +
+ -- LoadWalkingPlayerSpriteGraphics), the menu closes with the
+ -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
+ -- steps the player forward onto land (or across a connection
+ -- strip when the shore is the next map's edge)
+ self.game.stack:pop()
+ ow.player.surfing = false
+ require("src.core.Music").setSurfing(self.game.data, false)
+ self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
+ ow:stepForwardOrCrossEdge(ow.player.facing)
+ end))
+ return
+ end
+ local TextBox = require("src.render.TextBox")
+ local def = self.game.data.pokemon[mon.species]
+ local key = ({ no_badge = "_NewBadgeRequiredText",
+ forced_bike = "_CyclingIsFunText",
+ current = "_CurrentTooFastText",
+ no_place = "_SurfingNoPlaceToGetOffText" })[reason]
+ or "_NoSurfingHereText"
+ local txt = (self.game.data.text[key] or Strings("No SURFing here!"))
+ :gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
+ if reason == "no_place" then
+ -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
+ -- never zeroes wActionResultOrTookBattleTurn, so unlike the
+ -- other refusals the menu still closes afterwards
+ -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints
+ -- over the still-open menu like every other .loop refusal (#385)
+ self.game.stack:push(TextBox.new(self.game, txt, function()
+ self:close()
+ self.game.stack:push(Transition.whiteFlash(self.game))
+ end))
+ return
+ end
+ self.game.stack:push(TextBox.new(self.game, txt))
+ return -- .loop: submenu stays open behind the message
+ elseif action == "cut" then
+ -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
+ -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the
+ -- submenu when the FACING tile isn't a cuttable tree.
+ local ow = self.game.overworld
+ local reason = ow:useCutFieldMove()
+ if reason == "ok" then
+ self.game.stack:pop() -- close the party menu (CloseTextDisplay)
+ local fx, fy = ow.player:facingCell()
+ ow:tryCut(fx, fy)
+ return
+ end
+ local TextBox = require("src.render.TextBox")
+ local def = self.game.data.pokemon[mon.species]
+ local key = (reason == "no_badge") and "_NewBadgeRequiredText"
+ or "_NothingToCutText"
+ local txt = (self.game.data.text[key] or Strings("Nothing to CUT!"))
+ :gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
+ self.game.stack:push(TextBox.new(self.game, txt))
+ return -- .loop: submenu stays open behind the message
+ elseif action == "strength" then
+ -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time);
+ -- predef PrintStrengthText (field_move_messages.asm) sets
+ -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
+ -- push_boulder.asm reads -- then prints _UseStrengthText (no
+ -- prompt: after the text, the text_asm tail plays the chosen
+ -- mon's cry, Delay3, and it auto-advances) and
+ -- _MoveBoulderText (`prompt`: waits for A/B). Back in
+ -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
+ -- before CloseTextDisplay returns to the map.
+ local ow = self.game.overworld
+ local TextBox = require("src.render.TextBox")
+ local Transition = require("src.render.Transition")
+ local def = self.game.data.pokemon[mon.species]
+ local name = mon.nickname or (def and def.name) or mon.species
+ ow.strengthActive = true
+ -- Gen2 text keys are _UseStrengthText / _MoveBoulderText;
+ -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both.
+ local t1raw = self.game.data.text._UseStrengthText
+ or self.game.data.text._UsedStrengthText
+ local t2raw = self.game.data.text._MoveBoulderText
+ or self.game.data.text._CanMoveBouldersText
+ local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
+ local t2 = (t2raw or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
+ -- like surf (#320, #385): both texts print with the party menu
+ -- still on screen, and the blink IS the menu closing afterwards,
+ -- not a flashbang on the empty map
+ self.game.stack:push(TextBox.new(self.game, t1, function()
+ self.game.stack:push(TextBox.new(self.game, t2, function()
+ self:close()
+ self.game.stack:push(Transition.whiteFlash(self.game))
+ end))
+ end, { auto = { sound = function()
+ return require("src.core.Sound").playCry(self.game.data, mon.species)
+ end } }))
+ return
+ elseif action == "gen2_field" then
+ -- GSC runs the same handler from the menu as from the overworld
+ -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the
+ -- tile check lives in one place.
+ local ow = self.game.overworld
+ local fx, fy = ow.player:facingCell()
+ self:close()
+ if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then
+ local TextBox = require("src.render.TextBox")
+ -- _CantSurfText is "You can't SURF here", so using it for every
+ -- field move told a player holding HEADBUTT that the game thought
+ -- they had picked SURF. Only SURF gets the surf line.
+ local text = (entry.move == "SURF")
+ and self.game.data.text._CantSurfText or nil
+ self.game.stack:push(TextBox.new(self.game,
+ text or Strings("You can't use that\nhere.")))
+ end
+ return
+ elseif action == "softboiled" then
+ -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
+ -- 1/5 of the user's max HP to a chosen teammate
+ self.softboiledFrom = self.index
+ elseif action == "dig" then
+ -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as
+ -- a DOOR warp so the player steps out of the opening rather than
+ -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares
+ -- TELEPORT's Pokemon Center warp, so it falls through to the same
+ -- departure with no target.
+ local ow = self.game.overworld
+ self.game.stack:pop()
+ if ow then
+ local escape = require("src.core.GameVersion").isGen2()
+ and ow.escapePoint and ow:escapePoint() and true or nil
+ ow:beginTeleportOut(nil, { escape = escape })
+ end
+ return
+ elseif action == "escape" then
+ -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap,
+ -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction
+ -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup
+ -- / wLastSpawnMapNumber, so this destination is right for both
+ -- generations. Only DIG diverged; it has its own branch above.
+ -- pokered's .dig/.teleport spin the
+ -- player up (LeaveMapAnim), white/fade out, then land it; this port
+ -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut
+ -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
+ -- the exact departure; the fade + warp fire when the spin ends.
+ local ow = self.game.overworld
+ self.game.stack:pop()
+ if ow then ow:beginTeleportOut() end
+ return
+ end
+ self.submenu = nil
+ return
+ end
+ if #party == 0 then return end
+ local mon = party[self.index]
+ if self.softboiledFrom then
+ local user = party[self.softboiledFrom]
+ local heal = math.floor(user.stats.hp / 5)
+ if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp
+ or user.hp <= heal then
+ self.softboiledFrom = nil
+ local TextBox = require("src.render.TextBox")
+ self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
+ else
+ user.hp = user.hp - heal
+ mon.hp = math.min(mon.stats.hp, mon.hp + heal)
+ self.softboiledFrom = nil
+ require("src.core.Sound").play(self.game.data, "Heal_HP")
+ local def = self.game.data.pokemon[mon.species]
+ local TextBox = require("src.render.TextBox")
+ self.game.stack:push(TextBox.new(self.game,
+ Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
+ end
+ elseif self.swapFrom then
+ if self.swapFrom ~= self.index then
+ party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
+ require("src.core.Sound").play(self.game.data, "Swap")
+ end
+ self.swapFrom = nil
+ elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
+ -- keepOpen callers (HP medicine) need the menu still drawn while the
+ -- bar fills and the message prints, and close it themselves; everyone
+ -- else keeps the old pop-then-call order. Popping first is what made
+ -- a POTION snap the picker shut before the item had even run (#252).
+ if not self.keepOpen then self.game.stack:pop() end
+ self.onSwitch(mon, self)
+ else
+ self.submenu = true
+ self.subIndex = 1
+ local items
+ local ow = self.game.overworld
+ if self.battle and self.onSwitch then
+ -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
+ items = { { label = Strings("SWITCH"), action = "battle_switch" },
+ { label = Strings("STATS"), action = "stats" },
+ { label = Strings("CANCEL"), action = "cancel" } }
+ else
+ -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
+ -- builds the same dynamic list)
+ items = { { label = Strings("STATS"), action = "stats" },
+ { label = Strings("SWITCH"), action = "switch" } }
+ -- Field moves (HMs/TMs) are usable out of battle even when the mon
+ -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
+ -- Battle still excludes this list via `not self.battle`. Softboiled
+ -- can appear for a fainted user; its heal transfer then no-ops.
+ if not self.battle and ow then
+ -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
+ -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
+ local outside = Map.isOutside(ow.map.def,
+ FieldDefaults.field(self.game.data, "outsideTilesets"))
+ -- The badge gate per field move comes from constants.hmBadges and
+ -- the badge itself through Badges.has: Gen2 gates the same moves on
+ -- the Johto badges and stores them as engine flags rather than bag
+ -- items, so the hard-coded R/B item names listed nothing at all.
+ local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {}
+ local function badged(moveId)
+ local gate = gates[moveId]
+ if not (gate and gate.badge) then return true end
+ return Badges.has(self.game.save, { id = gate.badge })
+ end
+ for _, mv in ipairs(mon.moves) do
+ if mv.id == "FLY" and outside and badged("FLY") then
+ table.insert(items, { label = Strings("FLY"), action = "fly" })
+ -- LIGHT is carved on the Aerodactyl chamber wall, and the
+ -- chamber is not a dark cave -- so CheckUseFlash asks
+ -- SpecialAerodactylChamber FIRST and lets FLASH be used on its
+ -- carry, before it ever looks at the palset
+ -- (engine/events/overworld.asm:285). Without this the move is
+ -- never even offered there and the wall can never open.
+ elseif mv.id == "FLASH" and badged("FLASH")
+ and (ow.dark or (ow.map and ow.map.id
+ == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then
+ table.insert(items, { label = Strings("FLASH"), action = "flash" })
+ elseif mv.id == "CUT" and badged("CUT") then
+ -- CUT/SURF/STRENGTH are party-menu field moves too
+ -- (start_sub_menus.asm .outOfBattleMovePointers); listed here
+ -- with the same list-time badge filter this file already uses
+ -- for FLY/FLASH. The facing-tile/activation check happens on
+ -- selection (useCutFieldMove/useSurfFieldMove).
+ table.insert(items, { label = Strings("CUT"), action = "cut" })
+ elseif mv.id == "SURF" and badged("SURF") then
+ table.insert(items, { label = Strings("SURF"), action = "surf" })
+ elseif mv.id == "STRENGTH" and badged("STRENGTH") then
+ table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
+ elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then
+ table.insert(items, { label = Strings(mv.id:gsub("_", " ")),
+ action = "softboiled" })
+ elseif mv.id == "TELEPORT" and outside then
+ -- TELEPORT works only OUTDOORS (start_sub_menus.asm
+ -- .teleport -> CheckIfInOutsideMap); dark maps don't
+ -- block it
+ table.insert(items, { label = Strings("TELEPORT"), action = "escape" })
+ elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM"
+ and (DIG_TILESETS[ow.map.def.tileset]
+ or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment]
+ and ow.escapePoint and ow:escapePoint())) then
+ -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem =
+ -- ESCAPE_ROPE): usable in the dungeon tilesets of
+ -- escape_rope_tilesets.asm minus Agatha's room, even in
+ -- the dark (Rock Tunnel).
+ -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a
+ -- recorded entrance to come back out of. Its own action,
+ -- because Gen 2's DIG and TELEPORT no longer share a
+ -- destination the way Gen 1's did.
+ table.insert(items, { label = Strings("DIG"), action = "dig" })
+ elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then
+ -- GSC's own field moves; the tile check happens on selection,
+ -- exactly like CUT's (start_sub_menus.asm never gates the list
+ -- on where the player is standing)
+ table.insert(items, { label = Strings(mv.id:gsub("_", " ")),
+ action = "gen2_field", move = mv.id })
+ end
+ end
+ end
+ if require("src.core.GameVersion").isGen2() then
+ -- GSC's MonMenu lists the field moves first, then STATS, SWITCH,
+ -- ITEM and QUIT; Gen 1 has no held items and no QUIT row.
+ local stats, switch = table.remove(items, 1), table.remove(items, 1)
+ items[#items + 1] = stats
+ items[#items + 1] = switch
+ items[#items + 1] = { label = Strings("ITEM"), action = "item" }
+ items[#items + 1] = { label = Strings("QUIT"), action = "quit" }
+ end
+ end
+ local ctx = { battle = self.battle, overworld = ow }
+ local hooked = Runtime.call("ui.party.submenu", sameItems,
+ self.game, items, mon, ctx)
+ if type(hooked) == "table" then
+ items = hooked
+ else
+ Logger.error("ui.party.submenu returned %s; keeping the vanilla list",
+ type(hooked))
+ end
+ self.subItems = items
+ end
+end
+
+function PartyMenu:cancel()
+ if self.submenu then self.submenu = nil; return end
+ self.game.stack:pop()
+ if self.onCancel then self.onCancel() end
+end
+
function PartyMenu:update(dt)
-- icon animation counter; 320 = a whole cycle at every HP speed
self.blink = ((self.blink or 0) + 1) % 320
@@ -505,245 +898,9 @@ function PartyMenu:update(dt)
elseif input:wasPressed("down") then
self.subIndex = self.subIndex < n and self.subIndex + 1 or 1
elseif input:wasPressed("b") then
- self.submenu = nil
+ self:cancel()
elseif input:wasPressed("a") then
- local mon = party[self.index]
- local entry = self.subItems[self.subIndex]
- local action = entry.action
- if not action and entry.onSelect then
- -- hook-injected entries carry a callback instead of an action id
- entry.onSelect(mon, self.game)
- elseif action == "stats" then
- -- battle and field alike return to the party list afterwards
- -- (core.asm .partyMenuWasSelected)
- Screens.push(self.game, "SummaryMenu", mon, {
- mons = party,
- index = self.index,
- onMonChange = function(index) self.index = index end,
- })
- elseif action == "battle_switch" then
- self.game.stack:pop()
- self.onSwitch(mon)
- return
- elseif action == "cancel" then
- self.game.stack:pop()
- if self.onCancel then self.onCancel() end
- return
- elseif action == "switch" then
- self.swapFrom = self.index
- elseif action == "item" then
- self:openItemMenu(mon)
- elseif action == "quit" then
- self.submenu = nil
- elseif action == "fly" then
- -- FLY opens the TOWN MAP with a cursor over the visited fly towns,
- -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly).
- -- flyTo (OverworldController) validates the fly-warp + runs the
- -- departure/warp, so we just hand it the chosen mapId (#195).
- local ow = self.game.overworld
- self.game.stack:pop() -- close the party menu
- Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
- if ow then ow:flyTo(mapId) end
- end })
- return
- elseif action == "flash" then -- FLASH lights dark tunnels
- -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs
- -- with the party menu still on screen, and only then
- -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads
- -- over the menu, and the cave is lit when the blink hands the
- -- screen back, never under the text (#385).
- local ow = self.game.overworld
- local TextBox = require("src.render.TextBox")
- local Transition = require("src.render.Transition")
- -- The other half of CheckUseFlash: using it in the Aerodactyl chamber
- -- opens the wall. The chamber's scene script plays the opening the
- -- next time the map loads, exactly as the escape-rope one does.
- require("src.script.RuinsOfAlph").aerodactylChamber(self.game)
- self.game.save.flashLit = true
- self.game.stack:push(TextBox.new(self.game,
- self.game.data.text._FlashLightsAreaText
- or Strings("A blinding FLASH\nlights the area!"), function()
- self:close()
- -- setDark, not a bare field write: ADVANCED carries the darkness
- -- in a baked atlas, so lighting the cave drops every resident map
- -- and rebakes this one (#383). It runs HERE, before the blink,
- -- because start_sub_menus.asm .flash clears wMapPalOffset before
- -- PrintText and blinks last of all: the cave is already lit by the
- -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the
- -- blink's completion instead left that rebuild's whole cost --
- -- seconds of per-pixel atlas baking on a phone -- on screen as a
- -- solid white frame with nothing under it, which reads as a
- -- lockup (#610).
- ow:setDark(false)
- self.game.stack:push(Transition.whiteFlash(self.game))
- end))
- return
- elseif action == "surf" then
- -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time
- -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F
- -- current refusals, both of which loop back to the submenu), then
- -- ItemUseSurfboard: while surfing it tries to dismount instead;
- -- otherwise it mounts only if the FACING tile is water, else
- -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the
- -- submenu. useSurfFieldMove reports which; trySurf does the mount.
- local ow = self.game.overworld
- local reason = ow:useSurfFieldMove()
- local Transition = require("src.render.Transition")
- if reason == "ok" then
- -- UseItem prints _SurfingGotOnText with the party menu still up;
- -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so
- -- trySurf closes this menu when its text does (#385)
- local fx, fy = ow.player:facingCell()
- ow:trySurf(fx, fy, function() self:close() end)
- return
- end
- if reason == "dismount" then
- -- ItemUseSurfboard .stopSurfing: no text -- the walking state
- -- and music return first (PlayDefaultMusic +
- -- LoadWalkingPlayerSpriteGraphics), the menu closes with the
- -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
- -- steps the player forward onto land (or across a connection
- -- strip when the shore is the next map's edge)
- self.game.stack:pop()
- ow.player.surfing = false
- require("src.core.Music").setSurfing(self.game.data, false)
- self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
- ow:stepForwardOrCrossEdge(ow.player.facing)
- end))
- return
- end
- local TextBox = require("src.render.TextBox")
- local def = self.game.data.pokemon[mon.species]
- local key = ({ no_badge = "_NewBadgeRequiredText",
- forced_bike = "_CyclingIsFunText",
- current = "_CurrentTooFastText",
- no_place = "_SurfingNoPlaceToGetOffText" })[reason]
- or "_NoSurfingHereText"
- local txt = (self.game.data.text[key] or Strings("No SURFing here!"))
- :gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
- if reason == "no_place" then
- -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
- -- never zeroes wActionResultOrTookBattleTurn, so unlike the
- -- other refusals the menu still closes afterwards
- -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints
- -- over the still-open menu like every other .loop refusal (#385)
- self.game.stack:push(TextBox.new(self.game, txt, function()
- self:close()
- self.game.stack:push(Transition.whiteFlash(self.game))
- end))
- return
- end
- self.game.stack:push(TextBox.new(self.game, txt))
- return -- .loop: submenu stays open behind the message
- elseif action == "cut" then
- -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
- -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the
- -- submenu when the FACING tile isn't a cuttable tree.
- local ow = self.game.overworld
- local reason = ow:useCutFieldMove()
- if reason == "ok" then
- self.game.stack:pop() -- close the party menu (CloseTextDisplay)
- local fx, fy = ow.player:facingCell()
- ow:tryCut(fx, fy)
- return
- end
- local TextBox = require("src.render.TextBox")
- local def = self.game.data.pokemon[mon.species]
- local key = (reason == "no_badge") and "_NewBadgeRequiredText"
- or "_NothingToCutText"
- local txt = (self.game.data.text[key] or Strings("Nothing to CUT!"))
- :gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
- self.game.stack:push(TextBox.new(self.game, txt))
- return -- .loop: submenu stays open behind the message
- elseif action == "strength" then
- -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time);
- -- predef PrintStrengthText (field_move_messages.asm) sets
- -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
- -- push_boulder.asm reads -- then prints _UseStrengthText (no
- -- prompt: after the text, the text_asm tail plays the chosen
- -- mon's cry, Delay3, and it auto-advances) and
- -- _MoveBoulderText (`prompt`: waits for A/B). Back in
- -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
- -- before CloseTextDisplay returns to the map.
- local ow = self.game.overworld
- local TextBox = require("src.render.TextBox")
- local Transition = require("src.render.Transition")
- local def = self.game.data.pokemon[mon.species]
- local name = mon.nickname or (def and def.name) or mon.species
- ow.strengthActive = true
- -- Gen2 text keys are _UseStrengthText / _MoveBoulderText;
- -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both.
- local t1raw = self.game.data.text._UseStrengthText
- or self.game.data.text._UsedStrengthText
- local t2raw = self.game.data.text._MoveBoulderText
- or self.game.data.text._CanMoveBouldersText
- local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
- local t2 = (t2raw or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
- -- like surf (#320, #385): both texts print with the party menu
- -- still on screen, and the blink IS the menu closing afterwards,
- -- not a flashbang on the empty map
- self.game.stack:push(TextBox.new(self.game, t1, function()
- self.game.stack:push(TextBox.new(self.game, t2, function()
- self:close()
- self.game.stack:push(Transition.whiteFlash(self.game))
- end))
- end, { auto = { sound = function()
- return require("src.core.Sound").playCry(self.game.data, mon.species)
- end } }))
- return
- elseif action == "gen2_field" then
- -- GSC runs the same handler from the menu as from the overworld
- -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the
- -- tile check lives in one place.
- local ow = self.game.overworld
- local fx, fy = ow.player:facingCell()
- self:close()
- if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then
- local TextBox = require("src.render.TextBox")
- -- _CantSurfText is "You can't SURF here", so using it for every
- -- field move told a player holding HEADBUTT that the game thought
- -- they had picked SURF. Only SURF gets the surf line.
- local text = (entry.move == "SURF")
- and self.game.data.text._CantSurfText or nil
- self.game.stack:push(TextBox.new(self.game,
- text or Strings("You can't use that\nhere.")))
- end
- return
- elseif action == "softboiled" then
- -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
- -- 1/5 of the user's max HP to a chosen teammate
- self.softboiledFrom = self.index
- elseif action == "dig" then
- -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as
- -- a DOOR warp so the player steps out of the opening rather than
- -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares
- -- TELEPORT's Pokemon Center warp, so it falls through to the same
- -- departure with no target.
- local ow = self.game.overworld
- self.game.stack:pop()
- if ow then
- local escape = require("src.core.GameVersion").isGen2()
- and ow.escapePoint and ow:escapePoint() and true or nil
- ow:beginTeleportOut(nil, { escape = escape })
- end
- return
- elseif action == "escape" then
- -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap,
- -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction
- -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup
- -- / wLastSpawnMapNumber, so this destination is right for both
- -- generations. Only DIG diverged; it has its own branch above.
- -- pokered's .dig/.teleport spin the
- -- player up (LeaveMapAnim), white/fade out, then land it; this port
- -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut
- -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
- -- the exact departure; the fade + warp fire when the spin ends.
- local ow = self.game.overworld
- self.game.stack:pop()
- if ow then ow:beginTeleportOut() end
- return
- end
- self.submenu = nil
+ self:confirm()
end
return
end
@@ -753,150 +910,9 @@ function PartyMenu:update(dt)
elseif input:wasPressed("down") then
self.index = self.index < #party and self.index + 1 or 1
elseif input:wasPressed("b") then
- self.game.stack:pop()
- if self.onCancel then self.onCancel() end
+ self:cancel()
elseif input:wasPressed("a") and #party > 0 then
- local mon = party[self.index]
- if self.softboiledFrom then
- local user = party[self.softboiledFrom]
- local heal = math.floor(user.stats.hp / 5)
- if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp
- or user.hp <= heal then
- self.softboiledFrom = nil
- local TextBox = require("src.render.TextBox")
- self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
- else
- user.hp = user.hp - heal
- mon.hp = math.min(mon.stats.hp, mon.hp + heal)
- self.softboiledFrom = nil
- require("src.core.Sound").play(self.game.data, "Heal_HP")
- local def = self.game.data.pokemon[mon.species]
- local TextBox = require("src.render.TextBox")
- self.game.stack:push(TextBox.new(self.game,
- Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
- end
- elseif self.swapFrom then
- if self.swapFrom ~= self.index then
- party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
- require("src.core.Sound").play(self.game.data, "Swap")
- end
- self.swapFrom = nil
- elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
- -- keepOpen callers (HP medicine) need the menu still drawn while the
- -- bar fills and the message prints, and close it themselves; everyone
- -- else keeps the old pop-then-call order. Popping first is what made
- -- a POTION snap the picker shut before the item had even run (#252).
- if not self.keepOpen then self.game.stack:pop() end
- self.onSwitch(mon, self)
- else
- self.submenu = true
- self.subIndex = 1
- local items
- local ow = self.game.overworld
- if self.battle and self.onSwitch then
- -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
- items = { { label = Strings("SWITCH"), action = "battle_switch" },
- { label = Strings("STATS"), action = "stats" },
- { label = Strings("CANCEL"), action = "cancel" } }
- else
- -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
- -- builds the same dynamic list)
- items = { { label = Strings("STATS"), action = "stats" },
- { label = Strings("SWITCH"), action = "switch" } }
- -- Field moves (HMs/TMs) are usable out of battle even when the mon
- -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
- -- Battle still excludes this list via `not self.battle`. Softboiled
- -- can appear for a fainted user; its heal transfer then no-ops.
- if not self.battle and ow then
- -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
- -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
- local outside = Map.isOutside(ow.map.def,
- FieldDefaults.field(self.game.data, "outsideTilesets"))
- -- The badge gate per field move comes from constants.hmBadges and
- -- the badge itself through Badges.has: Gen2 gates the same moves on
- -- the Johto badges and stores them as engine flags rather than bag
- -- items, so the hard-coded R/B item names listed nothing at all.
- local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {}
- local function badged(moveId)
- local gate = gates[moveId]
- if not (gate and gate.badge) then return true end
- return Badges.has(self.game.save, { id = gate.badge })
- end
- for _, mv in ipairs(mon.moves) do
- if mv.id == "FLY" and outside and badged("FLY") then
- table.insert(items, { label = Strings("FLY"), action = "fly" })
- -- LIGHT is carved on the Aerodactyl chamber wall, and the
- -- chamber is not a dark cave -- so CheckUseFlash asks
- -- SpecialAerodactylChamber FIRST and lets FLASH be used on its
- -- carry, before it ever looks at the palset
- -- (engine/events/overworld.asm:285). Without this the move is
- -- never even offered there and the wall can never open.
- elseif mv.id == "FLASH" and badged("FLASH")
- and (ow.dark or (ow.map and ow.map.id
- == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then
- table.insert(items, { label = Strings("FLASH"), action = "flash" })
- elseif mv.id == "CUT" and badged("CUT") then
- -- CUT/SURF/STRENGTH are party-menu field moves too
- -- (start_sub_menus.asm .outOfBattleMovePointers); listed here
- -- with the same list-time badge filter this file already uses
- -- for FLY/FLASH. The facing-tile/activation check happens on
- -- selection (useCutFieldMove/useSurfFieldMove).
- table.insert(items, { label = Strings("CUT"), action = "cut" })
- elseif mv.id == "SURF" and badged("SURF") then
- table.insert(items, { label = Strings("SURF"), action = "surf" })
- elseif mv.id == "STRENGTH" and badged("STRENGTH") then
- table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
- elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then
- table.insert(items, { label = Strings(mv.id:gsub("_", " ")),
- action = "softboiled" })
- elseif mv.id == "TELEPORT" and outside then
- -- TELEPORT works only OUTDOORS (start_sub_menus.asm
- -- .teleport -> CheckIfInOutsideMap); dark maps don't
- -- block it
- table.insert(items, { label = Strings("TELEPORT"), action = "escape" })
- elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM"
- and (DIG_TILESETS[ow.map.def.tileset]
- or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment]
- and ow.escapePoint and ow:escapePoint())) then
- -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem =
- -- ESCAPE_ROPE): usable in the dungeon tilesets of
- -- escape_rope_tilesets.asm minus Agatha's room, even in
- -- the dark (Rock Tunnel).
- -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a
- -- recorded entrance to come back out of. Its own action,
- -- because Gen 2's DIG and TELEPORT no longer share a
- -- destination the way Gen 1's did.
- table.insert(items, { label = Strings("DIG"), action = "dig" })
- elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then
- -- GSC's own field moves; the tile check happens on selection,
- -- exactly like CUT's (start_sub_menus.asm never gates the list
- -- on where the player is standing)
- table.insert(items, { label = Strings(mv.id:gsub("_", " ")),
- action = "gen2_field", move = mv.id })
- end
- end
- end
- if require("src.core.GameVersion").isGen2() then
- -- GSC's MonMenu lists the field moves first, then STATS, SWITCH,
- -- ITEM and QUIT; Gen 1 has no held items and no QUIT row.
- local stats, switch = table.remove(items, 1), table.remove(items, 1)
- items[#items + 1] = stats
- items[#items + 1] = switch
- items[#items + 1] = { label = Strings("ITEM"), action = "item" }
- items[#items + 1] = { label = Strings("QUIT"), action = "quit" }
- end
- end
- local ctx = { battle = self.battle, overworld = ow }
- local hooked = Runtime.call("ui.party.submenu", sameItems,
- self.game, items, mon, ctx)
- if type(hooked) == "table" then
- items = hooked
- else
- Logger.error("ui.party.submenu returned %s; keeping the vanilla list",
- type(hooked))
- end
- self.subItems = items
- end
+ self:confirm()
end
end
diff --git a/src/ui/PokedexUnownMode.lua b/src/ui/PokedexUnownMode.lua
index d5907e743..0906e2f59 100644
--- a/src/ui/PokedexUnownMode.lua
+++ b/src/ui/PokedexUnownMode.lua
@@ -54,6 +54,8 @@ end
-- only have the unownForms set, so fall back to alphabetical for those
-- rather than showing an empty page.
local function caughtLetters(save)
+ local Unown = require("src.pokemon.Gen2Unown")
+ if Unown.supported() then return Unown.letters(save) end
local dex = save and save.pokedex
if not dex then return {} end
local ordered = dex.unownDex
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/ui/SaveCompanion.lua b/src/ui/SaveCompanion.lua
index 9ba0e5112..f42f6545c 100644
--- a/src/ui/SaveCompanion.lua
+++ b/src/ui/SaveCompanion.lua
@@ -165,6 +165,7 @@ local function adjustIndividual(game,mon,key,delta)
syncHpDv(mon.dvs)
end
recalc(game,mon)
+ Pokemon.syncIdentity(game.data,mon)
end
local function cycleMove(game,mon,slot,delta)
ensureCatalog(game)
@@ -208,6 +209,7 @@ local function applySpecies(game, picker, id)
mon.species=id
mon.exp=Growth.expForLevel(def.growthRate,mon.level or 1)
recalc(game,mon)
+ Pokemon.syncIdentity(game.data,mon)
state.speciesPicker=nil
return
end
diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua
index 090401b37..75194e8ab 100644
--- a/src/ui/SummaryMenu.lua
+++ b/src/ui/SummaryMenu.lua
@@ -147,7 +147,8 @@ function SummaryMenu.new(game, mon, opts)
-- which crashed the HP bar draw below (#233). Redundant once
-- SaveData.validate has run over a loaded save, but this is the site the
-- original recomputes at, and it also covers a mon handed in by a mod.
- Stats.ensure(game.data.pokemon[mon.species], mon)
+ if isGen2() then require('src.pokemon.Pokemon').refreshStats(game.data,mon)
+ else Stats.ensure(game.data.pokemon[mon.species],mon)end
local self = setmetatable({
game = game,
mon = mon,
diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua
index e1ab10a62..c69a6956b 100644
--- a/src/ui/TouchControlsEditor.lua
+++ b/src/ui/TouchControlsEditor.lua
@@ -72,6 +72,7 @@ local function persist()
-- dropped once the player saves under the new shape
opts.touchControls = {
enabled = cfg.enabled,
+ skin = cfg.skin,
layouts = cfg.layouts,
}
SaveData.saveOptions(opts)
diff --git a/src/ui/UICommands.lua b/src/ui/UICommands.lua
new file mode 100644
index 000000000..437932a88
--- /dev/null
+++ b/src/ui/UICommands.lua
@@ -0,0 +1,346 @@
+-- Admission and dispatch for native party, bag and dialogue screens.
+-- No screen is loaded here: an already loaded native class must own the top.
+local Commands = {}
+local CLASSES = {
+ PartyMenu = "party_gb",
+ Gen3PartyMenu = "party_gba",
+ Gen4PartyMenu = "party_ds",
+ Gen3BagMenu = "bag_gba",
+ Gen4BagMenu = "bag_ds",
+ HgssBagMenu = "bag_hgss",
+ Gen5BagMenu = "bag_bw",
+ Gen4BattleBag = "battle_bag",
+ Gen5BattleBag = "battle_bag",
+ Gen3ItemMenu = "item_actions",
+ ChoiceBox = "choice",
+}
+local function class(screen)
+ local mt = getmetatable(screen)
+ if mt == nil then
+ return
+ end
+ if mt == package.loaded["src.render.TextBox"] then
+ return "text"
+ end
+ for name, kind in pairs(CLASSES) do
+ if mt == package.loaded["src.ui." .. name] then
+ return kind
+ end
+ end
+ if mt == package.loaded["src.ui.ListMenu"] and screen.publicUIKind == "bag" then
+ return "bag_gb"
+ end
+ if mt == package.loaded["src.ui.Menu"] and screen.publicUIKind == "bag_actions" then
+ return "bag_actions"
+ end
+end
+local function row(out, value, ids, item)
+ local i = #out.rows + 1
+ local r = { index = i, enabled = true }
+ if type(value) == "table" then
+ r.label = type(value.label) == "string" and value.label or nil
+ r.action = type(value.action) == "string" and value.action or nil
+ if value.header then
+ r.enabled = false
+ end
+ if value.close then
+ r.action = "back"
+ end
+ if item then
+ r.item = ids:read("items", value.id or value.value)
+ r.count = type(value.qty) == "number" and value.qty or nil
+ if not r.label and type(value.def) == "table" and type(value.def.name) == "string" then
+ r.label = value.def.name
+ end
+ end
+ elseif type(value) == "string" then
+ r.action, r.label = value, value
+ end
+ out.rows[i] = r
+ return r
+end
+function Commands.describe(game, screen, ids)
+ local kind = class(screen)
+ if not kind then
+ return nil, "unsupported UI screen"
+ end
+ if screen.game ~= game then
+ return nil, "UI does not belong to current Game"
+ end
+ local out = {
+ kind = "party",
+ rows = {},
+ index = screen.index,
+ canBack = true,
+ canAdvance = false,
+ canPocket = false,
+ locked = false,
+ }
+ local refs = { screen = screen, stack = game.stack, states = game.stack.states, save = game.save }
+ -- Keep callback and nested option identity private; no engine references escape.
+ for key, value in pairs(screen) do
+ if type(value) == "function" or type(value) == "table" then
+ refs[key] = value
+ end
+ end
+ local function rows(values, items)
+ for i, value in ipairs(values or {}) do
+ refs["row:" .. i] = value
+ if type(value) == "table" then
+ refs["callback:" .. i] = value.onSelect
+ end
+ row(out, value, ids, items)
+ end
+ end
+ if kind:match("^party_") then
+ local party = kind == "party_gb" and (screen.party or game.save.party) or screen:party()
+ for i, mon in ipairs(party or {}) do
+ refs["mon:" .. i] = mon
+ end
+ if screen.selectNotice then
+ out.kind, out.canAdvance, out.canBack = "text", true, false
+ elseif screen.submenu then
+ out.kind = "party_actions"
+ rows(
+ kind == "party_gb" and screen.subItems or kind == "party_gba" and screen.submenu or screen:actions()
+ )
+ out.index = kind == "party_ds" and screen.submenu or screen.subIndex
+ else
+ for i, mon in ipairs(party or {}) do
+ refs["mon:" .. i] = mon
+ local r = row(out, {}, ids)
+ r.partyIndex, r.species, r.label = i, ids:read("pokemon", mon.species), mon.nickname
+ r.hp, r.maxHp = mon.hp, mon.stats and mon.stats.hp or mon.maxHp
+ end
+ if kind ~= "party_gb" then
+ if kind == "party_ds" and screen.select then
+ row(out, "confirm", ids)
+ end
+ row(out, "back", ids)
+ end
+ end
+ elseif kind == "text" then
+ out.kind, out.index, out.canBack = "text", nil, false
+ out.canAdvance = (
+ screen.waiting or (screen.done and not screen.auto and not screen.stay and not screen.choice)
+ )
+ and (screen.preWait or 0) <= 0
+ and (screen.holdFrames or 0) <= 0
+ or false
+ out.locked = not out.canAdvance
+ elseif kind == "choice" then
+ out.kind = "choice"
+ rows({ "yes", "no" })
+ out.locked = screen.pending ~= nil
+ elseif kind == "battle_bag" then
+ out.kind, out.page = "bag", screen.screen
+ local mem = screen.mem or screen.cur
+ out.pocket = screen.pocket
+ out.index = screen.cursor and screen.cursor.index
+ if screen.message then
+ out.kind, out.canAdvance, out.canBack = "text", true, false
+ elseif screen.screen == "menu" then
+ rows({ "pocket:1", "pocket:2", "pocket:3", "pocket:4", "last_item", "back" })
+ out.rows[5].enabled = mem.lastItem ~= nil
+ elseif screen.screen == "pocket" then
+ for i = 1, 6 do
+ local item = screen:itemAt(i)
+ refs["row:" .. i] = item
+ local r = row(out, item or {}, ids, true)
+ r.enabled = item ~= nil
+ end
+ row(out, "previous_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0
+ row(out, "next_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0
+ row(out, "back", ids)
+ else
+ rows({ "use", "back" })
+ out.rows[1].enabled = screen:itemAt((mem.positions[screen.pocket] or 0) + 1) ~= nil
+ end
+ out.locked = screen.press ~= nil
+ elseif kind == "bag_actions" or kind == "item_actions" then
+ out.kind = "bag_actions"
+ rows(kind == "bag_actions" and screen.items or screen.entries)
+ if kind == "item_actions" then
+ for i, r in ipairs(out.rows) do
+ r.enabled = screen:pickable(i)
+ r.action = screen.entries[i].kind
+ end
+ else
+ out.canBack = screen.cancelable
+ end
+ else
+ out.kind = "bag"
+ out.pocket = screen.pocket or screen.pocketIndex
+ if screen.message or screen.resultMessage then
+ out.kind, out.canAdvance, out.canBack = "text", true, false
+ elseif screen.menu then
+ out.kind, out.index = screen.menu.kind == "yesno" and "choice" or "bag_actions", screen.menu.index
+ rows(screen.menu.rows)
+ else
+ rows(kind == "bag_gb" and screen.items or kind == "bag_hgss" and screen:items() or screen.rows, true)
+ out.index = kind == "bag_hgss" and screen.page * 6 + screen.slot
+ or kind == "bag_bw" and screen.cursor
+ or screen.index
+ out.canPocket = not screen.lockPocket
+ and (kind == "bag_gb" and screen.onPocketSwitch ~= nil or kind ~= "bag_gb" and #(screen.pockets or {}) > 1)
+ or false
+ end
+ out.locked = screen.trash ~= nil and not screen.menu
+ or screen.moving ~= nil and screen.moving ~= false
+ or false
+ end
+ local battle = screen.battle or screen.publicUIBattle
+ if
+ battle and (battle.demo or battle.dudeDemo or battle.pokedudeDemo or battle.spectating or battle.dead)
+ then
+ out.locked = true
+ end
+ if screen.heal or screen.noInput or screen.script or screen.closed then
+ out.locked = true
+ end
+ if out.locked then
+ out.canAdvance, out.canPocket, out.canBack = false, false, false
+ end
+ return out, refs, kind
+end
+local function press(screen, ds)
+ local Sound = require("src.core.Sound")
+ if ds then
+ if Sound.playSe and screen.game.data then
+ Sound.playSe(screen.game.data, "SEQ_SE_DP_SELECT")
+ end
+ elseif screen.game.data then
+ Sound.play(screen.game.data, "Press_AB")
+ end
+end
+function Commands.dispatch(screen, kind, view, intent)
+ if intent.kind == "advance" then
+ if kind == "text" then
+ screen:advance()
+ elseif screen.selectNotice then
+ screen.selectNotice = nil
+ else
+ if screen.resultMessage then
+ press(screen)
+ end
+ screen.message, screen.resultMessage = nil, nil
+ end
+ elseif kind == "text" then
+ error("text only supports advance")
+ elseif kind == "choice" then
+ screen:choose(intent.kind == "back" and 2 or intent.index)
+ elseif kind == "battle_bag" then
+ if intent.kind == "select" then
+ screen.cursor.index = intent.index
+ end
+ screen:pick(intent.kind == "back" and ({ menu = 6, pocket = 9, use = 2 })[screen.screen] or intent.index)
+ elseif kind == "party_gb" then
+ press(screen)
+ if intent.kind == "back" then
+ screen:cancel()
+ else
+ if screen.submenu then
+ screen.subIndex = intent.index
+ else
+ screen.index = intent.index
+ end
+ screen:confirm()
+ end
+ elseif kind == "party_gba" or kind == "party_ds" then
+ if intent.kind == "back" then
+ if screen.submenu then
+ if screen.itemMenu then
+ screen.itemMenu, screen.submenu = nil, 1
+ else
+ screen.submenu = nil
+ end
+ else
+ screen:cancel()
+ end
+ elseif screen.submenu then
+ if kind == "party_ds" then
+ screen.submenu = intent.index
+ screen:runAction(screen:actions()[intent.index])
+ else
+ screen.subIndex = intent.index
+ local r = screen.submenu[intent.index]
+ screen:runAction(r.action, screen:party()[screen.index], r)
+ end
+ else
+ screen.index = intent.index
+ if kind == "party_gba" then
+ screen:confirm()
+ else
+ screen:choose()
+ end
+ end
+ elseif kind == "bag_actions" then
+ if intent.kind == "back" then
+ screen:cancel()
+ else
+ screen.index = intent.index
+ screen:choose()
+ end
+ elseif kind == "item_actions" then
+ press(screen)
+ if intent.kind == "select" then
+ screen.index = intent.index
+ end
+ screen:close(intent.kind == "back" and "cancel" or screen.entries[intent.index].kind)
+ elseif screen.menu then
+ if intent.kind == "back" then
+ if kind == "bag_ds" then
+ press(screen, true)
+ end
+ if screen.menu.kind == "yesno" then
+ screen:resolveTrash(false)
+ else
+ screen.menu = nil
+ end
+ else
+ screen.menu.index = intent.index
+ if kind ~= "bag_ds" then
+ screen:runMenuRow()
+ else
+ press(screen, true)
+ if screen.menu.kind == "yesno" then
+ screen:resolveTrash(intent.index == 1)
+ else
+ screen:runAction(screen.menu.rows[intent.index])
+ end
+ end
+ end
+ elseif intent.kind == "pocket" then
+ if kind == "bag_gb" then
+ screen.onPocketSwitch(screen, intent.direction)
+ elseif kind == "bag_hgss" or kind == "bag_bw" then
+ screen:setPocket((screen.pocket - 1 + intent.direction) % #screen.pockets + 1)
+ else
+ screen.pocketLit = nil
+ screen:movePocket(intent.direction)
+ end
+ elseif intent.kind == "back" then
+ if kind == "bag_gb" then
+ screen:cancel()
+ else
+ if kind ~= "bag_gba" then
+ press(screen, true)
+ end
+ screen:close()
+ end
+ else
+ if kind == "bag_hgss" then
+ screen.page, screen.slot = math.floor((intent.index - 1) / 6), (intent.index - 1) % 6 + 1
+ elseif kind == "bag_bw" then
+ screen.cursor = intent.index
+ screen.top = math.max(0, intent.index - 6)
+ else
+ screen.index = intent.index
+ if kind == "bag_ds" then
+ screen:clampScroll()
+ end
+ end
+ screen:choose()
+ end
+end
+return Commands
diff --git a/src/world/MapOverview.lua b/src/world/MapOverview.lua
new file mode 100644
index 000000000..adf912a6b
--- /dev/null
+++ b/src/world/MapOverview.lua
@@ -0,0 +1,176 @@
+-- Generation-neutral terrain grid; optional GB tile luminance. All cells are
+-- zero based. The scratch Map shares read-only data, but never the cold block
+-- decoder's mutable definition or streamed renderer.
+local Map = require("src.world.Map")
+local Assets = require("src.render.Assets")
+local Overview = {}
+local shades = {}
+Assets.register(function()
+ shades = {}
+end)
+
+local function digit(sum, count)
+ return tostring(math.max(0, math.min(3, math.floor((1 - sum / count) * 3 + 0.5))))
+end
+
+local function visual(map)
+ local ts = map.tileset
+ -- GBA metatiles are composed/paletted by their renderer; their opaque tileAt
+ -- identifiers do not index a GB atlas. DS maps do not have one either.
+ if
+ map.blockCells ~= 2
+ or not ts.blocks
+ or type(ts.image) ~= "string"
+ or type(ts.tilesPerRow) ~= "number"
+ or ts.tilesPerRow < 1
+ or ts.tilesPerRow >= math.huge
+ or ts.tilesPerRow ~= math.floor(ts.tilesPerRow)
+ then
+ return nil
+ end
+ local path = Assets.resolve(ts.image)
+ local cache = shades[path]
+ if not cache then
+ if not (love and love.image and love.image.newImageData) then
+ return nil
+ end
+ local ok, pixels = pcall(love.image.newImageData, path)
+ if not ok or not pixels then
+ return nil
+ end
+ cache = { pixels = pixels, tiles = {} }
+ shades[path] = cache
+ end
+ local rows, details, pixels = {}, {}, cache.pixels
+ local width, height = pixels:getDimensions()
+ for y = 0, map.heightCells * 2 - 1 do
+ local row, top, bottom = {}, {}, {}
+ for x = 0, map.widthCells * 2 - 1 do
+ local tile = map:tileAt(x, y)
+ if type(tile) ~= "number" or tile < 0 or tile ~= math.floor(tile) then
+ return nil
+ end
+ local ox, oy = tile % ts.tilesPerRow * 8, math.floor(tile / ts.tilesPerRow) * 8
+ if ox + 8 > width or oy + 8 > height then
+ return nil
+ end
+ local key = ts.tilesPerRow .. ":" .. tile
+ local s = cache.tiles[key]
+ if not s then
+ local sums = { 0, 0, 0, 0 }
+ for py = 0, 7 do
+ for px = 0, 7 do
+ local r, g, b = pixels:getPixel(ox + px, oy + py)
+ local q = math.floor(py / 4) * 2 + math.floor(px / 4) + 1
+ sums[q] = sums[q] + r * 0.2126 + g * 0.7152 + b * 0.0722
+ end
+ end
+ s = {
+ digit(sums[1] + sums[2] + sums[3] + sums[4], 64),
+ digit(sums[1], 16),
+ digit(sums[2], 16),
+ digit(sums[3], 16),
+ digit(sums[4], 16),
+ }
+ cache.tiles[key] = s
+ end
+ row[#row + 1], top[#top + 1], bottom[#bottom + 1] = s[1], s[2] .. s[3], s[4] .. s[5]
+ end
+ rows[#rows + 1] = table.concat(row)
+ details[#details + 1], details[#details + 2] = table.concat(top), table.concat(bottom)
+ end
+ return rows, details
+end
+
+local function integer(n)
+ return type(n) == "number" and n >= 0 and n < math.huge and n == math.floor(n)
+end
+
+function Overview.build(game, ow)
+ local map = ow.map
+ local w, h = map.widthCells, map.heightCells
+ if not integer(w) or not integer(h) or w == 0 or h == 0 then
+ return nil, "invalid map dimensions"
+ end
+ -- Outdoor DS terrain lives in lazily streamed renderer chunks. Walking that
+ -- renderer from a query can load/cache chunks. Interior permission grids are
+ -- already resident and can use the same pure Map classifiers below.
+ if map.def.generation == 4 and not map.def.behaviorCells then
+ return nil, "map overview unavailable for streamed terrain"
+ end
+ local scratch, def = {}, {}
+ for k, v in pairs(map) do
+ scratch[k] = v
+ end
+ for k, v in pairs(map.def) do
+ def[k] = v
+ end
+ scratch.def, scratch.renderer = def, nil
+ setmetatable(scratch, Map)
+ -- blockArray() writes only into the private definition on a cold GBA map.
+ -- It may reuse the warm immutable array, and honors the live block patches.
+ local rows, markers = {}, {}
+ local function marker(kind, x, y)
+ if integer(x) and integer(y) and x < w and y < h then
+ markers[#markers + 1] = { kind = kind, x = x, y = y }
+ end
+ end
+ for _, warp in ipairs(def.warps or {}) do
+ marker("warp", warp.x, warp.y)
+ end
+ -- The active object list is the result of native flag, session, time and
+ -- scripted spawn filters. Re-running those filters can consult another Game
+ -- singleton or initialize sprite state. Use the objects actually present.
+ for _, npc in ipairs(ow.npcs or {}) do
+ local obj = npc.def or {}
+ if obj.item and obj.item ~= 0 and obj.item ~= "0" and not npc.hidden and not obj.fruitTree then
+ marker("item", npc.cellX, npc.cellY)
+ end
+ end
+ local save = game.save or {}
+ local hidden = game.data and game.data.field and game.data.field.hiddenItems
+ for _, item in ipairs(hidden and hidden[map.id] or {}) do
+ if not (save.hiddenTaken and save.hiddenTaken[map.id .. "_" .. item.x .. "_" .. item.y]) then
+ marker("hidden", item.x, item.y)
+ end
+ end
+ -- Gen2/3 imports also carry hidden-item sign events, with their own taken
+ -- key and event flag (e.g. Cerulean Gym's Machine Part).
+ for _, item in ipairs(def.signs or {}) do
+ if
+ item.item
+ and item.item ~= 0
+ and item.item ~= "0"
+ and not (item.eventFlag and save.flags and save.flags[item.eventFlag] == true)
+ and not (save.hiddenTaken and save.hiddenTaken[map.id .. "_sign_" .. item.x .. "_" .. item.y])
+ then
+ marker("hidden", item.x, item.y)
+ end
+ end
+ for y = 0, h - 1 do
+ local row = {}
+ for x = 0, w - 1 do
+ row[#row + 1] = scratch:isWarpTileCell(x, y) and "+"
+ or scratch:isWaterCell(x, y) and "~"
+ or scratch:isWalkableCell(x, y) and "."
+ or " "
+ end
+ rows[#rows + 1] = table.concat(row)
+ end
+ local tiles, details = visual(scratch)
+ return {
+ mapId = map.id,
+ width = w,
+ height = h,
+ rows = rows,
+ markers = markers,
+ tileRows = tiles,
+ tileWidth = tiles and w * 2,
+ tileHeight = tiles and h * 2,
+ tileDetailRows = details,
+ tileDetailWidth = details and w * 4,
+ tileDetailHeight = details and h * 4,
+ }
+end
+
+return Overview
diff --git a/src/world/NPC.lua b/src/world/NPC.lua
index 28b1a2b3e..1550de56a 100644
--- a/src/world/NPC.lua
+++ b/src/world/NPC.lua
@@ -231,7 +231,7 @@ local function resolveBreedSprite(sprites, spriteId)
local save = require("src.core.Game").save
local mon = require("src.pokemon.DayCare").mon(save, tonumber(slot))
or (save and save.daycare and save.daycare.mon)
- local species = mon and tonumber(tostring(mon.species):match("(%d+)$"))
+ local species = mon and require('src.core.Gen2SpeciesNames').number(require('src.core.Game').data,mon)
return (species and sprites[string.format("SPRITE_MON_%03d", species)])
or spriteByIndex(sprites, 1)
end
diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua
index a17b2dd6e..3fbf373b3 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")
@@ -13113,7 +13137,7 @@ function OverworldState:hatchEgg(hatched)
-- ElmPhoneCalleeScript tests (`checkevent $2D / iffalse .next / checkevent
-- $54 / iftrue .egghatched`), so without it ringing Elm after the EGG
-- hatched could never reach ElmPhoneEggHatchedText.
- if GameVersion.isGen2() and hatched.species == "SPECIES_175" and Game.save.flags then
+ if GameVersion.isGen2() and require('src.core.Gen2SpeciesNames').same(Game.data,hatched.species,'SPECIES_175') and Game.save.flags then
Game.save.flags[require("src.script.Gen2Flags").eventFlag(0x54)] = true
end
local name = def and def.name or hatched.species
@@ -15374,7 +15398,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
self.arriveWarp = nil
local fromId = self.map.id
local fromSection = self.map.def and self.map.def.regionMapSection
- Game.stack:push(Transition.new(Game, function()
+ local transition = Transition.new(Game, function()
self:setMap(mapId, x, y, facing or "down", opts)
self:noteGen2Spawn(fromId)
-- FIRERED'S SIDE STAIRS (ExitStairsMovement): you land ON the stair tile
@@ -15564,7 +15588,15 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
-- side, which is what the scripts already do.
if self.player then self.player.inputLocked = false end
if onDone then onDone() end
- end))
+ end)
+ -- API 3 exposes the Gen 3 no-fade option. Reuse the exact native midpoint
+ -- and completion path; ordinary/native callers keep their existing fade.
+ if opts and opts.fade == false then
+ transition.onMidpoint()
+ transition.onDone()
+ else
+ Game.stack:push(transition)
+ end
end
-- Re-read a map record after its data changed (WorldAPI:invalidateMap,
diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua
index f21419ae9..3e2ae3080 100644
--- a/src/world/WorldAPI.lua
+++ b/src/world/WorldAPI.lua
@@ -6,17 +6,21 @@
-- stays unsupported; anything a mod legitimately needs belongs here.
local Logger = require("src.core.Logger")
+local Actions = require("src.world.WorldActions")
local MapLoader = require("src.world.MapLoader")
local Gen3Elevation = require("src.world.Gen3Elevation")
local Runtime = require("src.mods.Runtime")
+local Reads = require("src.mods.WorldReads")
+local WorldOverview = require("src.mods.WorldOverview")
+local MapOverview = require("src.world.MapOverview")
local WorldAPI = {}
WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld"
-function WorldAPI.new(game, modId)
- return setmetatable({ game = game, modId = modId }, WorldAPI)
+function WorldAPI.new(game, modId, readContext)
+ return setmetatable({ game = game, modId = modId, readContext = readContext }, WorldAPI)
end
-- the live overworld, or nil. Game.overworld is the fast path; the stack
@@ -58,6 +62,41 @@ function WorldAPI:current()
elevation) }
end
+-- Detached common Pokemon records. This also works with a save at the title
+-- screen: reading the party does not need an active overworld.
+function WorldAPI:party()
+ return Reads.party(self.game, self.readContext)
+end
+
+-- Require the expected active map to reject stale requests after a warp.
+-- The second result describes block size/coordinates and the DS proxy grid.
+function WorldAPI:activeBlockAt(mapId, bx, by)
+ local ow = self:overworld()
+ if not ow or not ow.map then return nil, NO_OVERWORLD end
+ return Reads.activeBlockAt(ow.map, mapId, bx, by)
+end
+
+-- Loaded maps only, active first and with distinct placements. loadedMaps()
+-- retains its legacy shape/order; these records also describe block units.
+function WorldAPI:liveMaps()
+ local ow = self:overworld()
+ if not ow or not ow.map then return nil, NO_OVERWORLD end
+ return Reads.liveMaps(ow)
+end
+
+-- Detached, zero-based terrain and marker grids for companion UIs/minimaps.
+function WorldAPI:mapOverview()
+ local ow = self:overworld()
+ if not ow or not ow.map then return nil, NO_OVERWORLD end
+ return MapOverview.build(self.game, ow)
+end
+
+-- Table distribution before per-step RNG, repel, roamers and roll hooks.
+-- Explicit map IDs also work at the title screen without loading that map.
+function WorldAPI:effectiveEncounters(mapId, terrain, opts)
+ return WorldOverview.encounters(self.game, self:overworld(), self.overviewContext, mapId, terrain, opts)
+end
+
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
-- lands the player without one, like a scripted warp.
function WorldAPI:warpTo(mapId, x, y, facing, opts)
@@ -386,6 +425,14 @@ function WorldAPI:queueScript(rows, extra)
return true
end
+-- Public field mutations are kept behind the same free-roam boundary.
+function WorldAPI:canReorderParty() return Actions.canReorderParty(self.game, self:overworld()) end
+function WorldAPI:reorderParty(fromSlot, toSlot) return Actions.reorderParty(self.game, self:overworld(), fromSlot, toSlot) end
+function WorldAPI:availableFieldActions() return Actions.availableFieldActions(self.game, self:overworld()) end
+function WorldAPI:useFieldAction(id, opts) return Actions.useFieldAction(self.game, self:overworld(), id, opts) end
+function WorldAPI:canFly() return Actions.canFly(self.game, self:overworld()) end
+function WorldAPI:flyTo(mapId) return Actions.flyTo(self.game, self:overworld(), mapId) end
+
-- drop a map's cached instance so the next load re-reads its record; when
-- it is the active map the world reloads around the player in place
function WorldAPI:invalidateMap(mapId)
diff --git a/src/world/WorldActions.lua b/src/world/WorldActions.lua
new file mode 100644
index 000000000..3244aa8a6
--- /dev/null
+++ b/src/world/WorldActions.lua
@@ -0,0 +1,471 @@
+-- Supported mutations go through the existing gameplay entry points. Shared
+-- guards keep companion UIs out of screens/scripts/transitions that own input.
+local GameVersion = require("src.core.GameVersion")
+local Map = require("src.world.Map")
+local FieldDefaults = require("src.world.FieldDefaults")
+local Party = require("src.pokemon.Party")
+local A = {}
+local METHODS = {
+ canReorderParty = true,
+ reorderParty = true,
+ availableFieldActions = true,
+ useFieldAction = true,
+ canFly = true,
+ flyTo = true,
+}
+local LOCKS = {
+ "transitioning",
+ "flyAnim",
+ "teleportOut",
+ "engaging",
+ "emote",
+ "pikaHop",
+ "healAnim",
+ "whirlSpin",
+ "gen4Waterfall",
+ "gen5SeamPending",
+ "gen3Decorate",
+ "frameScriptThisTick",
+ "_modScriptLock",
+}
+local pending = setmetatable({}, { __mode = "k" })
+local planning = setmetatable({}, { __mode = "k" })
+local function integer(v, lo, hi)
+ return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi
+end
+function A.supports(name)
+ return type(name) == "string" and METHODS[name] == true
+end
+
+local function ready(game, ow)
+ if not (game and game.save) then
+ return nil, "no save"
+ end
+ if not (ow and ow.map and ow.player) then
+ return nil, "no overworld"
+ end
+ local token = pending[game]
+ if token and token.state then
+ local held = false
+ for _, state in ipairs(game.stack and game.stack.states or {}) do
+ if state == token.state then
+ held = true
+ end
+ end
+ if game.stack and game.stack.top and game.stack:top() == token.state then
+ held = true
+ end
+ if
+ not held
+ or game.save ~= token.save
+ or game.data ~= token.data
+ or ow.map ~= token.map
+ or ow.player ~= token.player
+ then
+ pending[game] = nil
+ end
+ end
+ if not (game.stack and game.stack.top and game.stack:top() == ow) then
+ return nil, "world is busy"
+ end
+ if
+ pending[game]
+ or planning[game]
+ or (game.overworld and game.overworld ~= ow)
+ or game.linkSession
+ or (game.linkNet and not game.linkNet.closed)
+ then
+ return nil, "world is busy"
+ end
+ for _, key in ipairs(LOCKS) do
+ if ow[key] then
+ return nil, "world is busy"
+ end
+ end
+ local player = ow.player
+ if player.moving or player.inputLocked or player.stairExit or player.spinning or player.fieldMove then
+ return nil, "world is busy"
+ end
+ if not (ow.runner and type(ow.runner.isRunning) == "function") then
+ return nil, "world is busy"
+ end
+ if ow.runner:isRunning() or #(ow.scriptMoves or {}) > 0 or #(ow.pendingScripts or {}) > 0 then
+ return nil, "world is busy"
+ end
+ return true
+end
+-- Shared admission guard for the API-3 warp adapter.
+A.ready = ready
+local function guardedCall(game, fn, ...)
+ planning[game] = true
+ local result = { pcall(fn, game, ...) }
+ planning[game] = nil
+ if not result[1] then
+ error(result[2], 0)
+ end
+ return result[2], result[3]
+end
+local function party(game)
+ return game and game.save and game.save.party or {}
+end
+local function member(game, mon)
+ if type(mon) ~= "table" or Party.isEgg(mon) then
+ return nil
+ end
+ for _, p in ipairs(party(game)) do
+ if p == mon then
+ return mon
+ end
+ end
+end
+local function user(game, ow, move)
+ if type(ow.partyKnows) ~= "function" then
+ return nil
+ end
+ return member(game, ow:partyKnows(move))
+end
+local function context(game, ow)
+ local p = ow.player
+ return {
+ save = game.save,
+ data = game.data,
+ map = ow.map,
+ player = p,
+ x = p.cellX,
+ y = p.cellY,
+ facing = p.facing,
+ version = GameVersion.get(),
+ }
+end
+local function same(game, ow, c)
+ return game.save == c.save
+ and game.data == c.data
+ and ow.map == c.map
+ and ow.player == c.player
+ and ow.player.cellX == c.x
+ and ow.player.cellY == c.y
+ and ow.player.facing == c.facing
+ and GameVersion.get() == c.version
+end
+local function currentWorld(game, ow)
+ -- A state replaced while a cut-in was active must not receive its effect.
+ return game.stack and game.stack.top and game.stack:top() == ow
+end
+local function present(game, ow, mon, check, run)
+ local c = context(game, ow)
+ local called = false
+ local token = { save = game.save, data = game.data, map = ow.map, player = ow.player }
+ pending[game] = token
+ local result, reason
+ local function finish()
+ if called then
+ return
+ end
+ called = true
+ if pending[game] ~= token then
+ return
+ end
+ pending[game] = nil
+ if
+ not currentWorld(game, ow)
+ or not same(game, ow, c)
+ or not ready(game, ow)
+ or not member(game, mon)
+ then
+ result, reason = nil, "world changed"
+ return
+ end
+ if not check() then
+ result, reason = nil, "action unavailable"
+ return
+ end
+ -- A capability hook invoked by check() may have changed the session.
+ if not same(game, ow, c) or not ready(game, ow) then
+ result, reason = nil, "world changed"
+ return
+ end
+ local answer = guardedCall(game, run)
+ if answer == false then
+ result, reason = nil, "action unavailable"
+ else
+ result = true
+ end
+ end
+ if GameVersion.generation() == 3 then
+ -- Never retry an engine callback that threw after invoking finish().
+ local ok, shown = pcall(function()
+ return require("src.world.Gen3FieldMove").show(game, mon, finish)
+ end)
+ if not ok then
+ pending[game] = nil
+ error(shown, 0)
+ end
+ if shown then
+ token.state = game.stack:top()
+ return true
+ end
+ end
+ finish()
+ return result, reason
+end
+
+function A.canReorderParty(game, ow)
+ return #party(game) > 1 and ready(game, ow) == true
+end
+function A.reorderParty(game, ow, from, to)
+ local ok, reason = ready(game, ow)
+ if not ok then
+ return nil, reason
+ end
+ local p = party(game)
+ if
+ not integer(from, 1, Party.MAX)
+ or not integer(to, 1, Party.MAX)
+ or type(p[from]) ~= "table"
+ or type(p[to]) ~= "table"
+ then
+ return nil, "invalid party slot"
+ end
+ -- Match the native party menus: swap references, never reconstruct mons.
+ if from ~= to then
+ p[from], p[to] = p[to], p[from]
+ end
+ return true
+end
+
+local function outside(game, ow)
+ return Map.isOutside(ow.map.def, FieldDefaults.field(game.data, "outsideTilesets"))
+end
+local function validSpot(data, id, x, y, gen)
+ local def = data and data.maps and data.maps[id]
+ if not def or not integer(def.width, 1, 1000000) or not integer(def.height, 1, 1000000) then
+ return false
+ end
+ local tileset = data.tilesets and data.tilesets[def.tileset]
+ local cells = tileset and tileset.blockCells or (gen >= 3 and 1 or 2)
+ return integer(cells, 1, 1000000)
+ and integer(x, 0, def.width * cells - 1)
+ and integer(y, 0, def.height * cells - 1)
+end
+local function flyPlan(game, ow, mapId)
+ local gen = GameVersion.generation()
+ local data, save = game.data or {}, game.save
+ local mon
+ if gen >= 4 then
+ local F = require("src.world.Gen4FieldMoves")
+ for _, p in ipairs(party(game)) do
+ if not Party.isEgg(p) and F.knows(p, "FLY") then
+ mon = p
+ break
+ end
+ end
+ if not mon then
+ return nil, "fly unavailable"
+ end
+ if gen == 5 then
+ local Fly = require("src.world.Gen5Fly")
+ if not Fly.allowed(ow.map.def) or type(ow.gen4FlyTo) ~= "function" then
+ return nil, "fly unavailable"
+ end
+ for _, loc in ipairs(Fly.destinations(data, save)) do
+ local dest = Fly.destination(data, loc)
+ if dest and (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then
+ return { mon = mon, dest = dest, ds = true }
+ end
+ end
+ else
+ local Fly = require("src.world.Gen4Fly")
+ local costume = require("src.inventory.Gen4FieldChecks").inCostume(data, save)
+ if costume or not Fly.check(data, save, ow.map.def) or type(ow.gen4FlyTo) ~= "function" then
+ return nil, "fly unavailable"
+ end
+ for _, dest in ipairs(Fly.destinations(data, save)) do
+ if (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then
+ return { mon = mon, dest = { map = dest.map, x = dest.x, y = dest.y }, ds = true }
+ end
+ end
+ end
+ else
+ mon = user(game, ow, "FLY")
+ local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def)
+ or (gen < 3 and outside(game, ow))
+ if not mon or not travel or type(ow.flyTo) ~= "function" then
+ return nil, "fly unavailable"
+ end
+ local field = data.field or {}
+ for _, id in ipairs(field.flyOrder or {}) do
+ local def = data.maps and data.maps[id]
+ local spot = field.flyWarps and field.flyWarps[id]
+ if
+ (not mapId or mapId == id)
+ and save.visited
+ and save.visited[id]
+ and spot
+ and def
+ and (Map.isOutdoor(def) or def.tileset == "PLATEAU")
+ and validSpot(data, id, spot.x, spot.y, gen)
+ then
+ return { mon = mon, dest = { map = id, x = spot.x, y = spot.y } }
+ end
+ end
+ end
+ return nil, "destination unavailable"
+end
+function A.canFly(game, ow)
+ if not ready(game, ow) then
+ return false
+ end
+ local c = context(game, ow)
+ return guardedCall(game, flyPlan, ow) ~= nil and same(game, ow, c) and ready(game, ow) == true
+end
+function A.flyTo(game, ow, mapId)
+ local ok, reason = ready(game, ow)
+ if not ok then
+ return nil, reason
+ end
+ if type(mapId) ~= "string" or mapId == "" then
+ return nil, "invalid destination"
+ end
+ local c = context(game, ow)
+ local plan, why = guardedCall(game, flyPlan, ow, mapId)
+ if not plan then
+ return nil, why
+ end
+ if not same(game, ow, c) or not ready(game, ow) then
+ return nil, "world changed"
+ end
+ return present(game, ow, plan.mon, function()
+ local fresh = guardedCall(game, flyPlan, ow, mapId)
+ return fresh and fresh.mon == plan.mon and fresh.dest.x == plan.dest.x and fresh.dest.y == plan.dest.y
+ end, function()
+ if plan.ds then
+ return ow:gen4FlyTo(plan.dest, plan.mon)
+ end
+ return ow:flyTo(plan.dest.map, plan.mon)
+ end)
+end
+
+local ORDER = { "cut", "surf", "dig", "teleport" }
+local function fieldPlan(game, ow, id)
+ local gen = GameVersion.generation()
+ if gen >= 4 then
+ return nil, "DS field actions require a native script adapter"
+ end
+ if id == "cut" then
+ if gen == 3 then
+ return nil, "Gen 3 Cut requires an object-script adapter"
+ end
+ local mon = user(game, ow, "CUT")
+ if
+ mon
+ and type(ow.useCutFieldMove) == "function"
+ and ow:useCutFieldMove() == "ok"
+ and type(ow.tryCut) == "function"
+ then
+ return {
+ mon = mon,
+ run = function()
+ local x, y = ow.player:facingCell()
+ return ow:tryCut(x, y)
+ end,
+ }
+ end
+ elseif id == "surf" then
+ local mon = user(game, ow, "SURF")
+ if
+ mon
+ and not ow.player.surfing
+ and type(ow.useSurfFieldMove) == "function"
+ and ow:useSurfFieldMove() == "ok"
+ and type(ow.trySurf) == "function"
+ then
+ return {
+ mon = mon,
+ run = function()
+ local x, y = ow.player:facingCell()
+ return ow:trySurf(x, y, nil, mon)
+ end,
+ }
+ end
+ elseif id == "dig" or id == "teleport" then
+ local mon = user(game, ow, id:upper())
+ if not mon or type(ow.beginTeleportOut) ~= "function" then
+ return nil, "field action unavailable"
+ end
+ local escape = false
+ if id == "teleport" then
+ local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def)
+ or (gen < 3 and outside(game, ow))
+ if not travel or not game.save.lastHeal then
+ return nil, "field action unavailable"
+ end
+ elseif gen == 1 then
+ local tiles = { FOREST = true, CEMETERY = true, CAVERN = true, FACILITY = true, INTERIOR = true }
+ if not tiles[ow.map.def.tileset] or ow.map.id == "AGATHAS_ROOM" or not game.save.lastHeal then
+ return nil, "field action unavailable"
+ end
+ else
+ local point = type(ow.escapePoint) == "function" and ow:escapePoint()
+ if not point then
+ return nil, "field action unavailable"
+ end
+ if gen == 3 then
+ if not require("src.world.Gen3FieldRules").allowsEscape(ow.map.def, point) then
+ return nil, "field action unavailable"
+ end
+ elseif ow.map.def.environment ~= 4 and ow.map.def.environment ~= 7 then
+ return nil, "field action unavailable"
+ end
+ escape = true
+ end
+ return {
+ mon = mon,
+ run = function()
+ return ow:beginTeleportOut(nil, { escape = escape or nil })
+ end,
+ }
+ end
+ return nil, "field action unavailable"
+end
+function A.availableFieldActions(game, ow)
+ local ok, reason = ready(game, ow)
+ if not ok then
+ return {}, reason
+ end
+ local c = context(game, ow)
+ local out = {}
+ for _, id in ipairs(ORDER) do
+ if guardedCall(game, fieldPlan, ow, id) then
+ out[#out + 1] = { id = id, label = id:upper() }
+ end
+ if not same(game, ow, c) or not ready(game, ow) then
+ return {}, "world changed"
+ end
+ end
+ return out
+end
+function A.useFieldAction(game, ow, id, opts)
+ local ok, reason = ready(game, ow)
+ if not ok then
+ return nil, reason
+ end
+ if type(id) ~= "string" then
+ return nil, "invalid field action"
+ end
+ if opts ~= nil and (type(opts) ~= "table" or next(opts) ~= nil or getmetatable(opts) ~= nil) then
+ return nil, "unsupported field action options"
+ end
+ local c = context(game, ow)
+ local plan, why = guardedCall(game, fieldPlan, ow, id)
+ if not plan then
+ return nil, why
+ end
+ if not same(game, ow, c) or not ready(game, ow) then
+ return nil, "world changed"
+ end
+ return present(game, ow, plan.mon, function()
+ local fresh = guardedCall(game, fieldPlan, ow, id)
+ return fresh and fresh.mon == plan.mon
+ end, plan.run)
+end
+return A
diff --git a/tests/api3_contract/datasets.lua b/tests/api3_contract/datasets.lua
new file mode 100644
index 000000000..5fc4d1260
--- /dev/null
+++ b/tests/api3_contract/datasets.lua
@@ -0,0 +1,104 @@
+-- Native cache formats differ; the mod and assertions are identical.
+local M = {}
+function M.fixture(root, backend, version)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/datasets%.lua$")
+ local Version = require("src.core.GameVersion")
+ Version.set(version)
+ local gen = Version.generation(version)
+ local data = dofile(codeRoot .. "/run.lua").dataFor(backend, gen)
+ local writer = require("src.import.LuaWriter")
+ local prefix = Version.cachePrefix(version)
+ local files = {}
+ if backend == "gen1" then
+ local Contract = require("src.import.CacheContract")
+ if gen < 3 then
+ require("tests.modkit.dataset_view_fixture").cache(files, version, {
+ pokemon = data.pokemon,
+ moves = data.moves,
+ items = data.items,
+ maps = data.maps,
+ trainers = data.trainers,
+ encounters = data.encounters,
+ type_chart = data.type_chart,
+ })
+ else
+ files[prefix .. "rom-cache.complete"] = Contract.markerFor(version)
+ for _, path in ipairs(Contract.requiredFiles(version, true)) do
+ files[prefix .. path] = path:match("%.lua$") and writer.encode({}) or "fixture"
+ end
+ local p = data.gen3Pokemon
+ for _, key in ipairs({
+ "names",
+ "types",
+ "stats",
+ "abilities",
+ "abilityNames",
+ "meta",
+ "learnsets",
+ "evolutions",
+ "dex",
+ "moveNames",
+ }) do
+ local file = ({ abilityNames = "ability_names", moveNames = "move_names" })[key] or key
+ files[prefix .. "data/generated/gba/pokemon/" .. file .. ".lua"] =
+ writer.encode(p[({ meta = "_speciesMeta" })[key] or "_" .. key] or {})
+ end
+ files[prefix .. "data/generated/maps.lua"] = writer.encode(data.maps)
+ files[prefix .. "data/generated/gba/pokemon/battle_moves.lua"] =
+ writer.encode(data.gen3Moves._rom or {})
+ files[prefix .. "data/generated/gba/items/pack.lua"] = writer.encode({ items = data.gen3Items._byId })
+ end
+ else
+ local Contract = require("src.import.RomImporter").datasetContract(version)
+ files[prefix .. "rom-cache.complete"] = Contract.marker
+ for _, name in ipairs(Contract.modules) do
+ files[prefix .. "data/generated/" .. name .. ".lua"] = writer.encode(data[name] or {})
+ end
+ end
+ files[prefix .. "assets/generated/probe.png"] = "PNG"
+ local function read(path)
+ local f = assert(io.open(path, "rb"))
+ local s = f:read("*a")
+ f:close()
+ return s
+ end
+ for _, name in ipairs({ "manifest.json", "main.lua" }) do
+ files["mods/datasets/" .. name] = read(root .. "/mods/datasets/" .. name)
+ end
+ local fs = {
+ read = function(path)
+ return files[path]
+ end,
+ getInfo = function(path)
+ if files[path] then
+ return { type = "file", size = #files[path] }
+ end
+ if path == "mods" or path == "mods/datasets" then
+ return { type = "directory" }
+ end
+ end,
+ getDirectoryItems = function(path)
+ return path == "mods" and { "datasets" } or {}
+ end,
+ load = function(path)
+ return loadstring(assert(files[path]), "@" .. path)
+ end,
+ }
+ local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version })
+ loader._game = function()
+ error("dataset access must not bootstrap Game")
+ end
+ assert(loader:load(data), table.concat(loader.errors, "; "))
+ return { loader = loader, exports = assert(loader.exports.datasets), files = files, fs = fs, prefix = prefix }
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ local before = require("src.core.GameVersion").get()
+ local report, why = f.exports.inspect(version)
+ assert(report, why)
+ assert(require("src.core.GameVersion").get() == before)
+ assert(f.exports.open("not_a_version") == nil)
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/effects.lua b/tests/api3_contract/effects.lua
new file mode 100644
index 000000000..15430e813
--- /dev/null
+++ b/tests/api3_contract/effects.lua
@@ -0,0 +1,13 @@
+local M = {}
+function M.fixture(root, backend, version)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/effects%.lua$")
+ return dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "effects")
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ local report = f.exports.report()
+ assert(report.initial == "NO_ADDITIONAL_EFFECT" and report.final == "SLEEP_EFFECT")
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/flags.lua b/tests/api3_contract/flags.lua
new file mode 100644
index 000000000..ec36d5ddf
--- /dev/null
+++ b/tests/api3_contract/flags.lua
@@ -0,0 +1,72 @@
+local M = {}
+function M.fixture(root, backend, version)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/flags%.lua$")
+ local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "flags")
+ if backend == "gen1" and f.gen == 2 then
+ f.ow.events = require("src.world.gen2.Events").new()
+ elseif backend == "gen1" and f.gen == 3 then
+ package.loaded["src.core.game3.scripting.space"].store = { flags = f.game.save.flags, vars = {} }
+ end
+ if f.gen == 1 then
+ f.number, f.symbol = 0, "EVENT_FOLLOWED_OAK_INTO_LAB"
+ elseif f.gen == 2 then
+ f.number, f.symbol = 16, "EVENT_GOT_HM01_CUT"
+ else
+ f.symbol = "FLAG_SYS_POKEDEX_GET"
+ local catalog = dofile(
+ codeRoot .. "/../../src/mods/flag_names/" .. (version == "emerald" and "emerald" or "firered") .. ".lua"
+ )
+ for number, row in pairs(catalog.records) do
+ for _, alias in ipairs(row) do
+ if alias == f.symbol then
+ f.number = number
+ end
+ end
+ end
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ local report = { invalid = {} }
+ for _, name in ipairs({ -1, 0.5, math.huge, "UNKNOWN_NATIVE_FLAG", {}, false }) do
+ local value, why = f.exports.set(name, true)
+ assert(
+ value == nil
+ and (
+ why == "invalid or unknown flag"
+ or version == "crystal"
+ and type(name) == "string"
+ and why == "symbolic event flags unavailable for Crystal; use native ids"
+ ),
+ tostring(why)
+ )
+ assert(#f.exports.events() == 0)
+ report.invalid[#report.invalid + 1] = why
+ end
+ assert(f.exports.get(f.number) == false)
+ assert(f.exports.set(f.number, true))
+ assert(f.exports.get(f.number) == true)
+ assert(f.exports.set(f.number, true))
+ assert(#f.exports.events() == 1)
+ if version == "crystal" then
+ local value, why = f.exports.get(f.symbol)
+ assert(value == nil and why == "symbolic event flags unavailable for Crystal; use native ids")
+ report.symbol = why
+ else
+ assert(f.exports.get(f.symbol) == true)
+ assert(f.exports.set(f.symbol, false))
+ assert(f.exports.get(f.number) == false)
+ assert(#f.exports.events() == 2)
+ report.symbol = true
+ end
+ report.events = f.exports.events()
+ assert(f.exports.set("mod:quest", true))
+ assert(f.exports.get("mod.flags.quest") == true)
+ local value, why = f.exports.set("mod.other.quest", true)
+ assert(value == nil and why == "foreign mod flag")
+ report.private = true
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/invalidate.lua b/tests/api3_contract/invalidate.lua
new file mode 100644
index 000000000..56b11041a
--- /dev/null
+++ b/tests/api3_contract/invalidate.lua
@@ -0,0 +1,54 @@
+local M = {}
+function M.fixture(root, backend, version)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/invalidate%.lua$")
+ local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "invalidate")
+ local Runtime = require("src.mods.Runtime")
+ f.reloads, f.loads = 0, {}
+ function f:reload(id, why)
+ self.reloads = self.reloads + 1
+ self.loads[#self.loads + 1] = { id, why }
+ if self.reloadError then
+ error("reload probe", 0)
+ end
+ if self.ow.map.id == id then
+ self.ow.map = { id = id, def = self.game.data.maps[id] }
+ end
+ end
+ function f.ow:reloadMap(id, why)
+ f:reload(id, why)
+ Runtime.emit("map.reloaded", { mapId = id, reason = why })
+ end
+ function f.ow:reloadMapBadWarp()
+ f:reload(self.map.id, "invalidate")
+ end
+ if backend == "gen1" and f.gen == 3 then
+ function f.map.load(_, game, id, opts)
+ assert(game == f.game and opts.x == f.player.cellX and opts.facing == f.player.facing)
+ f:reload(id, "invalidate")
+ end
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ local report = { invalid = {} }
+ for _, id in ipairs({ "", "NO_SUCH_MAP", "HOME\n", 42, false }) do
+ local value, why = f.exports.request(id)
+ assert(value == nil and why == "unknown map")
+ assert(f.reloads == 0 and #f.exports.events() == 0)
+ report.invalid[#report.invalid + 1] = why
+ end
+ local old = f.ow.map
+ assert(f.exports.request(f.home))
+ assert(f.reloads == 1 and f.ow.map ~= old)
+ assert(f.player.cellX == 0 and f.player.cellY == 0 and f.player.facing == "left")
+ assert(#f.exports.events() == 1 and f.exports.events()[1].mapId == f.home)
+ report.active = f.exports.events()
+ f = M.fixture(root, backend, version)
+ assert(f.exports.request(f.dest))
+ assert(f.ow.map.id == f.home)
+ report.inactive = true -- Native inactive notification differences are not normalized.
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/mods/datasets/main.lua b/tests/api3_contract/mods/datasets/main.lua
new file mode 100644
index 000000000..f74b45333
--- /dev/null
+++ b/tests/api3_contract/mods/datasets/main.lua
@@ -0,0 +1,33 @@
+local mod = ...
+mod.exports.inspect = function(version)
+ local view, why = mod.datasets:open(version)
+ if not view then
+ return nil, why
+ end
+ local row = assert(view.content.pokemon:get("MEW"))
+ local before = row.baseStats.hp
+ row.baseStats.hp = 250
+ assert(view.content.pokemon:get("MEW").baseStats.hp == before)
+ assert(view.content.pokemon:has("MEW") and not view.content.pokemon:has("MISSING"))
+ local found = false
+ for id, value in view.content.pokemon:each() do
+ if id == "MEW" then
+ found = value.baseStats.hp == before
+ end
+ end
+ assert(found and view.content.pokemon.patch == nil and view.content.pokemon.register == nil)
+ return {
+ version = view.version,
+ generation = view.generation,
+ id = row.id,
+ hp = before,
+ special = view.content.pokemon:get("MEW").baseStats.special,
+ detached = true,
+ readonly = true,
+ path = type(view.assets:path("assets/generated/probe.png")) == "string",
+ size = view.assets:info("assets/generated/probe.png").size,
+ }
+end
+mod.exports.open = function(version)
+ return mod.datasets:open(version)
+end
diff --git a/tests/api3_contract/mods/datasets/manifest.json b/tests/api3_contract/mods/datasets/manifest.json
new file mode 100644
index 000000000..31fa4cc90
--- /dev/null
+++ b/tests/api3_contract/mods/datasets/manifest.json
@@ -0,0 +1 @@
+{"id":"datasets","name":"Read-only version datasets","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/edits/main.lua b/tests/api3_contract/mods/edits/main.lua
new file mode 100644
index 000000000..d26c5321e
--- /dev/null
+++ b/tests/api3_contract/mods/edits/main.lua
@@ -0,0 +1,37 @@
+-- Identical source on both engines; native layout stays in the driver.
+local mod = ...
+local world = mod.world
+assert(mod:supports("world", "toggleObject"))
+assert(mod:supports("world", "replaceBlock"))
+local ok, why = world:toggleObject("HOME", "NPC", false)
+assert(ok == nil and why == "no game")
+ok, why = world:replaceBlock(0, 0, 7)
+assert(ok == nil and why == "no game")
+local events, reenter, nested = {}, false, nil
+mod.events:on("world.object_toggled", function(ev)
+ events[#events + 1] = { kind = "object", mapId = ev.mapId, ref = ev.objName, visible = ev.visible }
+ if reenter then
+ nested = { world:replaceBlock(0, 0, 7) }
+ end
+end)
+mod.events:on("world.block_replaced", function(ev)
+ events[#events + 1] = { kind = "block", mapId = ev.mapId, x = ev.bx, y = ev.by, block = ev.block }
+end)
+mod.exports.toggle = function(...)
+ return world:toggleObject(...)
+end
+mod.exports.replace = function(...)
+ return world:replaceBlock(...)
+end
+mod.exports.read = function(...)
+ return world:activeBlockAt(...)
+end
+mod.exports.events = function()
+ return events
+end
+mod.exports.reenter = function(value)
+ reenter = value
+end
+mod.exports.nested = function()
+ return nested
+end
diff --git a/tests/api3_contract/mods/edits/manifest.json b/tests/api3_contract/mods/edits/manifest.json
new file mode 100644
index 000000000..8d0912a07
--- /dev/null
+++ b/tests/api3_contract/mods/edits/manifest.json
@@ -0,0 +1 @@
+{"id":"edits","name":"Portable World Edits","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/effects/main.lua b/tests/api3_contract/mods/effects/main.lua
new file mode 100644
index 000000000..a01378e23
--- /dev/null
+++ b/tests/api3_contract/mods/effects/main.lua
@@ -0,0 +1,33 @@
+local mod = ...
+local moves = mod.content.moves
+local report = { initial = assert(moves:get("POUND")).effect, effects = {} }
+for _, id in ipairs({
+ "SLEEP_EFFECT",
+ "BURN_SIDE_EFFECT1",
+ "BURN_SIDE_EFFECT2",
+ "SPECIAL_UP1_EFFECT",
+ "SPECIAL_DOWN_SIDE_EFFECT",
+ "NO_ADDITIONAL_EFFECT",
+}) do
+ assert(moves:patch("POUND", { effect = id }))
+ local row = assert(moves:get("POUND"))
+ assert(row.effect == id)
+ report.effects[#report.effects + 1] = row.effect
+ if mod.generation > 1 then
+ assert(moves:patch("POUND", { secondaryChance = 25 }))
+ assert(moves:get("POUND").secondaryChance == 25)
+ report.chance = 25
+ end
+ local detached = assert(moves:get("POUND"))
+ assert(moves:override("POUND", detached))
+ assert(moves:get("POUND").effect == id)
+end
+local value, why = moves:patch("POUND", { effect = "native:1:not_original" })
+assert(value == nil and type(why) == "string")
+assert(moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT")
+assert(moves:patch("POUND", { effect = "SLEEP_EFFECT" }))
+report.final = moves:get("POUND").effect
+report.preflight = true
+mod.exports.report = function()
+ return report
+end
diff --git a/tests/api3_contract/mods/effects/manifest.json b/tests/api3_contract/mods/effects/manifest.json
new file mode 100644
index 000000000..48e0d38a3
--- /dev/null
+++ b/tests/api3_contract/mods/effects/manifest.json
@@ -0,0 +1 @@
+{"id":"effects","name":"Portable move effects","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/flags/main.lua b/tests/api3_contract/mods/flags/main.lua
new file mode 100644
index 000000000..d15a56810
--- /dev/null
+++ b/tests/api3_contract/mods/flags/main.lua
@@ -0,0 +1,17 @@
+local mod = ...
+local world = mod.world
+local value, why = world:getFlag(1)
+assert(value == nil and why == "no game")
+local events = {}
+mod.events:on("flag.changed", function(ev)
+ events[#events + 1] = { name = ev.id or ev.name, value = ev.value }
+end)
+mod.exports.get = function(name)
+ return world:getFlag(name)
+end
+mod.exports.set = function(name, value)
+ return world:setFlag(name, value)
+end
+mod.exports.events = function()
+ return events
+end
diff --git a/tests/api3_contract/mods/flags/manifest.json b/tests/api3_contract/mods/flags/manifest.json
new file mode 100644
index 000000000..0394a27f6
--- /dev/null
+++ b/tests/api3_contract/mods/flags/manifest.json
@@ -0,0 +1 @@
+{"id":"flags","name":"Native flag contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/invalidate/main.lua b/tests/api3_contract/mods/invalidate/main.lua
new file mode 100644
index 000000000..efed5c918
--- /dev/null
+++ b/tests/api3_contract/mods/invalidate/main.lua
@@ -0,0 +1,15 @@
+local mod = ...
+local world = mod.world
+assert(mod:supports("world", "invalidateMap"))
+local value, why = world:invalidateMap("HOME")
+assert(value == nil and why == "no game")
+local events = {}
+mod.events:on("map.reloaded", function(ev)
+ events[#events + 1] = { mapId = ev.mapId, reason = ev.reason }
+end)
+mod.exports.request = function(...)
+ return world:invalidateMap(...)
+end
+mod.exports.events = function()
+ return events
+end
diff --git a/tests/api3_contract/mods/invalidate/manifest.json b/tests/api3_contract/mods/invalidate/manifest.json
new file mode 100644
index 000000000..4a1728907
--- /dev/null
+++ b/tests/api3_contract/mods/invalidate/manifest.json
@@ -0,0 +1 @@
+{"id":"invalidate","name":"Map invalidation contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/npcs/main.lua b/tests/api3_contract/mods/npcs/main.lua
new file mode 100644
index 000000000..696dfaafe
--- /dev/null
+++ b/tests/api3_contract/mods/npcs/main.lua
@@ -0,0 +1,28 @@
+-- Same public-only mod on both engines. Native timing stays in the driver.
+local mod = ...
+local world = mod.world
+for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do
+ assert(mod:supports("world", name))
+end
+local ok, why = world:npc("HOME", 1)
+assert(ok == nil and why == "no game")
+local calls, args = 0, nil
+mod.exports.spawn = function(...)
+ return world:spawnNpc(...)
+end
+mod.exports.remove = function(...)
+ return world:removeNpc(...)
+end
+mod.exports.npc = function(...)
+ return world:npc(...)
+end
+mod.exports.toggle = function(...)
+ return world:toggleObject(...)
+end
+mod.exports.done = function(...)
+ calls = calls + 1
+ args = { n = select("#", ...), first = (...), third = select(3, ...) }
+end
+mod.exports.result = function()
+ return { calls = calls, args = args }
+end
diff --git a/tests/api3_contract/mods/npcs/manifest.json b/tests/api3_contract/mods/npcs/manifest.json
new file mode 100644
index 000000000..16a8a3457
--- /dev/null
+++ b/tests/api3_contract/mods/npcs/manifest.json
@@ -0,0 +1 @@
+{"id":"npcs","name":"Portable NPC Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/portable/main.lua b/tests/api3_contract/mods/portable/main.lua
new file mode 100644
index 000000000..a428b942f
--- /dev/null
+++ b/tests/api3_contract/mods/portable/main.lua
@@ -0,0 +1,110 @@
+-- This exact entry and manifest run on both engines. No engine imports,
+-- format switches or engine-specific branches belong in this fixture.
+local mod = ...
+local world, battle = mod.world, mod.battle
+assert(type(world) == "table" and type(battle) == "table", "early handles")
+local function refused(value, reason)
+ assert(value == nil and type(reason) == "string" and #reason > 0, tostring(reason))
+end
+refused(world:party())
+refused(battle:snapshot())
+local no, why = mod:supports("world", "not_a_method")
+assert(no == false and type(why) == "string")
+assert(mod:supports("content", "pokemon"))
+assert(mod:supports("hooks", "evolution.check"))
+assert(mod:supports("events", "pokemon.received"))
+assert(mod:supports("world", "party"))
+-- Known API-3 names are registrable even when this host cannot execute them.
+mod.hooks:wrap("render.output", function(next, ctx)
+ return next(ctx)
+end)
+mod.events:on("checkpoint.restored", function() end)
+local c = mod.content
+assert(c.pokemon:has("MEW"))
+assert(c.maps:supports() and c.maps.available)
+assert(c.maps:has("ABSENT_MAP") == false)
+local map = assert(c.maps:get("PROBE"))
+map.width = 99
+assert(c.maps:get("PROBE").width == 2)
+for id, row in c.maps:each() do
+ if id == "api3Probe" then
+ row.value = 88
+ end
+end
+assert(c.maps:get("PROBE").width == 2)
+local before = assert(c.pokemon:get("MEW"))
+assert(before.id == "MEW" and before.baseStats.hp == 100)
+assert(before.level1Moves[1] == "POUND")
+assert(before.baseStats.special == 100)
+assert(c.moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT")
+before.baseStats.hp = 222
+assert(c.pokemon:get("MEW").baseStats.hp == 100, "detached read")
+assert(c.pokemon:patch("MEW", { baseStats = { hp = 101 } }))
+assert(c.moves:patch("POUND", { power = 45 }))
+assert(c.items:patch("POTION", { price = 123 }))
+local found = false
+for id, row in c.pokemon:each() do
+ if id == "MEW" then
+ found = true
+ assert(row.baseStats.hp == 101)
+ end
+end
+assert(found)
+local report = { species = "MEW", hp = 101, move = "POUND", power = 45, price = 123 }
+local function monCheck(mon)
+ assert(mon.species == "MEW" and mon.nickname == "Scout" and mon.name == "Scout")
+ assert(mon.status == "PSN", "short status")
+ assert(type(mon.shiny) == "boolean", "shiny presentation")
+ assert(mon.stats.special == 24 and mon.stats.hp == 40, "common stats")
+ assert(mon.moves[1].id == "POUND" and mon.moves[1].pp == 30 and mon.moves[1].maxPp == 35)
+end
+mod.events:on("pokemon.received", function(payload)
+ monCheck(payload.mon)
+ payload.mon.hp = 1
+ report.event = true
+end)
+mod.hooks:wrap("evolution.check", function(next, game, mon, evolution, trigger)
+ monCheck(mon)
+ mon.hp, mon.status = 27, "BRN"
+ mon.stats.special = 31
+ report.hook = true
+ report.special = 31
+ return next(game, mon, evolution, trigger)
+end)
+mod.events:on("mod.portable.probe", function(payload)
+ report.custom = payload.value
+end)
+mod.events:emit("mod.portable.probe", { value = 7 })
+mod.exports.exercise = function()
+ local first = assert(world:party())
+ monCheck(first[1])
+ first[1].hp = 1
+ assert(world:party()[1].hp == 30, "detached party")
+ report.party = true
+ report.status, report.shiny = first[1].status, first[1].shiny
+ assert(world:getFlag("mod:contract.flag") == false)
+ assert(world:setFlag("mod:contract.flag", true))
+ assert(world:getFlag("mod:contract.flag") == true)
+ assert(world:setFlag("mod:contract.flag", false))
+ assert(world:getFlag("mod:contract.flag") == false)
+ report.flags = true
+end
+mod.exports.snapshot = function()
+ local first = assert(battle:snapshot())
+ assert(first.kind == "wild" and first.prompt == "menu")
+ monCheck(first.player)
+ assert(first.player.hp == 30 and first.player.maxHp == 40)
+ assert(type(first.revision) == "number" and first.revision > 0)
+ first.player.hp = 1
+ assert(battle:snapshot().player.hp == 30, "detached battle snapshot")
+ refused(battle:submit({ id = 1, revision = first.revision, kind = "not_an_intent" }))
+ report.battle =
+ { kind = first.kind, prompt = first.prompt, species = first.player.species, status = first.player.status }
+end
+mod.exports.late = function()
+ local ok, value, reason = pcall(function()
+ return c.moves:patch("POUND", { power = 99 })
+ end)
+ assert(not ok or (value == nil and type(reason) == "string"), "frozen content")
+end
+mod.exports.world, mod.exports.battle, mod.exports.report = world, battle, report
diff --git a/tests/api3_contract/mods/portable/manifest.json b/tests/api3_contract/mods/portable/manifest.json
new file mode 100644
index 000000000..d98d5f821
--- /dev/null
+++ b/tests/api3_contract/mods/portable/manifest.json
@@ -0,0 +1 @@
+{"id":"portable","name":"Portable API contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/render_output/main.lua b/tests/api3_contract/mods/render_output/main.lua
new file mode 100644
index 000000000..0882775cd
--- /dev/null
+++ b/tests/api3_contract/mods/render_output/main.lua
@@ -0,0 +1,48 @@
+local mod = ...
+assert(mod:supports("hooks", "render.output"))
+assert(mod:supports("hooks", "render.output_enabled"))
+local enabled, handled, callback, gate = false, false, nil, nil
+local trace = {}
+mod.hooks:wrap("render.output_enabled", function(next)
+ if gate then
+ return gate()
+ end
+ if enabled then
+ return true
+ end
+ return next()
+end)
+mod.hooks:wrap("render.output", function(next, ctx)
+ assert(ctx.canvas and ctx.width == 320 and ctx.height == 288 and ctx.generation == mod.generation)
+ trace[#trace + 1] = {
+ width = ctx.width,
+ height = ctx.height,
+ gameWidth = ctx.gameWidth,
+ gameHeight = ctx.gameHeight,
+ gameX = ctx.gameX,
+ gameY = ctx.gameY,
+ scale = ctx.scale,
+ dpiX = ctx.dpiX,
+ dpiY = ctx.dpiY,
+ generation = ctx.generation,
+ }
+ if callback then
+ return callback(ctx)
+ end
+ if handled then
+ return true
+ end
+ return next(ctx)
+end)
+mod.exports.state = function(on, owns)
+ enabled, handled = on, owns
+end
+mod.exports.callback = function(fn)
+ callback = fn
+end
+mod.exports.gate = function(fn)
+ gate = fn
+end
+mod.exports.trace = function()
+ return trace
+end
diff --git a/tests/api3_contract/mods/render_output/manifest.json b/tests/api3_contract/mods/render_output/manifest.json
new file mode 100644
index 000000000..3a2878ae7
--- /dev/null
+++ b/tests/api3_contract/mods/render_output/manifest.json
@@ -0,0 +1 @@
+{"id":"render_output","name":"Render output contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/rollback/main.lua b/tests/api3_contract/mods/rollback/main.lua
new file mode 100644
index 000000000..daf6abe4a
--- /dev/null
+++ b/tests/api3_contract/mods/rollback/main.lua
@@ -0,0 +1,6 @@
+local mod = ...
+assert(mod.content.pokemon:patch("MEW", { baseStats = { hp = 199 } }))
+mod.events:on("mod.portable.probe", function()
+ error("failed subscription leaked")
+end)
+error("intentional API 3 rollback")
diff --git a/tests/api3_contract/mods/rollback/manifest.json b/tests/api3_contract/mods/rollback/manifest.json
new file mode 100644
index 000000000..ab532b029
--- /dev/null
+++ b/tests/api3_contract/mods/rollback/manifest.json
@@ -0,0 +1 @@
+{"id":"rollback","name":"Rollback contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","dependencies":["portable"]}
diff --git a/tests/api3_contract/mods/script_audio/main.lua b/tests/api3_contract/mods/script_audio/main.lua
new file mode 100644
index 000000000..2d21c725b
--- /dev/null
+++ b/tests/api3_contract/mods/script_audio/main.lua
@@ -0,0 +1,15 @@
+local mod = ...
+local done, success, reason = 0
+mod.exports.queue = function(rows)
+ return mod.world:queueScript(rows, {
+ onDone = function(ok, why)
+ done, success, reason = done + 1, ok, why
+ end,
+ })
+end
+mod.exports.result = function()
+ return { done = done, success = success, reason = reason }
+end
+mod.exports.get = function(name)
+ return mod.world:getFlag(name)
+end
diff --git a/tests/api3_contract/mods/script_audio/manifest.json b/tests/api3_contract/mods/script_audio/manifest.json
new file mode 100644
index 000000000..c4f8be8ba
--- /dev/null
+++ b/tests/api3_contract/mods/script_audio/manifest.json
@@ -0,0 +1 @@
+{"id":"script_audio","name":"Portable audio","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/script_battles/main.lua b/tests/api3_contract/mods/script_battles/main.lua
new file mode 100644
index 000000000..be627b337
--- /dev/null
+++ b/tests/api3_contract/mods/script_battles/main.lua
@@ -0,0 +1,15 @@
+local mod = ...
+local done, success = 0
+mod.exports.queue = function(rows)
+ return mod.world:queueScript(rows, {
+ onDone = function(ok)
+ done, success = done + 1, ok
+ end,
+ })
+end
+mod.exports.result = function()
+ return { done = done, success = success }
+end
+mod.exports.get = function(name)
+ return mod.world:getFlag(name)
+end
diff --git a/tests/api3_contract/mods/script_battles/manifest.json b/tests/api3_contract/mods/script_battles/manifest.json
new file mode 100644
index 000000000..33210dc51
--- /dev/null
+++ b/tests/api3_contract/mods/script_battles/manifest.json
@@ -0,0 +1 @@
+{"id":"script_battles","name":"Portable battles","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/script_dialogue_audio/main.lua b/tests/api3_contract/mods/script_dialogue_audio/main.lua
new file mode 100644
index 000000000..2d21c725b
--- /dev/null
+++ b/tests/api3_contract/mods/script_dialogue_audio/main.lua
@@ -0,0 +1,15 @@
+local mod = ...
+local done, success, reason = 0
+mod.exports.queue = function(rows)
+ return mod.world:queueScript(rows, {
+ onDone = function(ok, why)
+ done, success, reason = done + 1, ok, why
+ end,
+ })
+end
+mod.exports.result = function()
+ return { done = done, success = success, reason = reason }
+end
+mod.exports.get = function(name)
+ return mod.world:getFlag(name)
+end
diff --git a/tests/api3_contract/mods/script_dialogue_audio/manifest.json b/tests/api3_contract/mods/script_dialogue_audio/manifest.json
new file mode 100644
index 000000000..d81be00df
--- /dev/null
+++ b/tests/api3_contract/mods/script_dialogue_audio/manifest.json
@@ -0,0 +1 @@
+{"id":"script_dialogue_audio","name":"Portable dialogue audio","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/script_fields/main.lua b/tests/api3_contract/mods/script_fields/main.lua
new file mode 100644
index 000000000..2d21c725b
--- /dev/null
+++ b/tests/api3_contract/mods/script_fields/main.lua
@@ -0,0 +1,15 @@
+local mod = ...
+local done, success, reason = 0
+mod.exports.queue = function(rows)
+ return mod.world:queueScript(rows, {
+ onDone = function(ok, why)
+ done, success, reason = done + 1, ok, why
+ end,
+ })
+end
+mod.exports.result = function()
+ return { done = done, success = success, reason = reason }
+end
+mod.exports.get = function(name)
+ return mod.world:getFlag(name)
+end
diff --git a/tests/api3_contract/mods/script_fields/manifest.json b/tests/api3_contract/mods/script_fields/manifest.json
new file mode 100644
index 000000000..c50380150
--- /dev/null
+++ b/tests/api3_contract/mods/script_fields/manifest.json
@@ -0,0 +1 @@
+{"id":"script_fields","name":"Portable script fields","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/script_flags/main.lua b/tests/api3_contract/mods/script_flags/main.lua
new file mode 100644
index 000000000..3a96b1e6b
--- /dev/null
+++ b/tests/api3_contract/mods/script_flags/main.lua
@@ -0,0 +1,20 @@
+local mod = ...
+local world = mod.world
+assert(mod:supports("world", "queueScript"))
+local done, success = 0
+mod.exports.queue = function(rows)
+ return world:queueScript(rows, {
+ onDone = function(ok)
+ done, success = done + 1, ok
+ end,
+ })
+end
+mod.exports.set = function(name, value)
+ return world:setFlag(name, value)
+end
+mod.exports.get = function(name)
+ return world:getFlag(name)
+end
+mod.exports.result = function()
+ return { done = done, success = success }
+end
diff --git a/tests/api3_contract/mods/script_flags/manifest.json b/tests/api3_contract/mods/script_flags/manifest.json
new file mode 100644
index 000000000..2dcca5648
--- /dev/null
+++ b/tests/api3_contract/mods/script_flags/manifest.json
@@ -0,0 +1 @@
+{"id":"script_flags","name":"Portable script flags","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/script_gifts/main.lua b/tests/api3_contract/mods/script_gifts/main.lua
new file mode 100644
index 000000000..4c1039daf
--- /dev/null
+++ b/tests/api3_contract/mods/script_gifts/main.lua
@@ -0,0 +1,16 @@
+local mod = ...
+local world = mod.world
+local done, success = 0
+mod.exports.queue = function(rows)
+ return world:queueScript(rows, {
+ onDone = function(ok)
+ done, success = done + 1, ok
+ end,
+ })
+end
+mod.exports.get = function(name)
+ return world:getFlag(name)
+end
+mod.exports.result = function()
+ return { done = done, success = success }
+end
diff --git a/tests/api3_contract/mods/script_gifts/manifest.json b/tests/api3_contract/mods/script_gifts/manifest.json
new file mode 100644
index 000000000..7129948d2
--- /dev/null
+++ b/tests/api3_contract/mods/script_gifts/manifest.json
@@ -0,0 +1 @@
+{"id":"script_gifts","name":"Portable gifts","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/script_motion/main.lua b/tests/api3_contract/mods/script_motion/main.lua
new file mode 100644
index 000000000..be627b337
--- /dev/null
+++ b/tests/api3_contract/mods/script_motion/main.lua
@@ -0,0 +1,15 @@
+local mod = ...
+local done, success = 0
+mod.exports.queue = function(rows)
+ return mod.world:queueScript(rows, {
+ onDone = function(ok)
+ done, success = done + 1, ok
+ end,
+ })
+end
+mod.exports.result = function()
+ return { done = done, success = success }
+end
+mod.exports.get = function(name)
+ return mod.world:getFlag(name)
+end
diff --git a/tests/api3_contract/mods/script_motion/manifest.json b/tests/api3_contract/mods/script_motion/manifest.json
new file mode 100644
index 000000000..f6452d302
--- /dev/null
+++ b/tests/api3_contract/mods/script_motion/manifest.json
@@ -0,0 +1 @@
+{"id":"script_motion","name":"Portable motion","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/script_shops/main.lua b/tests/api3_contract/mods/script_shops/main.lua
new file mode 100644
index 000000000..2d21c725b
--- /dev/null
+++ b/tests/api3_contract/mods/script_shops/main.lua
@@ -0,0 +1,15 @@
+local mod = ...
+local done, success, reason = 0
+mod.exports.queue = function(rows)
+ return mod.world:queueScript(rows, {
+ onDone = function(ok, why)
+ done, success, reason = done + 1, ok, why
+ end,
+ })
+end
+mod.exports.result = function()
+ return { done = done, success = success, reason = reason }
+end
+mod.exports.get = function(name)
+ return mod.world:getFlag(name)
+end
diff --git a/tests/api3_contract/mods/script_shops/manifest.json b/tests/api3_contract/mods/script_shops/manifest.json
new file mode 100644
index 000000000..d53ea01c5
--- /dev/null
+++ b/tests/api3_contract/mods/script_shops/manifest.json
@@ -0,0 +1 @@
+{"id":"script_shops","name":"Portable shops","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/script_state/main.lua b/tests/api3_contract/mods/script_state/main.lua
new file mode 100644
index 000000000..4c1039daf
--- /dev/null
+++ b/tests/api3_contract/mods/script_state/main.lua
@@ -0,0 +1,16 @@
+local mod = ...
+local world = mod.world
+local done, success = 0
+mod.exports.queue = function(rows)
+ return world:queueScript(rows, {
+ onDone = function(ok)
+ done, success = done + 1, ok
+ end,
+ })
+end
+mod.exports.get = function(name)
+ return world:getFlag(name)
+end
+mod.exports.result = function()
+ return { done = done, success = success }
+end
diff --git a/tests/api3_contract/mods/script_state/manifest.json b/tests/api3_contract/mods/script_state/manifest.json
new file mode 100644
index 000000000..3f29a475f
--- /dev/null
+++ b/tests/api3_contract/mods/script_state/manifest.json
@@ -0,0 +1 @@
+{"id":"script_state","name":"Portable state actions","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/script_trainers/main.lua b/tests/api3_contract/mods/script_trainers/main.lua
new file mode 100644
index 000000000..be627b337
--- /dev/null
+++ b/tests/api3_contract/mods/script_trainers/main.lua
@@ -0,0 +1,15 @@
+local mod = ...
+local done, success = 0
+mod.exports.queue = function(rows)
+ return mod.world:queueScript(rows, {
+ onDone = function(ok)
+ done, success = done + 1, ok
+ end,
+ })
+end
+mod.exports.result = function()
+ return { done = done, success = success }
+end
+mod.exports.get = function(name)
+ return mod.world:getFlag(name)
+end
diff --git a/tests/api3_contract/mods/script_trainers/manifest.json b/tests/api3_contract/mods/script_trainers/manifest.json
new file mode 100644
index 000000000..66dd1a92c
--- /dev/null
+++ b/tests/api3_contract/mods/script_trainers/manifest.json
@@ -0,0 +1 @@
+{"id":"script_trainers","name":"Portable trainer battles","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/script_travel/main.lua b/tests/api3_contract/mods/script_travel/main.lua
new file mode 100644
index 000000000..be627b337
--- /dev/null
+++ b/tests/api3_contract/mods/script_travel/main.lua
@@ -0,0 +1,15 @@
+local mod = ...
+local done, success = 0
+mod.exports.queue = function(rows)
+ return mod.world:queueScript(rows, {
+ onDone = function(ok)
+ done, success = done + 1, ok
+ end,
+ })
+end
+mod.exports.result = function()
+ return { done = done, success = success }
+end
+mod.exports.get = function(name)
+ return mod.world:getFlag(name)
+end
diff --git a/tests/api3_contract/mods/script_travel/manifest.json b/tests/api3_contract/mods/script_travel/manifest.json
new file mode 100644
index 000000000..f07cf280a
--- /dev/null
+++ b/tests/api3_contract/mods/script_travel/manifest.json
@@ -0,0 +1 @@
+{"id":"script_travel","name":"Portable travel","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/script_world/main.lua b/tests/api3_contract/mods/script_world/main.lua
new file mode 100644
index 000000000..56fd85ea7
--- /dev/null
+++ b/tests/api3_contract/mods/script_world/main.lua
@@ -0,0 +1,26 @@
+local mod = ...
+local world = mod.world
+local done, success = 0
+local events = {}
+mod.events:on("world.object_toggled", function(ev)
+ events[#events + 1] = { kind = "object", mapId = ev.mapId, ref = ev.objName, visible = ev.visible }
+end)
+mod.events:on("world.block_replaced", function(ev)
+ events[#events + 1] = { kind = "block", mapId = ev.mapId, x = ev.bx, y = ev.by, value = ev.block }
+end)
+mod.exports.queue = function(rows)
+ return world:queueScript(rows, {
+ onDone = function(ok)
+ done, success = done + 1, ok
+ end,
+ })
+end
+mod.exports.result = function()
+ return { done = done, success = success, events = events }
+end
+mod.exports.get = function(name)
+ return world:getFlag(name)
+end
+mod.exports.read = function(...)
+ return world:activeBlockAt(...)
+end
diff --git a/tests/api3_contract/mods/script_world/manifest.json b/tests/api3_contract/mods/script_world/manifest.json
new file mode 100644
index 000000000..38990279c
--- /dev/null
+++ b/tests/api3_contract/mods/script_world/manifest.json
@@ -0,0 +1 @@
+{"id":"script_world","name":"Portable world edits","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/viewport/main.lua b/tests/api3_contract/mods/viewport/main.lua
new file mode 100644
index 000000000..08f76276f
--- /dev/null
+++ b/tests/api3_contract/mods/viewport/main.lua
@@ -0,0 +1,68 @@
+local mod = ...
+assert(mod:supports("hooks", "render.viewport"))
+assert(mod:supports("hooks", "render.window"))
+assert(mod:supports("hooks", "input.pointer"))
+local request, canvasRef, windowAction = nil, nil, nil
+local trace = { viewport = {}, window = {}, pointer = {} }
+mod.hooks:wrap("render.viewport", function(next, ctx)
+ trace.viewport[#trace.viewport + 1] = {
+ width = ctx.width,
+ height = ctx.height,
+ pixelWidth = ctx.pixelWidth,
+ pixelHeight = ctx.pixelHeight,
+ dpiX = ctx.dpiX,
+ dpiY = ctx.dpiY,
+ generation = ctx.generation,
+ }
+ if request then
+ return request
+ end
+ return next(ctx)
+end)
+mod.hooks:wrap("render.window", function(next, game, ctx)
+ assert(ctx.canvas == canvasRef)
+ trace.window[#trace.window + 1] = {
+ x = ctx.x,
+ y = ctx.y,
+ width = ctx.width,
+ height = ctx.height,
+ windowWidth = ctx.windowWidth,
+ windowHeight = ctx.windowHeight,
+ dpiX = ctx.dpiX,
+ dpiY = ctx.dpiY,
+ generation = ctx.generation,
+ }
+ if windowAction then
+ return windowAction(next, game, ctx)
+ end
+ return next(game, ctx)
+end)
+mod.hooks:wrap("input.pointer", function(next, game, ctx)
+ trace.pointer[#trace.pointer + 1] = {
+ phase = ctx.phase,
+ source = ctx.source,
+ id = ctx.id,
+ x = ctx.x,
+ y = ctx.y,
+ gameX = ctx.gameX,
+ gameY = ctx.gameY,
+ insideGame = ctx.insideGame,
+ dx = ctx.dx,
+ dy = ctx.dy,
+ pressure = ctx.pressure,
+ button = ctx.button,
+ }
+ return next(game, ctx)
+end)
+mod.exports.request = function(value)
+ request = value
+end
+mod.exports.canvas = function(value)
+ canvasRef = value
+end
+mod.exports.window = function(fn)
+ windowAction = fn
+end
+mod.exports.trace = function()
+ return trace
+end
diff --git a/tests/api3_contract/mods/viewport/manifest.json b/tests/api3_contract/mods/viewport/manifest.json
new file mode 100644
index 000000000..70047d193
--- /dev/null
+++ b/tests/api3_contract/mods/viewport/manifest.json
@@ -0,0 +1 @@
+{"id":"viewport","name":"Viewport/window contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/warp/main.lua b/tests/api3_contract/mods/warp/main.lua
new file mode 100644
index 000000000..2dd8de861
--- /dev/null
+++ b/tests/api3_contract/mods/warp/main.lua
@@ -0,0 +1,23 @@
+-- Identical public-only mod on both engines: the harness owns native timing.
+local mod = ...
+local world = mod.world
+assert(mod:supports("world", "warpTo"))
+local early, reason = world:warpTo("DEST", 0, 0)
+assert(early == nil and reason == "no game")
+local calls, args, events = 0, nil, {}
+mod.events:on("map.entered", function(ev)
+ events[#events + 1] = { mapId = ev.mapId, fromMapId = ev.fromMapId, via = ev.via }
+end)
+mod.exports.request = function(...)
+ return world:warpTo(...)
+end
+mod.exports.notify = function(...)
+ calls = calls + 1
+ args = { n = select("#", ...), first = (...), third = select(3, ...) }
+end
+mod.exports.results = function()
+ return { calls = calls, args = args, events = events }
+end
+mod.exports.current = function()
+ return world:current()
+end
diff --git a/tests/api3_contract/mods/warp/manifest.json b/tests/api3_contract/mods/warp/manifest.json
new file mode 100644
index 000000000..6bc2f80ea
--- /dev/null
+++ b/tests/api3_contract/mods/warp/manifest.json
@@ -0,0 +1 @@
+{"id":"warp","name":"Portable Warp Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/mods/wild_battle/main.lua b/tests/api3_contract/mods/wild_battle/main.lua
new file mode 100644
index 000000000..ae198c01d
--- /dev/null
+++ b/tests/api3_contract/mods/wild_battle/main.lua
@@ -0,0 +1,16 @@
+local mod = ...
+local world = mod.world
+assert(mod:supports("world", "startWildBattle"))
+local ok, why = world:startWildBattle("MEW", 5)
+assert(ok == nil and why == "no game")
+local calls, args = 0
+mod.exports.request = function(...)
+ return world:startWildBattle(...)
+end
+mod.exports.notify = function(...)
+ calls = calls + 1
+ args = { n = select("#", ...), first = (...), third = select(3, ...) }
+end
+mod.exports.result = function()
+ return { calls = calls, args = args }
+end
diff --git a/tests/api3_contract/mods/wild_battle/manifest.json b/tests/api3_contract/mods/wild_battle/manifest.json
new file mode 100644
index 000000000..492c88378
--- /dev/null
+++ b/tests/api3_contract/mods/wild_battle/manifest.json
@@ -0,0 +1 @@
+{"id":"wild_battle","name":"Wild battle contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}
diff --git a/tests/api3_contract/npcs.lua b/tests/api3_contract/npcs.lua
new file mode 100644
index 000000000..888ad92e5
--- /dev/null
+++ b/tests/api3_contract/npcs.lua
@@ -0,0 +1,321 @@
+-- Real loaders/native public adapters with native actor/map/timing seams.
+local M = {}
+local unpack = table.unpack or unpack
+function M.fixture(root, backend, version, modId)
+ local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/npcs%.lua$")
+ local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "npcs")
+ local Runtime = require("src.mods.Runtime")
+ love = love or require("tests.love_stub")
+ -- Render construction has no bearing on the public state contract.
+ local Renderer = require("src.render.SpriteRenderer")
+ local savedRenderer = Renderer.new
+ Renderer.new = function(def)
+ return { def = def, big = false }
+ end
+ f.restoreRenderer = function()
+ Renderer.new = savedRenderer
+ end
+ local game, ow = f.game, f.ow
+ game.data.sprites = {
+ PROBE = { image = "fixture.png", frames = 6, walker = true },
+ ALT = { image = "alt.png", frames = 6, walker = true },
+ }
+ game.data.gen2Sprites = game.data.sprites
+ ow.game, ow.sprites = game, game.data.sprites
+ local tiles = {
+ blocks = { [2] = {} },
+ walkable = { 0 },
+ collision = { [2] = { 0, 0, 0, 0 } },
+ blockCells = f.gen == 3 and 1 or 2,
+ blockTiles = f.gen == 3 and 2 or 4,
+ }
+ f.tiles = tiles
+ for i = 1, 16 do
+ tiles.blocks[2][i] = 0
+ end
+ if not (backend == "gen1" and f.gen == 2) then
+ tiles.collision = nil
+ end
+ for _, id in ipairs({ f.home, f.dest }) do
+ local def = game.data.maps[id]
+ def.width, def.height, def.objects, def.blocks = 8, 8, {}, {}
+ for i = 1, 64 do
+ def.blocks[i] = 1
+ end
+ end
+ local base = {
+ index = 1,
+ localId = 1,
+ name = "IMPORTED",
+ sprite = "PROBE",
+ x = 6,
+ y = 6,
+ movement = "STAY",
+ range = "DOWN",
+ }
+ game.data.maps[f.home].objects = { base }
+ if backend == "gen1" and f.gen == 2 then
+ ow.map = require("src.world.gen2.Map").new(game.data.maps[f.home], tiles)
+ elseif not (backend == "gen1" and f.gen == 3) then
+ ow.map = require("src.world.Map").new(game.data.maps[f.home], tiles)
+ end
+ ow.npcs, ow.entities, ow.npcPool = {}, { f.player }, {}
+ f.player.cellX, f.player.cellY = 7, 7
+ function f:make(id, row)
+ local actor
+ if backend == "gen1" and self.gen == 2 then
+ actor = require("src.world.gen2.Npc").new(id, row, game.data.sprites[row.sprite])
+ elseif backend == "gen1" and self.gen == 3 then
+ actor = require("src.core.game3.object_prepare").instance(row, { mapId = id })
+ actor.id, actor.publicId = id .. "_obj_" .. row.index, id .. "_obj_" .. row.index
+ else
+ actor = require("src.world.NPC").new(game.data, id, row)
+ end
+ function actor:queueApiMoves(_, callback)
+ f:begin(self, "march", nil, 1, callback)
+ return true
+ end
+ return actor
+ end
+ f.imported = f:make(f.home, base)
+ ow.npcs[1] = f.imported
+ ow.entities[2] = f.imported
+ ow.npcPool[f.imported.id] = f.imported
+ function f:begin(actor, kind, dir, count, callback)
+ self.pending = { actor = actor, kind = kind, dir = dir, count = count, callback = callback }
+ self.busy = true
+ actor.moving = true
+ if backend == "gen2" or self.gen == 1 then
+ local entry =
+ { entity = actor, dir = dir, remaining = count, onDone = callback, inPlace = kind == "march" }
+ ow.scriptMoves[#ow.scriptMoves + 1] = entry
+ end
+ if self.synchronous then
+ self:complete("sync", nil, "tail")
+ end
+ if self.failMovement then
+ return false, "movement refused"
+ end
+ return true
+ end
+ function ow:scriptMove(actor, dir, count, callback)
+ return f:begin(actor, "move", dir, count, callback)
+ end
+ function ow:marchInPlace(actor, callback)
+ return f:begin(actor, "march", nil, 1, callback)
+ end
+ function ow:beginMovement(objectId, bytes, callback)
+ local actor
+ for _, candidate in ipairs(self.npcs) do
+ if candidate.def.index + 1 == objectId then
+ actor = candidate
+ end
+ end
+ local dir = ({ [0x0C] = "down", [0x0D] = "up", [0x0E] = "left", [0x0F] = "right" })[bytes[1]]
+ return f:begin(assert(actor), "move", assert(dir), #bytes - 1, callback)
+ end
+ function ow:applySpritePalette(actor)
+ f.paletteActor = actor
+ end
+ function f:complete(...)
+ local pending = self.pending
+ assert(pending, "no pending move")
+ local actor = pending.actor
+ if pending.kind == "move" then
+ local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[pending.dir]
+ actor.cellX, actor.cellY =
+ actor.cellX + delta[1] * pending.count, actor.cellY + delta[2] * pending.count
+ end
+ actor.moving, actor.marching = false, false
+ actor.px, actor.py = actor.cellX * 16, actor.cellY * 16
+ self.busy = false
+ ow.scriptMoves = {}
+ if pending.callback then
+ pending.callback(...)
+ end
+ end
+ function f:spawn(id, row, owner)
+ local def = assert(game.data.maps[id])
+ local index = 0
+ for _, existing in ipairs(def.objects) do
+ index = math.max(index, existing.index)
+ end
+ row.index, row.localId, row.runtime, row.owner = index + 1, index + 1, true, owner
+ def.objects[#def.objects + 1] = row
+ local publicId = id .. "_obj_" .. row.index
+ if id == self.home then
+ local actor = self:make(id, row)
+ ow.npcs[#ow.npcs + 1] = actor
+ ow.entities[#ow.entities + 1] = actor
+ ow.npcPool[publicId] = actor
+ if self.byId then
+ self.byId[row.localId] = actor
+ self.order[#self.order + 1] = row.localId
+ end
+ end
+ return publicId
+ end
+ function ow:addRuntimeObject(id, row, owner)
+ return f:spawn(id, row, owner)
+ end
+ function f:remove(id, owner)
+ for _, def in pairs(game.data.maps) do
+ for i, row in ipairs(def.objects or {}) do
+ if row.runtime and def.id .. "_obj_" .. row.index == id then
+ if row.owner ~= owner then
+ return nil, "not owned by " .. tostring(owner)
+ end
+ table.remove(def.objects, i)
+ for _, list in ipairs({ ow.npcs, ow.entities }) do
+ for j = #list, 1, -1 do
+ if list[j].id == id then
+ table.remove(list, j)
+ end
+ end
+ end
+ ow.npcPool[id] = nil
+ if self.byId then
+ self.byId[row.localId] = nil
+ for j = #self.order, 1, -1 do
+ if self.order[j] == row.localId then
+ table.remove(self.order, j)
+ end
+ end
+ end
+ return true
+ end
+ end
+ end
+ return nil, "no runtime object " .. tostring(id)
+ end
+ function ow:removeRuntimeObject(id, owner)
+ return f:remove(id, owner)
+ end
+ if backend == "gen1" and f.gen == 3 then
+ f.byId, f.order = { [1] = f.imported }, { 1 }
+ package.loaded["src.core.game3.objects"] = {
+ _byId = f.byId,
+ _order = f.order,
+ addRuntimeObject = function(id, row, owner)
+ return f:spawn(id, row, owner)
+ end,
+ removeRuntimeObject = function(id, owner)
+ return f:remove(id, owner)
+ end,
+ scriptFace = function(actor, dir)
+ actor.facing = dir
+ end,
+ scriptStep = function(actor, dir)
+ local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[dir]
+ actor.facing = dir
+ actor.targetX = actor.cellX + delta[1]
+ actor.targetY = actor.cellY + delta[2]
+ actor.moving = true
+ end,
+ startTrack = function(id, actions, callback)
+ local count = 0
+ for _, action in ipairs(actions) do
+ if action.kind == "step" then
+ count = count + 1
+ end
+ end
+ return f:begin(f.byId[id], count > 0 and "move" or "march", actions[1].dir, count, callback)
+ end,
+ }
+ package.loaded["src.core.game3.collision"] = {
+ canEnter = function()
+ return true
+ end,
+ isWater = function()
+ return false
+ end,
+ }
+ end
+ function f:update()
+ return self.loader.hooks:call("input.step", function()
+ return "updated", nil, "tail"
+ end, game, 0)
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ local ex = f.exports
+ local report = { invalid = {}, actions = {} }
+ local function refused(value, why)
+ assert(value == nil and type(why) == "string" and #why > 0, tostring(why))
+ end
+ refused(ex.spawn("UNKNOWN", { x = 0, y = 0 }))
+ refused(ex.spawn(f.home, { x = -1, y = 0 }))
+ refused(ex.spawn(f.home, { x = 0.5, y = 0 }))
+ refused(ex.npc(f.home, false))
+ refused(ex.remove(false))
+ assert(#f.game.data.maps[f.home].objects == 1 and #f.ow.npcs == 1)
+ local definition = { name = "RUNTIME", sprite = "PROBE", x = 0, y = 0, movement = "STAY", range = "DOWN" }
+ local id = assert(ex.spawn(f.home, definition))
+ local handle = assert(ex.npc(f.home, id))
+ assert(handle.id == id and ex.npc(f.home, "RUNTIME").id == id)
+ definition.x, definition.sprite = 99, "changed"
+ local actor = f.ow.npcPool[id]
+ assert(actor.cellX == 0 and actor.def.sprite == "PROBE")
+ for _, method in ipairs({ "scriptMove", "face", "stepNow", "canStep" }) do
+ refused(handle[method](handle, "north"))
+ end
+ refused(handle:scriptMove("right", 0))
+ refused(handle:scriptMove("right", 1025))
+ refused(handle:scriptMove("right", 1, false))
+ refused(handle:marchInPlace(false))
+ refused(handle:placeAt(-1, 0))
+ refused(handle:setPassable("yes"))
+ refused(handle:setAppearance(""))
+ assert(handle:face("right"))
+ local allowed, blocked = handle:canStep("right")
+ assert(allowed == true, tostring(allowed) .. ": " .. tostring(blocked))
+ assert(handle:setPassable(true) and actor.passable == true)
+ assert(handle:setAppearance("ALT"))
+ assert(handle:position() == 0 and handle:isMoving() == false)
+ assert(handle:stepNow("right"))
+ assert(handle:isMoving() == true and actor.targetX == 1)
+ refused(handle:stepNow("right"))
+ assert(handle:placeAt(2, 2, "up"))
+ assert(handle:isMoving() == false and actor.px == 32 and actor.py == 32)
+ local x, y = handle:position()
+ assert(x == 2 and y == 2)
+ assert(handle:scriptMove("right", 2, ex.done))
+ assert(ex.result().calls == 0)
+ f:complete("done", nil, "tail")
+ x, y = handle:position()
+ assert(x == 4 and y == 2)
+ assert(ex.result().calls == 1 and ex.result().args.n == 3 and ex.result().args.third == "tail")
+ assert(handle:marchInPlace(ex.done))
+ f:complete("march")
+ x, y = handle:position()
+ assert(x == 4 and y == 2 and ex.result().calls == 2)
+ assert(handle:scriptMove("left"))
+ f:complete()
+ assert(handle:position() == 3 and ex.result().calls == 2)
+ assert(handle:marchInPlace())
+ f:complete()
+ assert(handle:placeAt(4, 2))
+ local imported = assert(ex.npc(f.home, "IMPORTED"))
+ refused(ex.remove(imported.id))
+ local other = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other")
+ refused(ex.remove(other))
+ assert(ex.remove(id))
+ refused(ex.npc(f.home, id))
+ assert(ex.npc(f.home, other) and ex.npc(f.home, imported.id))
+ report.actions = {
+ x = x,
+ y = y,
+ passable = actor.passable,
+ callbacks = ex.result().calls,
+ definitionCopied = true,
+ ownedRemoval = true,
+ }
+ report.invalid = true
+ assert(#f.loader.errors == 0)
+ f.restoreRenderer()
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/render_output.lua b/tests/api3_contract/render_output.lua
new file mode 100644
index 000000000..8e3dfce54
--- /dev/null
+++ b/tests/api3_contract/render_output.lua
@@ -0,0 +1,49 @@
+local M = {}
+function M.fixture(root, backend, version)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/render_output%.lua$")
+ local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "render_output")
+ love = love or require("tests.love_stub")
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ local Runtime = require("src.mods.Runtime")
+ local Output = backend == "gen2" and require("src.render.Output")
+ or {
+ enabled = function()
+ return Runtime.wantsHook("render.output")
+ and Runtime.call("render.output_enabled", function()
+ return false
+ end) == true
+ end,
+ draw = function(ctx)
+ return Runtime.call("render.output", function()
+ return false
+ end, ctx) == true
+ end,
+ }
+ assert(not Output.enabled())
+ local ctx = {
+ canvas = newproxy(true),
+ width = 320,
+ height = 288,
+ gameX = 0,
+ gameY = 0,
+ gameWidth = 320,
+ gameHeight = 288,
+ scale = 2,
+ dpiX = 1,
+ dpiY = 1,
+ generation = f.gen,
+ }
+ f.exports.state(true, true)
+ assert(Output.enabled() and Output.draw(ctx))
+ f.exports.state(true, false)
+ assert(Output.enabled() and not Output.draw(ctx))
+ f.exports.state(false, true)
+ assert(not Output.enabled())
+ local report = { trace = f.exports.trace(), disabled = true, fallback = true }
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua
new file mode 100644
index 000000000..c9fe68f51
--- /dev/null
+++ b/tests/api3_contract/run.lua
@@ -0,0 +1,257 @@
+-- Shared harness; only fixture construction knows the native engine layout.
+local M = {}
+local function read(path)
+ local f = assert(io.open(path, "rb"))
+ local body = f:read("*a")
+ f:close()
+ return body
+end
+local function dataFor(backend, generation)
+ if backend == "gen1" and generation == 3 then
+ local fixture = require("tests.modkit.sdk").gen3Data()
+ fixture.maps.PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } }
+ return fixture
+ end
+ local stats = { hp = 100, attack = 100, defense = 100, speed = 100, special = 100 }
+ local mon = {
+ id = "MEW",
+ name = "MEW",
+ dex = 151,
+ types = { "PSYCHIC" },
+ baseStats = stats,
+ catchRate = 45,
+ baseExp = 64,
+ growthRate = "MEDIUM_FAST",
+ spriteFront = "fixture.png",
+ spriteBack = "fixture.png",
+ frontSize = 5,
+ level1Moves = { "POUND" },
+ learnset = {},
+ evolutions = {},
+ }
+ local effect = "NO_ADDITIONAL_EFFECT"
+ if generation > 1 then
+ stats.spatk, stats.spdef = 100, 100
+ end
+ if backend == "gen1" and generation == 2 then
+ stats.special, stats.spatk, stats.spdef = nil, nil, nil
+ stats.specialAttack, stats.specialDefense = 100, 100
+ mon.level1Moves, mon.learnset, mon.frontSize = nil, nil, nil
+ mon.levelMoves, mon.picSize = { { level = 1, move = "POUND" } }, 5
+ effect = "EFFECT_NORMAL_HIT"
+ end
+ local pokemon = { MEW = mon }
+ if backend == "gen1" and generation == 2 then
+ pokemon.growthRates =
+ { MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 } }
+ end
+ return {
+ pokemon = pokemon,
+ moves = {
+ POUND = {
+ id = "POUND",
+ name = "POUND",
+ type = "NORMAL",
+ power = 40,
+ accuracy = 100,
+ pp = 35,
+ effect = effect,
+ },
+ },
+ items = { POTION = { id = "POTION", name = "POTION", price = 300, tossable = true } },
+ maps = { PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } },
+ trainers = {},
+ encounters = {},
+ audio = {},
+ type_chart = { types = { NORMAL = {}, PSYCHIC = {} }, matchups = {} },
+ gen2Maps = backend == "gen1" and generation == 2 and {
+ PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } },
+ } or nil,
+ }
+end
+local function nativeMon(backend, gen)
+ local stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24 }
+ local mon = {
+ species = "MEW",
+ nickname = "Scout",
+ name = "Native name",
+ level = 20,
+ hp = 30,
+ status = "PSN",
+ stats = stats,
+ moves = { { id = "POUND", pp = 30, ppUps = 0 } },
+ }
+ if gen > 1 then
+ stats.spatk, stats.spdef = 24, 25
+ end
+ if backend == "gen1" and gen > 1 then
+ stats.special, stats.spatk, stats.spdef = nil, nil, nil
+ stats.specialAttack, stats.specialDefense = 24, 25
+ mon.status = gen == 2 and "poison" or 8
+ if gen == 3 then
+ mon.species, mon.moves[1].id = 151, 1
+ end
+ end
+ return mon
+end
+function M.run(root, backend, version, failing, scaffold)
+ local Loader = require("src.mods.Loader")
+ local Runtime = require("src.mods.Runtime")
+ local Version = require("src.core.GameVersion")
+ local Json = require("src.link.Json")
+ Version.set(version)
+ local generation = Version.generation(version)
+ local data = dataFor(backend, generation)
+ local files = {}
+ for _, id in ipairs(failing and { "portable", "rollback" } or { "portable" }) do
+ for _, name in ipairs({ "manifest.json", "main.lua" }) do
+ files["mods/" .. id .. "/" .. name] = read(root .. "/mods/" .. id .. "/" .. name)
+ end
+ end
+ local fs = {
+ read = function(p)
+ return files[p]
+ end,
+ load = function(p)
+ return loadstring(assert(files[p]), "@" .. p)
+ end,
+ getInfo = function(p)
+ if files[p] then
+ return { type = "file", size = #files[p] }
+ end
+ for key in pairs(files) do
+ if key:sub(1, #p + 1) == p .. "/" then
+ return { type = "directory" }
+ end
+ end
+ end,
+ getDirectoryItems = function(p)
+ if p == "mods" then
+ return failing and { "portable", "rollback" } or { "portable" }
+ end
+ return {}
+ end,
+ }
+ local loader = Loader.new({ fs = fs, dev = false, generation = generation, version = version })
+ local game
+ loader._game = function(_, loadedOnly)
+ if backend == "gen2" then
+ assert(loadedOnly, "API 3 must not bootstrap Game")
+ end
+ return game
+ end
+ local ok = loader:load(data)
+ if scaffold then
+ assert(ok, table.concat(loader.errors, "; "))
+ assert(loader.mods.portable.manifest.api == 3, "generated manifest selects API 3")
+ local power = backend == "gen1" and generation == 3 and data.gen3Moves._rom[1].power
+ or data.moves.POUND.power
+ assert(power == 45, "generated entry changed the native move")
+ local report = { scaffold = true, power = power }
+ print("API3_CONTRACT " .. Json.encode(report))
+ return report
+ end
+ local errorCount = #loader.errors
+ local exported = assert(loader.exports.portable, table.concat(loader.errors, "; "))
+ assert(exported.report, table.concat(loader.errors, "; "))
+ if failing then
+ assert(not ok and table.concat(loader.errors, "; "):find("intentional API 3 rollback", 1, true))
+ else
+ assert(ok, table.concat(loader.errors, "; "))
+ end
+ local nativeHp = backend == "gen1" and generation == 3 and data.gen3Pokemon._stats[151].hp
+ or data.pokemon.MEW.baseStats.hp
+ assert(nativeHp == 101, "rollback/native merge")
+ assert(
+ (backend == "gen1" and generation == 2 and data.gen2Maps or data.maps).PROBE.width == 2,
+ "passthrough reads remain detached"
+ )
+ local raw = nativeMon(backend, generation)
+ local save = { party = { raw }, flags = {}, modData = {}, money = 0 }
+ game = { data = data, save = save, stack = { states = {} } }
+ if backend == "gen1" and generation == 3 then
+ game.session = save
+ end
+ exported.exercise()
+ Runtime.emit("pokemon.received", { mon = raw })
+ assert(raw.hp == 30, "event read did not mutate native Pokemon")
+ loader.hooks:call("evolution.check", function(_, mon)
+ assert(mon == raw and mon.hp == 27, "native hook forwarding")
+ assert(
+ mon.status
+ == (
+ backend == "gen1" and generation == 3 and 16
+ or backend == "gen1" and generation == 2 and "burn"
+ or "BRN"
+ ),
+ "native status writeback"
+ )
+ local attack = mon.stats.spatk or mon.stats.specialAttack or mon.stats.special
+ local defense = mon.stats.spdef or mon.stats.specialDefense or mon.stats.special
+ assert(attack == 31 and defense == 31, "common Special writes both later stats")
+ return "UNCHANGED"
+ end, game, raw, {}, {})
+ assert(raw.hp == 27)
+ exported.late()
+ Runtime.emit("mod.portable.probe", { value = 8 })
+ assert(exported.report.custom == 8, "custom callback reached live owner")
+ assert(#loader.errors == errorCount, "no callback errors or failed-owner subscription leaks")
+ if generation == 1 then
+ require("src.battle.TypeChart").load(data)
+ local player, enemy = nativeMon(backend, generation), nativeMon(backend, generation)
+ local function battler(mon)
+ return {
+ mon = mon,
+ species = mon.species,
+ name = mon.nickname,
+ curStats = mon.stats,
+ curTypes = { "PSYCHIC" },
+ curMoves = mon.moves,
+ stages = {},
+ }
+ end
+ local battle = {
+ isBattleState = true,
+ game = game,
+ data = data,
+ kind = "wild",
+ phase = "menu",
+ queue = {},
+ player = battler(player),
+ enemy = battler(enemy),
+ enemyParty = { enemy },
+ ruleset = {},
+ turnCount = 0,
+ sides = { { battlers = {} }, { battlers = {} } },
+ }
+ function battle:battleKind()
+ return "wild"
+ end
+ function battle:visibleText()
+ return {}
+ end
+ function battle:effectRecord()
+ return { accuracyChecked = true }
+ end
+ game.save.party, game.save.inventory = { player }, {}
+ game.stack.states = { battle }
+ exported.snapshot()
+ assert(player.hp == 30, "invalid intent never dispatched")
+ end
+ -- The entry-time world handle must follow a later Game instead of retaining the old save.
+ local replacement = nativeMon(backend, generation)
+ replacement.hp = 19
+ local nextSave = { party = { replacement }, modData = {}, flags = {} }
+ game = { data = data, save = nextSave, stack = { states = {} } }
+ if backend == "gen1" and generation == 3 then
+ game.session = nextSave
+ end
+ assert(exported.world:party()[1].hp == 19, "captured facade follows current game")
+ assert(exported.battle:snapshot() == nil, "no battle")
+ local result = exported.report
+ assert(result.event and result.hook and result.party and result.custom == 8)
+ print("API3_CONTRACT " .. Json.encode(result))
+ return result
+end
+M.dataFor = dataFor
+return M
diff --git a/tests/api3_contract/script_audio.lua b/tests/api3_contract/script_audio.lua
new file mode 100644
index 000000000..0548e0813
--- /dev/null
+++ b/tests/api3_contract/script_audio.lua
@@ -0,0 +1,155 @@
+-- Real loaders/adapters; an explicit source/clock seam avoids audio-device timing.
+local M = {}
+function M.fixture(root, backend, version, modId, base)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_audio%.lua$")
+ local f = base
+ or dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, modId or "script_audio")
+ f.game.data.audio = {
+ songs = { TUNE = { file = "tune.wav" }, JINGLE = { file = "jingle.wav" } },
+ sfx = { CHIME = { file = "chime.wav" } },
+ mapSongs = { [f.home] = "TUNE" },
+ }
+ f.audioTrace = {}
+ f.source = { playing = false }
+ function f.source:isPlaying()
+ return self.playing
+ end
+ local Sound, Music = require("src.core.Sound"), require("src.core.Music")
+ local restored = {}
+ local function patch(module, key, fn)
+ local old = module[key]
+ restored[#restored + 1] = function()
+ module[key] = old
+ end
+ module[key] = fn
+ end
+ local function sound(_, key)
+ f.audioTrace[#f.audioTrace + 1] = { kind = "sound", id = key }
+ f.source.playing = true
+ return f.source
+ end
+ local function music(_, key, _, opts)
+ f.audioTrace[#f.audioTrace + 1] = { kind = "music", id = key, keep = opts and opts.keep or false }
+ f.current = key
+ end
+ local function stop()
+ f.audioTrace[#f.audioTrace + 1] = { kind = "stop" }
+ f.current = nil
+ f.musicBusy = false
+ end
+ local function once(_, key)
+ f.audioTrace[#f.audioTrace + 1] = { kind = "once", id = key }
+ f.current = key
+ f.musicBusy = true
+ return true
+ end
+ local function fade(control)
+ f.audioTrace[#f.audioTrace + 1] = { kind = "fade", control = control }
+ end
+ local function default(_, map)
+ f.audioTrace[#f.audioTrace + 1] = { kind = "default", map = map }
+ end
+ patch(Sound, "play", sound)
+ patch(Sound, "playNamedEffect", sound)
+ patch(Sound, "sfxBusy", function()
+ return f.otherBusy == true
+ end)
+ patch(Sound, "anyPlaying", function()
+ return f.otherBusy == true
+ end)
+ patch(Music, "play", music)
+ patch(Music, "stop", stop)
+ patch(Music, "playOnce", once)
+ patch(Music, "oneShotPlaying", function()
+ return f.musicBusy
+ end)
+ patch(Music, "current", function()
+ return f.current
+ end)
+ patch(Music, "fadeOut", fade)
+ patch(Music, "playMap", default)
+ function f.ow:playMapMusic()
+ default(f.game.data, self.map.id)
+ end
+ if backend == "gen1" and f.gen == 3 then
+ local Audio = require("src.core.game3.audio")
+ patch(Audio, "songInfo", function(key)
+ return f.game.data.audio.songs[key] or f.game.data.audio.sfx[key]
+ end)
+ patch(Audio, "resolveSong", function(key)
+ return key
+ end)
+ patch(Audio, "playSe", function(key)
+ return sound(f.game.data, key)
+ end)
+ patch(Audio, "isSePlaying", function()
+ return f.source.playing or f.otherBusy == true
+ end)
+ patch(Audio, "playSong", function(key, opts)
+ if key == 0 then
+ return stop()
+ end
+ return music(f.game.data, key, nil, opts)
+ end)
+ patch(Audio, "playFanfare", function(key)
+ return once(f.game.data, key)
+ end)
+ patch(Audio, "isFanfareFinished", function()
+ return not f.musicBusy
+ end)
+ patch(Audio, "fadeOutBgm", fade)
+ patch(Audio, "restoreMapSong", function()
+ return default(f.game.data, f.ow.map.id)
+ end)
+ end
+ f.restore = function()
+ for i = #restored, 1, -1 do
+ restored[i]()
+ end
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ assert(f.exports.queue({
+ { "play_sound", "CHIME" },
+ { "wait_sound" },
+ { "play_music", "TUNE", { keep = true } },
+ { "fade_music", 2 },
+ { "stop_music" },
+ { "play_once", "JINGLE" },
+ { "play_default_music" },
+ { "set_flag", "mod:after" },
+ }))
+ for _ = 1, 3 do
+ f:tick()
+ end
+ assert(f.exports.result().done == 0 and #f.audioTrace == 1)
+ f.source.playing = false
+ f.otherBusy = true
+ for _ = 1, 3 do
+ f:tick()
+ end
+ assert(#f.audioTrace == 1 and f.exports.result().done == 0)
+ f.otherBusy = false
+ for _ = 1, 20 do
+ f:tick()
+ if f.musicBusy then
+ break
+ end
+ end
+ assert(f.musicBusy and f.exports.result().done == 0)
+ f.musicBusy = false
+ f:drain()
+ assert(f.exports.result().success and f.exports.get("mod:after"))
+ local report = { trace = f.audioTrace, complete = true }
+ f.restore()
+ f = M.fixture(root, backend, version)
+ local value, why = f.exports.queue({ { "play_sound", "CHIME" }, { "play_music", "" } })
+ assert(value == nil and type(why) == "string" and #f.audioTrace == 0)
+ report.preflight = true
+ f.restore()
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/script_battles.lua b/tests/api3_contract/script_battles.lua
new file mode 100644
index 000000000..f29912824
--- /dev/null
+++ b/tests/api3_contract/script_battles.lua
@@ -0,0 +1,64 @@
+local M = {}
+function M.fixture(root, backend, version, modId)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_battles%.lua$")
+ local f = dofile(codeRoot .. "/wild_battle.lua").fixture(root, backend, version, modId or "script_battles")
+ f.loader.game = f.game
+ f.ow.runner.update = function() end
+ if f.field then
+ function f.field.lock(token)
+ f.field._locks[token] = true
+ end
+ function f.field.unlock(token)
+ f.field._locks[token] = nil
+ end
+ end
+ function f:tick()
+ self.loader.hooks:call(backend == "gen1" and "core.update" or "input.step", function()
+ if backend == "gen2" then
+ self.ow.runner:update()
+ end
+ end, self.game, 1 / 60)
+ end
+ function f:drain()
+ for _ = 1, 50 do
+ if self.exports.result().done > 0 then
+ return
+ end
+ self:tick()
+ end
+ error("battle script did not finish")
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local report = {}
+ for _, outcome in ipairs({ "win", "lose", "run", "caught" }) do
+ local f = M.fixture(root, backend, version)
+ local beat = f.gen == 1 and "EVENT_BEAT_MEWTWO" or "mod:beat"
+ assert(f.exports.queue({
+ { "static_battle", "MEW", 5, beat },
+ { "check_battle_result", outcome },
+ { "jump_if_false", "end" },
+ { "set_flag", "mod:result" },
+ }))
+ assert(f.exports.result().done == 0 and f.pushed == 1)
+ f:complete(outcome)
+ f:drain()
+ assert(f.exports.result().success and f.exports.get("mod:result"))
+ assert(
+ f.exports.get(beat) == (outcome ~= "lose") and f.after == 1,
+ outcome .. " beat=" .. tostring(f.exports.get(beat)) .. " cleanup=" .. f.after
+ )
+ report[outcome] = { beat = f.exports.get(beat), after = f.after, result = true }
+ f.restore()
+ end
+ local f = M.fixture(root, backend, version)
+ local value, why =
+ f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "static_battle", "UNKNOWN", 5 } })
+ assert(value == nil and type(why) == "string" and f.constructed == 0)
+ report.preflight = true
+ f.restore()
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/script_dialogue_audio.lua b/tests/api3_contract/script_dialogue_audio.lua
new file mode 100644
index 000000000..e0de68ec7
--- /dev/null
+++ b/tests/api3_contract/script_dialogue_audio.lua
@@ -0,0 +1,111 @@
+-- Actual native dialogue UI, with an explicit fake audio-device clock.
+local M = {}
+function M.fixture(root, backend, version)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_dialogue_audio%.lua$")
+ local base =
+ dofile(codeRoot .. "/script_gifts.lua").fixture(root, backend, version, "script_dialogue_audio")
+ local f = dofile(codeRoot .. "/script_audio.lua").fixture(root, backend, version, nil, base)
+ f.game.data.audio.cries = { MEW = { file = "mew.wav" }, [151] = { file = "mew.wav" } }
+ f.game.data.text.WELCOME = "WELCOME"
+ f.game.data.text_pointers = {}
+ f.ow.map.def.label = "DIALOGUE_HOME"
+ f.ow.game, f.ow.text = f.game, f.game.data.text
+ local Sound = require("src.core.Sound")
+ local oldCry, restore = Sound.playCry, f.restore
+ local function cry(_, key)
+ f.audioTrace[#f.audioTrace + 1] = { kind = "cry", id = "MEW" }
+ f.source.playing = true
+ return f.source
+ end
+ Sound.playCry = cry
+ if backend == "gen1" and f.gen == 2 then
+ local World = require("src.world.gen2.World")
+ f.ow.showText, f.ow.askYesNo = World.showText, World.askYesNo
+ end
+ local sourceRestore = function() end
+ if backend == "gen1" and f.gen == 3 then
+ local A = require("src.core.game3.audio")
+ local oldPlay, oldBusy = A.playCry, A.isCryFinished
+ A.playCry = function(key)
+ return cry(f.game.data, key)
+ end
+ A.isCryFinished = function()
+ return not f.source.playing
+ end
+ sourceRestore = function()
+ A.playCry, A.isCryFinished = oldPlay, oldBusy
+ end
+ end
+ f.restore = function()
+ Sound.playCry = oldCry
+ sourceRestore()
+ restore()
+ end
+ function f:tick(key)
+ self.game.input.wasPressed = function(_, button)
+ return button == key
+ end
+ self.loader.hooks:call(backend == "gen1" and "core.update" or "input.step", function()
+ if backend == "gen2" then
+ self.ow.runner:update()
+ end
+ end, self.game, 1 / 60)
+ if backend == "gen1" and self.gen == 3 then
+ local Message = require("src.ui.game3.message")
+ Message.tick()
+ if key then
+ if Message.isWaiting() then
+ Message.advance()
+ else
+ Message.pressAB()
+ end
+ end
+ else
+ self.game.stack:update(1 / 60)
+ end
+ self.game.input.wasPressed = function()
+ return false
+ end
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local report = {}
+ for _, verb in ipairs({ "play_cry", "text_sound", "engage_music" }) do
+ local f = M.fixture(root, backend, version)
+ local cue = verb == "play_cry" and "MEW" or verb == "text_sound" and "CHIME" or "TUNE"
+ assert(f.exports.queue({ { verb, cue }, { "show_text", "WELCOME" }, { "set_flag", "mod:after" } }))
+ assert(#f.audioTrace == 0, "cue must be deferred")
+ for _ = 1, 300 do
+ f:tick()
+ if #f.audioTrace > 0 then
+ break
+ end
+ end
+ assert(#f.audioTrace == 1, "dialogue must start cue")
+ if verb ~= "engage_music" then
+ for _ = 1, 3 do
+ f:tick("a")
+ end
+ assert(f.exports.result().done == 0, "sounding cue blocks completion")
+ end
+ f.source.playing, f.musicBusy = false, false
+ for _ = 1, 30 do
+ f:tick()
+ end
+ if verb == "play_cry" then
+ assert(f.exports.result().success == true, "ordinary cry auto closes")
+ else
+ assert(f.exports.result().done == 0, "text cue keeps button wait")
+ for _ = 1, 30 do
+ f:tick("a")
+ end
+ end
+ assert(f.exports.result().success == true and f.exports.get("mod:after"))
+ report[verb] = { deferred = true, complete = true }
+ f.restore()
+ end
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/script_fields.lua b/tests/api3_contract/script_fields.lua
new file mode 100644
index 000000000..8b4f1a93f
--- /dev/null
+++ b/tests/api3_contract/script_fields.lua
@@ -0,0 +1,41 @@
+local M = {}
+function M.fixture(root, backend, version)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_fields%.lua$")
+ return dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, "script_fields")
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ f.game.save.modData.foreign = { marker = 17 }
+ local value = { phase = 2, enabled = false, items = { "A", 3 } }
+ assert(
+ f.exports.queue({
+ { "wait", 1 },
+ { "set_field", "portableRoot", true },
+ { "set_field", "mod:story", value },
+ { "set_option", "portableChoice", "FAST" },
+ { "set_option", "mod:theme", false },
+ { "set_flag", "mod:after" },
+ })
+ )
+ value.phase = 9
+ value.items[1] = "CHANGED"
+ f:drain()
+ assert(f.exports.result().success and f.exports.get("mod:after"))
+ local save = f.game.save
+ assert(save.portableRoot == true and save.modData.script_fields.story.phase == 2)
+ assert(save.modData.script_fields.story.items[1] == "A" and save.modData.foreign.marker == 17)
+ local report = {
+ root = save.portableRoot,
+ private = save.modData.script_fields.story,
+ options = save.options,
+ foreign = 17,
+ }
+ f = M.fixture(root, backend, version)
+ local result, why =
+ f.exports.queue({ { "set_field", "portableRoot", true }, { "set_field", "mod.foreign.field", 1 } })
+ assert(result == nil and type(why) == "string" and f.game.save.portableRoot == nil)
+ report.preflight = true
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/script_flags.lua b/tests/api3_contract/script_flags.lua
new file mode 100644
index 000000000..af3020908
--- /dev/null
+++ b/tests/api3_contract/script_flags.lua
@@ -0,0 +1,89 @@
+local M = {}
+function M.fixture(root, backend, version, modId)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_flags%.lua$")
+ local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "script_flags")
+ f.loader.game = f.game
+ f.ow.runner.update = function() end
+ if f.field then
+ function f.field.lock(token)
+ f.field._locks[token] = true
+ end
+ function f.field.unlock(token)
+ f.field._locks[token] = nil
+ end
+ end
+ function f:tick()
+ if backend == "gen1" then
+ self.loader.hooks:call("core.update", function() end, self.game, 1 / 60)
+ else
+ self.loader.hooks:call("input.step", function()
+ self.ow.runner:update()
+ end, self.game, 1 / 60)
+ end
+ end
+ function f:drain()
+ for _ = 1, 100 do
+ self:tick()
+ if self.exports.result().done > 0 then
+ return
+ end
+ end
+ error("script did not complete")
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ local rows = {
+ { "set_flag", "mod:ready" },
+ { "check_flag", "mod:ready" },
+ { "jump_if_false", "failed" },
+ { "clearflag", "mod:ready" },
+ { "check_flag", "mod:ready" },
+ { "jump_if_true", "failed" },
+ { "setflag", "mod:result" },
+ { "jump", "end" },
+ { "label", "failed" },
+ { "set_flag", "mod:failure" },
+ }
+ assert(f.exports.queue(rows))
+ f:drain()
+ assert(f.exports.get("mod:result") == true and f.exports.get("mod:failure") == false)
+ assert(f.exports.get("mod:ready") == false and f.exports.result().success == true)
+ local report = { branch = true }
+ f = M.fixture(root, backend, version)
+ assert(f.exports.queue({
+ { "wait_flag", "mod:signal", 20 },
+ { "jump_if_false", "end" },
+ {
+ "set_flag",
+ "mod:resumed",
+ },
+ }))
+ f:tick()
+ assert(f.exports.result().done == 0)
+ assert(f.exports.set("mod:signal", true))
+ f:drain()
+ assert(f.exports.get("mod:resumed") == true)
+ report.resumed = f.exports.result()
+ f = M.fixture(root, backend, version)
+ assert(f.exports.queue({
+ { "wait_flag", "mod:signal", 2 },
+ { "jump_if_true", "end" },
+ {
+ "set_flag",
+ "mod:timeout",
+ },
+ }))
+ f:drain()
+ assert(f.exports.get("mod:timeout") == true)
+ report.timeout = f.exports.result()
+ f = M.fixture(root, backend, version)
+ local value, why = f.exports.queue({ { "set_flag", "mod:prefix" }, { "set_flag", "not_a_real_flag" } })
+ assert(value == nil and type(why) == "string")
+ assert(f.exports.get("mod:prefix") == false and f.exports.result().done == 0)
+ report.preflight = true
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/script_gifts.lua b/tests/api3_contract/script_gifts.lua
new file mode 100644
index 000000000..9545bb462
--- /dev/null
+++ b/tests/api3_contract/script_gifts.lua
@@ -0,0 +1,57 @@
+local M = {}
+function M.fixture(root, backend, version, modId)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_gifts%.lua$")
+ local f = dofile(codeRoot .. "/script_state.lua").fixture(root, backend, version, modId or "script_gifts")
+ love = love or require("tests.love_stub")
+ local data, save = f.game.data, f.game.save
+ data.text = data.text or {}
+ data.font = data.font or dofile(codeRoot .. "/../fixture_data/font.lua")
+ if not getmetatable(data) then
+ setmetatable(data, { __index = require("src.core.Data") })
+ end
+ save.player = save.player or {}
+ save.player.name, save.player.id = "TEST", 12345
+ f.game.input = {
+ wasPressed = function()
+ return false
+ end,
+ isDown = function()
+ return false
+ end,
+ }
+ local Stack = require("src.core.StateStack")
+ setmetatable(f.game.stack, { __index = Stack })
+ f.ow.update = function(self)
+ self.runner:update()
+ end
+ require("src.render.Font").load(data)
+ if backend == "gen1" and f.gen == 3 then
+ local pokemon = require("src.core.game3.pokemon")
+ pokemon._learnsets, pokemon._national, pokemon._abilityNames = {}, { [151] = 151 }, {}
+ end
+ f.party = function()
+ local mon = save.party[#save.party]
+ return { count = #save.party, level = mon.level, species = (mon.species == 151 and "MEW" or mon.species) }
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ assert(f.exports.queue({
+ { "give_item", "POTION", 2, false },
+ { "give_pokemon", "MEW", 5, true, false },
+ { "set_flag", "mod:gifted" },
+ }))
+ f:drain()
+ assert(f.exports.result().success == true and f.exports.get("mod:gifted") == true)
+ assert(f.quantity() == 5 and #f.game.save.party == 2)
+ local report = { items = f.quantity(), party = f.party() }
+ f = M.fixture(root, backend, version)
+ local value, why =
+ f.exports.queue({ { "give_item", "POTION", 1, false }, { "give_pokemon", "UNKNOWN", 5, true, false } })
+ assert(value == nil and type(why) == "string" and f.quantity() == 3 and #f.game.save.party == 1)
+ report.preflight = true
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/script_motion.lua b/tests/api3_contract/script_motion.lua
new file mode 100644
index 000000000..e4f215fc7
--- /dev/null
+++ b/tests/api3_contract/script_motion.lua
@@ -0,0 +1,108 @@
+local M = {}
+function M.fixture(root, backend, version, modId)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_motion%.lua$")
+ local f = dofile(codeRoot .. "/npcs.lua").fixture(root, backend, version, modId or "script_motion")
+ f.loader.game = f.game
+ f.ow.runner.update = function() end
+ function f.ow:npcByIndex(id)
+ return id == 1 and f.imported
+ end
+ function f.ow:objectEntity(id)
+ return id == 0 and f.player or id == 1 and f.imported
+ end
+ if f.field then
+ function f.field.lock(token)
+ f.field._locks[token] = true
+ end
+ function f.field.unlock(token)
+ f.field._locks[token] = nil
+ end
+ end
+ if backend == "gen1" and f.gen == 2 then
+ function f.ow:beginMovement(id, bytes, callback)
+ local dir = ({ [0x0C] = "down", [0x0D] = "up", [0x0E] = "left", [0x0F] = "right" })[bytes[1]]
+ return f:begin(id == 0 and f.player or f.imported, "move", assert(dir), #bytes - 1, callback)
+ end
+ function f.ow:moveObject(id, x, y)
+ local actor = assert(self:objectEntity(id))
+ actor.cellX, actor.cellY = x, y
+ end
+ elseif backend == "gen1" and f.gen == 3 then
+ local objects = require("src.core.game3.objects")
+ objects._defs = { f.imported.def }
+ objects.find = function(id)
+ return id == 0xFF and f.player or f.byId[id]
+ end
+ objects.setObjectXY = function(id, x, y)
+ local actor = assert(objects.find(id))
+ actor.cellX, actor.cellY = x, y
+ end
+ objects.applyMovement = function(id, bytes, callback)
+ local dir = ({ [0x10] = "down", [0x11] = "up", [0x12] = "left", [0x13] = "right" })[bytes[1]]
+ return f:begin(assert(objects.find(id)), "move", assert(dir), #bytes - 1, callback)
+ end
+ local collision = require("src.core.game3.collision")
+ collision.inBounds = function(x, y)
+ return x >= 0 and y >= 0 and x < 8 and y < 8
+ end
+ collision.isWalkable = function()
+ return true
+ end
+ end
+ function f:tick()
+ local hook = backend == "gen1" and "core.update" or "input.step"
+ self.loader.hooks:call(hook, function()
+ if backend == "gen2" then
+ self.ow.runner:update()
+ end
+ end, self.game, 1 / 60)
+ end
+ function f:drain()
+ for _ = 1, 100 do
+ if self.exports.result().done > 0 then
+ return
+ end
+ if self.pending and self.pending.actor.moving then
+ self:complete()
+ end
+ self:tick()
+ end
+ error("movement script did not finish")
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ assert(f.exports.queue({
+ { "face_player_dir", "up" },
+ { "move_player", "left", 2 },
+ { "place_npc", 1, 2, 2, "left" },
+ { "move_npc", 1, "right", 2 },
+ { "move_npc_to", 1, 4, 4 },
+ { "face_object", 1, "up" },
+ { "set_flag", "mod:after" },
+ }))
+ f:drain()
+ assert(f.exports.result().success and f.exports.get("mod:after"))
+ assert(
+ f.player.cellX == 5
+ and f.player.cellY == 7
+ and f.imported.cellX == 4
+ and f.imported.cellY == 4
+ and f.imported.facing == "up"
+ )
+ local report = {
+ player = { f.player.cellX, f.player.cellY },
+ npc = { f.imported.cellX, f.imported.cellY, f.imported.facing },
+ complete = true,
+ }
+ f.restoreRenderer()
+ f = M.fixture(root, backend, version)
+ local ok, why = f.exports.queue({ { "face_player_dir", "up" }, { "move_npc", 99, "left", 1 } })
+ assert(ok == nil and type(why) == "string" and f.player.facing == "left")
+ report.preflight = true
+ f.restoreRenderer()
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/script_shops.lua b/tests/api3_contract/script_shops.lua
new file mode 100644
index 000000000..adde51e2a
--- /dev/null
+++ b/tests/api3_contract/script_shops.lua
@@ -0,0 +1,83 @@
+local M = {}
+function M.fixture(root, backend, version)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_shops%.lua$")
+ local f = dofile(codeRoot .. "/script_gifts.lua").fixture(root, backend, version, "script_shops")
+ local data = f.game.data
+ f.ow.game, f.ow.text = f.game, data.text
+ f.ow.map.def.label = "SHOP_HOME"
+ data.text_pointers = { SHOP_HOME = { SHOP = { mart = { "POTION" } } } }
+ if data.items and data.items.POTION then
+ data.items.POTION.price = 100
+ end
+ if backend == "gen1" and f.gen == 3 then
+ require("src.core.game3.scripting.space").ensureBundle = function()
+ local IR = require("src.core.game3.scripting.text_ir")
+ return {
+ text = {
+ Text_MayIHelpYou = IR.fromAscii("WELCOME"),
+ gText_HowMayIServeYou = IR.fromAscii("WELCOME"),
+ },
+ }
+ end
+ end
+ if backend == "gen1" and f.gen == 2 then
+ f.ow.openMart = require("src.world.gen2.World").openMart
+ end
+ function f:tick(key)
+ self.game.input.wasPressed = function(_, button)
+ return button == key
+ end
+ self.loader.hooks:call(backend == "gen1" and "core.update" or "input.step", function()
+ if backend == "gen2" then
+ self.ow.runner:update()
+ end
+ end, self.game, 1 / 60)
+ self.game.stack:update(1 / 60)
+ self.game.input.wasPressed = function()
+ return false
+ end
+ end
+ function f:close()
+ if backend == "gen1" and f.gen == 3 then
+ require("src.ui.game3.shop_menu").close()
+ else
+ for _ = 1, 2000 do
+ if self.exports.result().done > 0 then
+ return
+ end
+ self:tick("b")
+ end
+ error("native shop did not close")
+ end
+ end
+ function f:drain()
+ for _ = 1, 100 do
+ if self.exports.result().done > 0 then
+ return
+ end
+ self:tick()
+ end
+ error("shop script did not finish")
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ local ok, why = f.exports.queue({ { "open_mart", "SHOP" }, { "set_flag", "mod:after" } })
+ assert(ok, why)
+ assert(
+ f.exports.result().done == 0 and not f.exports.get("mod:after"),
+ require("src.link.Json").encode(f.exports.result()) .. " " .. table.concat(f.loader.errors, "; ")
+ )
+ f:close()
+ f:drain()
+ assert(f.exports.result().success and f.exports.get("mod:after"))
+ local report = { complete = true, quantity = f.quantity() }
+ f = M.fixture(root, backend, version)
+ local value, reason = f.exports.queue({ { "set_flag", "mod:prefix" }, { "open_mart", "UNKNOWN" } })
+ assert(value == nil and type(reason) == "string" and not f.exports.get("mod:prefix"))
+ report.preflight = true
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/script_state.lua b/tests/api3_contract/script_state.lua
new file mode 100644
index 000000000..dc7148928
--- /dev/null
+++ b/tests/api3_contract/script_state.lua
@@ -0,0 +1,109 @@
+local M = {}
+function M.fixture(root, backend, version, modId)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_state%.lua$")
+ local f = dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, modId or "script_state")
+ local save, data = f.game.save, f.game.data
+ save.money, save.coins = 100, 50
+ save.inventory, save.bagOrder = { POTION = 3 }, { "POTION" }
+ save.pokedex = { seen = {}, owned = { MEW = true } }
+ save.party =
+ { { species = "MEW", hp = 1, status = "PSN", stats = { hp = 20 }, moves = { { id = "POUND", pp = 1 } } } }
+ f.pp = function()
+ return save.party[1].moves[1].pp
+ end
+ f.seen = function()
+ return save.pokedex.seen.MEW == true
+ end
+ f.quantity = function()
+ return save.inventory.POTION or 0
+ end
+ f.wallet = function()
+ return save.money, save.coins
+ end
+ if backend == "gen1" and f.gen == 1 then
+ require("src.core.Data").moves = data.moves -- Native heal uses the active data singleton.
+ elseif backend == "gen1" and f.gen == 2 then
+ f.ow.game = f.game
+ save.player = { money = 100, coins = 50 }
+ local World = require("src.world.gen2.World")
+ f.ow.money, f.ow.setMoney = World.money, World.setMoney
+ f.ow.coins, f.ow.setCoins = World.coins, World.setCoins
+ f.ow.healParty = World.healParty
+ save.party[1].maxHp = 20
+ f.wallet = function()
+ return save.player.money, save.player.coins
+ end
+ elseif backend == "gen1" and f.gen == 3 then
+ local pokemon = require("src.core.game3.pokemon")
+ for key, value in pairs(data.gen3Pokemon) do
+ pokemon[key] = value
+ end
+ pokemon._byName = { MEW = 151 }
+ require("src.core.game3.items_data").installPack({ items = data.gen3Items._byId })
+ local Bag = require("src.core.game3.bag")
+ save.bag = Bag.new()
+ save.bag.pockets.ITEMS = { { id = 13, qty = 3 } }
+ save.dex = { seen = {}, caught = { [151] = true }, owned = { [151] = true } }
+ save.party =
+ { { species = 151, hp = 1, maxHp = 20, status = 8, moves = { 1 }, pp = { 1 }, maxPp = { 35 } } }
+ f.pp = function()
+ return save.party[1].pp[1]
+ end
+ f.seen = function()
+ return require("src.core.game3.dex").isSeen(save.dex, 151)
+ end
+ f.quantity = function()
+ return Bag.get(save.bag, 13)
+ end
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ assert(f.exports.queue({
+ { "give_money", 50 },
+ { "take_money", 20 },
+ { "check_money", 130 },
+ { "jump_if_false", "failed" },
+ { "take_coins", 12 },
+ { "check_item", "POTION" },
+ { "jump_if_false", "failed" },
+ { "take_item", "POTION", 2 },
+ { "check_dex_owned", 1 },
+ { "jump_if_false", "failed" },
+ { "mark_seen", "MEW" },
+ { "heal_party" },
+ { "set_flag", "mod:passed" },
+ { "jump", "end" },
+ { "label", "failed" },
+ { "set_flag", "mod:failed" },
+ }))
+ f:drain()
+ assert(f.exports.result().success == true)
+ assert(f.exports.get("mod:passed") == true and f.exports.get("mod:failed") == false)
+ local money, coins = f.wallet()
+ local report = {
+ money = money,
+ coins = coins,
+ items = f.quantity(),
+ hp = f.game.save.party[1].hp,
+ pp = f.pp(),
+ seen = f.seen(),
+ }
+ assert(
+ money == 130
+ and coins == 38
+ and report.items == 1
+ and report.hp == 20
+ and report.pp == 35
+ and report.seen == true
+ )
+ f = M.fixture(root, backend, version)
+ local value, why = f.exports.queue({ { "give_money", 1 }, { "take_item", "NO_SUCH_ITEM" } })
+ assert(value == nil and type(why) == "string")
+ assert(f.wallet() == 100 and f.quantity() == 3 and f.exports.result().done == 0)
+ report.preflight = true
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/script_trainers.lua b/tests/api3_contract/script_trainers.lua
new file mode 100644
index 000000000..81d2a4d8b
--- /dev/null
+++ b/tests/api3_contract/script_trainers.lua
@@ -0,0 +1,82 @@
+local M = {}
+function M.fixture(root, backend, version)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_trainers%.lua$")
+ local f = dofile(codeRoot .. "/script_battles.lua").fixture(root, backend, version, "script_trainers")
+ f.game.data.trainers =
+ { PROBE = { id = "PROBE", index = 7, name = "PROBE", parties = { { { species = "MEW", level = 5 } } } } }
+ f.game.data.constants = f.game.data.constants or {}
+ f.game.data.constants.trainerOrder = { [7] = "PROBE" }
+ f.game.data.constants.gen3TrainerFlagBase = 0x500
+ local battle = package.loaded["src.battle.BattleState"]
+ battle.newTrainer = function(game, class, member)
+ f.constructed = f.constructed + 1
+ if f.constructError then
+ error("trainer constructor probe", 0)
+ end
+ return { kind = "trainer", class = class, member = member }
+ end
+ f.trainerRef = f.gen == 1 and "PROBE" or 7
+ if backend == "gen1" and f.gen == 2 then
+ f.game.data.trainers = {
+ classes = {
+ PROBE = {
+ id = "PROBE",
+ index = 7,
+ name = "PROBE",
+ trainers = { { id = "PROBE_1", name = "PROBE", party = { { species = "MEW", level = 5 } } } },
+ },
+ },
+ }
+ function f.ow:trainerParty(class, member)
+ return require("src.world.gen2.TrainerHouse").lookup(f.game.data.trainers, f.game.save, class, member)
+ end
+ function f.ow:startScriptedBattle(trainer, _, done)
+ f.constructed = f.constructed + 1
+ return f:admit({ kind = "trainer", trainer = trainer }, done, true)
+ end
+ elseif backend == "gen1" and f.gen == 3 then
+ package.loaded["src.core.game3.scripting.trainers"] = {
+ get = function(id)
+ return id == 7 and { party = { { species = 151, level = 5 } } } or nil
+ end,
+ foeFromId = function(id)
+ return id == 7 and { trainerId = id, party = { { species = 151, level = 5 } } } or nil
+ end,
+ }
+ package.loaded["src.core.game3.battle_bridge"].start = function(_, game, foe, opts)
+ f.constructed = f.constructed + 1
+ local value, why = f:admit({ kind = "trainer", foe = foe }, opts.done, true)
+ if value == false then
+ return value, why
+ end
+ return true
+ end
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local report = {}
+ for _, outcome in ipairs({ "win", "lose", "run" }) do
+ local f = M.fixture(root, backend, version)
+ assert(f.exports.queue({
+ { "start_battle", "trainer", f.trainerRef, 1 },
+ { "check_battle_result", outcome },
+ { "jump_if_false", "end" },
+ { "set_flag", "mod:after" },
+ }))
+ assert(f.constructed == 1 and f.pushed == 1 and f.exports.result().done == 0)
+ f:complete(outcome)
+ f:drain()
+ assert(f.after == 1 and f.exports.result().success and f.exports.get("mod:after"))
+ report[outcome] = { after = f.after, complete = true }
+ f.restore()
+ end
+ local f = M.fixture(root, backend, version)
+ local value, why = f.exports.queue({ { "set_flag", "mod:prefix" }, { "start_battle", "trainer", 999, 1 } })
+ assert(value == nil and type(why) == "string" and not f.exports.get("mod:prefix") and f.constructed == 0)
+ report.preflight = true
+ f.restore()
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/script_travel.lua b/tests/api3_contract/script_travel.lua
new file mode 100644
index 000000000..4894ed053
--- /dev/null
+++ b/tests/api3_contract/script_travel.lua
@@ -0,0 +1,119 @@
+local M = {}
+function M.fixture(root, backend, version)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_travel%.lua$")
+ local f = dofile(codeRoot .. "/script_motion.lua").fixture(root, backend, version, "script_travel")
+ f.game.data.tilesets = f.game.data.tilesets or {}
+ f.game.data.tilesets.FIX = f.tiles
+ for _, def in pairs(f.game.data.maps) do
+ if def.midLayout then
+ def.midLayout.width, def.midLayout.height = def.width, def.height
+ end
+ end
+ local dest = f.game.data.maps[f.dest]
+ dest.objects = {
+ {
+ index = 1,
+ localId = 1,
+ name = "IMPORTED",
+ sprite = "PROBE",
+ x = 4,
+ y = 4,
+ movement = "STAY",
+ range = "DOWN",
+ },
+ }
+ local arrive = f.arrive
+ function f:arrive(id)
+ arrive(self, id)
+ local def = self.game.data.maps[id or self.dest]
+ if not def then
+ return
+ end
+ if backend == "gen1" and f.gen == 2 then
+ self.ow.map = require("src.world.gen2.Map").new(def, self.tiles)
+ elseif not (backend == "gen1" and f.gen == 3) then
+ self.ow.map = require("src.world.Map").new(def, self.tiles)
+ end
+ if backend == "gen1" and f.gen > 1 then
+ self.ow.runner = {
+ isRunning = function()
+ return false
+ end,
+ update = function() end,
+ }
+ else
+ self.ow.runner = require("src.script.ScriptRunner").new(self.game, self.ow)
+ end
+ self.imported.cellX, self.imported.cellY = 4, 4
+ self.imported.def = def.objects[1]
+ if self.byId then
+ require("src.core.game3.objects")._defs = def.objects
+ end
+ end
+ function f:drain()
+ for _ = 1, 150 do
+ if self.exports.result().done > 0 then
+ return
+ end
+ if self.destination and self.ow.transitioning then
+ self:arrive()
+ self:completeWarp()
+ end
+ if self.pending and self.pending.actor.moving then
+ self:complete()
+ end
+ self:tick()
+ end
+ error("travel script did not finish")
+ end
+ -- npc fixture's complete belongs to movement; preserve the warp seam separately.
+ function f:completeWarp()
+ self.busy, self.ow.transitioning = false, false
+ if self.callback then
+ self.callback()
+ end
+ end
+ local dispatch = f.dispatch
+ function f:dispatch(...)
+ local movementComplete = self.complete
+ self.complete = self.completeWarp
+ local called, a, b = pcall(dispatch, self, ...)
+ self.complete = movementComplete
+ if not called then
+ error(a, 0)
+ end
+ return a, b
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ local ok, why = f.exports.queue({
+ { "remember_outdoor", f.home, 1, 1 },
+ { "warp", f.dest, 2, 2, "right" },
+ { "move_player", "left" },
+ { "move_npc", 1, "down" },
+ { "face_object", 1, "up" },
+ { "set_flag", "mod:after" },
+ })
+ assert(ok, why)
+ assert(f.exports.result().done == 0)
+ f:drain()
+ assert(f.exports.result().success and f.exports.get("mod:after"))
+ assert(f.ow.map.id == f.dest and f.player.cellX == 1 and f.player.cellY == 2 and f.imported.cellY == 5)
+ local report = {
+ map = f.ow.map.id,
+ player = { f.player.cellX, f.player.cellY },
+ npc = { f.imported.cellX, f.imported.cellY, f.imported.facing },
+ complete = true,
+ }
+ f.restoreRenderer()
+ f = M.fixture(root, backend, version)
+ local value, reason = f.exports.queue({ { "face_player_dir", "up" }, { "warp", "UNKNOWN", 0, 0 } })
+ assert(value == nil and type(reason) == "string" and f.player.facing == "left" and #f.trace == 0)
+ report.preflight = true
+ f.restoreRenderer()
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/script_world.lua b/tests/api3_contract/script_world.lua
new file mode 100644
index 000000000..e52511375
--- /dev/null
+++ b/tests/api3_contract/script_world.lua
@@ -0,0 +1,66 @@
+local M = {}
+function M.fixture(root, backend, version)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_world%.lua$")
+ local f = dofile(codeRoot .. "/world_edits.lua").fixture(root, backend, version, "script_world")
+ f.loader.game = f.game
+ f.ow.runner.update = function() end
+ if f.field then
+ function f.field.lock(token)
+ f.field._locks[token] = true
+ end
+ function f.field.unlock(token)
+ f.field._locks[token] = nil
+ end
+ end
+ if f.gen == 1 then
+ local native = f.ow.setMap
+ function f.ow:setMap(...)
+ local old = self.map
+ native(self, ...)
+ self.map = require("src.world.Map").new(old.def, old.tileset)
+ self.map.renderer = old.renderer
+ self.runner = require("src.script.ScriptRunner").new(f.game, self)
+ end
+ end
+ function f:tick()
+ if backend == "gen1" then
+ self.loader.hooks:call("core.update", function() end, self.game, 1 / 60)
+ else
+ self.loader.hooks:call("input.step", function()
+ self.ow.runner:update()
+ end, self.game, 1 / 60)
+ end
+ end
+ function f:drain()
+ for _ = 1, 100 do
+ if self.exports.result().done > 0 then
+ return
+ end
+ self:tick()
+ end
+ error("world edit script did not finish")
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ assert(f.exports.queue({
+ { "hide_object", f.home, "NPC" },
+ { "show_object", f.home, "NPC" },
+ { "replace_block", 0, 0, 8 },
+ { "wait", 1 },
+ { "set_flag", "mod:after" },
+ }))
+ f:drain()
+ assert(f.exports.result().success == true and f.exports.get("mod:after") == true)
+ assert(f.visible.NPC == true and f.exports.read(f.home, 0, 0) == 8)
+ local report =
+ { events = f.exports.result().events, visible = f.visible.NPC, block = f.exports.read(f.home, 0, 0) }
+ f = M.fixture(root, backend, version)
+ local value, why = f.exports.queue({ { "hide_object", f.home, "NPC" }, { "replace_block", 99, 0, 8 } })
+ assert(value == nil and type(why) == "string" and f.visible.NPC and #f.exports.result().events == 0)
+ report.preflight = true
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/viewport.lua b/tests/api3_contract/viewport.lua
new file mode 100644
index 000000000..2415fd319
--- /dev/null
+++ b/tests/api3_contract/viewport.lua
@@ -0,0 +1,48 @@
+local M = {}
+function M.fixture(root, backend, version)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/viewport%.lua$")
+ local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "viewport")
+ love = love or require("tests.love_stub")
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ local V = require("src.render.GameViewport")
+ local Game = require(backend == "gen1" and f.gen > 1 and ("src.core.Game" .. f.gen) or "src.core.Game")
+ local G = love.graphics
+ local dims, pixels = G.getDimensions, G.getPixelDimensions
+ G.getDimensions = function()
+ return 640, 480
+ end
+ G.getPixelDimensions = function()
+ return 1280, 960
+ end
+ f.exports.request({ x = 100, y = 20, width = 320, height = 288 })
+ local r = V.begin(f.gen)
+ assert(V.active() and r.x == 100 and r.y == 20 and r.width == 320 and r.height == 288)
+ assert(select(1, V.dimensions()) == 320)
+ assert(select(1, V.pixelDimensions()) == 640)
+ local x, y, inside = V.toLocal(132, 52)
+ assert(x == 32 and y == 32 and inside)
+ x, y, inside = V.toLocal(99, 19)
+ assert(x == -1 and y == -1 and not inside)
+ f.exports.canvas(V.target())
+ Game.pointerEvent(f.game, "pressed", "mouse", "mouse", 132, 52, 0, 0, nil, 1)
+ Game.pointerEvent(f.game, "moved", "mouse", "mouse", 99, 19, -33, -33, nil, 1)
+ V.finish(f.game)
+ f.exports.request({ capture = true })
+ V.begin(f.gen)
+ assert(V.active() and select(1, V.dimensions()) == 640)
+ f.exports.canvas(V.target())
+ V.finish(f.game)
+ f.exports.request(nil)
+ V.begin(f.gen)
+ assert(not V.active())
+ V.finish(f.game)
+ local report = f.exports.trace()
+ G.getDimensions, G.getPixelDimensions = dims, pixels
+ V.reset()
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/warp.lua b/tests/api3_contract/warp.lua
new file mode 100644
index 000000000..5e7493d19
--- /dev/null
+++ b/tests/api3_contract/warp.lua
@@ -0,0 +1,388 @@
+-- Shared driver, real production loaders/adapters, native-shaped world seams.
+-- No backend distinction is passed into the mod files or public assertions.
+local M = {}
+local unpack = table.unpack or unpack
+local function read(path)
+ local f = assert(io.open(path, "rb"))
+ local value = f:read("*a")
+ f:close()
+ return value
+end
+function M.fixture(root, backend, version, modId)
+ modId = modId or "warp"
+ local Version = require("src.core.GameVersion")
+ local Runtime = require("src.mods.Runtime")
+ Version.set(version)
+ local gen = Version.generation(version)
+ local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/warp%.lua$")
+ local data = dofile(codeRoot .. "/run.lua").dataFor(backend, gen)
+ local prefix = gen == 3 and (version == "emerald" and "EM_" or "FR_") or ""
+ local home, dest = prefix .. "HOME", prefix .. "DEST"
+ for _, id in ipairs({ home, dest }) do
+ data.maps[id] = {
+ id = id,
+ width = 2,
+ height = 3,
+ tileset = "FIX",
+ blocks = { 1 },
+ midLayout = gen == 3 and { width = 2, height = 3 } or nil,
+ }
+ if data.gen2Maps then
+ data.gen2Maps[id] = data.maps[id]
+ end
+ end
+ local files = {}
+ for _, name in ipairs({ "manifest.json", "main.lua" }) do
+ files["mods/" .. modId .. "/" .. name] = read(root .. "/mods/" .. modId .. "/" .. name)
+ end
+ local fs = {
+ read = function(path)
+ return files[path]
+ end,
+ load = function(path)
+ return loadstring(assert(files[path]), "@" .. path)
+ end,
+ getInfo = function(path)
+ if files[path] then
+ return { type = "file", size = #files[path] }
+ end
+ if path == "mods" or path == "mods/" .. modId then
+ return { type = "directory" }
+ end
+ end,
+ getDirectoryItems = function(path)
+ return path == "mods" and { modId } or {}
+ end,
+ }
+ local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version })
+ local f = { loader = loader, gen = gen, home = home, dest = dest, trace = {}, busy = false }
+ loader._game = function(_, loadedOnly)
+ assert(backend ~= "gen2" or loadedOnly, "no Game bootstrap")
+ return f.live
+ end
+ assert(loader:load(data), table.concat(loader.errors, "; "))
+ f.exports = assert(loader.exports[modId])
+ local save = { party = {}, flags = {}, modData = {} }
+ local player = { cellX = 0, cellY = 0, facing = "left" }
+ local ow = {
+ isOverworld = true,
+ player = player,
+ map = { id = home, def = data.maps[home] },
+ maps = data.maps,
+ runner = {
+ isRunning = function()
+ return false
+ end,
+ },
+ scriptMoves = {},
+ }
+ local stack = { states = { ow } }
+ function stack:top()
+ return self.states[#self.states]
+ end
+ local game = { data = data, save = save, session = save, stack = stack, phase = "field", overworld = ow }
+ f.game, f.live, f.ow, f.player = game, game, ow, player
+ function f:dispatch(id, x, y, facing, opts)
+ self.trace[#self.trace + 1] = {
+ mapId = id,
+ x = x,
+ y = y,
+ facing = facing,
+ arrive = opts.arrive,
+ keepMusic = opts.keepMusic,
+ fade = opts.fade,
+ }
+ self.callback = opts.onDone
+ self.destination = { id, x, y, facing }
+ self.busy, ow.transitioning = true, true
+ if self.synchronous then
+ self:arrive()
+ self:complete("sync", nil, "tail")
+ end
+ if self.failure == "throw" then
+ error("native warp probe", 0)
+ end
+ if self.failure == "refuse" then
+ return false, "warp refused"
+ end
+ return true
+ end
+ function ow:startWarpTo(id, x, y, facing, onDone, opts)
+ local arrive = self.arriveWarp
+ self.arriveWarp = nil
+ local ok, why = f:dispatch(
+ id,
+ x,
+ y,
+ facing,
+ { onDone = onDone, arrive = arrive, keepMusic = opts and opts.keepMusic, fade = opts and opts.fade }
+ )
+ if not ok then
+ return ok, why
+ end
+ -- Native Gen 1/controller returns no value on successful admission.
+ end
+ function ow:acceptsMenuInput()
+ return not (f.busy or player.moving)
+ end
+ function ow:warpToMapId(id, x, y, facing, opts)
+ return f:dispatch(id, x, y, facing, opts or {})
+ end
+ if backend == "gen1" and gen == 2 then
+ game.world = ow
+ end
+ if backend == "gen1" and gen == 3 then
+ local field = { running = true, _game = game, _session = save, _locks = {} }
+ f.field = field
+ package.loaded["src.core.game3.field"] = field
+ package.loaded["src.core.game3.runtime"] = {
+ getSession = function()
+ return game.session
+ end,
+ uiBusy = function()
+ return f.busy
+ end,
+ }
+ local map = {
+ current = home,
+ currentDef = function()
+ return data.maps[f.map.current]
+ end,
+ }
+ f.map = map
+ package.loaded["src.core.game3.map"] = map
+ package.loaded["src.core.game3.player"] = player
+ package.loaded["src.core.game3.scripting.space"] = {}
+ package.loaded["src.core.game3.battle.init"] = {
+ isActive = function()
+ return false
+ end,
+ }
+ package.loaded["src.core.game3.warp"] = {
+ isBusy = function()
+ return f.busy
+ end,
+ request = function(_, owner, id, x, y, facing, opts)
+ assert(owner == game, "native request retains Game")
+ return f:dispatch(id, x, y, facing, opts)
+ end,
+ }
+ end
+ function f:arrive(id)
+ local d = assert(self.destination)
+ local from = ow.map.id
+ Runtime.emit("map.exited", { mapId = from, toMapId = id or d[1] })
+ ow.map = { id = id or d[1], def = data.maps[id or d[1]] }
+ player.cellX, player.cellY, player.facing = d[2], d[3], d[4]
+ if self.map then
+ self.map.current = ow.map.id
+ end
+ Runtime.emit("map.entered", { mapId = ow.map.id, fromMapId = from, via = "warp", map = ow.map })
+ end
+ function f:complete(...)
+ self.busy, ow.transitioning = false, false
+ if self.callback then
+ self.callback(...)
+ end
+ end
+ function f:update()
+ local name = backend == "gen1" and "core.update" or "input.step"
+ return loader.hooks:call(name, function()
+ return "updated", nil, "tail"
+ end, game, 0)
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local Runtime = require("src.mods.Runtime")
+ local Json = require("src.link.Json")
+ local report = { invalid = {}, busy = {}, successful = {} }
+ local function fixture()
+ return M.fixture(root, backend, version)
+ end
+ local f = fixture()
+ local function refused(args, expected)
+ local before = #f.trace
+ local ok, why = f.exports.request(unpack(args, 1, args.n or #args))
+ assert(ok == nil and why == expected, tostring(why) .. " ~= " .. expected)
+ assert(#f.trace == before and f.ow.map.id == f.home and f.player.cellX == 0)
+ assert(f.ow.arriveWarp == nil and #f.exports.results().events == 0)
+ report.invalid[#report.invalid + 1] = why
+ end
+ refused({ false, 0, 0 }, "invalid map id")
+ refused({ "", 0, 0 }, "invalid map id")
+ refused({ "bad\nmap", 0, 0 }, "invalid map id")
+ refused({ string.rep("x", 257), 0, 0 }, "invalid map id")
+ refused({ "UNKNOWN", 0, 0 }, "unknown map: UNKNOWN")
+ for _, coordinate in ipairs({ -1, 0.5, math.huge, -math.huge, 0 / 0, "0", false }) do
+ refused({ f.dest, coordinate, 0 }, "invalid coordinates")
+ refused({ f.dest, 0, coordinate }, "invalid coordinates")
+ end
+ local scale = f.gen == 3 and 1 or 2
+ refused({ f.dest, 2 * scale, 0 }, "coordinates out of bounds")
+ refused({ f.dest, 0, 3 * scale }, "coordinates out of bounds")
+ refused({ f.dest, 0, 0, "north" }, "invalid facing")
+ refused({ f.dest, 0, 0, "up", false }, "invalid options")
+ refused({ f.dest, 0, 0, "up", { arrive = "boat" } }, "invalid arrival")
+ refused({ f.dest, 0, 0, "up", { onDone = false } }, "invalid callback")
+ refused({ f.dest, 0, 0, "up", { keepMusic = 1 } }, "invalid options")
+ refused({ f.dest, 0, 0, "up", { fade = 1 } }, "invalid options")
+ refused({ f.dest, 0, 0, "up", { extra = true } }, "unknown warp option: extra")
+ if f.gen ~= 3 then
+ refused({ f.dest, 0, 0, "up", { fade = false } }, "unsupported fade")
+ end
+ for _, lock in ipairs({ "transitioning", "moving", "script", "overlay", "modscript" }) do
+ f = fixture()
+ if lock == "transitioning" then
+ f.ow.transitioning, f.busy = true, true
+ if f.field then
+ f.field.locked = true
+ end
+ elseif lock == "moving" then
+ f.player.moving = true
+ elseif lock == "script" then
+ f.ow.runner.isRunning = function()
+ return true
+ end
+ f.busy = true
+ if f.field then
+ package.loaded["src.core.game3.scripting.space"] =
+ { vm = {
+ isRunning = function()
+ return true
+ end,
+ } }
+ end
+ elseif lock == "overlay" then
+ f.game.stack.states[2] = {}
+ f.busy = true
+ else
+ f.ow._modScriptLock = {}
+ f.busy = true
+ if f.field then
+ f.field._modScriptLock = {}
+ end
+ end
+ local ok, why = f.exports.request(f.dest, 0, 0)
+ assert(ok == nil and type(why) == "string" and #f.trace == 0, lock .. ": " .. tostring(why))
+ report.busy[#report.busy + 1] = lock
+ end
+ for _, options in ipairs({
+ {},
+ { arrive = "fly", keepMusic = true },
+ { arrive = "teleport", keepMusic = false },
+ }) do
+ f = fixture()
+ options.onDone = f.exports.notify
+ assert(f.exports.request(f.dest, 2 * scale - 1, 3 * scale - 1, "right", options))
+ assert(f.exports.results().calls == 0 and #f.trace == 1)
+ local expectedArrival, expectedMusic = options.arrive, options.keepMusic
+ options.arrive, options.keepMusic, options.onDone =
+ "changed", true, function()
+ error("mutable options")
+ end
+ assert(f.trace[1].arrive == expectedArrival and f.trace[1].keepMusic == expectedMusic)
+ f:arrive()
+ f:complete("done", nil, "tail")
+ f:complete("replay")
+ assert(f.exports.results().calls == 1 and f.exports.results().args.n == 3)
+ assert(f.exports.results().args.first == "done" and f.exports.results().args.third == "tail")
+ local location = assert(f.exports.current())
+ assert(location.mapId == f.dest and location.x == 2 * scale - 1 and location.y == 3 * scale - 1)
+ report.successful[#report.successful + 1] = { native = f.trace[1], result = f.exports.results() }
+ end
+ f = fixture()
+ assert(f.exports.request(f.dest, 0, 0))
+ assert(f.trace[1].facing == (f.gen == 1 and "down" or "left"))
+ f:arrive()
+ f:complete()
+ report.defaultFacing = f.trace[1].facing
+ if f.gen == 3 then
+ f = fixture()
+ assert(f.exports.request("DEST", 1, 2, "up", { fade = false, onDone = f.exports.notify }))
+ assert(f.trace[1].mapId == f.dest and f.trace[1].fade == false)
+ f:arrive()
+ f:complete()
+ report.gen3Alias = true
+ end
+ f = fixture()
+ f.synchronous = true
+ assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }))
+ assert(f.exports.results().calls == 1 and f.exports.results().args.first == "sync")
+ report.synchronous = true
+ for _, change in ipairs({ "game", "save", "world", "bus", "owner", "other_map" }) do
+ f = fixture()
+ assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }))
+ f:arrive()
+ local restore
+ if change == "game" then
+ local old = f.live
+ f.live = {}
+ restore = function()
+ f.live = old
+ end
+ elseif change == "save" then
+ local old = f.game.save
+ f.game.save = {}
+ f.game.session = f.game.save
+ restore = function()
+ f.game.save = old
+ f.game.session = old
+ end
+ elseif change == "world" then
+ if backend == "gen1" and f.gen == 3 then
+ local old = package.loaded["src.core.game3.field"]
+ package.loaded["src.core.game3.field"] = { running = true }
+ restore = function()
+ package.loaded["src.core.game3.field"] = old
+ end
+ else
+ local old = f.game.stack.states
+ f.game.stack.states = {}
+ f.game.overworld = nil
+ f.game.world = nil
+ restore = function()
+ f.game.stack.states = old
+ f.game.overworld = f.ow
+ if f.gen == 2 then
+ f.game.world = f.ow
+ end
+ end
+ end
+ elseif change == "bus" then
+ local old = Runtime.events
+ Runtime.events = require("src.mods.Events").new()
+ restore = function()
+ Runtime.events = old
+ end
+ elseif change == "owner" then
+ f.loader:_rollback("warp")
+ else
+ f:arrive(f.home)
+ end
+ f:update()
+ -- A stale native completion must suppress the callback permanently.
+ f:complete("stale")
+ if restore then
+ restore()
+ end
+ f:complete("revived")
+ assert(f.exports.results().calls == 0, change .. " callback survived invalidation")
+ end
+ report.stale = true
+ f = fixture()
+ assert(f.exports.request(f.dest, 0, 0, "up", {
+ onDone = function()
+ error("authored warp failure", 0)
+ end,
+ }))
+ f:arrive()
+ f:complete()
+ f:complete()
+ assert(#f.loader.errors == 1 and f.loader.errors[1]:find("warp: world warp callback failed:", 1, true))
+ assert(Runtime.currentMod == nil)
+ report.attribution = true
+ print("API3_CONTRACT " .. Json.encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/wild_battle.lua b/tests/api3_contract/wild_battle.lua
new file mode 100644
index 000000000..704addeb1
--- /dev/null
+++ b/tests/api3_contract/wild_battle.lua
@@ -0,0 +1,128 @@
+-- Production public facades/loaders; native battle entry/timing seams only.
+local M = {}
+function M.fixture(root, backend, version, modId)
+ local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/wild_battle%.lua$")
+ local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "wild_battle")
+ love = love or require("tests.love_stub")
+ f.game.save.party = { { species = "MEW", hp = 20, level = 10, stats = { hp = 20 } } }
+ f.game.save.pokedex = { seen = {}, caught = {} }
+ f.constructed, f.pushed, f.after = 0, 0, 0
+ function f.ow:afterBattle(result, battle)
+ f.after = f.after + 1
+ f.lastResult, f.cleanedBattle = result, battle
+ if f.cleanupError then
+ error("native cleanup probe", 0)
+ end
+ end
+ function f:admit(battle, callback, nativeCleanup)
+ self.pushed = self.pushed + 1
+ self.battle, self.finish, self.nativeCleanup = battle, callback, nativeCleanup
+ self.busy = true
+ self.game.stack.states[2] = battle
+ if self.synchronous then
+ self:complete("win", nil, "tail")
+ end
+ if self.pushFailure then
+ return false, "battle failed"
+ end
+ -- Native host admission is void on success.
+ end
+ function f:complete(...)
+ self.busy = false
+ self.game.stack.states[2] = nil
+ if self.nativeCleanup and not self.cleaned then
+ self.cleaned = true
+ self.ow:afterBattle((...), self.battle)
+ end
+ return self.finish(...)
+ end
+ local original = package.loaded["src.battle.BattleState"]
+ local function construct(game, species, level)
+ f.constructed = f.constructed + 1
+ game.save.pokedex.seen[species] = true
+ if f.constructError then
+ error("native constructor probe", 0)
+ end
+ return { kind = "wild", species = species, level = level }
+ end
+ package.loaded["src.battle.BattleState"] = { newWild = construct }
+ f.restore = function()
+ package.loaded["src.battle.BattleState"] = original
+ end
+ function f.ow:pushBattle(battle)
+ return f:admit(battle, battle.onFinish, false)
+ end
+ if backend == "gen1" and f.gen == 2 then
+ package.loaded["src.battle.gen2.Mon"] = {
+ new = function(data, species, level)
+ return construct(f.game, species, level)
+ end,
+ }
+ function f.ow:startBattle(spec, onDone)
+ local result, reason = f:admit(spec.wild, onDone or function() end, true)
+ if result == false then
+ return result, reason
+ end
+ return true
+ end
+ elseif backend == "gen1" and f.gen == 3 then
+ local pokemon = require("src.core.game3.pokemon")
+ for key, value in pairs(f.game.data.gen3Pokemon) do
+ pokemon[key] = value
+ end
+ pokemon._byName = { MEW = 151 }
+ package.loaded["src.core.game3.battle_bridge"] = {
+ startWild = function(_, game, spec, opts)
+ local b = construct(game, spec.species, spec.level)
+ local result, reason = f:admit(b, opts and opts.done or function() end, true)
+ if result == false then
+ return result, reason
+ end
+ return true
+ end,
+ }
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local f = M.fixture(root, backend, version)
+ local report = { invalid = {} }
+ for _, args in ipairs({
+ { "", 5 },
+ { "NO_SUCH_SPECIES", 5 },
+ { "MEW", 0 },
+ { "MEW", 1.5 },
+ { "MEW", 101 },
+ { "MEW", 5, false },
+ }) do
+ local value, why = f.exports.request(unpack(args))
+ assert(value == nil, "invalid battle accepted")
+ report.invalid[#report.invalid + 1] = why
+ assert(f.constructed == 0 and f.pushed == 0 and next(f.game.save.pokedex.seen) == nil)
+ end
+ f.game.save.party[1].hp = 0
+ local value, why = f.exports.request("MEW", 5)
+ assert(value == nil and why == "no healthy party")
+ report.empty = why
+ f.game.save.party[1].hp = 20
+ assert(f.exports.request("MEW", 5, f.exports.notify))
+ assert(f.battle.level == 5 and f.pushed == 1)
+ value, why = f.exports.request("MEW", 5)
+ assert(value == nil and why == "world is busy")
+ f:complete("win", nil, "tail")
+ assert(f.after == 1)
+ f:complete("lose")
+ local r = f.exports.result()
+ assert(r.calls == 1 and r.args.n == 3 and r.args.third == "tail")
+ report.complete = r
+ f.restore()
+ f = M.fixture(root, backend, version)
+ f.synchronous = true
+ assert(f.exports.request("MEW", 100, f.exports.notify))
+ assert(f.exports.result().calls == 1 and f.after == 1)
+ report.synchronous = f.exports.result()
+ f.restore()
+ print("API3_CONTRACT " .. require("src.link.Json").encode(report))
+ return report
+end
+return M
diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua
new file mode 100644
index 000000000..d10e79740
--- /dev/null
+++ b/tests/api3_contract/worker.lua
@@ -0,0 +1,85 @@
+-- Separate process per engine/version: modules and runtime buses cannot leak.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+love = nil
+local root, backend, version, mode = arg[1], arg[2], arg[3], arg[4]
+assert(
+ root
+ and backend
+ and version
+ and (
+ mode == "normal"
+ or mode == "rollback"
+ or mode == "scaffold"
+ or mode == "warp"
+ or mode == "world_edits"
+ or mode == "npcs"
+ or mode == "wild_battle"
+ or mode == "invalidate"
+ or mode == "flags"
+ or mode == "script_flags"
+ or mode == "script_state"
+ or mode == "script_gifts"
+ or mode == "script_world"
+ or mode == "script_motion"
+ or mode == "script_travel"
+ or mode == "script_battles"
+ or mode == "script_trainers"
+ or mode == "script_shops"
+ or mode == "script_audio"
+ or mode == "script_dialogue_audio"
+ or mode == "script_fields"
+ or mode == "effects"
+ or mode == "datasets"
+ or mode == "render_output"
+ or mode == "viewport"
+ ),
+ "invalid worker arguments"
+)
+local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$")
+if mode == "viewport" then
+ dofile(codeRoot .. "/viewport.lua").run(root, backend, version)
+elseif mode == "render_output" then
+ dofile(codeRoot .. "/render_output.lua").run(root, backend, version)
+elseif mode == "datasets" then
+ dofile(codeRoot .. "/datasets.lua").run(root, backend, version)
+elseif mode == "effects" then
+ dofile(codeRoot .. "/effects.lua").run(root, backend, version)
+elseif mode == "script_fields" then
+ dofile(codeRoot .. "/script_fields.lua").run(root, backend, version)
+elseif mode == "script_dialogue_audio" then
+ dofile(codeRoot .. "/script_dialogue_audio.lua").run(root, backend, version)
+elseif mode == "script_audio" then
+ dofile(codeRoot .. "/script_audio.lua").run(root, backend, version)
+elseif mode == "script_shops" then
+ dofile(codeRoot .. "/script_shops.lua").run(root, backend, version)
+elseif mode == "script_trainers" then
+ dofile(codeRoot .. "/script_trainers.lua").run(root, backend, version)
+elseif mode == "script_battles" then
+ dofile(codeRoot .. "/script_battles.lua").run(root, backend, version)
+elseif mode == "script_travel" then
+ dofile(codeRoot .. "/script_travel.lua").run(root, backend, version)
+elseif mode == "script_motion" then
+ dofile(codeRoot .. "/script_motion.lua").run(root, backend, version)
+elseif mode == "script_world" then
+ dofile(codeRoot .. "/script_world.lua").run(root, backend, version)
+elseif mode == "script_gifts" then
+ dofile(codeRoot .. "/script_gifts.lua").run(root, backend, version)
+elseif mode == "script_state" then
+ dofile(codeRoot .. "/script_state.lua").run(root, backend, version)
+elseif mode == "script_flags" then
+ dofile(codeRoot .. "/script_flags.lua").run(root, backend, version)
+elseif mode == "flags" then
+ dofile(codeRoot .. "/flags.lua").run(root, backend, version)
+elseif mode == "invalidate" then
+ dofile(codeRoot .. "/invalidate.lua").run(root, backend, version)
+elseif mode == "wild_battle" then
+ dofile(codeRoot .. "/wild_battle.lua").run(root, backend, version)
+elseif mode == "npcs" then
+ dofile(codeRoot .. "/npcs.lua").run(root, backend, version)
+elseif mode == "world_edits" then
+ dofile(codeRoot .. "/world_edits.lua").run(root, backend, version)
+elseif mode == "warp" then
+ dofile(codeRoot .. "/warp.lua").run(root, backend, version)
+else
+ dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold")
+end
diff --git a/tests/api3_contract/world_edits.lua b/tests/api3_contract/world_edits.lua
new file mode 100644
index 000000000..8f7527a30
--- /dev/null
+++ b/tests/api3_contract/world_edits.lua
@@ -0,0 +1,230 @@
+-- Real loaders, adapters and host commands, with native-shaped map/render seams.
+local M = {}
+local unpack = table.unpack or unpack
+function M.fixture(root, backend, version, modId)
+ local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/world_edits%.lua$")
+ local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "edits")
+ local Runtime = require("src.mods.Runtime")
+ local ow, game = f.ow, f.game
+ local def = game.data.maps[f.home]
+ local flagged =
+ { index = 4, localId = 4, name = "NPC", id = "npc", x = 1, y = 1, eventFlag = "FLAG_A", flag = 7 }
+ local flagless = { index = 8, localId = 8, name = "TEMP", x = 0, y = 1 }
+ def.objects = { flagged, flagless }
+ def.blocks = { 7, 8, 7, 8, 7, 8 }
+ local tiles = {
+ walkable = {},
+ blocks = { [1] = {}, [8] = {}, [9] = {}, [256] = {} },
+ collision = {},
+ blockCells = f.gen == 3 and 1 or 2,
+ blockTiles = f.gen == 3 and 2 or 4,
+ }
+ f.flagged, f.flagless, f.visible = flagged, flagless, { NPC = true, TEMP = true }
+ f.rebuilds, f.syncs, f.reloads = 0, 0, 0
+ if backend == "gen1" and f.gen == 2 then
+ -- Native Gen2 object definitions use list keys; public refs are still 1-based.
+ flagged.index, flagless.index, flagged.eventFlag = 1, 2, 7
+ ow.map = require("src.world.gen2.Map").new(def, tiles)
+ elseif backend == "gen1" and f.gen == 3 then
+ -- Native Gen3 uses the reflected layout and field override service.
+ local overrides = {}
+ def.midLayout.midAt = function(_, x, y)
+ return def.blocks[y * def.width + x + 1]
+ end
+ f.field.metatileOverrideAt = function(_, x, y)
+ return overrides[y * 1024 + x]
+ end
+ f.field.setMetatile = function(x, y, block)
+ overrides[y * 1024 + x] = { metatile = block }
+ f.rebuilds = f.rebuilds + 1
+ Runtime.emit("world.block_replaced", { mapId = f.home, bx = x, by = y, block = block })
+ end
+ package.loaded["src.core.game3.objects"] = {
+ _defs = { flagged, flagless },
+ addObject = function(id)
+ local obj = id == flagged.localId and flagged or flagless
+ f.visible[obj.name] = true
+ if obj.flag then
+ game.save.flags[obj.flag] = false
+ end
+ end,
+ removeObject = function(id)
+ local obj = id == flagged.localId and flagged or flagless
+ f.visible[obj.name] = false
+ if obj.flag then
+ game.save.flags[obj.flag] = true
+ end
+ end,
+ }
+ else
+ ow.map = require("src.world.Map").new(def, tiles)
+ end
+ ow.map.renderer = {
+ rebuild = function()
+ f.rebuilds = f.rebuilds + 1
+ end,
+ }
+ function ow:replaceBlock(x, y, block)
+ self.map:setBlock(x, y, block)
+ self.map.renderer:rebuild()
+ self.map.blocksDirty = nil
+ Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block })
+ end
+ function ow:changeBlock(x, y, block)
+ self.map.blocks[y * self.map.width + x + 1] = block
+ self.map.renderer:rebuild()
+ Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block })
+ end
+ function ow:appearObject(id)
+ local obj = def.objects[id - 1]
+ f.visible[obj.name] = true
+ if obj.eventFlag then
+ game.save.flags[obj.eventFlag] = false
+ end
+ end
+ function ow:disappearObject(id)
+ local obj = def.objects[id - 1]
+ f.visible[obj.name] = false
+ if obj.eventFlag then
+ game.save.flags[obj.eventFlag] = true
+ end
+ end
+ function ow:syncObjectVisibility(obj)
+ f.syncs = f.syncs + 1
+ local save = game.save
+ local session = f.gen == 2 and save.g2ObjectToggles or save.gen3SessionObjects
+ local key = f.gen == 2 and obj.name or obj.index
+ local value = session and session.mapId == f.home and session[key]
+ if obj.eventFlag and save.flags and save.flags[obj.eventFlag] ~= nil then
+ value = not save.flags[obj.eventFlag]
+ end
+ f.visible[obj.name] = value == nil and true or value
+ end
+ function ow:setMap(id, x, y, facing, opts)
+ f.reloads = f.reloads + 1
+ assert(id == f.home and x == 0 and y == 0 and facing == "left", "seamless refresh preserves player")
+ assert(opts.seamless and opts.keepMusic and opts.via == "reload")
+ for _, obj in ipairs(def.objects) do
+ local toggles = game.save.objectToggles[id]
+ if toggles[obj.name] ~= nil then
+ f.visible[obj.name] = toggles[obj.name]
+ end
+ end
+ end
+ function f:flagValue()
+ local key = backend == "gen1" and self.gen > 1 and 7 or "FLAG_A"
+ return game.save.flags[key]
+ end
+ return f
+end
+function M.run(root, backend, version)
+ local Json = require("src.link.Json")
+ local f = M.fixture(root, backend, version)
+ local report = { invalid = {}, toggles = {}, blocks = {} }
+ local function unchanged()
+ assert(#f.exports.events() == 0 and f.rebuilds == 0 and f.reloads == 0 and f.syncs == 0)
+ assert(next(f.game.save.flags) == nil and f.game.save.objectToggles == nil)
+ assert(f.visible.NPC and f.visible.TEMP and f.player.cellX == 0 and f.player.cellY == 0)
+ end
+ local function refusal(method, args, reason)
+ local ok, why = f.exports[method](unpack(args, 1, args.n or #args))
+ assert(ok == nil and why == reason, tostring(why) .. " ~= " .. reason)
+ unchanged()
+ report.invalid[#report.invalid + 1] = why
+ end
+ for _, bad in ipairs({ false, "", "bad\nname", string.rep("x", 257) }) do
+ refusal("toggle", { bad, "NPC", false }, "invalid object toggle")
+ refusal("toggle", { f.home, bad, false }, "invalid object toggle")
+ end
+ for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0 }) do
+ refusal("toggle", { f.home, bad, false }, "invalid object toggle")
+ end
+ refusal("toggle", { "UNKNOWN", "NPC", false }, "unknown map: UNKNOWN")
+ refusal("toggle", { f.home, "NPC", "yes" }, "invalid object toggle")
+ for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "7", false, f.gen == 3 and 1024 or 256 }) do
+ refusal("replace", { 0, 0, bad }, "invalid block id")
+ end
+ for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "0", false }) do
+ refusal("replace", { bad, 0, 7 }, "invalid block coordinates")
+ refusal("replace", { 0, bad, 7 }, "invalid block coordinates")
+ end
+ refusal("replace", { 2, 0, 7 }, "block coordinates out of bounds")
+ refusal("replace", { 0, 3, 7 }, "block coordinates out of bounds")
+ if f.gen ~= 3 then
+ refusal("replace", { 0, 0, 9 }, "unknown block id")
+ end
+ if f.gen > 1 then
+ refusal("toggle", { f.dest, "NPC", false }, "map is not active: " .. f.dest)
+ refusal("toggle", { f.home, "UNKNOWN", false }, "no such object: UNKNOWN")
+ refusal("toggle", { f.home, 0, false }, "no such object: 0")
+ end
+ for _, state in ipairs({ "overlay", "moving", "script", "transition" }) do
+ local other = M.fixture(root, backend, version)
+ if state == "overlay" then
+ other.game.stack.states[2] = {}
+ other.busy = true
+ elseif state == "moving" then
+ other.player.moving = true
+ elseif state == "script" then
+ other.busy = true
+ other.ow.runner.isRunning = function()
+ return true
+ end
+ if other.field then
+ package.loaded["src.core.game3.scripting.space"] =
+ { vm = {
+ isRunning = function()
+ return true
+ end,
+ } }
+ end
+ else
+ other.busy, other.ow.transitioning = true, true
+ if other.field then
+ other.field.locked = true
+ end
+ end
+ for _, method in ipairs({ "toggle", "replace" }) do
+ local ok, why
+ if method == "toggle" then
+ ok, why = other.exports.toggle(other.home, "NPC", false)
+ else
+ ok, why = other.exports.replace(0, 0, 7)
+ end
+ assert(ok == nil and type(why) == "string" and #other.exports.events() == 0)
+ end
+ end
+ f = M.fixture(root, backend, version)
+ for _, visible in ipairs({ false, true, true, false }) do
+ assert(f.exports.toggle(f.home, "NPC", visible))
+ assert(f.visible.NPC == visible and f.visible.TEMP == true)
+ if f.gen > 1 then
+ assert(f:flagValue() == not visible)
+ end
+ report.toggles[#report.toggles + 1] = { visible = f.visible.NPC, flag = f:flagValue() }
+ end
+ -- Numeric refs retain native generation meaning: Gen1 a key, Gen2 list
+ -- position, Gen3 local id. A portable mod queries the active map definition.
+ if f.gen > 1 then
+ assert(f.exports.toggle(f.home, f.gen == 2 and 2 or 8, false))
+ assert(not f.visible.TEMP)
+ assert(f.exports.toggle(f.home, "TEMP", true))
+ assert(f.visible.TEMP)
+ else
+ assert(f.exports.toggle(f.dest, "LATER", false))
+ assert(f.game.save.objectToggles[f.dest].LATER == false)
+ end
+ for _, op in ipairs({ { 0, 0, 8 }, { 1, 2, f.gen == 3 and 1023 or 255 }, { 0, 0, 0 } }) do
+ assert(f.exports.replace(unpack(op)))
+ local value, metadata = f.exports.read(f.home, op[1], op[2])
+ assert(value == op[3] and metadata.coordinateBase == 0 and metadata.tileSize == (f.gen == 3 and 16 or 32))
+ report.blocks[#report.blocks + 1] = { x = op[1], y = op[2], block = value, kind = metadata.kind }
+ end
+ assert(f.rebuilds == 3 and f.player.cellX == 0 and f.player.cellY == 0)
+ assert(#f.loader.errors == 0)
+ report.events = f.exports.events()
+ report.busy = true
+ print("API3_CONTRACT " .. Json.encode(report))
+ return report
+end
+return M
diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua
new file mode 100644
index 000000000..e028c128d
--- /dev/null
+++ b/tests/engine/api3_compat.lua
@@ -0,0 +1,223 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.harness")
+local Manifest = require("src.mods.Manifest")
+local Loader = require("src.mods.Loader")
+local Version = require("src.core.GameVersion")
+local Runtime = require("src.mods.Runtime")
+local Json = require("src.link.Json")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love }
+love = nil
+local function manifest(api, extra)
+ local raw = { id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua", api = api }
+ for k, v in pairs(extra or {}) do
+ raw[k] = v
+ end
+ return raw
+end
+for _, api in ipairs({ 1, 2, 3 }) do
+ local m = Manifest.validate(manifest(api))
+ local format = api == 3 and "unified" or "native"
+ for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do
+ T.eq(m[field], format, "API " .. api .. " default " .. field)
+ end
+end
+T.eq(Manifest.validate(manifest()).api, 1, "missing API remains legacy")
+for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do
+ T.raises(function()
+ Manifest.validate(manifest(3, { [field] = "native" }))
+ end, "API 3 requires unified", "API 3 refuses contradictory " .. field)
+ T.eq(
+ Manifest.validate(manifest(2, { [field] = "unified" }))[field],
+ "unified",
+ "API 2 keeps explicit opt-in"
+ )
+ T.raises(function()
+ Manifest.validate(manifest(1, { [field] = "unified" }))
+ end, "mod API 2", "API 1 opt-in remains rejected")
+end
+T.raises(function()
+ Manifest.validate(manifest(4))
+end, "provides 3", "future API not silently accepted")
+T.raises(function()
+ Manifest.validate(manifest(3.5))
+end, "positive integer", "fractional API rejected")
+local function boot(api, extra, second, version)
+ Version.set(version or "red")
+ local rows = { probe = manifest(api, extra) }
+ if second then
+ rows.other = manifest(api, extra)
+ rows.other.id = "other"
+ end
+ local files = {}
+ for id, raw in pairs(rows) do
+ files["mods/" .. id .. "/manifest.json"] = Json.encode(raw)
+ files["mods/" .. id .. "/main.lua"] = "local mod = ...; mod.exports.api = mod"
+ end
+ local fs = {
+ read = function(p)
+ return files[p]
+ end,
+ load = function(p)
+ return loadstring(files[p], p)
+ end,
+ getInfo = function(p)
+ if files[p] then
+ return { type = "file" }
+ end
+ if p == "mods" or rows[p:match("^mods/(.*)$")] then
+ return { type = "directory" }
+ end
+ end,
+ getDirectoryItems = function(p)
+ return p == "mods" and (second and { "probe", "other" } or { "probe" }) or {}
+ end,
+ }
+ local loader = Loader.new({ fs = fs, dev = true })
+ local game, probes = nil, 0
+ loader._game = function(_, loadedOnly)
+ probes = probes + 1
+ if api == 3 then
+ assert(loadedOnly, "API 3 requested Game bootstrap")
+ end
+ return game
+ end
+ T.check(loader:load({ pokemon = {}, moves = {}, items = {} }), "real loader accepts API " .. api)
+ local mod = assert(loader.apis.probe)
+ return mod, loader, function(value)
+ game = value
+ end, function()
+ return probes
+ end
+end
+-- Real legacy loaders keep native defaults, API-2 status vocabulary and callback catalog.
+for _, api in ipairs({ 1, 2 }) do
+ local m = boot(api)
+ T.eq(m.contentFormat, "native", "legacy content native")
+ T.eq(m.callbackFormat, "native", "legacy callbacks native")
+ T.eq(m.scriptFormat, "native", "legacy scripts native")
+end
+do
+ local m, loader, attach =
+ boot(2, { content_format = "unified", callback_format = "unified", script_format = "unified" })
+ local mon = {
+ species = "MEW",
+ nickname = "Scout",
+ hp = 10,
+ status = "PSN",
+ stats = { hp = 20, special = 9 },
+ moves = {},
+ }
+ attach({
+ save = { party = { mon } },
+ data = { pokemon = {}, moves = {}, items = {} },
+ stack = {
+ states = {},
+ },
+ })
+ T.eq(m.world:party()[1].status, "poison", "API-2 unified party status unchanged")
+ local seen
+ m.events:on("pokemon.received", function(p)
+ seen = p.mon.status
+ end)
+ Runtime.emit("pokemon.received", { mon = mon })
+ T.eq(seen, "poison", "API-2 unified event status unchanged")
+ T.raises(function()
+ m.events:on("checkpoint.restored", function() end)
+ end, "unknown unified callback", "API-2 catalog unchanged")
+end
+-- Availability must be honest, and a probe cannot construct Game or attach a world.
+do
+ local m, loader, attach, probes = boot(3, nil, true)
+ T.eq(probes(), 0, "entry does not resolve Game")
+ local world, battle = m.world, m.battle
+ T.eq(type(world), "table", "world facade exists before Game")
+ T.eq(type(world.party), "function", "early world method callable")
+ T.eq(type(battle.snapshot), "function", "early battle method callable")
+ for _, name in ipairs({
+ "invalidateMap",
+ "startWildBattle",
+ "spawnNpc",
+ "removeNpc",
+ "npc",
+ "warpTo",
+ "toggleObject",
+ "replaceBlock",
+ "party",
+ "current",
+ "activeBlockAt",
+ "liveMaps",
+ "queueScript",
+ "getFlag",
+ "setFlag",
+ }) do
+ T.eq(m:supports("world", name), true, "implemented world method " .. name)
+ end
+ T.eq(probes(), 0, "capability and unsupported calls do not resolve Game")
+ T.eq(world:party(), nil, "no-game party refuses safely")
+ T.eq(battle:snapshot(), nil, "no-game battle refuses safely")
+ T.eq(m.datasets:open("gold"), nil, "unavailable dataset API callable")
+ T.eq(m:supports("hooks", "render.output"), true, "implemented output hook is available")
+ T.check(
+ pcall(function()
+ m.hooks:wrap("render.output", function(next, ctx)
+ return next(ctx)
+ end)
+ end),
+ "known absent hook can be registered"
+ )
+ T.check(
+ pcall(function()
+ m.events:on("checkpoint.restored", function() end)
+ end),
+ "known absent event can be registered"
+ )
+ T.raises(function()
+ m.events:on("not.catalogued", function() end)
+ end, "unknown unified callback", "unknown API-3 name rejected")
+ local save = { party = {}, flags = {}, modData = {} }
+ attach({ save = save, data = {}, stack = { states = {} } })
+ local flags = {}
+ m.events:on("flag.changed", function(p)
+ flags[#flags + 1] = { p.name, p.value }
+ end)
+ T.eq(world:getFlag("mod:quest.finished"), false, "flag read defaults false")
+ T.eq(next(save.modData), nil, "flag read allocates no bucket")
+ T.check(world:setFlag("mod:quest.finished", true), "private flag set")
+ T.check(world:setFlag("mod:quest.finished", true), "setting same flag accepted")
+ T.eq(#flags, 1, "unchanged flag emits no duplicate event")
+ T.eq(loader.apis.other.world:getFlag("mod:quest.finished"), false, "other mod cannot see flag")
+ T.check(world:setFlag("mod:quest.finished", false), "private flag cleared")
+ T.eq(save.modData.probe.worldFlags["quest.finished"], nil, "clear removes saved boolean")
+ T.same(
+ flags,
+ { { "mod.probe.quest.finished", true }, { "mod.probe.quest.finished", false } },
+ "portable flag notifications"
+ )
+ T.eq(next(save.flags), nil, "private flags never touch native flags")
+ T.eq(world:setFlag("mod:bad", "yes"), nil, "non-boolean flag rejected")
+ T.eq(world:setFlag("NATIVE_FLAG", true), nil, "unknown native flag refused")
+ T.eq(world:setFlag("mod:", true), nil, "empty private flag rejected")
+ save.modData.probe.worldFlags = false
+ T.eq(world:setFlag("mod:test", true), nil, "malformed store rejected")
+ T.eq(save.modData.probe.worldFlags, false, "malformed store never overwritten")
+ attach({ save = { party = {} }, data = {}, stack = { states = {} } })
+ T.eq(world:getFlag("mod:quest.finished"), false, "same handle follows replacement save")
+ T.eq(m.world, world, "world handle identity stable")
+ T.eq(m.battle, battle, "battle handle identity stable")
+end
+for _, version in ipairs({ "platinum", "black" }) do
+ local m = boot(3, nil, false, version)
+ T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version)
+ for _, name in ipairs({ "spawnNpc", "removeNpc", "npc", "startWildBattle", "invalidateMap" }) do
+ T.eq(m:supports("world", name), false, "no shared NPC API promised for " .. version)
+ end
+ T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version)
+ T.eq(m:supports("world", "toggleObject"), false, "no shared object edits promised for " .. version)
+ T.eq(m:supports("world", "replaceBlock"), false, "no shared block edits promised for " .. version)
+ local value, why = m.world:party()
+ T.eq(value, nil, "unsupported generation call refuses")
+ T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5]
+T.finish("API 3 entry compatibility")
diff --git a/tests/engine/api3_entry.lua b/tests/engine/api3_entry.lua
new file mode 100644
index 000000000..7eb1a2712
--- /dev/null
+++ b/tests/engine/api3_entry.lua
@@ -0,0 +1,16 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.harness")
+local Contract = require("tests.api3_contract.run")
+local Version = require("src.core.GameVersion")
+local Runtime = require("src.mods.Runtime")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love }
+love = nil
+for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ for _, failing in ipairs({ false, true }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version, failing)
+ T.check(ok, version .. (failing and " rollback: " or ": ") .. tostring(why))
+ end
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5]
+T.finish("API 3 cross-engine contracts")
diff --git a/tests/engine/api3_flags.lua b/tests/engine/api3_flags.lua
new file mode 100644
index 000000000..aa9366cf2
--- /dev/null
+++ b/tests/engine/api3_flags.lua
@@ -0,0 +1,113 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.flags")
+local F = require("src.mods.Api3Flags")
+local Loader = require("src.mods.Loader")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(version)
+ return Contract.fixture("tests/api3_contract", "gen2", version or "red")
+end
+for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version)
+ T.check(ok, version .. " shared native flags: " .. tostring(why))
+end
+-- Every generated catalog alias resolves to the live VM/save-converter key.
+local vm = require("src.script.Gen3ScriptVM")
+for _, version in ipairs({ "red", "blue", "yellow", "firered", "emerald" }) do
+ local key = version == "blue" and "red" or version
+ local data = require("src.mods.flag_names." .. key)
+ local gen = Version.generation(version)
+ for number, row in pairs(data.records) do
+ local expected = gen == 1 and row[1] or vm.flagName(number)
+ if row[1] then
+ T.eq(F.resolve(gen, version, number), expected, "catalog numeric resolves to native key")
+ end
+ for _, alias in ipairs(row) do
+ if alias then
+ T.eq(F.resolve(gen, version, alias), expected, "catalog alias resolves to native key")
+ end
+ end
+ end
+end
+for _, version in ipairs({ "gold", "silver", "crystal" }) do
+ for number = 0, 2047 do
+ T.eq(
+ F.resolve(2, version, number),
+ require("src.script.Gen2Flags").eventFlag(number),
+ "native Gen2 bit key"
+ )
+ end
+end
+for _, version in ipairs({ "prism", "polishedcrystal" }) do
+ T.eq(F.resolve(2, version, 16), nil, "no cartridge flag numbering guessed for hacks")
+end
+for _, bad in ipairs({ {}, false, 0 / 0, math.huge, -1, 0.5, string.rep("A", 257), "FLAG\0BAD" }) do
+ local f = fixture()
+ T.eq(f.exports.set(bad, true), nil, "malformed native ref refused")
+ T.eq(next(f.game.save.flags), nil, "refusal leaves native store untouched")
+end
+for _, bad in ipairs({ 0, "yes", {}, function() end }) do
+ local f = fixture()
+ T.eq(f.exports.set(f.number, bad), nil, "real boolean required")
+ T.eq(next(f.game.save.flags), nil, "bad value not stored")
+end
+for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do
+ local f = fixture(version)
+ local storage = assert(F.resolve(f.gen, version, f.number))
+ T.check(f.exports.set(f.number, true), "native set")
+ T.eq(f.game.save.flags[storage], true, "native VM sees same flag")
+ T.check(f.exports.set(f.number, false), "native clear")
+ T.eq(f.game.save.flags[storage], false, "clear distinguished from untouched for visibility")
+ T.eq(f.exports.get(f.number), false, "public clear boolean")
+ f.game.save.flags = false
+ T.eq(f.exports.get(f.number), nil, "malformed native store refused")
+ T.eq(f.exports.set(f.number, true), nil, "malformed native store not overwritten")
+ T.eq(f.game.save.flags, false, "malformed native store retained")
+end
+do
+ local f = fixture("firered")
+ local storage = assert(F.resolve(3, "firered", f.number))
+ local own = { index = 1, eventFlag = storage }
+ local other = { index = 2, eventFlag = "unrelated" }
+ f.ow.map.def.objects = { own, other }
+ local calls = {}
+ function f.ow:syncObjectVisibility(object)
+ calls[#calls + 1] = object
+ end
+ T.check(f.exports.set(f.symbol, true), "visibility flag set")
+ T.eq(#calls, 1, "only affected actor synchronized")
+ T.eq(calls[1], own, "synchronization scoped to native flag")
+ T.eq(f.ow.map.def.objects[2], other, "unrelated template unchanged")
+end
+do
+ local f = fixture()
+ local observed = 0
+ f.loader.events:on("flag.changed", function()
+ observed = observed + 1
+ local ok, why = f.exports.set(f.number, false)
+ T.eq(ok, nil, "flag event cannot reenter a write")
+ T.eq(why, "world is busy", "flag reentry diagnosed")
+ end, 0, "probe")
+ T.check(f.exports.set(f.number, true), "outer write accepted")
+ T.eq(observed, 1, "no recursive event loop")
+ T.eq(f.exports.get(f.number), true, "reentrant attempt cannot alter committed flag")
+end
+for _, action in ipairs({ "rollback", "reload", "teardown" }) do
+ local f = fixture()
+ if action == "rollback" then
+ f.loader:_rollback("flags")
+ elseif action == "reload" then
+ fixture()
+ else
+ Loader.teardown()
+ end
+ T.eq(f.exports.set(f.number, true), nil, "retired native setter")
+ T.eq(f.exports.set("mod:key", true), nil, "retired private setter")
+ T.eq(next(f.game.save.flags), nil, "retired setter no effects")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("API 3 native flags")
diff --git a/tests/engine/api3_invalidate.lua b/tests/engine/api3_invalidate.lua
new file mode 100644
index 000000000..ee295e14f
--- /dev/null
+++ b/tests/engine/api3_invalidate.lua
@@ -0,0 +1,126 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.invalidate")
+local Loader = require("src.mods.Loader")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local OW = require("src.world.OverworldController")
+local MapLoader = require("src.world.MapLoader")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(version)
+ return Contract.fixture("tests/api3_contract", "gen2", version or "red")
+end
+for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version)
+ T.check(ok, version .. " shared invalidation contract: " .. tostring(why))
+end
+for _, lock in ipairs({ "overlay", "moving", "script", "parallel", "link", "transition", "no_save" }) do
+ local f = fixture()
+ if lock == "overlay" then
+ f.game.stack.states[2] = {}
+ elseif lock == "moving" then
+ f.player.moving = true
+ elseif lock == "script" then
+ f.ow.runner.isRunning = function()
+ return true
+ end
+ elseif lock == "parallel" then
+ f.ow.parallelQueue = { {} }
+ elseif lock == "link" then
+ f.game.linkSession = {}
+ elseif lock == "transition" then
+ f.ow.transitioning = true
+ else
+ f.game.save = nil
+ end
+ T.eq(f.exports.request(f.home), nil, lock .. " refuses reload")
+ T.eq(f.reloads, 0, lock .. " has no native effects")
+end
+for _, bad in ipairs({ {}, 1, 0 / 0, math.huge, false, string.rep("x", 257), "bad\0id" }) do
+ local f = fixture()
+ T.eq(f.exports.request(bad), nil, "malformed ID refused")
+ T.eq(f.reloads, 0, "malformed ID not dispatched")
+end
+do
+ local f = fixture()
+ f.reloadError = true
+ T.eq(f.exports.request(f.home), nil, "native failure returns refusal")
+ T.eq(#f.exports.events(), 0, "failed reload emits no success event")
+ f.reloadError = false
+ T.check(f.exports.request(f.home), "native exception releases guard")
+ local native = f.ow.reloadMap
+ function f.ow:reloadMap(...)
+ local value, why = f.exports.request(f.home)
+ T.eq(value, nil, "native callback cannot reenter")
+ T.eq(why, "world is busy", "reload reentry diagnosed")
+ return native(self, ...)
+ end
+ T.check(f.exports.request(f.home), "outer reload accepted")
+end
+for _, action in ipairs({ "rollback", "reload", "teardown" }) do
+ local f = fixture()
+ if action == "rollback" then
+ f.loader:_rollback("invalidate")
+ elseif action == "reload" then
+ fixture()
+ else
+ Loader.teardown()
+ end
+ T.eq(f.exports.request(f.home), nil, "retired facade refuses after " .. action)
+ T.eq(f.reloads, 0, "retired facade makes no native calls")
+end
+local function upvalue(fn, wanted)
+ for i = 1, 100 do
+ local name, value = debug.getupvalue(fn, i)
+ if name == wanted then
+ return value, i
+ end
+ if not name then
+ break
+ end
+ end
+ error("native upvalue missing: " .. wanted)
+end
+for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do
+ for _, outside in ipairs({ false, true }) do
+ local f = fixture(version)
+ local old, index = upvalue(OW.reloadMap, "Game")
+ debug.setupvalue(OW.reloadMap, index, f.game)
+ f.ow.reloadMap = OW.reloadMap
+ f.ow.neighbors = { { map = { id = f.dest } } }
+ local cache = upvalue(MapLoader.invalidate, "cache")
+ cache[f.home], cache[f.dest] = {}, {}
+ local loads = {}
+ function f.ow:setMap(id, x, y, facing, opts)
+ loads[#loads + 1] = { id = id, x = x, y = y, facing = facing, opts = opts }
+ self.map = {
+ id = id,
+ def = f.game.data.maps[id],
+ inBounds = function()
+ return not outside
+ end,
+ }
+ end
+ function f.ow:healPoint()
+ return { map = f.dest, x = 1, y = 2 }
+ end
+ T.check(f.exports.request(f.home), "native reload accepted")
+ T.eq(cache[f.home], nil, "native target cache evicted")
+ T.eq(cache[f.dest], nil, "native neighbor cache evicted")
+ T.eq(loads[1].x, 0, "native reload preserves x")
+ T.eq(loads[1].facing, "left", "native reload preserves facing")
+ T.eq(loads[1].opts.keepMusic, true, "native seamless reload keeps music")
+ T.eq(loads[1].opts.seamless, true, "native seamless reload selected")
+ T.eq(#loads, outside and 2 or 1, "native out-of-bounds fallback count")
+ if outside then
+ T.eq(loads[2].id, f.dest, "invalidated geometry uses heal map")
+ T.eq(loads[2].facing, "down", "heal fallback orientation")
+ end
+ T.eq(#f.exports.events(), 1, "one native reload notification")
+ debug.setupvalue(OW.reloadMap, index, old)
+ end
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("API 3 map invalidation")
diff --git a/tests/engine/api3_npc.lua b/tests/engine/api3_npc.lua
new file mode 100644
index 000000000..f3c4844c4
--- /dev/null
+++ b/tests/engine/api3_npc.lua
@@ -0,0 +1,423 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.harness")
+local Contract = require("tests.api3_contract.npcs")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local Loader = require("src.mods.Loader")
+local Renderer = require("src.render.SpriteRenderer")
+local originalRenderer = Renderer.new
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+love = nil
+local function fixture(version)
+ return Contract.fixture("tests/api3_contract", "gen2", version or "red")
+end
+local function spawn(f)
+ local id = assert(
+ f.exports.spawn(
+ f.home,
+ { x = 0, y = 0, name = "OWN", sprite = "PROBE", movement = "STAY", range = "DOWN" }
+ )
+ )
+ return assert(f.exports.npc(f.home, id)), f.ow.npcPool[id]
+end
+for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version)
+ T.check(ok, version .. " shared NPC contract: " .. tostring(why))
+end
+local cycle = { x = 0, y = 0 }
+cycle.self = cycle
+local meta = setmetatable({ x = 0, y = 0 }, {})
+local deep = { x = 0, y = 0 }
+local child = deep
+for _ = 1, 18 do
+ child.next = {}
+ child = child.next
+end
+local wide = { x = 0, y = 0 }
+for i = 1, 260 do
+ wide[i] = i
+end
+for _, row in ipairs({
+ cycle,
+ meta,
+ deep,
+ wide,
+ { x = 0, y = 0, handler = function() end },
+ { x = math.huge, y = 0 },
+ { x = 0 / 0, y = 0 },
+ { x = 1000001, y = 0 },
+ { x = 0, y = -1 },
+ { x = 0, y = 0.5 },
+ { x = 0, y = 0, name = "bad\nname" },
+ { x = 0, y = 0, sprite = "missing" },
+ { x = 0, y = 0, facing = "north" },
+ { x = 0, y = 0, payload = string.rep("x", 4097) },
+}) do
+ local f = fixture()
+ local value, why = f.exports.spawn(f.home, row)
+ T.eq(value, nil, "invalid definition refused")
+ T.check(type(why) == "string", "definition failure explained")
+ T.eq(#f.game.data.maps[f.home].objects, 1, "invalid definition adds no template")
+ T.eq(#f.ow.npcs, 1, "invalid definition adds no actor")
+end
+for _, version in ipairs({ "red", "gold", "firered" }) do
+ for _, lock in ipairs({ "overlay", "player", "script", "link", "parallel", "controller" }) do
+ local f = fixture(version)
+ local handle, actor = spawn(f)
+ if lock == "overlay" then
+ f.game.stack.states[2] = {}
+ elseif lock == "player" then
+ f.player.moving = true
+ elseif lock == "script" then
+ f.ow.runner.isRunning = function()
+ return true
+ end
+ elseif lock == "link" then
+ f.game.linkSession = {}
+ elseif lock == "parallel" then
+ f.ow.parallelRunners = { {} }
+ else
+ f.ow._modScriptLock = {}
+ end
+ T.eq(handle:position(), 0, "busy world permits position read")
+ T.eq(handle:isMoving(), false, "busy world permits motion read")
+ T.eq(handle:face("left"), nil, lock .. " blocks mutation")
+ T.eq(f.exports.spawn(f.home, { x = 0, y = 0 }), nil, lock .. " blocks spawn")
+ T.eq(f.exports.remove(handle.id), nil, lock .. " blocks removal")
+ T.eq(actor.facing, "down", "refusal does not change actor")
+ end
+end
+for _, method in ipairs({ "face", "stepNow", "scriptMove", "canStep" }) do
+ for _, bad in ipairs({ "north", false, {}, 1 }) do
+ local f = fixture()
+ local handle = spawn(f)
+ local ok, why = handle[method](handle, bad)
+ T.eq(ok, nil, "bad direction refused for " .. method)
+ T.eq(why, "invalid direction", "bad direction diagnosed")
+ T.eq(#f.ow.scriptMoves, 0, "no queued movement on invalid direction")
+ end
+end
+for _, bad in ipairs({ 0, -1, 0.5, math.huge, 0 / 0, 1025, "1" }) do
+ local f = fixture()
+ local handle = spawn(f)
+ T.eq(handle:scriptMove("up", bad), nil, "invalid move count refused")
+ T.eq(#f.ow.scriptMoves, 0, "invalid count has no effect")
+end
+do
+ local f = fixture()
+ local handle, actor = spawn(f)
+ local native = f.ow.addRuntimeObject
+ function f.ow:addRuntimeObject(...)
+ local value, why = handle:face("left")
+ T.eq(value, nil, "native construction cannot reenter NPC mutation")
+ T.eq(why, "world is busy", "NPC reentry diagnosed")
+ value, why = f.exports.toggle(f.home, "IMPORTED", false)
+ T.eq(value, nil, "NPC construction cannot reenter object edit")
+ T.eq(why, "world is busy", "shared mutation guard diagnosed")
+ return native(self, ...)
+ end
+ T.check(f.exports.spawn(f.home, { x = 2, y = 2, sprite = "PROBE" }), "outer construction succeeds")
+ T.eq(actor.facing, "down", "reentry leaves actor untouched")
+ T.check(handle:face("right"), "construction releases guard")
+ local constructor = Renderer.new
+ Renderer.new = function()
+ error("renderer failed", 0)
+ end
+ local def, sprite = actor.def, actor.sprite
+ T.eq(handle:setAppearance("ALT"), nil, "failed renderer construction contained")
+ T.eq(actor.def, def, "failed renderer retains original definition")
+ T.eq(actor.sprite, sprite, "failed renderer retains original sprite")
+ Renderer.new = constructor
+ T.check(handle:face("up"), "renderer exception releases guard")
+end
+for _, change in ipairs({ "game", "save", "data", "stack", "world", "map", "actor", "version", "bus" }) do
+ local f = fixture()
+ local handle, actor = spawn(f)
+ T.check(handle:scriptMove("right", 2, f.exports.done), "movement starts before " .. change)
+ local callback = f.pending.callback
+ local restore
+ if change == "game" then
+ local old = f.live
+ f.live = {}
+ restore = function()
+ f.live = old
+ end
+ elseif change == "version" then
+ Version.set("blue")
+ restore = function()
+ Version.set("red")
+ end
+ elseif change == "world" then
+ local old = f.game.overworld
+ f.game.overworld = {}
+ restore = function()
+ f.game.overworld = old
+ end
+ elseif change == "bus" then
+ local old = Runtime.events
+ Runtime.events = require("src.mods.Events").new()
+ restore = function()
+ Runtime.events = old
+ end
+ elseif change == "actor" then
+ table.remove(f.ow.npcs, #f.ow.npcs)
+ restore = function()
+ f.ow.npcs[#f.ow.npcs + 1] = actor
+ end
+ else
+ local object = (change == "map") and f.ow or f.game
+ local old = object[change]
+ object[change] = {}
+ restore = function()
+ object[change] = old
+ end
+ end
+ f:update()
+ T.eq(#f.ow.scriptMoves, 0, "changed context retires only owned movement")
+ restore()
+ callback("stale")
+ callback("replayed")
+ T.eq(f.exports.result().calls, 0, "restored " .. change .. " cannot revive callback")
+end
+for _, event in ipairs({ "map.exited", "map.entered" }) do
+ local f = fixture()
+ local handle = spawn(f)
+ T.check(handle:scriptMove("right", 1, f.exports.done), "movement starts before map lifecycle")
+ local callback = f.pending.callback
+ Runtime.emit(event, { mapId = f.home })
+ T.eq(#f.ow.scriptMoves, 0, "map lifecycle retires movement immediately")
+ callback()
+ T.eq(f.exports.result().calls, 0, "old map callback suppressed")
+end
+for _, action in ipairs({ "rollback", "reload", "teardown" }) do
+ local f = fixture()
+ local handle, actor = spawn(f)
+ local foreign = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other")
+ T.check(handle:scriptMove("right", 1, f.exports.done), "queue before " .. action)
+ local callback = f.pending.callback
+ local foreignEntry = {
+ entity = f.imported,
+ remaining = 1,
+ onDone = function()
+ error("do not invoke")
+ end,
+ }
+ f.ow.scriptMoves[#f.ow.scriptMoves + 1] = foreignEntry
+ if action == "rollback" then
+ f.loader:_rollback("npcs")
+ elseif action == "reload" then
+ fixture()
+ else
+ Loader.teardown()
+ end
+ T.eq(#f.ow.scriptMoves, 1, "unload removes only owned queue entries")
+ T.eq(f.ow.scriptMoves[1], foreignEntry, "foreign movement retained")
+ T.eq(#f.game.data.maps[f.home].objects, 2, "unload removes only own runtime template")
+ T.eq(f.ow.npcPool[handle.id], nil, "own actor removed from native pool")
+ T.check(f.ow.npcPool[foreign], "foreign owner retained")
+ callback()
+ T.eq(f.exports.result().calls, 0, "unload suppresses native callback")
+ T.eq(handle:position(), nil, "unloaded handle retired")
+ T.eq(f.exports.npc(f.home, "IMPORTED"), nil, "unloaded facade retired")
+end
+do
+ local f = fixture()
+ local handle, actor = spawn(f)
+ T.check(f.exports.remove(handle.id), "own removal succeeds")
+ local replacement = assert(f.exports.spawn(f.home, { name = "NEW", sprite = "PROBE", x = 1, y = 1 }))
+ T.eq(replacement, handle.id, "native ID can be reused")
+ local value, why = handle:face("left")
+ T.eq(value, nil, "old handle cannot change replacement with same ID")
+ T.eq(why, "NPC handle is stale", "stale handle diagnosed")
+ T.eq(f.exports.npc(f.home, replacement):position(), 1, "replacement remains untouched")
+end
+do
+ local f = fixture()
+ local handle, actor = spawn(f)
+ f.synchronous = true
+ T.check(
+ handle:scriptMove("right", 1, function(...)
+ T.eq(Runtime.currentMod, "npcs", "movement callback attributed")
+ f.exports.done(...)
+ f.synchronous = false
+ T.check(handle:face("up"), "synchronous callback runs after commit guard released")
+ end),
+ "synchronous move accepted"
+ )
+ T.eq(f.exports.result().calls, 1, "synchronous callback called once")
+ T.eq(f.exports.result().args.n, 3, "nil argument holes preserved")
+ f.pending.callback()
+ T.eq(f.exports.result().calls, 1, "duplicate completion ignored")
+ T.eq(Runtime.currentMod, nil, "callback restores runtime owner")
+end
+for _, err in ipairs({
+ "authored failure",
+ setmetatable({}, {
+ __tostring = function()
+ error("hostile tostring")
+ end,
+ }),
+}) do
+ local f = fixture()
+ local handle = spawn(f)
+ T.check(
+ handle:scriptMove("right", 1, function()
+ error(err, 0)
+ end),
+ "throwing callback admitted"
+ )
+ T.check(
+ pcall(function()
+ f:complete()
+ f.pending.callback()
+ end),
+ "callback errors cannot escape native completion"
+ )
+ T.eq(#f.loader.errors, 1, "callback error attributed once")
+ T.check(
+ f.loader.errors[1]:find("npcs: NPC movement callback failed:", 1, true),
+ "callback error names owner"
+ )
+end
+do
+ local f = fixture()
+ local handle = spawn(f)
+ f.failMovement, f.synchronous = true, true
+ T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "native refusal never counts as admission")
+ T.eq(f.exports.result().calls, 0, "callback deferred until native result")
+ T.eq(#f.ow.scriptMoves, 0, "native refusal leaves no owned queue entry")
+end
+do
+ local f = fixture()
+ local handle = spawn(f)
+ local native = f.ow.scriptMove
+ function f.ow:scriptMove(...)
+ Runtime.emit("map.exited", { mapId = f.home })
+ return native(self, ...)
+ end
+ T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "map change during admission refuses completion")
+ T.eq(#f.ow.scriptMoves, 0, "late-created owned entry cleaned after cancellation race")
+end
+do
+ local f = fixture()
+ local handle = spawn(f)
+ T.check(handle:scriptMove("right"), "optional count/callback accepted")
+ f:complete()
+ T.eq(handle:position(), 1, "default count is one")
+ T.eq(#f.loader.errors, 0, "omitted callback never treated as a callable")
+end
+-- Real native object creation/removal, NPC updates and movement queue.
+local OW = require("src.world.OverworldController")
+local function bind(game)
+ for i = 1, 100 do
+ local name, old = debug.getupvalue(OW.addRuntimeObject, i)
+ if not name then
+ break
+ end
+ if name == "Game" then
+ debug.setupvalue(OW.addRuntimeObject, i, game)
+ return function()
+ debug.setupvalue(OW.addRuntimeObject, i, old)
+ end
+ end
+ end
+ error("native Game upvalue missing")
+end
+for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do
+ local f = fixture(version)
+ local restore = bind(f.game)
+ f.ow.addRuntimeObject, f.ow.removeRuntimeObject = OW.addRuntimeObject, OW.removeRuntimeObject
+ f.ow.scriptMove, f.ow.marchInPlace = OW.scriptMove, OW.marchInPlace
+ local handle, actor = spawn(f)
+ actor.frozen = true
+ T.check(handle:scriptMove("right", 2, f.exports.done), version .. " native movement starts")
+ for _ = 1, 40 do
+ OW.updateScriptMoves(f.ow)
+ actor:update(f.ow.map, f.ow.entities)
+ end
+ T.eq(handle:position(), 2, "native queue walks exactly two cells")
+ T.eq(f.exports.result().calls, 1, "native completion exactly once")
+ T.eq(#f.ow.scriptMoves, 0, "native movement releases world input")
+ T.check(handle:marchInPlace(f.exports.done), "native march starts")
+ for _ = 1, 20 do
+ OW.updateScriptMoves(f.ow)
+ actor:update(f.ow.map, f.ow.entities)
+ end
+ T.eq(handle:position(), 2, "march does not translate")
+ T.eq(f.exports.result().calls, 2, "native march completes")
+ T.check(handle:setAppearance("ALT"), "native renderer refresh path")
+ T.eq(actor.def.sprite, "ALT", "actor appearance changed")
+ T.eq(
+ f.game.data.maps[f.home].objects[2].sprite,
+ "PROBE",
+ "native template data not rewritten by appearance"
+ )
+ T.check(f.exports.remove(handle.id), "native removal succeeds")
+ T.eq(#f.ow.npcs, 1, "native removal retains imported actor")
+ restore()
+end
+do
+ local f = fixture()
+ local native = f.ow.addRuntimeObject
+ function f.ow:addRuntimeObject(...)
+ native(self, ...)
+ error("creation failure", 0)
+ end
+ T.eq(f.exports.spawn(f.home, { sprite = "PROBE", x = 0, y = 0 }), nil, "creation exception contained")
+ T.eq(#f.game.data.maps[f.home].objects, 1, "failed creation template rolled back")
+ T.eq(#f.ow.npcs, 1, "failed creation actor rolled back")
+ T.eq(next(f.ow.npcPool), f.imported.id, "failed creation leaves pool unchanged")
+end
+do
+ local f = fixture()
+ local row =
+ { name = "LATER", sprite = "PROBE", x = 1, y = 1, owner = "forged", runtime = false, index = 900 }
+ local id = assert(f.exports.spawn(f.dest, row))
+ local record = f.game.data.maps[f.dest].objects[1]
+ T.eq(record.owner, "npcs", "caller cannot forge runtime ownership")
+ T.eq(record.runtime, true, "native runtime marker assigned")
+ T.eq(record.index, 1, "native identity replaces supplied index")
+ T.eq(#f.ow.npcs, 1, "inactive spawn adds no active actor")
+ local actor = f:make(f.dest, record)
+ actor.def = require("src.mods.Merge").deepCopy(record) -- Native placement/appearance clones.
+ local map = require("src.world.Map").new(f.game.data.maps[f.dest], f.ow.map.tileset)
+ f.ow.ghosts = { { npc = actor, map = map } }
+ f.ow.npcPool[id] = actor
+ local handle = assert(f.exports.npc(f.dest, id))
+ T.eq(handle:position(), 1, "live neighbor uses its own coordinate frame")
+ T.eq(handle:canStep("right"), true, "neighbor collision uses neighbor map")
+ local homeHandle, homeActor = spawn(f)
+ homeActor.cellX, homeActor.cellY = 2, 1
+ local nb = { map = map, cx = 0, cy = 0, peers = { actor } }
+ f.ow.neighbors = { nb }
+ f.ow.ghosts[1].nb = nb
+ require("src.world.OverworldController").updateCast(f.ow)
+ T.eq(handle:canStep("right"), false, "neighbor cast sees translated primary actor")
+ T.eq(homeHandle:canStep("left"), false, "primary cast sees translated neighbor actor")
+ homeActor.passable = true
+ actor.passable = true
+ require("src.world.OverworldController").updateCast(f.ow)
+ T.eq(handle:canStep("right"), true, "neighbor cast respects primary passability")
+ T.eq(homeHandle:canStep("left"), true, "primary cast respects neighbor passability")
+ T.check(handle:face("left"), "neighbor can face")
+ local ok, why = handle:scriptMove("right", 1)
+ T.eq(ok, nil, "neighbor track refused")
+ T.eq(why, "movement tracks require the active map", "neighbor restriction explicit")
+ Loader.teardown()
+ T.eq(#f.game.data.maps[f.dest].objects, 0, "inactive template cleaned on unload")
+ T.eq(#f.ow.ghosts, 0, "later neighbor actor clone cleaned on unload")
+ T.eq(f.ow.npcPool[id], nil, "neighbor pool cleaned on unload")
+end
+do
+ local f = fixture()
+ local handle, actor = spawn(f)
+ handle.id = f.imported.id
+ T.check(handle:face("left"), "public ID field cannot redirect private handle binding")
+ T.eq(actor.facing, "left", "original binding changed")
+ T.eq(f.imported.facing, "down", "imported actor unaffected")
+end
+Renderer.new = originalRenderer
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("API 3 public NPC actions")
diff --git a/tests/engine/api3_warp.lua b/tests/engine/api3_warp.lua
new file mode 100644
index 000000000..0bc8ddbb1
--- /dev/null
+++ b/tests/engine/api3_warp.lua
@@ -0,0 +1,249 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.harness")
+local Contract = require("tests.api3_contract.warp")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local Loader = require("src.mods.Loader")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+love = nil
+local function fixture(version)
+ return Contract.fixture("tests/api3_contract", "gen2", version or "red")
+end
+for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version)
+ T.check(ok, version .. " shared warp contract: " .. tostring(why))
+end
+for _, lock in ipairs({
+ "flyAnim",
+ "teleportOut",
+ "engaging",
+ "emote",
+ "pikaHop",
+ "healAnim",
+ "whirlSpin",
+ "gen3Decorate",
+ "gen3Locked",
+ "frameScriptThisTick",
+ "_modScriptLock",
+ "parallelRunners",
+ "parallelQueue",
+ "pendingScripts",
+ "scriptMoves",
+ "stairExit",
+ "spinning",
+ "inputLocked",
+ "fieldMove",
+ "linkSession",
+ "linkNet",
+}) do
+ local f = fixture()
+ if lock == "linkSession" then
+ f.game.linkSession = {}
+ elseif lock == "linkNet" then
+ f.game.linkNet = { closed = false }
+ elseif lock == "stairExit" or lock == "spinning" or lock == "inputLocked" or lock == "fieldMove" then
+ f.player[lock] = true
+ elseif lock:find("Runners") or lock:find("Queue") or lock == "pendingScripts" or lock == "scriptMoves" then
+ f.ow[lock] = { {} }
+ else
+ f.ow[lock] = true
+ end
+ local ok, why = f.exports.request(f.dest, 0, 0)
+ T.eq(ok, nil, lock .. " declines admission")
+ T.eq(why, "world is busy", lock .. " explains busy state")
+ T.eq(#f.trace, 0, lock .. " never dispatches")
+end
+do
+ local f = fixture()
+ f.game.save = nil
+ T.eq(f.exports.request(f.dest, 0, 0), nil, "missing save refuses")
+ f.live = nil
+ T.eq(f.exports.request(f.dest, 0, 0), nil, "missing game refuses")
+ T.eq(#f.trace, 0, "missing context never dispatches")
+end
+for _, size in ipairs({ false, -1, 0, 0.5, math.huge, 0 / 0 }) do
+ local f = fixture()
+ f.game.data.maps[f.dest].width = size
+ local ok, why = f.exports.request(f.dest, 0, 0)
+ T.eq(ok, nil, "malformed map dimensions refuse")
+ T.eq(why, "invalid map dimensions", "dimensions diagnosed before native work")
+ T.eq(#f.trace, 0, "malformed maps never dispatch")
+end
+do
+ local f = fixture()
+ local opts = setmetatable({}, {
+ __index = function()
+ error("must not run")
+ end,
+ })
+ local ok, why = f.exports.request(f.dest, 0, 0, "down", opts)
+ T.eq(ok, nil, "metatable options rejected")
+ T.eq(why, "invalid options", "plain options required")
+ T.eq(#f.trace, 0, "metatable never reaches native warp")
+end
+for _, change in ipairs({ "data", "stack", "player", "version" }) do
+ local f = fixture()
+ T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start " .. change)
+ f:arrive()
+ local restore
+ if change == "version" then
+ Version.set("blue")
+ restore = function()
+ Version.set("red")
+ end
+ else
+ local obj = change == "player" and f.ow or f.game
+ local previous = obj[change]
+ obj[change] = {}
+ restore = function()
+ obj[change] = previous
+ end
+ end
+ f:update()
+ restore()
+ f:complete()
+ T.eq(f.exports.results().calls, 0, change .. " cannot revive callback after observed change")
+end
+for _, failure in ipairs({ "refuse", "throw" }) do
+ local f = fixture()
+ f.failure, f.synchronous = failure, true
+ f.ow.arriveWarp = "original"
+ local ok, why = f.exports.request(f.dest, 0, 0, "up", { arrive = "fly", onDone = f.exports.notify })
+ T.eq(ok, nil, "native " .. failure .. " is not admitted")
+ T.check(type(why) == "string", "native failure explained")
+ T.eq(f.exports.results().calls, 0, "synchronous callback waits for native acceptance")
+ T.eq(f.ow.arriveWarp, "original", "failed dispatch restores borrowed arrival field")
+ f:complete()
+ T.eq(f.exports.results().calls, 0, "failed callback cannot replay")
+end
+do
+ local f = fixture()
+ f.synchronous = true
+ local ok = f.exports.request(f.dest, 0, 0, "up", {
+ onDone = function(...)
+ T.eq(Runtime.currentMod, "warp", "callback attributed to owning mod")
+ f.exports.notify(...)
+ f.synchronous = false
+ T.check(
+ f.exports.request(f.home, 1, 1, "down", { onDone = f.exports.notify }),
+ "callback may start next warp"
+ )
+ end,
+ })
+ T.check(ok, "synchronous native arrival admitted")
+ T.eq(#f.trace, 2, "one reentrant request dispatches once")
+ T.eq(Runtime.currentMod, nil, "callback restores previous runtime owner")
+ f:arrive()
+ f:complete()
+ T.eq(f.exports.results().calls, 2, "old completion does not clear next request")
+end
+for _, operation in ipairs({ "rollback", "reload", "teardown" }) do
+ local f = fixture()
+ T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before " .. operation)
+ f:arrive()
+ if operation == "rollback" then
+ f.loader:_rollback("warp")
+ elseif operation == "reload" then
+ fixture() -- A new loader/session retires every previous facade.
+ else
+ Loader.teardown()
+ end
+ f:complete()
+ T.eq(f.exports.results().calls, 0, operation .. " retires callback")
+ local ok, why = f.exports.request(f.dest, 0, 0)
+ T.eq(ok, nil, operation .. " retires facade")
+ T.eq(why, "warp facade is retired", "stale facade diagnostic")
+end
+for _, direction in ipairs({ "up", "down", "left", "right" }) do
+ local f = fixture()
+ T.check(f.exports.request(f.home, 1, 1, direction, { onDone = f.exports.notify }), "same-map " .. direction)
+ T.eq(f.trace[1].facing, direction, "direction reaches native warp unchanged")
+ f:arrive()
+ f:complete()
+ T.eq(f.exports.results().calls, 1, "same-map arrival not mistaken for stale completion")
+end
+do
+ local f = fixture()
+ T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before false arrival")
+ f:complete()
+ T.eq(f.exports.results().calls, 0, "completion on old map never invokes mod")
+ f:arrive()
+ f:complete()
+ T.eq(f.exports.results().calls, 0, "false completion cannot be revived by later arrival")
+end
+-- Exercise the actual native transition; only map materialization is synthetic.
+love = require("tests.love_stub")
+local OW = require("src.world.OverworldController")
+local Transition = require("src.render.Transition")
+local StateStack = require("src.core.StateStack")
+local function bind(game)
+ for i = 1, 100 do
+ local name, old = debug.getupvalue(OW.startWarpTo, i)
+ if not name then
+ break
+ end
+ if name == "Game" then
+ debug.setupvalue(OW.startWarpTo, i, game)
+ return function()
+ debug.setupvalue(OW.startWarpTo, i, old)
+ end
+ end
+ end
+ error("native Game upvalue missing")
+end
+for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do
+ for _, fade in ipairs({ true, false }) do
+ if fade or Version.generation(version) == 3 then
+ local f = fixture(version)
+ setmetatable(f.game.stack, { __index = StateStack })
+ f.ow.startWarpTo = OW.startWarpTo
+ local restore = bind(f.game)
+ function f.ow:frlgBehaviours()
+ return false
+ end
+ function f.ow:noteGen2Spawn(from)
+ self.notedFrom = from
+ end
+ function f.ow:setMap(id, x, y, facing, opts)
+ f.destination = { id, x, y, facing }
+ T.eq(opts.via, "warp", "native transition retains warp reason")
+ T.eq(opts.keepMusic, true, "native transition retains music request")
+ f:arrive()
+ end
+ local opts = { onDone = f.exports.notify, keepMusic = true }
+ if f.gen == 3 then
+ opts.fade = fade
+ end
+ T.check(f.exports.request(f.dest, 1, 2, "right", opts), "actual native warp admitted")
+ if fade then
+ local state = f.game.stack:top()
+ T.eq(getmetatable(state), Transition, "ordinary warp pushes native fade")
+ T.eq(f.exports.results().calls, 0, "callback waits for native transition")
+ for _ = 1, state.frames + state.framesIn + 2 do
+ if f.game.stack:top() == state then
+ state:update(1 / 60)
+ end
+ end
+ else
+ T.eq(#f.game.stack.states, 1, "no-fade warp creates no transition screen")
+ end
+ T.eq(f.ow.map.id, f.dest, "native transition arrives at destination")
+ T.eq(f.ow.notedFrom, f.home, "native spawn bookkeeping retained")
+ T.same(f.ow.warpEntryCell, { x = 1, y = 2 }, "native arrival tile guard retained")
+ T.eq(f.ow.transitioning, false, "native transition releases lock")
+ T.eq(f.exports.results().calls, 1, "actual native callback runs exactly once")
+ restore()
+ end
+ end
+end
+-- Legacy public facade continues to ignore fade, rather than opting into API 3.
+do
+ local f = fixture("firered")
+ local native = require("src.world.WorldAPI").new(f.game, "legacy")
+ T.check(native:warpTo(f.dest, -1, 0, "north", { fade = false }), "legacy dispatch behavior unchanged")
+ T.eq(f.trace[1].fade, nil, "legacy fade option remains ignored")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("API 3 portable warp")
diff --git a/tests/engine/api3_wild_battle.lua b/tests/engine/api3_wild_battle.lua
new file mode 100644
index 000000000..71bafc967
--- /dev/null
+++ b/tests/engine/api3_wild_battle.lua
@@ -0,0 +1,179 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.wild_battle")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local Loader = require("src.mods.Loader")
+local Battle = require("src.battle.BattleState")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(version)
+ return Contract.fixture("tests/api3_contract", "gen2", version or "red")
+end
+for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version)
+ T.check(ok, version .. " shared battle contract: " .. tostring(why))
+end
+for _, level in ipairs({ -1, 0, 1.5, 101, math.huge, 0 / 0, "5", false, {} }) do
+ local f = fixture()
+ T.eq(f.exports.request("MEW", level), nil, "invalid level refused")
+ T.eq(f.constructed, 0, "invalid level never constructs/marks seen")
+ f.restore()
+end
+for _, lock in ipairs({ "overlay", "moving", "script", "parallel", "link", "transition", "no_save" }) do
+ local f = fixture()
+ if lock == "overlay" then
+ f.game.stack.states[2] = {}
+ elseif lock == "moving" then
+ f.player.moving = true
+ elseif lock == "script" then
+ f.ow.runner.isRunning = function()
+ return true
+ end
+ elseif lock == "parallel" then
+ f.ow.parallelQueue = { {} }
+ elseif lock == "link" then
+ f.game.linkSession = {}
+ elseif lock == "transition" then
+ f.ow.transitioning = true
+ else
+ f.game.save = nil
+ end
+ T.eq(f.exports.request("MEW", 5), nil, lock .. " blocks battle")
+ T.eq(f.constructed, 0, lock .. " leaves dex/RNG untouched")
+ f.restore()
+end
+for _, change in ipairs({ "game", "save", "data", "stack", "world", "player", "version", "bus" }) do
+ local f = fixture()
+ T.check(f.exports.request("MEW", 5, f.exports.notify), "start before " .. change)
+ local restore
+ if change == "game" then
+ f.live = {}
+ restore = function()
+ f.live = f.game
+ end
+ elseif change == "world" then
+ f.game.overworld = {}
+ restore = function()
+ f.game.overworld = f.ow
+ end
+ elseif change == "version" then
+ Version.set("blue")
+ restore = function()
+ Version.set("red")
+ end
+ elseif change == "bus" then
+ local old = Runtime.events
+ Runtime.events = {}
+ restore = function()
+ Runtime.events = old
+ end
+ else
+ local object = change == "player" and f.ow or f.game
+ local old = object[change]
+ object[change] = {}
+ restore = function()
+ object[change] = old
+ end
+ end
+ f.loader.hooks:call("input.step", function() end)
+ restore()
+ f:complete("win")
+ T.eq(f.exports.result().calls, 0, "observed replacement permanently retires callback")
+ T.eq(f.after, 1, "restored native session still completes cleanup")
+ f.restore()
+end
+for _, action in ipairs({ "rollback", "reload", "teardown" }) do
+ local f = fixture()
+ T.check(f.exports.request("MEW", 5, f.exports.notify), "start before " .. action)
+ if action == "rollback" then
+ f.loader:_rollback("wild_battle")
+ elseif action == "reload" then
+ local nextFixture = fixture()
+ nextFixture.restore()
+ else
+ Loader.teardown()
+ end
+ f:complete("caught")
+ T.eq(f.after, 1, "unloading mod preserves native cleanup")
+ T.eq(f.exports.result().calls, 0, "unloading retires callback")
+ T.eq(f.exports.request("MEW", 5), nil, "unloaded facade retired")
+ f.restore()
+end
+do
+ local f = fixture()
+ T.check(f.exports.request("MEW", 5, f.exports.notify), "start before foreign save")
+ f.game.save = {}
+ f:complete("lose")
+ T.eq(f.after, 0, "late result cannot run cleanup against replaced save")
+ T.eq(f.exports.result().calls, 0, "late result cannot call mod on replaced save")
+ f.restore()
+end
+for _, mode in ipairs({ "constructError", "pushFailure", "cleanupError", "callbackError", "syncRefusal" }) do
+ local f = fixture()
+ local callback = f.exports.notify
+ if mode == "callbackError" then
+ callback = function()
+ error("authored callback probe")
+ end
+ elseif mode == "syncRefusal" then
+ f.synchronous, f.pushFailure = true, true
+ else
+ f[mode] = true
+ end
+ local value = f.exports.request("MEW", 5, callback)
+ if mode == "constructError" or mode == "pushFailure" or mode == "syncRefusal" then
+ T.eq(value, nil, "failed admission returns nil")
+ T.eq(f.after, 0, "failed admission never invokes native completion")
+ T.eq(f.exports.result().calls, 0, "failed admission never calls mod")
+ else
+ T.check(value, "completion-error battle admitted")
+ T.check(pcall(f.complete, f, "win"), "completion errors contained")
+ T.eq(#f.loader.errors, 1, "completion error attributed once")
+ f:complete("win")
+ T.eq(#f.loader.errors, 1, "replay cannot repeat error")
+ end
+ f.restore()
+end
+do
+ local f = fixture()
+ f.synchronous = true
+ T.check(
+ f.exports.request("MEW", 5, function(...)
+ T.eq(Runtime.currentMod, "wild_battle", "completion owner restored")
+ f.exports.notify(...)
+ f.synchronous = false
+ T.check(f.exports.request("MEW", 6), "synchronous completion releases admission guard")
+ end),
+ "synchronous battle admitted"
+ )
+ T.eq(f.pushed, 2, "callback can start next battle")
+ f:complete("run")
+ f.restore()
+end
+-- Real native BattleState/Pokemon construction with fixture data. The entry
+-- transition and cleanup seams are independent from the public adapter.
+for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do
+ local f = fixture(version)
+ f.restore()
+ local data = T.fixtures.fresh()
+ data.pokemon.MEW = require("src.mods.Merge").deepCopy(data.pokemon.FIXMON_A)
+ data.pokemon.MEW.id, data.pokemon.MEW.name = "MEW", "MEW"
+ data.constants.gen = Version.generation(version)
+ f.game.data = data
+ f.game.save = require("src.core.SaveData").newGame()
+ f.game.session = f.game.save
+ f.game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 15) }
+ require("src.render.Font").load(data)
+ T.check(f.exports.request("MEW", 5, f.exports.notify), version .. " native constructor admitted")
+ T.eq(f.battle.kind, "wild", "native battle kind")
+ T.eq(f.battle.enemy.mon.species, "MEW", "native enemy species")
+ T.eq(f.battle.enemy.mon.level, 5, "native level")
+ T.eq(f.game.save.pokedex.seen.MEW, true, "native constructor marks seen")
+ f:complete("caught")
+ T.eq(f.after, 1, "native battle retains cleanup")
+ T.eq(f.exports.result().calls, 1, "native battle completes public callback")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("API 3 public wild battle")
diff --git a/tests/engine/api3_world_edits.lua b/tests/engine/api3_world_edits.lua
new file mode 100644
index 000000000..3ef071813
--- /dev/null
+++ b/tests/engine/api3_world_edits.lua
@@ -0,0 +1,241 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.harness")
+local Contract = require("tests.api3_contract.world_edits")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local Loader = require("src.mods.Loader")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+love = nil
+local function fixture(version)
+ return Contract.fixture("tests/api3_contract", "gen2", version or "red")
+end
+for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version)
+ T.check(ok, version .. " shared object/block contracts: " .. tostring(why))
+end
+for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do
+ for _, key in ipairs({ "flags", "objectToggles", "g2ObjectToggles", "gen3Spawned", "gen3SessionObjects" }) do
+ local gen = Version.generation(version)
+ if
+ gen == 1 and (key == "objectToggles" or key == "flags")
+ or gen == 2 and (key == "flags" or key == "g2ObjectToggles" or key == "objectToggles")
+ or gen == 3
+ and (key == "flags" or key == "gen3Spawned" or key == "gen3SessionObjects" or key == "objectToggles")
+ then
+ local f = fixture(version)
+ f.game.save[key] = "keep"
+ local ok, why = f.exports.toggle(f.home, "NPC", false)
+ T.eq(ok, nil, key .. " malformed store rejected")
+ T.check(type(why) == "string", "store refusal explained")
+ T.eq(f.game.save[key], "keep", "malformed store retained")
+ T.eq(#f.exports.events(), 0, "malformed store emits no event")
+ T.eq(f.visible.NPC, true, "malformed store leaves native object untouched")
+ end
+ end
+end
+for _, version in ipairs({ "red", "firered" }) do
+ local f = fixture(version)
+ local key = f.gen == 1 and "objectToggles" or "gen3Spawned"
+ f.game.save[key] = { [f.home] = "keep" }
+ T.eq(f.exports.toggle(f.home, "NPC", false), nil, "malformed nested bucket rejected")
+ T.eq(f.game.save[key][f.home], "keep", "nested bucket never replaced")
+ T.eq(#f.exports.events(), 0, "no event for malformed nested bucket")
+end
+for _, lock in ipairs({
+ "linkSession",
+ "linkNet",
+ "gen3Locked",
+ "parallelRunners",
+ "parallelQueue",
+ "pendingScripts",
+ "_modScriptLock",
+}) do
+ local f = fixture()
+ if lock == "linkSession" then
+ f.game.linkSession = {}
+ elseif lock == "linkNet" then
+ f.game.linkNet = { closed = false }
+ elseif lock == "parallelRunners" or lock == "parallelQueue" or lock == "pendingScripts" then
+ f.ow[lock] = { {} }
+ else
+ f.ow[lock] = true
+ end
+ T.eq(f.exports.toggle(f.home, "NPC", false), nil, lock .. " blocks objects")
+ T.eq(f.exports.replace(0, 0, 7), nil, lock .. " blocks blocks")
+ T.eq(#f.exports.events(), 0, "busy requests never emit effects")
+end
+for _, failure in ipairs({
+ "renderer",
+ "coordinates",
+ "geometry",
+ "current_block",
+ "objects",
+ "object_runtime",
+}) do
+ local f = fixture("gold")
+ if failure == "renderer" then
+ f.ow.map.renderer = nil
+ elseif failure == "coordinates" then
+ f.ow.map.def.width = 0
+ elseif failure == "geometry" then
+ f.ow.map.blockCells, f.ow.map.blockTiles = 1, 2
+ elseif failure == "current_block" then
+ f.ow.map.def.blocks[1] = nil
+ elseif failure == "objects" then
+ f.ow.map.def.objects = nil
+ else
+ f.ow.syncObjectVisibility = nil
+ end
+ local ok, why
+ if failure == "objects" or failure == "object_runtime" then
+ ok, why = f.exports.toggle(f.home, "NPC", false)
+ else
+ ok, why = f.exports.replace(0, 0, 7)
+ end
+ T.eq(ok, nil, failure .. " refuses safely")
+ T.check(type(why) == "string", "native context refusal explained")
+ T.eq(#f.exports.events(), 0, "invalid context emits nothing")
+ T.eq(f.rebuilds + f.syncs + f.reloads, 0, "invalid context performs no native work")
+end
+for _, operation in ipairs({ "rollback", "reload", "teardown" }) do
+ local f = fixture()
+ if operation == "rollback" then
+ f.loader:_rollback("edits")
+ elseif operation == "reload" then
+ fixture()
+ else
+ Loader.teardown()
+ end
+ local ok, why = f.exports.toggle(f.home, "NPC", false)
+ T.eq(ok, nil, operation .. " retires object action")
+ T.eq(why, "world facade is retired", "retired object diagnostic")
+ ok, why = f.exports.replace(0, 0, 7)
+ T.eq(ok, nil, operation .. " retires block action")
+ T.eq(why, "world facade is retired", "retired block diagnostic")
+ T.eq(#f.exports.events(), 0, "retired facade emits nothing")
+end
+do
+ local f = fixture()
+ f.exports.reenter(true)
+ T.check(f.exports.toggle(f.home, "NPC", false), "outer edit accepted")
+ T.eq(f.exports.nested()[1], nil, "event cannot reenter a partially committed edit")
+ T.eq(f.exports.nested()[2], "world is busy", "reentry refused explicitly")
+ T.eq(f.rebuilds, 0, "nested edit never redraws")
+ T.eq(#f.exports.events(), 1, "outer edit emits once")
+ f.exports.reenter(false)
+ T.check(f.exports.replace(0, 0, 8), "guard released after successful edit")
+ local rebuild = f.ow.map.renderer.rebuild
+ f.ow.map.renderer.rebuild = function()
+ error("draw probe", 0)
+ end
+ local ok, why = f.exports.replace(0, 0, 7)
+ T.eq(ok, nil, "native redraw failure contained")
+ T.check(why:find("draw probe", 1, true), "native failure explained")
+ f.ow.map.renderer.rebuild = rebuild
+ T.check(f.exports.replace(0, 0, 8), "guard released after native exception")
+end
+-- Packed Gen3 source reads remain observational; replacement uses instance patches.
+do
+ local f = fixture("emerald")
+ f.ow.map.def.blocks = string.rep(string.char(7, 0), 6)
+ f.ow.map.def._blockArray = nil
+ f.ow.map.collisionPatch[1] = 1
+ f.ow.map.shutCells = { ["0:0"] = true }
+ T.check(f.exports.replace(0, 0, 8), "packed metatile map writable")
+ T.eq(f.exports.read(f.home, 0, 0), 8, "reads see metatile patch")
+ T.eq(f.ow.map.def.blocks, string.rep(string.char(7, 0), 6), "generated source bytes untouched")
+ T.eq(f.ow.map.def._blockArray, nil, "validation does not decode/cache source")
+ T.eq(f.ow.map.collisionPatch[1], 0, "native default clears stale collision override")
+ T.eq(f.ow.map.shutCells["0:0"], nil, "native default clears stale shut cell")
+end
+-- Native controller methods; no actor construction or graphics are mocked.
+love = require("tests.love_stub")
+local OW = require("src.world.OverworldController")
+local function bind(game)
+ for i = 1, 100 do
+ local name, old = debug.getupvalue(OW.syncObjectVisibility, i)
+ if not name then
+ break
+ end
+ if name == "Game" then
+ debug.setupvalue(OW.syncObjectVisibility, i, game)
+ return function()
+ debug.setupvalue(OW.syncObjectVisibility, i, old)
+ end
+ end
+ end
+ error("native Game upvalue missing")
+end
+for _, version in ipairs({ "gold", "silver", "crystal", "firered", "emerald" }) do
+ local f = fixture(version)
+ local restore = bind(f.game)
+ setmetatable(f.ow, { __index = OW })
+ f.ow.syncObjectVisibility = OW.syncObjectVisibility
+ f.ow.replaceBlock = OW.replaceBlock
+ local function actor(def, x)
+ return {
+ id = f.home .. "_obj_" .. def.index,
+ def = def,
+ cellX = x,
+ cellY = 1,
+ px = x * 16,
+ py = 16,
+ facing = "right",
+ }
+ end
+ local target, sibling = actor(f.flagged, 3), actor(f.flagless, 2)
+ -- Distinct live actors share one persistent flag; only the named actor moves.
+ f.flagless.eventFlag = f.flagged.eventFlag
+ f.ow.npcs = { target, sibling }
+ f.ow.entities = { f.player, target, sibling }
+ f.ow.npcPool = { [target.id] = target, [sibling.id] = sibling }
+ T.check(f.exports.toggle(f.home, "NPC", false), version .. " actual native removal")
+ T.eq(#f.ow.npcs, 1, "only target removed")
+ T.eq(f.ow.npcs[1], sibling, "shared-flag sibling retained live")
+ T.eq(sibling.cellX, 2, "unrelated actor not relocated")
+ T.check(f.exports.toggle(f.home, "NPC", true), "actual native respawn")
+ T.eq(#f.ow.npcs, 2, "one actor restored")
+ T.eq(target.cellX, 3, "pooled position retained")
+ T.eq(target.facing, "right", "pooled facing retained")
+ f.flagless.eventFlag = nil
+ T.check(f.exports.toggle(f.home, "TEMP", false), "flagless object hidden for visit")
+ T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), false, "native spawn predicate sees session hide")
+ f.game.save.g2ObjectToggles = nil
+ f.game.save.gen3SessionObjects = nil
+ T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), true, "flagless default restored after visit reset")
+ T.check(f.exports.replace(1, 2, 7), "actual native block/redraw path")
+ T.eq(f.ow.map:blockAt(1, 2), 7, "actual Map returns patched block")
+ T.eq(f.ow.map.def.blocks[6], 8, "generated map record retained")
+ T.eq(f.ow.map.blocksDirty, nil, "native redraw consumes dirty flag")
+ restore()
+end
+for _, version in ipairs({ "firered", "emerald" }) do
+ local f = fixture(version)
+ T.check(f.exports.toggle("HOME", "NPC", false), "unprefixed Gen3 active map resolves")
+ T.eq(f.exports.events()[1].mapId, f.home, "event reports resolved native map ID")
+end
+do
+ local f = fixture("gold")
+ require("src.script.Gen2Commands")
+ local Commands = require("src.script.Commands")
+ Commands.g2ResetObjectBase()
+ local ctx = { game = f.game, save = f.game.save, overworld = f.ow }
+ Commands.g2_object(ctx, 2, true) -- Cache the native script numbering first.
+ f.game.data.field = { objectScriptBase = 1 }
+ T.check(f.exports.toggle(f.home, 1, false), "public slot resolves independently of cached script IDs")
+ T.eq(f.visible.NPC, false, "current native template receives visibility effect")
+ Commands.g2_object(ctx, 1, true)
+ T.eq(f.visible.NPC, false, "public adapter leaves existing script-ID cache unchanged")
+ Commands.g2ResetObjectBase()
+end
+-- Existing native/API-2 facade retains its own coercion/validation behavior.
+do
+ local f = fixture()
+ local api = require("src.world.WorldAPI").new(f.game, "legacy")
+ T.check(api:toggleObject("UNKNOWN", "missing", "truthy"), "legacy off-map toggle unchanged")
+ T.eq(f.game.save.objectToggles.UNKNOWN.missing, true, "legacy boolean coercion unchanged")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("API 3 object and block edits")
diff --git a/tests/engine/battle_actions.lua b/tests/engine/battle_actions.lua
new file mode 100644
index 000000000..2c6799081
--- /dev/null
+++ b/tests/engine/battle_actions.lua
@@ -0,0 +1,758 @@
+-- Public intents exercised through real Loader, BattleState and native input.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Loader = require("src.mods.Loader")
+local BattleState = require("src.battle.BattleState")
+local GameVersion = require("src.core.GameVersion")
+local Pokemon = require("src.pokemon.Pokemon")
+local SaveData = require("src.core.SaveData")
+local Font = require("src.render.Font")
+local Runtime = require("src.mods.Runtime")
+local Json = require("src.link.Json")
+local Merge = require("src.mods.Merge")
+local Commands = require("src.battle.BattleCommands")
+local Intents = require("src.mods.BattleIntents")
+local oldVersion = GameVersion.get()
+
+local function boot(version, trainer, level)
+ GameVersion.set(version)
+ math.randomseed(12345)
+ local data = T.fixtures.fresh()
+ local files = {}
+ for _, id in ipairs({ "probe", "other" }) do
+ files["mods/" .. id .. "/manifest.json"] =
+ Json.encode({ id = id, name = id, version = "1.0.0", api = level or 2, entry = "main.lua" })
+ files["mods/" .. id .. "/main.lua"] = [[return function(mod)
+ assert(type(mod.battle.submit)=="function")
+ assert(mod.battle:snapshot()==nil)
+ end]]
+ end
+ local fs = {
+ read = function(p)
+ return files[p]
+ end,
+ load = function(p)
+ return loadstring(files[p], p)
+ end,
+ getDirectoryItems = function(p)
+ return p == "mods" and { "probe", "other" } or {}
+ end,
+ getInfo = function(p)
+ if p == "mods" or p == "mods/probe" or p == "mods/other" then
+ return { type = "directory" }
+ end
+ if files[p] then
+ return { type = "file" }
+ end
+ end,
+ }
+ local loader = Loader.new({ fs = fs, dev = false })
+ local game = { data = data, stack = { states = {} }, save = SaveData.newGame() }
+ loader.game = game
+ assert(loader:load(data), table.concat(loader.errors, "; "))
+ Font.load(data)
+ game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) }
+ game.input = {
+ wasPressed = function()
+ return false
+ end,
+ isDown = function()
+ return false
+ end,
+ }
+ game.stack.top = function(stack)
+ return stack.states[#stack.states]
+ end
+ game.stack.push = function(stack, state)
+ stack.states[#stack.states + 1] = state
+ end
+ game.stack.pop = function(stack)
+ return table.remove(stack.states)
+ end
+ local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
+ or BattleState.newWild(game, "FIXMON_C", 10)
+ b:syncSides()
+ b.phase = "menu"
+ b.gen4CursorActive = true
+ b.frame = 0
+ b.queue = {}
+ b.menuIndex = 1
+ b.moveIndex = 1
+ local rolls = 0
+ b.rng = function(lo, hi)
+ rolls = rolls + 1
+ return lo
+ end
+ game.stack.states = { b }
+ return loader.apis.probe.battle,
+ game,
+ b,
+ loader.apis.other.battle,
+ loader,
+ function()
+ return rolls
+ end
+end
+local function intent(api, id, kind, fields)
+ local s = assert(api:snapshot())
+ local out = { id = id, revision = s.revision, kind = kind }
+ for k, v in pairs(fields or {}) do
+ out[k] = v
+ end
+ return out
+end
+local function send(api, id, kind, fields)
+ return api:submit(intent(api, id, kind, fields))
+end
+local function freeze(root)
+ local graph = {}
+ local function visit(value)
+ if type(value) ~= "table" or graph[value] then
+ return
+ end
+ local row = { values = {}, mt = getmetatable(value) }
+ graph[value] = row
+ for key, item in pairs(value) do
+ row.values[key] = item
+ visit(item)
+ end
+ end
+ visit(root)
+ return function()
+ for tbl, row in pairs(graph) do
+ if getmetatable(tbl) ~= row.mt then
+ return false
+ end
+ for key, value in pairs(row.values) do
+ if tbl[key] ~= value then
+ return false
+ end
+ end
+ for key in pairs(tbl) do
+ if row.values[key] == nil then
+ return false
+ end
+ end
+ end
+ return true
+ end
+end
+local function forbidden()
+ error("unexpected native side effect")
+end
+local function reject(api, game, raw, fragment, label)
+ local battler = game.stack.states[1]
+ local oldRng = battler and battler.rng
+ if battler and battler.isBattleState then
+ battler.rng = forbidden
+ end
+ local unchanged = freeze(game)
+ local oldLua, oldLove, oldEmit, oldCall = math.random, love.math.random, Runtime.emit, Runtime.call
+ math.random, love.math.random, Runtime.emit, Runtime.call = forbidden, forbidden, forbidden, forbidden
+ local ok, reason = api:submit(raw)
+ math.random, love.math.random, Runtime.emit, Runtime.call = oldLua, oldLove, oldEmit, oldCall
+ T.eq(ok, nil, label .. ": rejected")
+ T.check(
+ type(reason) == "string" and reason:find(fragment, 1, true) ~= nil,
+ label .. ": reason " .. tostring(reason)
+ )
+ T.check(unchanged(), label .. ": whole game graph unchanged")
+ if battler and battler.isBattleState then
+ battler.rng = oldRng
+ end
+end
+local function view(api)
+ local s = assert(api:snapshot())
+ s.revision = nil
+ return s
+end
+local function press(game, b, key)
+ game.input.wasPressed = function(_, name)
+ return name == key
+ end
+ b:update(1 / 60)
+ game.input.wasPressed = function()
+ return false
+ end
+end
+
+for _, version in ipairs(GameVersion.ORDER) do
+ local label = version .. ": "
+ -- Identical native input and API commands, including actual turn planning.
+ local api, game, b, other, loader, rolls = boot(version)
+ local control, cgame, cb, _, _, crolls = boot(version)
+ T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "FIGHT accepted")
+ press(cgame, cb, "a")
+ T.same(view(api), view(control), label .. "FIGHT matches native A dispatch")
+ T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "move accepted")
+ press(cgame, cb, "a")
+ T.same(view(api), view(control), label .. "move matches native A dispatch")
+ T.eq(rolls(), crolls(), label .. "native RNG consumption matches")
+ T.eq(#b.queue, #cb.queue, label .. "native action queue matches")
+ T.eq(b.turnCount, 1, label .. "exactly one native turn planned")
+ local function drain(g, bb)
+ g.input.wasPressed = function()
+ return true
+ end
+ g.input.isDown = function()
+ return true
+ end
+ for frame = 1, 5000 do
+ if not bb:updateQueue() then
+ return true
+ end
+ end
+ return false
+ end
+ T.check(drain(game, b), label .. "API native turn queue completes")
+ T.check(drain(cgame, cb), label .. "input native turn queue completes")
+ T.eq(b.enemy.mon.hp, cb.enemy.mon.hp, label .. "native damage identical after queue")
+ T.eq(b.player.mon.hp, cb.player.mon.hp, label .. "enemy retaliation identical after queue")
+ T.eq(b.player.curMoves[1].pp, cb.player.curMoves[1].pp, label .. "PP consumption identical after queue")
+ T.eq(rolls(), crolls(), label .. "full native turn RNG identical")
+ if loader.apis.probe.supports then
+ T.eq(loader.apis.probe:supports("battle", "submit"), true, label .. "submit capability")
+ end
+ -- Menu opening does not bypass the party/bag controller or consume items.
+ for _, choice in ipairs({ "party", "item", "run" }) do
+ local a, g, bb = boot(version)
+ local c, cg, cc = boot(version)
+ T.eq(send(a, 1, "menu", { choice = choice }), true, label .. choice .. " accepted")
+ local indices = cc:menuActions()
+ for i, value in ipairs(indices) do
+ if value == choice or (choice == "party" and value == "pkmn") then
+ cc.menuIndex = i
+ end
+ end
+ press(cg, cc, "a")
+ -- Native cursor is selected by the player; the public API addresses the
+ -- action by name, so it need not move that display cursor.
+ local sv, cv = view(a), view(c)
+ sv.menuIndex = nil
+ cv.menuIndex = nil
+ T.same(sv, cv, label .. choice .. " matches native input")
+ T.eq(#bb.queue, #cc.queue, label .. choice .. " native queue")
+ end
+ api, game, b = boot(version)
+ local first = assert(api:snapshot())
+ local bads = { false, {}, setmetatable({ id = 1, revision = first.revision, kind = "back" }, {}) }
+ for _, raw in ipairs(bads) do
+ reject(api, game, raw, "", label .. "malformed intent")
+ end
+ for _, value in ipairs({ 0, -1, 1.5, math.huge, -math.huge, 0 / 0, "1", true, 9007199254740992 }) do
+ reject(
+ api,
+ game,
+ { id = value, revision = first.revision, kind = "menu", choice = "fight" },
+ "integer",
+ label .. "unsafe ID"
+ )
+ reject(
+ api,
+ game,
+ { id = 1, revision = value, kind = "menu", choice = "fight" },
+ "revision",
+ label .. "unsafe revision"
+ )
+ end
+ local malformed = {
+ { kind = "mystery" },
+ { kind = "menu", choice = "catch" },
+ { kind = "menu", choice = {} },
+ { kind = "move", slot = "1" },
+ { kind = "move", slot = 0 },
+ { kind = "move", slot = 5 },
+ { kind = "target", target = -1 },
+ { kind = "target", target = 4 },
+ { kind = "mimic", index = 0 },
+ { kind = "mimic", index = 5 },
+ { kind = "safari", action = "mud" },
+ { kind = "safari", action = false },
+ { kind = "back", extra = true },
+ { kind = "move", slot = 1, idOverride = 3 },
+ }
+ for _, fields in ipairs(malformed) do
+ local raw = { id = 1, revision = first.revision }
+ for k, v in pairs(fields) do
+ raw[k] = v
+ end
+ reject(api, game, raw, "", label .. "strict intent fields")
+ end
+ local current = intent(api, 1, "menu", { choice = "fight" })
+ T.eq(api:submit(current), true, label .. "rejected IDs were not consumed")
+ reject(api, game, current, "replayed", label .. "accepted ID cannot replay")
+ current.id = 0
+ reject(api, game, current, "integer", label .. "ID zero is invalid")
+ current = intent(api, 3, "move", { slot = 1 })
+ b.player.mon.hp = b.player.mon.hp - 1
+ reject(api, game, current, "stale", label .. "changed HP invalidates revision")
+ current = intent(api, 3, "move", { slot = 1 })
+ b.player.curMoves[1] = Merge.deepCopy(b.player.curMoves[1])
+ reject(api, game, current, "stale", label .. "identical replacement move invalidates revision")
+ current = intent(api, 3, "move", { slot = 1 })
+ b.player.mon = Merge.deepCopy(b.player.mon)
+ reject(api, game, current, "stale", label .. "identical replacement Pokemon invalidates revision")
+ current = intent(api, 3, "move", { slot = 1 })
+ game.stack.states[2] = { battle = b }
+ reject(api, game, current, "stale", label .. "new covering screen invalidates revision")
+ reject(
+ api,
+ game,
+ intent(api, 3, "move", { slot = 1 }),
+ "covered",
+ label .. "fresh covered snapshot still cannot act"
+ )
+ game.stack.states[2] = nil
+ for _, phase in ipairs({ "intro", "messages", "waitRemote", "finished" }) do
+ b.phase = phase
+ reject(
+ api,
+ game,
+ intent(api, 3, "menu", { choice = "fight" }),
+ "active",
+ label .. "wrong phase " .. phase
+ )
+ end
+ b.phase = "menu"
+ for _, flag in ipairs({ "demo", "dudeDemo", "pokedudeDemo", "spectating", "palace", "arena" }) do
+ b[flag] = true
+ reject(
+ api,
+ game,
+ intent(api, 3, "menu", { choice = "fight" }),
+ "controller",
+ label .. "automatic controller " .. flag
+ )
+ b[flag] = nil
+ end
+ for _, flag in ipairs({ "result", "dead", "waitingUI" }) do
+ b[flag] = true
+ reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "not ready " .. flag)
+ b[flag] = nil
+ end
+ b.kind = "link"
+ reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "controller", label .. "link refused")
+ b.kind = "wild"
+ for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do
+ b.player[flag] = 1
+ reject(
+ api,
+ game,
+ intent(api, 3, "menu", { choice = "fight" }),
+ "locked",
+ label .. flag .. " automatic lock"
+ )
+ b.player[flag] = nil
+ end
+ b.player.ally = true
+ reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "NPC ally refused")
+ b.player.ally = nil
+ b.player.mon.hp = 0
+ reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "fainted chooser refused")
+ b.player.mon.hp = 30
+ b.phase = "moveSelect"
+ for _, guard in ipairs({ "pp", "disable", "taunt", "torment", "swap" }) do
+ local mv = b.player.curMoves[1]
+ local def = game.data.moves[mv.id]
+ local power = def.power
+ if guard == "pp" then
+ mv.pp = 0
+ end
+ if guard == "disable" then
+ b.player.disabledSlot = 1
+ end
+ if guard == "taunt" then
+ b.player.tauntTurns = 2
+ def.power = 0
+ end
+ if guard == "torment" then
+ b.player.tormented = true
+ b.player.lastMove = mv.id
+ end
+ if guard == "swap" then
+ b.moveSwapIndex = 1
+ end
+ reject(api, game, intent(api, 3, "move", { slot = 1 }), "", label .. guard .. " rejected before action")
+ mv.pp = 20
+ b.player.disabledSlot = nil
+ b.player.tauntTurns = nil
+ b.player.tormented = nil
+ b.player.lastMove = nil
+ b.moveSwapIndex = nil
+ def.power = power
+ end
+ reject(api, game, intent(api, 3, "move", { slot = 4 }), "slot", label .. "missing slot refused")
+ b.moveSwapIndex = 1
+ T.eq(send(api, 3, "back"), true, label .. "back cancels reorder")
+ T.eq(b.moveSwapIndex, nil, label .. "reorder marker cleared")
+ T.eq(b.phase, "menu", label .. "back to menu")
+ reject(api, game, intent(api, 4, "back"), "nothing", label .. "single menu has no back action")
+ game.stack.states = {}
+ reject(api, game, { id = 4, revision = 1, kind = "back" }, "no battle", label .. "outside battle refused")
+end
+local function doubles(api, game, b)
+ math.randomseed(54321)
+ b.double = true
+ b:placeBattler(2, BattleState.makeBattler(game.data, game.save.party[2], true, game.save))
+ local foe = Pokemon.new(game.data, "FIXMON_B", 10)
+ b.enemyParty = b.enemyParty or { b.enemy.mon }
+ b.enemyParty[2] = foe
+ b:placeBattler(3, BattleState.makeBattler(game.data, foe, false))
+ b:syncSides()
+end
+for _, version in ipairs(GameVersion.ORDER) do
+ local label = version .. " doubles: "
+ local api, game, b = boot(version)
+ local control, cgame, cb = boot(version)
+ doubles(api, game, b)
+ doubles(control, cgame, cb)
+ T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "first FIGHT")
+ press(cgame, cb, "a")
+ T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "first move enters targets")
+ press(cgame, cb, "a")
+ T.eq(b.phase, "targetSelect", label .. "native targeting phase")
+ reject(api, game, intent(api, 3, "target", { target = 0 }), "selector", label .. "cannot target own slot")
+ local offered = intent(api, 3, "target", { target = 3 })
+ b.targetChoices = { unpack(b.targetChoices) }
+ reject(api, game, offered, "stale", label .. "selector identity changed")
+ T.eq(send(api, 3, "target", { target = 3 }), true, label .. "position 3 selected")
+ cb.targetIndex = 2
+ press(cgame, cb, "a")
+ T.same(view(api), view(control), label .. "position maps to correct native cursor")
+ T.eq(b.chosenTargets[0], b:battlerAt(3), label .. "chosen actual right foe")
+ T.eq(b.choosingSlot, 2, label .. "second chooser")
+ T.eq(send(api, 4, "back"), true, label .. "undo first selection from second menu")
+ press(cgame, cb, "b")
+ T.same(view(api), view(control), label .. "back matches native B")
+ T.eq(b.pendingActions[0], nil, label .. "pending first action cleared")
+ T.eq(b.chosenTargets[0], nil, label .. "pending target cleared")
+ -- Native B from target returns to moves, then from moves to action menu.
+ T.eq(send(api, 5, "menu", { choice = "fight" }), true, label .. "fight again")
+ T.eq(send(api, 6, "move", { slot = 1 }), true, label .. "target again")
+ T.eq(send(api, 7, "back"), true, label .. "cancel target")
+ T.eq(b.pendingMove, nil, label .. "pending move cleared")
+ T.eq(b.targetChoices, nil, label .. "target selector cleared")
+ T.eq(b.phase, "moveSelect", label .. "back to moves")
+ T.eq(send(api, 8, "back"), true, label .. "back to menu")
+ T.eq(send(api, 9, "menu", { choice = "fight" }), true, label .. "choose first action")
+ T.eq(send(api, 10, "move", { slot = 1 }), true, label .. "select first move")
+ local stale = intent(api, 11, "target", { target = 1 })
+ b.pendingMove = Merge.deepCopy(b.pendingMove)
+ reject(api, game, stale, "stale", label .. "pending move identity changed")
+ reject(api, game, intent(api, 11, "target", { target = 1 }), "pending", label .. "orphaned pending move")
+ b.pendingMove = b.player.curMoves[1]
+ b.pendingMove.pp = 0
+ reject(api, game, intent(api, 11, "target", { target = 1 }), "PP", label .. "pending move rechecks PP")
+ b.pendingMove.pp = 20
+ b:battlerAt(1).mon.hp = 0
+ reject(api, game, intent(api, 11, "target", { target = 1 }), "field", label .. "fainted target refused")
+ b:battlerAt(1).mon.hp = 30
+ T.eq(send(api, 11, "target", { target = 1 }), true, label .. "first target")
+ T.eq(send(api, 12, "menu", { choice = "fight" }), true, label .. "second FIGHT")
+ T.eq(send(api, 13, "move", { slot = 1 }), true, label .. "second move")
+ T.eq(send(api, 14, "target", { target = 0 }), true, label .. "target position zero supported")
+ T.eq(b.chosenTargets[2], b.player, label .. "second actor aimed at left partner")
+ T.eq(b.turnCount, 1, label .. "second chooser resolves one whole turn")
+ -- A surviving right flank still owns a usable menu.
+ api, game, b = boot(version)
+ doubles(api, game, b)
+ b:placeBattler(0, nil)
+ T.eq(api:snapshot().active, 2, label .. "missing-left chooser")
+ T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "right-only FIGHT")
+ T.eq(b.phase, "moveSelect", label .. "right-only moves")
+ -- Mimic belongs to its queued user, even when that is the right flank.
+ b.phase = "mimicSelect"
+ local user = b:battlerAt(2)
+ local target = b.enemy
+ b.mimicCtx = { user = user, target = target, moveInst = user.curMoves[1] }
+ b.mimicMoves = { { slot = 1, id = target.curMoves[1].id } }
+ b.mimicIndex = 1
+ T.eq(api:snapshot().active, 2, label .. "Mimic uses queued right-flank user")
+ local picked = intent(api, 2, "mimic", { index = 1 })
+ b.mimicCtx.target = b:battlerAt(3)
+ reject(api, game, picked, "stale", label .. "Mimic source identity changed")
+ b.mimicCtx.target = target
+ T.eq(send(api, 2, "mimic", { index = 1 }), true, label .. "native Mimic copy")
+ T.eq(user.curMoves[1].id, target.curMoves[1].id, label .. "Mimic installs selected source")
+ T.eq(b.phase, "messages", label .. "Mimic resumes queue")
+ T.eq(#b.mimicRestores, 1, label .. "native restoration bookkeeping preserved")
+end
+
+-- Safari uses its native model, including Platinum's own action implementation.
+for _, version in ipairs({ "red", "firered", "platinum", "heartgold" }) do
+ for _, action in ipairs({ "ball", "bait", "rock", "run" }) do
+ local api, game, b = boot(version)
+ local control, cgame, cb = boot(version)
+ for _, battle in ipairs({ b, cb }) do
+ battle.safari = { balls = 5 }
+ battle.safariCatchRate = 80
+ battle.baitFactor = 0
+ battle.escapeFactor = 0
+ if version == "platinum" then
+ battle.gen4Safari = require("src.battle.Gen4Safari").init()
+ end
+ end
+ T.eq(send(api, 1, "safari", { action = action }), true, version .. ": Safari " .. action)
+ cb.menuIndex = ({ ball = 1, bait = 2, rock = 3, run = 4 })[action]
+ press(cgame, cb, "a")
+ T.same(view(api), view(control), version .. ": Safari matches native " .. action)
+ T.eq(b.safari.balls, action == "ball" and 4 or 5, version .. ": ball spent only for throw")
+ local old = intent(api, 2, "safari", { action = "ball" })
+ reject(api, game, old, "", version .. ": cannot repeat Safari action during messages")
+ b.phase = "menu"
+ b.result = nil
+ b.safari.balls = 0
+ reject(
+ api,
+ game,
+ intent(api, 2, "safari", { action = "run" }),
+ "balls",
+ version .. ": depleted Safari belongs to automatic exit"
+ )
+ b.safari.balls = 1
+ reject(
+ api,
+ game,
+ intent(api, 2, "menu", { choice = "fight" }),
+ "safari",
+ version .. ": regular menu cannot bypass Safari"
+ )
+ end
+end
+
+-- BW eligibility goes through the native read-only rules, not the snapshot's
+-- deliberately partial disabled flag.
+for _, version in ipairs({ "black", "white" }) do
+ for _, guard in ipairs({ "choice", "imprison", "gravity", "heal", "sky" }) do
+ local api, game, b = boot(version)
+ b.phase = "moveSelect"
+ b.bw = { field = {}, sides = { {}, {} } }
+ local mv = b.player.curMoves[1]
+ local def = game.data.moves[mv.id]
+ if guard == "choice" then
+ game.data.items.CHOICE = { name = "Choice", bwPocket = 1, holdEffect = 55 }
+ b.player.items = game.data.items
+ b.player.mon.item = "CHOICE"
+ b.player.bwChoiceLock = "FIX_CUT"
+ b.player.curMoves[2] = { id = "FIX_CUT", pp = 3 }
+ elseif guard == "imprison" then
+ b.enemy.bwImprison = true
+ b.enemy.curMoves = { { id = mv.id, pp = 2 } }
+ elseif guard == "gravity" then
+ b.bw.field.gravity = 3
+ def.gravityBanned = true
+ elseif guard == "heal" then
+ b.player.bwHealBlock = 3
+ def.healMove = true
+ else
+ b.player.bwSkyDropped = b.enemy
+ end
+ reject(
+ api,
+ game,
+ intent(api, 1, "move", { slot = 1 }),
+ "",
+ version .. ": native BW restriction " .. guard
+ )
+ end
+end
+
+-- Cross-facade atomicity, consumption and recovery are deliberately tested
+-- with native boundary overrides; the real-input cases above verify gameplay.
+do
+ local api, game, b, other = boot("crystal")
+ reject(
+ api,
+ game,
+ { id = 1, revision = 1, kind = "menu", choice = "fight" },
+ "snapshot",
+ "unobserved context"
+ )
+ local native = b.chooseMenu
+ local executions = 0
+ local nested, mid
+ local otherRequest = intent(other, 1, "menu", { choice = "fight" })
+ b.chooseMenu = function(self, choice)
+ executions = executions + 1
+ nested = { other:submit(otherRequest) }
+ mid = other:snapshot()
+ return native(self, choice)
+ end
+ T.eq(send(api, 1, "menu", { choice = "fight" }), true, "outer command accepted")
+ T.eq(nested[1], nil, "reentrant other mod rejected")
+ T.check(nested[2]:find("progress", 1, true) ~= nil, "reentrant rejection reason")
+ T.eq(mid.prompt, "locked", "mid-dispatch snapshot is locked")
+ reject(
+ other,
+ game,
+ { id = 1, revision = mid.revision, kind = "back" },
+ "stale",
+ "mid-dispatch revision cannot act after return"
+ )
+ T.eq(executions, 1, "one native dispatch")
+ T.eq(send(other, 1, "back"), true, "other facade has independent intent ID sequence")
+ b.chooseMenu = function()
+ executions = executions + 1
+ return true
+ end
+ local held = intent(other, 2, "menu", { choice = "fight" })
+ T.eq(send(api, 2, "menu", { choice = "fight" }), true, "same-shaped accepted native command")
+ reject(other, game, held, "stale", "shared epoch invalidates unchanged records across mods")
+ local raw = intent(api, 3, "menu", { choice = "fight" })
+ b.chooseMenu = function()
+ executions = executions + 1
+ b.phase = "messages"
+ error("native boom")
+ end
+ local accepted, err = api:submit(raw)
+ T.eq(accepted, nil, "native exception reported")
+ T.check(
+ err:find("consumed", 1, true) ~= nil and err:find("native boom", 1, true) ~= nil,
+ "native error explains consumed ID"
+ )
+ reject(api, game, raw, "replayed", "partial failure cannot replay")
+ T.eq(Intents.state(game).busy, false, "error clears shared busy flag")
+ b.phase = "menu"
+ b.chooseMenu = native
+ T.eq(send(api, 4, "menu", { choice = "fight" }), true, "new request recovers after native failure")
+end
+
+do
+ local api, game, b, other, loader = boot("red")
+ local original = b.menuBattler
+ local request = intent(api, 1, "menu", { choice = "fight" })
+ local nested
+ local otherRequest = intent(other, 1, "menu", { choice = "fight" })
+ b.menuBattler = function(self)
+ nested = { other:submit(otherRequest) }
+ return original(self)
+ end
+ T.eq(api:submit(request), true, "validation rejects recursion while outer intent succeeds")
+ T.eq(nested[1], nil, "validation reentry rejected")
+ T.eq(send(api, 2, "back"), true, "validation reentry leaves facade usable")
+ b.menuBattler = function(self)
+ game.stack.states[2] = {}
+ return original(self)
+ end
+ local ok, err = api:submit(intent(api, 3, "menu", { choice = "fight" }))
+ T.eq(ok, nil, "preflight context change refused")
+ T.check(err:find("stale", 1, true) ~= nil, "preflight context change reason")
+ -- The intentionally overridden validator above changed the stack itself;
+ -- test the refusal separately from the normal no-side-effects contract.
+ game.stack.states[2] = nil
+ b.menuBattler = original
+ T.eq(Intents.state(game).busy, false, "preflight failure clears busy flag")
+ local raw = intent(api, 3, "menu", { choice = "fight" })
+ b.menuBattler = function(self)
+ raw.choice = "run"
+ raw.id = 999
+ return original(self)
+ end
+ T.eq(api:submit(raw), true, "intent copied before native validation")
+ T.eq(b.phase, "moveSelect", "caller mutation cannot change dispatch choice")
+ b.menuBattler = original
+ local _, newGame, newBattle = boot("blue")
+ loader.game = newGame
+ reject(
+ api,
+ newGame,
+ { id = 3, revision = raw.revision, kind = "back" },
+ "replayed",
+ "consumption survives Game replacement"
+ )
+ T.eq(send(api, 4, "menu", { choice = "fight" }), true, "captured facade follows new Game")
+ T.eq(newBattle.phase, "moveSelect", "action reaches replacement Game")
+end
+
+-- Accepted calls can enter normal refusal/forced-action paths too. They do
+-- not reinterpret a native trainer RUN, no-PP Struggle or trapped FIGHT.
+for _, mode in ipairs({ "struggle", "trapping", "ghost", "trainerRun" }) do
+ local api, game, b = boot("crystal", mode == "trainerRun")
+ local control, cgame, cb = boot("crystal", mode == "trainerRun")
+ for _, bb in ipairs({ b, cb }) do
+ if mode == "struggle" then
+ for _, mv in ipairs(bb.player.curMoves) do
+ mv.pp = 0
+ end
+ end
+ if mode == "trapping" then
+ bb.player.trappingTurns = 2
+ end
+ if mode == "ghost" then
+ bb.ghost = true
+ end
+ end
+ local choice = mode == "trainerRun" and "run" or "fight"
+ T.eq(send(api, 1, "menu", { choice = choice }), true, mode .. ": public selection accepted")
+ cb.menuIndex = mode == "trainerRun" and 4 or 1
+ press(cgame, cb, "a")
+ local actual, expected = view(api), view(control)
+ actual.menuIndex = nil
+ expected.menuIndex = nil
+ T.same(actual, expected, mode .. ": preserves native branch")
+ T.eq(#b.queue, #cb.queue, mode .. ": native queue preserved")
+end
+
+do
+ local api, game, b = boot("red")
+ local raw = intent(api, 1, "menu", { choice = "fight" })
+ local rev = raw.revision
+ raw.extra = "typo"
+ reject(api, game, raw, "field", "invalid field is observational")
+ T.eq(api:snapshot().revision, rev, "pure invalid intent does not advance revision")
+ raw.extra = nil
+ local native = b.chooseMenu
+ b.chooseMenu = function()
+ return nil, "native declined"
+ end
+ local ok, err = api:submit(raw)
+ T.eq(ok, nil, "native refusal returned")
+ T.eq(err, "native declined", "native refusal reason retained")
+ reject(api, game, raw, "replayed", "native invocation consumes even when it declines")
+ b.chooseMenu = native
+ T.eq(send(api, 2, "menu", { choice = "fight" }), true, "native decline clears busy lock")
+ local _, otherGame = boot("blue")
+ game.stack.states[1] = otherGame.stack.states[1]
+ reject(api, game, intent(api, 3, "back"), "Game", "foreign Game battle cannot be controlled")
+end
+do
+ local api, game, b = boot("red")
+ local owner = Runtime.currentMod
+ Runtime.currentMod = "probe"
+ b.chooseMenu = function()
+ T.eq(Runtime.currentMod, nil, "native dispatch uses engine attribution")
+ error("scope failure")
+ end
+ local ok = send(api, 1, "menu", { choice = "fight" })
+ T.eq(ok, nil, "scoped native error returned")
+ T.eq(Runtime.currentMod, "probe", "native error restores caller attribution")
+ Runtime.currentMod = owner
+end
+do
+ local api, game, b, other, loader = boot("emerald")
+ local callbackCount, nested, mid = 0, nil, nil
+ local held = intent(other, 1, "menu", { choice = "fight" })
+ loader.apis.probe.events:on("battle.turn_started", function()
+ callbackCount = callbackCount + 1
+ T.eq(Runtime.currentMod, nil, "real event uses native engine scope")
+ mid = other:snapshot()
+ nested = { other:submit(held) }
+ end)
+ T.eq(send(api, 1, "menu", { choice = "fight" }), true, "real event test selects FIGHT")
+ T.eq(send(api, 2, "move", { slot = 1 }), true, "real event test plans native turn")
+ T.eq(callbackCount, 1, "one native turn-start callback")
+ T.eq(nested[1], nil, "real event cannot recursively submit")
+ T.eq(mid.prompt, "locked", "real event sees locked in-flight snapshot")
+ reject(
+ other,
+ game,
+ { id = 1, revision = mid.revision, kind = "back" },
+ "stale",
+ "real event snapshot expires after dispatch"
+ )
+ T.eq(Runtime.currentMod, nil, "real native event restores engine/caller scope")
+end
+local api = boot("red", false, 1)
+T.eq(send(api, 1, "menu", { choice = "fight" }), true, "API 1 mod can use additive public actions")
+GameVersion.set(oldVersion)
+T.finish("battle actions")
diff --git a/tests/engine/battle_snapshots.lua b/tests/engine/battle_snapshots.lua
new file mode 100644
index 000000000..acf55273d
--- /dev/null
+++ b/tests/engine/battle_snapshots.lua
@@ -0,0 +1,613 @@
+-- ROM-free contract tests through the real mod loader and BattleState.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.harness")
+local Loader = require("src.mods.Loader")
+local BattleState = require("src.battle.BattleState")
+local GameVersion = require("src.core.GameVersion")
+local Merge = require("src.mods.Merge")
+local Json = require("src.link.Json")
+local oldVersion, oldLove = GameVersion.get(), love
+love = nil
+
+-- Exercise the real loader resolver, including Lua 5.1's in-progress marker.
+local cachedGame = package.loaded["src.core.Game"]
+package.loaded["src.core.Game"] = nil
+T.eq(Loader._game({}, true), nil, "loaded-only lookup does not bootstrap Game")
+T.eq(package.loaded["src.core.Game"], nil, "lookup leaves module cache untouched")
+package.loaded["src.core.Game"] = true
+T.eq(Loader._game({}, true), nil, "in-progress Game is unavailable")
+local injected = {}
+T.eq(Loader._game({ game = injected }, true), injected, "explicit Game takes precedence")
+package.loaded["src.core.Game"] = injected
+T.eq(Loader._game({}, true), injected, "live cached Game is available")
+package.loaded["src.core.Game"] = cachedGame
+
+local function boot(version, level)
+ GameVersion.set(version)
+ local ds = GameVersion.generation() >= 4
+ local s, m, item = ds and 25 or "PIKACHU", ds and 33 or "TACKLE", ds and 4 or "POKE_BALL"
+ local species, move =
+ { name = "Pikachu", types = { "ELECTRIC" } },
+ { name = "Tackle", pp = 35, power = 40, accuracy = 95, type = "NORMAL" }
+ local data = {
+ pokemon = { [s] = species },
+ moves = { [m] = move },
+ items = {
+ [item] = { name = "Poké Ball", ball = "POKE_BALL" },
+ Z_EXTRA = { name = "Extra" },
+ A_EXTRA = { name = "Earlier" },
+ },
+ }
+ if ds then
+ data.pokemon.SPECIES_25 = species
+ data.moves.MOVE_33 = move
+ end
+ local function mon(nick)
+ return {
+ species = s,
+ nickname = nick,
+ level = 7,
+ hp = 19,
+ status = "PSN",
+ stats = { hp = 23, attack = 10, defense = 11, special = 12, speed = 13 },
+ moves = { { id = m, pp = 29, ppUps = 1 } },
+ isEgg = false,
+ extension = { flag = false },
+ }
+ end
+ local a, b, e, f = mon("Left"), mon("Right"), mon("Foe"), mon("Other foe")
+ if GameVersion.generation() > 1 then
+ for _, raw in ipairs({ a, b, e, f }) do
+ raw.stats.spatk, raw.stats.spdef = 14, 15
+ end
+ end
+ local party = { a, b }
+ local function battler(raw)
+ return {
+ mon = raw,
+ name = raw.nickname,
+ species = raw.species,
+ curStats = raw.stats,
+ curTypes = { "ELECTRIC" },
+ curMoves = raw.moves,
+ stages = {},
+ }
+ end
+ local game = {
+ data = data,
+ save = {
+ party = party,
+ inventory = {
+ [item] = 3,
+ Z_EXTRA = 2,
+ A_EXTRA = 1,
+ ZERO = 0,
+ },
+ bagOrder = { item, item, "ZERO" },
+ },
+ stack = { states = {} },
+ }
+ local btl = setmetatable({
+ game = game,
+ data = data,
+ kind = "wild",
+ phase = "menu",
+ queue = {},
+ player = battler(a),
+ enemy = battler(e),
+ enemyParty = { e, f },
+ turnCount = 0,
+ sides = { { battlers = {} }, { battlers = {} } },
+ }, BattleState)
+ btl:placeBattler(0, btl.player)
+ btl:placeBattler(1, btl.enemy)
+ local files = {
+ ["mods/probe/manifest.json"] = Json.encode({
+ id = "probe",
+ name = "Probe",
+ version = "1.0.0",
+ api = level or 2,
+ entry = "main.lua",
+ }),
+ ["mods/probe/main.lua"] = [[return function(mod)
+ assert(type(mod.battle.snapshot)=="function")
+ mod.exports.api=mod
+ mod.content.moves:register("MOVE_9000", {id="MOVE_9000", name="Late move", pp=10, power=10,
+ accuracy=100, type="NORMAL", effect="NO_ADDITIONAL_EFFECT"})
+ end]],
+ }
+ local fs = {
+ read = function(p)
+ return files[p]
+ end,
+ load = function(p)
+ return loadstring(files[p], p)
+ end,
+ getDirectoryItems = function(p)
+ return p == "mods" and { "probe" } or {}
+ end,
+ getInfo = function(p)
+ return (p == "mods" or p == "mods/probe") and { type = "directory" } or files[p] and { type = "file" }
+ end,
+ }
+ local loader = Loader.new({ fs = fs, dev = false })
+ local resolves = 0
+ loader._game = function()
+ resolves = resolves + 1
+ return game
+ end
+ assert(loader:load(data), table.concat(loader.errors, "; "))
+ return loader.apis.probe, loader, game, btl, battler, { s, m, item }, function()
+ return resolves
+ end
+end
+
+-- Freeze the whole graph as table identities + raw entries, including cycles.
+local function freeze(root)
+ local graph = {}
+ local function visit(value)
+ if type(value) ~= "table" or graph[value] then
+ return
+ end
+ local entries = {}
+ graph[value] = entries
+ for key, child in pairs(value) do
+ entries[key] = child
+ visit(child)
+ end
+ end
+ visit(root)
+ return function()
+ for tbl, entries in pairs(graph) do
+ for key, value in pairs(entries) do
+ if tbl[key] ~= value then
+ return false
+ end
+ end
+ for key in pairs(tbl) do
+ if entries[key] == nil then
+ return false
+ end
+ end
+ end
+ return true
+ end
+end
+
+local function forbidden()
+ error("snapshot crossed into gameplay")
+end
+local oldRandom = math.random
+math.random = forbidden
+for _, version in ipairs(GameVersion.ORDER) do
+ local api, loader, game, b, battler, ids, resolves = boot(version)
+ local label = version .. ": "
+ T.eq(resolves(), 0, label .. "entry can capture facade before Game exists")
+ if api.supports then
+ T.eq(api:supports("battle", "snapshot"), true, label .. "snapshot capability")
+ T.eq(api:supports("battle", "submit"), true, label .. "additive input submission")
+ T.eq(resolves(), 0, label .. "probe does not resolve Game")
+ end
+ T.eq(api.battle, api.battle, label .. "stable facade")
+ T.eq(type(api.battle.submit), "function", label .. "additive submit method")
+ local no, reason = api.battle:snapshot()
+ T.eq(no, nil, label .. "no battle")
+ T.eq(reason, "no battle", label .. "no battle reason")
+ game.stack.states = { { battle = b, phase = "menu", player = b.player, enemy = b.enemy } }
+ T.eq(api.battle:snapshot(), nil, label .. "a UI holding battle is not battle")
+ game.stack.states = { b }
+ b.rng = forbidden
+ b.menuLockedAction, b.fightLockedAction, b.menuBattler, b.playerHasPP =
+ forbidden, forbidden, forbidden, forbidden
+ b.catchAttempt, b.ballDef, b.moveDef, b.choosingSlotNow = forbidden, forbidden, forbidden, forbidden
+ b.volatile, b.enemyAction, b.update, b.draw = forbidden, forbidden, forbidden, forbidden
+ local unchanged = freeze(game)
+ local snap = api.battle:snapshot()
+ T.check(unchanged(), label .. "complete game graph unchanged")
+ T.eq(snap.revision, 1, label .. "first revision")
+ T.eq(snap.prompt, "menu", label .. "menu phase")
+ T.eq(snap.double, false, label .. "single battle")
+ T.eq(snap.active, 0, label .. "left actor")
+ T.eq(snap.kind, "wild", label .. "wild kind")
+ T.eq(snap.turn, 0, label .. "before first turn")
+ T.eq(snap.catchable, true, label .. "single wild target")
+ T.eq(#snap.battlers, 2, label .. "both occupied slots")
+ T.eq(snap.player.species, "PIKACHU", label .. "portable species")
+ T.eq(snap.player.name, "Left", label .. "battler display name")
+ T.eq(snap.player.hp, 19, label .. "logical HP")
+ T.eq(snap.player.maxHp, 23, label .. "max HP")
+ T.eq(snap.player.status, "poison", label .. "portable status")
+ T.eq(snap.player.stats.specialAttack, GameVersion.generation() == 1 and 12 or 14, label .. "special attack")
+ T.eq(
+ snap.player.stats.specialDefense,
+ GameVersion.generation() == 1 and 12 or 15,
+ label .. "special defense"
+ )
+ T.eq(snap.player.partyIndex, 1, label .. "party slot by identity")
+ T.eq(snap.party[1].active, true, label .. "lead active")
+ T.eq(snap.party[2].active, false, label .. "bench inactive")
+ T.eq(snap.party[2].slot, 2, label .. "party slot one based")
+ T.eq(snap.moves[1].id, "TACKLE", label .. "portable move")
+ T.eq(snap.moves[1].maxPp, 42, label .. "PP Up maximum")
+ T.eq(snap.moves[1].pp, 29, label .. "current PP")
+ T.eq(snap.moves[1].disabled, false, label .. "unrestricted move")
+ T.eq(snap.moves[1].accuracy, 95, label .. "base accuracy")
+ T.eq(#snap.items, 3, label .. "positive inventory deduplicated")
+ T.eq(snap.items[1].id, "POKE_BALL", label .. "portable ball and bag order")
+ T.eq(snap.items[1].count, 3, label .. "inventory quantity")
+ T.eq(snap.items[1].ball, true, label .. "ball classification")
+ T.eq(snap.items[2].id, "A_EXTRA", label .. "unlisted inventory sorted")
+ T.eq(snap.items[3].id, "Z_EXTRA", label .. "stable inventory order")
+ T.eq(snap.items[1].catchChance, nil, label .. "no guessed catch preview")
+ T.eq(snap.items[1].needsTarget, nil, label .. "no guessed item eligibility")
+ T.same(api.battle:snapshot(), snap, label .. "unchanged reads stable")
+ snap.player.hp = 0
+ snap.player.native.hp = 0
+ snap.player.native.extension.flag = true
+ snap.moves[1].pp = 0
+ snap.items[1].count = 0
+ snap.party[1].stats.hp = 0
+ snap.player.types[1] = "WATER"
+ snap.player.stages.attack = 6
+ local clean = api.battle:snapshot()
+ T.eq(clean.revision, 1, label .. "caller writes do not alter revision")
+ T.eq(clean.player.hp, 19, label .. "caller writes isolated")
+ T.eq(clean.player.native.extension.flag, false, label .. "false preserved in diagnostics")
+ T.eq(clean.moves[1].pp, 29, label .. "move tables isolated")
+ T.eq(clean.items[1].count, 3, label .. "item tables isolated")
+ T.eq(clean.party[1].stats.hp, 23, label .. "party stats isolated")
+ T.eq(clean.player.types[1], "ELECTRIC", label .. "types isolated")
+ T.eq(clean.player.stages.attack, nil, label .. "stages isolated")
+
+ b.frame, b.player.shownHP, b.player.picAnim = 99, 12, { tick = 5 }
+ T.eq(api.battle:snapshot().revision, 1, label .. "animation ticks excluded")
+ b.player.mon.hp = 18
+ local changed = api.battle:snapshot()
+ T.eq(changed.revision, 2, label .. "HP changes revision")
+ T.eq(changed.player.hp, 18, label .. "new logical HP visible")
+ T.eq(clean.player.hp, 19, label .. "old result remains detached")
+ b.turnCount = 1
+ T.eq(api.battle:snapshot().turn, 1, label .. "turn number updates")
+ local rev = api.battle:snapshot().revision
+ game.stack.states[#game.stack.states + 1] = { battle = b }
+ T.eq(api.battle:snapshot().prompt, "locked", label .. "covered battle locked")
+ T.check(api.battle:snapshot().revision > rev, label .. "coverage changes revision")
+ table.remove(game.stack.states)
+ local cases = {
+ { "intro", "locked" },
+ { "messages", "locked" },
+ { "moveSelect", "moves" },
+ { "targetSelect", "target" },
+ { "mimicSelect", "mimic" },
+ { "finished", "locked" },
+ }
+ for _, c in ipairs(cases) do
+ b.phase = c[1]
+ T.eq(api.battle:snapshot().prompt, c[2], label .. "phase " .. c[1])
+ end
+ b.phase = "messages"
+ b.current = { text = "Current text" }
+ b.msgPrompt = true
+ b.msgPromptWait = 3
+ T.eq(api.battle:snapshot().prompt, "locked", label .. "pre-advance delay")
+ b.msgPromptWait = 0
+ T.eq(api.battle:snapshot().prompt, "advance", label .. "manual message advance")
+ T.eq(api.battle:snapshot().messageSource, "Current text", label .. "current message source")
+ T.eq(api.battle:snapshot().message, nil, label .. "no invented visible message")
+ game.data.font =
+ { charmap = { { code = 128, seq = "A" }, { code = 129, seq = "B" }, { code = 130, seq = "C" } } }
+ b.shown = { { 128, 129 }, { 130 } }
+ local text = api.battle:snapshot()
+ T.same(text.message, { "AB", "C" }, label .. "only revealed glyphs in visible message")
+ b.shown[2][2] = 128
+ T.check(api.battle:snapshot().revision > text.revision, label .. "revealed glyph changes revision")
+ T.same(text.message, { "AB", "C" }, label .. "older visible message detached")
+ b.shown[1][1] = 999999
+ T.eq(api.battle:snapshot().message, nil, label .. "unknown glyph is not guessed")
+ game.data.font.pages = { extra = { charmap = { { code = 999999, seq = "Ω" } } } }
+ T.same(api.battle:snapshot().message, { "ΩB", "CA" }, label .. "font page charmap read")
+ local unicode = package.loaded["src.translation.UnicodeFont"]
+ package.loaded["src.translation.UnicodeFont"] = { chars = { [-1] = "漢" } }
+ b.shown = { { -1 } }
+ T.same(api.battle:snapshot().message, { "漢" }, label .. "already loaded Unicode mapping read")
+ package.loaded["src.translation.UnicodeFont"] = unicode
+ game.data.font = { source = "Gen2 scaffold" }
+ b.shown = { { 65, 66 } }
+ T.same(api.battle:snapshot().message, { "AB" }, label .. "scaffold ASCII glyphs read")
+ game.stack.states[2] = { battle = b }
+ T.eq(api.battle:snapshot().message, nil, label .. "covered screen has no guessed visible message")
+ game.stack.states[2] = nil
+ b.shown = nil
+ b.current.choice = function() end
+ T.eq(api.battle:snapshot().prompt, "locked", label .. "choice is its own controller")
+ b.current = nil
+ b.msgPrompt = nil
+ b.phase = "menu"
+ for _, flag in ipairs({ "waitingUI", "demo", "pokedudeDemo", "palace", "arena" }) do
+ b[flag] = true
+ T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked")
+ b[flag] = nil
+ end
+ for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do
+ b.player[flag] = 1
+ T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked")
+ b.player[flag] = nil
+ end
+ b.bw = true
+ b.player.bwSkyDropped = true
+ T.eq(api.battle:snapshot().prompt, "locked", label .. "Sky Drop lock")
+ b.bw = nil
+ b.player.bwSkyDropped = nil
+ b.safari = { balls = 3 }
+ T.eq(api.battle:snapshot().kind, "safari", label .. "Safari battle kind")
+ T.eq(api.battle:snapshot().safariBalls, 3, label .. "Safari ball count")
+ T.eq(api.battle:snapshot().prompt, "safari", label .. "Safari prompt")
+ b.safari.balls = 0
+ T.eq(api.battle:snapshot().prompt, "locked", label .. "no Safari balls")
+ b.safari = nil
+ b.demo = true
+ T.eq(api.battle:snapshot().kind, "oldman", label .. "automatic capture demo kind")
+ b.demo = nil
+ b.ghost = true
+ T.eq(api.battle:snapshot().kind, "ghost", label .. "ghost kind")
+ b.ghost = nil
+ b.menuIndex = 2
+ b.moveIndex = 3
+ b.moveSwapIndex = 1
+ local cursors = api.battle:snapshot()
+ T.eq(cursors.menuIndex, 2, label .. "menu cursor")
+ T.eq(cursors.moveIndex, 3, label .. "move cursor")
+ T.eq(cursors.moveSwapIndex, 1, label .. "reorder cursor")
+ b.menuIndex = 1
+ T.check(api.battle:snapshot().revision > cursors.revision, label .. "cursor changes revision")
+ for _, flag in ipairs({ "ghost", "noCatch", "result" }) do
+ b[flag] = true
+ T.eq(api.battle:snapshot().catchable, false, label .. flag .. " uncatchable")
+ b[flag] = nil
+ end
+ b.kind = "trainer"
+ T.eq(api.battle:snapshot().catchable, false, label .. "trainer uncatchable")
+ b.kind = "wild"
+ b.phase = "moveSelect"
+ for _, guard in ipairs({ "disable", "pp", "taunt", "torment" }) do
+ if guard == "disable" then
+ b.player.disabledSlot = 1
+ end
+ if guard == "pp" then
+ b.player.curMoves[1].pp = 0
+ end
+ if guard == "taunt" then
+ b.player.tauntTurns = 2
+ game.data.moves[ids[2]].power = 0
+ end
+ if guard == "torment" then
+ b.player.tormented = true
+ b.player.lastMove = ids[2]
+ end
+ T.eq(api.battle:snapshot().moves[1].disabled, true, label .. guard .. " menu restriction")
+ b.player.disabledSlot = nil
+ b.player.curMoves[1].pp = 29
+ b.player.tauntTurns = nil
+ game.data.moves[ids[2]].power = 40
+ b.player.tormented = nil
+ b.player.lastMove = nil
+ end
+ -- Native alias keys may be split during merging; a name patch must not
+ -- rename a portable ID. Later registrations still appear in fresh reads.
+ game.data.pokemon[ids[1]].name = "Renamed presentation"
+ T.eq(api.battle:snapshot().player.species, "PIKACHU", label .. "name patches preserve identity")
+ local originalMoves = b.player.curMoves
+ b.player.curMoves = { { id = "MOVE_9000", pp = 8 } }
+ T.eq(api.battle:snapshot().moves[1].id, "LATE_MOVE", label .. "late mod ID resolved")
+ b.player.curMoves = originalMoves
+ b.bw = { field = {}, sides = { {}, {} } }
+ T.eq(api.battle:snapshot().moves[1].disabled, nil, label .. "BW eligibility remains unknown")
+ b.player.disabledSlot = 1
+ T.eq(api.battle:snapshot().moves[1].disabled, true, label .. "BW known common restriction")
+ b.player.disabledSlot = nil
+ b.bw = nil
+ b.player.curMoves[1].ppUps = 99
+ T.eq(api.battle:snapshot().moves[1].maxPp, 56, label .. "PP Ups clamped as native engine")
+ b.player.curMoves[1].ppUps = -1
+ T.eq(api.battle:snapshot().moves[1].maxPp, 35, label .. "negative PP Ups clamped")
+ b.player.curMoves[1].ppUps = 1
+ -- Transform changes the in-battle view while the stored party keeps its moves/stats.
+ game.data.moves.COPIED = { name = "Copied", pp = 10, power = 50, type = "WATER" }
+ b.player.curMoves = { { id = "COPIED", pp = 5 } }
+ b.player.curStats = { hp = 23, attack = 90, special = 80 }
+ b.player.species = "COPIED_SPECIES"
+ b.player.curTypes = { "WATER" }
+ local transformed = api.battle:snapshot()
+ T.eq(transformed.moves[1].id, "COPIED", label .. "current transformed move")
+ T.eq(transformed.party[1].moves[1].id, "TACKLE", label .. "stored party move")
+ T.eq(transformed.player.battleStats.attack, 90, label .. "current transformed stats")
+ T.eq(transformed.player.stats.attack, 10, label .. "base stats preserved")
+ T.eq(transformed.player.displaySpecies, "COPIED_SPECIES", label .. "display species separate")
+ T.eq(transformed.player.species, "PIKACHU", label .. "original species preserved")
+ -- All four native positional slots, including a missing left flank.
+ b.double = true
+ b:placeBattler(2, battler(game.save.party[2]))
+ b:placeBattler(3, battler(b.enemyParty[2]))
+ b.choosingSlot = 2
+ b.phase = "menu"
+ local double = api.battle:snapshot()
+ T.eq(double.double, true, label .. "doubles flag")
+ T.eq(#double.battlers, 4, label .. "four occupied battlers")
+ T.eq(double.active, 2, label .. "second chooser")
+ T.eq(double.player.name, "Right", label .. "player follows chooser")
+ T.eq(double.player.partyIndex, 2, label .. "second chooser party index")
+ T.eq(double.catchable, false, label .. "cannot aim at two wild foes")
+ T.eq(double.party[2].active, true, label .. "both player slots active")
+ b.phase = "targetSelect"
+ b.targetChoices = { b.enemy, b.sides[2].battlers[2], b.player }
+ b.targetIndex = 2
+ T.same(api.battle:snapshot().targets, { 1, 3, 0 }, label .. "target IDs, not array positions")
+ T.eq(api.battle:snapshot().targetIndex, 2, label .. "target cursor")
+ b:placeBattler(0, nil)
+ b:placeBattler(1, nil)
+ b.choosingSlot = nil
+ b.phase = "menu"
+ local missing = api.battle:snapshot()
+ T.eq(missing.active, 2, label .. "right flank after left faint")
+ T.eq(missing.enemy, nil, label .. "missing enemy left not silently aliased")
+ T.eq(missing.catchable, true, label .. "one wild foe remaining")
+ T.eq(#missing.battlers, 2, label .. "missing slots omitted")
+ T.eq(missing.battlers[1].id, 2, label .. "battler identity survives sparse slots")
+ b.phase = "mimicSelect"
+ b.mimicMoves = { { id = ids[2], slot = 3 } }
+ b.mimicIndex = 1
+ local mimic = api.battle:snapshot()
+ T.eq(mimic.mimicMoves[1].id, "TACKLE", label .. "portable mimic ID")
+ T.eq(mimic.mimicMoves[1].slot, 3, label .. "original mimic move slot")
+ b.phase = "menu"
+ local ally = b.sides[1].battlers[2]
+ ally.ally = true
+ b.ally = { party = { ally.mon } }
+ local withAlly = api.battle:snapshot()
+ T.eq(withAlly.party[2].active, false, label .. "NPC ally not player active party")
+ T.eq(withAlly.battlers[1].ally, true, label .. "NPC ally marked")
+ T.eq(withAlly.prompt, "locked", label .. "NPC controlled actor menu locked")
+ ally.ally = nil
+ b.ally = nil
+ b.kind = "link"
+ b.playerParty = { game.save.party[2] }
+ local link = api.battle:snapshot()
+ T.eq(#link.party, 1, label .. "link uses clamped battle party")
+ T.eq(link.party[1].nickname, "Right", label .. "link party is actual participant")
+ T.eq(link.prompt, "locked", label .. "link controller owns input")
+ b.kind = "wild"
+ b.playerParty = nil
+ -- Snapshot diagnostics discard executable values and remain cycle-safe.
+ local raw = game.save.party[2]
+ raw.extension.self = raw.extension
+ raw.extension.fn = forbidden
+ raw.extension[{}] = "hidden"
+ setmetatable(raw.extension, { __index = { secret = true } })
+ local cyc = api.battle:snapshot()
+ T.eq(cyc.party[2].native.extension.self, cyc.party[2].native.extension, label .. "cycles copied")
+ T.eq(cyc.party[2].native.extension.fn, nil, label .. "function dropped")
+ T.eq(getmetatable(cyc.party[2].native.extension), nil, label .. "metatable dropped")
+ T.eq(cyc.party[2].native.extension.secret, nil, label .. "no inherited value")
+ T.eq(api.battle:snapshot().revision, cyc.revision, label .. "cyclic record stable revision")
+ local last = cyc.revision
+ game.stack.states = {}
+ T.eq(api.battle:snapshot(), nil, label .. "battle exit")
+ game.stack.states = { b }
+ T.check(api.battle:snapshot().revision > last, label .. "re-entry invalidates revision")
+ last = api.battle:snapshot().revision
+ local replacement = setmetatable(Merge.deepCopy(b), BattleState)
+ game.stack.states = { replacement }
+ T.check(api.battle:snapshot().revision > last, label .. "identical new battle invalidates revision")
+ -- A previously captured facade follows the loader's current Game.
+ local other = { data = game.data, save = game.save, stack = game.stack }
+ loader._game = function()
+ return other
+ end
+ last = api.battle:snapshot().revision
+ loader._game = function()
+ return game
+ end
+ T.check(api.battle:snapshot().revision > last, label .. "Game replacement invalidates revision")
+end
+math.random = oldRandom
+
+-- Exercise constructors and a native damage action with the committed fixture
+-- dataset, rather than asserting only hand-built record shapes.
+love = require("tests.love_stub")
+local Fixtures = require("tests.modkit.fixtures")
+local Pokemon = require("src.pokemon.Pokemon")
+local SaveData = require("src.core.SaveData")
+local Font = require("src.render.Font")
+local function native(version, trainer)
+ local api, loader = boot(version)
+ love = require("tests.love_stub")
+ math.randomseed(12345)
+ local data = Fixtures.fresh()
+ Font.load(data)
+ local save = SaveData.newGame()
+ save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) }
+ local game = {
+ data = data,
+ save = save,
+ input = {
+ wasPressed = function()
+ return false
+ end,
+ },
+ stack = {
+ states = {},
+ push = function() end,
+ pop = function() end,
+ top = function(self)
+ return self.states[#self.states]
+ end,
+ },
+ }
+ local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
+ or BattleState.newWild(game, "FIXMON_C", 10)
+ game.stack.states = { b }
+ loader._game = function()
+ return game
+ end
+ local calls = 0
+ b.rng = function(_, high)
+ calls = calls + 1
+ return high
+ end
+ return api, game, b, function()
+ return calls
+ end
+end
+for _, version in ipairs(GameVersion.ORDER) do
+ for _, trainer in ipairs({ false, true }) do
+ local api, game, b, calls = native(version, trainer)
+ local unchanged = freeze(game)
+ local rngBefore = calls()
+ local oldLoveRandom, oldLuaRandom = love.math.random, math.random
+ love.math.random, math.random = forbidden, forbidden
+ local Runtime = require("src.mods.Runtime")
+ local oldEmit, oldCall = Runtime.emit, Runtime.call
+ Runtime.emit, Runtime.call = forbidden, forbidden
+ local loaded = {}
+ for name in pairs(package.loaded) do
+ loaded[name] = true
+ end
+ local s = api.battle:snapshot()
+ for _ = 1, 10 do
+ T.eq(api.battle:snapshot().revision, s.revision, version .. ": repeated native snapshot stable")
+ end
+ local noNewModules = true
+ for name in pairs(package.loaded) do
+ if not loaded[name] then
+ noNewModules = false
+ end
+ end
+ T.check(noNewModules, version .. ": snapshot loads no modules")
+ Runtime.emit, Runtime.call = oldEmit, oldCall
+ T.check(unchanged(), version .. ": native constructor graph unchanged")
+ T.eq(calls(), rngBefore, version .. ": native RNG unchanged")
+ T.eq(s.kind, trainer and "trainer" or "wild", version .. ": native constructor kind")
+ T.eq(s.player.species, "FIXMON_A", version .. ": real Pokemon record")
+ T.eq(s.player.maxHp, b.player.mon.stats.hp, version .. ": constructor HP")
+ T.eq(s.moves[1].id, b.player.curMoves[1].id, version .. ": constructor moves")
+ T.eq(s.catchable, not trainer, version .. ": constructor catchability")
+ love.math.random, math.random = oldLoveRandom, oldLuaRandom
+ -- The same native action on an independently constructed control battle
+ -- must produce the same HP, PP, queue and RNG consumption.
+ local _, _, control, controlCalls = native(version, trainer)
+ b:performMove(b.player, b.enemy, b.player.curMoves[1])
+ control:performMove(control.player, control.enemy, control.player.curMoves[1])
+ T.eq(b.enemy.mon.hp, control.enemy.mon.hp, version .. ": action damage unaffected")
+ T.eq(b.player.curMoves[1].pp, control.player.curMoves[1].pp, version .. ": action PP unaffected")
+ T.eq(calls(), controlCalls(), version .. ": action RNG consumption unaffected")
+ T.eq(#b.queue, #control.queue, version .. ": action queue unaffected")
+ local later = api.battle:snapshot()
+ T.eq(later.enemy.hp, b.enemy.mon.hp, version .. ": snapshot follows native damage")
+ T.check(later.revision > s.revision, version .. ": native damage changes revision")
+ end
+end
+local api = boot("crystal", 1)
+T.eq(type(api.battle.snapshot), "function", "API 1 mods also get additive reader")
+GameVersion.set(oldVersion)
+love = oldLove
+T.finish("battle snapshots")
diff --git a/tests/engine/battler_data.lua b/tests/engine/battler_data.lua
new file mode 100644
index 000000000..03b7ef5c9
--- /dev/null
+++ b/tests/engine/battler_data.lua
@@ -0,0 +1,92 @@
+-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+-- This process deliberately has no LÖVE stub. Model construction must not
+-- silently allocate pictures, animations or a battle screen.
+package.path = './?.lua;./?/init.lua;' .. package.path
+local T = require('tests.harness')
+T.eq(_G.love, nil, 'fresh plain-Lua process has no rendering runtime')
+for _, name in ipairs({
+ 'src.battle.BattleState', 'src.pokemon.Sprites', 'src.pokemon.PicAnim',
+ 'src.pokemon.MonAnim', 'src.render.Assets',
+}) do
+ package.preload[name] = function() error('data constructor loaded presentation: ' .. name) end
+end
+local Battler = require('src.battle.Battler')
+local Version = require('src.core.GameVersion')
+local initial = Version.get()
+local function copy(t)
+ if type(t) ~= 'table' then return t end
+ local r = {}; for k, v in pairs(t) do r[k] = copy(v) end; return r
+end
+
+for _, edition in ipairs(Version.ORDER) do
+ Version.set(edition)
+ local label = edition .. ': '
+ local types, moves = {'NORMAL'}, {{id='QA_MOVE', pp=8}}
+ local stats = {hp=40, attack=20, defense=21, speed=22, special=23, spatk=24, spdef=25}
+ local def = {name='SPECIES', types=types}
+ local data = {pokemon={QA_MON=def}, statuses={PSN={}}, items={QA_BERRY={}},
+ constants={gen=Version.generation(), natures={}, badgeBoosts={
+ {badge='QA_BADGE', stat='speed', num=2, den=1},
+ {badge='QA_MISSING', stat='attack', num=2, den=1},
+ }}}
+ local mon = {species='QA_MON', nickname='NICK', hp=17, status='PSN', toxic=true,
+ stats=stats, moves=moves, item='QA_BERRY'}
+ local save = {inventory={QA_BADGE=1, BOULDERBADGE=1}, party={mon}}
+ local beforeMon, beforeSave, beforeData = copy(mon), copy(save), copy(data)
+ local player = Battler.new(data, mon, true, save)
+ local opponent = Battler.new(data, mon, false, save)
+ local link = Battler.new(data, mon, true, nil)
+ T.eq(player.mon, mon, label .. 'original party member is retained')
+ T.eq(player.def, def, label .. 'species registry row is retained')
+ T.eq(player.name, 'NICK', label .. 'nickname is displayed')
+ T.eq(player.curStats, stats, label .. 'stats are a live native view')
+ T.eq(player.curTypes, types, label .. 'types are a live registry view')
+ T.eq(player.curMoves, moves, label .. 'PP-bearing move slots retain identity')
+ T.eq(player.items, data.items, label .. 'item registry retains identity')
+ T.eq(player.statuses, data.statuses, label .. 'status registry retains identity')
+ T.eq(player.natures, data.constants.natures, label .. 'nature registry retains identity')
+ T.eq(player.badgeBoosts, data.constants.badgeBoosts, label .. 'merged badge rows retain identity')
+ T.same(player.badges, {QA_BADGE=true}, label .. 'only merged possessed badges are captured')
+ T.eq(opponent.badges, nil, label .. 'opponent does not inherit player badges')
+ T.eq(link.badges, nil, label .. 'link construction without save has no badge boosts')
+ T.neq(player.stages, opponent.stages, label .. 'each battler owns transient stages')
+ T.eq(player.shownHP, 17, label .. 'HUD starts at actual HP')
+ T.eq(player.shownStatus, 'PSN', label .. 'HUD starts at actual condition')
+ T.eq(player.toxicCounter, nil, label .. 'older host retains native poison defaults')
+ for _, battler in ipairs({player, opponent, link}) do
+ for _, key in ipairs({'sprite', 'picAnim', 'monAnim'}) do
+ T.eq(battler[key], nil, label .. 'data construction has no ' .. key)
+ end
+ end
+ T.same(mon, beforeMon, label .. 'construction cannot change a Pokemon')
+ T.same(save, beforeSave, label .. 'construction cannot change a save')
+ T.same(data, beforeData, label .. 'construction cannot change registries')
+ mon.hp, mon.status = 3, 'SLP'; mon.sleepTurns = 4; mon.nickname = nil
+ T.eq(player.shownHP, 17, label .. 'native HUD snapshot waits for refresh')
+ T.eq(player.shownStatus, 'PSN', label .. 'native condition snapshot waits for refresh')
+ moves[1].pp = 7; T.eq(player.curMoves[1].pp, 7, label .. 'real PP writes remain visible')
+ local asleep = Battler.new(data, mon, true, save)
+ T.eq(asleep.name, def.name, label .. 'unnamed Pokemon uses species name')
+ T.eq(asleep.sleepTurns, 4, label .. 'outside-battle sleep count survives')
+ T.eq(asleep.toxicCounter, nil, label .. 'sleep does not acquire a toxic counter')
+ T.neq(asleep.badges, player.badges, label .. 'badge snapshots are per battler')
+ data.constants.badgeBoosts = nil
+ local fallback = Battler.new(data, mon, true, save)
+ T.same(fallback.badges, {BOULDERBADGE=true}, label .. 'unmerged badges use actual native Damage defaults')
+ T.eq(_G.love, nil, label .. 'construction never installs a fake rendering runtime')
+end
+
+Version.set('platinum')
+local base = {name='DEOXYS', types={'PSYCHIC'}, baseStats={attack=1}}
+local attack = {types={'FIGHTING'}, baseStats={attack=180}}
+local data = {constants={gen=4}, pokemon={[386]=base, [496]=attack}}
+local mon = {species=386, form=1, hp=1, stats={}, moves={}}
+local form = Battler.new(data, mon, false)
+T.eq(form.species, 386, 'form construction keeps actual species identity')
+T.eq(form.name, 'DEOXYS', 'form construction keeps base display name')
+T.eq(form.curTypes, attack.types, 'form construction uses native personal-record types')
+T.eq(form.def.baseStats, attack.baseStats, 'form construction uses native personal-record stats')
+T.eq(data.pokemon[386], base, 'form view does not replace registry row')
+T.same(base.types, {'PSYCHIC'}, 'alternate form does not mutate base types')
+Version.set(initial)
+T.finish('native battler data without LÖVE or presentation resources')
diff --git a/tests/engine/battler_native_construction.lua b/tests/engine/battler_native_construction.lua
new file mode 100644
index 000000000..cdc21a7b0
--- /dev/null
+++ b/tests/engine/battler_native_construction.lua
@@ -0,0 +1,82 @@
+-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+-- Exercise the actual native constructor and switches, including real sprite
+-- selection and animation factories. No replacement art or cloned battle loop.
+package.path = './?.lua;./?/init.lua;' .. package.path
+local T = require('tests.modkit')
+local Version = require('src.core.GameVersion')
+local Battler = require('src.battle.Battler')
+local Screen = require('src.battle.BattleState')
+local Pokemon = require('src.pokemon.Pokemon')
+local Save = require('src.core.SaveData')
+local Sprites = require('src.pokemon.Sprites')
+local PicAnim = require('src.pokemon.PicAnim')
+local MonAnim = require('src.pokemon.MonAnim')
+local initial = Version.get()
+local make, path, pic, anim = Battler.new, Sprites.path, PicAnim.forMon, MonAnim.new
+local builds, paths, pics, anims
+Battler.new = function(...)
+ local b = make(...)
+ T.eq(b.sprite, nil, 'shared native factory does not hydrate a sprite')
+ T.eq(b.picAnim, nil, 'shared native factory does not hydrate a pic animation')
+ T.eq(b.monAnim, nil, 'shared native factory does not hydrate a procedural animation')
+ local fields = {}; for key, value in pairs(b) do fields[key] = value end
+ builds[#builds+1] = {battler=b, fields=fields}
+ return b
+end
+Sprites.path = function(data, species, side, opts)
+ paths[#paths+1] = {species=species, side=side, mon=opts.mon, kind=opts.kind}
+ return path(data, species, side, opts)
+end
+PicAnim.forMon = function(data, mon, side)
+ local value = pic(data, mon, side)
+ pics[#pics+1] = {mon=mon, side=side, value=value}
+ return value
+end
+MonAnim.new = function(data, species)
+ local value = anim(data, species)
+ anims[#anims+1] = {species=species, value=value}
+ return value
+end
+
+for _, edition in ipairs(Version.ORDER) do
+ Version.set(edition)
+ builds, paths, pics, anims = {}, {}, {}, {}
+ local data = T.fixtures.fresh(); data.constants.gen = Version.generation()
+ require('src.render.Font').load(data)
+ local save = Save.newGame()
+ save.party = {Pokemon.new(data, 'FIXMON_A', 25), Pokemon.new(data, 'FIXMON_B', 20)}
+ local game = {data=data, save=save, stack={top=function() end}}
+ local battle = Screen.newWild(game, 'FIXMON_C', 15)
+ T.eq(#builds, 2, edition .. ' real wild constructor uses shared data factory for both sides')
+ T.eq(builds[1].battler, battle.player, 'player uses the actual constructed table')
+ T.eq(builds[2].battler, battle.enemy, 'enemy uses the actual constructed table')
+ T.eq(battle.player.mon, save.party[1], 'native player retains save identity')
+ T.eq(#paths, 2, 'one actual battle picture selection per side')
+ T.eq(paths[1].side, 'back', 'player keeps native back picture')
+ T.eq(paths[2].side, 'front', 'enemy keeps native front picture')
+ T.eq(paths[1].mon, battle.player.mon, 'player picture sees actual form/shiny-bearing Pokemon')
+ T.eq(paths[2].mon, battle.enemy.mon, 'enemy picture sees actual form/shiny-bearing Pokemon')
+ T.eq(paths[1].kind, 'battle', 'actual sprite selector retains battle context')
+ T.eq(#pics, 1, 'ordinary player back picture does not acquire a front animation')
+ T.eq(pics[1].mon, battle.enemy.mon, 'native picture animation owns actual opponent')
+ T.eq(pics[1].value, battle.enemy.picAnim, 'native picture animation result is retained')
+ T.eq(#anims, 1, 'only the opponent gets native procedural animation construction')
+ T.eq(anims[1].value, battle.enemy.monAnim, 'native procedural animation result is retained')
+ for _, row in ipairs(builds) do
+ for key, value in pairs(row.fields) do
+ T.eq(row.battler[key], value, edition .. ' hydration preserves native field ' .. key)
+ end
+ end
+ local incoming = save.party[2]
+ battle:switchPlayerInto(Screen.POS.PLAYER_LEFT, incoming)
+ T.eq(#builds, 3, 'actual switch constructs a fresh transient battler')
+ T.eq(battle.player, builds[3].battler, 'actual switch uses shared factory result')
+ T.eq(battle.player.mon, incoming, 'actual switch retains live bench Pokemon')
+ T.eq(paths[3].mon, incoming, 'switched sprite selector receives live bench Pokemon')
+ T.eq(paths[3].side, 'back', 'switch retains native player picture direction')
+ T.eq(#pics, 1, 'ordinary back-picture switch does not allocate front animation')
+ T.eq(#anims, 1, 'player switch does not allocate opponent procedural animation')
+end
+Battler.new, Sprites.path, PicAnim.forMon, MonAnim.new = make, path, pic, anim
+Version.set(initial)
+T.finish('shared native battler construction and actual art hydration across editions')
diff --git a/tests/engine/dataset_views.lua b/tests/engine/dataset_views.lua
new file mode 100644
index 000000000..683746803
--- /dev/null
+++ b/tests/engine/dataset_views.lua
@@ -0,0 +1,194 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local Contract = require("tests.api3_contract.datasets")
+local Views = require("src.mods.DatasetViews")
+local Writer = require("src.import.LuaWriter")
+local function fixture(v)
+ return Contract.fixture("tests/api3_contract", "gen2", v or "gold")
+end
+for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v)
+ T.check(ok, v .. " public dataset contract: " .. tostring(why))
+end
+local function setMon(f, edit)
+ local data = require("tests.api3_contract.run").dataFor("gen2", Version.generation())
+ edit(data.pokemon.MEW, data.pokemon)
+ f.files[f.prefix .. "data/generated/pokemon.lua"] = Writer.encode(data.pokemon)
+end
+for _, payload in ipairs({
+ "return (function() _G.datasetExecuted=true; return {} end)()",
+ "while true do end",
+ "return {a=0/0}",
+ "return {a=1e999}",
+ "return {a=function()end}",
+ "return {a=setmetatable({},{})}",
+ "return {a={ [nil]=4}}",
+ "return {a=1,a=2}",
+ 'return {MEW={name="broken",baseStats=false}}',
+}) do
+ local f = fixture()
+ local view = assert(f.exports.open("gold"))
+ f.files[f.prefix .. "data/generated/pokemon.lua"] = payload
+ _G.datasetExecuted = nil
+ T.eq(view.content.pokemon:get("MEW"), nil, "bad generated data refused")
+ T.eq(_G.datasetExecuted, nil, "generated bytes never execute")
+ T.eq(view.content.pokemon:has("MEW"), false, "bad view remains unavailable")
+ T.eq(view.content.pokemon:each()(), nil, "bad view has empty iterator")
+ setMon(f, function() end)
+ T.eq(
+ assert(f.exports.open("gold")).content.pokemon:get("MEW").name,
+ "MEW",
+ "new view recovers repaired cache"
+ )
+ T.eq(view.content.pokemon:get("MEW"), nil, "rejected view remains retired")
+end
+for _, failure in ipairs({ "missing", "stale", "missing_module", "huge_module", "aggregate" }) do
+ local f = fixture()
+ if failure == "missing" then
+ f.files[f.prefix .. "rom-cache.complete"] = nil
+ elseif failure == "stale" then
+ f.files[f.prefix .. "rom-cache.complete"] = "wrong-format-hash"
+ elseif failure == "missing_module" then
+ f.files[f.prefix .. "data/generated/moves.lua"] = nil
+ else
+ local original = f.fs.getInfo
+ f.fs.getInfo = function(path, ...)
+ local info = original(path, ...)
+ if info and path:match("data/generated/.+%.lua$") then
+ info.size = failure == "huge_module" and 8 * 1024 * 1024 + 1 or 8 * 1024 * 1024
+ end
+ return info
+ end
+ end
+ local view, why = f.exports.open("gold")
+ T.eq(view, nil, "unavailable cache: " .. failure)
+ T.eq(
+ why,
+ (failure == "huge_module" or failure == "aggregate") and "invalid_cache" or "not_imported",
+ "stable reason"
+ )
+end
+do
+ local f = fixture()
+ local rawRead = f.fs.read
+ local reads = {}
+ f.fs.read = function(path, ...)
+ reads[path] = (reads[path] or 0) + 1
+ return rawRead(path, ...)
+ end
+ f.fs.load = function()
+ error("dataset must not execute fs.load")
+ end
+ local view = assert(Views.new(f.fs):open("gold"))
+ local path = f.prefix .. "data/generated/pokemon.lua"
+ T.eq(reads[path], nil, "open leaves semantic data lazy")
+ T.eq(view.content.pokemon:get("MEW").name, "MEW", "first bounded native read")
+ local original = reads[path]
+ T.eq(view.content.pokemon:get("MEW").name, "MEW", "cached data remains available")
+ T.check(reads[path] > original, "cached bytes are checked for changes")
+ setMon(f, function(mon, mons)
+ local copy = require("src.mods.Merge").deepCopy(mon)
+ copy.id = "TESTMON"
+ copy.name = "TESTMON"
+ copy.dex = 152
+ mons.TESTMON = copy
+ end)
+ local iter = view.content.pokemon:each()
+ T.eq(iter(), "MEW", "canonical iteration is sorted")
+ setMon(f, function(mon, mons)
+ mon.name = "NEW_NAME"
+ local copy = require("src.mods.Merge").deepCopy(mon)
+ copy.id = "TESTMON"
+ copy.name = "NEW_TESTMON"
+ copy.dex = 152
+ mons.TESTMON = copy
+ end)
+ local id, row = iter()
+ T.eq(id, "TESTMON", "iterator retains its validated snapshot")
+ T.eq(row.name, "TESTMON", "snapshot never mixes edited bytes")
+ local stale = view.content.pokemon:each()
+ T.eq(view.content.pokemon:get("MEW").name, "NEW_NAME", "later read refreshes edited cache")
+ -- Create an iterator, then force another revision through a public read.
+ stale = view.content.pokemon:each()
+ setMon(f, function(mon)
+ mon.name = "LATEST"
+ end)
+ T.eq(view.content.pokemon:get("MEW").name, "LATEST", "second revision refreshed")
+ T.eq(stale(), nil, "iterator retires after observed revision change")
+ f.files[f.prefix .. "rom-cache.complete"] = nil
+ T.eq(view.content.pokemon:get("MEW"), nil, "removed marker retires existing view")
+end
+do
+ local f = fixture("red")
+ local view = assert(f.exports.open("red"))
+ T.eq(
+ view.assets:path("assets/generated/probe.png"),
+ "assets/generated/probe.png",
+ "host Red keeps native root path"
+ )
+ for _, path in ipairs({
+ "../secrets",
+ "assets/generated/../file",
+ "assets/generated/a\\b",
+ "assets/generated/a:b",
+ "assets/generated/a\0b",
+ }) do
+ T.check(not pcall(view.assets.path, view.assets, path), "unsafe asset path refused")
+ end
+ T.eq(view.content.pokemon:get("MEW").name, "MEW", "invalid input does not poison valid cache")
+ T.eq(view.content.commands:get("show_text"), nil, "function-bearing engine records not exposed")
+ T.eq(view.content.pokemon:get(151), nil, "only public canonical IDs accepted")
+ local result, why = f.exports.open("../gold")
+ T.eq(result, nil, "unknown version refused")
+ T.eq(why, "unknown_version", "unknown version reason")
+ result, why = f.exports.open("platinum")
+ T.eq(why, "unsupported_generation", "later native datasets honestly unavailable")
+end
+do
+ local f = fixture()
+ f.files[f.prefix .. "rom-cache.complete"] = nil
+ f.fs.getSource = function()
+ return "developer-source"
+ end
+ f.fs.getRealDirectory = function()
+ return "developer-source"
+ end
+ T.eq(
+ assert(f.exports.open("gold")).content.pokemon:get("MEW").name,
+ "MEW",
+ "complete verified developer source can open without marker"
+ )
+ f.fs.getRealDirectory = function(path)
+ return path:match("pokemon.lua$") and "another-cache" or "developer-source"
+ end
+ T.eq(f.exports.open("gold"), nil, "mixed source/cache provenance refused without marker")
+end
+do
+ local f = fixture()
+ local oldRequire = require
+ local oldWarn = require("src.core.Logger").warn
+ local warnings = {}
+ require("src.core.Logger").warn = function(message, ...)
+ warnings[#warnings + 1] = tostring(message)
+ end
+ f.loader.dev = true
+ f.loader:_installDevShim()
+ local result = f.exports.inspect("gold")
+ T.check(result ~= nil, "lazy public dataset works under permission tripwire")
+ T.eq(#warnings, 0, "native facade work does not become a mod permission warning")
+ _G.require = oldRequire
+ require("src.core.Logger").warn = oldWarn
+end
+do
+ local f = fixture()
+ f.files[f.prefix .. "data/generated/constants.lua"] = Writer.encode({ generation = 1 })
+ local view = assert(f.exports.open("gold"))
+ T.eq(view.content.pokemon:get("MEW"), nil, "contradictory native generation metadata rejected")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5]
+love = saved[6]
+T.finish()
diff --git a/tests/engine/game_viewport.lua b/tests/engine/game_viewport.lua
new file mode 100644
index 000000000..af029ece4
--- /dev/null
+++ b/tests/engine/game_viewport.lua
@@ -0,0 +1,258 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.viewport")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local V = require("src.render.GameViewport")
+local Game = require("src.core.Game")
+local Touch = require("src.core.TouchControls")
+local Safe = require("src.core.SafeArea")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v)
+ T.check(ok, v .. " shared viewport/window contract: " .. tostring(why))
+end
+local G = love.graphics
+local dimensions, pixels = G.getDimensions, G.getPixelDimensions
+G.getDimensions = function()
+ return 640, 480
+end
+G.getPixelDimensions = function()
+ return 1280, 960
+end
+local function fixture()
+ V.finish({})
+ V.reset()
+ local f = Contract.fixture("tests/api3_contract", "gen2", "red")
+ return f
+end
+local function begin(f, value)
+ f.exports.request(value)
+ local r = V.begin(1)
+ f.exports.canvas(V.target())
+ return r
+end
+for _, value in ipairs({
+ { x = -20, y = -40, width = 10000, height = 10000 },
+ { x = 1 / 0, y = 0 / 0, width = -3, height = 0 },
+ { x = 639.9, y = 479.9, width = 320, height = 288 },
+ setmetatable({}, {
+ __index = function()
+ error("metatable must not execute")
+ end,
+ }),
+}) do
+ local f = fixture()
+ local r = begin(f, value)
+ T.check(r.x >= 0 and r.y >= 0 and r.width >= 1 and r.height >= 1, "clamped viewport bounds")
+ T.check(r.x + r.width <= 640 and r.y + r.height <= 480, "viewport stays inside window")
+ V.finish(f.game)
+end
+do
+ local f = fixture()
+ local creates, releases = 0, 0
+ local oldNew = G.newCanvas
+ G.newCanvas = function(...)
+ creates = creates + 1
+ local c = oldNew(...)
+ c.release = function()
+ releases = releases + 1
+ end
+ return c
+ end
+ begin(f, { x = 10, y = 20, width = 320, height = 288 })
+ V.finish(f.game)
+ begin(f, { x = 11, y = 21, width = 320, height = 288 })
+ V.finish(f.game)
+ T.eq(creates, 1, "moving rectangle reuses same-size canvas")
+ begin(f, { width = 321, height = 288 })
+ V.finish(f.game)
+ T.eq(creates, 2, "resize allocates matching canvas")
+ T.eq(releases, 1, "resize releases old target")
+ begin(f, nil)
+ V.finish(f.game)
+ T.eq(releases, 2, "disabling releases viewport target")
+ G.newCanvas = oldNew
+end
+do
+ local f = fixture()
+ local oldNew = G.newCanvas
+ local attempts = 0
+ G.newCanvas = function()
+ attempts = attempts + 1
+ error("injected allocation failure")
+ end
+ local r = begin(f, { x = 10, y = 20, width = 100, height = 80 })
+ T.eq(V.active(), false, "allocation failure keeps ordinary rendering")
+ T.eq(r.width, 640, "failed viewport falls back to full width")
+ V.finish(f.game)
+ begin(f, { x = 10, y = 20, width = 100, height = 80 })
+ V.finish(f.game)
+ T.eq(attempts, 1, "failed size does not retry every frame")
+ G.newCanvas = oldNew
+ begin(f, { width = 101, height = 80 })
+ V.finish(f.game)
+ T.eq(V.active(), true, "changed request retries successfully")
+ begin(f, { width = 100, height = 80 })
+ V.finish(f.game)
+ T.eq(V.active(), true, "former failed size can recover after request change")
+end
+do
+ local f = fixture()
+ local n = #f.exports.trace().viewport
+ V.begin(4, false)
+ V.finish(f.game)
+ T.eq(V.active(), false, "unsupported/native combined layout stays direct")
+ T.eq(#f.exports.trace().viewport, n, "unsupported layout does not dispatch viewport request")
+ begin(f, { x = 100, y = 20, width = 320, height = 288 })
+ local x, y, w, h = Safe.rect()
+ T.eq(x, 0, "safe area mapped to local x")
+ T.eq(y, 0, "safe area mapped to local y")
+ T.eq(w, 320, "safe area clipped to game width")
+ T.eq(h, 288, "safe area clipped to game height")
+ local ox, oy, ow, oh = Safe.windowRect()
+ T.eq(ow, 640, "OS chrome keeps full window width")
+ T.eq(oh, 480, "OS chrome keeps full window height")
+ V.finish(f.game)
+end
+for _, phase in ipairs({ "viewport", "window", "window_context" }) do
+ local f = fixture()
+ local shader, foreign = {}, {}
+ G.setShader(shader)
+ G.setColor(0.2, 0.3, 0.4, 0.5)
+ local mod = f.loader.apis.viewport
+ if phase == "viewport" then
+ mod.hooks:wrap("render.viewport", function(next, ctx)
+ G.setShader({})
+ G.setCanvas(foreign)
+ G.push("all")
+ ctx.width = 0
+ error("injected viewport hook error")
+ end, 100)
+ elseif phase == "window" then
+ f.exports.window(function()
+ G.setShader({})
+ G.setCanvas(foreign)
+ G.push("all")
+ error("injected window hook error")
+ end)
+ else
+ f.exports.window(function(next, g)
+ return next(g, { canvas = false, x = 0, y = 0 })
+ end)
+ end
+ begin(f, { x = 100, y = 20, width = 320, height = 288 })
+ V.finish(f.game)
+ T.eq(G.getCanvas(), nil, "window target restored after " .. phase .. " failure")
+ T.eq(G.getShader(), shader, "shader restored after " .. phase .. " failure")
+ T.eq(select(1, G.getColor()), 0.2, "color restored after " .. phase .. " failure")
+end
+do
+ local f = fixture()
+ local resetInside = false
+ begin(f, { capture = true })
+ local canvas = V.target()
+ canvas.release = function()
+ resetInside = true
+ end
+ f.exports.window(function(next, game, ctx)
+ V.reset()
+ T.eq(resetInside, false, "reset inside render callback defers canvas release")
+ return next(game, ctx)
+ end)
+ V.finish(f.game)
+ T.eq(resetInside, true, "retired target released after composition")
+ T.eq(V.active(), false, "deferred reset clears viewport state")
+end
+-- Actual native touch dispatch preserves pad and screen capture ownership.
+do
+ local f = fixture()
+ begin(f, { x = 100, y = 20, width = 320, height = 288 })
+ V.finish(f.game)
+ local old = {
+ Touch.visible,
+ Touch.hitTest,
+ Touch.touchpressed,
+ Touch.touchmoved,
+ Touch.touchreleased,
+ Touch.draw,
+ Touch.touches,
+ }
+ local pad, screen = {}, {}
+ Touch.touches = {}
+ Touch.visible = function()
+ return true
+ end
+ Touch.hitTest = function(_, x, y)
+ return x == 10 and y == 10
+ end
+ Touch.touchpressed = function(_, id, x, y)
+ pad[#pad + 1] = { "pressed", id, x, y }
+ if id == "pad" then
+ Touch.touches[id] = true
+ end
+ end
+ Touch.touchmoved = function(_, id, x, y)
+ pad[#pad + 1] = { "moved", id, x, y }
+ end
+ Touch.touchreleased = function(_, id, x, y)
+ pad[#pad + 1] = { "released", id, x, y }
+ Touch.touches[id] = nil
+ end
+ local top = {}
+ for _, name in ipairs({ "touchpressed", "touchmoved", "touchreleased" }) do
+ top[name] = function(_, id, x, y)
+ screen[#screen + 1] = { name, id, x, y }
+ return true
+ end
+ end
+ local game = setmetatable(
+ { stack = {
+ top = function()
+ return top
+ end,
+ }, save = { options = {} } },
+ { __index = Game }
+ )
+ local mod = f.loader.apis.viewport
+ mod.hooks:wrap("render.window", function(next, g, ctx)
+ return next(g, ctx)
+ end, -100)
+ mod.hooks:wrap("input.pointer", function(next, g, ctx)
+ if ctx.id == "mod" or ctx.id == "native" and ctx.phase ~= "pressed" then
+ return true
+ end
+ return next(g, ctx)
+ end, 100)
+ game:touchpressed("pad", 10, 10)
+ game:touchmoved("pad", 20, 20)
+ game:touchreleased("pad", 20, 20)
+ T.eq(#f.exports.trace().pointer, 0, "virtual pad gesture never reaches mod pointer hook")
+ T.eq(#pad, 3, "virtual pad keeps entire gesture")
+ game:touchpressed("native", 132, 52, nil, nil, 0.5)
+ game:touchmoved("native", 149, 63)
+ game:touchreleased("native", 160, 64)
+ T.eq(#screen, 3, "native screen retains gesture after mod later claims movement")
+ T.eq(screen[1][3], 32, "native screen receives game-local x")
+ T.eq(screen[1][4], 32, "native screen receives game-local y")
+ T.eq(game.pointerOwners.native, nil, "native capture released")
+ game:touchpressed("mod", 132, 52)
+ game:touchmoved("mod", 149, 63)
+ game:touchreleased("mod", 160, 64)
+ T.eq(#screen, 3, "claimed mod gesture does not leak to native screen")
+ T.eq(game.modPointers.mod, nil, "mod pointer released")
+ game:touchpressed("outside", 99, 19)
+ T.eq(#screen, 3, "outside viewport does not activate native screen")
+ local last = pad[#pad]
+ T.eq(last[3], 99, "pad fallback keeps physical x")
+ T.eq(last[4], 19, "pad fallback keeps physical y")
+ Touch.visible, Touch.hitTest, Touch.touchpressed, Touch.touchmoved, Touch.touchreleased, Touch.draw, Touch.touches =
+ unpack(old)
+end
+V.finish({})
+V.reset()
+G.getDimensions, G.getPixelDimensions = dimensions, pixels
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5]
+love = saved[6]
+T.finish()
diff --git a/tests/engine/gate_meta_coverage.lua b/tests/engine/gate_meta_coverage.lua
index 2c29d47cc..d3bbee83b 100644
--- a/tests/engine/gate_meta_coverage.lua
+++ b/tests/engine/gate_meta_coverage.lua
@@ -111,13 +111,11 @@ local DEBT = {
["hook:encounter.fishing"] = "M5",
["hook:render.zones"] = "M9",
- ["hook:trainer.party"] = "M7",
["hook:ui.pc.items"] = "M8",
["event:link.connected"] = "M12",
["event:link.ended"] = "M12",
["event:player.warped"] = "M5",
- ["event:pokemon.before_give"] = "M7",
["event:pokemon.evolved"] = "M7",
["event:pokemon.level_up"] = "M7",
["event:pokemon.move_learned"] = "M7",
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_appearance_identity.lua b/tests/engine/gen2_appearance_identity.lua
new file mode 100644
index 000000000..760283a7a
--- /dev/null
+++ b/tests/engine/gen2_appearance_identity.lua
@@ -0,0 +1,133 @@
+-- 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 old=V.get()
+local P=require('src.pokemon.Pokemon')
+local Stats=require('src.pokemon.Stats')
+local Save=require('src.core.SaveData')
+local Copy=require('src.mods.Merge').deepCopy
+local F=require('tests.gsc_species_fixture')
+require('src.script.Gen2Commands')
+local function genes(a,d,s,c)return {attack=a,defense=d,speed=s,special=c}end
+local function noRoll()error('fixed genes must not draw random birth DVs')end
+for _,edition in ipairs{'gold','silver','crystal'}do
+ V.set(edition);local data=F.new();data:seedDefaults()
+ -- All ratio classes, both sides of thresholds and all shiny Attack-DV
+ -- classes. Consumers must be able to read the actual saved fields directly.
+ for _,ratio in ipairs{0,31,63,127,191,254,255}do
+ data.pokemon.CYNDAQUIL.genderRatio=ratio
+ for attack=0,15 do for _,speed in ipairs{0,10,15}do
+ local dvs=genes(attack,10,speed,10);local before=Copy(dvs)
+ local mon=P.new(data,'CYNDAQUIL',5,noRoll,nil,{dvs=dvs})
+ local expected=ratio==255 and 'unknown' or ratio==0 and 'male'
+ or ratio==254 and 'female' or (attack*16+speed<=ratio and 'female' or 'male')
+ T.eq(mon.gender,expected,'native public birth gender '..edition..'/'..ratio)
+ T.eq(mon.shiny,Stats.isShiny(dvs),'native public birth shiny')
+ T.same(dvs,before,'constructor never edits caller genes')
+ T.check(mon.dvs~=dvs,'native Pokemon owns its gene table')
+ T.eq(mon.dvs.hp,attack%2*8+speed%2*2,'HP DV derived from final genes')
+ end end
+ end
+ data.pokemon.CYNDAQUIL.genderRatio=127
+ local draws=0
+ local born=P.new(data,'CYNDAQUIL',5,function(lo,hi)draws=draws+1;return lo end)
+ T.eq(draws,4,'ordinary birth still draws exactly four native DVs')
+ T.eq(born.gender,'female');T.eq(born.shiny,false)
+ for _,bad in ipairs{-1,16,0.5,'3'}do
+ local dvs=genes(0,0,0,0);dvs.attack=bad
+ T.raises(function()P.new(data,'CYNDAQUIL',5,noRoll,nil,{dvs=dvs})end,'invalid native Gen2 DV')
+ end
+ local shiny=genes(2,10,10,10)
+ local function make(dvs)return P.new(data,'CYNDAQUIL',5,noRoll,nil,{dvs=dvs or shiny})end
+ -- Loading a pre-field save goes through the actual codec and validator,
+ -- including sparse boxes, day care, pending eggs and held contest parties.
+ local function legacy()
+ local m=make();m.gender=nil;m.shiny=nil;m.hp=3;m.moves[1].pp=1
+ return m
+ end
+ local s={party={legacy()},boxes={{[20]=legacy()}},
+ daycare={mon=legacy(),breed={{mon=legacy()},{mon=legacy()},egg=legacy()}},
+ g2SecondMonBackup={legacy()},g2BugContest={party={legacy()}},
+ player={name='TEST',id=17,map='FIX_TOWN',x=1,y=1},flags={},
+ pokedex={seen={},owned={}},hallOfFame={{{species='CYNDAQUIL',level=5}}}}
+ local restored=assert(Save.decode(Save.encode(s)))
+ Save.validate(restored,data)
+ for _,m in ipairs{restored.party[1],restored.boxes[1][20],restored.daycare.mon,
+ restored.daycare.breed[1].mon,restored.daycare.breed[2].mon,
+ restored.daycare.breed.egg,restored.g2SecondMonBackup[1],restored.g2BugContest.party[1]}do
+ T.eq(m.gender,'female','validated live/save Pokemon has appearance')
+ T.eq(m.shiny,true);T.eq(m.hp,3);T.eq(m.moves[1].pp,1)
+ T.same(m.dvs,make().dvs,'validation preserves final genes')
+ end
+ T.eq(s.party[1].gender,nil,'source checkpoint untouched')
+ T.eq(restored.hallOfFame[1][1].gender,nil,'minimal record gets no invented genes')
+ local explicit=make();explicit.gender='male';explicit.shiny=false
+ P.ensureIdentity(data,explicit)
+ T.eq(explicit.gender,'male');T.eq(explicit.shiny,false,'saved explicit false stays authoritative on load')
+ local snapshot={dvs=Copy(explicit.dvs),stats=Copy(explicit.stats),hp=explicit.hp,exp=explicit.exp,moves=Copy(explicit.moves)}
+ P.syncIdentity(data,explicit)
+ T.eq(explicit.gender,'female');T.eq(explicit.shiny,true,'stat/summary refresh reads final genes')
+ T.same({dvs=explicit.dvs,stats=explicit.stats,hp=explicit.hp,exp=explicit.exp,moves=explicit.moves},snapshot)
+ explicit.dvs=genes(15,0,15,0);P.syncIdentity(data,explicit)
+ T.eq(explicit.gender,'male');T.eq(explicit.shiny,true,'forced true remains sticky at refresh')
+ local incomplete={species='CYNDAQUIL'};P.syncIdentity(data,incomplete);P.ensureIdentity(data,incomplete)
+ T.same(incomplete,{species='CYNDAQUIL'},'no invented genes for incomplete records')
+
+ -- Final trainer genes are installed before the one actual birth callback;
+ -- two trainer slots never share their mutable DVs with each other or data.
+ local slot={species='CYNDAQUIL',level=5,dvs=Copy(shiny)}
+ data.trainers.APPEARANCE={name='TEST',parties={{slot,slot}}}
+ local game={data=data,save=Save.newGame(),stack={top=function()end}}
+ game.save.party={make()}
+ local battle=require('src.battle.BattleState').newTrainer(game,'APPEARANCE',1)
+ for _,m in ipairs(battle.enemyParty)do T.eq(m.shiny,true);T.eq(m.gender,'female')end
+ battle.enemyParty[1].dvs.attack=15
+ T.eq(battle.enemyParty[2].dvs.attack,2);T.eq(slot.dvs.attack,2)
+
+ -- The cartridge's Odd Egg is a fixed-gene gift, not a random birth whose
+ -- cached identity survives replacing DVs with the distribution record.
+ data.field.gen2OddEggs={eggs={{species='CYNDAQUIL',level=5,dvs={0x2a,0xaa}}},probabilities={65535}}
+ local oddSave={party={}}
+ require('src.script.Commands').g2_give_odd_egg({game={data=data},save=oddSave})
+ T.eq(oddSave.party[1].shiny,true);T.eq(oddSave.party[1].gender,'female')
+ T.eq(oddSave.party[1].isEgg,true)
+
+ -- Normal DayCare.step inherits before publishing the egg. The Ditto's
+ -- inherited Defense/Special turn an otherwise non-shiny roll into shiny.
+ data.pokemon.DITTO.genderRatio=255;data.pokemon.DITTO.eggGroups={'DITTO'}
+ local ditto=P.new(data,'DITTO',5,noRoll,nil,{dvs=genes(0,10,0,2)})
+ local parent=make(genes(15,3,15,3));ditto.otId=1;parent.otId=2
+ local breed={{mon=ditto},{mon=parent},stepsToEgg=1}
+ local dcSave={daycare={breed=breed},player={name='TEST',id=17},flags={}}
+ local random,mathRandom=love.math.random,math.random
+ local values={2,0,10,10};local i=0
+ love.math.random=function()i=i+1;return assert(values[i],'extra birth roll')end
+ math.random=function(lo)return lo or 0 end
+ local ok,created=pcall(require('src.pokemon.DayCare').step,data,dcSave)
+ love.math.random,math.random=random,mathRandom
+ T.check(ok,tostring(created));T.eq(created,true,'actual day-care step creates egg')
+ if breed.egg then
+ T.eq(i,4,'inheritance consumes one native DV roll')
+ T.eq(breed.egg.gender,'female');T.eq(breed.egg.shiny,true)
+ T.eq(breed.egg.dvs.defense,10);T.eq(breed.egg.dvs.special,10)
+ T.eq(breed.egg.hp,0,'native egg current HP unchanged')
+ T.eq(ditto.dvs.special,2,'parent genes never mutated')
+ require('src.pokemon.Gen2Breeding').hatch({data=data,save=dcSave},breed.egg)
+ T.eq(breed.egg.gender,'female');T.eq(breed.egg.shiny,true)
+ end
+ -- A real evolution can cross a gender threshold (a modded species too).
+ local evolved=make(genes(0,0,0,0));data.pokemon.QUILAVA.genderRatio=255
+ require('src.pokemon.Evolution').apply(game,evolved,'QUILAVA','level')
+ T.eq(evolved.gender,'unknown');T.eq(evolved.shiny,false)
+ T.eq(evolved.species,'QUILAVA')
+end
+-- No unconditional field cache or extra random birth path for other engines.
+for _,edition in ipairs{'red','firered','emerald','platinum','black'}do
+ V.set(edition);local data=T.fixtures.fresh()
+ local mon=P.new(data,'FIXMON_A',5,function(lo)return lo end)
+ T.eq(mon.gender,nil,edition..' birth behavior unchanged without hooks')
+ local before=Copy(mon);P.ensureIdentity(data,mon);P.syncIdentity(data,mon)
+ T.same(mon,before,edition..' refresh remains unchanged')
+end
+V.set(old)
+T.finish('native GSC appearance birth, fixed genes, saves, trainer, eggs and evolution')
diff --git a/tests/engine/gen2_appearance_transactions.lua b/tests/engine/gen2_appearance_transactions.lua
new file mode 100644
index 000000000..2ee98a3e7
--- /dev/null
+++ b/tests/engine/gen2_appearance_transactions.lua
@@ -0,0 +1,69 @@
+-- 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 old=V.get()
+local P=require('src.pokemon.Pokemon')
+local F=require('tests.gsc_species_fixture')
+local Commands=require('src.script.Commands');require('src.script.Gen2Commands')
+local Stack=require('src.core.StateStack')
+local Runner=require('src.script.ScriptRunner')
+-- Editor handlers are private closures. Exercise the real handlers, not a
+-- duplicate of their mutation rules. This is a CPU transaction test, not a
+-- lower-screen device/GUI acceptance test.
+local function handler(root,name)
+ local visited={}
+ local function find(fn)
+ if visited[fn]then return end;visited[fn]=true
+ for i=1,100 do
+ local key,value=debug.getupvalue(fn,i);if not key then break end
+ if key==name then return value end
+ if type(value)=='function'then local found=find(value);if found then return found end end
+ end
+ end
+ return assert(find(root),'missing actual editor handler '..name)
+end
+local companion=require('src.ui.SaveCompanion')
+local adjust=handler(companion.tick,'adjustIndividual')
+local recalc=handler(companion.tick,'recalc')
+local applySpecies=handler(companion.tick,'applySpecies')
+for _,edition in ipairs{'gold','silver','crystal'}do
+ V.set(edition);local data=F.new();data:seedDefaults()
+ local sent=P.new(data,'CYNDAQUIL',12,function()return 15 end)
+ data.field.gen2Trades={trades={{give='CYNDAQUIL',get='EEVEE',dvs={0x2a,0xaa},
+ nickname='GIFT',ot='NPC',otId=17,item='POTION'}}}
+ local stack=setmetatable({},{__index=Stack});stack:init()
+ local game={data=data,save={party={sent},player={name='TEST',id=7},pokedex={seen={},owned={}}},stack=stack}
+ -- Dialogue IO is outside this test. Actual script, party picker, trade
+ -- materialization and movie still run on the engine's real owners.
+ local ask,show=Commands.ask,Commands.show_text
+ Commands.ask=function(ctx)ctx.lastCheck=true end
+ Commands.show_text=function()end
+ local runner=Runner.new(game)
+ runner:run({{'g2_trade',0}})
+ local picker=assert(stack:top());T.eq(picker.pickOnly,true)
+ game.input={wasPressed=function(_,button)return button=='a'end}
+ picker:update(0)
+ local received=game.save.party[1]
+ T.check(received~=sent,'actual native NPC exchange replaces picked mon')
+ T.eq(received.species,'EEVEE');T.eq(received.level,12)
+ T.eq(received.shiny,true);T.eq(received.gender,'female')
+ T.eq(received.dvs.attack,2);T.eq(received.dvs.defense,10)
+ T.eq(received.nickname,'GIFT');T.eq(received.ot,'NPC');T.eq(received.item,'POTION')
+ local movie=assert(stack:top());T.eq(movie.received,received,'movie sees same final Pokemon')
+ movie:finish()
+ T.eq(runner:isRunning(),false,'trade script resumes through actual movie completion')
+ Commands.ask,Commands.show_text=ask,show
+ -- Passive panel recalculation must not reroll identity each frame. Actual
+ -- DV/species edits refresh it once at their mutation boundary instead.
+ received.gender='male';received.shiny=false
+ recalc(game,received)
+ T.eq(received.gender,'male');T.eq(received.shiny,false,'passive panel preserves cached identity')
+ adjust(game,received,'attack',0)
+ T.eq(received.gender,'female');T.eq(received.shiny,true)
+ local hp=received.hp;data.pokemon.QUILAVA.genderRatio=255
+ applySpecies(game,{mode='change',mon=received},'QUILAVA')
+ T.eq(received.species,'QUILAVA');T.eq(received.gender,'unknown');T.eq(received.shiny,true)
+ T.eq(received.hp,hp,'editor identity refresh does not heal the Pokemon')
+end
+V.set(old)
+T.finish('actual GSC NPC trade and companion appearance mutation boundaries')
diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua
new file mode 100644
index 000000000..b16f8d48c
--- /dev/null
+++ b/tests/engine/gen2_battle_model_contract.lua
@@ -0,0 +1,193 @@
+-- Native calculation and completion paths, decorated through the real SDK.
+package.path = './?.lua;./?/init.lua;' .. package.path
+local T = require('tests.modkit')
+local Version = require('src.core.GameVersion')
+local Screen = require('src.battle.BattleState')
+local Model = require('src.battle.gen2.Battle')
+local Read = require('src.battle.Gen2BattleRead')
+local Pokemon = require('src.pokemon.Pokemon')
+local Save = require('src.core.SaveData')
+local Order = require('src.battle.TurnOrder')
+local Status = require('src.battle.StatusRegistry')
+local Effects = require('src.battle.MoveEffects')
+local savedVersion = Version.get()
+local nativeStat, nativeDef, nativeEnd = Model.battleStat, Model.speciesDef, Model.endBattle
+
+local function fixture(version)
+ Version.set(version)
+ local data = T.fixtures.fresh()
+ data.constants.gen, data.constants.generation = Version.generation(version), Version.generation(version)
+ data.type_chart.matchups[#data.type_chart.matchups+1] = {
+ attacker='NORMAL', defender='GHOST', multiplier=0,
+ }
+ local run = T.sdk.loadMods({'mods/model_probe'}, {
+ data=data, fs=T.sdk.memfs({
+ ['mods/model_probe/manifest.json']=[[{
+ "id":"model_probe","name":"Native model probe","version":"1.0.0",
+ "api":2,"entry":"main.lua","affects_link":true
+ }]],
+ ['mods/model_probe/main.lua']=[[
+ local mod=...
+ local Model=require('src.battle.gen2.Battle')
+ local stat,def,finish=Model.battleStat,Model.speciesDef,Model.endBattle
+ mod.exports.scale=1
+ Model.battleStat=function(model,mon,key)
+ local value=stat(model,mon,key)
+ if mon==model.player and (key=='attack' or key=='speed') then
+ return value*mod.exports.scale
+ end
+ return value
+ end
+ Model.speciesDef=function(model,mon)
+ local value=def(model,mon)
+ if not (value and mon==model.enemy and mod.exports.types) then return value end
+ local result={};for key,item in pairs(value)do result[key]=item end
+ result.types=mod.exports.types
+ return result
+ end
+ Model.endBattle=function(model,...)
+ if model.player then model.player._probeTransient=nil end
+ mod.exports.endCalls=(mod.exports.endCalls or 0)+1
+ return finish(model,...)
+ end
+ mod.events:on('battle.ended',function(event)
+ mod.exports.ended=(mod.exports.ended or 0)+1
+ mod.exports.event=event
+ mod.exports.cleanAtEvent=not(event.model and event.model.player
+ and event.model.player._probeTransient)
+ end)
+ ]],
+ }),
+ })
+ T.eq(#run.errors,0,version..' real SDK model decorator loads')
+ require('src.render.Font').load(data)
+ local save=Save.newGame()
+ save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',18)}
+ local game={data=data,save=save}
+ local stack={states={},pops=0}
+ function stack:top()return self.states[#self.states]end
+ function stack:pop()self.pops=self.pops+1;return table.remove(self.states)end
+ function stack:push(state)self.states[#self.states+1]=state end
+ game.stack=stack
+ local battle=Screen.newWild(game,'FIXMON_C',23)
+ stack.states={{},battle}
+ return battle,game,run,assert(run.loader.exports.model_probe)
+end
+
+for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do
+ local b,game,run,probe=fixture(version)
+ local model=b.battle
+ local supported=type(model.battleStat)=='function' and type(model.speciesDef)=='function'
+ T.check(supported,version..' actual model resolves the genuine model class')
+ T.check(model~=b,version..' screen and model stay distinct')
+ T.eq(model.player,game.save.party[1],'model retains live party identity')
+ T.eq(model.moveDef,Model.moveDef,'method lookup retains the class decoration seam')
+ if supported then
+ T.eq(model:speciesDef(model.player),game.data.pokemon[model.player.species],'actual species catalog row')
+ T.eq(model:speciesDef(nil),nil,'no invented missing species')
+ T.eq(model:battleStat(model.player,'attack'),b.player.curStats.attack,'native raw stat authority')
+ T.eq(model:battleStat({},'attack'),nil,'unknown stat stays absent')
+ T.eq(model:sideOf(model.player),'player','native player side')
+ T.eq(model:sideOf(model.enemy),'enemy','native enemy side')
+ end
+ T.eq(Read.stats(b.player).attack,b.player.curStats.attack,'default decorated read keeps native values')
+ local playerStats,enemyStats=b.player.curStats,b.enemy.curStats
+ local partyStats,types=b.player.mon.stats,b.enemy.def.types
+ local pp=b.player.curMoves[1].pp
+ local move=b:moveDef(b.player.curMoves[1])
+ local function damage()
+ return b:computeDamage(b.player,b.enemy,move,{forceCrit=false,rng=function(lo,hi)return hi or lo end})
+ end
+ local normal=damage()
+ local speed=Order.effectiveSpeed(b.player,b)
+ probe.scale=4
+ local boosted=damage()
+ T.check(boosted>normal,version..' actual native damage consumes decorated model stat')
+ T.check(Order.effectiveSpeed(b.player,b)>speed,'actual native speed consumes same model stat')
+ T.eq(b.player.curStats,playerStats,'native stat table identity is retained')
+ T.eq(b.enemy.curStats,enemyStats,'native opposing stat table is retained')
+ T.eq(b.player.mon.stats,partyStats,'save stats are not replaced')
+ T.eq(b.player.curMoves[1].pp,pp,'queries cannot spend PP')
+ T.eq(Read.stats(b.player).attack,partyStats.attack*4,'live value has no frame cache')
+ probe.scale=1
+ T.eq(damage(),normal,'removing presentation-owned stat override restores native damage')
+ probe.types={'GHOST'}
+ T.eq(damage(),0,'actual damage honors model species type immunity')
+ T.eq(b.enemy.def.types,types,'species registry type list is not replaced')
+ T.eq(b.enemy.curTypes,types,'native volatile type list is not replaced')
+ probe.types={'FIRE'}
+ b.enemy.mon.status=nil
+ Status.inflict(b,b.enemy,'BRN',{})
+ T.eq(b.enemy.mon.status,nil,'actual native status immunity honors model type')
+ probe.types={'NORMAL'}
+ Status.inflict(b,b.enemy,'BRN',{})
+ T.eq(b.enemy.mon.status,'BRN','removing immunity restores native status path')
+ b.enemy.mon.status=nil
+ b.enemy.curTypes={'WATER'}
+ probe.types={'GHOST'}
+ T.eq(Read.types(b.enemy)[1],'WATER','native Conversion list wins over a species decoration')
+ T.check(damage()>0,'volatile type override reaches actual damage')
+ b.enemy.curTypes=types
+ probe.types=nil
+ local old=b.player
+ b:switchPlayerInto(Screen.POS.PLAYER_LEFT,game.save.party[2])
+ T.eq(model.player,game.save.party[2],'model follows actual native switch')
+ probe.scale=2
+ T.eq(Read.stats(b.player).attack,b.player.curStats.attack*2,'new native battler is bound')
+ T.neq(b.player,old,'native wrapper really changed')
+ probe.scale=1
+ -- Native Transform keeps temporary stats on its wrapper, never the save.
+ -- Link/scaffold owners use the public factory without a screen argument,
+ -- then place/synchronize their actual wrappers on the native controller.
+ local placed=Screen.makeBattler(game.data,game.save.party[1],true,nil)
+ b:placeBattler(Screen.POS.PLAYER_LEFT,placed)
+ probe.scale=3
+ T.eq(Read.stats(placed).attack,placed.curStats.attack*3,'native placement binds a public-factory wrapper')
+ local rebound=Screen.makeBattler(game.data,game.save.party[2],true,nil)
+ b.player=rebound
+ b:syncSides()
+ T.eq(Read.stats(rebound).attack,rebound.curStats.attack*3,'native side synchronization binds external wrappers')
+ T.eq(model.player,game.save.party[2],'external native replacement retains live model identity')
+ probe.scale=1
+ local source=b.enemy.curStats.attack
+ Effects.primary.TRANSFORM_EFFECT(b,b.player,b.enemy)
+ if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end
+ T.eq(model.player.stats,game.save.party[2].stats,'Transform does not replace saved stats')
+ model.player._probeTransient=true
+ b.result=({gold='ended',silver='hostWin',crystal='guestWin',prism='draw'})[version]
+ local outcome=b.result
+ local complete=type(b.completeBattle)=='function'
+ T.check(complete,'native Gen2 completion seam exists')
+ if complete then
+ b:completeBattle()
+ T.eq(probe.ended,1,'actual native finish emits one ended event')
+ T.eq(probe.event.result,outcome or 'run','notification retains the native event fallback')
+ T.eq(b.result,outcome,'notification does not persist an event-only fallback')
+ T.eq(probe.event.battle,b,'existing native consumers retain their screen owner')
+ T.eq(probe.event.model,model,'notification also exposes actual live model')
+ T.check(probe.cleanAtEvent,'model end decoration cleans before native end notification')
+ T.eq(game.stack.pops,1,'completion uses native stack return once')
+ b:completeBattle()
+ T.eq(game.stack.pops,1,'repeated completion cannot pop another screen')
+ if type(model.endBattle)=='function'then model:endBattle('run')end
+ T.eq(probe.ended,1,'repeated model completion cannot re-emit ended event')
+ end
+ require('src.mods.Loader').teardown()
+ run.release()
+ T.eq(Model.battleStat,nativeStat,'loader retirement restores model stat method')
+ T.eq(Model.speciesDef,nativeDef,'loader retirement restores model species method')
+ T.eq(Model.endBattle,nativeEnd,'loader retirement restores model end method')
+ T.eq(Read.stats(b.player),b.player.curStats,'undecorated read returns original stat table')
+ T.eq(Read.types(b.enemy),b.enemy.curTypes,'undecorated read returns original type list')
+end
+for _,version in ipairs({'red','firered','emerald'})do
+ local b,game,run,probe=fixture(version)
+ T.eq(b.battle,b,version..' keeps its original shared native identity')
+ probe.scale=9;probe.types={'GHOST'}
+ T.eq(Read.stats(b.player),b.player.curStats,'other generation retains exact raw stat table')
+ T.eq(Read.types(b.enemy),b.enemy.curTypes,'other generation retains exact raw type list')
+ require('src.mods.Loader').teardown()
+ run.release()
+end
+Version.set(savedVersion)
+T.finish('native Gen2 model calculation and completion boundary')
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_frame_queries.lua b/tests/engine/gen2_frame_queries.lua
new file mode 100644
index 000000000..34f33f31c
--- /dev/null
+++ b/tests/engine/gen2_frame_queries.lua
@@ -0,0 +1,124 @@
+-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+-- Public source Game2 geometry must agree with the actual native classic
+-- compositor, retain logical/pixel units and remain a passive query.
+package.path = './?.lua;./?/init.lua;' .. package.path
+local T = require('tests.modkit')
+local Game = require('src.core.Game')
+local Renderer = require('src.render.Renderer')
+local Version = require('src.core.GameVersion')
+local Viewport = require('src.render.GameViewport')
+local initial = Version.get()
+local available = type(Game.frameFit)=='function' and type(Game.viewport)=='function'
+T.check(available, 'native Game2 owner exposes frameFit and viewport queries')
+if not available then T.finish('source Game2 frame queries are absent') end
+local graphics, window = love.graphics, love.window
+local dimensions, pixels, gdpi = graphics.getDimensions, graphics.getPixelDimensions, graphics.getDPIScale
+local wdpi = window.getDPIScale
+local oldUIW, oldUIH = Renderer.uiWidth, Renderer.uiHeight
+local oldFill, oldSplit, oldSkin = Renderer.uiFill, Renderer.splitViewport, Viewport.skinFill
+Renderer.uiWidth, Renderer.uiHeight = 160, 144
+Renderer.uiFill, Renderer.splitViewport, Viewport.skinFill = false, false, false
+Renderer:init()
+local game = setmetatable({}, {__index=Game})
+
+for _, edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'}) do
+ Version.set(edition)
+ local data = T.fixtures.fresh(); data.constants.gen = 2
+ local run = T.sdk.loadMods({'mods/frame_probe'}, {data=data, fs=T.sdk.memfs({
+ ['mods/frame_probe/manifest.json']='{"id":"frame_probe","name":"Frame probe","version":"1.0.0","api":2,"entry":"main.lua"}',
+ ['mods/frame_probe/main.lua']=[[
+ local mod=...
+ local Game2=require('src.core.Game2')
+ mod.exports.fit=function(game,w,h)return Game2.frameFit(game,w,h)end
+ mod.exports.viewport=function(game,w,h)return Game2.viewport(game,w,h)end
+ ]],
+ })})
+ T.eq(#run.errors, 0, edition .. ' actual SDK Game2 geometry consumer loads')
+ local probe = assert(run.loader.exports.frame_probe)
+ for _, size in ipairs({{160,144},{320,240},{640,576},{1024,768},{1280,960},{720,1280}}) do
+ local w, h = size[1], size[2]
+ for _, dpi in ipairs({1,1.25,1.5,2,3}) do
+ graphics.getDimensions = function() return w,h end
+ graphics.getPixelDimensions = function() return w*dpi,h*dpi end
+ graphics.getDPIScale, window.getDPIScale = function() return dpi end, function() return dpi end
+ local before = Renderer:endFrame()
+ local scale,x,y,d,pw,ph = probe.fit(game,w,h)
+ local view = probe.viewport(game,w,h)
+ T.eq(scale, (before.gameWidth/160), 'source scale agrees with actual native logical draw scale')
+ T.eq(x, before.gameX, 'source horizontal origin agrees with native composition')
+ T.eq(y, before.gameY, 'source vertical origin agrees with native composition')
+ T.eq(d, dpi, 'source query reports the actual window DPI')
+ T.eq(pw, w*dpi, 'source query retains drawable pixel width')
+ T.eq(ph, h*dpi, 'source query retains drawable pixel height')
+ T.eq(view.width, w, 'public viewport retains logical window width')
+ T.eq(view.height, h, 'public viewport retains logical window height')
+ T.eq(view.gameWidth, before.gameWidth, 'public playfield width matches native composition')
+ T.eq(view.gameHeight, before.gameHeight, 'public playfield height matches native composition')
+ T.eq(view.gameX, before.gameX, 'public playfield horizontal origin matches native composition')
+ T.eq(view.gameY, before.gameY, 'public playfield vertical origin matches native composition')
+ T.eq(view.scale, scale, 'source viewport uses its LOVE-unit scale')
+ T.eq(view.dpiX, dpi, 'source viewport retains DPI X')
+ T.eq(view.dpiY, dpi, 'source viewport retains DPI Y')
+ T.check(math.abs(x*dpi-math.floor(x*dpi+.5))<1e-8, 'horizontal origin lands on a physical pixel')
+ T.check(math.abs(y*dpi-math.floor(y*dpi+.5))<1e-8, 'vertical origin lands on a physical pixel')
+ -- Unchanged battle scenes pass drawable dimensions instead of window
+ -- dimensions. Those queries must use pixel coordinates exactly once.
+ local ps,px,py,pd,qpw,qph = probe.fit(game,w*dpi,h*dpi)
+ local pview = probe.viewport(game,w*dpi,h*dpi)
+ T.eq(ps, before.scale, 'drawable query uses actual integer framebuffer scale')
+ T.check(math.abs(px-before.gameX*dpi)<1e-8, 'drawable query matches native pixel origin X')
+ T.check(math.abs(py-before.gameY*dpi)<1e-8, 'drawable query matches native pixel origin Y')
+ T.eq(pd, dpi, 'drawable query retains actual DPI metadata')
+ T.eq(qpw, w*dpi, 'drawable query retains actual pixel width')
+ T.eq(qph, h*dpi, 'drawable query retains actual pixel height')
+ T.check(math.abs(pview.gameWidth-before.gameWidth*dpi)<1e-8, 'drawable viewport width matches native pixels')
+ T.check(math.abs(pview.gameHeight-before.gameHeight*dpi)<1e-8, 'drawable viewport height matches native pixels')
+ if edition=='crystal' and w==1024 and h==768 and dpi==2 then
+ -- Independent real Metal observation: native scale10/origin224,48;
+ -- the rejected API candidate instead returned10.5/origin184,12.
+ T.eq(ps, 10, 'actual Retina regression scale')
+ T.eq(px, 224, 'actual Retina regression horizontal origin')
+ T.eq(py, 48, 'actual Retina regression vertical origin')
+ end
+ view.gameX = -1000; view.scale = -1
+ local again = probe.viewport(game,w,h)
+ T.eq(again.gameX, before.gameX, 'returned view cannot mutate the next query')
+ T.eq(again.scale, (before.gameWidth/160), 'returned scale cannot mutate the next query')
+ T.same(Renderer:endFrame(), before, 'public queries do not mutate native frame fitting')
+ T.eq(Version.get(), edition, 'public query does not change active cartridge')
+ end
+ end
+ for _,size in ipairs({{801,601,1.25},{1001,751,2},{1137,803,1.5}}) do
+ local w,h,dpi = size[1],size[2],size[3]
+ local pw,ph = math.floor(w*dpi),math.floor(h*dpi)
+ graphics.getDimensions = function() return w,h end
+ graphics.getPixelDimensions = function() return pw,ph end
+ graphics.getDPIScale, window.getDPIScale = function() return dpi end, function() return dpi end
+ local native = Renderer:endFrame()
+ local scale,x,y = probe.fit(game,pw,ph)
+ T.eq(scale, native.scale, 'odd drawable size retains actual native pixel scale')
+ T.check(math.abs(x-native.gameX*native.dpiX)<1e-8, 'odd drawable size retains native pixel origin X')
+ T.check(math.abs(y-native.gameY*native.dpiY)<1e-8, 'odd drawable size retains native pixel origin Y')
+ T.eq(x%1, 0, 'odd drawable horizontal origin is a complete pixel')
+ T.eq(y%1, 0, 'odd drawable vertical origin is a complete pixel')
+ end
+ graphics.getDimensions = function() return 1024,768 end
+ graphics.getPixelDimensions = function() return 2048,1536 end
+ graphics.getDPIScale, window.getDPIScale = function() return 2 end, function() return 2 end
+ local nativeFit = Renderer.fitScale
+ Renderer.fitScale = function() return 3 end
+ local native = Renderer:endFrame()
+ local scale,x,y = probe.fit(game,2048,1536)
+ T.eq(scale, native.scale, 'drawable query observes actual decorated native fit')
+ T.eq(x, native.gameX*2, 'decorated native horizontal origin remains authoritative')
+ T.eq(y, native.gameY*2, 'decorated native vertical origin remains authoritative')
+ Renderer.fitScale = nativeFit
+ T.eq(#run.errors, 0, 'actual SDK query did not swallow an engine error')
+ run.release()
+end
+graphics.getDimensions, graphics.getPixelDimensions, graphics.getDPIScale = dimensions, pixels, gdpi
+window.getDPIScale = wdpi
+Renderer.uiWidth, Renderer.uiHeight = oldUIW, oldUIH
+Renderer.uiFill, Renderer.splitViewport, Viewport.skinFill = oldFill, oldSplit, oldSkin
+Version.set(initial)
+T.finish('source Game2 frame queries through the SDK and actual native compositor')
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_menu_chrome.lua b/tests/engine/gen2_menu_chrome.lua
new file mode 100644
index 000000000..f1fd4be61
--- /dev/null
+++ b/tests/engine/gen2_menu_chrome.lua
@@ -0,0 +1,79 @@
+package.path = './?.lua;./?/init.lua;' .. package.path
+local T = require('tests.modkit')
+local P = require('src.render.PaletteFX')
+local Font = require('src.render.Font')
+local V = require('src.core.GameVersion')
+local C = require('src.ui.Gen2Chrome')
+local before = {mode=P.mode, shade=P.shadeMap(), version=V.get()}
+require('src.core.ModCompat').install()
+T.eq(require('src.ui.gen2.Chrome'),C,'public Gen2 name resolves actual native menu renderer')
+Font.load(T.fixtures.fresh())
+local ramp={{250,240,230},{180,170,160},{100,90,80},{20,10,0}}
+local map={[0]=1,[1]=3,[2]=0,[3]=2}
+P.setShadeMap(map)
+for _,version in ipairs({'gold','silver','crystal','red','firered','emerald'})do
+ V.set(version)
+ for _,mode in ipairs({'gbc','gbc_inv','og','og_inv','classic'})do
+ P.setMode(mode)
+ T.same(C.throughPalette(ramp),P.effectiveColors(ramp),'menu uses actual native mode/register '..version..'/'..mode)
+ -- Inversion belongs BEFORE the noncommuting shade map. Compare against
+ -- the native resolved display ramp with the shade register disarmed.
+ P.setShadeMap(nil);local settled=P.effectiveColors(ramp);P.setShadeMap(map)
+ local reversed={settled[4],settled[3],settled[2],settled[1]}
+ T.same(C.throughPalette(ramp,true),P.permute(reversed,map),'inverted text then hardware shade register '..version..'/'..mode)
+ T.same(C.rawPalette(ramp,true),{ramp[4],ramp[3],ramp[2],ramp[1]},'raw palette skips native display substitutions')
+ end
+end
+P.setShadeMap(nil);P.setMode('gbc');V.set('crystal')
+local dpi=love.graphics.getDPIScale
+for _,d in ipairs({1,1.5,2})do
+ love.graphics.getDPIScale=function()return d end -- output metrics only
+ local scale=C.fitScale(801,601)
+ T.eq(scale*d,math.floor(scale*d),'native panel pixels land on framebuffer grid')
+ local x,y=C.fitOrigin(801,601,scale)
+ T.check(x>=0 and y>=0 and x+160*scale<=801 and y+144*scale<=601,'native panel fits its actual viewport')
+end
+love.graphics.getDPIScale=dpi
+T.eq(C.number(23,4,false),' 23','native padded number')
+T.eq(C.number(23,4,true),'0023','native zero-filled number')
+T.eq(C.money(23),' ¥23','native floating yen field')
+T.same(C.wrap('ONE TWO\nTHREE',7),{'ONE TWO','THREE'},'cart hard line break retained')
+local chosen,cancel,moved
+local list=C.List.new({items={{label='ONE',value=1},{label='TWO',value=2,disabled=true},{label='THREE',value=3}},rows=2,onChoose=function(v)chosen=v end,onCancel=function()cancel=true end,onMove=function(v)moved=v end})
+local input=require('src.core.Input');input:init()
+local function tap(key)input:keypressed(key);input:step();local out=list:update(input);input:keyreleased(key);input:step();return out end
+T.check(tap('down'),'real native down edge consumed')
+T.eq(list.index,2,'list moves once per native edge');T.eq(moved,2,'movement callback follows actual item')
+tap('z');T.eq(chosen,nil,'disabled entry cannot be selected')
+tap('down');T.eq(list.scroll,1,'native list scrolls into actual item')
+tap('z');T.eq(chosen,3,'real native A selects current item')
+tap('down');T.eq(list.index,1,'native list wraps at end')
+tap('x');T.eq(cancel,true,'real native B cancels list')
+
+-- Real native box and font calls, with only the output APIs recorded.
+local draw,rect=love.graphics.draw,love.graphics.rectangle
+local drawn,filled=0,0
+love.graphics.draw=function(...)drawn=drawn+1;return draw(...)end
+love.graphics.rectangle=function(...)filled=filled+1;return rect(...)end
+C.clear();C.textbox(0,12,18,4);C.print('ONE',1,13);C.cursor(1,1);list:draw()
+T.check(drawn>0 and filled>0,'native chrome actually renders font, box and paper')
+local shader={tag='caller'};love.graphics.setShader(shader)
+T.raises(function()C.withPanel(800,600,1,1,1,function()love.graphics.setShader({});error('panel body probe')end)end,'panel body probe','panel propagates actual draw failure')
+T.eq(love.graphics.getShader(),shader,'panel restores native graphics state on failure')
+love.graphics.setShader();love.graphics.draw,love.graphics.rectangle=draw,rect
+-- Exercise the shaded path too; only the GPU uniform sink is replaced.
+local nativeShader=P.shader
+local uniforms={}
+local output={send=function(_,key,value)uniforms[key]=value end}
+P.shader=function()return output end
+P.setShadeMap(map)
+local caller={tag='native caller'};love.graphics.setShader(caller)
+local expected=P.effectiveColors(ramp,true)
+C.printThrough('ONE',1,1,ramp,true)
+T.same(uniforms.c0,{expected[1][1]/255,expected[1][2]/255,expected[1][3]/255},'glyph shader receives once-resolved native paper')
+T.same(uniforms.c3,{expected[4][1]/255,expected[4][2]/255,expected[4][3]/255},'glyph shader receives once-resolved native ink')
+T.eq(love.graphics.getShader(),caller,'palette text restores actual caller shader')
+T.eq(P.shadeMap(),map,'chrome does not replace native shade register')
+P.shader=nativeShader;love.graphics.setShader()
+P.setMode(before.mode);P.setShadeMap(before.shade);V.set(before.version)
+T.finish('native Gen2 shared menu chrome')
diff --git a/tests/engine/gen2_model_rng.lua b/tests/engine/gen2_model_rng.lua
new file mode 100644
index 000000000..be92999ce
--- /dev/null
+++ b/tests/engine/gen2_model_rng.lua
@@ -0,0 +1,133 @@
+-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+-- Actual source consumers must share the native stream, including after its
+-- function is replaced. No shadow generator, draws on lookup or saved closures.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local V=require('src.core.GameVersion')
+local Screen=require('src.battle.BattleState')
+local Model=require('src.battle.gen2.Battle')
+local Pokemon=require('src.pokemon.Pokemon')
+local Save=require('src.core.SaveData')
+local before=V.get()
+for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do
+ V.set(edition)
+ local data=T.fixtures.fresh();data.constants.gen=2
+ local run=T.sdk.loadMods({'mods/rng_probe'},{data=data,fs=T.sdk.memfs({
+ ['mods/rng_probe/manifest.json']='{"id":"rng_probe","name":"Native RNG probe","version":"1.0.0","api":2,"entry":"main.lua"}',
+ ['mods/rng_probe/main.lua']=[[
+ local mod=...
+ local Battle=require('src.battle.gen2.Battle')
+ mod.exports.love=function(model,lo,hi)return model.rng(lo,hi)end
+ mod.exports.cart=function(model,n)return model.random(n)end
+ mod.exports.method=function(model,n)return Battle.roller(model)(n)end
+ mod.exports.decorate=function()
+ local native=Battle.roller
+ Battle.roller=function(model)
+ mod.exports.lookups=(mod.exports.lookups or 0)+1
+ return native(model)
+ end
+ end
+ ]],
+ })})
+ T.eq(#run.errors,0,'actual API2 source RNG consumer loads')
+ require('src.render.Font').load(data)
+ local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',25)}
+ local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30)
+ local probe=run.loader.exports.rng_probe
+ local trace={}
+ local function stream(tag)
+ return function(lo,hi)
+ local first,last=lo,hi
+ if hi==nil then lo,hi=1,lo end
+ trace[#trace+1]={tag=tag,lo=first,hi=last}
+ return lo+(#trace*37)%(hi-lo+1)
+ end
+ end
+ local native=stream('initial');b.rng=native
+ local model=b.battle
+ T.eq(type(model.rng),'function',edition..' source LOVE RNG exists')
+ T.eq(type(model.random),'function','source zero-based random exists')
+ T.eq(type(model.roller),'function','source roller method exists')
+ if type(model.rng)=='function'and type(model.random)=='function'and type(model.roller)=='function'then
+ local loveRng,random,roller=model.rng,model.random,model:roller()
+ T.eq(model.rng,loveRng,'LOVE adapter identity is stable')
+ T.eq(model.random,random,'zero-based adapter identity is stable')
+ T.eq(model:roller(),roller,'roller identity is stable')
+ T.check(random~=roller,'source random field remains separate from the roller method wrapper')
+ T.eq(#trace,0,'lookup and cache creation consume no native draws')
+ for n=1,256 do
+ local index=#trace+1
+ T.eq(probe.love(model,n),1+(index*37)%n,'source LOVE result uses the next actual native draw')
+ T.eq(#trace,index,'one actual draw for LOVE call')
+ index=#trace+1
+ T.eq(probe.cart(model,n),(index*37)%n,'source random uses zero-based native result')
+ T.eq(#trace,index,'one actual draw for source random')
+ index=#trace+1
+ T.eq(probe.method(model,n),(index*37)%n,'public class roller uses the next same native draw')
+ T.eq(#trace,index,'one actual draw for source roller')
+ end
+ for _,bounds in ipairs{{5,9},{9,5},{-3,2},{4,4}}do
+ local lo,hi=math.min(bounds[1],bounds[2]),math.max(bounds[1],bounds[2])
+ local index=#trace+1
+ T.eq(probe.love(model,bounds[1],bounds[2]),lo+(index*37)%(hi-lo+1),'source range normalization uses native draw')
+ T.eq(trace[index].lo,lo,'normalized native lower bound')
+ T.eq(trace[index].hi,hi,'normalized native upper bound')
+ end
+ for _,bound in ipairs{0,-7}do
+ local index=#trace+1
+ T.eq(probe.love(model,bound),1,'source small bound normalizes to one')
+ T.eq(#trace,index,'normalized one-point range still consumes one actual draw')
+ end
+ local index=#trace+1
+ T.eq(probe.love(model),1,'source missing bound normalizes to one')
+ T.eq(#trace,index,'missing bound uses one actual native draw')
+ local replacement=stream('replacement');b.rng=replacement
+ index=#trace+1
+ T.eq(loveRng(2,8),2+(index*37)%7,'retained LOVE view follows replacement owner')
+ T.eq(trace[index].tag,'replacement','retained LOVE view cannot draw from old function')
+ index=#trace+1
+ T.eq(random(256),(index*37)%256,'retained random follows replacement owner')
+ T.eq(trace[index].tag,'replacement','retained zero-based view cannot draw from old function')
+ index=#trace+1
+ T.eq(roller(255),(index*37)%255,'retained roller follows replacement owner')
+ T.eq(trace[index].tag,'replacement','retained roller cannot draw from old function')
+ T.eq(b.rng,replacement,'adapters never replace the actual native function')
+ b:switchPlayerInto(Screen.POS.PLAYER_LEFT,save.party[2])
+ T.eq(b.battle,model,'real native switch retains its model')
+ T.eq(model.rng,loveRng,'real native switch retains LOVE adapter')
+ T.eq(model:roller(),roller,'real native switch retains source roller')
+ index=#trace+1
+ T.eq(probe.method(model,256),(index*37)%256,'incoming actor shares the same real stream')
+ T.eq(#trace,index,'incoming actor does not create another generator')
+ probe.decorate()
+ local count=#trace
+ T.eq(model.random,random,'decorating roller does not replace the source random field')
+ T.eq(model.rng,loveRng,'decorating roller does not replace source LOVE field')
+ T.eq(probe.lookups,nil,'source field lookup does not invoke a decorated roller method')
+ index=#trace+1
+ T.eq(probe.method(model,255),(index*37)%255,'actual API2 roller decoration keeps native draw ownership')
+ T.eq(probe.lookups,1,'actual API2 consumer reaches the live class decoration')
+ T.eq(#trace,count+1,'decoration lookup itself adds no draw')
+ T.raises(function()model.rng=function()end end,'read-only','source view cannot replace native RNG authority')
+ T.raises(function()model.random=function()end end,'read-only','source view cannot replace cart RNG authority')
+ T.raises(function()model.roller=function()end end,'read-only','source metadata cannot replace the public method')
+ end
+ for _,mon in ipairs(save.party)do
+ T.eq(mon.rng,nil,'no RNG function enters party mon')
+ T.eq(mon.random,nil,'no random function enters party mon')
+ T.eq(mon._nativeRoller,nil,'no adapter cache enters party mon')
+ end
+ run.release()
+end
+-- Internal adapter cycles must be collectible when their native owner and
+-- caller references end. A global value closure retaining a weak key is not.
+local weak=setmetatable({},{__mode='k'})
+local function retired()
+ local screen={rng=function(n)return n end};local model=Model.bind({},screen)
+ Model.roller(model);weak[model]=true;weak[screen]=true
+end
+for _=1,12 do retired()end
+collectgarbage('collect');collectgarbage('collect')
+T.eq(next(weak),nil,'retired model/RNG adapters do not leak through module state')
+V.set(before)
+T.finish('native source model RNG fields, domains, replacement, switch and lifetime')
diff --git a/tests/engine/gen2_model_rng_link.lua b/tests/engine/gen2_model_rng_link.lua
new file mode 100644
index 000000000..5b0194b4c
--- /dev/null
+++ b/tests/engine/gen2_model_rng_link.lua
@@ -0,0 +1,78 @@
+-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+-- Source callbacks draw from the actual seeded link stream in every role.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local V=require('src.core.GameVersion')
+local Copy=require('src.mods.Merge').deepCopy
+local P,N,L=require('src.link.Protocol'),require('src.link.Net'),require('src.link.LinkBattle')
+local old=V.get()
+for _,edition in ipairs{'gold','silver','crystal'}do
+ V.set(edition)
+ local data=T.fixtures.fresh();data.constants.gen=2
+ data.moves.QA_ENDURE={id='QA_ENDURE',name='QA ENDURE',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'}
+ local run=T.sdk.loadMods({'mods/source_rng'},{data=data,fs=T.sdk.memfs({
+ ['mods/source_rng/manifest.json']='{"id":"source_rng","name":"Source RNG","version":"1.0.0","api":2,"entry":"main.lua","affects_link":true}',
+ ['mods/source_rng/main.lua']=[[
+ local mod=...
+ local Model=require('src.battle.gen2.Battle')
+ mod.exports.rows={}
+ mod.events:on('battle.move_used',function(c)
+ local model=c.battle.battle
+ local rows=mod.exports.rows[c.battle]
+ if not rows then rows={};mod.exports.rows[c.battle]=rows end
+ rows[#rows+1]={name=c.user.mon.nickname,turn=model.turn,love=model.rng(16),
+ cart=model.random(256),roller=Model.roller(model)(255)}
+ end)
+ ]],
+ })})
+ T.eq(#run.errors,0,'actual source callback consumer loads');T.link.prepare(data)
+ local games={}
+ for i=1,3 do
+ local game=T.link.fakeGame(data,'FIXMON_A',{name='ROLE'..i,level=30})
+ game.save.version=edition;game.save.party[1].nickname=i==1 and'HOST'or'GUEST'
+ game.save.party[1].moves={{id='QA_ENDURE',pp=20}};games[i]=game
+ end
+ local na,nb=N.loopbackPair();local inbox={}
+ local sn={update=function()end,poll=function()local packets=inbox;inbox={};return packets end}
+ for i,net in ipairs{na,nb}do
+ local send,side=net.send,i==1 and'host'or'guest'
+ net.send=function(self,packet)inbox[#inbox+1]={type='spectate',side=side,msg=Copy(packet)};return send(self,packet)end
+ end
+ local pa,pb=P.packParty(games[1].save.party),P.packParty(games[2].save.party)
+ local a=assert(L.newHost(games[1],na,{myParty=pa,theirParty=pb,theirName='GUEST',seed=327,verdict='full',strict=true}))
+ local b=assert(L.newGuest(games[2],nb,{myParty=pb,theirParty=pa,theirName='HOST',seed=327,verdict='full',strict=true}))
+ local s=assert(L.newSpectator(games[3],sn,{hostParty=pa,guestParty=pb,hostName='HOST',guestName='GUEST',seed=327,verdict='full',strict=true}))
+ local roles={a,b,s}
+ for i,battle in ipairs(roles)do games[i].stack:push(battle)end
+ for _=1,30000 do
+ for i,game in ipairs(games)do
+ require('src.core.Input').pressed=(i==3 or not roles[i].localHashes[2])and{a=true}or{}
+ game.stack:update(1/60)
+ end
+ if a.localHashes[2]and b.localHashes[2]and s.turnCount==2 and s.phase=='waitBoth'then break end
+ end
+ for turn=1,2 do
+ T.check(a.localHashes[turn]~=nil and b.localHashes[turn]~=nil,'actual source-consumer link turn completes')
+ T.eq(a.localHashes[turn],b.localHashes[turn],'source RNG calls preserve native lockstep')
+ T.same(a.localParts[turn],b.localParts[turn],'every completed native state component agrees')
+ end
+ local rows=run.loader.exports.source_rng.rows
+ T.same(rows[a],rows[b],'both participants observe the same source RNG results')
+ T.same(rows[a],rows[s],'spectator observes the same source RNG results')
+ for _,battle in ipairs(roles)do
+ T.eq(#(rows[battle]or{}),4,'every actual move callback reads all three source RNG domains')
+ for index,row in ipairs(rows[battle]or{})do
+ T.eq(row.turn,math.floor((index-1)/2)+1,'actual participant/spectator moves observe the same native round')
+ T.check(row.love>=1 and row.love<=16,'source LOVE call retains its positive range')
+ T.check(row.cart>=0 and row.cart<256,'source random retains its byte range')
+ T.check(row.roller>=0 and row.roller<255,'source roller retains its nonzero-byte-index range')
+ end
+ T.eq(battle.player.mon.moves[1].pp,18,'own native moves spend PP once despite source random calls')
+ T.eq(battle.enemy.mon.moves[1].pp,18,'other native moves spend PP once despite source random calls')
+ T.eq(battle.result,nil,'no hidden link simulation error')
+ end
+ T.eq(#run.errors,0,'no source callback failure is swallowed')
+ run.release()
+end
+V.set(old)
+T.finish('actual source RNG callbacks in native GSC link participant and spectator roles')
diff --git a/tests/engine/gen2_model_round_clock.lua b/tests/engine/gen2_model_round_clock.lua
new file mode 100644
index 000000000..fb1b7055c
--- /dev/null
+++ b/tests/engine/gen2_model_round_clock.lua
@@ -0,0 +1,85 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- An actual API2 consumer observes the native round clock at execution edges.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local V=require('src.core.GameVersion')
+local Screen=require('src.battle.BattleState')
+local Pokemon=require('src.pokemon.Pokemon')
+local Save=require('src.core.SaveData')
+local old=V.get()
+for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do
+ V.set(edition)
+ local data=T.fixtures.fresh();data.constants.gen=2
+ data.moves.QA_CLOCK={id='QA_CLOCK',name='CLOCK',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'}
+ local run=T.sdk.loadMods({'mods/round_clock'},{data=data,fs=T.sdk.memfs({
+ ['mods/round_clock/manifest.json']='{"id":"round_clock","name":"Round clock","version":"1.0.0","api":2,"entry":"main.lua"}',
+ ['mods/round_clock/main.lua']=[[
+ local mod=...
+ mod.exports.rows={}
+ for _,name in ipairs{'battle.turn_started','battle.move_used','battle.turn_ended'}do
+ local eventName=name
+ mod.events:on(name,function(event)
+ local b=event.battle
+ mod.exports.rows[#mod.exports.rows+1]={name=eventName,turn=b.battle.turn,native=b.turnCount,eventTurn=event.turn}
+ end)
+ end
+ mod.exports.read=function(model)return model.turn end
+ ]],
+ })})
+ T.eq(#run.errors,0,'real API2 round observer loads')
+ require('src.render.Font').load(data)
+ local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',30)}
+ save.party[1].moves={{id='QA_CLOCK',pp=20}}
+ local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30)
+ b.enemy.curMoves={{id='QA_CLOCK',pp=20}}
+ b.enemyAction=function()return b.enemy.curMoves[1]end
+ local probe=run.loader.exports.round_clock
+ local model=b.battle
+ T.eq(probe.read(model),0,edition..' starts at source round zero')
+ T.eq(b.turnCount,nil,'reading initial round does not initialize native state')
+ for round=1,3 do
+ local pp=b.player.mon.moves[1].pp
+ b.queue={};b:resolveTurn(b.player.curMoves[1])
+ T.eq(probe.read(model),round,'actual queued native round reaches public model')
+ local queued=#b.queue
+ for _=1,10 do T.eq(probe.read(model),round,'repeated observations never advance a round')end
+ T.eq(#b.queue,queued,'clock observation cannot drain native actions')
+ T.eq(b.player.mon.moves[1].pp,pp,'clock observation cannot spend PP')
+ -- Run the actual two chosen moves; their callback reads must see the same
+ -- round as the earlier turn-start callback, not a per-move/frame counter.
+ local first,second=b.queue[1],b.queue[2]
+ T.check(first and second and first.fn and second.fn,'native planner queues both actual actions')
+ -- This older host executes action rows inline in updateQueue; the newer
+ -- runQueueAction helper belongs to a separately offered prerequisite.
+ -- Execute the actual planner-owned closures with that host's insertion
+ -- context, rather than adding a fake helper or a second move executor.
+ for _,item in ipairs{first,second}do
+ b.nextInsert=0;item.fn();b.nextInsert=nil;b.current=nil
+ end
+ T.eq(b.player.mon.moves[1].pp,pp-1,'actual native move spends PP once')
+ T.eq(probe.read(model),round,'both actual moves share one round')
+ b:endOfTurn()
+ T.eq(probe.read(model),round,'residual processing does not create a new round')
+ end
+ local starts,moves,ends=0,0,0
+ for _,row in ipairs(probe.rows)do
+ T.eq(row.turn,row.native,'API2 execution event sees actual native round')
+ if row.eventTurn then T.eq(row.turn,row.eventTurn,'public model agrees with native event round')end
+ if row.name=='battle.turn_started'then starts=starts+1
+ elseif row.name=='battle.move_used'then moves=moves+1
+ elseif row.name=='battle.turn_ended'then ends=ends+1 end
+ end
+ T.eq(starts,3,'one real start callback per chosen round')
+ T.eq(moves,6,'both sides execute in every real round')
+ T.eq(ends,3,'one real residual completion callback per round')
+ T.eq(save.party[1].turn,nil,'round state never enters a party Pokemon')
+ T.eq(save.turn,nil,'round state never enters persistent save data')
+ local incoming=save.party[2]
+ b:switchPlayerInto(Screen.POS.PLAYER_LEFT,incoming)
+ T.eq(model.player,incoming,'native switch really replaces the player')
+ T.eq(probe.read(model),3,'native switch does not reset battle-wide clock')
+ T.eq(#run.errors,0,'no swallowed source callback error')
+ run.release()
+end
+V.set(old)
+T.finish('native GSC source model round clock at actual queue and API2 event edges')
diff --git a/tests/engine/gen2_mon_fields_data.lua b/tests/engine/gen2_mon_fields_data.lua
new file mode 100644
index 000000000..59c66825e
--- /dev/null
+++ b/tests/engine/gen2_mon_fields_data.lua
@@ -0,0 +1,155 @@
+-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.harness')
+T.eq(_G.love,nil,'field views need no rendering runtime')
+for _,name in ipairs{'src.battle.BattleState','src.pokemon.Sprites','src.render.Assets'}do
+ package.preload[name]=function()error('field view imported presentation: '..name)end
+end
+local ok,Fields=pcall(require,'src.pokemon.Gen2MonFields')
+T.check(ok and type(Fields.bind)=='function','native GSC source fields exist')
+if not ok then T.finish('native GSC field views are absent') end
+local Stats=require('src.pokemon.Stats')
+local Version=require('src.core.GameVersion')
+local initial=Version.get()
+for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do
+ Version.set(edition)
+ local def={name='SOURCE NAME',types={'FIRE'},baseStats={hp=39,attack=52,defense=43,
+ speed=65,special=60,spatk=60,spdef=50}}
+ local data={pokemon={QA_MON=def}}
+ local stats=Stats.calc(def,15,{attack=14,defense=10,speed=10,special=10,hp=0})
+ local mon={species='QA_MON',stats=stats,hp=11,moves={{id='QA_MOVE',pp=5}}}
+ local nativeCount=0;for _ in pairs(stats)do nativeCount=nativeCount+1 end
+ local same,attached=Fields.bind(data,mon)
+ T.eq(same,mon,'source view retains the actual Pokemon')
+ T.check(attached,'ordinary native Pokemon accepts source fields')
+ T.eq(mon.maxHp,stats.hp,'maximum HP is read from the actual native stat block')
+ T.eq(mon.name,def.name,'species display name comes from native registry')
+ T.eq(mon.types,def.types,'types retain the live registry table')
+ T.eq(stats.specialAttack,stats.spatk,'Special Attack reads native spelling')
+ T.eq(stats.specialDefense,stats.spdef,'Special Defense reads native spelling')
+ stats.specialAttack=77;T.eq(stats.spatk,77,'source stat write changes actual native stat')
+ stats.spdef=63;T.eq(stats.specialDefense,63,'native stat write remains visible')
+ mon.maxHp=80;T.eq(stats.hp,80,'source maximum write updates actual native maximum')
+ T.eq(mon.hp,11,'maximum write does not heal the current HP')
+ T.eq(rawget(mon,'maxHp'),nil,'no duplicated HP maximum is serialized')
+ T.eq(rawget(mon,'name'),nil,'no duplicated species display copy is serialized')
+ T.eq(rawget(mon,'types'),nil,'no duplicated type list is serialized')
+ T.eq(rawget(stats,'specialAttack'),nil,'no duplicated Special Attack is serialized')
+ T.eq(rawget(stats,'specialDefense'),nil,'no duplicated Special Defense is serialized')
+ local count=0;for _ in pairs(stats)do count=count+1 end
+ T.eq(count,nativeCount,'iteration keeps exactly the native stored columns')
+ local meta=getmetatable(mon);Fields.bind(data,mon)
+ T.eq(getmetatable(mon),meta,'same-owner binding is stable')
+ def.name='EDITED';T.eq(mon.name,'EDITED','live registry edits cannot leave a stale name')
+ data.pokemon.QA_MON={name='REPLACED',types={'WATER'}}
+ T.eq(mon.name,'REPLACED','full native species replacement is observed')
+ T.eq(mon.types,data.pokemon.QA_MON.types,'type replacement retains live identity')
+ local other={pokemon={QA_MON={name='OTHER OWNER',types={'GRASS'}}}}
+ local second={species='QA_MON',stats={hp=30,spatk=1,spdef=2}}
+ Fields.bind(data,second);Fields.bind(other,mon)
+ T.eq(mon.name,'OTHER OWNER','rebinding follows the new native data owner')
+ T.eq(second.name,'REPLACED','rebinding one mon cannot retarget another')
+ mon.stats=Stats.calc(def,20,{attack=1,defense=2,speed=3,special=4,hp=5})
+ T.eq(mon.maxHp,mon.stats.hp,'native stat-block replacement updates maximum immediately')
+ T.eq(mon.stats.specialAttack,mon.stats.spatk,'native recalculation retains source stat spelling')
+ Fields.publish(other)
+ other.pokemon.QA_MON.baseStats={spatk=51,spdef=52};Fields.publish(other)
+ T.eq(other.pokemon.QA_MON.baseStats.specialAttack,51,'species base-stat aliases are published')
+ other.pokemon.QA_MON.baseStats.specialDefense=53
+ T.eq(other.pokemon.QA_MON.baseStats.spdef,53,'base-stat edits reach native column')
+end
+
+-- Semantic snapshots may strip only the exact native wrapper. A cloned,
+-- customized or modified metatable is still an opaque external owner.
+local native={species='QA_MON',stats={hp=9,spatk=2,spdef=3}}
+Fields.bind({pokemon={QA_MON={name='NATIVE'}}},native)
+T.eq(Fields.plainKind(native),'mon','native Pokemon wrapper authenticated for raw serialization')
+T.eq(Fields.plainKind(native.stats),'stats','native stats wrapper authenticated for raw serialization')
+T.eq(Fields.plainKind({}),nil,'ordinary data does not claim native wrapper ownership')
+T.eq(Fields.plainKind(1),nil,'scalar has no native wrapper')
+local nativeMeta=getmetatable(native)
+local impostor={};local copied={};for k,v in pairs(nativeMeta)do copied[k]=v end
+setmetatable(impostor,copied)
+T.eq(Fields.plainKind(impostor),nil,'copying a metatable does not authenticate it')
+local originalIndex=nativeMeta.__index
+nativeMeta.__index=function()return 'opaque'end
+T.eq(Fields.plainKind(native),nil,'modified native metatable is rejected')
+nativeMeta.__index=originalIndex
+T.eq(Fields.plainKind(native),'mon','restoring exact native wrapper restores authentication')
+nativeMeta.external=true
+T.eq(Fields.plainKind(native),nil,'additional metatable key is opaque')
+nativeMeta.external=nil
+T.eq(Fields.plainKind(native),'mon','exact original wrapper remains available')
+local customNative=setmetatable({stats={hp=3}},{__index=function()return 1 end})
+Fields.bind({},customNative)
+T.eq(Fields.plainKind(customNative),nil,'custom source fields cannot weaken plain checkpoint admission')
+local customStats=setmetatable({hp=2},{__len=function()return 8 end})
+Fields.stats(customStats)
+T.eq(Fields.plainKind(customStats),nil,'custom stat metadata remains opaque')
+
+local records={}
+for i=1,8 do records[i]={species='QA_MON',stats={hp=i,spatk=2,spdef=3}}end
+local restored={party={records[1]},boxes={{[3]=records[2]},{mons={[7]=records[3]}}},
+ daycare={mon=records[4],breed={{mon=records[5]},{mon=records[6]},egg=records[7]}},
+ modData={consumer=records[8]}}
+local restoredData={pokemon={QA_MON={name='RECONSTRUCTED'}}}
+Version.set('crystal')
+T.eq(Fields.bindSave(restoredData,restored),restored,'semantic save binding retains actual save identity')
+for i=1,7 do
+ T.eq(records[i].name,'RECONSTRUCTED','native saved Pokemon slot rebound')
+ T.eq(records[i].maxHp,i,'native saved stat block retained')
+end
+T.eq(getmetatable(records[8]),nil,'arbitrary mod storage is not traversed')
+local foreign={party={{species='QA_MON'}}}
+Version.set('red');Fields.bindSave(restoredData,foreign)
+T.eq(getmetatable(foreign.party[1]),nil,'non-GSC semantic save is excluded')
+Version.set('crystal')
+
+-- Existing custom behavior remains active. Plain native assignments bypass
+-- __newindex exactly when the real canonical key is already stored.
+local writes=0
+local custom={__index=function(_,key)if key=='legacy'then return 42 end end,
+ __newindex=function(t,key,value)writes=writes+1;rawset(t,key,value)end,
+ __len=function()return 9 end}
+local stats=setmetatable({hp=20,spatk=12,spdef=13},custom)
+Fields.stats(stats)
+T.eq(stats.legacy,42,'old stat lookup survives')
+T.eq(getmetatable(stats).__len,custom.__len,'unrelated metamethod survives')
+stats.specialAttack=14;T.eq(stats.spatk,14);T.eq(writes,0,'stored native key bypasses legacy writer')
+stats.extra=15;T.eq(writes,1,'unknown field retains existing writer')
+local mon=setmetatable({species='QA_MON',stats=stats},custom)
+Fields.bind({pokemon={QA_MON={name='VISIBLE'}}},mon)
+T.eq(mon.name,'VISIBLE');T.eq(mon.legacy,42,'old Pokemon lookup survives')
+mon.extra=1;T.eq(writes,2,'unknown Pokemon writes keep custom behavior')
+local protected=setmetatable({stats={hp=2}},{__metatable='owned'})
+local _,bound=Fields.bind({},protected)
+T.eq(bound,false,'protected custom owner is not replaced')
+T.eq(getmetatable(protected),'owned','protected metatable remains intact')
+local explicit={name='AUTHORED',maxHp=7,types={'CUSTOM'},stats={hp=8}}
+Fields.bind({pokemon={}},explicit)
+T.eq(explicit.name,'AUTHORED');T.eq(explicit.maxHp,7,'explicit authored stored fields retain precedence')
+T.eq(explicit.types[1],'CUSTOM')
+local unowned={species='QA_MON',stats={hp=3}}
+Fields.bind(nil,unowned)
+T.eq(unowned.name,nil,'unbound record has no guessed source species name')
+local rebound={species='QA_MON',stats={hp=4}}
+Fields.bind({pokemon={QA_MON={name='REAL OWNER'}}},rebound)
+T.eq(rebound.name,'REAL OWNER','a real-owner cache miss cannot select unowned metadata')
+
+-- LuaJIT weak-key/value semantics: metadata may retain its dataset, but no
+-- global weak-key/value cycle may keep an abandoned model/registry alive.
+local weak=setmetatable({},{__mode='v'})
+do
+ local data={pokemon={QA_MON={name='TEMP'}}}
+ local mon={species='QA_MON',stats={hp=1,spatk=2,spdef=3}}
+ data.ownedMon=mon
+ Fields.bind(data,mon)
+ weak[1],weak[2],weak[3]=mon,data,getmetatable(mon)
+end
+for _=1,4 do collectgarbage('collect')end
+T.eq(weak[1],nil,'abandoned Pokemon is collectible')
+T.eq(weak[2],nil,'cyclic abandoned dataset is collectible')
+T.eq(weak[3],nil,'abandoned field metadata is collectible')
+Version.set(initial)
+T.eq(_G.love,nil,'field views never install a fake rendering runtime')
+T.finish('native GSC source fields, live ownership, custom tables and collection without LÖVE')
diff --git a/tests/engine/gen2_mon_fields_native.lua b/tests/engine/gen2_mon_fields_native.lua
new file mode 100644
index 000000000..bf7cb1cef
--- /dev/null
+++ b/tests/engine/gen2_mon_fields_native.lua
@@ -0,0 +1,127 @@
+-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local ok,Fields=pcall(require,'src.pokemon.Gen2MonFields')
+T.check(ok and type(Fields.bind)=='function','native GSC source fields exist')
+local Version=require('src.core.GameVersion')
+local Pokemon=require('src.pokemon.Pokemon')
+local Save=require('src.core.SaveData')
+local Stats=require('src.pokemon.Stats')
+local Native=require('src.battle.BattleState')
+local initial=Version.get()
+-- Exercise the real strict nested schema, not a direct unchecked Registry
+-- call. Both native split stats retain the same 1..255 bounds as other stats.
+local Schemas=require('src.mods.Schemas')
+for _,key in ipairs({'spatk','spdef'})do
+ for _,value in ipairs({1,91,255})do
+ T.check(Schemas.check(Schemas.REGISTRIES.pokemon,'pokemon','FIXMON_A',
+ {baseStats={[key]=value}},'patch'),'native split-stat patch accepted')
+ end
+ for _,value in ipairs({0,256,1.5,'91'})do
+ local accepted=Schemas.check(Schemas.REGISTRIES.pokemon,'pokemon','FIXMON_A',
+ {baseStats={[key]=value}},'patch')
+ T.check(not accepted,'invalid native split-stat patch rejected')
+ end
+end
+if not ok then
+ Version.set('crystal')
+ local data=T.fixtures.fresh();data.constants.gen=2
+ data.pokemon.FIXMON_A.baseStats.spatk=60;data.pokemon.FIXMON_A.baseStats.spdef=50
+ local mon=Pokemon.new(data,'FIXMON_A',25)
+ T.eq(mon.maxHp,mon.stats.hp,'actual native birth supplies source maximum HP')
+ T.eq(mon.name,data.pokemon.FIXMON_A.name,'actual native birth supplies source species name')
+ T.eq(mon.types,data.pokemon.FIXMON_A.types,'actual native birth supplies source type table')
+ T.eq(mon.stats.specialAttack,mon.stats.spatk,'actual native birth supplies source Special Attack')
+ T.eq(mon.stats.specialDefense,mon.stats.spdef,'actual native birth supplies source Special Defense')
+ Version.set(initial)
+ T.finish('actual native birth source fields are absent')
+end
+local function clone(value)
+ if type(value)~='table'then return value end
+ local out={};for k,v in pairs(value)do out[k]=clone(v)end;return out
+end
+for _,edition in ipairs(Version.ORDER)do
+ Version.set(edition)
+ local data=T.fixtures.fresh();data.constants.gen=Version.generation()
+ if Version.isGen2()then
+ for _,def in pairs(data.pokemon)do
+ def.baseStats.spatk,def.baseStats.spdef=60,50
+ end
+ end
+ local save=Save.newGame()
+ local mon=Pokemon.new(data,'FIXMON_A',25)
+ save.party={mon}
+ if Version.isGen2()then
+ T.eq(mon.name,data.pokemon.FIXMON_A.name,edition..' actual native birth publishes source name')
+ T.eq(mon.maxHp,mon.stats.hp,'actual native birth publishes source maximum')
+ T.eq(mon.stats.specialAttack,mon.stats.spatk,'actual native birth publishes Special Attack')
+ T.eq(mon.stats.specialDefense,mon.stats.spdef,'actual native birth publishes Special Defense')
+ T.eq(mon.types,data.pokemon.FIXMON_A.types,'source types are actual native registry row')
+ local before=Save.encode(save)
+ local plain=clone(save)
+ T.eq(Save.encode(plain),before,'actual native save codec stores no derived alias/helper fields')
+ T.eq(rawget(mon,'maxHp'),nil);T.eq(rawget(mon.stats,'specialAttack'),nil)
+ local sourceName=mon.name
+ data.pokemon.FIXMON_A.name='LIVE EDIT'
+ T.eq(mon.name,'LIVE EDIT','native registry name edit is visible immediately')
+ data.pokemon.FIXMON_A.name=sourceName
+ local b=Native.makeBattler(data,mon,true,save)
+ T.eq(b.mon,mon,'actual native battler retains original Pokemon identity')
+ T.eq(b.curStats,mon.stats,'actual native battler retains original stat identity')
+ mon.stats.specialAttack=97
+ T.eq(b.curStats.spatk,97,'source Special Attack writes reach actual native combat input')
+ local statBlock=mon.stats
+ mon.stats=Stats.calc(data.pokemon.FIXMON_A,mon.level,mon.dvs,mon.statExp)
+ T.neq(mon.stats,statBlock,'real native recalculation replaced the stat block')
+ T.eq(mon.maxHp,mon.stats.hp,'source maximum follows real native recalculation')
+ T.eq(mon.stats.specialDefense,mon.stats.spdef,'recalculation retains source special fields')
+ -- Import/semantic load removes all metatables. Real save validation must
+ -- hydrate the same decoded party member before normal menus/battles.
+ local loaded=assert(Save.decode(Save.encode(save)))
+ local original=loaded.party[1]
+ T.eq(original.maxHp,nil,'decoded native bytes contain no helper maximum')
+ Save.validate(loaded,data)
+ T.eq(loaded.party[1],original,'real validation retains decoded party identity')
+ T.eq(original.name,data.pokemon.FIXMON_A.name,'real validation restores native source name')
+ T.eq(original.maxHp,original.stats.hp,'real validation restores native maximum view')
+ T.eq(original.stats.specialAttack,original.stats.spatk,'real validation restores source special names')
+ local game={data=data,save=loaded,stack={top=function()end}}
+ local screen=Native.newWild(game,'FIXMON_B',15)
+ T.eq(screen.player.mon,original,'actual older native controller uses original validated Pokemon')
+ T.eq(screen.player.mon.maxHp,screen.player.mon.stats.hp,'actual older native controller reports combat maximum')
+ T.eq(screen.enemy.mon.name,data.pokemon.FIXMON_B.name,'live wild opponent has native source name')
+ T.eq(screen.enemy.mon.stats.specialDefense,screen.enemy.curStats.spdef,
+ 'live source model and actual enemy combat statistics agree')
+ -- A box mon can have no stat block until the native status/withdraw path.
+ local boxed=clone(original);boxed.stats=nil
+ Fields.bind(data,boxed);Stats.ensure(data.pokemon[boxed.species],boxed)
+ T.eq(boxed.maxHp,boxed.stats.hp,'box stat creation supplies the source maximum')
+ T.eq(boxed.stats.specialAttack,boxed.stats.spatk,'box stat creation supplies source split stats')
+ else
+ T.eq(rawget(mon,'maxHp'),nil,edition..' native serialization remains unchanged')
+ T.eq(getmetatable(mon),nil,edition..' other generation Pokemon is not decorated')
+ end
+end
+
+-- The actual SDK/Loader publishes source base-stat spellings before a mod
+-- reads its host data; the real registry writer retains them after edits.
+Version.set('crystal')
+local data=T.fixtures.fresh();data.constants.gen=2
+data.pokemon.FIXMON_A.baseStats.spatk=61;data.pokemon.FIXMON_A.baseStats.spdef=62
+local run=T.sdk.loadMods({'mods/field_probe'},{data=data,fs=T.sdk.memfs({
+ ['mods/field_probe/manifest.json']='{"id":"field_probe","name":"Field probe","version":"1.0.0","api":2,"entry":"main.lua"}',
+ ['mods/field_probe/main.lua']=[[
+ local mod=...
+ local Mon=require('src.battle.gen2.Mon')
+ mod.content.pokemon:patch('FIXMON_A',{baseStats={spatk=91,spdef=92}})
+ mod.exports.make=function(data)return Mon.new(data,'FIXMON_A',15,function(a)return a end)end
+ ]],
+})})
+T.eq(#run.errors,0,'actual SDK source Mon consumer loads')
+T.eq(data.pokemon.FIXMON_A.baseStats.specialAttack,91,'actual older Loader publishes source base stats after a real native registry fold')
+T.eq(data.pokemon.FIXMON_A.baseStats.specialDefense,92,'actual older Loader retains both native split-stat patches')
+local mon=run.loader.exports.field_probe.make(data)
+T.eq(mon.maxHp,mon.stats.hp,'actual source Mon constructor returns native maximum view')
+T.eq(rawget(data.pokemon.FIXMON_A.baseStats,'specialAttack'),nil,'registry view stores no duplicate column')
+run.release();Version.set(initial)
+T.finish('source GSC fields through native birth, save codec, validation, combat, boxes and SDK registries')
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_move_priority.lua b/tests/engine/gen2_move_priority.lua
new file mode 100644
index 000000000..3538a9bcc
--- /dev/null
+++ b/tests/engine/gen2_move_priority.lua
@@ -0,0 +1,90 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- GSC's GetMovePriority precedes Speed. Exercise the shared comparator, the
+-- actual native queued turn, and the public live-model query together.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local Version=require('src.core.GameVersion')
+local Screen=require('src.battle.BattleState')
+local Pokemon=require('src.pokemon.Pokemon')
+local Order=require('src.battle.TurnOrder')
+local Save=require('src.core.SaveData')
+local saved=Version.get()
+local cases={
+ {id='QA_PROTECT',effect='PROTECT_EFFECT',priority=3},
+ {id='QA_ENDURE',effect='ENDURE_EFFECT',priority=3},
+ {id='QA_COUNTER',effect='COUNTER_EFFECT',priority=-1},
+ {id='QA_MIRROR',effect='MIRROR_COAT_EFFECT',priority=-1},
+ {id='VITAL_THROW',effect='ALWAYS_HIT_EFFECT',priority=-1},
+ {id='ROAR',effect='SWITCH_AND_TELEPORT_EFFECT',priority=-1},
+ {id='WHIRLWIND',effect='SWITCH_AND_TELEPORT_EFFECT',priority=-1},
+ {id='TELEPORT',effect='SWITCH_AND_TELEPORT_EFFECT',priority=0},
+}
+local function fixture(version,chosen)
+ Version.set(version)
+ local data=T.fixtures.fresh();data.constants.gen=2
+ for _,row in ipairs(cases)do
+ data.moves[row.id]={id=row.id,name=row.id,effect=row.effect,power=0,type='NORMAL',accuracy=100,pp=10}
+ end
+ local run=T.sdk.loadNone({data=data})
+ require('src.render.Font').load(data)
+ local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',25)}
+ save.party[1].moves={{id=chosen or 'QA_PROTECT',pp=10}}
+ local game={data=data,save=save,stack={top=function()end}}
+ local b=Screen.newWild(game,'FIXMON_B',25)
+ b.rng=function(lo)return lo end
+ return b,run
+end
+for _,version in ipairs{'gold','silver','crystal'}do
+ local b,run=fixture(version)
+ local a,z=b.player,b.enemy
+ local plain=b.data.moves.FIX_TACKLE
+ for _,row in ipairs(cases)do
+ local move=b.data.moves[row.id]
+ a.curStats.speed=row.priority>0 and 1 or 999
+ z.curStats.speed=row.priority>0 and 999 or 1
+ local expect=row.priority>=0
+ T.eq(Order.firstMover(a,move,z,plain,b.rng,false,b,b.data),expect,
+ version..' native effect priority for '..row.id)
+ T.eq(b.battle:movePriority(row.id),row.priority,'public live-model priority agrees: '..row.id)
+ end
+ local explicit={id='QA_EXPLICIT',effect='PROTECT_EFFECT',priority=-7}
+ T.eq(Order.firstMover(a,explicit,z,plain,b.rng,false,b,b.data),false,'explicit native mod priority still wins')
+ b.data.moves[explicit.id]=explicit
+ T.eq(b.battle:movePriority(explicit.id),-7,'model uses the same explicit override')
+ T.eq(b.battle:movePriority('UNKNOWN_NATIVE_MOVE'),0,'missing priority stays neutral')
+ run.release()
+ for _,id in ipairs{'QA_PROTECT','QA_ENDURE'}do
+ local battle,owner=fixture(version,id)
+ battle.player.curStats.speed=1;battle.enemy.curStats.speed=999
+ local selected=battle.player.curMoves[1]
+ local enemyMove=battle.enemy.curMoves[1]
+ -- Fix only the legal AI choice; the turn planner, queue closures, move
+ -- execution, PP and protection handlers below are the genuine owners.
+ battle.enemyAction=function()return enemyMove end
+ battle.queue={}
+ local hp=battle.player.mon.hp
+ battle:resolveTurn(selected)
+ local first,second=battle.queue[1],battle.queue[2]
+ T.check(first and second and first.fn and second.fn,'native turn queued both actual actions')
+ battle.nextInsert=0;first.fn();battle.nextInsert=nil
+ local field=id=='QA_PROTECT'and'protecting'or'enduring'
+ T.eq(battle.player[field],true,'slow '..id..' really executes before the faster enemy')
+ T.eq(selected.pp,9,'actual first move spends one PP')
+ if id=='QA_PROTECT'then
+ battle.nextInsert=0;second.fn();battle.nextInsert=nil
+ T.eq(battle.player.mon.hp,hp,'faster enemy attack is blocked by the actual protection state')
+ end
+ owner.release()
+ end
+end
+-- The added effect fallback belongs only to the GSC controller. Preserve
+-- other generations' explicit imported priorities and legacy ID behavior.
+for _,version in ipairs{'red','firered','emerald','heartgold','black'}do
+ Version.set(version)
+ local a={curStats={speed=1},stages={},mon={hp=100,stats={speed=1}}}
+ local z={curStats={speed=999},stages={},mon={hp=100,stats={speed=999}}}
+ T.eq(Order.firstMover(a,{id='QA_OTHER',effect='PROTECT_EFFECT'},z,{id='PLAIN'},function(lo)return lo end),false,
+ version..' retains its own priority contract')
+end
+Version.set(saved)
+T.finish('native GSC priority, live model queries and actual protection turns')
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/gen2_protection.lua b/tests/engine/gen2_protection.lua
new file mode 100644
index 000000000..af5440e26
--- /dev/null
+++ b/tests/engine/gen2_protection.lua
@@ -0,0 +1,99 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- Native registered protection effects, RNG boundaries and real queued order.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local V=require('src.core.GameVersion')
+local Screen=require('src.battle.BattleState')
+local Pokemon=require('src.pokemon.Pokemon')
+local Save=require('src.core.SaveData')
+local P=require('src.battle.Gen3MoveEffects').primary
+local before=V.get()
+local function fixture(edition)
+ V.set(edition)
+ local data=T.fixtures.fresh();data.constants.gen=V.generation()
+ data.moves.QA_PROTECT={id='QA_PROTECT',name='QA PROTECT',power=0,type='NORMAL',accuracy=100,pp=20,effect='PROTECT_EFFECT'}
+ data.moves.QA_ENDURE={id='QA_ENDURE',name='QA ENDURE',power=0,type='NORMAL',accuracy=100,pp=20,effect='ENDURE_EFFECT'}
+ local run=T.sdk.loadNone({data=data});require('src.render.Font').load(data)
+ local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',30)}
+ save.party[1].moves={{id='QA_PROTECT',pp=20}}
+ local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30)
+ return b,run
+end
+for _,edition in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do
+ local b,run=fixture(edition)
+ for _,effect in ipairs{{'PROTECT_EFFECT','protecting'},{'ENDURE_EFFECT','enduring'}}do
+ for count=0,9 do
+ local threshold=math.floor(255/2^count)
+ for _,byte in ipairs{0,1,62,63,126,127,253,254}do
+ local draws,loSeen,hiSeen=0
+ b.rng=function(lo,hi)draws=draws+1;loSeen,hiSeen=lo,hi;return lo+byte%(hi-lo+1)end
+ b.player.protectRun=count;b.player[effect[2]]=nil
+ P[effect[1]](b,b.player)
+ local succeeds=threshold>0 and byte0 and 1 or 0,'one nonzero-byte draw until threshold reaches zero')
+ if threshold>0 then
+ T.eq(loSeen,1,'native draw starts at one')
+ T.eq(hiSeen,255,'native draw spans all nonzero bytes')
+ end
+ end
+ end
+ for _,reason in ipairs{'substitute','opponent-first'}do
+ b.player.protectRun=3;b.player[effect[2]]=nil
+ b.player.substituteHP=reason=='substitute'and 10 or nil
+ b.turnCount=10;b._gen2ProtectionTurn=10
+ b._gen2ProtectionFirst=reason=='opponent-first'and'enemy'or'player'
+ local draws=0;b.rng=function(lo)draws=draws+1;return lo end
+ P[effect[1]](b,b.player)
+ T.eq(b.player[effect[2]],nil,reason..' prevents '..effect[1])
+ T.eq(b.player.protectRun,0,reason..' resets actual counter')
+ T.eq(draws,0,reason..' fails before the random draw')
+ end
+ b.player.substituteHP=nil;b._gen2ProtectionTurn=nil;b._gen2ProtectionFirst=nil
+ end
+ -- Both sides choose the same priority. The slower user's actual queued
+ -- action must fail after the faster opponent has acted, without a draw.
+ b.player.curStats.speed=1;b.enemy.curStats.speed=999
+ local selected=b.player.curMoves[1]
+ b.enemy.curMoves[1]={id='QA_ENDURE',pp=20}
+ b.enemyAction=function()return b.enemy.curMoves[1]end
+ b.queue={};local draws=0;b.rng=function(lo)draws=draws+1;return lo end
+ b:resolveTurn(selected)
+ local first,second=b.queue[1],b.queue[2]
+ T.check(first and second and first.fn and second.fn,'real native actions are queued')
+ b.nextInsert=0;first.fn()
+ T.eq(b.enemy.enduring,true,'the faster opponent executes actual Endure first')
+ local afterFirst=draws
+ b.nextInsert=0;second.fn()
+ T.eq(b.player.protecting,nil,'second actor cannot Protect after opponent')
+ T.eq(b.player.protectRun,0,'second actor resets source-readable counter')
+ T.eq(draws,afterFirst,'opponent-first failure consumes no protection RNG')
+ T.eq(selected.pp,19,'failed protection still spends the actual move PP')
+ -- A new round retires the previous first actor. Called effects remain in
+ -- the same round and cannot change its first side.
+ b.queue={};b:resolveTurn(b.player.curMoves[1]);b:executeAction(b.player,b.enemy,{special='recharge'})
+ T.eq(b._gen2ProtectionFirst,'player','next round records its actual first side')
+ -- First-side identity must survive replacement of that side's wrapper.
+ b.queue={};b:resolveTurn(b.player.curMoves[1])
+ local withdrawn=b.player
+ b:executeAction(withdrawn,b.enemy,{special='playerSwitch',mon=b.game.save.party[2]})
+ T.check(b.player~=withdrawn,'real native switch replaces the first wrapper')
+ b.enemy.protectRun=2
+ b:executeAction(b.enemy,b.player,{id='QA_ENDURE',pp=20})
+ T.eq(b.enemy.enduring,nil,'second actor cannot Endure after opponent switches')
+ T.eq(b.enemy.protectRun,0,'switch-first failure resets actual counter')
+ run.release()
+end
+-- Existing Gen1/GBA behavior is deliberately untouched by the GSC owner.
+for _,edition in ipairs{'red','firered','emerald'}do
+ local b,run=fixture(edition);local draws=0
+ b.rng=function(lo)draws=draws+1;return lo end
+ b.player.substituteHP=10;b._gen2ProtectionTurn=b.turnCount;b._gen2ProtectionFirst='enemy'
+ P.PROTECT_EFFECT(b,b.player)
+ T.eq(b.player.protecting,true,edition..' retains its previous protection behavior')
+ T.eq(draws,0,edition..' first use retains its old random protocol')
+ run.release()
+end
+V.set(before)
+T.finish('native GSC protection thresholds, restrictions, order, PP and RNG')
diff --git a/tests/engine/gen2_protection_link.lua b/tests/engine/gen2_protection_link.lua
new file mode 100644
index 000000000..f39e734c5
--- /dev/null
+++ b/tests/engine/gen2_protection_link.lua
@@ -0,0 +1,64 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- Actual participant/spectator queues must share GSC order and RNG ownership.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local V=require('src.core.GameVersion')
+local Copy=require('src.mods.Merge').deepCopy
+local H=require('src.link.Handshake')
+local P,N,L=require('src.link.Protocol'),require('src.link.Net'),require('src.link.LinkBattle')
+local old=V.get()
+for _,edition in ipairs{'gold','silver','crystal'}do
+ V.set(edition)
+ local data=T.fixtures.fresh();data.constants.gen=2
+ data.moves.QA_PROTECT={id='QA_PROTECT',name='QA PROTECT',power=0,type='NORMAL',accuracy=100,pp=20,effect='PROTECT_EFFECT'}
+ local run=T.sdk.loadNone({data=data});T.link.prepare(data)
+ local games={}
+ for i=1,3 do
+ local game=T.link.fakeGame(data,'FIXMON_A',{name='ROLE'..i,level=30})
+ game.save.version=edition;game.save.party[1].moves={{id='QA_PROTECT',pp=20}}
+ games[i]=game
+ end
+ local hello=H.hello(games[1],'battle');local legacy=Copy(hello);legacy.nativeBattleRevision=nil
+ T.eq(hello.nativeBattleRevision,'gsc-1','GSC advertises changed command/RNG semantics')
+ T.eq(H.checkCompat(hello,legacy),'subset','legacy GSC cannot enter changed lockstep')
+ T.eq(H.checkCompat(legacy,hello),'subset','GSC command mismatch is symmetric')
+ for _,host in ipairs{false,true}do
+ local state=setmetatable({game=games[1],myHello=hello,peerHello=legacy},{__index=require('src.link.LinkState')})
+ state:decideCompat('battle',host)
+ T.eq(state.stage,'notice','actual admission refuses old commands before exchange')
+ end
+ local na,nb=N.loopbackPair();local inbox={}
+ local sn={update=function()end,poll=function()local packets=inbox;inbox={};return packets end}
+ for i,net in ipairs{na,nb}do
+ local send,side=net.send,i==1 and'host'or'guest'
+ net.send=function(self,packet)inbox[#inbox+1]={type='spectate',side=side,msg=Copy(packet)};return send(self,packet)end
+ end
+ local pa,pb=P.packParty(games[1].save.party),P.packParty(games[2].save.party)
+ local a=assert(L.newHost(games[1],na,{myParty=pa,theirParty=pb,theirName='GUEST',seed=327,verdict='full',strict=true}))
+ local b=assert(L.newGuest(games[2],nb,{myParty=pb,theirParty=pa,theirName='HOST',seed=327,verdict='full',strict=true}))
+ local s=assert(L.newSpectator(games[3],sn,{hostParty=pa,guestParty=pb,hostName='HOST',guestName='GUEST',seed=327,verdict='full',strict=true}))
+ local roles={a,b,s}
+ for i,battle in ipairs(roles)do games[i].stack:push(battle)end
+ for _=1,20000 do
+ for i,game in ipairs(games)do
+ require('src.core.Input').pressed=(i==3 or not roles[i].localHashes[1])and{a=true}or{}
+ game.stack:update(1/60)
+ end
+ if a.localHashes[1]and b.localHashes[1]and s.turnCount==1 and s.phase=='waitBoth'then break end
+ end
+ T.check(a.localHashes[1]~=nil and b.localHashes[1]~=nil,'both actual participant queues complete')
+ T.eq(a.localHashes[1],b.localHashes[1],'GSC protection RNG and state hashes agree')
+ T.same(a.localParts[1],b.localParts[1],'all completed native state components agree')
+ for _,battle in ipairs(roles)do
+ local x,y=battle.player.protectRun,battle.enemy.protectRun
+ T.check(x==1 and y==0 or x==0 and y==1,'only actual first actor succeeds; second clears its counter')
+ T.eq(battle.player.mon.moves[1].pp,19,'own first-round move spends PP exactly once')
+ T.eq(battle.enemy.mon.moves[1].pp,19,'other first-round move spends PP exactly once')
+ T.eq(battle.result,nil,'no hidden participant/spectator error')
+ end
+ T.eq(s.player.protectRun,a.player.protectRun,'spectator preserves host protection owner')
+ T.eq(s.enemy.protectRun,a.enemy.protectRun,'spectator preserves guest protection owner')
+ run.release()
+end
+V.set(old)
+T.finish('native GSC protection participant, spectator and protocol admission')
diff --git a/tests/engine/gen2_protection_protocol.lua b/tests/engine/gen2_protection_protocol.lua
new file mode 100644
index 000000000..fa78cb78d
--- /dev/null
+++ b/tests/engine/gen2_protection_protocol.lua
@@ -0,0 +1,27 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local V,F,H=require('src.core.GameVersion'),require('src.link.Fingerprint'),require('src.link.Handshake')
+local Copy=require('src.mods.Merge').deepCopy
+local before=V.get();local data=T.fixtures.fresh()
+-- Independently audited Python integer FNV: the old raw fixture surface,
+-- with only [native_gsc_commands]gsc-1 inserted before its mods domain.
+for _,edition in ipairs{'red','gold','firered','crystal','emerald','silver','red'}do
+ V.set(edition);local gsc=V.isGen2()
+ T.eq(F.compute(data,{}),gsc and '40df18dc503213c8' or 'e33d251961f2a41d','only GSC changes the older host fixture surface')
+ T.eq(F.nativeBattleRevision(),gsc and 'gsc-1'or nil,'only GSC advertises new native rules')
+ local hello=H.hello({data=data,save={player={name='RULES'}}},'battle')
+ T.eq(H.checkCompat(hello,Copy(hello)),'full','matching actual hello admits lockstep')
+ if gsc then
+ local old=Copy(hello);old.nativeBattleRevision=nil
+ T.eq(H.checkCompat(hello,old),'subset','equal-digest old rule field cannot enter lockstep')
+ T.eq(H.checkCompat(old,hello),'subset','rule mismatch remains symmetric')
+ T.eq(H.checkCompat(hello,{}),'refused','unversioned peer cannot assume changed GSC rules')
+ local lines=H.describe(hello,old,'subset','battle')
+ T.check(table.concat(lines,' '):find('native battle rules',1,true)~=nil,'visible mismatch describes native rules')
+ else
+ T.eq(H.checkCompat(hello,{}),'vanilla_peer','other old-host editions retain old wire behavior')
+ end
+end
+V.set(before)
+T.finish('older-host native GSC fingerprint cache and actual protocol negotiation')
diff --git a/tests/engine/gen2_sfx_arbitration.lua b/tests/engine/gen2_sfx_arbitration.lua
new file mode 100644
index 000000000..43b272fe1
--- /dev/null
+++ b/tests/engine/gen2_sfx_arbitration.lua
@@ -0,0 +1,138 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- Real Sound -> ChipAudio -> GSC bytecode -> PCM and native animation VM.
+-- Only the audio device is replaced; no playback clock is invented here.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local V=require('src.core.GameVersion')
+local Sound=require('src.core.Sound')
+local Bus=require('src.audio.Gen2Sfx')
+local Player=require('src.battle.Gen2AnimPlayer')
+local oldVersion,oldAudio=V.get(),love.audio
+local made={}
+love.audio={newSource=function(pcm,kind)
+ local s={pcm=pcm,kind=kind,playing=false,starts=0,stops=0}
+ function s:play()self.playing=true;self.starts=self.starts+1 end
+ function s:stop()self.playing=false;self.stops=self.stops+1 end
+ function s:isPlaying()return self.playing end
+ function s:setVolume(v)self.volume=v end
+ function s:getVolume()return self.volume end
+ function s:setPitch(v)self.pitch=v end
+ function s:setLooping(v)self.looping=v end
+ function s:isLooping()return self.looping or false end
+ made[#made+1]=s
+ return s
+end}
+-- One channel-5 header and a real finite Gen2 square-note program, followed
+-- by wave storage. These bytes are authored here, not extracted game data.
+love.filesystem.write('sfx-probe.bin',string.char(4,3,0x40,3,0xf0,0,7,0xff)..string.rep('\0',0x4000-8))
+local function fixture(edition,shape)
+ V.set(edition);Sound.invalidate();Sound.applyOptions({stereo=true})
+ local function header()return{bank=1,address=0x4000,engine='gen2'}end
+ local data={audio={programFile='sfx-probe.bin',programPrefix='',bankOrder={1},
+ waveBanks={gen2={bank=1,address=0x4100,count=1}},
+ sfx={HIGH=header(),MID=header(),LOW=header(),WRAPPED=header(),UNINDEXED=header(),FILE='custom.ogg'},
+ songs={wind={file='weather.ogg'}},fanfares={}}}
+ if shape=='index'then data.audio.sfxIndex={['0']='HIGH',['5']='MID',['9']='LOW',['257']='WRAPPED'}
+ else data.audio.sfxOrder={'HIGH',false,false,false,false,'MID',false,false,false,'LOW'}end
+ data.audio.sfx.ALIAS=data.audio.sfx.MID
+ return data
+end
+for _,edition in ipairs{'gold','silver','crystal'}do
+ for _,shape in ipairs{'index','order'}do
+ local d=fixture(edition,shape)
+ T.eq(Bus.idFor(d,'MID',d.audio.sfx.MID),5,'exact native label wins over shared header aliases')
+ T.eq(Bus.idFor(d,'ALIAS',d.audio.sfx.ALIAS),shape=='index'and 257 or 9,'alias uses deterministic native header mapping')
+ T.eq(Bus.idFor(d,'FILE',d.audio.sfx.FILE),nil,'file effects never acquire guessed cartridge priority')
+ local low=Sound.play(d,'LOW')
+ T.check(low and low:isPlaying(),'native low-priority effect starts when channels are idle')
+ T.eq(low.kind,'static');T.eq(low.pcm:getChannelCount(),2)
+ T.check(low.pcm:getSampleCount()>441,'actual GSC interpreter produced an audible-length PCM buffer')
+ local energy=0
+ for n=0,low.pcm:getSampleCount()-1 do
+ energy=energy+math.abs(low.pcm:getSample(n,1))
+ if n%137==0 then T.eq(low.pcm:getSample(n,1),low.pcm:getSample(n,2),'existing two-channel PCM routing is retained')end
+ end
+ T.check(energy>0,'real program synthesis is non-silent')
+ local mid=Sound.play(d,'MID')
+ T.eq(low:isPlaying(),false,'ordinary PlaySFX clears preceding native channels')
+ local count=#made
+ T.eq(Sound.play(d,'LOW'),nil,'larger low-byte id is dropped while higher-priority SFX plays')
+ T.eq(#made,count,'dropped effect allocates no Source');T.eq(mid:isPlaying(),true)
+ local high=Sound.play(d,'HIGH')
+ T.eq(mid:isPlaying(),false);T.eq(high:isPlaying(),true,'accepted normal effect replaces the prior source')
+ local again=Sound.play(d,'HIGH')
+ T.eq(again,high);T.eq(high.starts,2,'equal priority restarts the existing native cache entry')
+ local stereo=Sound.playStereo(d,'LOW')
+ T.check(stereo and stereo:isPlaying(),'PlayStereoSFX bypasses normal priority')
+ T.eq(high:isPlaying(),true,'stereo entry preserves preceding static voices')
+ T.eq(Sound.play(d,'MID'),nil,'stereo entry does not overwrite wCurSFX priority byte')
+ high:stop()
+ T.eq(Sound.play(d,'MID'),nil,'CheckSFX sees the remaining actual stereo source')
+ stereo:stop()
+ mid=Sound.play(d,'MID')
+ T.check(mid and mid:isPlaying(),'real device completion releases the ordinary gate')
+ Sound.sfxChannelsOff()
+ T.eq(mid:isPlaying(),false)
+ local unindexed=Sound.play(d,'FILE')
+ high=Sound.play(d,'HIGH');T.eq(unindexed:isPlaying(),true,'non-GSC file definitions retain their existing owner')
+ Sound.invalidate('HIGH');T.eq(high:isPlaying(),false)
+ T.check(Sound.play(d,'LOW')~=nil,'partial invalidation forgets the removed active channel')
+ Sound.invalidate();Sound.applyOptions({stereo=false})
+ mid=Sound.play(d,'MID');stereo=Sound.playStereo(d,'LOW')
+ T.eq(mid:isPlaying(),false,'mono PlayStereoSFX clears prior channels through its ungated entry')
+ T.check(stereo and stereo:isPlaying());T.eq(Sound.play(d,'LOW'),nil,'mono stereo entry still preserves current priority byte')
+ high=Sound.play(d,'HIGH');T.eq(stereo:isPlaying(),false)
+ Sound.suspend();T.eq(high:isPlaying(),false)
+ count=#made;T.eq(Sound.playStereo(d,'LOW'),nil);T.eq(#made,count,'suspended session cannot allocate or publish effects')
+ Sound.resume();T.check(Sound.play(d,'LOW')~=nil,'resume retires the old gate and recreates real playback')
+ Sound.invalidate();Sound.applyOptions({stereo=true})
+ stereo=Sound.playStereo(d,'LOW')
+ T.eq(Sound.play(d,'MID'),nil,'initial wCurSFX zero is preserved by stereo playback')
+ stereo:stop();mid=Sound.play(d,'MID');mid:stop()
+ stereo=Sound.playStereo(d,'LOW')
+ T.eq(Sound.play(d,'LOW'),nil,'last normal priority persists after its source finishes')
+ if shape=='index'then
+ Sound.sfxChannelsOff();mid=Sound.play(d,'MID')
+ T.check(Sound.play(d,'WRAPPED')~=nil,'normal priority compares E, the low byte of a 16-bit native id')
+ T.eq(mid:isPlaying(),false)
+ end
+ -- The actual native player/command VM must use PlayStereoSFX too.
+ Sound.invalidate();high=Sound.play(d,'HIGH')
+ d.battle_anims={generation=2,oamsets={},scripts={probe={{'sound',0,9},{'wait',1},{'sound',0,5},{'ret'}}},moves={PROBE='probe'}}
+ local p=Player.new(d)
+ T.check(p:start('PROBE',true))
+ local steps=0
+ repeat p:update();steps=steps+1;T.check(steps<20)until p:isDone()
+ T.check(Sound.isPlaying('LOW'),'first native anim_sound survives a high-priority ordinary effect')
+ T.check(Sound.isPlaying('MID'),'second native anim_sound is admitted through the same actual VM')
+ T.eq(high:isPlaying(),true,'VM never routes animation sounds through ordinary channel clearing')
+ T.eq(Sound.play(d,'MID'),nil,'animation path never replaces ordinary current priority')
+ p:release();Sound.invalidate()
+ end
+end
+-- Native source lifecycle and public event observers, including reentrancy.
+local d=fixture('crystal','index')
+local run=T.sdk.loadMods({'mods/audio_probe'},{data=d,fs=T.sdk.memfs({
+ ['mods/audio_probe/manifest.json']=[[{"id":"audio_probe","name":"Audio probe","version":"1.0.0","api":2,"entry":"main.lua"}]],
+ ['mods/audio_probe/main.lua']=[[
+ local mod=...
+ mod.exports.events={}
+ mod.events:on('sound.played',function(e)
+ mod.exports.events[#mod.exports.events+1]=e.name
+ if mod.exports.callback then mod.exports.callback(e)end
+ end)
+ ]],
+})})
+local probe=assert(run.loader.exports.audio_probe)
+Sound.play(d,'HIGH');T.eq(#probe.events,1)
+T.eq(Sound.play(d,'LOW'),nil);T.eq(#probe.events,1,'dropped effects emit no false sound.played event')
+Sound.invalidate()
+local nested
+probe.callback=function(e)if e.name=='MID'then nested=Sound.play(d,'LOW')end end
+local source=Sound.play(d,'MID')
+T.eq(nested,nil,'reentrant callback observes committed normal priority')
+T.eq(source:isPlaying(),true);T.eq(#probe.events,2,'reentrant denied effect emits nothing')
+probe.callback=nil
+require('src.mods.Loader').teardown();run.release()
+Sound.invalidate();Sound.applyOptions({stereo=true});love.audio=oldAudio;V.set(oldVersion)
+T.finish('native GSC normal/stereo SFX admission, real PCM, animation VM and playback ownership')
diff --git a/tests/engine/gen2_sleep_cures.lua b/tests/engine/gen2_sleep_cures.lua
new file mode 100644
index 000000000..768071338
--- /dev/null
+++ b/tests/engine/gen2_sleep_cures.lua
@@ -0,0 +1,56 @@
+-- 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 Screen=require('src.battle.BattleState')
+local Pokemon=require('src.pokemon.Pokemon')
+local Items=require('src.inventory.ItemEffects')
+local Registry=require('src.battle.StatusRegistry')
+local old=V.get()
+local function fixture(edition)
+ V.set(edition)
+ local data=T.fixtures.fresh();data.constants.gen=V.generation()
+ require('src.render.Font').load(data)
+ local save=require('src.core.SaveData').newGame()
+ save.party={Pokemon.new(data,'FIXMON_A',30)}
+ local s=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',30)
+ s.queue={};s.rng=function()return 1 end
+ return s
+end
+local function sleep(b)
+ b.mon.status,b.mon.sleepTurns,b.sleepTurns='SLP',3,3
+end
+for _,edition in ipairs{'gold','silver','crystal'}do
+ for _,side in ipairs{'player','enemy'}do
+ for _,effect in ipairs{13,15}do
+ local s=fixture(edition);local b=s[side]
+ s.data.items.PROBE_SLEEP_BERRY={name='Probe berry',heldEffect=effect,heldParam=0}
+ b.mon.item='PROBE_SLEEP_BERRY'
+ local msgs=Registry.inflict(s,b,'SLP',{})
+ T.eq(b.mon.status,nil,'native held effect immediately cures inflicted sleep')
+ T.eq(b.mon.sleepTurns,nil,'held cure clears the party counter written by native infliction')
+ T.eq(b.sleepTurns,nil);T.eq(b.mon.item,nil,'actual held owner consumes the berry once')
+ T.eq(#msgs,2,'infliction and held cure preserve their native caption order')
+ end
+ for _,id in ipairs{'AWAKENING','FULL_HEAL','MINT_BERRY','FULL_RESTORE','REVIVE','MAX_REVIVE'}do
+ local s=fixture(edition);local b=s[side];sleep(b)
+ s.data.items[id]={name=id}
+ if id=='REVIVE'or id=='MAX_REVIVE'then b.mon.hp=0 end
+ local result=Items.use(s.data,s.game.save,id,b.mon,s)
+ T.eq(result,'consumed','actual native medicine succeeds')
+ T.eq(b.mon.status,nil);T.eq(b.mon.sleepTurns,nil,'medicine clears persistent sleep count')
+ T.eq(b.sleepTurns,nil,'same active wrapper is cleared immediately')
+ end
+ end
+ local s=fixture(edition);local mon=s.player.mon;sleep(s.player)
+ s.data.items.AWAKENING={name='AWAKENING'}
+ T.eq(Items.use(s.data,s.game.save,'AWAKENING',mon), 'consumed')
+ T.eq(mon.status,nil);T.eq(mon.sleepTurns,nil,'field medicine clears the actual party counter without a battle')
+end
+-- Gen1's unrelated dormant fields retain their previous shared-item behavior.
+local s=fixture('red');local b=s.player;sleep(b)
+s.data.items.AWAKENING={name='AWAKENING'}
+T.eq(Items.use(s.data,s.game.save,'AWAKENING',b.mon,s),'consumed')
+T.eq(b.mon.sleepTurns,3);T.eq(b.sleepTurns,3,'GSC counter cleanup does not reach Gen1')
+V.set(old)
+T.finish('native GSC held and medicine cures retire party and active sleep counters')
diff --git a/tests/engine/gen2_sleep_turns.lua b/tests/engine/gen2_sleep_turns.lua
new file mode 100644
index 000000000..c18b1ed66
--- /dev/null
+++ b/tests/engine/gen2_sleep_turns.lua
@@ -0,0 +1,171 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- Actual native status records, infliction, action and move-effect owners.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local V=require('src.core.GameVersion')
+local Status=require('src.battle.Status')
+local Registry=require('src.battle.StatusRegistry')
+local Screen=require('src.battle.BattleState')
+local Pokemon=require('src.pokemon.Pokemon')
+local old=V.get()
+local function fixture(edition,id,side)
+ V.set(edition)
+ local data=T.fixtures.fresh();data.constants.gen=V.generation()
+ for key,definition in pairs({
+ SNORE={id='SNORE',name='SNORE',index=173,power=40,type='NORMAL',accuracy=100,pp=15,effect='SNORE_EFFECT'},
+ SLEEP_TALK={id='SLEEP_TALK',name='SLEEP TALK',index=214,power=0,type='NORMAL',accuracy=100,pp=10,effect='SLEEP_TALK_EFFECT'},
+ REST={id='REST',name='REST',index=156,power=0,type='PSYCHIC',accuracy=100,pp=10,effect='HEAL_EFFECT'},
+ })do data.moves[key]=definition end
+ require('src.render.Font').load(data)
+ local save=require('src.core.SaveData').newGame()
+ save.party={Pokemon.new(data,'FIXMON_A',30)}
+ local s=Screen.newWild({data=data,save=save,stack={top=function()end},
+ input={isDown=function()return true end,wasPressed=function()return true end}},'FIXMON_C',30)
+ s.queue,s.current,s.waitFrames={},nil,nil
+ s.rng=function(lo,hi)return hi and lo+math.floor((hi-lo)/2)or lo end
+ local b=s[side or 'player'];local t=s[(side or 'player')=='player'and'enemy'or'player']
+ b.curMoves={{id=id or 'FIX_TACKLE',pp=10},{id='FIX_TACKLE',pp=0}}
+ b.mon.moves=b.curMoves
+ return s,b,t
+end
+local function asleep(b,turns)
+ b.mon.status,b.sleepTurns,b.mon.sleepTurns='SLP',turns,turns
+end
+local function texts(s)
+ local out={};for _,row in ipairs(s.queue)do if row.text then out[#out+1]=row.text end end
+ return table.concat(out,'|')
+end
+for _,edition in ipairs{'gold','silver','crystal'}do
+ for _,side in ipairs{'player','enemy'}do
+ local s,b,t=fixture(edition,'FIX_TACKLE',side)
+ local ppCost=side=='player'and 1 or 0
+ asleep(b,1);b.nightmare,b.expNightmare=true,true
+ local hp,pp=t.mon.hp,b.curMoves[1].pp
+ s:executeAction(b,t,b.curMoves[1])
+ T.eq(b.mon.status,nil,'native wake clears persistent status')
+ T.eq(b.sleepTurns,nil);T.eq(b.mon.sleepTurns,nil)
+ T.eq(b.nightmare,nil);T.eq(b.expNightmare,nil)
+ T.eq(b.curMoves[1].pp,pp-ppCost,'Gen2 acts on the same turn it wakes')
+ T.check(t.mon.hp1 then expectedStatus,expectedTurns='SLP',turns-1 end
+ T.eq(b.mon.status,expectedStatus)
+ T.eq(b.sleepTurns,expectedTurns,'GSC sleep is processed before flinch')
+ T.eq(#msgs,2,'sleep/wake caption then flinch caption')
+ T.check(msgs[2]:find('flinched!',1,true)~=nil)
+ end
+
+ s,b,t=fixture(edition,'FIX_TACKLE',side);asleep(b,3)
+ b.mustRecharge,b.flinched=true,true
+ local rolls=0;s.rng=function()rolls=rolls+1;return 0 end
+ s:executeAction(b,t,{special='recharge'})
+ T.eq(b.mustRecharge,nil,'GSC recharge is consumed before sleep/flinch')
+ T.eq(b.sleepTurns,3);T.eq(b.mon.sleepTurns,3,'recharge does not advance a second sleep clock')
+ T.eq(b.flinched,true,'recharge never consumes the later flinch check')
+ T.eq(rolls,0);T.check(texts(s):find('must recharge!',1,true)~=nil)
+ end
+
+ -- Exercise actual StatusRegistry infliction for every possible ROM byte,
+ -- including zero/seven rejection and the Tower's two-bit mask.
+ for _,tower in ipairs{false,true}do
+ local s,b=fixture(edition);s.inBattleTowerBattle=tower
+ for byte=0,255 do
+ b.mon.status,b.sleepTurns,b.mon.sleepTurns=nil,nil,nil
+ local calls=0
+ s.rng=function(lo,hi)
+ calls=calls+1;T.eq(lo,0);T.eq(hi,255,'native sleep reads a full battle byte')
+ return calls==1 and byte or 1
+ end
+ local msgs=Registry.inflict(s,b,'SLP',{})
+ local value=byte%(tower and 4 or 8)
+ local rejected=value==0 or value==7
+ local expected=rejected and 2 or value+1
+ T.eq(b.sleepTurns,expected,'actual byte/mask/rejection determines sleep duration')
+ T.eq(b.mon.sleepTurns,expected,'party counter is owned by the same native infliction')
+ T.eq(calls,rejected and 2 or 1,'rejected byte consumes another real battle draw')
+ T.eq(#msgs,1)
+ end
+ end
+ local s,b=fixture(edition);s.ruleset={sleepTurnsMin=3,sleepTurnsMax=9}
+ s.rng=function(lo,hi)T.eq(lo,3);T.eq(hi,9);return 9 end
+ Registry.inflict(s,b,'SLP',{});T.eq(b.sleepTurns,9);T.eq(b.mon.sleepTurns,9,'explicit custom sleep rules retain their own range')
+
+ s,b=fixture(edition,'REST');b.mon.hp=math.floor(b.mon.stats.hp/2)
+ s:executeAction(b,s.enemy,b.curMoves[1])
+ T.eq(b.mon.hp,b.mon.stats.hp);T.eq(b.sleepTurns,3);T.eq(b.mon.sleepTurns,3,'Rest initializes two lost turns plus waking turn')
+ for turn=1,2 do
+ local can=Status.beforeMove(b,s.rng,s,{id='FIX_TACKLE'})
+ T.eq(can,false);T.eq(b.sleepTurns,3-turn)
+ end
+ T.eq(Status.beforeMove(b,s.rng,s,{id='FIX_TACKLE'}),true,'Rest wakes and acts on the third subsequent attempt')
+ s,b=fixture(edition);asleep(b,3);Status.beforeMove(b,s.rng,s,b.curMoves[1])
+ local nextBattle=Screen.newWild(s.game,'FIXMON_C',30)
+ T.eq(nextBattle.player.mon,b.mon,'reconstruction retains the actual party Pokemon')
+ T.eq(nextBattle.player.sleepTurns,2,'new native wrapper reads the remaining party sleep counter')
+end
+
+-- Shared native record behavior must remain unchanged outside Gen2.
+for _,edition in ipairs{'red','yellow','emerald','firered'}do
+ local s,b=fixture(edition);asleep(b,1)
+ local allowed=Status.beforeMove(b,s.rng,s,b.curMoves[1])
+ T.eq(allowed,false,'other-generation wake behavior retains the upstream baseline')
+ s,b=fixture(edition,'SNORE');asleep(b,3);b.flinched=true
+ local can=Status.beforeMove(b,s.rng,s,b.curMoves[1])
+ T.eq(can,false)
+ T.eq(b.sleepTurns,3,'other-generation flinch/status order retains the upstream baseline')
+end
+V.set('crystal')
+local s,b=fixture('crystal');asleep(b,3)
+local custom={id='SLP',beforeMovePriority=40,beforeMove=function()
+ return true,{'custom status'},false,'custom-native-result'
+end}
+s.data.statuses={SLP=custom}
+local can,msgs,selfHit,reason=Status.beforeMove(b,s.rng,s,b.curMoves[1])
+T.eq(can,true);T.eq(msgs[1],'custom status');T.eq(selfHit,nil);T.eq(reason,nil)
+T.eq(b.sleepTurns,3,'registered custom status retains its actual handler and counter')
+V.set(old)
+T.finish('native GSC sleep duration, wake/bypass admission, ordering, Rest and persistent counter')
diff --git a/tests/engine/gen2_species_gameplay.lua b/tests/engine/gen2_species_gameplay.lua
new file mode 100644
index 000000000..1a32b90cf
--- /dev/null
+++ b/tests/engine/gen2_species_gameplay.lua
@@ -0,0 +1,75 @@
+-- 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 old=V.get()
+local F=require('tests.gsc_species_fixture')
+local Names=require('src.core.Gen2SpeciesNames')
+local Pokemon=require('src.pokemon.Pokemon')
+local Commands=require('src.script.Commands')
+local VM=require('src.script.Gen2ScriptVM')
+local DC=require('src.pokemon.DayCare')
+for _,edition in ipairs{'gold','silver','crystal'}do
+ V.set(edition);local d=F.new();d:seedDefaults()
+ local function mon(index)return Pokemon.new(d,Names.fromNumber(d,index),5,function(lo)return lo end)end
+ d.map_scripts={scripts={IDENTITY={
+ {'getmonname',155,0},{'checkpoke',155},{'giveegg',175,5},{'cry',155},
+ {'pokepic',155},{'loadwildmon',133,5},{'end'},
+ },GIFT={{'checkpoke',155},{'giveegg',175,5},{'end'}}}}
+ local rows=assert(VM.compile(d,'IDENTITY'));local lowered={}
+ for _,row in ipairs(rows)do lowered[row[1]]=row end
+ for _,op in ipairs{'g2_getmonname','g2_check_poke','g2_cry','g2_pokepic'}do
+ T.eq(lowered[op][2],'CYNDAQUIL',op..' resolves native byte operand through the same dataset')
+ end
+ T.eq(lowered.g2_give_egg[2],'TOGEPI');T.eq(lowered.g2_load_wild[2],'EEVEE')
+ local save={party={mon(155)},boxes={},flags={},player={name='TEST',id=42},
+ pokedex={seen={},owned={}}}
+ local game={data=d,save=save,overworld={}}
+ local ctx={game=game,save=save,overworld=game.overworld,g2Var=155}
+ Commands.g2_check_poke(ctx,'SPECIES_155');T.eq(ctx.g2Var,1)
+ ctx.g2Var=155;Commands.g2_find_party_species_own(ctx);T.eq(ctx.g2Var,1)
+ ctx.g2Var=155;Commands.g2_seen_mon(ctx);T.eq(save.pokedex.seen.CYNDAQUIL,true)
+ T.eq(save.pokedex.seen.SPECIES_155,nil)
+ Commands.g2_give_poke(ctx,133,5,0)
+ T.eq(#save.party,2);T.eq(save.party[2].species,'EEVEE')
+ -- Runtime wildcard/gift/check bytecode executes with actual native rows,
+ -- not a second simulated VM or a facade replacing the party Pokemon.
+ local Runner=require('src.script.ScriptRunner')
+ local runner=Runner.new(game,game.overworld)
+ runner:run(VM.compile(d,'GIFT'))
+ T.eq(runner:isRunning(),false,'real noninteractive gift program finishes')
+ T.eq(save.party[1].species,'CYNDAQUIL')
+ local found
+ for _,m in ipairs(save.party)do if m.isEgg and m.species=='TOGEPI'then found=m end end
+ T.check(found~=nil,'real native lowered giveegg creates the named hatchling')
+ if found then
+ require('src.pokemon.Gen2Breeding').hatch(game,found)
+ T.eq(save.flags[require('src.script.Gen2Flags').eventFlag(0x54)],true)
+ T.eq(save.pokedex.owned.TOGEPI,true)
+ end
+ -- A genderless Ditto must breed with a male of another egg group and
+ -- return that parent's actual canonical species in either slot order.
+ d.pokemon.DITTO.eggGroups={'DITTO'};d.pokemon.DITTO.genderRatio=255
+ d.pokemon.CYNDAQUIL.eggGroups={'FIELD'}
+ local ditto,other=mon(132),mon(155)
+ other.dvs.attack=15;other.dvs.defense=3;ditto.dvs.defense=6
+ for _,parents in ipairs{{ditto,other},{other,ditto}}do
+ local s={daycare={breed={{mon=parents[1]},{mon=parents[2]}}}}
+ local a,b=DC.pair(d,s);T.eq(a,parents[1]);T.eq(b,parents[2])
+ T.eq(DC.eggSpecies(d,s),'CYNDAQUIL')
+ end
+ local s={daycare={breed={{mon=ditto},{mon=mon(132)}}}}
+ T.eq(DC.pair(d,s),nil,'two canonical Dittos cannot breed')
+ local nidoran=mon(29)
+ s={daycare={breed={{mon=ditto},{mon=nidoran}}}}
+ local random=math.random
+ math.random=function()return 127 end;T.eq(DC.eggSpecies(d,s),'NIDORAN_F')
+ math.random=function()return 128 end;T.eq(DC.eggSpecies(d,s),'NIDORAN_M')
+ math.random=random
+ -- Roaming encounters, once staged, also expose the canonical identity.
+ Commands.g2_init_roam_mons(ctx)
+ T.eq(save.g2Roam.RAIKOU.speciesId,'RAIKOU')
+ T.eq(save.g2Roam.ENTEI.speciesId,'ENTEI')
+
+end
+V.set(old)
+T.finish('native canonical GSC script operands, gifts, breeding and hatch ownership')
diff --git a/tests/engine/gen2_species_runtime.lua b/tests/engine/gen2_species_runtime.lua
new file mode 100644
index 000000000..73a13efd8
--- /dev/null
+++ b/tests/engine/gen2_species_runtime.lua
@@ -0,0 +1,123 @@
+-- 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 old=V.get()
+local Names=require('src.core.Gen2SpeciesNames')
+local F=require('tests.gsc_species_fixture')
+local Pokemon=require('src.pokemon.Pokemon')
+local Save=require('src.core.SaveData')
+local Merge=require('src.mods.Merge')
+local Registry=require('src.mods.Registry')
+local function count(t)local n=0;for _ in pairs(t)do n=n+1 end;return n end
+for _,edition in ipairs{'gold','silver','crystal'}do
+ V.set(edition)
+ local d=F.new();local p=d.pokemon;local cyndaquil=p.SPECIES_155
+ local order=d.constants.speciesOrder
+ p.SPECIES_155.evolutions={{method='LEVEL',level=14,species='SPECIES_156'}}
+ d.encounters.TEST={slots={{species='SPECIES_155',level=5}}}
+ d.identityTest={SPECIES_155={species='SPECIES_155'}}
+ d.text.IDENTITY_LITERAL='SPECIES_155'
+ d.field.identityTest={name='SPECIES_155',source='SPECIES_155',path='SPECIES_155',species='SPECIES_155'}
+ local entry=d.identityTest.SPECIES_155
+ -- Exercise the real pre-loader publication pass, including its second
+ -- post-mod invocation. Existing tables/art and numeric indices survive.
+ d:seedDefaults()
+ T.eq(d.constants.speciesIdentity,'gsc-canonical-v1')
+ T.eq(d.pokemon,p);T.eq(d.pokemon.CYNDAQUIL,cyndaquil)
+ T.eq(d.pokemon.SPECIES_155,cyndaquil);T.eq(rawget(p,'SPECIES_155'),nil)
+ T.eq(count(p),251,'exactly one iterated definition per native species')
+ T.eq(cyndaquil.id,'CYNDAQUIL');T.eq(cyndaquil.index,155);T.eq(cyndaquil.dex,155)
+ T.eq(cyndaquil.name,'Localized display name');T.eq(cyndaquil.source,'ROM:SPECIES_155')
+ T.eq(cyndaquil.spriteFront,'tests/fixture_data/assets/fixmon_a_front.png')
+ T.eq(cyndaquil.evolutions[1].species,'QUILAVA')
+ T.eq(d.constants.speciesOrder,order);T.eq(order[155],'CYNDAQUIL')
+ T.eq(d.encounters.TEST.slots[1].species,'CYNDAQUIL')
+ T.eq(d.identityTest.CYNDAQUIL,entry);T.eq(entry.species,'CYNDAQUIL')
+ T.eq(d.text.IDENTITY_LITERAL,'SPECIES_155','literal text never rewritten')
+ for _,key in ipairs{'name','source','path'}do T.eq(d.field.identityTest[key],'SPECIES_155')end
+ T.eq(d.field.identityTest.species,'CYNDAQUIL')
+ T.eq(p.FARFETCHD,p.FARFETCH_D);T.eq(p.MR_MIME,p.MR__MIME)
+ d:seedDefaults();T.eq(count(p),251);T.eq(p.CYNDAQUIL,cyndaquil)
+ for index=1,251 do
+ local id=Names.fromNumber(d,index)
+ T.eq(p[id].id,id);T.eq(Names.number(d,id),index)
+ T.eq(Names.registryId(p,string.format('SPECIES_%03d',index)),id)
+ local mon=Pokemon.new(d,string.format('SPECIES_%03d',index),5,function(lo)return lo end)
+ T.eq(mon.species,id,'actual native constructor exposes stable named identity')
+ end
+ local made=Pokemon.new(d,'CYNDAQUIL',5,function(lo)return lo end)
+ T.eq(made.species,'CYNDAQUIL')
+ -- Old and named addresses must share the real registry's edit/conflict/
+ -- rollback/removal path, with no duplicate alias definitions after merge.
+ local r=Registry.new('pokemon',require('src.mods.Schemas').REGISTRIES.pokemon)
+ r.base=function()return p end
+ r:patch('SPECIES_155',{baseExp=17},'old')
+ r:patch('CYNDAQUIL',{baseStats={hp=73}},'new')
+ T.eq(r:get('SPECIES_155'),r:get('CYNDAQUIL'));T.eq(#r:claimants('CYNDAQUIL'),2)
+ r:rollback('old');Names.write(p,r)
+ T.eq(p.CYNDAQUIL.baseStats.hp,73);T.eq(p.CYNDAQUIL.baseExp,cyndaquil.baseExp)
+ r=Registry.new('pokemon',require('src.mods.Schemas').REGISTRIES.pokemon);r.base=function()return p end
+ local replacement=Merge.deepCopy(cyndaquil);replacement.id='SPECIES_155'
+ r:override('SPECIES_155',replacement,'replace');Names.write(p,r)
+ T.eq(p.CYNDAQUIL.id,'CYNDAQUIL');T.eq(count(p),251)
+ -- Validate a real save codec round trip: party, sparse PC, both day-care
+ -- parents, pending egg, Hall of Fame, dex flags and roaming species.
+ local function legacy(index)
+ local mon=Pokemon.new(d,Names.fromNumber(d,index),5,function(lo)return lo end)
+ mon.species=string.format('SPECIES_%03d',index)
+ mon.hp=3;mon.moves[1].pp=2;mon.nickname='SPECIES_155'
+ return mon
+ end
+ local s={party={legacy(155)},boxes={{[20]=legacy(133)}},
+ daycare={mon=legacy(25),breed={{mon=legacy(132)},{mon=legacy(29)},egg=legacy(175)}},
+ player={name='TEST',id=17,map='FIX_TOWN',x=1,y=1},flags={},
+ hallOfFame={{{species='SPECIES_155',level=5}}},
+ g2SecondMonBackup={legacy(152)},g2BugContest={party={legacy(158)}},
+ g2PokeonlySpecies='SPECIES_155',g2Roam={RAIKOU={speciesId='SPECIES_243',hp=7}},
+ pokedex={seen={SPECIES_155=true,CYNDAQUIL=false},owned={SPECIES_133=true}}}
+ local encoded=Save.encode(s);local restored=assert(Save.decode(encoded))
+ local report=Save.validate(restored,d)
+ T.eq(#report.lostMons,0);T.eq(#report.lostItems,0)
+ T.eq(restored.party[1].species,'CYNDAQUIL');T.eq(restored.boxes[1][20].species,'EEVEE')
+ T.eq(restored.daycare.mon.species,'PIKACHU')
+ T.eq(restored.daycare.breed[1].mon.species,'DITTO')
+ T.eq(restored.daycare.breed[2].mon.species,'NIDORAN_F')
+ T.eq(restored.daycare.breed.egg.species,'TOGEPI')
+ T.eq(restored.hallOfFame[1][1].species,'CYNDAQUIL')
+ T.eq(restored.g2SecondMonBackup[1].species,'CHIKORITA')
+ T.eq(restored.g2BugContest.party[1].species,'TOTODILE')
+ T.eq(restored.g2Roam.RAIKOU.speciesId,'RAIKOU');T.eq(restored.g2Roam.RAIKOU.hp,7)
+ T.eq(restored.g2PokeonlySpecies,'CYNDAQUIL')
+ T.eq(restored.pokedex.seen.CYNDAQUIL,true);T.eq(restored.pokedex.seen.SPECIES_155,nil)
+ T.eq(restored.pokedex.owned.EEVEE,true)
+ T.eq(restored.party[1].hp,3);T.eq(restored.party[1].moves[1].pp,2)
+ T.eq(restored.party[1].nickname,'SPECIES_155','nicknames are literal user data')
+ T.eq(s.party[1].species,'SPECIES_155','decode/validate never mutates source save')
+ local cw=require('src.save_convert.Gen2Save').crosswalks(d)
+ T.eq(cw.pokemonByIndex[155],'CYNDAQUIL');T.eq(cw.pokemonIndex.CYNDAQUIL,155)
+ -- Legacy removal cannot leave the old alias serving a cached definition.
+ r=Registry.new('pokemon',require('src.mods.Schemas').REGISTRIES.pokemon);r.base=function()return p end
+ r:remove('SPECIES_155','remove');Names.write(p,r)
+ T.eq(p.CYNDAQUIL,nil);T.eq(p.SPECIES_155,nil)
+ d:seedDefaults();T.eq(p.CYNDAQUIL,nil)
+end
+V.set('crystal')
+for _,kind in ipairs{'incomplete','different-index','authored','collision','alias-collision','lookup'}do
+ local d=F.new();local p=d.pokemon;local mt
+ if kind=='incomplete'then p.SPECIES_251=nil
+ elseif kind=='different-index'then p.SPECIES_155.dex=1
+ elseif kind=='authored'then p.CYNDAQUIL={id='CYNDAQUIL'}
+ elseif kind=='collision'then d.extra={SPECIES_155=1,CYNDAQUIL=2}
+ elseif kind=='alias-collision'then d.extra={SPECIES_083=1,FARFETCH_D=2}
+ else mt={__index=function()return 'authored'end};setmetatable(p,mt)end
+ T.eq(Names.canonicalize(d),false,kind..' is rejected atomically')
+ T.eq(p.SPECIES_155.id,'SPECIES_155');T.eq(d.constants.speciesOrder[155],'SPECIES_155')
+ T.eq(getmetatable(p),mt)
+end
+for _,edition in ipairs{'red','blue','yellow','firered','emerald','prism','polishedcrystal'}do
+ V.set(edition);local d=F.new()
+ T.eq(Names.canonicalize(d),false,edition..' own address space retained')
+ T.eq(d.pokemon.SPECIES_155.id,'SPECIES_155')
+end
+V.set(old)
+T.finish('native GSC canonical runtime graph, registry edits and legacy save round trips')
diff --git a/tests/engine/gen2_stat_refresh_data.lua b/tests/engine/gen2_stat_refresh_data.lua
new file mode 100644
index 000000000..99fbd1714
--- /dev/null
+++ b/tests/engine/gen2_stat_refresh_data.lua
@@ -0,0 +1,80 @@
+-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.harness')
+T.eq(_G.love,nil,'GSC formula needs no rendering runtime')
+for _,name in ipairs{'src.battle.BattleState','src.pokemon.Sprites','src.render.Assets'}do
+ package.preload[name]=function()error('stat calculation imported presentation: '..name)end
+end
+local Stats=require('src.pokemon.Stats')
+local Mon=require('src.compat.gen2.Mon')
+T.check(type(Mon.stats)=='function'and type(Mon.hpDV)=='function','actual source formula entry points exist')
+if type(Mon.stats)~='function'or type(Mon.hpDV)~='function' then T.finish('source stat formula APIs are absent')end
+-- Independent integer search for the cartridge ceiling-square-root loop.
+local function bonus(n)
+ local root=0
+ while root<255 and root*root queue -> VM -> picture/BG observations.
+-- Programs are generated fixtures, not cartridge data or modified mods.
+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 Catalog=require('src.battle.Gen2AnimCatalog')
+local previous=Version.get()
+local function make(edition,format)
+ Version.set(edition)
+ local data=T.fixtures.fresh();data.constants.gen=Version.generation()
+ for _,id in ipairs{'FLY','DIG','SOLARBEAM','PROBE'}do
+ data.moves[id]={id=id,name=id,type='NORMAL',power=1,accuracy=100,accuracyRaw=255,pp=10,
+ effect=id=='PROBE'and 'NO_ADDITIONAL_EFFECT' or id=='SOLARBEAM'and 'CHARGE_EFFECT' or 'FLY_EFFECT'}
+ end
+ local main={entry=1,code={
+ {op='ifparamequal',args={1},to=7},
+ {op='delay',frames=3},
+ {op='bgeffect',args={'BATTLE_BG_EFFECT_SHOW_MON',0,1,0}},
+ {op='delay',frames=8},{op='ret'},{op='ret'},
+ {op='delay',frames=3},
+ {op='bgeffect',args={'BATTLE_BG_EFFECT_REMOVE_MON',0,1,0}},
+ {op='delay',frames=45},{op='ret'},
+ }}
+ local probe={entry=1,code={
+ {op='bgeffect',args={'BATTLE_BG_EFFECT_SHOW_MON',0,0,0}},
+ {op='bgeffect',args={'BATTLE_BG_EFFECT_BATTLEROBJ_1ROW',0,0,0}},
+ {op='delay',frames=8},{op='ret'},
+ }}
+ local anims={gen2=true,moveAnims={FLY=1,DIG=1,SOLARBEAM=1,PROBE=2},scripts={[1]=main,[2]=probe},
+ objects={},framesets={},oam={},gfx={}}
+ anims.functions={[0]='Null'}
+ anims.framesets[0]={frames={{oam=0,duration=1}},loop='restart'}
+ anims.oam[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}
+ for index,name in ipairs(require('src.battle.Gen2AnimObjectIds').battleAnimObjectOrder)do
+ if name=='BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW' or name=='BATTLE_ANIM_OBJ_ENEMYFEET_1ROW'then
+ anims.objects[index-1]={oamFlags=0,fixY=255,fn=0,frameset=0,palette=0,gfx=0}
+ end
+ end
+ if format=='native'then
+ anims=Catalog.compile(anims,nil,edition)
+ anims.gen2,anims.format,anims.moveAnims,anims.indices,anims.misc=nil,nil,nil,nil,nil
+ end
+ data.battle_anims=anims
+ require('src.render.Font').load(data)
+ local save=Save.newGame();save.options.textSpeed=1
+ save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',18)}
+ local s=Battle.newWild({data=data,save=save,stack={top=function()end},
+ input={wasPressed=function()return true end,isDown=function()return true 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.rngCalls=0;s.rng=function()s.rngCalls=s.rngCalls+1;return 0 end
+ return s
+end
+local function untilState(s,predicate,label)
+ local frames=0
+ while not predicate() and frames<1500 do s:updateQueue();frames=frames+1 end
+ T.check(frames<1500,label)
+ return frames
+end
+local function drained(s)
+ return #s.queue==0 and not s.current and not s.animPlaying and (s.waitFrames or 0)<=0
+ and not s.draining
+end
+local function finish(s)untilState(s,function()return drained(s)end,'native queue and VM release')end
+local function pictureHidden(s,side)
+ local state=s:animPicState(side);return state and state.hidden or false
+end
+local function underground(s,side,mon)
+ return s.isUnderground and s:isUnderground(side,mon) or false
+end
+local function release(s,id,side,move)
+ local p=s._gen2VanishPictures and s._gen2VanishPictures[side]
+ s:act(function()
+ s:performMove(s[side],s[side=='player'and'enemy'or'player'],move)
+ if id~='SOLARBEAM'then
+ T.eq(p and p.phase,'release','actual release retains the preceding cleared box')
+ T.eq(p and p.row,s.moveAnimRow,'release latch binds the real native row')
+ end
+ end)
+ untilState(s,function()return s.animPlaying end,'actual release program starts')
+ if id~='SOLARBEAM'then
+ T.eq(s._gen2VanishPictures[side],p,'release admission retains its original picture latch')
+ T.eq(p and p.runner,s.animPlayer.runtime,'release admission binds its actual VM')
+ end
+ T.eq(s.animPlayer.moveId,id,'release uses the actual move')
+ T.eq(s.animPlayer.vars.param,0,'release uses cartridge param zero')
+end
+for _,edition in ipairs{'gold','silver','crystal'}do
+ for _,format in ipairs{'flat','native'}do for _,side in ipairs{'player','enemy'}do
+ for _,id in ipairs{'FLY','DIG','SOLARBEAM'}do
+ local s=make(edition,format)
+ local b,target=s[side],s[side=='player'and'enemy'or'player']
+ local move={id=id,pp=10};b.curMoves={move}
+ local expectedPP=not b.isPlayer and s.kind~='link' and s.ruleset
+ and s.ruleset.enemyUnlimitedPP and 10 or 9
+ local hp=b.mon.hp
+ s:performMove(b,target,move)
+ T.eq(b.charging,move,'native first charge retains the original instance')
+ T.eq(move.pp,expectedPP,'first charge keeps the native side PP policy')
+ T.eq(b.invulnerable==true,id~='SOLARBEAM','only real Fly/Dig charge hides the battler')
+ local rows={};for _,row in ipairs(s.queue)do if row.anim then rows[#rows+1]=row end end
+ T.eq(#rows,1,'charge has one animation, no legacy extra clock')
+ T.eq(rows[1].anim,id,'charge uses its own ROM program, never a Gen1 substitute')
+ T.eq(rows[1].animParam,1,'first charge selects the real param-1 arm')
+ untilState(s,function()return s.animPlaying end,'actual queued charge program starts')
+ T.eq(s.animPlayer.moveId,id,'native queue starts the move itself')
+ T.eq(s.animPlayer.vars.param,1,'actual interpreter sees charge parameter')
+ T.eq(underground(s,side),false,'takeoff/burrow still starts with a visible battler')
+ T.eq(s.animPlayer.runtime.env.flying[side],false,'first charge SHOW_MON can still use the battler')
+ local r=s.anim
+ finish(s)
+ local vanished=id~='SOLARBEAM'
+ T.eq(underground(s,side),vanished,'picture disappears only after native charge animation')
+ T.eq(pictureHidden(s,side),vanished,'actual public picture state follows the native latch')
+ local calls=s.rngCalls
+ for _=1,3 do underground(s,side);s:animPicState(side);if s.pic then s:pic(b.mon,false)end end
+ T.eq(s.rngCalls,calls,'picture reads never consume battle RNG')
+ T.eq(b.mon.hp,hp,'charge/picture reads retain native HP')
+ T.eq(move.pp,expectedPP,'picture reads never spend additional PP')
+ T.eq(r.env.flying[side],false,'retained first runner keeps its admission snapshot')
+ release(s,id,side,move)
+ T.eq(b.charging,nil,'native release clears its original charge instance')
+ T.eq(b.invulnerable,nil,'native release clears invulnerability before animation')
+ T.eq(move.pp,expectedPP,'native second turn spends no extra PP')
+ T.eq(pictureHidden(s,side),vanished,'release stays absent during pre-SHOW_MON wait')
+ T.eq(s.animPlayer.runtime.env.flying[side],false,'release SHOW_MON is admitted after real flag clears')
+ local current=s.anim
+ untilState(s,function()return current.bg.picSize[side]~=nil end,'real native SHOW_MON executes')
+ T.eq(pictureHidden(s,side),false,'SHOW_MON reveals the same native battler')
+ T.eq(current.frames,5,'reveal uses the VM wait, no invented presentation clock')
+ finish(s)
+ T.eq(underground(s,side),false,'release retires cleared picture latch')
+ T.eq(next(s._gen2VanishPictures or {}),nil,'completed native release retains no stale battler owner')
+ s.animPlayer:release()
+ end
+ -- A move aimed at a genuinely vanished target must not copy its battler
+ -- rows into OBJ or reveal the target's BG box (both read the same flags).
+ local s=make(edition,format)
+ local b=s[side];local other=side=='player'and'enemy'or'player'
+ local move={id='DIG',pp=10};b.curMoves={move}
+ s:performMove(b,s[other],move);finish(s)
+ T.check(underground(s,side,b.mon),'optional source mon resolves actual active slot')
+ T.eq(underground(s,side,{}),false,'an unrelated mon cannot borrow active volatile state')
+ T.check(s:animForMove('PROBE',other),'actual opposing native move starts')
+ local r=s.anim
+ T.eq(r.env.flying[side],true,'the real opposing VM sees the hidden target')
+ s:updateQueue();s:updateQueue();s:updateQueue()
+ T.eq(r.bg.picSize[side],nil,'CheckFlyDigStatus suppresses actual SHOW_MON')
+ T.eq(r.bg.liftedRows[side],nil,'hidden target contributes no lifted BG rows')
+ T.eq(r.objects:activeCount(),0,'hidden target contributes no battler-row OBJ')
+ finish(s)
+ b.invulnerable=nil;b.hiddenAs=nil;s:updateQueue()
+ T.eq(underground(s,side),false,'native cancellation removes visibility flag')
+ T.eq(r.env.flying[side],true,'retained VM snapshot never follows future combat flags')
+ T.check(s:animForMove('PROBE',other),'next opposing program has a fresh native runner')
+ T.eq(s.anim.env.flying[side],false,'fresh runner reads the cleared actual flag')
+ s:updateQueue();s:updateQueue()
+ T.check(s.anim.bg.picSize[side]~=nil,'on-field target admits actual SHOW_MON')
+ T.check(s.anim.objects:activeCount()>0,'on-field target admits real battler OBJ')
+ T.eq(r.env.flying[side],true,'starting next runner cannot rebind retained runner')
+ s.animPlayer:release()
+ end end
+end
+-- Disabled/missing programs, skipped/cancelled rows and replacement/faint
+-- release only their own picture state; the move's combat result is native.
+for _,mode in ipairs{'disabled','missing','suppressed','cancelled','switch','faint','replaced','missed-release'}do
+ local s=make('crystal','native');local b=s.player;local move={id='DIG',pp=10};b.curMoves={move}
+ if mode=='disabled'then s.game.save.options.animations=false end
+ if mode=='missing'then s.data.battle_anims.scripts={}end
+ s:performMove(b,s.enemy,move,false,mode=='suppressed')
+ if mode=='cancelled'then s:cancelMoveAnim()end
+ if mode=='replaced'then
+ untilState(s,function()return s.animPlaying end,'charge to replace starts')
+ s:animForMove('PROBE','enemy')
+ end
+ finish(s)
+ T.eq(underground(s,'player'),mode~='cancelled',mode..' follows its actual charge admission')
+ if mode=='switch'then
+ s:switchPlayerInto(Battle.POS.PLAYER_LEFT,s.game.save.party[2]);s:updateQueue()
+ T.eq(underground(s,'player'),false,'incoming battler never inherits old cleared box')
+ T.eq(next(s._gen2VanishPictures or {}),nil,'actual switch releases old native picture owner')
+ elseif mode=='faint'then
+ b.mon.hp=0;b.fainted=true;s:updateQueue()
+ T.eq(next(s._gen2VanishPictures or {}),nil,'actual faint releases its cleared picture owner')
+ else
+ if mode=='disabled'then s.game.save.options.animations=true end
+ if mode=='missed-release'then s.data.moves.DIG.accuracyRaw=0 end
+ s:act(function()s:performMove(b,s.enemy,move)end);finish(s)
+ T.eq(underground(s,'player'),false,mode..' releases the hidden box after the actual second turn')
+ T.eq(next(s._gen2VanishPictures or {}),nil,mode..' retains no stale presentation owner')
+ end
+ T.eq(move.pp,9,mode..' presentation preserves native PP continuation')
+ s.animPlayer:release()
+end
+-- Existing non-GSC charge animation selection is unchanged.
+for _,edition in ipairs{'red','firered','emerald'}do
+ local s=make(edition,'flat');local move={id='DIG',pp=10}
+ s:performMove(s.player,s.enemy,move)
+ local animation;for _,row in ipairs(s.queue)do if row.anim then animation=row.anim end end
+ if edition=='red'then T.eq(animation,'SLIDE_DOWN_ANIM','Gen1 keeps original charge animation')end
+ T.eq(s._gen2VanishPictures,nil,edition..' never creates GSC presentation state')
+end
+Version.set(previous)
+T.finish('native GSC Fly/Dig charge, release, VM status snapshots and picture ownership')
diff --git a/tests/engine/gen2_volatile_state.lua b/tests/engine/gen2_volatile_state.lua
new file mode 100644
index 000000000..a498fb18b
--- /dev/null
+++ b/tests/engine/gen2_volatile_state.lua
@@ -0,0 +1,133 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- Source GSC volatile names must reach the actual native status/damage owner,
+-- not a disconnected metadata table. All monsters below are ROM-free fixtures.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local V=require('src.core.GameVersion')
+local Screen=require('src.battle.BattleState')
+local Model=require('src.battle.gen2.Battle')
+local Pokemon=require('src.pokemon.Pokemon')
+local Save=require('src.core.SaveData')
+local Damage=require('src.battle.Damage')
+local oldVersion=V.get()
+local function fixture(version,chosen,effect)
+ V.set(version)
+ local data=T.fixtures.fresh();data.constants.gen=2;data.constants.generation=2
+ for _,id in ipairs{'PROTECT','QA_GUARD'}do
+ data.moves[id]={id=id,name=id,effect=effect or'PROTECT_EFFECT',power=0,type='NORMAL',accuracy=100,pp=10}
+ end
+ local run=T.sdk.loadMods({'mods/volatile_probe'},{data=data,fs=T.sdk.memfs({
+ ['mods/volatile_probe/manifest.json']='{"id":"volatile_probe","name":"Owned state probe","version":"1.0.0","api":2,"entry":"main.lua"}',
+ ['mods/volatile_probe/main.lua']=[[
+ local mod=...
+ local Model=require('src.battle.gen2.Battle')
+ mod.exports.write=function(model,mon,key,value)Model.volatile(model,mon)[key]=value end
+ mod.exports.read=function(model,mon,key)return Model.volatile(model,mon)[key]end
+ ]],
+ })})
+ T.eq(#run.errors,0,'actual API2 source consumer loads')
+ require('src.render.Font').load(data)
+ local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',30)}
+ if chosen then save.party[1].moves={{id=chosen,pp=10}}end
+ local game={data=data,save=save,stack={top=function()end}}
+ local b=Screen.newWild(game,'FIXMON_C',30)
+ local rolls=0;b.rng=function(lo,hi)rolls=rolls+1;return hi or lo end
+ return b,run,run.loader.exports.volatile_probe,function()return rolls end
+end
+local fields={
+ {'flinched','flinched',true}, {'confuseCount','confusedTurns',3},
+ {'endure','enduring',true}, {'recharge','mustRecharge',true},
+ {'protectCount','protectRun',2}, {'focusEnergy','focusEnergy',true},
+ {'xAccuracy','xAccuracy',true}, {'substitute','substituteHP',17},
+ {'protect','protecting',true}, {'mist','mist',true},
+}
+for _,version in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do
+ local b,run,probe,rolls=fixture(version)
+ local model,mon=b.battle,b.player.mon
+ local view=model:volatile(mon)
+ T.eq(view,model:volatile(mon),'one stable source view for the same active battler')
+ T.eq(view,model:volatile(b.player),'party and native-wrapper queries share the owner')
+ for _,row in ipairs(fields)do
+ probe.write(model,mon,row[1],row[3])
+ T.eq(b.player[row[2]],row[3],version..' source write reaches native '..row[2])
+ b.player[row[2]]=nil
+ T.eq(probe.read(model,mon,row[1]),nil,'source read sees native clear: '..row[1])
+ b.player[row[2]]=row[3]
+ T.eq(probe.read(model,mon,row[1]),row[3],'native state reaches source read: '..row[1])
+ probe.write(model,mon,row[1],nil)
+ T.eq(b.player[row[2]],nil,'source clear retires native state: '..row[1])
+ T.eq(mon[row[1]],nil,'transient source field is not written into party/save')
+ end
+ probe.write(model,mon,'flinched',true)
+ local pp=mon.moves[1].pp
+ T.eq(b:statusInterrupt(b.player,b.enemy,{id='FIX_TACKLE'}),true,'source flinch actually blocks native admission')
+ T.eq(not not probe.read(model,mon,'flinched'),false,'native admission clears source flinch')
+ T.eq(rolls(),0,'flinch does not consume battle RNG')
+ T.eq(mon.moves[1].pp,pp,'blocked admission cannot spend PP')
+ probe.write(model,mon,'confuseCount',2)
+ T.eq(b:statusInterrupt(b.player,b.enemy,{id='FIX_TACKLE'}),false,'high native roll allows a confused attack')
+ T.eq(b.player.confusedTurns,1,'actual native confusion decrements source counter')
+ T.eq(probe.read(model,mon,'confuseCount'),1,'source sees the actual remaining counter')
+ T.eq(rolls(),1,'confusion consumes exactly one actual byte roll')
+ probe.write(model,mon,'confuseCount',nil)
+ probe.write(model,mon,'recharge',true)
+ local locked=b:lockedAction(b.player)
+ T.eq(locked and locked.special,'recharge','source recharge reaches the real move lock')
+ probe.write(model,mon,'recharge',nil)
+ local hp=mon.hp
+ probe.write(model,mon,'endure',true)
+ b:applyDamage(b.player,100000,true)
+ T.eq(mon.hp,1,'source Endure caps actual incoming damage at one HP')
+ mon.hp=hp
+ b:applyDamage(b.player,100000,false)
+ T.eq(mon.hp,0,'Endure does not absorb native residual damage')
+ probe.write(model,mon,'endure',nil);mon.hp=hp
+ local low={id='QA_LOW_ACCURACY',accuracy=1,type='NORMAL'}
+ local function accuracy()return Damage.accuracyRoll({oneIn256Miss=true},low,b.player,b.enemy,function()return 255 end,nil,b.data)end
+ T.eq(accuracy(),false,'low accuracy misses before source override')
+ probe.write(model,mon,'xAccuracy',true);T.eq(accuracy(),true,'source X Accuracy bypasses the real hit check')
+ probe.write(model,mon,'xAccuracy',nil)
+ local function critical()return Damage.critRoll({},b.player,'FIX_TACKLE',function()return 20 end,false,b.data,b)end
+ T.eq(critical(),false,'fixed byte is outside the native base critical stage')
+ probe.write(model,mon,'focusEnergy',true);T.eq(critical(),true,'source Focus Energy reaches native GSC critical stage')
+ probe.write(model,mon,'focusEnergy',nil)
+ local marker={owned=true};view._qaPresentation=marker
+ collectgarbage('collect');T.eq(model:volatile(mon)._qaPresentation,marker,'consumer metadata persists while active')
+ local old=b.player
+ b:switchPlayerInto(Screen.POS.PLAYER_LEFT,b.game.save.party[2])
+ local nextView=model:volatile(model.player)
+ T.check(nextView~=view,'new native wrapper has a new volatile owner')
+ T.eq(nextView._qaPresentation,nil,'source metadata does not leak to incoming actor')
+ probe.write(model,model.player,'confuseCount',4)
+ T.eq(b.player.confusedTurns,4,'incoming native battler owns the new source state')
+ T.eq(old.confusedTurns,nil,'new state cannot change the withdrawn native wrapper')
+ T.eq(mon.volatile,nil,'source view cannot create persistent party volatile data')
+ run.release()
+ for _,chosen in ipairs{'PROTECT','QA_GUARD'}do
+ local battle,owner,source=fixture(version,chosen)
+ source.write(battle.battle,battle.player.mon,'protectCount',2)
+ battle:performMove(battle.player,battle.enemy,battle.player.curMoves[1],false,true)
+ T.eq(battle.player.protecting,nil,'native repeated '..chosen..' fails its real odds roll')
+ T.eq(source.read(battle.battle,battle.player.mon,'protectCount'),0,'native failed protection clears source counter')
+ owner.release()
+ end
+ local battle,owner,source=fixture(version,'PROTECT','NO_ADDITIONAL_EFFECT')
+ source.write(battle.battle,battle.player.mon,'protectCount',2)
+ battle:performMove(battle.player,battle.enemy,battle.player.curMoves[1],false,true)
+ T.eq(source.read(battle.battle,battle.player.mon,'protectCount'),nil,'non-protection effect resets run even with old PROTECT ID')
+ owner.release()
+end
+-- A module-global weak-key map whose value closure retains its key is not
+-- collectible on LuaJIT's Lua5.1 collector. No caller-held view remains here.
+local weak=setmetatable({},{__mode='k'})
+local function retired()
+ local mon={};local b={mon=mon};local screen={player=b};local model={}
+ Model.bind(model,screen);Model.volatile(model,mon)._owned=true
+ T.eq(screen.player,b,'owner stays live during its actual view creation')
+ weak[b]=true
+end
+for _=1,12 do retired()end
+collectgarbage('collect');collectgarbage('collect')
+T.eq(next(weak),nil,'module cache cannot retain retired battlers through view closures')
+V.set(oldVersion)
+T.finish('native source volatile state, real mechanics, switch and cache lifetime')
diff --git a/tests/engine/generated_data_literal.lua b/tests/engine/generated_data_literal.lua
new file mode 100644
index 000000000..e23b1c5ec
--- /dev/null
+++ b/tests/engine/generated_data_literal.lua
@@ -0,0 +1,55 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local D = require("src.import.DataLiteral")
+local Writer = require("src.import.LuaWriter")
+local Serializer = require("src.core.SaveSerializer")
+local data = { a = { 1, 2, false, "hello\n\0\255", { quoted = "a'\"b" } }, [0] = 17, bytes = "\1\127", map = {
+ name = "MEW",
+} }
+for _, encode in ipairs({ Writer.encode, Serializer.encode }) do
+ local value, why = D.decode(encode(data))
+ T.check(value ~= nil, "native writer accepted: " .. tostring(why))
+ T.same(value, data, "native literal roundtrip")
+end
+local value, why = D.decode(
+ "-- heading\nreturn {a='x', b=[=[\nlong]=], --[=[block]=]\n c={true,false,nil}, [false]=0x10, n=-2.5e2};-- tail"
+)
+T.check(value ~= nil, "arrays/comments/long strings/hex: " .. tostring(why))
+T.eq(value and value.b, "long", "long string newline convention")
+T.eq(value and value.n, -250, "finite exponent")
+T.eq(value and value[false], 16, "literal boolean key")
+for _, text in ipairs({
+ "return 1",
+ "return {} extra",
+ "return {a=1+2}",
+ "return os.execute('x')",
+ "return {a=1e999}",
+ "return {a=0/0}",
+ "return {a='\\999'}",
+ "return {a='\\x0g'}",
+ "return {a='\\q'}",
+ "return {a='bad\n'}",
+ "return {a=1,a=2}",
+ "return {[{}]=1}",
+ "return {a=}",
+ "return {",
+ "--[[unterminated",
+ "return {[=[bad}",
+ "return {function() while true do end end}",
+ "return {a=1}; while true do end",
+}) do
+ local result, reason = D.decode(text)
+ T.eq(result, nil, "invalid/executable literal refused")
+ T.check(type(reason) == "string", "invalid literal has reason")
+end
+local limits = { bytes = 1000, depth = 2, nodes = 10, string = 4, entries = 3 }
+for _, text in ipairs({
+ string.rep(" ", 1001),
+ "return {{{}}}",
+ "return {'longstring'}",
+ "return {a=1,b=2,c=3,d=4}",
+ "return {{1,2,3},{4,5,6}}",
+}) do
+ T.eq(D.decode(text, limits), nil, "literal parser resource bound")
+end
+T.finish()
diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua
new file mode 100644
index 000000000..5d4562cd8
--- /dev/null
+++ b/tests/engine/mod_supports.lua
@@ -0,0 +1,251 @@
+-- API availability is observable through the real loader, before Game/world
+-- construction. No imported content, graphics, or network is required.
+package.path = './?.lua;./?/init.lua;' .. package.path
+local T = require('tests.harness')
+local GameVersion = require('src.core.GameVersion')
+local Loader = require('src.mods.Loader')
+local Runtime = require('src.mods.Runtime')
+local WorldAPI = require('src.world.WorldAPI')
+local Schemas = require('src.mods.Schemas')
+local Catalog = require('tests.modkit.catalog')
+local originalEvents, originalHooks, originalErrors = Runtime.events, Runtime.hooks, Runtime.errors
+local originalVersion = GameVersion.get()
+local savedLove = love
+love = nil
+
+local files = {
+ ['mods/probe/manifest.json'] = [[{"id":"probe","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua"}]],
+ ['mods/probe/main.lua'] = [[
+return function(mod)
+ -- Runs inside the entry window, not after the loader has finished.
+ mod.exports.early = mod:supports('world', 'warpTo')
+ mod.exports.api = mod
+end
+]],
+}
+local fs = {
+ read = function(path) return files[path] end,
+ getDirectoryItems = function(path)
+ if path == 'mods' then return { 'probe' } end
+ return {}
+ end,
+ getInfo = function(path)
+ if path == 'mods' or path == 'mods/probe' then return { type = 'directory' } end
+ if files[path] then return { type = 'file' } end
+ end,
+ load = function(path) return loadstring(files[path], path) end,
+}
+local function probe(version)
+ GameVersion.set(version)
+ local data = { pokemon = {} }
+ local loader = Loader.new({ fs = fs, dev = false })
+ loader._game = function() error('supports must not construct Game') end
+ assert(loader:load(data))
+ assert(loader.apis.probe, tostring(loader.errors.probe))
+ return loader.apis.probe, loader, data
+end
+local function yes(api, surface, name)
+ local ok, reason = api:supports(surface, name)
+ return ok == true and reason == nil
+end
+local function no(api, surface, name)
+ local ok, reason = api:supports(surface, name)
+ return ok == false and type(reason) == 'string' and #reason > 0
+end
+
+-- Builtin registrants can allocate fresh closures on each load. Compare
+-- record structure and values while treating function identity as opaque,
+-- as the registry parity gate does.
+local function record(value)
+ if type(value) == 'function' then return '' end
+ if type(value) ~= 'table' then return value end
+ local out = {}; for k, v in pairs(value) do out[k] = record(v) end; return out
+end
+
+local allHooks, allEvents = {}, {}
+for _, version in ipairs(GameVersion.ORDER) do
+ local api, loader, data = probe(version)
+ T.check(api.exports.early, version .. ': query succeeds inside entry chunk')
+ T.check(yes(api, 'hooks', 'battle.damage') and yes(api, 'events', 'world.stepped'),
+ version .. ': shared simulation/world call sites available')
+ local registries = true
+ for name in pairs(Schemas.REGISTRIES) do
+ registries = registries and yes(api, 'content', name)
+ end
+ T.check(registries, version .. ': all API-2 registration surfaces available')
+ T.eq(yes(api, 'world', 'flyTo'), type(WorldAPI.flyTo) == 'function',
+ version .. ': additive native flight interface is reported when installed')
+ T.check(no(api, 'hooks', 'core.logic_speed')
+ and no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'),
+ version .. ': absent Gen1Recomp API-3 features are not advertised')
+ T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'),
+ version .. ': namespaced user events do not need a producer installed')
+ for _, name in ipairs(Catalog.hooks()) do
+ if yes(api, 'hooks', name) then allHooks[name] = true end
+ end
+ for _, name in ipairs(Catalog.events()) do
+ if yes(api, 'events', name) then allEvents[name] = true end
+ end
+ -- Mutation of the handed-out metadata cannot change a capability answer.
+ local statusHud = api:supports('hooks', 'battle.status_hud_visible')
+ api.gameVersion, api.generation, api.host.version = 'red', 1, 'red'
+ GameVersion.set('red')
+ T.eq(api:supports('hooks', 'battle.status_hud_visible'), statusHud,
+ version .. ': query retains captured host even after metadata/global changes')
+ T.check(next(loader.events.listeners) == nil and next(loader.hooks.chains) == nil,
+ version .. ': probing installs no listeners or wrappers')
+ local pristine = { pokemon = {} }
+ GameVersion.set(version)
+ local vanilla = Loader.new({ fs = fs, noMods = true, dev = false })
+ assert(vanilla:load(pristine))
+ T.same(record(data), record(pristine), version .. ': querying mod leaves merged data equal to no-mod boot')
+end
+
+-- The union covers every existing native callback; stale catalog entries
+-- also fail. This gate reads source rather than a second hand-kept name list.
+T.same(allHooks, (function()
+ local out = {}; for _, name in ipairs(Catalog.hooks()) do out[name] = true end; return out
+end)(), 'all native hook names have a queryable implementation')
+T.same(allEvents, (function()
+ local out = {}; for _, name in ipairs(Catalog.events()) do out[name] = true end; return out
+end)(), 'all native event names have a queryable implementation')
+
+-- Also reject advertised names whose native call site was removed. Read
+-- the simple declaration lists without exporting mutable production state.
+local h = assert(io.open('src/mods/Capabilities.lua', 'r'))
+local body = h:read('*a'); h:close()
+local declared = { hooks = {}, events = {} }
+for surface, list in body:gmatch('add%("(%w+)", "%d+", ([^%)]+)%)') do
+ local names = list:match('^%[%[(.-)%]%]$') or list:match('^"(.-)"$')
+ assert(names, 'unrecognized capability declaration')
+ for name in names:gmatch('%S+') do declared[surface][name] = true end
+end
+T.same(declared.hooks, allHooks, 'advertised hook catalog has no stale call sites')
+T.same(declared.events, allEvents, 'advertised event catalog has no stale call sites')
+
+local api, loader = probe('crystal')
+local noWorldBefore = package.loaded['src.world.WorldAPI']
+T.check(yes(api, 'world', 'warpTo') and yes(api, 'world', 'heightAt'),
+ 'world methods available independently of save/map state')
+T.eq(package.loaded['src.world.WorldAPI'], noWorldBefore, 'queries never require WorldAPI')
+for _, surface in ipairs({ 'content', 'world', 'hooks', 'events' }) do
+ for _, invalid in ipairs({ '', 1, false, {}, function() end }) do
+ T.check(no(api, surface, invalid), surface .. ': malformed name declines with reason')
+ end
+ T.check(no(api, surface, nil), surface .. ': missing name declines with reason')
+ T.check(no(api, surface, 'does.not.exist'), surface .. ': unknown name declines with reason')
+end
+for _, surface in ipairs({ '', 'event', 'hook', 'storage', 1, false, {} }) do
+ T.check(no(api, surface, 'anything'), 'unknown/malformed surface declines with reason')
+end
+T.check(no(api, nil, 'anything'), 'missing surface declines with reason')
+T.check(no(api, 'events', 'mod.probe.') and no(api, 'events', 'mod..ready'),
+ 'empty user-event namespace/name is unavailable')
+T.check(no(api, 'world', 'new') and no(api, 'world', '_mapNamed')
+ and no(api, 'world', 'face') and no(api, 'world', '__index'),
+ 'constructors, internals and NPC-handle methods are not world capabilities')
+T.check(no(api, 'content', 'Pokemon') and no(api, 'hooks', 'BATTLE.DAMAGE'),
+ 'names are case-sensitive')
+
+-- Verify world catalog against the real public method table, not just names
+-- duplicated in a test. A supported method may still decline without a map.
+love = savedLove or require('tests.love_stub')
+local WorldAPI = require('src.world.WorldAPI')
+for name, method in pairs(WorldAPI) do
+ if type(method) == 'function' and name ~= 'new' and name:sub(1, 1) ~= '_' then
+ T.check(yes(api, 'world', name), 'public WorldAPI method query: ' .. name)
+ end
+end
+local worldNames = assert(body:match('for name in %(%[%[(.-)%]%]%):gmatch'))
+for name in worldNames:gmatch('%S+') do
+ T.eq(type(WorldAPI[name]), 'function', 'advertised world method exists: ' .. name)
+end
+local world = WorldAPI.new({}, 'probe')
+local result, reason = world:warpTo('missing', 0, 0)
+T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo'),
+ 'availability is distinct from current-state eligibility')
+
+-- Version-specific routing is deliberately different from Gen1Recomp.
+for _, version in ipairs(GameVersion.ORDER) do
+ local mod = probe(version)
+ local gen = GameVersion.generation(version)
+ T.eq(mod:supports('hooks', 'battle.status_hud_visible'), gen >= 4,
+ version .. ': DS-only HUD seam')
+ T.eq(mod:supports('hooks', 'battle.overlay'), gen <= 3,
+ version .. ': GB/GBA-only overlay seam')
+ T.eq(mod:supports('hooks', 'ui.party.submenu'), gen <= 3,
+ version .. ': native party-menu seam')
+ T.eq(mod:supports('hooks', 'ui.pc.items'), gen <= 2,
+ version .. ': native PC seam')
+ T.eq(mod:supports('events', 'intro.birch_speech.started'), version == 'emerald',
+ version .. ': Emerald-only Birch intro')
+ T.eq(mod:supports('hooks', 'intro.oak_speech.build'), gen <= 2 and version ~= 'prism',
+ version .. ': Oak route excludes Prism and newer intros')
+end
+
+-- A content query gates an actual registration through the entry API.
+local entry = files['mods/probe/main.lua']
+files['mods/probe/main.lua'] = [[
+return function(mod)
+ if mod:supports('content', 'strings') then
+ mod.content.strings:register('QUERY_TEXT', 'Query succeeded')
+ end
+ if mod:supports('content', 'not_a_registry') then
+ error('unavailable registration must be skipped')
+ end
+end
+]]
+local _, contentLoader, contentData = probe('crystal')
+T.eq(contentData.strings.QUERY_TEXT, 'Query succeeded', 'supported registry accepts real registration')
+T.eq(#contentLoader.errors, 0, 'unavailable content fallback loads without errors')
+files['mods/probe/main.lua'] = entry
+
+-- A generation-specific query gates the real DS HUD call site. Returning
+-- false avoids drawing and lets this run without a graphics context.
+local Gen4Battle = require('src.battle.Gen4Battle')
+for _, version in ipairs({ 'platinum', 'black' }) do
+ local mod = probe(version)
+ local calls = 0
+ if mod:supports('hooks', 'battle.status_hud_visible') then
+ mod.hooks:wrap('battle.status_hud_visible', function()
+ calls = calls + 1
+ return false
+ end)
+ end
+ T.eq(Gen4Battle.drawHealthboxes({}), true, version .. ': supported HUD hook suppresses native boxes')
+ T.eq(calls, 1, version .. ': native HUD call reaches mod once')
+end
+
+-- A successful probe gates an actual native call: world.timeOfDay dispatches
+-- the hook and resulting event. Queries themselves have no bus side effects.
+api, loader = probe('crystal')
+local calls, changes = 0, 0
+if api:supports('hooks', 'world.tod') then
+ api.hooks:wrap('world.tod', function(next, tod, ctx)
+ calls = calls + 1
+ return 'TEST_NIGHT'
+ end)
+end
+if api:supports('events', 'world.tod_changed') then
+ api.events:on('world.tod_changed', function(payload)
+ changes = changes + 1
+ T.eq(payload.tod, 'TEST_NIGHT', 'native event carries hook result')
+ end)
+end
+local Overworld = require('src.world.OverworldController')
+local ow = setmetatable({ tod = 'DAY', map = { id = 'PROBE' }, player = {} }, { __index = Overworld })
+ow.syncGen2Tod = function() end
+T.eq(ow:timeOfDay(), 'TEST_NIGHT', 'queried native hook executes through actual world method')
+T.check(calls == 1 and changes == 1, 'native hook/event each dispatched once')
+-- Unsubscribe and use the same native path: original time-of-day behavior.
+loader.events:removeOwner('probe')
+loader.hooks:removeOwner('probe')
+GameVersion.set('red')
+ow.tod = 'DAY'
+T.eq(ow:timeOfDay(), 'DAY', 'unmodified native path retains vanilla result')
+T.check(calls == 1 and changes == 1, 'no-mod path invokes no mod callbacks')
+
+GameVersion.set(originalVersion)
+love = savedLove
+Runtime.install(originalEvents, originalHooks, originalErrors)
+T.finish('mod supports')
diff --git a/tests/engine/native_screen_position.lua b/tests/engine/native_screen_position.lua
new file mode 100644
index 000000000..4071b029c
--- /dev/null
+++ b/tests/engine/native_screen_position.lua
@@ -0,0 +1,181 @@
+-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md.
+-- Real native composition and option-input contracts, without a GPU.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local ok,Position=pcall(require,'src.core.ScreenPosition')
+T.check(ok and type(Position)=='table','native source screen-position module exists')
+if not ok then T.finish('native screen position is absent') end
+local Renderer=require('src.render.Renderer')
+local Game=require('src.core.Game')
+local Viewport=require('src.render.GameViewport')
+local Chrome=require('src.ui.Gen2Chrome')
+local Skin=require('src.core.TouchSkin')
+local Version=require('src.core.GameVersion')
+local Safe=require('src.core.SafeArea')
+local Zoom=require('src.render.Zoom')
+local Tilt=require('src.render.Tilt')
+local G=love.graphics
+local previous={G.getDimensions,G.getPixelDimensions,G.getDPIScale,love.window.getDPIScale,
+ Safe.rect,Skin.active,Version.get(),Zoom.offset,Position.mode}
+local width,height,dpi=360,640,1
+local function metrics(w,h,d)
+ width,height,dpi=w,h,d
+ G.getDimensions=function()return width,height end
+ G.getPixelDimensions=function()return width*dpi,height*dpi end
+ G.getDPIScale=function()return dpi end
+ love.window.getDPIScale=G.getDPIScale
+end
+metrics(width,height,dpi)
+Safe.rect=function()return 0,0,width,height end
+Skin.setActive(nil);Viewport.reset();Renderer:init();Zoom.offset=0
+Tilt.setLevel(0)
+
+T.eq(Position.normalize(nil),'center');T.eq(Position.normalize('bogus'),'center')
+T.eq(Position.cycle('center',-1),'top');T.eq(Position.cycle('top',1),'center')
+Position.setMode('top')
+T.eq(Position.lift(640,288),176,'TOP places the centered content at zero')
+T.eq(Position.lift(640,288,40),136,'TOP respects the safe inset')
+T.eq(Position.lift(640,288,999),0,'unsafe excessive inset cannot move below center')
+T.eq(Position.lift(144,288),0,'oversized content cannot be raised')
+Position.setMode('upper');T.eq(Position.lift(640,288),88,'UPPER uses upper-half center')
+T.eq(Position.lift(640,288,120),56,'UPPER also respects the safe inset')
+
+-- This offered host has no frameRects observation API. Exercise its actual
+-- final compositor and normalize only the returned test observations.
+local function composed()
+ local out=Renderer:endFrame()
+ local _,uih=Renderer:uiSize()
+ return {Sp=out.scale,ph=height*dpi,uih=uih,ox=out.gameX,oy=out.gameY,
+ uoy=Renderer.uiPresentation.y}
+end
+local query=setmetatable({},{__index=Game})
+for _,edition in ipairs{'red','gold','silver','crystal','prism','polishedcrystal','firered','emerald'}do
+ Version.set(edition)
+ for _,size in ipairs{{360,640,1},{360,640,2},{801,1001,1.5},{1280,960,2},{160,144,1}}do
+ metrics(unpack(size));Position.setMode('center')
+ local center=composed()
+ local _,baseHeight=Renderer:worldViewSize()
+ for _,mode in ipairs{'center','upper','top'}do
+ Position.setMode(mode)
+ local r=composed()
+ local slack=r.ph-r.uih*r.Sp
+ local expected=0
+ if slack>0 and mode~='center' then
+ expected=math.floor(slack/2)-(mode=='top' and 0 or math.floor(slack/4))
+ end
+ T.eq(r.ox,center.ox,'screen position does not move horizontal origin')
+ T.eq(r.Sp,center.Sp,'screen position does not change integer zoom')
+ T.eq(r.oy,center.oy-expected/dpi,'world/UI anchor uses the selected vertical position')
+ T.eq(r.uoy,center.uoy-expected/dpi,'unzoomed UI follows the same anchor')
+ local _,vh=Renderer:worldViewSize()
+ T.eq(vh,baseHeight+2*math.ceil(expected/Zoom.scale(r.Sp)),
+ 'native world canvas grows enough to cover the raised camera')
+ if Version.isGen2()then
+ local s,x,y=Game.frameFit(query,width*dpi,height*dpi)
+ T.eq(s,r.Sp,'source pixel query keeps compositor scale')
+ T.check(math.abs(x-r.ox*dpi)<1e-8,'source pixel query shares compositor origin X')
+ T.check(math.abs(y-r.oy*dpi)<1e-8,'source pixel query shares compositor origin Y')
+ local cx,cy=Chrome.fitOrigin(width,height)
+ T.check(math.abs(cx-r.ox)<1e-8,'native Gold menu uses positioned logical origin X')
+ T.check(math.abs(cy-r.oy)<1e-8,'native Gold menu uses positioned logical origin Y')
+ end
+ end
+ end
+end
+
+-- Inspect the actual final world/UI blits, not only the rectangle query.
+metrics(360,640,2);Version.set('crystal');Position.setMode('top')
+Renderer:init()
+local vw,vh=Renderer:worldViewSize()
+Renderer.worldCanvas=G.newCanvas(vw,vh)
+Renderer.worldActive=true
+local nativeDraw=G.draw
+local worldDraw
+G.draw=function(image,x,y,rotation,sx,sy,...)
+ if image==Renderer.worldCanvas then worldDraw={x=x,y=y,sx=sx,sy=sy}end
+ return nativeDraw(image,x,y,rotation,sx,sy,...)
+end
+local final=Renderer:endFrame()
+G.draw=nativeDraw
+T.check(worldDraw~=nil,'actual compositor submits the native world canvas')
+if worldDraw then
+ T.check(worldDraw.y<=0,'raised world still covers the top edge')
+ T.check(worldDraw.y+vh*worldDraw.sy>=height,'grown world still covers the bottom edge')
+ T.eq(worldDraw.y,(math.floor((height*dpi-vh*4)/2)-352)/dpi,
+ 'actual native world blit subtracts the same physical lift')
+end
+T.eq(final.gameY,0,'actual final classic UI is raised to the top')
+T.eq(Renderer.uiPresentation.y,0,'actual final UI blit uses the raised origin')
+
+-- A safe area owns the minimum top inset in LOVE units, then the compositor
+-- snaps the chosen origin to physical pixels. No OS display setting changes.
+metrics(360,640,2);Version.set('crystal');Position.setMode('top')
+Safe.rect=function()return 0,40,width,height-40 end
+local safe=composed()
+T.eq(safe.oy,40,'actual compositor stops at a 40-unit safe inset on Retina')
+local _,_,pixelY=Game.frameFit(query,720,1280)
+T.eq(pixelY,80,'source drawable query reports the same inset in pixels')
+Safe.rect=function()return 0,0,width,height end
+
+-- Skin-owned rectangles must win over the stored position. Exercise the real
+-- native skin parser and viewport query rather than a fake presence flag.
+local skin=assert(Skin.parse([[
+overlays = 1
+overlay0_name = "position-test"
+overlay0_full_screen = true
+overlay0_normalized = true
+overlay0_viewport = "0.1,0.1,0.8,0.4"
+overlay0_descs = 1
+overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02"
+]]))
+Skin.setActive(skin);Skin.setOverlayLive(false)
+T.eq(Position.label('top'),'SKIN','option label names the skin owner')
+T.eq(Position.cycle('top',1),'top','skin keeps stored position without cycling')
+T.eq(composed().oy,math.floor((height*dpi-144*Renderer:fitScale())/2)/dpi,
+ 'actual older compositor leaves the skin-owned origin centered')
+Skin.setActive(nil)
+Renderer.splitViewport={w=360,h=640}
+T.eq(composed().oy,math.floor((height*dpi-144*Renderer:fitScale())/2)/dpi,
+ 'actual older compositor preserves split-screen origin')
+Renderer.splitViewport=nil
+
+-- Normal option input, native save codec and actual boot application.
+local Menu=require('src.ui.OptionsMenu')
+local Stack=require('src.core.StateStack')
+local Save=require('src.core.SaveData')
+for _,edition in ipairs{'gold','silver','crystal'}do
+ Version.set(edition);Position.setMode('center')
+ local g={data=T.fixtures.fresh(),save=Save.newGame(),writes=0,input={pressed={}}}
+ g.stack=setmetatable({},{__index=Stack});g.stack:init()
+ g.input.wasPressed=function(self,key)return self.pressed[key]==true end
+ g.writeOptions=function(self)self.writes=self.writes+1;self.persisted=Save.encode(self.save)end
+ local menu=Menu.new(g);g.stack:push(menu)
+ -- Actual older menu is flat: reach the new descriptor with ordinary
+ -- DOWN input; do not invent the newer fork's focusRow/group methods.
+ for _=1,#menu.rows do
+ if menu.rows[menu.index] and menu.rows[menu.index].id=='screenPos' then break end
+ g.input.pressed={down=true};menu:update(1/60);g.input.pressed={}
+ end
+ local page=menu
+ local found=menu.rows[menu.index] and menu.rows[menu.index].id=='screenPos'
+ T.check(found,'SCREEN POS is reachable through actual older native menu input')
+ if found then
+ T.eq(menu.rows[menu.index].id,'screenPos')
+ for _,mode in ipairs{'upper','top','center'}do
+ g.input.pressed={right=true};page:update(1/60);g.input.pressed={}
+ T.eq(g.save.options.screenPos,mode,'real right input updates the stored option')
+ T.eq(Position.mode,mode,'real input immediately updates native fitting')
+ end
+ T.eq(g.writes,3,'one normal native options write per input change')
+ local saved=assert(Save.decode(g.persisted))
+ Position.setMode('top');Game.applyOptions(g,saved.options)
+ T.eq(Position.mode,'center','actual boot applies the persisted position')
+ saved.options.screenPos='upper';Game.applyOptions(g,saved.options)
+ T.eq(Position.mode,'upper','actual boot restores a nondefault position')
+ end
+end
+
+Position.setMode('center');Viewport.reset();Skin.setActive(previous[6])
+G.getDimensions,G.getPixelDimensions,G.getDPIScale,love.window.getDPIScale=unpack(previous,1,4)
+Safe.rect=previous[5];Version.set(previous[7]);Zoom.offset=previous[8];Position.setMode(previous[9])
+T.finish('native screen position: compositor, source queries, safe areas, skins and saved menu input')
diff --git a/tests/engine/native_trainer_party_scope.lua b/tests/engine/native_trainer_party_scope.lua
new file mode 100644
index 000000000..ee05106f1
--- /dev/null
+++ b/tests/engine/native_trainer_party_scope.lua
@@ -0,0 +1,117 @@
+-- Copyright (c) 2026 Roxas2712. See the offered contribution licence.
+-- Actual older-host constructors, menus, EXP/faint owners and party effects.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local V=require('src.core.GameVersion')
+local B=require('src.battle.BattleState')
+local Pokemon=require('src.pokemon.Pokemon')
+local Save=require('src.core.SaveData')
+local old=V.get()
+for _,edition in ipairs(V.ORDER)do
+ V.set(edition)
+ local data=T.fixtures.fresh();data.constants.gen=V.generation(edition)
+ if data.constants.gen>=3 then
+ -- These editions use the structural six-stat/IV schema. The base fixture
+ -- is Gen1-shaped, so give the owned species their proper native layout.
+ for _,def in pairs(data.pokemon)do
+ if type(def)=='table'and def.baseStats then
+ def.baseStats.spatk=def.baseStats.spatk or def.baseStats.special
+ def.baseStats.spdef=def.baseStats.spdef or def.baseStats.special
+ def.evYield={hp=0,attack=0,defense=0,speed=0,spatk=0,spdef=0}
+ end
+ end
+ end
+ local run=T.sdk.loadMods({'mods/scope_probe'},{data=data,fs=T.sdk.memfs({
+ ['mods/scope_probe/manifest.json']='{"id":"scope_probe","name":"Native scope probe","version":"1.0.0","api":2,"entry":"main.lua"}',
+ ['mods/scope_probe/main.lua']=[[
+ local mod=...
+ mod.hooks:wrap('battle.exp_award',function(next,ctx)
+ mod.exports.award=ctx
+ return next(ctx)
+ end)
+ ]],
+ })})
+ T.eq(#run.errors,0,'real native party observer loads: '..edition)
+ require('src.render.Font').load(data)
+ local save=Save.newGame();save.party={}
+ for i=1,3 do save.party[i]=Pokemon.new(data,'FIXMON_A',20+i)end
+ local game={data=data,save=save,stack={top=function()end}}
+ data.trainers.OWN_SCOPE={id='OWN_SCOPE',index=22,name='SCOPE',baseMoney=10,
+ parties={{{level=5,species='FIXMON_C'}}}}
+ local original,first,second,third=save.party,save.party[1],save.party[2],save.party[3]
+ local selected={3,2}
+ local b=B.newTrainer(game,'OWN_SCOPE',1,{playerPartyIndices=selected})
+ T.eq(b.player.mon,third,'native lead is the first selected original mon')
+ T.eq(b:playerPartyView()[1],third,'ordered scope retains actual first reference')
+ T.eq(b:playerPartyView()[2],second,'ordered scope retains actual second reference')
+ T.neq(b.playerPartyIndices,selected,'caller cannot mutate retained selection indices')
+ T.eq(b:benchFor(true),b.playerParty,'native bench uses the actual selected roster')
+ T.eq(#b:benchFor(true),2,'excluded healthy mon is not a replacement')
+ -- AI runs after real screen entry initializes native on-field sides; a
+ -- constructor's not-yet-entered empty side table is not an AI phase.
+ b:enter()
+ T.eq(b.sides[1].battlers[1],b.player,'actual enter initializes the selected on-field owner')
+ local menu=b:buildScreen(require('src.battle.Gen4Battle').partyScreenId(b)or 'PartyMenu',
+ {battle=b,onSwitch=function()end})
+ local shown=type(menu.party)=='function'and menu:party()or menu.party
+ T.eq(shown,b.playerParty,'actual generation-appropriate party menu uses scope')
+ T.eq(#shown,2,'actual party menu cannot offer an excluded mon')
+ -- The existing AI already understands playerParty. Ensure construction
+ -- and replacement draw from that same original roster, including doubles.
+ local vm={battle=b,user=b.player,target=b.enemy}
+ require('src.battle.Gen3AI').OPS.count_usable_party_mons(vm,{1})
+ T.eq(vm.result,1,'real AI counts only one selected off-field replacement')
+ b.double=true;b:sendOutSecondPair()
+ T.eq(b:battlerAt(B.POS.PLAYER_RIGHT).mon,second,'second flank draws from selected roster')
+ require('src.battle.Gen3AI').OPS.count_usable_party_mons(vm,{1})
+ T.eq(vm.result,0,'both selected mons on field leave no AI replacement')
+ -- Party-wide effects must not heal or count the excluded save-party mon.
+ for _,mon in ipairs(save.party)do mon.status='BRN'end
+ require('src.battle.Gen3MoveEffects').primary.HEAL_BELL_EFFECT(b,b.player)
+ T.eq(first.status,'BRN','actual Heal Bell leaves excluded mon alone')
+ T.eq(second.status,nil,'actual Heal Bell heals selected second mon')
+ T.eq(third.status,nil,'actual Heal Bell heals selected lead')
+ first.status=nil
+ local count=require('src.battle.Gen3MoveEffects').full.BEAT_UP_EFFECT.hitCount(
+ {battle=b,user=b.player,target=b.enemy})
+ T.eq(count,2,'real Beat Up hit count excludes healthy save-party outsider')
+ if V.isGen5()then
+ for _,mon in ipairs(save.party)do mon.status='BRN'end
+ require('src.battle.Gen5MoveEffects').record(b,'BW_HEAL_BELL').run({
+ battle=b,user=b.player,target=b.player,
+ move={id='OWN_SCOPE_BELL',name='BELL',power=0,type='NORMAL'},
+ })
+ T.eq(first.status,'BRN','actual Gen5 effect leaves excluded mon alone')
+ T.eq(second.status,nil,'actual Gen5 effect heals selected second mon')
+ T.eq(third.status,nil,'actual Gen5 effect heals selected lead')
+ end
+ -- Native EXP.ALL includes exactly this battle's party, even if stale
+ -- participation bookkeeping still names the excluded save-party mon.
+ b.queue={};b.participants={[first]=true,[second]=true,[third]=true}
+ save.inventory.EXP_ALL=1
+ local excludedExp=first.exp
+ b:awardExp(b.enemy)
+ local award=run.loader.exports.scope_probe.award
+ T.check(award~=nil,'actual native EXP hook executes')
+ if award then
+ T.eq(award.participants,2,'actual EXP divisor contains selected participants')
+ T.eq(#award.alive,2,'actual EXP living roster excludes outsider')
+ end
+ T.eq(first.exp,excludedExp,'actual EXP.ALL cannot pay excluded outsider')
+ second.hp,third.hp=0,0
+ T.check(first.hp>0,'excluded save-party member remains healthy')
+ b.result=nil;b:playerMonFainted()
+ T.eq(b.result,'lose','actual native defeat ignores excluded healthy party member')
+ T.eq(save.party,original,'battle never replaces real save.party')
+ T.eq(save.party[1],first,'battle never reorders excluded save-party member')
+ T.eq(save.party[2],second,'battle never reorders selected save-party member')
+ T.eq(save.party[3],third,'battle retains every original party mon')
+ for _,invalid in ipairs{{},{1,1},{0},{4},{1.5},{[1]=1,[3]=2},{1,unexpected=true},'bad'}do
+ local party,indices=require('src.battle.PlayerPartyScope').select(game,invalid)
+ T.eq(party,nil,'invalid native selection falls back to complete party')
+ T.eq(indices,nil,'invalid selection keeps no misleading remap')
+ end
+ run.release()
+end
+V.set(old)
+T.finish('older-host real trainer party selection, UI, AI, party effects, EXP and defeat')
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/portable_dialogue_audio.lua b/tests/engine/portable_dialogue_audio.lua
new file mode 100644
index 000000000..a5882405f
--- /dev/null
+++ b/tests/engine/portable_dialogue_audio.lua
@@ -0,0 +1,211 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.script_dialogue_audio")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(v)
+ return Contract.fixture("tests/api3_contract", "gen2", v or "red")
+end
+local function sounding(f)
+ for _ = 1, 300 do
+ f:tick()
+ if #f.audioTrace > 0 then
+ return f.game.stack:top()
+ end
+ end
+ error("no deferred cue")
+end
+for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v)
+ T.check(ok, v .. " native dialogue audio contract: " .. tostring(why))
+end
+for _, row in ipairs({
+ { "play_cry", "UNKNOWN" },
+ { "play_cry", 151 },
+ { "play_cry", "MEW", 1 },
+ { "play_cry", "MEW", true, 1 },
+ { "text_sound", "UNKNOWN" },
+ { "engage_music", "UNKNOWN" },
+ { "text_sound", "CHIME", false },
+ { "engage_music", false },
+}) do
+ local f = fixture()
+ T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, row }), nil, "bad deferred cue refused")
+ T.eq(f.exports.get("mod:prefix"), false, "whole script preflight")
+ T.eq(#f.audioTrace, 0, "preflight is silent")
+ f.restore()
+end
+for _, change in ipairs({ "rollback", "save", "data", "map" }) do
+ for _, phase in ipairs({ "typing", "sounding", "button" }) do
+ local f = fixture()
+ T.check(
+ f.exports.queue({
+ { "play_cry", "MEW", true },
+ { "engage_music", "TUNE" },
+ { "show_text", "WELCOME" },
+ { "set_flag", "mod:after" },
+ }),
+ "retirement fixture queued"
+ )
+ local box = f.game.stack:top()
+ local auto = box.auto
+ if phase ~= "typing" then
+ sounding(f)
+ end
+ if phase == "button" then
+ f.source.playing = false
+ f:tick()
+ end
+ if change == "rollback" then
+ f.loader:_rollback("script_dialogue_audio")
+ elseif change == "map" then
+ f.ow.map = { id = f.home }
+ else
+ f.game[change] = {}
+ end
+ local foreign = { update = function() end }
+ f.game.stack:push(foreign)
+ -- Retained callbacks after retirement cannot replay cues or remove UI.
+ auto.sound()
+ auto.tick()
+ f:tick()
+ T.eq(f.exports.get("mod:after"), false, "retired cue has no continuation")
+ T.eq(f.game.stack:top(), foreign, "foreign overlay retained")
+ T.eq(f.exports.result().done, 1, "retired cue completes once")
+ f.restore()
+ end
+end
+for _, callback in ipairs({ "sound", "poll", "engage" }) do
+ local f = fixture()
+ local Sound, Music = require("src.core.Sound"), require("src.core.Music")
+ local foreign = { update = function() end }
+ local function retire()
+ f.loader:_rollback("script_dialogue_audio")
+ f.game.stack:push(foreign)
+ end
+ local oldSound, oldMusic = Sound.playCry, Music.play
+ if callback == "sound" then
+ Sound.playCry = function(...)
+ local src = oldSound(...)
+ retire()
+ return src
+ end
+ elseif callback == "poll" then
+ f.source.isPlaying = function()
+ retire()
+ return false
+ end
+ else
+ Music.play = function(...)
+ oldMusic(...)
+ retire()
+ end
+ end
+ T.check(
+ f.exports.queue({
+ { "play_cry", "MEW" },
+ { "engage_music", "TUNE" },
+ { "show_text", "WELCOME" },
+ { "set_flag", "mod:after" },
+ }),
+ "reentrant cue admitted"
+ )
+ for _ = 1, 300 do
+ f:tick()
+ if callback == "engage" then
+ f.source.playing = false
+ end
+ if f.exports.result().done > 0 then
+ break
+ end
+ end
+ T.eq(f.game.stack:top(), foreign, "reentrant callback cannot pop foreign state")
+ T.eq(f.exports.result().done, 1, "reentrant completion once")
+ T.eq(f.exports.get("mod:after"), false, "reentrant continuation suppressed")
+ Sound.playCry, Music.play = oldSound, oldMusic
+ f.restore()
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({
+ { "play_cry", "MEW" },
+ { "text_sound", "CHIME" },
+ { "engage_music", "TUNE" },
+ { "ask", "WELCOME" },
+ { "set_flag", "mod:after" },
+ }),
+ "combined cues with question"
+ )
+ local box = sounding(f)
+ T.eq(#f.audioTrace, 2, "both cry and text effect play")
+ for _ = 1, 3 do
+ f:tick("a")
+ end
+ T.eq(f.game.stack:top(), box, "question waits for sound before choice")
+ local retained = box.auto
+ retained.sound()
+ T.eq(#f.audioTrace, 2, "sound callback is once only")
+ f.source.playing = false
+ for _ = 1, 3 do
+ f:tick()
+ end
+ T.check(f.game.stack:top() ~= box, "native choice after audio gate")
+ T.eq(f.audioTrace[3].kind, "music", "engage follows completed cue")
+ retained.tick()
+ retained.sound()
+ T.eq(#f.audioTrace, 3, "underlay callbacks add no playback")
+ for _ = 1, 30 do
+ f:tick("a")
+ end
+ T.eq(f.exports.result().success, true, "choice continues script once")
+ T.eq(f.exports.result().done, 1, "choice single completion")
+ f.restore()
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({
+ { "play_cry", "MEW" },
+ { "engage_music", "TUNE" },
+ { "show_text", "WELCOME" },
+ { "set_flag", "mod:after" },
+ }),
+ "bounded cry fixture"
+ )
+ sounding(f)
+ for _ = 1, 600 do
+ f:tick()
+ end
+ T.eq(f.exports.result().success, true, "600-frame native audio ceiling releases dialogue")
+ T.eq(#f.audioTrace, 2, "music starts once at sound ceiling")
+ f.restore()
+end
+for _, stage in ipairs({ "dispatch", "typed" }) do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "wait", 2 }, { "play_cry", "MEW" }, { "show_text", "WELCOME" }, {
+ "set_flag",
+ "mod:after",
+ } }),
+ "asset-change fixture"
+ )
+ if stage == "typed" then
+ for _ = 1, 4 do
+ f:tick()
+ end
+ end
+ f.game.data.audio.cries.MEW = nil
+ for _ = 1, 300 do
+ f:tick()
+ end
+ T.eq(f.exports.result().success, false, "missing cry revalidated at " .. stage)
+ T.eq(f.exports.get("mod:after"), false, "changed asset prevents continuation")
+ T.eq(#f.audioTrace, 0, "missing asset never played")
+ f.restore()
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5]
+love = saved[6]
+T.finish()
diff --git a/tests/engine/portable_script_audio.lua b/tests/engine/portable_script_audio.lua
new file mode 100644
index 000000000..bdc15eb4d
--- /dev/null
+++ b/tests/engine/portable_script_audio.lua
@@ -0,0 +1,288 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.script_audio")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(v)
+ return Contract.fixture("tests/api3_contract", "gen2", v or "red")
+end
+for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v)
+ T.check(ok, v .. " shared audio contract: " .. tostring(why))
+end
+for _, rows in ipairs({
+ { { "set_flag", "mod:prefix" }, { "play_sound", "UNKNOWN" } },
+ { { "play_music", "UNKNOWN" } },
+ { { "play_once", "UNKNOWN" } },
+ { { "play_sound", "" } },
+ { { "play_music", "TUNE", { keep = "yes" } } },
+ { { "play_music", "TUNE", { volume = 1 } } },
+ { { "fade_music", -1 } },
+ { { "fade_music", 256 } },
+ { { "wait_sound", 1 } },
+ { { "stop_music", false } },
+}) do
+ local f = fixture()
+ T.eq(f.exports.queue(rows), nil, "invalid audio preflight")
+ T.eq(f.exports.get("mod:prefix"), false, "invalid audio suffix prevents prefix")
+ T.eq(#f.audioTrace, 0, "preflight plays no cue")
+ f.restore()
+end
+for _, phase in ipairs({ "sound", "once" }) do
+ for _, change in ipairs({ "rollback", "save", "data", "map" }) do
+ local f = fixture()
+ local original = f.ow.runner
+ local first = phase == "sound" and { "play_sound", "CHIME" } or { "play_once", "JINGLE" }
+ local rows = phase == "sound" and { first, { "wait_sound" }, { "set_flag", "mod:after" } }
+ or { first, { "set_flag", "mod:after" } }
+ T.check(f.exports.queue(rows), "audio admitted before context change")
+ if change == "rollback" then
+ f.loader:_rollback("script_audio")
+ elseif change == "map" then
+ f.ow.map = { id = f.home }
+ else
+ f.game[change] = {}
+ end
+ f:tick()
+ f.source.playing = false
+ f.musicBusy = false
+ f:tick()
+ T.eq(f.exports.result().success, false, "retired audio wait fails script")
+ T.eq(f.exports.result().done, 1, "retired audio finishes once")
+ T.eq(f.ow.runner, original, "retired audio restores idle runner")
+ T.eq(#f.audioTrace, 1, "retirement adds no audio or later effects")
+ f.restore()
+ end
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "play_sound", "CHIME" }, { "wait_sound" }, { "set_flag", "mod:after" } }),
+ "sound timeout admitted"
+ )
+ for _ = 1, 600 do
+ f:tick()
+ end
+ T.eq(f.exports.result().success, true, "600-frame sound ceiling releases native wait")
+ T.eq(f.exports.get("mod:after"), true, "sound timeout keeps native continuation behavior")
+ f.restore()
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "play_once", "JINGLE" }, { "set_flag", "mod:after" } }),
+ "stalled fanfare admitted"
+ )
+ for _ = 1, 3600 do
+ f:tick()
+ end
+ T.eq(f.exports.result().success, false, "stalled one-shot fails at bounded deadline")
+ T.eq(f.exports.get("mod:after"), false, "stalled one-shot cannot burn later flag")
+ f.restore()
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "play_once", "JINGLE" }, { "set_flag", "mod:after" } }),
+ "one-shot before foreign music"
+ )
+ f.current = "FOREIGN"
+ f:drain()
+ T.eq(f.exports.result().success, true, "one-shot does not wait on a replacement song")
+ T.eq(f.current, "FOREIGN", "replacement song retained")
+ f.restore()
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "play_sound", "CHIME" }, { "wait_sound" }, { "set_flag", "mod:after" } }),
+ "sound before overlay"
+ )
+ f.source.playing = false
+ f.game.stack.states[2] = {}
+ for _ = 1, 5 do
+ f:tick()
+ end
+ T.eq(f.exports.get("mod:after"), false, "audio continuation pauses beneath overlay")
+ f.game.stack.states[2] = nil
+ f:drain()
+ T.eq(f.exports.get("mod:after"), true, "audio resumes in foreground")
+ f.restore()
+end
+for _, v in ipairs({ "firered", "emerald" }) do
+ local f = fixture(v)
+ f.game.data.audio.songs.SONG_007 = { index = 7, musName = "MUS_TEST", sfxName = "SE_TEST", tracks = {} }
+ f.game.data.audio.sfx.Get_Item1 = { m4a = 7, song = "SONG_007" }
+ T.check(
+ f.exports.queue({
+ { "play_sound", "SE_TEST" },
+ { "wait_sound" },
+ { "play_music", 7 },
+ {
+ "play_once",
+ "Get_Item1",
+ },
+ }),
+ "native Gen3 names/number/fanfare role admitted"
+ )
+ T.eq(f.audioTrace[1].id, "SONG_007", "native sequence resolved for effect")
+ f.source.playing = false
+ for _ = 1, 5 do
+ f:tick()
+ end
+ T.eq(f.audioTrace[2].id, "SONG_007", "numeric music id resolved")
+ T.eq(f.audioTrace[3].id, "SONG_007", "native obtain-item fanfare role resolved")
+ f.musicBusy = false
+ f:drain()
+ T.eq(f.exports.result().success, true, "native Gen3 resolved sequence completes")
+ f.restore()
+ f = fixture(v)
+ f.game.data.audio.songs.A = { index = 7, musName = "MUS_DUP" }
+ f.game.data.audio.songs.B = { index = 8, musName = "MUS_DUP" }
+ T.eq(
+ f.exports.queue({ { "set_flag", "mod:prefix" }, { "play_music", "MUS_DUP" } }),
+ nil,
+ "ambiguous sequence name preflight"
+ )
+ T.eq(f.exports.get("mod:prefix"), false, "ambiguous cue prevents prefix")
+ T.check(f.exports.queue({ { "play_music", 0 } }), "Gen3 native zero music id admitted")
+ T.eq(f.audioTrace[1].kind, "stop", "Gen3 native zero means stop")
+ f.restore()
+end
+do
+ local f = fixture()
+ local opts = { keep = true, reason = "script" }
+ T.check(
+ f.exports.queue({ { "wait", 1 }, { "play_music", "TUNE", opts } }),
+ "music options snapshot admitted"
+ )
+ opts.keep = false
+ f:drain()
+ T.eq(f.ow.keepMusicOnce, true, "caller cannot mutate queued music options")
+ f.restore()
+end
+do
+ local f = fixture()
+ T.check(f.exports.queue({ { "wait", 1 }, { "play_music", "TUNE" } }), "deferred cue admitted")
+ f.game.data.audio.songs.TUNE = nil
+ f:drain()
+ T.eq(f.exports.result().success, false, "live missing cue fails dispatch")
+ T.eq(#f.audioTrace, 0, "missing live cue does not play")
+ f.restore()
+end
+do
+ local f = fixture()
+ local Music = require("src.core.Music")
+ local native = Music.play
+ Music.play = function(...)
+ native(...)
+ f.loader:_rollback("script_audio")
+ end
+ T.check(
+ f.exports.queue({ { "play_music", "TUNE", { keep = true } }, { "set_flag", "mod:after" } }),
+ "retirement during native music admitted"
+ )
+ T.eq(f.exports.result().success, false, "music callback retirement cancels script")
+ T.eq(f.ow.keepMusicOnce, nil, "retired cue cannot arm a later warp's music flag")
+ T.eq(#f.audioTrace, 1, "already committed native cue is retained")
+ f.restore()
+end
+for _, v in ipairs({ "firered", "emerald" }) do
+ local f = fixture(v)
+ T.eq(
+ f.exports.queue({ { "set_flag", "mod:prefix" }, { "play_music", "MUS_UNKNOWN" } }),
+ nil,
+ "unknown Gen3 cue refused before prefix"
+ )
+ T.eq(f.exports.get("mod:prefix"), false, "Gen3 cue refusal has no prefix")
+ f.game.data.audio.songs.TUNE = false
+ T.eq(f.exports.queue({ { "play_music", "TUNE" } }), nil, "disabled cue not accepted as a definition")
+ f.restore()
+end
+-- Native Sound file/effect dispatch, with only the actual device replaced.
+do
+ love = require("tests.love_stub")
+ local Sound = require("src.core.Sound")
+ local previousAudio = love.audio
+ local calls = 0
+ local src = {
+ playing = false,
+ setVolume = function() end,
+ stop = function(self)
+ self.playing = false
+ end,
+ play = function(self)
+ self.playing = true
+ end,
+ isPlaying = function(self)
+ return self.playing
+ end,
+ }
+ love.audio = {
+ newSource = function(file, kind)
+ T.eq(file, "native-cue.wav", "native file cue passed to device")
+ calls = calls + 1
+ return src
+ end,
+ }
+ local data = { audio = { songs = { PORTABLE_NATIVE_FILE = { file = "native-cue.wav" } } } }
+ T.eq(Sound.playNamedEffect(data, "PORTABLE_NATIVE_FILE"), src, "native helper returns started source")
+ T.eq(calls, 1, "native helper builds source once")
+ T.eq(Sound.playNamedEffect(data, "PORTABLE_NATIVE_FILE"), src, "native helper reuses native cache")
+ T.eq(calls, 1, "native helper preserves cache")
+ Sound.invalidate("PORTABLE_NATIVE_FILE")
+ love.audio = previousAudio
+end
+-- Actual native music one-shot and restore state; only the device is a seam.
+do
+ local f = fixture()
+ f.restore()
+ love = require("tests.love_stub")
+ local previousAudio = love.audio
+ local src = {
+ playing = false,
+ setVolume = function() end,
+ setLooping = function() end,
+ setPitch = function() end,
+ stop = function(self)
+ self.playing = false
+ end,
+ play = function(self)
+ self.playing = true
+ end,
+ isPlaying = function(self)
+ return self.playing
+ end,
+ }
+ love.audio = {
+ newSource = function(file)
+ T.eq(file, "native-jingle.wav", "native music resolves file definition")
+ return src
+ end,
+ }
+ local Music = require("src.core.Music")
+ Music.stop()
+ f.game.data.audio.songs.JINGLE = { file = "native-jingle.wav" }
+ T.check(
+ f.exports.queue({ { "play_once", "JINGLE" }, { "set_flag", "mod:after" } }),
+ "native music one-shot admitted"
+ )
+ T.eq(Music.current(), "JINGLE", "native music controller owns current jingle")
+ T.eq(Music.oneShotPlaying(), true, "native music pending restore retained")
+ for _ = 1, 3 do
+ f:tick()
+ end
+ T.eq(f.exports.get("mod:after"), false, "script waits on actual native restore state")
+ src.playing = false
+ Music.update(f.game.data)
+ f:drain()
+ T.eq(Music.oneShotPlaying(), false, "actual native controller drains restore")
+ T.eq(f.exports.get("mod:after"), true, "actual native restore resumes script")
+ Music.stop()
+ love.audio = previousAudio
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("Portable script audio")
diff --git a/tests/engine/portable_script_battles.lua b/tests/engine/portable_script_battles.lua
new file mode 100644
index 000000000..429afb3cf
--- /dev/null
+++ b/tests/engine/portable_script_battles.lua
@@ -0,0 +1,192 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.script_battles")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local Battle = require("src.battle.BattleState")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(v)
+ return Contract.fixture("tests/api3_contract", "gen2", v or "red")
+end
+for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v)
+ T.check(ok, v .. " shared script battle: " .. tostring(why))
+end
+for _, rows in ipairs({
+ { { "set_flag", "mod:prefix" }, { "start_battle", "wild", "UNKNOWN", 5 } },
+ { { "start_battle", "wild", "MEW", 0 } },
+ { { "start_battle", "wild", "MEW", 101 } },
+ { { "start_battle", "trainer", "ANY", 1 } },
+ { { "static_battle", "MEW", 5, "mod.other.bad" } },
+ { { "static_battle", "MEW", 5 }, { "check_battle_result", "invalid" } },
+}) do
+ local f = fixture()
+ T.eq(f.exports.queue(rows), nil, "invalid battle list preflight")
+ T.eq(f.exports.get("mod:prefix"), false, "invalid suffix has no flag prefix")
+ T.eq(f.constructed, 0, "preflight constructs no native battle")
+ f.restore()
+end
+for _, outcome in ipairs({ "win", "lose", "run", "caught" }) do
+ local f = fixture()
+ T.check(
+ f.exports.queue({
+ { "static_battle", "MEW", 5, "mod:beat" },
+ { "check_battle_result", outcome },
+ { "jump_if_false", "end" },
+ { "set_flag", "mod:after" },
+ }),
+ "private static beat flag admitted"
+ )
+ f:complete(outcome)
+ f:drain()
+ T.eq(f.exports.get("mod:beat"), outcome ~= "lose", "private static flag is generation independent")
+ T.eq(f.exports.get("mod:after"), true, "battle result branch executes")
+ f.restore()
+end
+for _, change in ipairs({ "cancel", "rollback", "save", "data", "player" }) do
+ local f = fixture()
+ local original = f.ow.runner
+ T.check(
+ f.exports.queue({ { "static_battle", "MEW", 5, "mod:beat" }, { "set_flag", "mod:after" } }),
+ "battle admitted before retirement"
+ )
+ local finish = f.finish
+ if change == "cancel" then
+ require("src.mods.PortableScripts").cancelAll(f.loader, "script_battles")
+ elseif change == "rollback" then
+ f.loader:_rollback("script_battles")
+ elseif change == "player" then
+ f.ow.player = {}
+ else
+ f.game[change] = {}
+ end
+ f:tick()
+ T.eq(f.game.stack:top(), f.battle, "cancelling script preserves native battle screen")
+ T.eq(f.exports.result().done, 1, "retirement completes once")
+ T.eq(f.exports.result().success, false, "retired battle cannot finish script")
+ f:complete("caught")
+ finish("win")
+ finish("run")
+ T.eq(
+ f.after,
+ (change == "cancel" or change == "rollback") and 1 or 0,
+ "cleanup only for retained native session"
+ )
+ T.eq(f.exports.result().done, 1, "native finish cannot replay retired completion")
+ T.eq(f.ow.runner, original, "idle runner restored")
+ f.restore()
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "set_flag", "mod:after" } }),
+ "battle before foreign overlay"
+ )
+ local callback = f.finish
+ f:complete("win")
+ local overlay = {}
+ f.game.stack.states[2] = overlay
+ callback("lose")
+ callback("run")
+ for _ = 1, 5 do
+ f:tick()
+ end
+ T.eq(f.exports.get("mod:after"), false, "battle callback cannot resume under foreign UI")
+ T.eq(f.after, 1, "native cleanup is one-shot")
+ f.game.stack.states[2] = nil
+ f:drain()
+ T.eq(f.exports.get("mod:after"), true, "foreground battle continuation runs")
+ f.restore()
+end
+for _, failure in ipairs({ "constructError", "pushFailure", "cleanupError", "unhealthy", "constructUnload" }) do
+ local f = fixture()
+ if failure == "unhealthy" then
+ f.game.save.party[1].hp = 0
+ elseif failure == "constructUnload" then
+ local native = package.loaded["src.battle.BattleState"].newWild
+ package.loaded["src.battle.BattleState"].newWild = function(...)
+ local result = native(...)
+ f.loader:_rollback("script_battles")
+ return result
+ end
+ else
+ f[failure] = true
+ end
+ T.check(
+ f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "set_flag", "mod:after" } }),
+ "admitted program reports native failure"
+ )
+ if failure == "cleanupError" then
+ f:complete("win")
+ end
+ T.eq(f.exports.result().success, false, "native failure finishes script")
+ T.eq(f.exports.get("mod:after"), false, "native failure suppresses suffix")
+ if failure == "constructUnload" then
+ T.eq(f.pushed, 0, "owner retired during construction cannot push battle")
+ end
+ f.restore()
+end
+do
+ local f = fixture()
+ f.synchronous = true
+ T.check(
+ f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "set_flag", "mod:after" } }),
+ "synchronous battle admitted"
+ )
+ T.eq(f.exports.result().success, true, "synchronous battle completion handled")
+ T.eq(f.after, 1, "synchronous cleanup once")
+ f.restore()
+end
+for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do
+ for _, outcome in ipairs({ "win", "lose", "run", "caught" }) do
+ local f = fixture(v)
+ local def = { name = "NPC", index = 1, localId = 1, x = 1, y = 1, eventFlag = "FLAG_A", flag = 7 }
+ f.ow.map.def.objects = { def }
+ f.ow.talkNpc = { def = def, cellX = 1, cellY = 1 }
+ function f.ow:syncObjectVisibility()
+ f.hidden = true
+ end
+ function f.ow:setMap()
+ f.hidden = f.game.save.objectToggles[f.home].NPC == false
+ end
+ T.check(
+ f.exports.queue({ { "static_battle", "MEW", 5, "mod:beat" }, { "set_flag", "mod:after" } }),
+ "static NPC battle admitted"
+ )
+ T.eq(f.hidden, nil, "NPC stays visible during battle")
+ f:complete(outcome)
+ f:drain()
+ T.eq(f.exports.result().success, true, "static NPC continuation succeeds")
+ T.eq(f.hidden == true, outcome ~= "lose", "static NPC hidden on every non-loss outcome")
+ T.eq(f.exports.get("mod:after"), true, "static object update resumes later rows")
+ f.restore()
+ end
+end
+-- Actual native constructors and Pokémon creation, retaining the entry timing seam.
+for _, v in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do
+ local f = fixture(v)
+ f.restore()
+ local data = T.fixtures.fresh()
+ data.pokemon.MEW = require("src.mods.Merge").deepCopy(data.pokemon.FIXMON_A)
+ data.pokemon.MEW.id, data.pokemon.MEW.name = "MEW", "MEW"
+ data.constants.gen = Version.generation(v)
+ f.game.data = data
+ f.game.save = require("src.core.SaveData").newGame()
+ f.game.session = f.game.save
+ f.game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 15) }
+ require("src.render.Font").load(data)
+ T.check(
+ f.exports.queue({ { "static_battle", "MEW", 5, "mod:beat" }, { "set_flag", "mod:after" } }),
+ v .. " native constructor admitted"
+ )
+ T.eq(f.battle.kind, "wild", "native battle kind")
+ T.eq(f.battle.enemy.mon.species, "MEW", "native enemy species")
+ f:complete("caught")
+ f:drain()
+ T.eq(f.after, 1, "native cleanup retained")
+ T.eq(f.exports.get("mod:beat"), true, "actual native battle resumes static suffix")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("Portable script battles")
diff --git a/tests/engine/portable_script_fields.lua b/tests/engine/portable_script_fields.lua
new file mode 100644
index 000000000..81f85b718
--- /dev/null
+++ b/tests/engine/portable_script_fields.lua
@@ -0,0 +1,146 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.script_fields")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(v)
+ return Contract.fixture("tests/api3_contract", "gen2", v or "red")
+end
+for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v)
+ T.check(ok, v .. " shared field contract: " .. tostring(why))
+end
+local cyclic = {}
+cyclic.self = cyclic
+local deep = {}
+local node = deep
+for _ = 1, 18 do
+ node.child = {}
+ node = node.child
+end
+local wide = {}
+for i = 1, 600 do
+ wide[i] = i
+end
+for _, row in ipairs({
+ { "set_field", "modData", {} },
+ { "set_field", "mod:" },
+ { "set_field", "mod.other.x", 1 },
+ { "set_field", "foreign:x", 1 },
+ { "set_option", "x\n", 1 },
+ { "set_field", "", 1 },
+ { "set_field", false, 1 },
+ { "set_field", "x", function() end },
+ { "set_field", "x", 0 / 0 },
+ { "set_field", "x", math.huge },
+ { "set_field", "x", cyclic },
+ { "set_field", "x", deep },
+ { "set_field", "x", wide },
+ { "set_field", "x", string.rep("a", 32769) },
+ { "set_field", "x", { [false] = 1 } },
+ { "set_field", "x", setmetatable({}, {}) },
+ { "set_option", "x", { nested = function() end } },
+ { "set_option", "x", 1, 2 },
+}) do
+ local f = fixture()
+ T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, row }), nil, "invalid field rejected")
+ T.eq(f.exports.get("mod:prefix"), false, "invalid field prevents prefix")
+end
+for _, store in ipairs({ "options", "modData", "bucket" }) do
+ for _, value in ipairs({ false, setmetatable({}, {}) }) do
+ local f = fixture()
+ local verb, key = "set_option", "choice"
+ if store == "bucket" then
+ f.game.save.modData.script_fields = value
+ verb, key = "set_field", "mod:x"
+ else
+ f.game.save[store] = value
+ if store == "modData" then
+ verb, key = "set_field", "mod:x"
+ end
+ end
+ T.eq(
+ f.exports.queue({ { "set_field", "portableRoot", true }, { verb, key, 1 } }),
+ nil,
+ "invalid store refused"
+ )
+ T.eq(f.game.save.portableRoot, nil, "store failure before prefix")
+ end
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "wait", 1 }, { "set_field", "mod:x", 1 }, { "set_flag", "mod:after" } }),
+ "changed bucket fixture"
+ )
+ f.game.save.modData.script_fields = false
+ f:drain()
+ T.eq(f.exports.result().success, false, "store revalidated at dispatch")
+ T.eq(f.game.save.modData.script_fields, false, "changed bucket retained")
+ T.eq(f.exports.get("mod:after"), nil, "changed bucket has no continuation")
+end
+do
+ local f = fixture()
+ T.eq(
+ f.exports.queue({ { "set_field", "options", false }, { "set_option", "x", 1 } }),
+ nil,
+ "future options shape preflight"
+ )
+ T.eq(f.game.save.options, nil, "future invalid store leaves original untouched")
+end
+do
+ local f = fixture()
+ local value = { a = { 3, false, "x" } }
+ T.check(
+ f.exports.queue({
+ { "set_field", "mod:a", value },
+ { "set_field", "portableRoot", 9 },
+ { "set_option", "portableChoice", true },
+ }),
+ "values written"
+ )
+ f:drain()
+ T.eq(f.game.save.modData.script_fields.a.a[2], false, "false preserved")
+ value.a[1] = 7
+ T.eq(f.game.save.modData.script_fields.a.a[1], 3, "caller table not aliased")
+ T.check(
+ f.exports.queue({ { "set_field", "mod:a" }, { "set_field", "portableRoot" }, {
+ "set_option",
+ "portableChoice",
+ } }),
+ "omitted value deletes"
+ )
+ f:drain()
+ T.eq(f.game.save.modData.script_fields.a, nil, "private field cleared")
+ T.eq(f.game.save.portableRoot, nil, "root field cleared")
+ T.eq(f.game.save.options.portableChoice, nil, "option cleared")
+end
+do
+ local F = require("src.mods.ScriptFields")
+ local game = { save = {} }
+ local rows = { { "set_field", "mod:x", { n = 1 } } }
+ local run = assert(F.prepare(game, "owner", rows))
+ run({}, rows[1])
+ game.save.modData.owner.x.n = 4
+ run({}, rows[1])
+ T.eq(game.save.modData.owner.x.n, 1, "repeated dispatch copies compiled value")
+ local rows = {}
+ for i = 1, 5 do
+ rows[i] = { "set_field", "mod:x", string.rep("a", 32768) }
+ end
+ T.eq(F.prepare(game, "owner", rows), nil, "aggregate data string limit")
+ local rows = {}
+ for i = 1, 20 do
+ local v = {}
+ for j = 1, 250 do
+ v[j] = j
+ end
+ rows[i] = { "set_field", "mod:x", v }
+ end
+ T.eq(F.prepare(game, "owner", rows), nil, "aggregate data node limit")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5]
+love = saved[6]
+T.finish()
diff --git a/tests/engine/portable_script_flags.lua b/tests/engine/portable_script_flags.lua
new file mode 100644
index 000000000..0f25d1d66
--- /dev/null
+++ b/tests/engine/portable_script_flags.lua
@@ -0,0 +1,141 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.script_flags")
+local Program = require("src.mods.ScriptProgram")
+local Loader = require("src.mods.Loader")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(version)
+ return Contract.fixture("tests/api3_contract", "gen2", version or "red")
+end
+for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version)
+ T.check(ok, version .. " shared script flags: " .. tostring(why))
+end
+for _, verb in ipairs({ "set_flag", "clear_flag", "check_flag", "wait_flag", "setflag", "clearflag" }) do
+ T.eq(Program.supports(verb), true, "flag verb advertised")
+end
+for _, rows in ipairs({
+ { { "set_flag", "mod:prefix" }, { "set_flag", "no_such_flag" } },
+ { { "jump", "end" }, { "set_flag", "no_such_flag" } },
+ { { "set_flag", "mod:prefix" }, { "check_flag", "mod.other.secret" } },
+ { { "set_flag", {} } },
+ { { "set_flag", "mod:" } },
+ { { "set_flag", "mod:x", true } },
+ { { "wait_flag", "mod:x", -1 } },
+ { { "wait_flag", "mod:x", 0.5 } },
+ { { "wait_flag", "mod:x", math.huge } },
+ { { "wait_flag", "mod:x", 36001 } },
+ { { "wait_flag", "mod:x", "5" } },
+}) do
+ local f = fixture()
+ local runner = f.ow.runner
+ local value, why = f.exports.queue(rows)
+ T.eq(value, nil, "entire invalid list declined")
+ T.check(type(why) == "string", "invalid row explained")
+ T.eq(f.exports.get("mod:prefix"), false, "no accepted prefix executes")
+ T.eq(f.ow.runner, runner, "no runner replacement on refusal")
+ T.eq(f.exports.result().done, 0, "no completion on refusal")
+end
+do
+ local f = fixture()
+ f.game.save.modData = false
+ T.eq(f.exports.queue({ { "set_flag", "mod:prefix" } }), nil, "malformed store rejected before admission")
+ T.eq(f.game.save.modData, false, "malformed store not overwritten")
+end
+do
+ local f = fixture()
+ local rows = { { "wait_flag", "mod:signal" }, { "set_flag", "mod:copied" } }
+ T.check(f.exports.queue(rows), "indefinite wait admitted")
+ rows[2][2] = "mod:mutated"
+ for _ = 1, 5 do
+ f:tick()
+ end
+ T.eq(f.exports.result().done, 0, "omitted timeout keeps waiting")
+ T.check(f.exports.set("mod:signal", true), "external signal")
+ f:drain()
+ T.eq(f.exports.get("mod:copied"), true, "admitted rows detached")
+ T.eq(f.exports.get("mod:mutated"), false, "caller mutation ignored")
+end
+for _, timeout in ipairs({ 0, 1, 2 }) do
+ local f = fixture()
+ T.check(
+ f.exports.queue({
+ { "wait_flag", "mod:signal", timeout },
+ { "jump_if_true", "end" },
+ {
+ "set_flag",
+ "mod:timed",
+ },
+ }),
+ "timeout admitted"
+ )
+ T.eq(f.exports.result().done, 0, "even zero yields once")
+ f:drain()
+ T.eq(f.exports.get("mod:timed"), true, "timeout sets lastCheck false")
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "wait_flag", "mod:signal", 1 }, { "set_flag", "mod:resumed" } }),
+ "wait admitted before overlay"
+ )
+ local overlay = {}
+ f.game.stack.states[2] = overlay
+ for _ = 1, 5 do
+ f:tick()
+ end
+ T.eq(f.exports.result().done, 0, "foreign overlay pauses timeout")
+ T.eq(f.game.stack.states[2], overlay, "owned wait does not pop foreign screen")
+ f.game.stack.states[2] = nil
+ f:drain()
+ T.eq(f.exports.result().success, true, "wait resumes after uncovering")
+end
+for _, change in ipairs({ "save", "data", "map", "version", "rollback", "teardown" }) do
+ local f = fixture()
+ local original = f.ow.runner
+ T.check(
+ f.exports.queue({ { "wait_flag", "mod:signal" }, { "set_flag", "mod:late" } }),
+ "wait starts before " .. change
+ )
+ local owned = f.ow.runner
+ if change == "version" then
+ Version.set("blue")
+ elseif change == "rollback" then
+ f.loader:_rollback("script_flags")
+ elseif change == "teardown" then
+ Loader.teardown()
+ elseif change == "map" then
+ f.ow.map = { id = f.home, def = f.ow.map.def }
+ else
+ f.game[change] = {}
+ end
+ f:tick()
+ T.eq(f.exports.result().done, 1, "changed context completes once")
+ T.eq(f.exports.result().success, false, "changed context cancels wait")
+ T.eq(f.ow.runner, original, "original runner restored")
+ owned:resume(true)
+ T.eq(f.exports.result().done, 1, "late condition cannot replay completion")
+end
+for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do
+ local f = fixture(version)
+ local ref = version == "red" and 0 or (f.gen == 2 and 16 or 1)
+ T.check(
+ f.exports.queue({
+ { "set_flag", ref },
+ { "check_flag", ref },
+ { "jump_if_false", "end" },
+ { "set_flag", "mod:native" },
+ { "clear_flag", ref },
+ }),
+ "native flag program admitted"
+ )
+ f:drain()
+ T.eq(f.exports.get("mod:native"), true, "native condition drives control flow")
+ T.eq(f.exports.get(ref), false, "native flag cleared")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("Portable script flags")
diff --git a/tests/engine/portable_script_gifts.lua b/tests/engine/portable_script_gifts.lua
new file mode 100644
index 000000000..f1a42964e
--- /dev/null
+++ b/tests/engine/portable_script_gifts.lua
@@ -0,0 +1,199 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.script_gifts")
+local Loader = require("src.mods.Loader")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(version)
+ return Contract.fixture("tests/api3_contract", "gen2", version or "red")
+end
+local function tick(f, key)
+ f.game.input.wasPressed = function(_, button)
+ return button == key
+ end
+ f.loader.hooks:call("input.step", function() end, f.game, 1 / 60)
+ f.game.stack:update(1 / 60)
+ f.game.input.wasPressed = function()
+ return false
+ end
+end
+local function close(f)
+ for _ = 1, 2000 do
+ if f.exports.result().done > 0 then
+ return
+ end
+ tick(f, "a")
+ end
+ error("gift UI did not close")
+end
+for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version)
+ T.check(ok, version .. " shared gift contract: " .. tostring(why))
+end
+for _, rows in ipairs({
+ { { "give_item", "POTION", 1, false }, { "give_pokemon", "UNKNOWN", 5, true, false } },
+ { { "give_item", "POTION", 0 } },
+ { { "give_item", "POTION", 1000 } },
+ { { "give_item", "POTION", 1, true } },
+ { { "give_item", "POTION", 1, "\255" } },
+ { { "give_pokemon", "MEW", 0 } },
+ { { "give_pokemon", "MEW", 101 } },
+ { { "give_pokemon", "MEW", 5, "yes" } },
+ { { "give_pokemon", "MEW", 5, true, "text" } },
+}) do
+ local f = fixture()
+ T.eq(f.exports.queue(rows), nil, "invalid gift list refused")
+ T.eq(f.quantity(), 3, "gift prefix does not write inventory")
+ T.eq(#f.game.save.party, 1, "gift prefix does not create party mon")
+ T.eq(f.exports.result().done, 0, "refusal has no completion")
+end
+for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do
+ local f = fixture(version)
+ T.check(
+ f.exports.queue({ { "give_item", "POTION", 1, "RECEIVED POTION!" }, { "set_flag", "mod:after" } }),
+ "native receipt admitted"
+ )
+ T.eq(f.quantity(), 4, "native bag already contains received item")
+ T.eq(f.exports.result().done, 0, "script waits for native receipt")
+ close(f)
+ T.eq(f.exports.result().success, true, "receipt completes native script")
+ T.eq(f.exports.get("mod:after"), true, "next command runs after receipt only")
+ T.eq(f.game.stack:top(), f.ow, "owned receipt removed")
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "give_pokemon", "MEW", 5, true, true }, { "set_flag", "mod:after" } }),
+ "native mon receipt admitted"
+ )
+ T.eq(#f.game.save.party, 2, "native mon gift created once")
+ T.eq(f.exports.result().done, 0, "native mon receipt waits")
+ close(f)
+ T.eq(f.exports.result().success, true, "native mon receipt complete")
+ T.eq(f.exports.get("mod:after"), true, "mon gift resumes script")
+end
+do
+ local f = fixture()
+ f.game.save.inventory.POTION = 99
+ T.check(
+ f.exports.queue({ { "give_item", "POTION", 1, false, "BAG IS FULL!" }, { "set_flag", "mod:burned" } }),
+ "full bag admitted for native refusal UI"
+ )
+ T.eq(f.quantity(), 99, "full bag adds nothing")
+ T.eq(f.exports.result().done, 0, "bag full message awaited")
+ close(f)
+ T.eq(f.exports.result().success, true, "native full bag halts cleanly")
+ T.eq(f.exports.get("mod:burned"), false, "full bag cannot burn later gift flag")
+end
+for _, action in ipairs({ "rollback", "teardown", "save" }) do
+ local f = fixture()
+ local original = f.ow.runner
+ T.check(
+ f.exports.queue({ { "give_item", "POTION", 1, "RECEIVED!" }, { "set_flag", "mod:after" } }),
+ "receipt before " .. action
+ )
+ local box = f.game.stack:top()
+ local overlay = { update = function() end }
+ f.game.stack:push(overlay)
+ if action == "rollback" then
+ f.loader:_rollback("script_gifts")
+ elseif action == "teardown" then
+ Loader.teardown()
+ else
+ f.game.save = {}
+ tick(f)
+ end
+ T.eq(f.game.stack:top(), overlay, "foreign overlay retained")
+ T.eq(#f.game.stack.states, 2, "only owned receipt removed")
+ T.eq(f.ow.runner, original, "native original runner restored")
+ T.eq(f.exports.result().done, 1, "cancelled receipt completes once")
+ T.eq(f.exports.result().success, false, "cancelled receipt reports failure")
+ box.onDone()
+ T.eq(f.exports.result().done, 1, "retained native callback cannot replay cancellation")
+ T.eq(f.game.stack:top(), overlay, "retained native callback preserves foreign overlay")
+end
+for _, field in ipairs({ "player", "party", "pokedex", "boxes", "boxNames", "currentBox" }) do
+ local f = fixture()
+ f.game.save[field] = false
+ T.eq(
+ f.exports.queue({ { "give_item", "POTION", 1, false }, { "give_pokemon", "MEW", 5, true, false } }),
+ nil,
+ "malformed " .. field .. " preflight"
+ )
+ T.eq(f.quantity(), 3, "malformed gift destination prevents prefix")
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "give_pokemon", "MEW", 5, false, false }, { "wait", 5 }, { "set_flag", "mod:after" } }),
+ "native nickname flow admitted"
+ )
+ local question = f.game.stack:top()
+ local Choice = require("src.ui.ChoiceBox")
+ for _ = 1, 2000 do
+ if getmetatable(f.game.stack:top()) == Choice then
+ break
+ end
+ tick(f, "a")
+ end
+ T.eq(getmetatable(f.game.stack:top()), Choice, "native question opens native choice")
+ tick(f, "a")
+ for _ = 1, 40 do
+ tick(f)
+ end
+ local naming = f.game.stack:top()
+ T.check(
+ naming ~= f.ow and naming ~= question and getmetatable(naming) ~= Choice,
+ "native naming screen opened"
+ )
+ T.eq(type(naming.onDone), "function", "native naming completion exists")
+ question.onDone()
+ question.choice(false)
+ T.eq(f.game.stack:top(), naming, "old question callbacks cannot bypass naming")
+ T.eq(f.exports.result().done, 0, "old question cannot complete gift")
+ f.game.stack:pop()
+ naming.onDone("SCOUT")
+ naming.onDone("REPLAY")
+ T.eq(f.game.save.party[2].nickname, "SCOUT", "native naming callback commits once")
+ T.eq(f.exports.result().done, 0, "stale callback cannot skip later wait")
+ for _ = 1, 5 do
+ tick(f)
+ end
+ T.eq(f.exports.get("mod:after"), true, "native nickname flow resumes normally")
+end
+do
+ local f = fixture()
+ for i = 2, 6 do
+ f.game.save.party[i] = require("src.mods.Merge").deepCopy(f.game.save.party[1])
+ end
+ T.check(
+ f.exports.queue({ { "give_pokemon", "MEW", 5, true, false }, { "set_flag", "mod:boxed" } }),
+ "native box gift admitted"
+ )
+ T.eq(#f.game.save.party, 6, "full party retained")
+ T.check(f.game.save.boxes[1][1], "actual native box receives gift")
+ T.eq(f.exports.result().done, 0, "native sent-to-box message awaited")
+ close(f)
+ T.eq(f.exports.get("mod:boxed"), true, "box message resumes script")
+end
+do
+ local f = fixture()
+ local party = f.game.save.party
+ f.game.mods = { events = f.loader.events }
+ f.loader.events:on("pokemon.before_give", function()
+ f.loader:_rollback("script_gifts")
+ end, 0, "probe")
+ T.check(
+ f.exports.queue({ { "give_pokemon", "MEW", 5, true, false } }),
+ "gift admission reaches native hook"
+ )
+ T.eq(#party, 1, "owner retired in before_give cannot construct a gift")
+ T.eq(f.exports.result().done, 1, "mid-construction cancellation completes once")
+ T.eq(f.exports.result().success, false, "mid-construction cancellation reported")
+ T.eq(f.game.stack:top(), f.ow, "cancelled native gift opens no late UI")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("Portable script gifts")
diff --git a/tests/engine/portable_script_movement.lua b/tests/engine/portable_script_movement.lua
new file mode 100644
index 000000000..3cf4e8581
--- /dev/null
+++ b/tests/engine/portable_script_movement.lua
@@ -0,0 +1,189 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.script_motion")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(version)
+ return Contract.fixture("tests/api3_contract", "gen2", version or "red")
+end
+local function dispose(f)
+ f.restoreRenderer()
+end
+for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version)
+ T.check(ok, version .. " motion contract: " .. tostring(why))
+end
+for _, rows in ipairs({
+ { { "face_player_dir", "up" }, { "move_npc", 99, "down" } },
+ { { "move_player", "north" } },
+ { { "move_player", "down", -1 } },
+ { { "move_player", "down", 1025 } },
+ { { "move_npc_to", 1, 0.5, 0 } },
+ { { "place_npc", 1, 0, 0, "north" } },
+ { { "place_npc", 1, 0, math.huge } },
+ { { "face_object", 1, "down", { hold = -1 } } },
+ { { "face_object", 1, "down", { other = 1 } } },
+ { { "face", "down" } },
+ { { "face_player" } },
+ { { "face_npc" } },
+}) do
+ local f = fixture()
+ T.eq(f.exports.queue(rows), nil, "invalid later command preflight")
+ T.eq(f.player.facing, "left", "invalid prefix does not turn player")
+ T.eq(#f.ow.scriptMoves, 0, "preflight creates no native track")
+ dispose(f)
+end
+for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do
+ for _, kind in ipairs({ "player", "npc" }) do
+ local f = fixture(version)
+ local actor = kind == "player" and f.player or f.imported
+ local row = kind == "player" and { "move_player", "left", 2 } or { "move_npc", 1, "left", 2 }
+ local x = actor.cellX
+ T.check(f.exports.queue({ row, { "set_flag", "mod:after" } }), "two-step movement admitted")
+ local old = f.pending.callback
+ f:complete()
+ local overlay = {}
+ f.game.stack.states[2] = overlay
+ old()
+ old()
+ for _ = 1, 4 do
+ f:tick()
+ end
+ T.eq(actor.cellX, x - 1, "overlay pauses next owned step")
+ T.eq(f.exports.result().done, 0, "old callback cannot skip movement")
+ f.game.stack.states[2] = nil
+ f:drain()
+ T.eq(actor.cellX, x - 2, "two native steps completed once")
+ T.eq(f.exports.result().success, true, "native continuation completes")
+ T.eq(f.exports.get("mod:after"), true, "only finished walk resumes script")
+ dispose(f)
+ end
+end
+for _, action in ipairs({ "cancel", "rollback", "save", "map", "actor" }) do
+ local f = fixture()
+ local original = f.ow.runner
+ T.check(
+ f.exports.queue({ { "move_npc", 1, "left", 2 }, { "set_flag", "mod:after" } }),
+ "cancellable track admitted"
+ )
+ local callback = f.pending.callback
+ local foreign = { entity = f.imported, dir = "up", remaining = 1, onDone = function() end }
+ f.ow.scriptMoves[#f.ow.scriptMoves + 1] = foreign
+ if action == "cancel" then
+ require("src.mods.PortableScripts").cancelAll(f.loader, "script_motion", "probe cancellation")
+ elseif action == "rollback" then
+ f.loader:_rollback("script_motion")
+ elseif action == "save" then
+ f.game.save = {}
+ elseif action == "map" then
+ f.ow.map = { id = f.home }
+ else
+ f.ow.npcs = {}
+ end
+ f:tick()
+ T.eq(f.exports.result().done, 1, "retired movement completes once")
+ T.eq(f.exports.result().success, false, "retirement fails script")
+ T.eq(#f.ow.scriptMoves, 1, "only owned track removed")
+ T.eq(f.ow.scriptMoves[1], foreign, "foreign actor track retained")
+ callback()
+ callback()
+ T.eq(f.exports.result().done, 1, "retained callback cannot replay")
+ T.eq(f.ow.runner, original, "original runner restored")
+ dispose(f)
+end
+do
+ local f = fixture()
+ f.synchronous = true
+ T.check(
+ f.exports.queue({ { "move_player", "left", 2 }, { "set_flag", "mod:after" } }),
+ "synchronous native steps admitted"
+ )
+ T.eq(f.exports.result().success, true, "synchronous callback completes without yielding forever")
+ T.eq(f.player.cellX, 5, "synchronous two-step position")
+ dispose(f)
+end
+for _, failure in ipairs({ "refuse", "throw", "unload" }) do
+ local f = fixture()
+ local native = f.ow.scriptMove
+ function f.ow:scriptMove(...)
+ native(self, ...)
+ if failure == "refuse" then
+ return false, "probe refusal"
+ elseif failure == "throw" then
+ error("probe native error")
+ else
+ f.loader:_rollback("script_motion")
+ end
+ end
+ T.check(
+ f.exports.queue({ { "move_player", "left", 2 }, { "set_flag", "mod:after" } }),
+ "native failure after admission"
+ )
+ T.eq(f.exports.result().success, false, "native failure reported")
+ T.eq(#f.ow.scriptMoves, 0, "late native track cleaned")
+ T.eq(f.player.moving, false, "owned actor unlocked")
+ dispose(f)
+end
+do
+ local f = fixture()
+ f.ow.talkNpc = f.imported
+ T.check(
+ f.exports.queue({
+ { "face", "left" },
+ { "face_npc" },
+ { "face_player" },
+ {
+ "face_object",
+ 1,
+ "up",
+ { hold = 20 },
+ },
+ }),
+ "context facing admitted"
+ )
+ T.eq(f.exports.result().success, true, "context facing completes")
+ T.eq(f.player.facing, "up", "player faces talking NPC")
+ T.eq(f.imported.facing, "up", "arbitrary object explicit direction")
+ T.eq(f.imported.timer, 20, "bounded facing hold carried")
+ dispose(f)
+end
+do
+ local f = fixture()
+ T.check(f.exports.queue({ { "move_player", "left" } }), "stalled native track admitted")
+ for _ = 1, 3601 do
+ f:tick()
+ end
+ T.eq(f.exports.result().success, false, "stalled track times out")
+ T.eq(#f.ow.scriptMoves, 0, "timeout removes owned track")
+ dispose(f)
+end
+-- Borrow the actual production movement queue and both walker updates.
+local OW = require("src.world.OverworldController")
+local Player = require("src.world.Player")
+for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do
+ local f = fixture(version)
+ f.ow.scriptMove = OW.scriptMove
+ f.imported.frozen = true
+ f.player.turnTimer, f.player.progress = 0, 0
+ T.check(
+ f.exports.queue({ { "move_player", "left", 2 }, { "move_npc", 1, "up", 2 }, { "set_flag", "mod:after" } }),
+ version .. " native queues admitted"
+ )
+ for _ = 1, 100 do
+ OW.updateScriptMoves(f.ow)
+ Player.update(f.player)
+ f.imported:update(f.ow.map, f.ow.entities)
+ f:tick()
+ end
+ T.eq(f.player.cellX, 5, "actual player updater walks exactly two cells")
+ T.eq(f.imported.cellY, 4, "actual NPC updater walks exactly two cells")
+ T.eq(#f.ow.scriptMoves, 0, "actual native tracks released")
+ T.eq(f.exports.result().success, true, "actual native movement resumes script")
+ T.eq(f.exports.get("mod:after"), true, "native walk continuation runs once")
+ dispose(f)
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("Portable script movement")
diff --git a/tests/engine/portable_script_shops.lua b/tests/engine/portable_script_shops.lua
new file mode 100644
index 000000000..b900ac25f
--- /dev/null
+++ b/tests/engine/portable_script_shops.lua
@@ -0,0 +1,329 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.script_shops")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(v, native)
+ local f = Contract.fixture("tests/api3_contract", "gen2", v or "red")
+ f.game.save.money = 1000
+ if native then
+ f.game.data.constants = f.game.data.constants or {}
+ f.game.data.constants.gen3MartText = { buy = "BUY", sell = "SELL", quit = "QUIT" }
+ end
+ return f
+end
+local function queue(f)
+ return f.exports.queue({ { "open_mart", "SHOP" }, { "set_flag", "mod:after" } })
+end
+local function finishChoice(f)
+ f:tick("a")
+ for _ = 1, 20 do
+ f:tick()
+ end
+end
+for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v)
+ T.check(ok, v .. " shared shop contract: " .. tostring(why))
+end
+for _, change in ipairs({
+ "unknown",
+ "invalid_price",
+ "unknown_item",
+ "empty",
+ "sparse",
+ "wallet",
+ "inventory",
+ "overflow",
+}) do
+ local f = fixture()
+ local ref = "SHOP"
+ if change == "unknown" then
+ ref = "UNKNOWN"
+ elseif change == "invalid_price" then
+ f.game.data.items.POTION.price = -1
+ elseif change == "unknown_item" then
+ f.game.data.text_pointers.SHOP_HOME.SHOP.mart = { "UNKNOWN" }
+ elseif change == "empty" then
+ f.game.data.text_pointers.SHOP_HOME.SHOP.mart = {}
+ elseif change == "sparse" then
+ f.game.data.text_pointers.SHOP_HOME.SHOP.mart[3] = "POTION"
+ elseif change == "wallet" then
+ f.game.save.money = false
+ elseif change == "inventory" then
+ f.game.save.inventory.POTION = -1
+ else
+ f.game.save.money = 9007199254740991
+ end
+ T.eq(
+ f.exports.queue({ { "set_flag", "mod:prefix" }, { "open_mart", ref } }),
+ nil,
+ "invalid shop preflight: " .. change
+ )
+ T.eq(f.exports.get("mod:prefix"), false, "invalid suffix prevents prefix")
+ T.eq(f.game.stack:top(), f.ow, "preflight opens no native UI")
+end
+for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local f = fixture(v)
+ T.check(queue(f), v .. " native classic shop admitted")
+ local root = f.game.stack:top()
+ f:tick("a")
+ local list = f.game.stack:top()
+ T.eq(getmetatable(list), require("src.ui.ListMenu"), "native buy list opened")
+ f:tick("a")
+ local quantity = f.game.stack:top()
+ local oldQuantity = quantity.onDone
+ T.eq(getmetatable(quantity), require("src.ui.QuantityBox"), "native quantity picker opened")
+ f:tick("a")
+ local choice = f.game.stack:top()
+ local oldChoice = choice.onChoose
+ finishChoice(f)
+ T.eq(f.quantity(), 4, "native bag receives purchase")
+ T.eq(f.game.save.money, 900, "native wallet charged once")
+ oldQuantity(99)
+ oldChoice(true)
+ T.eq(f.quantity(), 4, "old quantity/confirm callbacks cannot replay purchase")
+ T.eq(f.game.save.money, 900, "old callbacks cannot charge again")
+ T.eq(f.game.stack:top(), list, "nested callbacks retain native list")
+ f:close()
+ f:drain()
+ T.eq(f.exports.result().success, true, "shop quit resumes script")
+ T.eq(f.exports.get("mod:after"), true, "script runs after native close")
+ root.onCancel()
+ oldQuantity(1)
+ oldChoice(true)
+ T.eq(f.game.stack:top(), f.ow, "finished UI cannot reopen a child")
+end
+for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do
+ local f = fixture(v)
+ T.check(queue(f), "native sale admitted")
+ f.game.stack:top().index = 2
+ f:tick("a")
+ f:tick("a")
+ local quantity = f.game.stack:top()
+ quantity.qty = 3
+ f:tick("a")
+ finishChoice(f)
+ T.eq(f.quantity(), 0, "native sale removes full item stack")
+ T.eq(f.game.save.money, 1150, "native sale credits half price")
+ T.eq(#f.game.save.bagOrder, 0, "native sale removes stale bag order entry")
+ f:close()
+ f:drain()
+ T.eq(f.exports.result().success, true, "native sale closes normally")
+end
+for _, change in ipairs({ "rollback", "save", "data", "price", "inventory", "removed" }) do
+ local f = fixture()
+ T.check(queue(f), "shop before live change")
+ f:tick("a")
+ f:tick("a")
+ local quantity = f.game.stack:top()
+ local callback = quantity.onDone
+ local overlay = {}
+ f.game.stack:push(overlay)
+ if change == "rollback" then
+ f.loader:_rollback("script_shops")
+ elseif change == "price" then
+ f.game.data.items.POTION.price = 200
+ elseif change == "inventory" then
+ f.game.save.inventory = { POTION = 3 }
+ elseif change == "removed" then
+ table.remove(f.game.stack.states, 2)
+ else
+ f.game[change] = {}
+ end
+ f.loader.hooks:call("input.step", function() end, f.game, 1 / 60)
+ T.eq(f.exports.result().success, false, "live context/item change cancels shop")
+ T.eq(f.game.stack:top(), overlay, "foreign overlay survives shop cancellation")
+ T.eq(#f.game.stack.states, 2, "only owned nested screens removed")
+ callback(1)
+ T.eq(#f.game.stack.states, 2, "stale quantity cannot open a confirmation")
+end
+for _, v in ipairs({ "firered", "emerald" }) do
+ local f = fixture(v, true)
+ T.check(queue(f), v .. " native Gen3 counter admitted")
+ local root = f.game.stack:top()
+ f:tick("a")
+ local counter = f.game.stack:top()
+ T.eq(getmetatable(counter), require("src.ui.Gen3ShopMenu"), "Gen3 buy counter selected")
+ f:tick("a")
+ local asking = counter.asking
+ local oldQuantity = asking and asking.onDone
+ T.check(asking, "native Gen3 inline quantity opened")
+ f:tick("a")
+ local box = f.game.stack:top()
+ local oldChoice = box.choice
+ for _ = 1, 2000 do
+ f:tick("a")
+ if f.quantity() == 4 then
+ break
+ end
+ end
+ T.eq(f.quantity(), 4, "native Gen3 confirmation purchases item")
+ T.eq(f.game.save.money, 900, "native Gen3 wallet charged once")
+ oldQuantity(5)
+ oldChoice(true)
+ T.eq(f.quantity(), 4, "old Gen3 inline callbacks cannot replay")
+ T.eq(f.game.save.money, 900, "old Gen3 callbacks cannot charge again")
+ f:close()
+ f:drain()
+ T.eq(f.exports.result().success, true, "native Gen3 counter closes")
+end
+-- A retained confirmation is inert under a foreign overlay, then the native
+-- answer still works after that overlay closes.
+do
+ local f = fixture()
+ T.check(queue(f), "shop before foreign confirmation overlay")
+ f:tick("a")
+ f:tick("a")
+ f:tick("a")
+ local choice = f.game.stack:top()
+ local callback = choice.onChoose
+ local overlay = {}
+ f.game.stack:push(overlay)
+ callback(true)
+ T.eq(f.quantity(), 3, "foreign overlay blocks direct confirmation")
+ T.eq(f.game.save.money, 1000, "foreign overlay blocks debit")
+ f.game.stack:pop()
+ finishChoice(f)
+ T.eq(f.quantity(), 4, "legitimate answer still works after overlay")
+ f:close()
+ f:drain()
+end
+for _, v in ipairs({ "firered", "emerald" }) do
+ local f = fixture(v, true)
+ T.check(queue(f), "Gen3 quantity before cancellation")
+ f:tick("a")
+ f:tick("a")
+ local counter = f.game.stack:top()
+ local old = counter.asking.onDone
+ f:tick("b")
+ old(1)
+ T.eq(f.game.stack:top(), counter, "cancelled inline quantity cannot create confirmation")
+ T.eq(f.quantity(), 3, "cancelled inline quantity cannot give item")
+ f:tick("a")
+ local current = counter.asking
+ old(1)
+ T.eq(counter.asking, current, "old inline callback cannot replace a new request")
+ f:close()
+ f:drain()
+end
+for _, v in ipairs({ "firered", "emerald" }) do
+ local f = fixture(v, true)
+ T.check(queue(f), "Gen3 native sale admitted")
+ f.game.stack:top().index = 2
+ f:tick("a")
+ f:tick("a")
+ local quantity = f.game.stack:top()
+ if v == "firered" then
+ T.eq(getmetatable(quantity), require("src.ui.Gen3ShopMenu"), "FireRed native inline sale quantity")
+ T.check(quantity.asking, "FireRed sale quantity is active")
+ quantity.asking.qty = 2
+ else
+ T.eq(getmetatable(quantity), require("src.ui.QuantityBox"), "Emerald native bag sale quantity")
+ quantity.qty = 2
+ end
+ f:tick("a")
+ for _ = 1, 2000 do
+ f:tick("a")
+ if f.quantity() == 1 then
+ break
+ end
+ end
+ T.eq(f.quantity(), 1, "Gen3 native sale removes selected quantity")
+ T.eq(f.game.save.money, 1100, "Gen3 native sale credits wallet")
+ f:close()
+ f:drain()
+ T.eq(f.exports.result().success, true, "Gen3 native sale returns to script")
+end
+for _, phase in ipairs({ "classic_quantity", "classic_choice", "gen3_quantity", "gen3_choice" }) do
+ local native = phase:find("gen3", 1, true) ~= nil
+ local f = fixture(native and "emerald" or "red", native)
+ T.check(queue(f), "native shop before phase unload")
+ f:tick("a")
+ f:tick("a")
+ local callbacks = {}
+ if native then
+ callbacks[#callbacks + 1] = f.game.stack:top().asking.onDone
+ else
+ callbacks[#callbacks + 1] = f.game.stack:top().onDone
+ end
+ if phase:find("choice", 1, true) then
+ f:tick("a")
+ local state = f.game.stack:top()
+ callbacks[#callbacks + 1] = native and state.choice or state.onChoose
+ end
+ local overlay = {}
+ f.game.stack:push(overlay)
+ f.loader:_rollback("script_shops")
+ for _, fn in ipairs(callbacks) do
+ fn(1)
+ fn(true)
+ end
+ T.eq(f.game.stack:top(), overlay, "unload keeps foreign overlay")
+ T.eq(#f.game.stack.states, 2, "unload removes exact native shop screens")
+ T.eq(f.quantity(), 3, "retired purchase callback cannot grant item")
+ T.eq(f.game.save.money, 1000, "retired purchase callback cannot debit wallet")
+end
+for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do
+ local f = fixture(v)
+ f.game.data.maps[f.dest].label = "SHOP_DEST"
+ f.game.data.text_pointers.SHOP_DEST = { DEST_SHOP = { mart = { "POTION" } } }
+ T.check(
+ f.exports.queue({ { "warp", f.dest, 1, 1 }, { "open_mart", "DEST_SHOP" }, { "set_flag", "mod:after" } }),
+ "destination shop preflight"
+ )
+ f:arrive()
+ f:complete()
+ f:tick()
+ T.check(f.game.stack:top() ~= f.ow, "destination native shop opens after arrival")
+ f:close()
+ f:drain()
+ T.eq(f.exports.result().success, true, "destination native shop resumes program")
+ f = fixture(v)
+ f.game.data.maps[f.dest].label = "SHOP_DEST"
+ f.game.data.text_pointers.SHOP_DEST = { DEST_SHOP = { mart = { "UNKNOWN" } } }
+ T.eq(
+ f.exports.queue({ { "warp", f.dest, 1, 1 }, { "open_mart", "DEST_SHOP" } }),
+ nil,
+ "invalid destination stock refuses whole script"
+ )
+ T.eq(#f.trace, 0, "invalid future stock prevents warp")
+end
+for _, change in ipairs({ "wallet", "inventory", "stock" }) do
+ local f = fixture()
+ T.check(f.exports.queue({ { "wait", 1 }, { "open_mart", "SHOP" } }), "deferred shop admitted")
+ if change == "wallet" then
+ f.game.save.money = false
+ elseif change == "inventory" then
+ f.game.save.inventory = false
+ else
+ f.game.data.items.POTION.price = -1
+ end
+ f:drain()
+ T.eq(f.exports.result().success, false, "live shop stores revalidated")
+ T.eq(f.game.stack:top(), f.ow, "bad live data opens no native shop")
+end
+do
+ local f = fixture()
+ local overlay = {}
+ local Screens = require("src.ui.Screens")
+ f.game.data.screens = {
+ ShopMenu = {
+ new = function(game, stock, onQuit)
+ f.loader:_rollback("script_shops")
+ game.stack:push(overlay)
+ return require("src.ui.ShopMenu").new(game, stock, onQuit)
+ end,
+ },
+ }
+ Screens.invalidate()
+ T.check(queue(f), "shop admitted before constructor hook retires owner")
+ T.eq(f.exports.result().success, false, "constructor retirement cancels program")
+ T.eq(f.game.stack:top(), overlay, "constructor retirement preserves foreign overlay")
+ T.eq(#f.game.stack.states, 2, "late native root removed after constructor returns")
+ Screens.invalidate()
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("Portable script shops")
diff --git a/tests/engine/portable_script_state.lua b/tests/engine/portable_script_state.lua
new file mode 100644
index 000000000..4dd84c487
--- /dev/null
+++ b/tests/engine/portable_script_state.lua
@@ -0,0 +1,109 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.script_state")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(version)
+ return Contract.fixture("tests/api3_contract", "gen2", version or "red")
+end
+for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version)
+ T.check(ok, version .. " shared state actions: " .. tostring(why))
+end
+for _, rows in ipairs({
+ { { "give_money", 1 }, { "take_item", "UNKNOWN" } },
+ { { "jump", "end" }, { "mark_seen", "UNKNOWN" } },
+ { { "give_money", -1 } },
+ { { "take_money", 0.5 } },
+ { { "give_money", math.huge } },
+ { { "check_money", "10" } },
+ { { "take_coins", false } },
+ { { "take_item", "POTION", 0 } },
+ { { "take_item", "POTION", 1000 } },
+ { { "check_dex_owned", -1 } },
+ { { "heal_party", true } },
+ { { "check_item", {} } },
+}) do
+ local f = fixture()
+ local runner = f.ow.runner
+ T.eq(f.exports.queue(rows), nil, "whole invalid program declined")
+ T.eq(f.wallet(), 100, "money prefix not executed")
+ T.eq(f.quantity(), 3, "inventory untouched")
+ T.eq(f.ow.runner, runner, "no runner installed on refusal")
+ T.eq(f.exports.result().done, 0, "no completion on refusal")
+end
+for _, store in ipairs({ "money", "coins", "inventory", "bagOrder", "pokedex", "party" }) do
+ local f = fixture()
+ f.game.save[store] = false
+ local row = ({
+ money = { "give_money", 1 },
+ coins = { "take_coins", 1 },
+ inventory = { "take_item", "POTION" },
+ bagOrder = { "take_item", "POTION" },
+ pokedex = { "mark_seen", "MEW" },
+ party = { "heal_party" },
+ })[store]
+ T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, row }), nil, "malformed " .. store .. " declined")
+ T.eq(f.exports.get("mod:prefix"), false, "store failure prevents prefix")
+ T.eq(f.game.save[store], false, "malformed store not overwritten")
+end
+do
+ local f = fixture()
+ f.game.save.party[1].stats = nil
+ T.eq(
+ f.exports.queue({ { "give_money", 1 }, { "heal_party" } }),
+ nil,
+ "malformed party record refused up front"
+ )
+ T.eq(f.wallet(), 100, "party refusal preserves wallet")
+end
+for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do
+ local f = fixture(version)
+ f.game.save.money = 999990
+ T.check(
+ f.exports.queue({ { "give_money", 50 }, { "take_coins", 999 }, { "take_item", "POTION", 999 } }),
+ "bounded state operations admitted"
+ )
+ f:drain()
+ T.eq(f.wallet(), version == "red" and 1000040 or 999999, "native generation wallet cap")
+ T.eq(f.game.save.coins, 0, "coin subtraction saturates at zero")
+ T.eq(f.quantity(), 0, "item subtraction removes available quantity only")
+ T.eq(#f.game.save.bagOrder, 0, "native bag order entry removed")
+end
+do
+ local f = fixture()
+ f.game.save.money = 9007199254740991
+ T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, { "give_money", 1 } }), nil, "wallet overflow refused")
+ T.eq(f.exports.get("mod:prefix"), false, "overflow refusal prevents prefix")
+ T.eq(f.game.save.money, 9007199254740991, "overflow cannot round native wallet")
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "check_dex_owned" }, { "jump_if_false", "end" }, { "set_flag", "mod:owned" } }),
+ "default dex threshold admitted"
+ )
+ f:drain()
+ T.eq(f.exports.get("mod:owned"), true, "default dex threshold is one")
+end
+do
+ local f = fixture()
+ T.check(f.exports.queue({ { "wait", 1 }, { "take_item", "POTION", 1 } }), "state action waits")
+ f.game.save.inventory = false
+ f:drain()
+ T.eq(f.exports.result().success, false, "changed live store fails safely")
+ T.eq(f.game.save.inventory, false, "changed malformed store not overwritten")
+end
+do
+ local f = fixture()
+ local rows = { { "wait", 1 }, { "give_money", 10 } }
+ T.check(f.exports.queue(rows), "copy program admitted")
+ rows[2][2] = 100
+ f:drain()
+ T.eq(f.wallet(), 110, "accepted scalar arguments detached")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("Portable script state actions")
diff --git a/tests/engine/portable_script_trainers.lua b/tests/engine/portable_script_trainers.lua
new file mode 100644
index 000000000..022ecd236
--- /dev/null
+++ b/tests/engine/portable_script_trainers.lua
@@ -0,0 +1,198 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.script_trainers")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(v)
+ return Contract.fixture("tests/api3_contract", "gen2", v or "red")
+end
+for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v)
+ T.check(ok, v .. " shared trainer contract: " .. tostring(why))
+end
+for _, rows in ipairs({
+ { { "set_flag", "mod:prefix" }, { "start_battle", "trainer", 999, 1 } },
+ { { "start_battle", "trainer", {}, 1 } },
+ { { "start_battle", "trainer", "PROBE", 0 } },
+ { { "start_battle", "trainer", "PROBE", 256 } },
+ { { "start_battle", "trainer", "PROBE", 2 } },
+ { { "start_battle", "trainer", "PROBE", 1, {} } },
+}) do
+ local f = fixture()
+ T.eq(f.exports.queue(rows), nil, "invalid trainer preflight")
+ T.eq(f.constructed, 0, "invalid trainer does not construct")
+ T.eq(f.exports.get("mod:prefix"), false, "invalid suffix prevents prefix")
+ f.restore()
+end
+for _, change in ipairs({ "species", "level", "empty", "oversized", "ambiguous" }) do
+ local f = fixture("gold")
+ local row = f.game.data.trainers.PROBE
+ if change == "species" then
+ row.parties[1][1].species = "UNKNOWN"
+ elseif change == "level" then
+ row.parties[1][1].level = 101
+ elseif change == "empty" then
+ row.parties[1] = {}
+ elseif change == "oversized" then
+ for i = 2, 7 do
+ row.parties[1][i] = row.parties[1][1]
+ end
+ else
+ f.game.data.trainers.OTHER = { id = "OTHER", index = 7, parties = row.parties }
+ end
+ T.eq(
+ f.exports.queue({ { "set_flag", "mod:prefix" }, { "start_battle", "trainer", 7, 1 } }),
+ nil,
+ "invalid native trainer data: " .. change
+ )
+ T.eq(f.constructed, 0, "invalid data never constructs")
+ T.eq(f.exports.get("mod:prefix"), false, "invalid data never runs prefix")
+ f.restore()
+end
+do
+ local f = fixture("gold")
+ f.game.data.trainers.ALIAS = require("src.mods.Merge").deepCopy(f.game.data.trainers.PROBE)
+ T.check(f.exports.queue({ { "start_battle", "trainer", 7, 1 } }), "canonical native alias resolves once")
+ T.eq(f.battle.class, "PROBE", "native class identity selected deterministically")
+ f:complete("win")
+ f:drain()
+ f.restore()
+end
+for _, change in ipairs({ "missing_order", "ambiguous_order", "missing_base", "invalid_flags", "member" }) do
+ local f = fixture("firered")
+ local row = { "start_battle", "trainer", 7, 1 }
+ if change == "missing_order" then
+ f.game.data.constants.trainerOrder = {}
+ elseif change == "ambiguous_order" then
+ f.game.data.constants.trainerOrder[8] = "PROBE"
+ elseif change == "missing_base" then
+ f.game.data.constants.gen3TrainerFlagBase = nil
+ elseif change == "invalid_flags" then
+ f.game.save.flags = false
+ else
+ row[4] = 2
+ end
+ T.eq(f.exports.queue({ row }), nil, "unrepresentable Gen3 trainer refused: " .. change)
+ T.eq(f.constructed, 0, "Gen3 refusal has no constructor")
+ f.restore()
+end
+for _, change in ipairs({ "rollback", "save", "data", "player", "cancel" }) do
+ local f = fixture("firered")
+ T.check(
+ f.exports.queue({ { "start_battle", "trainer", 7, 1 }, { "set_flag", "mod:after" } }),
+ "trainer admitted before retirement"
+ )
+ local callback = f.finish
+ if change == "rollback" then
+ f.loader:_rollback("script_trainers")
+ elseif change == "player" then
+ f.ow.player = {}
+ elseif change == "cancel" then
+ require("src.mods.PortableScripts").cancelAll(f.loader, "script_trainers")
+ else
+ f.game[change] = {}
+ end
+ f:tick()
+ T.eq(f.game.stack:top(), f.battle, "trainer native screen survives script retirement")
+ f:complete("win")
+ callback("lose")
+ T.eq(f.exports.result().success, false, "retired trainer cannot continue")
+ T.eq(
+ f.after,
+ (change == "rollback" or change == "cancel") and 1 or 0,
+ "native cleanup only on same session"
+ )
+ T.eq(f.exports.result().done, 1, "late trainer callback completes once")
+ if change == "rollback" or change == "cancel" then
+ T.eq(
+ f.game.save.flags.FLAG_G3_0507,
+ true,
+ "native trainer victory flag survives originating mod retirement"
+ )
+ end
+ f.restore()
+end
+do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "wait", 1 }, { "start_battle", "trainer", "PROBE", 1 } }),
+ "deferred trainer preflight"
+ )
+ f.game.data.trainers.PROBE.parties[1][1].species = "UNKNOWN"
+ f:drain()
+ T.eq(f.exports.result().success, false, "trainer data revalidated at dispatch")
+ T.eq(f.constructed, 0, "changed live roster cannot construct")
+ f.restore()
+end
+for _, failure in ipairs({ "constructError", "pushFailure", "cleanupError", "constructUnload" }) do
+ local f = fixture()
+ if failure == "constructUnload" then
+ local native = package.loaded["src.battle.BattleState"].newTrainer
+ package.loaded["src.battle.BattleState"].newTrainer = function(...)
+ local result = native(...)
+ f.loader:_rollback("script_trainers")
+ return result
+ end
+ else
+ f[failure] = true
+ end
+ T.check(
+ f.exports.queue({ { "start_battle", "trainer", "PROBE", 1 }, { "set_flag", "mod:after" } }),
+ "trainer native failure admitted"
+ )
+ if failure == "cleanupError" then
+ f:complete("win")
+ end
+ T.eq(f.exports.result().success, false, "trainer native failure terminates")
+ T.eq(f.exports.get("mod:after"), false, "trainer native failure suppresses suffix")
+ if failure == "constructUnload" then
+ T.eq(f.pushed, 0, "retired constructor cannot push trainer battle")
+ end
+ f.restore()
+end
+for _, v in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do
+ for _, outcome in ipairs({ "win", "lose" }) do
+ local f = fixture(v)
+ f.restore()
+ local data = T.fixtures.fresh()
+ data.trainers.PROBE = {
+ id = "PROBE",
+ index = 7,
+ name = "PROBE",
+ baseMoney = 10,
+ parties = { { { species = "FIXMON_B", level = 5 } } },
+ }
+ data.constants.gen = Version.generation(v)
+ data.constants.trainerOrder = { [7] = "PROBE" }
+ data.constants.gen3TrainerFlagBase = 0x500
+ f.game.data = data
+ f.game.save = require("src.core.SaveData").newGame()
+ f.game.session = f.game.save
+ f.game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 15) }
+ require("src.render.Font").load(data)
+ T.check(
+ f.exports.queue({
+ { "start_battle", "trainer", f.trainerRef, 1 },
+ { "check_battle_result", outcome },
+ { "jump_if_false", "end" },
+ { "set_flag", "mod:after" },
+ }),
+ v .. " native trainer admitted"
+ )
+ T.eq(f.battle.kind, "trainer", "actual native trainer kind")
+ T.eq(f.battle.enemy.mon.species, "FIXMON_B", "actual native trainer party built")
+ T.eq(f.battle.partyIndex, 1, "actual native member selected")
+ f:complete(outcome)
+ f:drain()
+ T.eq(f.after, 1, "actual trainer cleanup retained")
+ T.eq(f.exports.get("mod:after"), true, "native trainer outcome branch")
+ if f.gen == 3 then
+ T.eq(f.game.save.flags.FLAG_G3_0507 == true, outcome == "win", "native trainer flag tracks win only")
+ end
+ end
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("Portable script trainers")
diff --git a/tests/engine/portable_script_travel.lua b/tests/engine/portable_script_travel.lua
new file mode 100644
index 000000000..16397e435
--- /dev/null
+++ b/tests/engine/portable_script_travel.lua
@@ -0,0 +1,232 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.script_travel")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(v)
+ return Contract.fixture("tests/api3_contract", "gen2", v or "red")
+end
+for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v)
+ T.check(ok, v .. " shared travel: " .. tostring(why))
+end
+for _, rows in ipairs({
+ { { "face_player_dir", "up" }, { "warp", "UNKNOWN", 0, 0 } },
+ { { "warp", "DEST", 0.5, 0 } },
+ { { "warp", "DEST", -1, 0 } },
+ { { "warp", "DEST", 16, 0 } },
+ { { "warp", "DEST", 0, 0, "north" } },
+ { { "warp", "DEST", 0, 0, "down", {} } },
+ { { "warp", "DEST", 0, 0 }, { "move_npc", 99, "down" } },
+ { { "warp", "DEST", 0, 0 }, { "replace_block", 999, 0, 1 } },
+ { { "warp", "DEST", 0, 0 }, { "face", "down" } },
+}) do
+ local f = fixture()
+ T.eq(f.exports.queue(rows), nil, "invalid destination/later row preflight")
+ T.eq(#f.trace, 0, "invalid suffix prevents native warp")
+ T.eq(f.player.facing, "left", "invalid list has no prefix effect")
+ f.restoreRenderer()
+end
+for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do
+ local f = fixture(v)
+ T.check(f.exports.queue({ { "remember_outdoor", f.home, 1, 2 } }), "outdoor memory admitted")
+ T.same(f.game.save.lastOutdoor, { id = f.home, x = 1, y = 2 }, "canonical remembered destination")
+ if f.gen == 3 then
+ T.same(f.game.save.escapeWarp, { map = f.home, warpId = 255, x = 1, y = 2 }, "native Gen3 escape memory")
+ end
+ f.restoreRenderer()
+end
+for _, change in ipairs({ "cancel", "rollback", "save", "data", "stack", "player", "version" }) do
+ local f = fixture()
+ local original = f.ow.runner
+ T.check(
+ f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }),
+ "cancellable warp admitted"
+ )
+ local callback = f.callback
+ if change == "cancel" then
+ require("src.mods.PortableScripts").cancelAll(f.loader, "script_travel")
+ elseif change == "rollback" then
+ f.loader:_rollback("script_travel")
+ elseif change == "version" then
+ Version.set("blue")
+ elseif change == "player" then
+ f.ow.player = { cellX = 2, cellY = 2 }
+ else
+ f.game[change] = {}
+ end
+ local ok = pcall(function()
+ f:tick()
+ end)
+ T.check(ok, "changed context checked without crashing")
+ T.eq(f.exports.result().done, 1, "changed context completes once")
+ T.eq(f.exports.result().success, false, "changed warp fails script")
+ callback()
+ callback()
+ T.eq(f.exports.result().done, 1, "stale warp callback cannot replay")
+ T.eq(f.ow.runner, original, "source idle runner restored")
+ f.restoreRenderer()
+end
+do
+ local f = fixture()
+ T.check(f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), "warp before overlay")
+ local old = f.callback
+ f:arrive()
+ f:completeWarp()
+ local replacement = f.ow.runner
+ local overlay = {}
+ f.game.stack.states[2] = overlay
+ old()
+ old()
+ for _ = 1, 4 do
+ f:tick()
+ end
+ T.eq(f.exports.get("mod:after"), false, "arrival callback cannot run beneath foreign UI")
+ f.game.stack.states[2] = nil
+ f:drain()
+ T.eq(f.exports.result().success, true, "arrival resumes in foreground")
+ T.check(f.ow.runner ~= replacement and not f.ow.runner:isRunning(), "new destination idle runner restored")
+ f.restoreRenderer()
+end
+for _, failure in ipairs({ "throw", "refuse", "false_arrival", "wrong_map" }) do
+ local f = fixture()
+ if failure == "throw" or failure == "refuse" then
+ f.failure = failure
+ end
+ T.check(
+ f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }),
+ "native warp admitted before result"
+ )
+ if failure == "false_arrival" then
+ f:completeWarp()
+ elseif failure == "wrong_map" then
+ f:arrive(f.home)
+ f:completeWarp()
+ end
+ f:tick()
+ T.eq(f.exports.result().success, false, "native failure cannot complete script")
+ T.eq(f.exports.get("mod:after"), false, "native failure cannot burn later flag")
+ f.restoreRenderer()
+end
+do
+ local f = fixture()
+ f.synchronous = true
+ T.check(
+ f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }),
+ "synchronous arrival admitted"
+ )
+ T.eq(f.exports.result().success, true, "synchronous warp continuation does not hang")
+ f.restoreRenderer()
+end
+for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do
+ local f = fixture(v)
+ function f.ow:startWarpTo(id, x, y, facing, callback)
+ self.player.cellX, self.player.cellY, self.player.facing = x, y, facing
+ callback()
+ end
+ T.check(
+ f.exports.queue({ { "warp", f.home, 1, 1 }, { "set_flag", "mod:after" } }),
+ "same-map reused runtime admitted"
+ )
+ T.eq(f.exports.result().success, true, "same-map warp can retain native map identity")
+ T.eq(f.exports.get("mod:after"), true, "same-map native callback resumes once")
+ f.restoreRenderer()
+end
+do
+ local f = fixture()
+ T.check(f.exports.queue({ { "warp", f.dest, 2, 2 } }), "stalled warp admitted")
+ for _ = 1, 3601 do
+ f:tick()
+ end
+ T.eq(f.exports.result().success, false, "warp deadline expires")
+ f:arrive()
+ f:completeWarp()
+ T.eq(f.ow.transitioning, false, "engine can still finish timed-out warp")
+ T.eq(f.exports.result().done, 1, "timed-out script cannot resume")
+ f.restoreRenderer()
+end
+for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do
+ local f = fixture(v)
+ f.tiles.blocks[8] = {}
+ local arrive = f.arrive
+ function f:arrive(id)
+ arrive(self, id)
+ self.ow.map.renderer = { rebuild = function() end }
+ end
+ function f.ow:replaceBlock(x, y, block)
+ self.map:setBlock(x, y, block)
+ self.map.renderer:rebuild()
+ end
+ T.check(
+ f.exports.queue({ { "warp", f.dest, 2, 2 }, { "replace_block", 0, 0, 7 }, { "set_flag", "mod:after" } }),
+ "future destination block preflight"
+ )
+ f:drain()
+ T.eq(f.exports.result().success, true, "block edit executes on arrived map")
+ T.eq(f.ow.map:blockAt(0, 0), 7, "native block patch is on destination")
+ f.restoreRenderer()
+end
+-- Exercise the actual native transition; only destination materialization is a fixture.
+local OW = require("src.world.OverworldController")
+local Transition = require("src.render.Transition")
+local StateStack = require("src.core.StateStack")
+local function bind(game)
+ for i = 1, 100 do
+ local name, old = debug.getupvalue(OW.startWarpTo, i)
+ if not name then
+ break
+ end
+ if name == "Game" then
+ debug.setupvalue(OW.startWarpTo, i, game)
+ return function()
+ debug.setupvalue(OW.startWarpTo, i, old)
+ end
+ end
+ end
+ error("native Game upvalue missing")
+end
+for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do
+ for _, cancel in ipairs({ false, true }) do
+ local f = fixture(v)
+ setmetatable(f.game.stack, { __index = StateStack })
+ f.ow.startWarpTo = OW.startWarpTo
+ local restore = bind(f.game)
+ function f.ow:frlgBehaviours()
+ return false
+ end
+ function f.ow:noteGen2Spawn(from)
+ self.notedFrom = from
+ end
+ function f.ow:setMap(id, x, y, facing, opts)
+ f.destination = { id, x, y, facing }
+ f:arrive()
+ end
+ T.check(
+ f.exports.queue({ { "warp", f.dest, 2, 2, "right" }, { "set_flag", "mod:after" } }),
+ "actual transition admitted"
+ )
+ local state = f.game.stack:top()
+ T.eq(getmetatable(state), Transition, "native warp transition retained")
+ if cancel then
+ f.loader:_rollback("script_travel")
+ T.eq(f.game.stack:top(), state, "unload retains engine-owned warp transition")
+ end
+ for _ = 1, state.frames + state.framesIn + 3 do
+ if f.game.stack:top() == state then
+ state:update(1 / 60)
+ end
+ f:tick()
+ end
+ T.eq(f.ow.map.id, f.dest, "actual transition arrives")
+ T.eq(f.ow.transitioning, false, "native warp releases its lock even after unload")
+ T.eq(f.exports.result().success, not cancel, "only live script continues")
+ T.eq(f.exports.get("mod:after"), not cancel, "no late effects after unload")
+ restore()
+ f.restoreRenderer()
+ end
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("Portable script travel")
diff --git a/tests/engine/portable_script_world.lua b/tests/engine/portable_script_world.lua
new file mode 100644
index 000000000..0faea304c
--- /dev/null
+++ b/tests/engine/portable_script_world.lua
@@ -0,0 +1,117 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.script_world")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local function fixture(version)
+ return Contract.fixture("tests/api3_contract", "gen2", version or "red")
+end
+for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version)
+ T.check(ok, version .. " shared world script contract: " .. tostring(why))
+end
+for _, rows in ipairs({
+ { { "hide_object", "HOME", "NPC" }, { "show_object", "HOME", "MISSING" } },
+ { { "hide_object", "HOME", "NPC" }, { "replace_block", 99, 0, 8 } },
+ { { "replace_block", 0, 0, 9 } },
+ { { "replace_block", -1, 0, 8 } },
+ { { "replace_block", 0, 0, 256 } },
+ { { "show_object", "HOME", false } },
+ { { "show_object", "MISSING", "NPC" } },
+ { { "hide_object", "HOME", "NPC", true } },
+}) do
+ local f = fixture()
+ T.eq(f.exports.queue(rows), nil, "invalid list refused before prefix")
+ T.eq(f.visible.NPC, true, "object unchanged after refusal")
+ T.eq(f.rebuilds + f.reloads + f.syncs, 0, "no native effects on refusal")
+end
+for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do
+ for _, target in ipairs({ "object", "block" }) do
+ local f = fixture(version)
+ local row = target == "object" and { "hide_object", f.home, "NPC" } or { "replace_block", 0, 0, 8 }
+ T.check(f.exports.queue({ { "wait", 1 }, row, { "set_flag", "mod:after" } }), "world script queued")
+ local overlay = {}
+ f.game.stack.states[2] = overlay
+ for _ = 1, 5 do
+ f:tick()
+ end
+ T.eq(f.exports.result().done, 0, "foreign overlay pauses native edit")
+ T.eq(#f.exports.result().events, 0, "paused edit emits nothing")
+ f.game.stack.states[2] = nil
+ f:drain()
+ T.eq(f.exports.result().success, true, "edit resumes after overlay")
+ T.eq(f.exports.get("mod:after"), true, "script continues after native edit")
+ T.eq(#f.exports.result().events, 1, "single native event")
+ end
+end
+for _, field in ipairs({ "objectToggles", "flags" }) do
+ local f = fixture()
+ T.check(
+ f.exports.queue({ { "wait", 1 }, { "hide_object", f.home, "NPC" } }),
+ "deferred object edit admitted"
+ )
+ f.game.save[field] = false
+ f:drain()
+ T.eq(f.exports.result().success, false, "live malformed store refuses edit")
+ T.eq(f.visible.NPC, true, "malformed live store has no write")
+end
+do
+ local f = fixture()
+ T.check(f.exports.queue({ { "wait", 1 }, { "replace_block", 0, 0, 8 } }), "deferred block edit admitted")
+ f.ow.map.renderer = nil
+ f:drain()
+ T.eq(f.exports.result().success, false, "live missing renderer refuses edit")
+ T.eq(f.rebuilds, 0, "no partial replacement")
+end
+for _, change in ipairs({ "rollback", "save", "map", "runner" }) do
+ local f = fixture()
+ local original = f.ow.runner
+ T.check(f.exports.queue({ { "wait", 1 }, { "hide_object", f.home, "NPC" } }), "owned edit admitted")
+ local replacement
+ if change == "rollback" then
+ f.loader:_rollback("script_world")
+ elseif change == "save" then
+ f.game.save = {}
+ elseif change == "map" then
+ f.ow.map = { id = f.home }
+ else
+ replacement = { update = function() end }
+ f.ow.runner = replacement
+ end
+ f:tick()
+ T.eq(f.exports.result().done, 1, "changed context cancels once")
+ T.eq(f.exports.result().success, false, "changed context fails")
+ T.eq(f.visible.NPC, true, "cancelled edit cannot run")
+ T.eq(f.ow.runner, replacement or original, "foreign or original runner preserved")
+end
+do
+ local f = fixture()
+ local original = f.ow.runner
+ f.loader.events:on("world.object_toggled", function()
+ f.loader:_rollback("script_world")
+ end, 0, "probe")
+ T.check(
+ f.exports.queue({ { "hide_object", f.home, "NPC" }, { "set_flag", "mod:after" } }),
+ "native event retires owner"
+ )
+ T.eq(f.exports.result().done, 1, "event cancellation completes once")
+ T.eq(f.exports.result().success, false, "event cancellation reported")
+ T.check(f.ow.runner ~= original and not f.ow.runner:isRunning(), "reload's new idle runner retained")
+ T.eq(f.exports.get("mod:after"), false, "retired owner cannot execute following flag")
+end
+do
+ local f = fixture()
+ local old = f.ow.runner
+ T.check(
+ f.exports.queue({ { "hide_object", f.home, "NPC" }, { "wait", 2 }, { "set_flag", "mod:after" } }),
+ "native reload continuation admitted"
+ )
+ f:drain()
+ T.eq(f.exports.result().success, true, "continuation survives native map and runner replacement")
+ T.check(f.ow.runner ~= old and not f.ow.runner:isRunning(), "destination idle runner restored")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love =
+ saved[2], saved[3], saved[4], saved[5], saved[6]
+T.finish("Portable script world edits")
diff --git a/tests/engine/portable_world_scripts.lua b/tests/engine/portable_world_scripts.lua
new file mode 100644
index 000000000..ffe1fd55d
--- /dev/null
+++ b/tests/engine/portable_world_scripts.lua
@@ -0,0 +1,857 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Loader = require("src.mods.Loader")
+local Manifest = require("src.mods.Manifest")
+local Program = require("src.mods.ScriptProgram")
+local Runtime = require("src.mods.Runtime")
+local Runner = require("src.script.ScriptRunner")
+local WorldAPI = require("src.world.WorldAPI")
+local GameVersion = require("src.core.GameVersion")
+local Font = require("src.render.Font")
+local SaveData = require("src.core.SaveData")
+local StateStack = require("src.core.StateStack")
+local Json = require("src.link.Json")
+local originalVersion = GameVersion.get()
+local function boot(version)
+ GameVersion.set(version)
+ local data, files = T.fixtures.fresh(), {}
+ for _, id in ipairs({ "probe", "other", "legacy" }) do
+ files["mods/" .. id .. "/manifest.json"] = Json.encode({
+ id = id,
+ name = id,
+ version = "1.0.0",
+ api = 2,
+ entry = "main.lua",
+ script_format = id ~= "legacy" and "unified" or nil,
+ content_format = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and id ~= "legacy" and "unified"
+ or nil,
+ callback_format = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and id ~= "legacy" and "unified"
+ or nil,
+ description = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and "all-formats" or "",
+ })
+ files["mods/" .. id .. "/main.lua"] = [[return function(mod)
+ if mod.id ~= "legacy" then
+ assert(mod.scriptFormat == "unified")
+ if mod.manifest.description == "all-formats" then
+ assert(mod.contentFormat == "unified")
+ assert(mod.callbackFormat == "unified")
+ end
+ local ok, why = mod.world:queueScript({ { "wait", 1 } })
+ assert(ok == nil and type(why) == "string")
+ else assert(mod.scriptFormat == "native") end
+ end]]
+ end
+ local fs = {
+ read = function(p)
+ return files[p]
+ end,
+ load = function(p)
+ return loadstring(files[p], p)
+ end,
+ getDirectoryItems = function(p)
+ return p == "mods" and { "probe", "other", "legacy" } or {}
+ end,
+ getInfo = function(p)
+ if p == "mods" or p == "mods/probe" or p == "mods/other" or p == "mods/legacy" then
+ return { type = "directory" }
+ end
+ if files[p] then
+ return { type = "file" }
+ end
+ end,
+ }
+ local loader = Loader.new({ fs = fs, dev = false })
+ T.check(loader:load(data), "fixture mods load")
+ T.eq(package.loaded["src.core.Game"], nil, "early portable request does not bootstrap Game")
+ Font.load(data)
+ local game = {
+ data = data,
+ save = SaveData.newGame(),
+ stack = setmetatable({ states = {} }, { __index = StateStack }),
+ }
+ local ow = {
+ isOverworld = true,
+ map = { id = "FIX_TOWN", def = data.maps.FIX_TOWN },
+ player = { cellX = 4, cellY = 5, facing = "up" },
+ scriptMoves = {},
+ pendingScripts = {},
+ parallelRunners = {},
+ }
+ game.overworld, ow.game = ow, game
+ ow.runner = Runner.new(game, ow)
+ function ow:update()
+ self.runner:update()
+ end
+ game.stack.states = { ow }
+ game.input = {
+ wasPressed = function()
+ return false
+ end,
+ isDown = function()
+ return false
+ end,
+ }
+ loader.game = game
+ return loader.apis.probe.world, game, ow, loader, files
+end
+local function tick(game, key)
+ game.input.wasPressed = function(_, k)
+ return k == key
+ end
+ Runtime.call("input.step", function() end, game, 1 / 60)
+ game.stack:update(1 / 60)
+ game.input.wasPressed = function()
+ return false
+ end
+end
+local function untilState(game, predicate)
+ for _ = 1, 2000 do
+ if predicate() then
+ return
+ end
+ tick(game)
+ end
+ error("native UI did not reach requested state")
+end
+local function finishText(game)
+ local box = game.stack:top()
+ untilState(game, function()
+ return box.done or box.waiting and (box.preWait or 0) <= 0
+ end)
+ tick(game, "a")
+end
+for _, version in ipairs(GameVersion.ORDER) do
+ local world, game, ow, loader = boot(version)
+ local before, calls, result = ow.runner, 0, nil
+ T.check(
+ world:queueScript({ { "wait", 2 }, { "text", "First" }, { "end" }, { "text", "Unreachable" } }, {
+ onDone = function(ok, why)
+ calls = calls + 1
+ result = { ok, why }
+ end,
+ }),
+ version .. " program admitted"
+ )
+ T.neq(ow.runner, before, "isolated native runner installed")
+ T.eq(ow.runner:isRunning(), true, "runner locks foreground input")
+ tick(game)
+ T.eq(game.stack:top(), ow, "wait does not end early")
+ tick(game)
+ T.neq(game.stack:top(), ow, "native text appears after wait")
+ T.eq(calls, 0, "completion awaits text")
+ finishText(game)
+ T.same(result, { true }, "success completion")
+ T.eq(calls, 1, "completion once")
+ T.eq(ow.runner, before, "idle native runner restored")
+ T.eq(game.stack:top(), ow, "script text closed")
+
+ local Choice = require("src.ui.ChoiceBox")
+ for _, yes in ipairs({ true, false }) do
+ local lines = {}
+ T.check(
+ world:queueScript({
+ { "ask", "Question?" },
+ { "jump_if_false", "no" },
+ { "text", "YES" },
+ { "jump", "end" },
+ { "label", "no" },
+ { "text", "NO" },
+ }, {
+ onDone = function(ok)
+ lines[#lines + 1] = ok
+ end,
+ }),
+ "question script admitted"
+ )
+ untilState(game, function()
+ return getmetatable(game.stack:top()) == Choice
+ end)
+ local choice = game.stack:top()
+ tick(game, yes and "a" or "b")
+ T.eq(choice.pending, yes, "native answer pending")
+ T.eq(#lines, 0, "answer hold preserved")
+ untilState(game, function()
+ return game.stack:top() ~= choice
+ end)
+ T.same(
+ game.stack:top().pages,
+ require("src.render.TextBox").paginate(
+ yes and "YES" or "NO",
+ game.stack:top().maxCols,
+ game.stack:top().letterSpacing,
+ game.stack:top().maxPixels,
+ true
+ ),
+ "correct branch text"
+ )
+ finishText(game)
+ T.same(lines, { true }, "question branch completes")
+ end
+ local trace = {}
+ T.check(
+ world:queueScript({
+ { "choice", { "ONE", "TWO", "THREE" }, { default = 2, cancel = 3 } },
+ { "jump_if_false", 5 },
+ { "text", "WRONG" },
+ { "end" },
+ { "text", "RIGHT" },
+ }, {
+ onDone = function(ok)
+ trace[#trace + 1] = ok
+ end,
+ }),
+ "multiple choice admitted"
+ )
+ T.eq(game.stack:top().index, 2, "native default selection")
+ tick(game, "b")
+ finishText(game)
+ T.same(trace, { true }, "native B choice follows false branch")
+ T.eq(loader.apis.legacy.world.queueScript, WorldAPI.queueScript, "legacy method unchanged")
+end
+
+local function freeze(root)
+ local graph = {}
+ local function visit(t)
+ if type(t) ~= "table" or graph[t] then
+ return
+ end
+ local entry = { mt = getmetatable(t), values = {} }
+ graph[t] = entry
+ for k, v in pairs(t) do
+ entry.values[k] = v
+ visit(k)
+ visit(v)
+ end
+ end
+ visit(root)
+ return function(label)
+ local same = true
+ for t, entry in pairs(graph) do
+ same = same and getmetatable(t) == entry.mt
+ for k, v in pairs(entry.values) do
+ same = same and rawget(t, k) == v
+ end
+ for k, v in pairs(t) do
+ same = same and entry.values[k] == v
+ end
+ end
+ T.check(same, label)
+ end
+end
+local function refusal(world, game, rows, options, label)
+ local unchanged = freeze(game)
+ local ok, why = world:queueScript(rows, options)
+ T.eq(ok, nil, label)
+ T.eq(type(why), "string", label .. " has reason")
+ unchanged(label .. " is observational")
+end
+local badPrograms = {
+ false,
+ 1,
+ "text",
+ function() end,
+ { [1] = { "wait", 1 }, [3] = { "end" } },
+ { [1] = { "wait", 1 }, extra = true },
+ setmetatable({ { "end" } }, {}),
+ { false },
+ { {} },
+ { { "wait" } },
+ { { "wait", 1, 2 } },
+ { { "wait", -1 } },
+ { { "wait", 1.5 } },
+ { { "wait", 0 / 0 } },
+ { { "wait", math.huge } },
+ { { "wait", 36001 } },
+ { { "end", false } },
+ { { "native_mutate", {} } },
+ { { "show_text", {} } },
+ { { "show_text", string.rep("x", 4097) } },
+ { { "text", "Valid" }, { "give_item", "POTION" } },
+ { { "text", "Valid" }, { "jump", "missing" } },
+ { { "label", "same" }, { "label", "same" } },
+ { { "label", "end" } },
+ { { "label", "bad\nname" } },
+ { { "jump", 0 } },
+ { { "jump", 99 } },
+ { { "jump", false } },
+ { { "choice", {} } },
+ { { "choice", { "A", "B" }, { cancel = 3 } } },
+ { { "choice", { "A" }, { default = 0 } } },
+ { { "choice", { "A" }, { custom = true } } },
+ { { "choice", { "A", function() end } } },
+ { { "choice", { "" } } },
+ { { "choice", { "A" }, setmetatable({}, {}) } },
+ { setmetatable({ "wait", 1 }, {}) },
+}
+for _, version in ipairs(GameVersion.ORDER) do
+ local world, game, ow, loader = boot(version)
+ local callbacks = 0
+ for _, rows in ipairs(badPrograms) do
+ refusal(world, game, rows, {
+ onDone = function()
+ callbacks = callbacks + 1
+ end,
+ }, "invalid whole program")
+ end
+ for _, options in ipairs({
+ false,
+ 1,
+ "options",
+ { onDone = true },
+ { onDone = function() end, runner = {} },
+ setmetatable({}, {}),
+ }) do
+ refusal(world, game, { { "wait", 1 } }, options, "invalid options")
+ end
+ T.eq(callbacks, 0, "rejected lists never complete or execute a prefix")
+ for _, key in ipairs({
+ "transitioning",
+ "flyAnim",
+ "teleportOut",
+ "engaging",
+ "emote",
+ "pikaHop",
+ "healAnim",
+ "whirlSpin",
+ "gen4Waterfall",
+ "gen5SeamPending",
+ "gen3Decorate",
+ "gen3Locked",
+ "frameScriptThisTick",
+ "_modScriptLock",
+ }) do
+ ow[key] = true
+ refusal(world, game, { { "wait", 1 } }, nil, key .. " owns world")
+ ow[key] = nil
+ end
+ for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do
+ ow.player[key] = true
+ refusal(world, game, { { "wait", 1 } }, nil, key .. " owns player")
+ ow.player[key] = nil
+ end
+ for _, key in ipairs({ "pendingScripts", "scriptMoves", "parallelRunners", "parallelQueue" }) do
+ ow[key] = { {} }
+ refusal(world, game, { { "wait", 1 } }, nil, key .. " owns script scheduling")
+ ow[key] = {}
+ end
+ game.linkSession = {}
+ refusal(world, game, { { "wait", 1 } }, nil, "link session busy")
+ game.linkSession = nil
+ game.linkNet = { closed = false }
+ refusal(world, game, { { "wait", 1 } }, nil, "link transport busy")
+ game.linkNet = nil
+ game.stack:push({})
+ refusal(world, game, { { "wait", 1 } }, nil, "covered world busy")
+ game.stack:pop()
+ ow.runner:run({ { "wait", 9 } })
+ refusal(world, game, { { "wait", 1 } }, nil, "native script busy")
+ for _ = 1, 9 do
+ tick(game)
+ end
+ T.check(world:queueScript({ { "wait", 1 } }), "new script after native completion")
+ tick(game)
+ T.eq(loader.apis.other.world:cancelScript(), false, "another mod cannot cancel a program")
+end
+-- Limits, data copying and compiler immutability, including a cyclic payload.
+do
+ local world, game, ow = boot("crystal")
+ local over = {}
+ for i = 1, 1025 do
+ over[i] = { "end" }
+ end
+ refusal(world, game, over, nil, "row bound")
+ over = {}
+ for i = 1, 33 do
+ over[i] = { "text", string.rep("x", 4096) }
+ end
+ refusal(world, game, over, nil, "total text bound")
+ local cycle = {}
+ cycle[1] = cycle
+ refusal(world, game, cycle, nil, "cyclic command denied")
+ local rows = { { "wait", 2 }, { "choice", { "ONE", "TWO" }, { default = 2 } } }
+ local unchanged = freeze(rows)
+ local compiled = Program.compile(rows)
+ unchanged("compiler leaves caller rows intact")
+ T.neq(compiled[2][2], rows[2][2], "compiler copies nested labels")
+ T.check(world:queueScript(rows), "copied program accepted")
+ rows[2][1], rows[2][2][2], rows[2][3].default = "invalid", "MUTATED", 1
+ tick(game)
+ tick(game)
+ T.eq(game.stack:top().index, 2, "choice options captured before caller edit")
+ T.eq(game.stack:top().items[2].label, "TWO", "choice labels copied before execution")
+ world:cancelScript()
+end
+-- Session changes cancel even while an old Game is no longer being stepped.
+for _, version in ipairs({ "red", "crystal", "emerald", "platinum", "heartgold", "black" }) do
+ for _, change in ipairs({
+ "save",
+ "data",
+ "stack",
+ "states",
+ "map",
+ "mapId",
+ "player",
+ "position",
+ "runner",
+ "game",
+ "version",
+ "removed",
+ "transition",
+ }) do
+ local world, game, ow, loader = boot(version)
+ local originalRunner, calls, result = ow.runner, 0
+ T.check(
+ world:queueScript({ { "wait", 30 }, { "text", "Must not run" } }, {
+ onDone = function(ok, why)
+ calls = calls + 1
+ result = { ok, why }
+ end,
+ }),
+ "session test admitted"
+ )
+ local stale = ow.runner
+ local replacement
+ if change == "save" then
+ game.save = SaveData.newGame()
+ elseif change == "data" then
+ game.data = T.fixtures.fresh()
+ elseif change == "stack" then
+ game.stack = setmetatable({ states = { ow } }, { __index = StateStack })
+ elseif change == "states" then
+ game.stack.states = { ow }
+ elseif change == "map" then
+ ow.map = { id = "FIX_ROUTE", def = game.data.maps.FIX_ROUTE }
+ elseif change == "mapId" then
+ ow.map.id = "FIX_ROUTE"
+ elseif change == "player" then
+ ow.player = { cellX = 4, cellY = 5 }
+ elseif change == "position" then
+ ow.player.cellX = 7
+ elseif change == "runner" then
+ replacement = Runner.new(game, ow)
+ ow.runner = replacement
+ elseif change == "game" then
+ replacement = { stack = setmetatable({ states = {} }, { __index = StateStack }), input = game.input }
+ loader.game = replacement
+ elseif change == "version" then
+ GameVersion.set(version == "red" and "crystal" or "red")
+ elseif change == "removed" then
+ game.stack.states = {}
+ elseif change == "transition" then
+ ow.transitioning = true
+ end
+ Runtime.call("input.step", function() end, change == "game" and replacement or game, 1 / 60)
+ T.eq(calls, 1, change .. " cancellation delivered")
+ T.eq(result[1], false, "session cancellation is not success")
+ T.eq(type(result[2]), "string", "cancellation gives reason")
+ T.eq(ow.runner, change == "runner" and replacement or originalRunner, "restore only owned runner")
+ stale:resume()
+ stale:update()
+ T.eq(calls, 1, "old runner callbacks cannot replay cancellation")
+ T.eq(stale:isRunning(), false, "abandoned coroutine released")
+ GameVersion.set(version)
+ end
+end
+-- Cancel removes only owned windows, including the choice above its text.
+for _, version in ipairs(GameVersion.ORDER) do
+ local world, game, ow, loader = boot(version)
+ local originalRunner, calls = ow.runner, 0
+ T.check(
+ world:queueScript({ { "ask", "Question?" }, { "text", "Later" } }, {
+ onDone = function(ok)
+ T.eq(ok, false, "explicit cancel completion")
+ calls = calls + 1
+ end,
+ }),
+ "cancellable question"
+ )
+ local box = game.stack:top()
+ untilState(game, function()
+ return game.stack:top() ~= box
+ end)
+ local choice = game.stack:top()
+ local foreign = { update = function() end }
+ game.stack:push(foreign)
+ T.eq(loader.apis.other.world:cancelScript(), false, "foreign mod has no cancellation authority")
+ T.check(world:cancelScript(), "owner cancels")
+ T.same(game.stack.states, { ow, foreign }, "foreign overlay survives cancellation")
+ T.eq(ow.runner, originalRunner, "cancel restores idle runner")
+ T.eq(calls, 1, "cancel callback once")
+ choice.onChoose(true)
+ box.onDone()
+ choice:update(1 / 60)
+ box:update(1 / 60)
+ T.same(game.stack.states, { ow, foreign }, "stale native UI cannot pop foreign screen")
+ T.eq(calls, 1, "stale native callbacks do not complete twice")
+end
+-- Replaying an old callback during a later row must not skip that row's wait.
+do
+ local world, game, ow = boot("red")
+ T.check(
+ world:queueScript({ { "text", "First" }, { "wait", 10 }, { "text", "Last" } }),
+ "callback replay test admitted"
+ )
+ local old = game.stack:top()
+ finishText(game)
+ T.eq(ow.runner.waitingFrames, 10, "later wait begins")
+ old.onDone()
+ old:update(1 / 60)
+ T.eq(ow.runner.waitingFrames, 10, "old text callback cannot skip current wait")
+ for _ = 1, 10 do
+ tick(game)
+ end
+ T.neq(game.stack:top(), ow, "new text after full wait")
+ world:cancelScript()
+end
+
+-- Native error cleanup, completion exceptions and event reentry are bounded.
+for _, version in ipairs({ "red", "crystal", "emerald", "platinum", "black" }) do
+ local world, game, ow, loader = boot(version)
+ local TextBox = require("src.render.TextBox")
+ local original, make = ow.runner, TextBox.new
+ local completions, result = 0
+ TextBox.new = function()
+ error("injected native constructor failure")
+ end
+ Runtime.currentMod = "probe"
+ T.check(
+ world:queueScript({ { "text", "Fail" } }, {
+ onDone = function(ok, why)
+ completions = completions + 1
+ result = { ok, why }
+ end,
+ }),
+ "entered native failure is admitted"
+ )
+ T.eq(Runtime.currentMod, "probe", "engine ownership restored on failure")
+ Runtime.currentMod = nil
+ TextBox.new = make
+ T.eq(result[1], false, "native error completes as failure")
+ T.check(result[2]:find("constructor failure", 1, true) ~= nil, "native error reason retained")
+ T.eq(completions, 1, "native error completion once")
+ T.eq(ow.runner, original, "native error releases runner")
+ T.check(
+ world:queueScript({}, {
+ onDone = function()
+ error("completion failure")
+ end,
+ }),
+ "callback error isolated"
+ )
+ T.eq(ow.runner, original, "callback error cannot strand runner")
+ local events = {}
+ local stop1 = loader.events:on("script.started", function(ev)
+ if ev.ctx.source and ev.ctx.source.modId == "probe" then
+ events[#events + 1] = "started"
+ refusal(
+ loader.apis.other.world,
+ game,
+ { { "wait", 1 } },
+ nil,
+ "event callback cannot reenter owned run"
+ )
+ end
+ end)
+ local stop2 = loader.events:on("script.ended", function(ev)
+ if ev.ctx.source and ev.ctx.source.modId == "probe" then
+ events[#events + 1] = ev.completed and "success" or "failure"
+ end
+ end)
+ T.check(world:queueScript({ { "wait", 1 } }), "eventful wait admitted")
+ tick(game)
+ T.same(events, { "started", "success" }, "one lifecycle event pair")
+ T.check(world:queueScript({ { "wait", 10 } }), "eventful cancel admitted")
+ world:cancelScript()
+ T.same(events, { "started", "success", "started", "failure" }, "cancel emits failure once")
+ stop1()
+ stop2()
+end
+-- An infinite authored jump yields slices to the engine and eventually
+-- reports failure rather than blocking a frame or permanently owning input.
+do
+ local world, game, ow = boot("red")
+ local original, result, ticks = ow.runner, nil, 0
+ T.check(
+ world:queueScript({ { "label", "loop" }, { "jump", "loop" } }, {
+ onDone = function(ok, why)
+ result = { ok, why }
+ end,
+ }),
+ "loop admitted"
+ )
+ T.eq(result, nil, "loop yields back from initial call")
+ untilState(game, function()
+ ticks = ticks + 1
+ return result ~= nil
+ end)
+ T.check(ticks > 1, "loop spans engine updates")
+ T.eq(result[1], false, "instruction limit fails explicitly")
+ T.check(result[2]:find("budget", 1, true) ~= nil, "budget reason")
+ T.eq(ow.runner, original, "budget exhaustion releases world")
+end
+-- Teardown/reload and failed entry chunks retire captured facades. Neither
+-- completion nor a script-ended listener may start fresh work during teardown.
+for _, mode in ipairs({ "teardown", "reload", "rollback" }) do
+ local world, game, ow, loader, files = boot("emerald")
+ local original, calls, retry = ow.runner, 0
+ T.check(
+ world:queueScript({ { "text", "Pending" } }, {
+ onDone = function(ok)
+ T.eq(ok, false, "lifecycle cancellation")
+ calls = calls + 1
+ retry = world:queueScript({ { "wait", 1 } })
+ end,
+ }),
+ "lifecycle program accepted"
+ )
+ if mode == "teardown" then
+ Loader.teardown()
+ elseif mode == "reload" then
+ boot("emerald")
+ else
+ loader:_rollback("probe")
+ end
+ T.eq(calls, 1, "lifecycle completion once")
+ T.eq(retry, nil, "no restart while retiring")
+ T.eq(ow.runner, original, "lifecycle restores runner")
+ T.same(game.stack.states, { ow }, "lifecycle removes owned text")
+ refusal(world, game, { { "wait", 1 } }, nil, "retained API retired")
+end
+do
+ local _, game, ow, loader, files = boot("crystal")
+ local original = ow.runner
+ files["mods/probe/main.lua"] = [[return function(mod)
+ assert(mod.world:queueScript({ { "wait", 30 } }))
+ error("entry failure after queue")
+ end]]
+ files["mods/other/main.lua"] = "return function() end"
+ local failed = Loader.new({ fs = loader.fs, dev = false })
+ failed.game = game
+ failed:load(T.fixtures.fresh())
+ T.eq(ow.runner, original, "failed entry cancels its accepted run")
+ T.eq(original:isRunning(), false, "failed entry cannot strand foreground")
+end
+-- Omitted/native formats preserve the old API 1/2 contract. Explicit malformed
+-- or API-1 unified declarations must fail instead of silently changing mode.
+do
+ local function manifest(format, level)
+ return {
+ id = "probe",
+ name = "Probe",
+ version = "1.0.0",
+ entry = "main.lua",
+ api = level or 2,
+ script_format = format,
+ }
+ end
+ for _, bad in ipairs({ false, true, 1, {}, "portable", "" }) do
+ T.raises(function()
+ Manifest.validate(manifest(bad), "mods/probe")
+ end, "script_format", "invalid format")
+ end
+ T.raises(function()
+ Manifest.validate(manifest("unified", 1), "mods/probe")
+ end, "API 2", "unified requires API 2")
+ T.eq(
+ Manifest.validate(manifest(nil, 1), "mods/probe").script_format,
+ "native",
+ "API 1 default remains native"
+ )
+ T.eq(
+ Manifest.validate(manifest("native", 1), "mods/probe").script_format,
+ "native",
+ "explicit API 1 native accepted"
+ )
+end
+-- Per-frame traces against the real native runner/commands, rather than
+-- against another implementation of the portable adapter.
+local function stateRecord(game, ow)
+ local top = game.stack:top()
+ local record =
+ { depth = #game.stack.states, running = ow.runner:isRunning(), wait = ow.runner.waitingFrames }
+ if top ~= ow then
+ record.index, record.pending, record.hold = top.index, top.pending, top.holdFrames
+ record.page, record.line, record.char = top.pageIndex, top.lineIndex, top.charIndex
+ record.done, record.waiting, record.preWait = top.done, top.waiting, top.preWait
+ if top.shown then
+ record.shown = Json.decode(Json.encode(top.shown))
+ end
+ if top.items then
+ record.labels = {}
+ for _, item in ipairs(top.items) do
+ record.labels[#record.labels + 1] = item.label
+ end
+ end
+ end
+ return record
+end
+for _, version in ipairs(GameVersion.ORDER) do
+ for _, yes in ipairs({ true, false }) do
+ local traces = {}
+ for _, native in ipairs({ true, false }) do
+ local world, game, ow = boot(version)
+ local rows = {
+ { "wait", 2 },
+ { "show_text", "First\fSecond" },
+ { "ask", "Question?" },
+ { "jump_if_false", "no" },
+ { "choice", { "ONE", "TWO" }, { default = 2, cancel = 2 } },
+ { "jump", "end" },
+ { "label", "no" },
+ { "show_text", "NO" },
+ }
+ local completed, trace = false, {}
+ local saveUnchanged = freeze(game.save)
+ if native then
+ ow.runner:run(rows, {
+ onDone = function()
+ completed = true
+ end,
+ })
+ else
+ T.check(
+ world:queueScript(rows, {
+ onDone = function(ok)
+ T.eq(ok, true, "parity program succeeds")
+ completed = true
+ end,
+ }),
+ "parity program admitted"
+ )
+ end
+ for frame = 1, 2000 do
+ trace[#trace + 1] = stateRecord(game, ow)
+ if completed then
+ break
+ end
+ local top = game.stack:top()
+ local key
+ if top.pending == nil and getmetatable(top) == require("src.ui.ChoiceBox") then
+ key = yes and "a" or "b"
+ elseif top.items then
+ key = "b"
+ elseif top.done or top.waiting and (top.preWait or 0) <= 0 then
+ key = "a"
+ end
+ tick(game, key)
+ end
+ T.eq(completed, true, "native parity trace completed")
+ saveUnchanged("dialogue program leaves save unchanged")
+ traces[#traces + 1] = trace
+ end
+ T.same(traces[1], traces[2], version .. " frame-by-frame native dialogue/wait/branch parity")
+ end
+end
+
+do
+ local world, game, ow, loader = boot("black")
+ for _, invalid in ipairs({
+ "bad\0text",
+ "\128",
+ "\192\128",
+ "\224\128\128",
+ "\237\160\128",
+ "\244\144\128\128",
+ "\240\159",
+ }) do
+ refusal(
+ world,
+ game,
+ { { "text", "Valid first" }, { "ask", invalid } },
+ nil,
+ "invalid UTF-8 refused before prefix"
+ )
+ end
+ T.check(world:queueScript({ { "text", "Grüße Pokémon ♀" } }), "valid UTF-8 accepted")
+ world:cancelScript()
+ local calls = 0
+ local stop = loader.hooks:wrap("input.step", function(nextFn, ...)
+ calls = calls + 1
+ return nextFn(...)
+ end, -1, "other")
+ for _ = 1, 3 do
+ T.check(world:queueScript({ { "wait", 2 } }), "reused observer admission")
+ world:cancelScript()
+ end
+ local before = calls
+ tick(game)
+ T.eq(calls, before + 1, "observer does not skip or duplicate downstream hooks")
+ stop()
+ local random = math.random
+ local rolls = 0
+ math.random = function()
+ rolls = rolls + 1
+ error("portable scripts must not roll RNG")
+ end
+ T.check(world:queueScript({ { "text", "FIRST" } }), "no-RNG dialogue")
+ finishText(game)
+ math.random = random
+ T.eq(rolls, 0, "no RNG consumed")
+ local events = {}
+ local done = loader.events:on("script.ended", function(ev)
+ events[#events + 1] = ev.completed
+ end)
+ T.check(world:queueScript({ { "text", "Native failure" } }), "native update failure admission")
+ local box = game.stack:top()
+ -- The guarded method captures the native update when the box is created.
+ -- Fail native work reached on a later frame instead of changing the guard.
+ box.beginLine = function()
+ error("native text update failure")
+ end
+ box.done, box.waiting, box.preWait, box.contAdvance = false, true, 0, true
+ tick(game, "a")
+ T.same(events, { false }, "native UI update error completes once")
+ T.eq(game.stack:top(), ow, "native update error releases owned UI")
+ done()
+end
+for _, version in ipairs(GameVersion.ORDER) do
+ local world, game, ow, loader = boot(version)
+ local api = loader.apis.probe
+ if api.supports then
+ T.eq(api:supports("world", "queueScript"), true, "portable queue capability")
+ T.eq(api:supports("world", "cancelScript"), true, "portable cancel capability")
+ for _, verb in ipairs({
+ "text",
+ "show_text",
+ "ask",
+ "choice",
+ "wait",
+ "label",
+ "jump",
+ "jump_if_true",
+ "jump_if_false",
+ "end",
+ }) do
+ T.eq(api:supports("scripts", verb), true, "bounded verb capability")
+ end
+ T.eq(api:supports("scripts", "engine_private_command"), false, "unsupported verb declined")
+ T.eq(api:supports("scripts", "warp"), GameVersion.generation(version) <= 3, "travel capability matches generation")
+ T.eq(loader.apis.legacy:supports("world", "cancelScript"), false, "legacy has no portable cancellation")
+ T.eq(loader.apis.legacy:supports("scripts", "wait"), false, "native format does not claim portable verbs")
+ end
+ T.eq(world:current().mapId, "FIX_TOWN", "unrelated world methods still delegate")
+ T.eq(world.game, game, "existing native fields still delegate")
+end
+
+-- First admission from an input.step callback must not sort a new observer
+-- into the chain while it is being traversed (which would replay the caller).
+do
+ local world, game = boot("crystal")
+ local wrappers, vanilla = 0, 0
+ local stop = Runtime.hooks:wrap("input.step", function(nextFn, ...)
+ wrappers = wrappers + 1
+ if wrappers == 1 then
+ T.check(world:queueScript({ { "wait", 2 } }), "first queue inside input hook")
+ end
+ return nextFn(...)
+ end, 0, "probe")
+ Runtime.call("input.step", function()
+ vanilla = vanilla + 1
+ end, game, 1 / 60)
+ T.eq(wrappers, 1, "first queue does not replay input callback")
+ T.eq(vanilla, 1, "native input boundary called once")
+ world:cancelScript()
+ stop()
+end
+GameVersion.set(originalVersion)
+T.finish("portable world scripts")
diff --git a/tests/engine/render_output.lua b/tests/engine/render_output.lua
new file mode 100644
index 000000000..d8afd8ffd
--- /dev/null
+++ b/tests/engine/render_output.lua
@@ -0,0 +1,81 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Contract = require("tests.api3_contract.render_output")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local Output = require("src.render.Output")
+for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v)
+ T.check(ok, v .. " output boundary contract: " .. tostring(why))
+end
+for _, mode in ipairs({ "dirty", "throw", "leak", "overpop" }) do
+ local f = Contract.fixture("tests/api3_contract", "gen2", "red")
+ local G = love.graphics
+ local previous, foreign, shader = {}, {}, {}
+ G.setCanvas(previous)
+ G.setShader(shader)
+ G.setColor(0.2, 0.3, 0.4, 0.5)
+ G.setBlendMode("add")
+ local originalPush, originalPop = G.push, G.pop
+ f.exports.state(true, true)
+ f.exports.callback(function()
+ G.setShader({})
+ G.setCanvas(foreign)
+ G.setColor(1, 0, 0, 1)
+ G.setBlendMode("replace")
+ if mode == "throw" then
+ error("injected output failure")
+ end
+ if mode == "leak" then
+ G.push("all")
+ G.push("all")
+ end
+ if mode == "overpop" then
+ G.pop()
+ end
+ return true
+ end)
+ local ctx = {
+ canvas = {},
+ width = 320,
+ height = 288,
+ gameX = 0,
+ gameY = 0,
+ gameWidth = 320,
+ gameHeight = 288,
+ scale = 2,
+ dpiX = 1,
+ dpiY = 1,
+ generation = 1,
+ }
+ T.eq(Output.enabled(), true, "enabled output owner")
+ T.eq(Output.draw(ctx), mode == "dirty" or mode == "leak", "error/overpop falls back")
+ T.eq(G.getCanvas(), previous, "canvas restored after " .. mode)
+ T.eq(G.getShader(), shader, "shader restored after " .. mode)
+ T.eq(G.getBlendMode(), "add", "blend restored after " .. mode)
+ T.eq(select(1, G.getColor()), 0.2, "color restored after " .. mode)
+ T.eq(G.push, originalPush, "push wrapper removed after " .. mode)
+ T.eq(G.pop, originalPop, "pop wrapper removed after " .. mode)
+ f.exports.gate(function()
+ G.setCanvas(foreign)
+ G.setShader({})
+ return 1
+ end)
+ T.eq(Output.enabled(), false, "only exact true enables capture")
+ T.eq(G.getCanvas(), previous, "predicate canvas restored")
+ T.eq(G.getShader(), shader, "predicate shader restored")
+ local mod = f.loader.apis.render_output
+ mod.hooks:wrap("render.output_enabled", function(next)
+ return next()
+ end, -10)
+ mod.hooks:wrap("render.output", function(next, frame)
+ return next(frame)
+ end, -10)
+ f.loader:_rollback("render_output")
+ T.eq(Output.enabled(), false, "unloaded owner no longer captures output")
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5]
+love = saved[6]
+T.finish()
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/engine/skin_format_import_test.lua b/tests/engine/skin_format_import_test.lua
new file mode 100644
index 000000000..4dd93ea15
--- /dev/null
+++ b/tests/engine/skin_format_import_test.lua
@@ -0,0 +1,728 @@
+-- From Gen1Recomp 535a5ec0, Copyright (C) 2026 BOIS CLUB GAMES, LLC.
+-- GPLv3 + Section 7; see licenses/GEN1RECOMP-TOUCH-SKINS.md.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+if not _G.love then _G.love = require("tests.love_stub") end
+-- The native general stub does not model mounts/FileData. These contracts use
+-- a dedicated memory archive fixture, rather than pretending to open ZIPs.
+love.filesystem = require('tests.skin_fs_fixture')()
+local newImage = love.graphics.newImage
+love.graphics.newImage = function(source)
+ if type(source)=='table' and source.contents then
+ return {getWidth=function() return 1 end,getHeight=function() return 1 end,
+ getDimensions=function() return 1,1 end,setFilter=function() end}
+ end
+ return newImage(source)
+end
+
+local T = require("tests.harness")
+local check, eq = T.check, T.eq
+
+local TouchSkin = require("src.core.TouchSkin")
+local DeltaSkin = require("src.core.DeltaSkin")
+local Json = require("src.link.Json")
+
+local function near(got, want, msg)
+ return check(type(got) == "number" and math.abs(got - want) < 1e-6,
+ ("%s (got %s, want %s)"):format(msg, tostring(got), tostring(want)))
+end
+
+local function hasWarning(skin, fragment)
+ for _, w in ipairs(skin.warnings or {}) do
+ if tostring(w):find(fragment, 1, true) then return true end
+ end
+ return false
+end
+
+local function unzip(bytes)
+ local out, i = {}, 1
+ while bytes:sub(i, i + 3) == "PK\3\4" do
+ local function u16(off)
+ local a, b = bytes:byte(i + off, i + off + 1)
+ return a + b * 256
+ end
+ local function u32(off)
+ local a, b, c, d = bytes:byte(i + off, i + off + 3)
+ return a + b * 256 + c * 65536 + d * 16777216
+ end
+ local size, nameLen, extraLen = u32(18), u16(26), u16(28)
+ local name = bytes:sub(i + 30, i + 29 + nameLen)
+ local start = i + 30 + nameLen + extraLen
+ out[name] = bytes:sub(start, start + size - 1)
+ out[#out + 1] = name
+ i = start + size
+ end
+ return out
+end
+
+local function readBytes(path)
+ local f = assert(io.open(path, "rb"))
+ local data = f:read("*a")
+ f:close()
+ return data
+end
+
+local GAMEBOY_CFG = [[
+overlays = 4
+
+overlay0_name = "landscape"
+overlay0_full_screen = true
+overlay0_normalized = true
+overlay0_range_mod = 1.5
+overlay0_alpha_mod = 2.0
+overlay0_aspect_ratio = 2.22222222222222
+overlay0_descs = 13
+overlay0_desc0 = "nul,0.0985,0.6825,rect,0.0525,0.0875"
+overlay0_desc0_overlay = img/dpad.png
+overlay0_desc1 = "up,0.0985,0.5950,rect,0.0175,0.0292"
+overlay0_desc2 = "down,0.0985,0.7700,rect,0.0175,0.0292"
+overlay0_desc3 = "left,0.0460,0.6825,rect,0.0175,0.0292"
+overlay0_desc4 = "right,0.1510,0.6825,rect,0.0175,0.0292"
+overlay0_desc5 = "left|up,0.0460,0.5950,rect,0.0175,0.0292"
+overlay0_desc6 = "right|up,0.1510,0.5950,rect,0.0175,0.0292"
+overlay0_desc7 = "left|down,0.0460,0.7700,rect,0.0175,0.0292"
+overlay0_desc8 = "right|down,0.1510,0.7700,rect,0.0175,0.0292"
+overlay0_desc9 = "a,0.8975,0.6300,radial,0.0525,0.0875"
+overlay0_desc9_overlay = img/a.png
+overlay0_desc10 = "b,0.8100,0.7350,radial,0.0525,0.0875"
+overlay0_desc10_overlay = img/b.png
+overlay0_desc11 = "start,0.5500,0.9000,rect,0.0500,0.0400"
+overlay0_desc12 = "select,0.4500,0.9000,rect,0.0500,0.0400"
+
+overlay1_name = "portrait"
+overlay1_full_screen = true
+overlay1_normalized = true
+overlay1_aspect_ratio = 0.45
+overlay1_descs = 2
+overlay1_desc0 = "a,0.8975,0.6300,radial,0.0875,0.0525"
+overlay1_desc1 = "b,0.8100,0.7350,radial,0.0875,0.0525"
+
+overlay2_name = "menu"
+overlay2_full_screen = true
+overlay2_normalized = true
+overlay2_descs = 1
+overlay2_desc0 = "menu_toggle,0.5,0.5,rect,0.1,0.1"
+
+overlay3_name = "hide"
+overlay3_full_screen = true
+overlay3_normalized = true
+overlay3_descs = 1
+overlay3_desc0 = "overlay_next,0.95,0.05,radial,0.04,0.04"
+overlay3_desc0_next_target = "landscape"
+]]
+
+local gameboy = assert(TouchSkin.parse(GAMEBOY_CFG))
+eq(#gameboy.pages, 4, "the canonical gameboy overlay has four pages")
+eq(gameboy.pages[1].orient, "landscape", "page 1 auto-rotates landscape")
+eq(gameboy.pages[2].orient, "portrait", "page 2 auto-rotates portrait")
+check(TouchSkin.hasOrientPair(gameboy), "so it is an auto-rotate overlay")
+eq(gameboy.pages[3].orient, nil, "the menu page is not part of the pair")
+eq(#gameboy.pages[1].controls, 13, "every landscape desc parsed")
+check(gameboy.pages[1].controls[1].decorative, "the d-pad art desc binds nothing")
+eq(gameboy.pages[1].controls[1].imagePath, "img/dpad.png", "and carries the art")
+eq(gameboy.pages[1].imagePath, nil, "the overlay ships no page background")
+eq(gameboy.pages[4].controls[1].nextTarget, "landscape", "hide jumps back by name")
+local named = {}
+for _, ctl in ipairs(gameboy.pages[1].controls) do
+ for _, btn in ipairs(ctl.buttons) do named[btn] = true end
+end
+for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do
+ check(named[btn], "landscape binds GB " .. btn)
+end
+eq(#gameboy.warnings, 0, "a well-formed overlay warns about nothing")
+
+local SPACED_CFG = [[
+overlays = 1
+overlay0_name = "spaced"
+overlay0_normalized = true
+overlay0_descs = 2
+overlay0_desc0 = "a 0.5 0.5 rect 0.05 0.05"
+overlay0_desc0_saturate_pct = 0.6
+overlay0_desc0_exclusive = true
+overlay0_desc0_movable = true
+overlay0_desc1 = b,0.25,0.5,radial,0.05,0.05
+]]
+local spaced = assert(TouchSkin.parse(SPACED_CFG))
+near(spaced.pages[1].controls[1].saturatePct, 0.6, "_saturate_pct is parsed")
+check(spaced.pages[1].controls[1].exclusive, "_exclusive is parsed")
+check(spaced.pages[1].controls[1].movable, "_movable is parsed on a plain desc")
+eq(spaced.pages[1].controls[2].exclusive, nil, "and is not inherited")
+eq(#spaced.pages[1].controls, 2, "a space-separated desc still parses")
+eq(spaced.pages[1].controls[1].buttons[1], "a", "space-separated bind")
+near(spaced.pages[1].controls[1].x, 0.5, "space-separated position")
+eq(spaced.pages[1].controls[2].buttons[1], "b", "an unquoted desc parses too")
+eq(spaced.pages[1].controls[2].shape, "radial", "and keeps its hitbox shape")
+
+local SHORT_CFG = [[
+overlays = 1
+overlay0_name = "short"
+overlay0_normalized = true
+overlay0_descs = 2
+overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05"
+]]
+local short = assert(TouchSkin.parse(SHORT_CFG))
+eq(#short.pages[1].controls, 1, "a missing desc is skipped, not faked")
+check(hasWarning(short, "missing desc 1"), "and the importer says so")
+
+eq(select(1, TouchSkin.parse("overlay0_descs = 1\n")), nil,
+ "a cfg without the overlays key is refused")
+
+local AREA_CFG = [[
+overlays = 1
+overlay0_name = "portrait"
+overlay0_full_screen = true
+overlay0_normalized = true
+overlay0_descs = 3
+overlay0_desc0 = "dpad_area,0.2,0.7,rect,0.15,0.1"
+overlay0_desc0_overlay = img/dpad.png
+overlay0_desc0_reach_x = 1.5
+overlay0_desc0_movable = true
+overlay0_desc1 = "abxy_area,0.8,0.7,radial,0.12,0.08"
+overlay0_desc1_up = "start"
+overlay0_desc2 = "analog_left,0.2,0.3,radial,0.1,0.1"
+overlay0_desc2_saturate_pct = 0.6
+overlay0_desc2_exclusive = true
+]]
+local area = assert(TouchSkin.parse(AREA_CFG))
+local ap = area.pages[1]
+near(ap.aspect, 0.5625, "a portrait-named overlay defaults to 9:16")
+check(not ap.aspectFromCfg, "and that default is not a cfg aspect lock")
+eq(#ap.controls, 1 + 8 + 8 + 8, "each area desc expands into eight hitboxes")
+
+local art = ap.controls[1]
+check(art.decorative, "the dpad_area art is carried by a decoration")
+eq(art.imagePath, "img/dpad.png", "with the desc's own overlay image")
+near(art.rangeX, 0.15, "sized like the area it replaces")
+
+local sectorE = ap.controls[2]
+eq(sectorE.spec, "right", "the first sector is the one pointing right")
+near(sectorE.x, 0.2, "every sector sits on the area centre")
+near(sectorE.y, 0.7, "on both axes")
+near(sectorE.rangeX, 0.15, "and covers the whole area, not a ninth of it")
+near(sectorE.reachLeft, 1.5, "the desc reach_x rides onto the sectors as it is")
+near(sectorE.reachRight, 1.5, "on both sides")
+eq(sectorE.sector, 1, "the sector index is kept for the hit test")
+eq(ap.controls[3].spec, "right|down", "the next sector is the lower-right corner")
+eq(ap.controls[4].spec, "down", "then straight down, y growing downwards")
+eq(ap.controls[8].spec, "up", "and straight up seven sectors along")
+check(ap.controls[1].movable, "_movable is parsed")
+
+local abxy = ap.controls[10]
+eq(abxy.spec, "a", "abxy right is RetroPad a, which is GB A")
+eq(abxy.buttons[1], "a", "and reaches that GB button")
+eq(ap.controls[16].spec, "start", "abxy_area honours an _up override")
+eq(ap.controls[15].spec, "y|start", "the up-left sector combines both sides")
+check(ap.controls[15].exclusive == nil, "and inherits nothing the desc did not set")
+check(ap.controls[14].decorative, "RetroPad Y has no GB button, so that sector is inert")
+eq(abxy.shape, "radial", "a radial area keeps its ellipse")
+
+eq(ap.controls[18].spec, "right", "analog_left degrades to a directional pad")
+check(ap.controls[18].exclusive, "_exclusive rides onto the expanded sectors")
+eq(ap.controls[23].spec, "left|up", "with all eight sectors")
+near(ap.controls[18].rangeX, 0.1, "analog sectors share the whole stick area")
+
+local sq = { x = 0.5, y = 0.5, rangeX = 0.25, rangeY = 0.25, shape = "rect",
+ rangeMod = 1, alphaMod = 1,
+ reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1 }
+local sectors = TouchSkin.expandSectors(sq, TouchSkin.AREA_DEFAULTS.dpad_area)
+eq(#sectors, 8, "a dpad area expands into eight sector hitboxes")
+local page = { rect = { x = 0, y = 0, w = 1, h = 1 }, fullScreen = true,
+ aspect = 1, controls = sectors }
+local function hitSpecs(px, py)
+ local out = {}
+ for _, ctl in ipairs(sectors) do
+ if TouchSkin.hits(page, ctl, 100, 100, px, py, 0, 0) then out[#out + 1] = ctl.spec end
+ end
+ return table.concat(out, "+")
+end
+eq(hitSpecs(50, 50), "right", "the exact centre still fires a direction: no dead zone")
+eq(hitSpecs(60, 50), "right", "a touch to the right of centre is right")
+eq(hitSpecs(50, 60), "down", "a touch below centre is down, y growing downwards")
+eq(hitSpecs(50, 40), "up", "a touch above centre is up")
+eq(hitSpecs(40, 40), "left|up", "a diagonal touch fires both directions")
+eq(hitSpecs(58, 52), "right", "17 degrees off the axis is still a pure direction")
+eq(hitSpecs(55, 53), "right|down", "and 31 degrees is the diagonal, not a grid corner")
+eq(hitSpecs(50, 80), "", "outside the area nothing fires")
+
+local PIXEL_NO_IMAGE = [[
+overlays = 1
+overlay0_name = "pixels"
+overlay0_descs = 1
+overlay0_desc0 = "a,120,80,rect,20,10"
+]]
+local noImage = assert(TouchSkin.parse(PIXEL_NO_IMAGE))
+check(hasWarning(noImage, "no base image"),
+ "pixel coords without a base image are called out")
+check(noImage.pages[1].pixelCoords == false,
+ "and read as normalized rather than dividing by nothing")
+
+love.filesystem.write("skins/px/overlay.cfg", [[
+overlays = 1
+overlay0_name = "px"
+overlay0_overlay = img/base.png
+overlay0_full_screen = true
+overlay0_descs = 2
+overlay0_desc0 = "a,4,4,rect,2,1"
+overlay0_desc1 = "b,6,2,rect,1,1"
+overlay0_desc1_normalized = true
+]])
+local px = assert(TouchSkin.load("skins/px", "px"))
+local pxPage = px.pages[1]
+check(pxPage.image ~= nil, "the base overlay image loads")
+local iw, ih = pxPage.image:getDimensions()
+near(pxPage.controls[1].x, 4 / iw, "pixel x is divided by the base image width")
+near(pxPage.controls[1].y, 4 / ih, "pixel y is divided by the base image height")
+near(pxPage.controls[1].rangeX, 2 / iw, "and so are the half extents")
+near(pxPage.controls[2].x, 6, "a per-desc normalized flag opts that desc out")
+check(pxPage.pixelCoords == false, "the page is normalized once converted")
+
+love.filesystem.write("skins/pxbad/overlay.cfg", [[
+overlays = 1
+overlay0_name = "pxbad"
+overlay0_overlay = img/broken.png
+overlay0_descs = 1
+overlay0_desc0 = "a,4,4,rect,2,1"
+]])
+local savedNewImage = love.graphics.newImage
+love.graphics.newImage = function() error("unreadable image") end
+local badPx, badPxErr = TouchSkin.load("skins/pxbad", "pxbad")
+love.graphics.newImage = savedNewImage
+eq(badPx, nil, "a skin whose pixel coordinates have no base image fails to load")
+check(tostring(badPxErr):find("img/broken.png", 1, true) ~= nil,
+ "and the error names the image it could not read")
+
+local DELTA_JSON = [[
+{
+ "name": "Test GBC",
+ "identifier": "com.example.gbc.test",
+ "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
+ "debug": false,
+ "representations": {
+ "iphone": {
+ "edgeToEdge": {
+ "portrait": {
+ "assets": { "small": "p_small.png", "medium": "p_medium.png",
+ "large": "p_large.png" },
+ "items": [
+ { "inputs": ["a"], "frame": {"x":240,"y":320,"width":64,"height":64},
+ "mask": "circle" },
+ { "inputs": ["b"], "frame": {"x":160,"y":360,"width":64,"height":64},
+ "extendedEdges": {"right":16} },
+ { "inputs": {"up":"up","down":"down","left":"left","right":"right"},
+ "frame": {"x":16,"y":320,"width":96,"height":96} },
+ { "inputs": ["start","select"],
+ "frame": {"x":128,"y":448,"width":64,"height":32} },
+ { "inputs": ["menu"], "frame": {"x":0,"y":0,"width":32,"height":32} },
+ { "inputs": ["quickSave"],
+ "frame": {"x":288,"y":0,"width":32,"height":32} }
+ ],
+ "mappingSize": {"width":320,"height":480},
+ "extendedEdges": {"top":8,"bottom":8,"left":8,"right":8},
+ "translucent": false,
+ "screens": [{ "inputFrame": {"x":0,"y":0,"width":160,"height":144},
+ "outputFrame": {"x":0,"y":32,"width":320,"height":288} }]
+ }
+ },
+ "standard": {
+ "portrait": { "items": [], "mappingSize": {"width":320,"height":480} }
+ }
+ }
+ }
+}
+]]
+
+local delta = assert(TouchSkin ~= nil and DeltaSkin.parse(DELTA_JSON))
+eq(delta.format, "delta", "a .deltaskin parses into the native model")
+eq(delta.name, "Test GBC", "info.json name")
+eq(delta.system, "gbc", "gbc covers both GB and GBC")
+eq(#delta.pages, 1, "only the orientations present become pages")
+local dp = delta.pages[1]
+eq(dp.name, "portrait", "the page is named for its orientation")
+eq(dp.orient, "portrait", "and locked to it")
+eq(dp.imagePath, "p_large.png", "the PNG ladder picks the largest for a phone")
+check(dp.fullScreen, "Delta stretches its skin over the whole surface")
+check(not dp.aspectFromCfg, "so nothing letterboxes it")
+near(dp.aspect, 320 / 480, "the page aspect is the mappingSize aspect")
+eq(#dp.controls, 13, "edgeToEdge wins over standard, so all six items parsed")
+
+local dA = dp.controls[1]
+eq(dA.buttons[1], "a", "an inputs array binds its button")
+eq(dA.shape, "radial", 'mask "circle" becomes a radial hitbox')
+near(dA.x, 0.85, "frame top-left plus half width is the native centre")
+near(dA.y, 352 / 480, "and the same for y")
+near(dA.rangeX, 0.1, "frame width halves into the native half extent")
+near(dA.reachLeft, 1.25, "orientation extendedEdges become reach")
+
+local dB = dp.controls[2]
+near(dB.reachRight, 1.5, "a per-item extendedEdges key overrides that side")
+near(dB.reachLeft, 1.25, "and leaves the others inherited")
+
+eq(dp.controls[3].spec, "left|up", "a dpad input object expands to a 3x3 grid")
+near(dp.controls[3].x, 0.1, "dpad top-left cell x")
+near(dp.controls[3].rangeX, 0.05, "dpad cells are a third of the frame")
+near(dp.controls[3].reachLeft, 1.5, "with the extended edge re-scaled onto them")
+eq(dp.controls[4].spec, "up", "dpad top-centre cell")
+eq(dp.controls[10].spec, "right|down", "dpad bottom-right cell")
+
+eq(dp.controls[11].spec, "start|select", "a multi-input item fires both")
+eq(dp.controls[12].hotkeys[1], "menu", "the Delta menu button becomes a hotkey")
+check(dp.controls[13].decorative,
+ "quickSave has no engine hotkey, so it is inert rather than a game button")
+
+check(dp.viewport ~= nil, "screens[] places the emulator picture")
+near(dp.viewport.y, 32 / 480, "outputFrame y normalizes by mappingSize")
+near(dp.viewport.h, 288 / 480, "outputFrame height normalizes by mappingSize")
+
+local bx, by, bw, bh = TouchSkin.pageBox(dp, 1000, 500)
+eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y")
+eq(bw, 1000, "delta page box fills the width")
+eq(bh, 500, "delta page box fills the height")
+
+local DECK_JSON = [[
+{ "name": "Deck", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
+ "representations": { "iphone": { "standard": { "portrait": {
+ "assets": { "large": "deck.png" },
+ "mappingSize": {"width":320,"height":240},
+ "items": [ { "inputs": ["a"], "frame": {"x":240,"y":60,"width":64,"height":64} } ]
+ } } } } }
+]]
+local deck = assert(DeltaSkin.parse(DECK_JSON))
+local deckPage = deck.pages[1]
+check(deckPage.aspectFromCfg, "a portrait deck without screens keeps mapping aspect")
+eq(deckPage.anchor, "bottom", "and sits at the bottom of the window")
+eq(deckPage.screenFit, "remainder", "with the leftover given to the GB picture")
+eq(deckPage.viewport, nil, "no screens[] means no baked cutout")
+local dbx, dby, dbw, dbh = TouchSkin.pageBox(deckPage, 1080, 1920)
+eq(dbx, 0, "deck overlay is full width")
+eq(dbw, 1080, "deck overlay width")
+near(dbh, 1080 * 240 / 320, "deck overlay height is mapping aspect")
+near(dby, 1920 - dbh, "pinned to the bottom, not stretched")
+local vx, vy, vw, vh = TouchSkin.pageViewport(deckPage, 1080, 1920)
+eq(vx, 0, "screen leftover x") eq(vy, 0, "screen leftover y")
+eq(vw, 1080, "screen leftover is full width")
+near(vh, dby, "and fills everything above the overlay")
+check(vh > dbh, "there is more room for the picture than for the pad")
+
+local LEGACY_SCREEN = [[
+{ "gameTypeIdentifier": "public.aoshuang.game.gbc",
+ "representations": { "iphone": { "standard": { "landscape": {
+ "mappingSize": {"width":640,"height":320},
+ "gameScreenFrame": {"x":160,"y":0,"width":320,"height":288},
+ "translucent": true,
+ "items": [ { "inputs": {"up":"analogStickUp","down":"analogStickDown",
+ "left":"analogStickLeft","right":"analogStickRight"},
+ "frame": {"x":0,"y":0,"width":120,"height":120} } ] } } } } }
+]]
+local legacy = assert(DeltaSkin.parse(LEGACY_SCREEN))
+eq(legacy.system, "gbc", "the Manic public.aoshuang prefix is accepted")
+eq(#legacy.pages, 1, "landscape only")
+eq(legacy.pages[1].orient, "landscape", "orientation key drives the lock")
+near(legacy.pages[1].viewport.x, 0.25, "gameScreenFrame is the legacy screen rect")
+near(legacy.pages[1].alphaMod, 0.7, "translucent dims the controls")
+eq(#legacy.pages[1].controls, 8, "a thumbstick degrades to a directional pad")
+eq(legacy.pages[1].controls[1].spec, "left|up", "with the analog names mapped")
+
+local snes = assert(DeltaSkin.parse([[
+{ "gameTypeIdentifier": "com.rileytestut.delta.game.snes",
+ "representations": { "iphone": { "standard": { "portrait": {
+ "mappingSize": {"width":320,"height":480}, "items": [] } } } } }
+]]))
+check(hasWarning(snes, "not Game Boy"), "a non Game Boy skin warns")
+eq(#snes.pages, 1, "but still imports")
+
+eq(select(1, DeltaSkin.parse([[
+{ "name": "old", "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gba",
+ "representations": { "iphone": { "portrait": { "assets": {} } } } }
+]])), nil, "a GBA4iOS skin is refused")
+local _, gbaErr = DeltaSkin.parse([[
+{ "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gbc", "representations": {} }
+]])
+check(tostring(gbaErr):find("GBA4iOS", 1, true) ~= nil,
+ "and the message names the old format")
+
+local _, noTypeErr = DeltaSkin.parse('{ "representations": {} }')
+check(tostring(noTypeErr):find("gameTypeIdentifier", 1, true) ~= nil,
+ "info.json without a gameTypeIdentifier is refused by name")
+eq(select(1, DeltaSkin.parse("not json at all")), nil, "garbage is refused")
+eq(select(1, DeltaSkin.parse([[
+{ "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", "representations": {} }
+]])), nil, "an empty representations tree is refused")
+
+local PDF_JSON = [[
+{ "name": "Vector", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
+ "representations": { "iphone": { "standard": { "portrait": {
+ "assets": { "resizable": "iphone_portrait.pdf" },
+ "mappingSize": {"width":320,"height":480},
+ "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":32,"height":32} } ]
+ } } } } }
+]]
+local pdf = assert(DeltaSkin.parse(PDF_JSON))
+eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art")
+eq(pdf.pages[1].pdfPath, "iphone_portrait.pdf",
+ "but the PDF path is kept so load can extract a JPEG from it")
+local convert = DeltaSkin.needsConversion(pdf)
+check(convert ~= nil, "PDF-only skins report that they need conversion")
+if convert then
+ check(convert.pdfOnly, "the report is flagged pdfOnly")
+ eq(convert.files[1], "iphone_portrait.pdf", "and names the file to convert")
+end
+eq(DeltaSkin.needsConversion(delta), nil, "a PNG skin needs no conversion")
+
+local mixed = assert(DeltaSkin.parse([[
+{ "gameTypeIdentifier": "com.rileytestut.delta.game.gb",
+ "representations": { "iphone": { "standard": { "portrait": {
+ "assets": { "resizable": "art.pdf", "medium": "art.png" },
+ "mappingSize": {"width":320,"height":480}, "items": [] } } } } }
+]]))
+eq(mixed.pages[1].imagePath, "art.png", "a raster asset beats the PDF")
+eq(DeltaSkin.needsConversion(mixed), nil, "so no conversion is needed")
+
+eq(DeltaSkin.pickAsset({ small = "s.png" }, { targetWidth = 1080 }, {}), "s.png",
+ "the ladder falls back to the largest shipped asset")
+eq(DeltaSkin.pickAsset({ small = "s.png", medium = "m.png", large = "l.png" },
+ { targetWidth = 640 }, {}), "s.png",
+ "a small target takes the small asset")
+eq(DeltaSkin.pickAsset({ normal = "n.png" }, { targetWidth = 640 }, {}), "n.png",
+ 'the Manic "normal" alias is accepted')
+
+love.filesystem.write("skins/wrapped.deltaskin/MySkin/info.json", [[
+{ "name": "Wrapped", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
+ "representations": { "iphone": { "standard": { "portrait": {
+ "assets": { "large": "Portrait.PNG" },
+ "mappingSize": {"width":320,"height":480},
+ "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":64,"height":64} } ]
+ } } } } }
+]])
+love.filesystem.write("skins/wrapped.deltaskin/MySkin/portrait.png", "\137PNG\r\n\26\n")
+
+local wrappedId, wrappedErr = TouchSkin.installArchive("wrapped.deltaskin", "PK\3\4stub")
+eq(wrappedId, "wrapped", "a .deltaskin installs under its bare name: " .. tostring(wrappedErr))
+eq(TouchSkin.find('wrapped').id,'wrapped','refresh retains an already-mounted archive')
+eq(TouchSkin.find('wrapped').id,'wrapped','repeated refresh still retains the archive')
+local wrapped = assert(TouchSkin.load("skins/_mounted/wrapped", "wrapped"))
+eq(wrapped.format, "delta", "the mounted archive is recognised as a Delta skin")
+eq(wrapped.name, "Wrapped", "and its name comes from info.json")
+eq(wrapped.pages[1].imagePath, "MySkin/portrait.png",
+ "the wrapping folder is prefixed onto assets and the real file name wins")
+eq(#wrapped.pages[1].controls, 1, "the wrapped items parsed")
+eq(TouchSkin.installArchive('wrapped.deltaskin','PK\3\4replacement'),'wrapped',
+ 'replacement releases the previous mount before installing')
+check(TouchSkin.find('wrapped') ~= nil,'replacement is visible on a subsequent refresh')
+
+love.filesystem.write("skins/vector.deltaskin/info.json", PDF_JSON)
+local vectorId, vectorErr = TouchSkin.installArchive("vector.deltaskin", "PK\3\4stub")
+eq(vectorId, nil, "a PDF-only skin is refused instead of installing invisible")
+check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil,
+ "with the message that asks for a PNG version")
+eq(love.filesystem.read("skins/vector.deltaskin"), nil,
+ "and the refused archive is not left behind")
+
+local PdfImage = require("src.core.PdfImage")
+local function unhex(s)
+ return (s:gsub("..", function(cc)
+ return string.char(tonumber(cc, 16))
+ end))
+end
+-- 1x1 JFIF JPEG, so extract tests do not need a file on disk.
+local TINY_JPEG = unhex(
+ "ffd8ffe000104a46494600010100000100010000ffdb0043000806060706050807070709" ..
+ "09080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c283729" ..
+ "2c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc400140001" ..
+ "0000000000000000000000000000000008ffc40014100100000000000000000000000000" ..
+ "00000000ffda0008010100003f007f3fffd9")
+local function jpegPdf(jpeg, w, h)
+ return "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width "
+ .. tostring(w) .. " /Height " .. tostring(h)
+ .. " /BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter /DCTDecode /Length "
+ .. tostring(#jpeg) .. " >>\nstream\n" .. jpeg .. "\nendstream\nendobj\n%%EOF\n"
+end
+local extracted = assert(PdfImage.extract(jpegPdf(TINY_JPEG, 1, 1)))
+eq(extracted.ext, "jpg", "a JPEG-in-PDF yields a jpg")
+eq(extracted.data, TINY_JPEG, "and the JPEG body is recovered byte for byte")
+eq(extracted.width, 1, "width comes from the Image XObject")
+eq(extracted.height, 1, "and so does height")
+
+local indirect = "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width 1"
+ .. " /Height 1 /Filter /DCTDecode /Length 5 0 R >>\nstream\n" .. TINY_JPEG
+ .. "\nendstream\nendobj\n5 0 obj\n" .. tostring(#TINY_JPEG) .. "\nendobj\n%%EOF\n"
+local fromRef = assert(PdfImage.extract(indirect))
+eq(fromRef.data, TINY_JPEG,
+ "an indirect /Length (the 3-Heights Image-to-PDF layout) still extracts")
+
+eq(select(1, PdfImage.extract("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")),
+ nil, "a vector PDF with no image is not pretended to be art")
+eq(select(1, PdfImage.extract("not a pdf")), nil, "and neither is garbage")
+
+love.filesystem.write("skins/pikapdf.deltaskin/info.json", PDF_JSON)
+love.filesystem.write("skins/pikapdf.deltaskin/iphone_portrait.pdf",
+ jpegPdf(TINY_JPEG, 1, 1))
+local pikaId, pikaErr = TouchSkin.installArchive("pikapdf.deltaskin", "PK\3\4stub")
+eq(pikaId, "pikapdf", "a Delta skin whose PDF wraps a JPEG installs: "
+ .. tostring(pikaErr))
+local pika = assert(TouchSkin.load("skins/_mounted/pikapdf", "pikapdf"))
+check(pika.pages[1].rasterData == TINY_JPEG,
+ "load recovers the JPEG from the PDF")
+check(pika.pages[1].image ~= nil, "and LOVE gets an image from those bytes")
+eq(DeltaSkin.needsConversion(pika), nil,
+ "so the skin no longer reports that it needs conversion")
+
+eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil,
+ "a GBA4iOS .gbcskin is refused at the door")
+local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub")
+check(tostring(legacyErr):find("GBA4iOS", 1, true) ~= nil,
+ "with a message that names the format")
+eq(select(1, TouchSkin.installArchive("skin.rar", "PK\3\4stub")), nil,
+ "an unknown archive extension is refused")
+eq(TouchSkin.archiveId("pad.deltaskin"), "pad", "archiveId strips .deltaskin")
+eq(TouchSkin.archiveId("pad.zip"), "pad", "archiveId strips .zip")
+eq(TouchSkin.archiveId("pad"), nil, "a bare name is not an archive")
+
+local AUTHORED = [[
+return { name = "Authored", pages = {
+ { name = "portrait", orient = "portrait", fullScreen = true,
+ viewport = { x = 0, y = 0, w = 1, h = 0.5 },
+ controls = {
+ { bind = "a", x = 0.8, y = 0.75, w = 0.2, h = 0.1, shape = "radial" },
+ { bind = "b", x = 0.6, y = 0.8, w = 0.2, h = 0.1, shape = "radial",
+ reachRight = 1.5 },
+ { bind = "start", x = 0.5, y = 0.95, w = 0.1, h = 0.04 },
+ { bind = "menu_toggle", x = 0.05, y = 0.05, w = 0.08, h = 0.04 },
+ { bind = "nul", x = 0.2, y = 0.7, w = 0.3, h = 0.2, image = "img/dpad.png" },
+ } },
+ { name = "landscape", orient = "landscape", fullScreen = true,
+ controls = {
+ { bind = "a", x = 0.9, y = 0.8, w = 0.1, h = 0.15, shape = "radial" },
+ } },
+} }
+]]
+local authored = assert(TouchSkin.parseNative(AUTHORED))
+authored.id = "authored"
+authored.root = "skins/authored"
+
+local cfgText = TouchSkin.toRetroArchConfig(authored)
+check(cfgText:find("overlays = 2", 1, true) ~= nil, "the cfg declares its overlays")
+local reparsed = assert(TouchSkin.parse(cfgText))
+eq(#reparsed.pages, 2, "the generated cfg round-trips both pages")
+eq(reparsed.pages[1].name, "portrait", "and their names")
+eq(#reparsed.pages[1].controls, 5, "and every desc")
+eq(reparsed.pages[1].controls[1].spec, "a", "binds survive the round trip")
+near(reparsed.pages[1].controls[1].x, 0.8, "centres survive the round trip")
+near(reparsed.pages[1].controls[1].rangeX, 0.1, "half extents survive")
+eq(reparsed.pages[1].controls[1].shape, "radial", "hitbox shape survives")
+near(reparsed.pages[1].controls[2].reachRight, 1.5, "per-side reach survives")
+eq(reparsed.pages[1].controls[4].hotkeys[1], "menu", "hotkeys survive")
+check(reparsed.pages[1].controls[5].decorative, "decoration stays decoration")
+eq(reparsed.pages[1].controls[5].imagePath, "img/dpad.png", "and keeps its art")
+near(reparsed.pages[1].viewport.h, 0.5, "the screen cutout survives")
+eq(reparsed.pages[1].orient, "portrait", "the orientation lock survives by name")
+
+local KEY_SKIN = [[
+return { name = "Keys", pages = {
+ { name = "portrait", fullScreen = true, controls = {
+ { bind = "key:escape", x = 0.5, y = 0.5, w = 0.1, h = 0.1 },
+ } },
+} }
+]]
+local keySkin = assert(TouchSkin.parseNative(KEY_SKIN))
+local keyCfg = TouchSkin.toRetroArchConfig(keySkin)
+check(keyCfg:find("retrok_escape", 1, true) ~= nil,
+ "a key bind exports in the grammar RetroArch understands")
+check(keyCfg:find("key:escape", 1, true) == nil, "and not in the native spelling")
+eq(assert(TouchSkin.parse(keyCfg)).pages[1].controls[1].keys[1], "escape",
+ "which this importer still reads back as the same key")
+
+local areaCfg = TouchSkin.toRetroArchConfig({ pages = area.pages })
+local areaBack = assert(TouchSkin.parse(areaCfg))
+eq(#areaBack.pages[1].controls, #ap.controls,
+ "an area desc exports as one desc, not eight overlapping ones")
+check(areaCfg:find("dpad_area", 1, true) ~= nil, "the area kind is written back")
+check(areaCfg:find('_up = "start"', 1, true) ~= nil, "with its output override")
+eq(areaBack.pages[1].controls[16].spec, "start", "which survives the round trip")
+
+local areaInfo = assert(DeltaSkin.build({ id = "area", pages = area.pages }))
+local areaRep = areaInfo.representations.iphone.edgeToEdge.portrait
+local dpadItem
+for _, item in ipairs(areaRep.items) do
+ if not dpadItem and type(item.inputs) == "table" and item.inputs.up then
+ dpadItem = item
+ end
+end
+check(dpadItem ~= nil, "the same area exports to Delta as one d-pad item")
+eq(dpadItem.inputs.left, "left", "carrying each direction")
+eq(#areaRep.items, 3, "one per area desc, not eight stacked on one another")
+
+local raPath = os.tmpname() .. "-ra.zip"
+local raWritten, raMissing = TouchSkin.exportRetroArch(authored, raPath)
+eq(raWritten, raPath, "exportRetroArch writes where it was told")
+eq(raMissing[1], "img/dpad.png", "and reports art it could not find")
+local raZip = unzip(readBytes(raPath))
+eq(raZip[1], "overlay.cfg", "the RetroArch zip leads with overlay.cfg")
+check(raZip["overlay.cfg"] ~= nil, "and the entry has bytes")
+check(TouchSkin.parse(raZip["overlay.cfg"]) ~= nil, "which RetroArch grammar accepts")
+os.remove(raPath)
+
+local dsPath = os.tmpname() .. ".deltaskin"
+local dsWritten, _, dsWarnings = TouchSkin.exportDelta(authored, { path = dsPath })
+eq(dsWritten, dsPath, "exportDelta writes where it was told")
+check(#dsWarnings > 0, "and warns that per-button art has nowhere to go")
+local dsZip = unzip(readBytes(dsPath))
+eq(dsZip[1], "info.json", "the .deltaskin leads with info.json")
+local info = assert(Json.decode(dsZip["info.json"]))
+eq(info.gameTypeIdentifier, "com.rileytestut.delta.game.gbc",
+ "the export claims the GBC game type")
+eq(info.name, "Authored", "and carries the skin name")
+check(info.identifier:find("authored", 1, true) ~= nil, "identifier names the skin")
+local rep = info.representations.iphone.edgeToEdge.portrait
+check(rep ~= nil, "an iPhone edgeToEdge portrait representation is emitted")
+eq(info.representations.iphone.standard.portrait.mappingSize.width, 1080,
+ "standard portrait maps 1080 wide")
+eq(rep.mappingSize.height, 1920, "portrait maps 1920 tall")
+eq(#rep.items, 4, "only bound controls become Delta items")
+eq(rep.items[1].inputs[1], "a", "the first item is A")
+eq(rep.items[1].mask, "circle", "a radial hitbox exports as a circle mask")
+eq(rep.items[1].frame.x, 756, "frame x is top-left, not centre")
+eq(rep.items[1].frame.width, 216, "frame width is the full extent")
+eq(rep.items[2].extendedEdges.right, 54, "reach exports as extendedEdges")
+eq(rep.items[4].inputs[1], "menu", "the menu hotkey exports as a Delta host input")
+eq(rep.screens[1].inputFrame.width, 160, "the screen crop is a full GB frame")
+eq(rep.screens[1].outputFrame.height, 960, "and the output frame follows the viewport")
+eq(info.representations.iphone.edgeToEdge.landscape.mappingSize.width, 1920,
+ "the landscape page maps 1920 wide")
+
+local back = assert(DeltaSkin.parse(dsZip["info.json"]))
+eq(#back.pages, 2, "the exported skin re-imports both orientations")
+local bp = back.pages[1]
+eq(#bp.controls, 4, "with every bound control")
+near(bp.controls[1].x, 0.8, "and the same centres it started with")
+near(bp.controls[1].rangeX, 0.1, "and the same half extents")
+eq(bp.controls[1].shape, "radial", "and the same hitbox shape")
+near(bp.controls[2].reachRight, 1.5, "and the same reach")
+near(bp.viewport.h, 0.5, "and the same screen cutout")
+os.remove(dsPath)
+
+love.filesystem.write("skins/collide/overlay.cfg", [[
+overlays = 1
+overlay0_name = "collide"
+overlay0_descs = 1
+overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05"
+]])
+local collide = assert(TouchSkin.load("skins/collide", "collide"))
+local defaultDelta = assert(TouchSkin.exportDelta(collide))
+eq(defaultDelta, "skins/_export/collide.deltaskin",
+ "a default export lands outside the folder the skin list scans")
+local listedRoot, listedExport
+for _, entry in ipairs(TouchSkin.list()) do
+ if entry.id == "collide" then listedRoot = entry.root end
+ if entry.id == "_export" then listedExport = true end
+end
+eq(listedRoot, "skins/collide", "so the export cannot shadow the skin it came from")
+check(not listedExport, "and the export folder is not a skin of its own")
+
+local oldUnmount=love.filesystem.unmount
+love.filesystem.unmount=function() return false end
+eq(TouchSkin.remove('wrapped'),nil,'a busy mounted archive is not deleted')
+check(love.filesystem.read('skins/wrapped.deltaskin') ~= nil,'busy archive bytes are retained')
+love.filesystem.unmount=oldUnmount
+check(TouchSkin.remove('wrapped'),'removal releases its mount before deleting archive bytes')
+eq(TouchSkin.find('wrapped'),nil,'removed archive is absent on subsequent refresh')
+
+T.finish("skin_format_import")
diff --git a/tests/engine/touch_haptics.lua b/tests/engine/touch_haptics.lua
new file mode 100644
index 000000000..c63c7a296
--- /dev/null
+++ b/tests/engine/touch_haptics.lua
@@ -0,0 +1,89 @@
+-- Native haptic preferences and input edges. No physical vibrator claim.
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.modkit')
+local Touch=require('src.core.TouchControls')
+local Skin=require('src.core.TouchSkin')
+local Input=require('src.core.Input')
+local Save=require('src.core.SaveData')
+local Version=require('src.core.GameVersion')
+T.eq(Touch.normalizeHaptics(nil),'light')
+T.eq(Touch.normalizeHaptics('physical'),'light')
+T.eq(Touch.normalizeHaptics('medium'),'normal')
+T.eq(Touch.normalizeHaptics('heavy'),'strong')
+T.eq(Touch.normalizeHaptics('bad'),'light')
+T.eq(Touch.cycleHaptics('strong',1),'off')
+T.eq(Touch.cycleHaptics('off',-1),'strong')
+for _,level in ipairs{'off','light','normal','strong'}do
+ T.eq(Touch.hapticLabel(level),level:upper(),'native haptic label')
+end
+love.system=love.system or {}
+local oldOS,oldVibrate=love.system.getOS,love.system.vibrate
+love.system.getOS=oldOS or function() return "Linux" end
+local pulses={}
+love.system.vibrate=function(seconds)pulses[#pulses+1]=seconds end
+T.eq(Touch.buzz('off'),false,'OFF never pulses')
+for _,row in ipairs{{'light',.012},{'normal',.025},{'strong',.045}}do
+ T.check(Touch.buzz(row[1]),'supported level requests a pulse')
+ T.eq(pulses[#pulses],row[2],'duration follows reference preset')
+end
+love.system.vibrate=nil
+T.eq(Touch.buzz('light'),false,'runtime without vibrator remains usable')
+love.system.vibrate=function(seconds)pulses[#pulses+1]=seconds end
+Version.set('crystal');Input:init();Touch:init();Touch.active=true
+Touch:applyOptions({haptics='normal'})
+local skin=Skin.newSkin('haptic-memory')
+skin.pages[1].controls={Skin.newControl('a',.8,.8,.2,.2,'rect'),
+ Skin.newControl('key:f5',.2,.8,.2,.2,'rect')}
+Skin.setActive(skin)
+local w,h=love.graphics.getDimensions()
+local ax,ay=Skin.controlGeometry(Skin.page(),Skin.page().controls[1],w,h)
+local kx,ky=Skin.controlGeometry(Skin.page(),Skin.page().controls[2],w,h)
+local n=#pulses
+T.check(Touch:touchpressed('one',ax,ay))
+T.eq(#pulses,n+1,'first native button owner pulses')
+Touch:touchpressed('two',ax,ay)
+T.eq(#pulses,n+1,'second owner of held button does not pulse')
+Touch:touchmoved('one',ax,ay)
+T.eq(#pulses,n+1,'stationary held contact does not pulse')
+Touch:touchreleased('one',ax,ay)
+T.check(Input:isDown('a'),'first release retains other owner')
+Touch:touchreleased('two',ax,ay)
+T.eq(#pulses,n+1,'release does not pulse')
+T.check(not Input:isDown('a'))
+Touch:touchpressed('key-one',kx,ky)
+T.eq(#pulses,n+2,'key-only control pulses on its first owner')
+Touch:touchpressed('key-two',kx,ky)
+T.eq(#pulses,n+2,'shared key owner does not retrigger vibration')
+Touch:reset()
+T.eq(#pulses,n+2,'focus reset releases holds without a new pulse')
+Touch.haptics='off';Skin.setActive(skin)
+Touch:touchpressed('off',ax,ay);Touch:touchreleased('off',ax,ay)
+T.eq(#pulses,n+2,'OFF preference applies to actual skin input')
+Skin.setActive(nil)
+T.eq(Touch:config().haptics,nil,'layout snapshots do not relocate the global preference')
+T.eq(Save.defaultOptions().haptics,'light','new native options retain the reference default')
+
+-- Normal OPTIONS input, not direct calls to the row implementation.
+love.system.getOS=function()return 'Android'end
+local Stack=require('src.core.StateStack')
+local game={data=T.fixtures.fresh(),save=Save.newGame(),writes=0,input={pressed={}}}
+game.stack=setmetatable({},{__index=Stack});game.stack:init()
+game.input.wasPressed=function(self,key)return self.pressed[key]end
+game.writeOptions=function(self)self.writes=self.writes+1;self.persisted=Save.encode(self.save)end
+local menu=require('src.ui.OptionsMenu').new(game);game.stack:push(menu)
+local page
+for index,row in ipairs(menu.rows) do
+ if row.id=='haptics' then menu.index=index;page=menu;break end
+end
+T.check(page~=nil,'mobile native options offer haptic intensity')
+if page then
+ game.input.pressed={right=true};page:update(1/60);game.input.pressed={}
+ T.eq(game.save.options.haptics,'normal','normal RIGHT changes haptic preference')
+ T.eq(Touch.haptics,'normal','native controller applies the chosen intensity')
+ T.eq(game.writes,1,'normal input persists exactly one change')
+ local saved=assert(Save.decode(game.persisted))
+ T.eq(saved.options.haptics,'normal','native save codec retains haptics')
+end
+love.system.getOS, love.system.vibrate=oldOS,oldVibrate
+Touch:reset();Skin.setActive(nil)
+T.finish('native haptics preferences, ownership edges and normal options input')
diff --git a/tests/engine/touch_skin_dpad_area.lua b/tests/engine/touch_skin_dpad_area.lua
new file mode 100644
index 000000000..abb10e198
--- /dev/null
+++ b/tests/engine/touch_skin_dpad_area.lua
@@ -0,0 +1,201 @@
+-- From Gen1Recomp 535a5ec0, Copyright (C) 2026 BOIS CLUB GAMES, LLC.
+-- GPLv3 + Section 7; see licenses/GEN1RECOMP-TOUCH-SKINS.md.
+-- RetroArch dpad_area / abxy_area descs (#1533): one hitbox whose fired
+-- input is resolved by the angle of the touch from the area centre, and
+-- range_mod growing a hitbox only while it is held. The cfg below is the
+-- reporter's GBA skin, trimmed to the d-pad and face buttons.
+-- luajit tests/engine/touch_skin_dpad_area.lua
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+if not _G.love then _G.love = require("tests.love_stub") end
+
+local T = require("tests.harness")
+local check, eq = T.check, T.eq
+
+local TouchSkin = require("src.core.TouchSkin")
+local TouchControls = require("src.core.TouchControls")
+local Input = require("src.core.Input")
+
+local CFG = [[
+overlays = 2
+
+overlay0_name = "portrait"
+overlay0_full_screen = true
+overlay0_normalized = true
+overlay0_range_mod = 1.5
+overlay0_alpha_mod = 1
+overlay0_aspect_ratio = 0.45
+overlay0_descs = 13
+overlay0_desc0 = "select,0.41944,0.58793,radial,0.06667,0.03"
+overlay0_desc0_overlay = p-btn-select.png
+overlay0_desc0_reach_x = 1.25
+overlay0_desc0_reach_y = 1.25
+overlay0_desc1 = "start,0.58056,0.58793,radial,0.06667,0.03"
+overlay0_desc1_overlay = p-btn-start.png
+overlay0_desc1_reach_x = 1.25
+overlay0_desc1_reach_y = 1.25
+overlay0_desc2 = "up,0.25,0.6625,radial,0.07778,0.035"
+overlay0_desc2_overlay = p-btn-dpad-up.png
+overlay0_desc2_reach_x = 0
+overlay0_desc3 = "left,0.12222,0.72,radial,0.07778,0.035"
+overlay0_desc3_overlay = p-btn-dpad-left.png
+overlay0_desc3_reach_x = 0
+overlay0_desc4 = "right,0.37778,0.72,radial,0.07778,0.035"
+overlay0_desc4_overlay = p-btn-dpad-right.png
+overlay0_desc4_reach_x = 0
+overlay0_desc5 = "down,0.25,0.7775,radial,0.07778,0.035"
+overlay0_desc5_overlay = p-btn-dpad-down.png
+overlay0_desc5_reach_x = 0
+overlay0_desc6 = "up|left,0.11644,0.6599,radial,0.01667,0.0075"
+overlay0_desc6_overlay = p-btn-corner.png
+overlay0_desc6_reach_x = 0
+overlay0_desc7 = "up|right,0.38356,0.6599,radial,0.01667,0.0075"
+overlay0_desc7_overlay = p-btn-corner.png
+overlay0_desc7_reach_x = 0
+overlay0_desc8 = "down|left,0.11644,0.7801,radial,0.01667,0.0075"
+overlay0_desc8_overlay = p-btn-corner.png
+overlay0_desc8_reach_x = 0
+overlay0_desc9 = "down|right,0.38356,0.7801,radial,0.01667,0.0075"
+overlay0_desc9_overlay = p-btn-corner.png
+overlay0_desc9_reach_x = 0
+overlay0_desc10 = "dpad_area,0.25,0.72,radial,0.22778,0.1025"
+overlay0_desc10_overlay = p-area-dpad.png
+overlay0_desc10_reach_x = 1.25
+overlay0_desc10_reach_y = 1.25
+overlay0_desc11 = "a,0.84382,0.69563,radial,0.09722,0.04375"
+overlay0_desc11_overlay = p-btn-act2-a.png
+overlay0_desc11_reach_x = 1.25
+overlay0_desc11_reach_y = 1.25
+overlay0_desc12 = "b,0.65618,0.74438,radial,0.09722,0.04375"
+overlay0_desc12_overlay = p-btn-act2-b.png
+overlay0_desc12_reach_x = 1.25
+overlay0_desc12_reach_y = 1.25
+
+overlay1_name = "areas"
+overlay1_full_screen = true
+overlay1_normalized = true
+overlay1_descs = 2
+overlay1_desc0 = "dpad_area,0.25,0.5,rect,0.2,0.2"
+overlay1_desc0_up = "start"
+overlay1_desc0_down = "select"
+overlay1_desc0_left = "nul"
+overlay1_desc1 = "abxy_area,0.75,0.5,rect,0.2,0.2"
+]]
+
+local skin = assert(TouchSkin.parse(CFG))
+local page = skin.pages[1]
+
+eq(#page.controls, 12 + 1 + 8, "the dpad_area expands into eight sector controls")
+local sectors = {}
+for _, ctl in ipairs(page.controls) do
+ if ctl.sector then sectors[ctl.sector] = ctl end
+end
+eq(#sectors, 8, "eight sectors, one per direction")
+eq(sectors[1].spec, "right", "sector 1 is right")
+eq(sectors[2].spec, "right|down", "sector 2 is the down-right diagonal")
+eq(sectors[3].spec, "down", "sector 3 is down, y growing downwards")
+eq(sectors[7].spec, "up", "sector 7 is up")
+eq(sectors[1].x, 0.25, "every sector keeps the area centre")
+eq(sectors[5].y, 0.72, "on both axes")
+eq(sectors[3].rangeX, 0.22778, "and the whole area range")
+eq(sectors[3].shape, "radial", "and the declared hitbox shape")
+
+local art = page.controls[11]
+eq(art.imagePath, "p-area-dpad.png", "the area art rides a decorative desc")
+check(art.decorative, "which presses nothing")
+
+TouchControls:init()
+TouchControls.active = true
+TouchControls.enabled = true
+TouchSkin.setOverlayLive(true)
+TouchSkin.setActive(skin)
+Input:init()
+
+local W, H = 720, 1600
+TouchSkin.setSurface(0, 0, W, H)
+eq(TouchSkin.page().name, "portrait", "the portrait page is live at 720x1600")
+
+local BUTTONS = { "up", "down", "left", "right", "a", "b", "start", "select" }
+local function heldNow()
+ local out = {}
+ for _, btn in ipairs(BUTTONS) do
+ if Input:isDown(btn) then out[#out + 1] = btn end
+ end
+ return table.concat(out, "+")
+end
+
+local function press(nx, ny)
+ TouchControls:touchpressed("f1", nx * W, ny * H)
+ local got = heldNow()
+ TouchControls:touchreleased("f1", nx * W, ny * H)
+ return got
+end
+
+local function fires(nx, ny, want, why)
+ eq(press(nx, ny), want, why)
+end
+
+fires(0.25, 0.6625, "up", "the d-pad up arrow fires up alone")
+fires(0.12222, 0.72, "left", "the left arrow fires left alone")
+fires(0.37778, 0.72, "right", "the right arrow fires right alone")
+fires(0.25, 0.7775, "down", "the down arrow fires down alone")
+fires(0.11644, 0.6599, "up+left", "the up-left corner fires both, and only both")
+fires(0.38356, 0.7801, "down+right", "as does the down-right corner")
+
+fires(0.32, 0.77, "down+right",
+ "a spot inside the area but off every arrow resolves by angle: "
+ .. "(50.4, 80) pixels out is 57.8 degrees, the down-right sector")
+
+fires(0.84382, 0.69563, "a", "A fires alone")
+fires(0.65618, 0.74438, "b", "B fires alone: the 1.5x range_mod does not grow the resting d-pad area over it")
+fires(0.58056, 0.58793, "start", "START fires alone")
+fires(0.41944, 0.58793, "select", "SELECT fires alone, with no phantom direction")
+fires(0.5, 0.3, "", "the screen area presses nothing")
+
+TouchControls:touchpressed("f2", 0.25 * W, 0.6625 * H)
+eq(heldNow(), "up", "slide starts on up")
+TouchControls:touchmoved("f2", 0.37778 * W, 0.72 * H)
+eq(heldNow(), "right", "sliding across the area swaps direction")
+TouchControls:touchmoved("f2", 0.38356 * W, 0.7801 * H)
+eq(heldNow(), "down+right", "and picks up the diagonal")
+TouchControls:touchmoved("f2", 0.5 * W, 0.3 * H)
+eq(heldNow(), "", "sliding out of the area releases it")
+TouchControls:touchreleased("f2", 0.5 * W, 0.3 * H)
+
+local area = sectors[1]
+local bx = 0.65618 * W
+local by = 0.74438 * H
+check(not TouchSkin.hits(page, area, W, H, bx, by, 0, 0, false),
+ "at rest the area hitbox stops short of B")
+check(TouchSkin.hits(page, area, W, H, bx, by, 0, 0, true),
+ "a held area grows over B so the finger keeps its direction")
+TouchControls:touchpressed("f3", 0.37778 * W, 0.72 * H)
+TouchControls:touchmoved("f3", bx, by)
+eq(heldNow(), "right+b", "sliding from the held area onto B keeps right held")
+TouchControls:touchreleased("f3", bx, by)
+eq(heldNow(), "", "and lifting clears both")
+
+TouchSkin.autoOrient = false
+TouchSkin.setPage("areas")
+eq(TouchSkin.page().name, "areas", "second page is live")
+
+W, H = 1000, 1000
+TouchSkin.setSurface(0, 0, W, H)
+
+fires(0.25, 0.35, "start", "_up rebinds the up sector of a dpad_area")
+fires(0.25, 0.65, "select", "_down rebinds the down sector")
+fires(0.12, 0.5, "", "_left = nul makes that sector inert")
+fires(0.38, 0.5, "right", "an unset side keeps the d-pad default")
+
+fires(0.88, 0.5, "a", "abxy_area right is GB A")
+fires(0.75, 0.62, "b", "abxy_area down is GB B")
+fires(0.88, 0.62, "a+b", "the down-right sector fires both")
+fires(0.75, 0.38, "", "RetroPad X has no GB button, so up is inert")
+fires(0.94, 0.68, "a+b", "a rect area still hits inside its corner")
+fires(0.75, 0.75, "", "and nothing past its edge")
+
+TouchSkin.setSurface(nil)
+TouchSkin.setActive(nil)
+TouchSkin.autoOrient = true
+
+T.finish("touch_skin_dpad_area")
diff --git a/tests/engine/touch_skin_native.lua b/tests/engine/touch_skin_native.lua
new file mode 100644
index 000000000..3ad3d339b
--- /dev/null
+++ b/tests/engine/touch_skin_native.lua
@@ -0,0 +1,223 @@
+-- Native host integration, using owned memory-only skins. No ROM/profile IO.
+package.path = './?.lua;./?/init.lua;' .. package.path
+local T = require('tests.modkit')
+local Skin = require('src.core.TouchSkin')
+local Touch = require('src.core.TouchControls')
+local Input = require('src.core.Input')
+local V = require('src.render.GameViewport')
+local Runtime = require('src.mods.Runtime')
+local Save = require('src.core.SaveData')
+local Game = require('src.core.Game')
+local sdk=T.sdk.loadMods({'mods/skin_consumer'},{data=T.fixtures.fresh(),fs=T.sdk.memfs({
+ ['mods/skin_consumer/manifest.json']=require('src.link.Json').encode({
+ id='skin_consumer',name='Unmodified skin API consumer fixture',version='1.0.0',api=2,entry='main.lua'}),
+ ['mods/skin_consumer/main.lua']=[[
+ local mod=...
+ local S=require('src.core.TouchSkin')
+ mod.exports.hasGeometry=type(S.page)=='function' and type(S.controlGeometry)=='function'
+ mod.exports.hasLibrary=type(S.installArchive)=='function' and type(S.exportDelta)=='function'
+ ]],
+})})
+T.eq(#sdk.errors,0,'actual mod loader resolves native skin API')
+T.eq(sdk.loader.apis.skin_consumer.exports.hasGeometry,true,'optional mod geometry consumer receives real API')
+T.eq(sdk.loader.apis.skin_consumer.exports.hasLibrary,true,'optional mod library consumer receives real API')
+sdk.release()
+local W, H = 640, 480
+love.graphics.getDimensions = function() return W, H end
+love.graphics.getPixelDimensions = function() return W * 2, H * 2 end
+local files, dirs = {}, {skins=true, ['assets/skins']=true}
+local fs = {}
+function fs.read(path) return files[path] end
+function fs.write(path, bytes) files[path] = bytes; return true end
+function fs.getInfo(path)
+ if dirs[path] then return {type='directory'} end
+ if files[path] then return {type='file', size=#files[path]} end
+end
+function fs.createDirectory(path) dirs[path]=true; return true end
+function fs.remove(path) files[path], dirs[path]=nil,nil; return true end
+function fs.getDirectoryItems(path)
+ local found, out = {}, {}
+ for _, table_ in ipairs{dirs,files} do
+ for key in pairs(table_) do
+ local tail = key:sub(1,#path+1)==path..'/' and key:sub(#path+2)
+ local name = tail and tail:match('^([^/]+)')
+ if name and not found[name] then found[name]=true; out[#out+1]=name end
+ end
+ end
+ table.sort(out); return out
+end
+love.filesystem = fs
+-- Same persistence seam as portable/account saves, with an explicit memory fs.
+Save.persistenceFs = function() return fs end
+local skin = Skin.newSkin('native_owned')
+skin.pages[1].aspectFromCfg = false
+skin.pages[1].viewport = {x=.1,y=.1,w=.8,h=.5}
+skin.pages[1].controls = {
+ Skin.newControl('a|key:f5', .8,.8,.2,.2,'rect'),
+ Skin.newControl('hold_fast_forward', .2,.8,.2,.2,'rect'),
+}
+skin.pages[1].imagePath = 'img/bezel.png'
+skin.pages[1].controls[1].imagePath = 'img/a.png'
+dirs[skin.root], dirs[skin.root..'/img'] = true,true
+files[skin.root..'/img/bezel.png'] = 'owned-bezel-image'
+files[skin.root..'/img/a.png'] = 'owned-control-image'
+local drawn = {}
+love.graphics.newImage = function(path)
+ return {getWidth=function() return W end, getHeight=function() return H end,
+ getDimensions=function() return W,H end, setFilter=function() end, path=path}
+end
+love.graphics.draw = function(image,x,y,...) drawn[#drawn+1]={image=image,x=x,y=y} end
+T.eq(Skin.saveTo(skin), 'skins/native_owned', 'native skin saves through persistence owner')
+local restored = assert(Skin.load('skins/native_owned','native_owned'))
+T.eq(#restored.pages[1].controls, 2, 'saved skin reloads both controls')
+T.eq(restored.pages[1].controls[1].spec, 'a|key:f5', 'combined binding survives disk shape')
+T.eq(#Skin.list(), 1, 'library discovers the saved native skin')
+local export, missing = Skin.export(restored)
+T.eq(#missing, 0, 'export finds every owned asset')
+T.check(files[export]:find('owned-bezel-image',1,true) ~= nil, 'export contains original bezel bytes')
+T.check(files[export]:find('owned-control-image',1,true) ~= nil, 'export contains original control bytes')
+local ra = assert(Skin.exportRetroArch(restored))
+T.check(files[ra]:find('overlay0_desc0',1,true) ~= nil, 'RetroArch export contains actual bindings')
+local delta = assert(Skin.exportDelta(restored, {system='gbc'}))
+T.check(files[delta]:find('info.json',1,true) ~= nil, 'Delta export contains metadata')
+
+-- The actual option row accepts normal input and retains other layout options.
+local Version=require('src.core.GameVersion')
+Version.set('crystal')
+local Menu=require('src.ui.OptionsMenu')
+local Stack=require('src.core.StateStack')
+local g={data=T.fixtures.fresh(),save=Save.newGame(),writes=0,input={pressed={}}}
+g.stack=setmetatable({},{__index=Stack});g.stack:init()
+g.input.wasPressed=function(self,key) return self.pressed[key]==true end
+g.writeOptions=function(self) self.writes=self.writes+1;self.persisted=Save.encode(self.save) end
+g.save.options.touchControls={layouts={portrait={scale=1.2}}}
+local menu=Menu.new(g);g.stack:push(menu)
+-- This offered base retains upstream's actual flat OPTIONS page.
+local page
+for index,row in ipairs(menu.rows) do
+ if row.id=='touchSkin' then menu.index=index;page=menu;break end
+end
+T.check(page ~= nil,'installed skin exposes a native Options row')
+if page then
+ g.input.pressed={right=true};page:update(1/60);g.input.pressed={}
+ T.eq(g.save.options.touchControls.skin,'native_owned','normal RIGHT selects installed skin')
+ T.eq(Skin.active and Skin.active.id,'native_owned','normal Options input applies real skin')
+ T.eq(g.writes,1,'normal Options input writes through native owner')
+ local roundTrip=assert(Save.decode(g.persisted))
+ T.eq(roundTrip.options.touchControls.skin,'native_owned','save codec round-trips selected skin')
+ T.eq(roundTrip.options.touchControls.layouts.portrait.scale,1.2,'skin choice retains layout preferences')
+ g.input.pressed={right=true};page:update(1/60);g.input.pressed={}
+ T.eq(g.save.options.touchControls.skin,nil,'normal RIGHT wraps to default pad')
+ T.eq(Skin.active,nil,'default choice releases custom viewport')
+end
+
+Touch:init(); Input:init()
+Touch.active = true
+Touch:applyOptions({touchControls={skin='native_owned'}})
+T.eq(Touch.skinId, 'native_owned', 'options select the real saved skin')
+T.eq(Touch:config().skin, 'native_owned', 'editor config retains selection')
+local keyEdges, speedEdges = {}, {}
+love.keypressed = function(key) keyEdges[#keyEdges+1]='+'..key end
+love.keyreleased = function(key) keyEdges[#keyEdges+1]='-'..key end
+Touch:setHotkeyHandler(function(key, down) speedEdges[#speedEdges+1]=key..':'..tostring(down) end)
+local cx,cy=Skin.controlGeometry(Skin.page(),Skin.page().controls[1],W,H)
+T.check(Touch:touchpressed('one',cx,cy), 'custom A contact captured')
+T.check(Touch:touchpressed('two',cx,cy), 'overlapping second contact captured')
+T.check(Input:isDown('a'), 'custom A reaches real native input')
+T.eq(#keyEdges,1, 'shared keyboard bind has one press edge')
+Touch:touchreleased('one',cx,cy)
+T.check(Input:isDown('a'), 'one release retains other A owner')
+T.eq(#keyEdges,1, 'one release retains other key owner')
+Touch:touchreleased('two',cx,cy)
+T.check(not Input:isDown('a'), 'last release drops real native A')
+T.eq(keyEdges[2],'-f5', 'last release drops native key')
+local sx,sy=Skin.controlGeometry(Skin.page(),Skin.page().controls[2],W,H)
+Touch:touchpressed('fast',sx,sy)
+T.eq(speedEdges[1],'fast_forward_hold:true','skin speed action reaches host')
+Touch:touchpressed('again',cx,cy)
+Touch:reset()
+T.eq(speedEdges[2],'fast_forward_hold:false','focus reset releases held speed action')
+T.eq(keyEdges[#keyEdges],'-f5','focus reset releases keyboard binding')
+T.check(not Input:isDown('a'), 'focus reset releases real native input')
+Touch:touchpressed('reused',cx,cy)
+Touch:touchpressed('reused',sx,sy)
+T.check(not Input:isDown('a'), 'reused native pointer id releases previous owner')
+Touch:reset()
+
+-- Decorative desktop skins draw after composition without stealing input.
+Touch.active=false; Touch.controllerHidden=true
+T.check(Touch:visible(), 'desktop/controller retains bezel artwork')
+T.eq(Touch:touchpressed('desktop',cx,cy),nil,'desktop bezel does not capture touch input')
+Touch:draw()
+T.eq(#drawn,2,'draws bezel and control once each in window space')
+Touch:applyOptions({touchControls={enabled=false,skin='native_owned'}})
+T.eq(Skin.active,nil,'disabling controls also clears skin viewport')
+T.eq(Touch:visible(),false,'disabled skin is invisible')
+Touch:applyOptions({touchControls={skin='missing'}})
+T.eq(Skin.active,nil,'missing skin cannot leave stale art selected')
+T.check(Touch.skinError ~= nil,'missing skin reports its load error')
+
+-- Skin cutouts share the actual viewport owner; mod hooks keep final say.
+local Hooks=require('src.mods.Hooks')
+local hooks=Hooks.new()
+Runtime.install(require('src.mods.Events').new(),hooks,{})
+Touch:applyOptions({touchControls={skin='native_owned'}})
+local x,y,w,h=Skin.viewport(W,H)
+local r=V.begin(2)
+T.eq(r.x,math.floor(x),'skin reserves native viewport x')
+T.eq(r.y,math.floor(y),'skin reserves native viewport y')
+T.eq(r.width,math.floor(w),'skin reserves native viewport width')
+T.eq(r.height,math.floor(h),'skin reserves native viewport height')
+local lx,ly,inside=V.toLocal(r.x+10,r.y+20)
+T.eq(lx,10,'pointer maps into real cutout x')
+T.eq(ly,20,'pointer maps into real cutout y')
+T.check(inside,'pointer inside cutout is accepted')
+V.finish({}); V.reset()
+local undo=hooks:wrap('render.viewport',function(next,ctx)
+ local base=next(ctx)
+ T.eq(base.width,math.floor(w),'mod receives selected skin as default viewport')
+ return {x=20,y=30,width=100,height=90}
+end,0,'owned-fixture')
+r=V.begin(2)
+T.eq(r.x,20,'layout mod can override selected skin viewport')
+T.eq(r.width,100,'layout mod retains final cutout authority')
+V.finish({}); V.reset(); undo()
+V.begin(4,false)
+T.eq(V.active(),false,'existing native dual-screen layout keeps its owner')
+V.finish({}); V.reset()
+Skin.setActive(nil)
+V.begin(2)
+T.eq(V.active(),false,'no selected skin allocates no extra viewport')
+V.finish({}); V.reset()
+
+-- Real engine hotkeys must release an explicit speed override and obey locks.
+local host=setmetatable({speedOverride=2,save={options={speed=1}},writes=0}, {__index=Game})
+host.speedLocked=function() return false end
+host.writeOptions=function(self) self.writes=self.writes+1 end
+host:touchSkinHotkey('fast_forward_hold',true)
+T.eq(host.speedOverride,4,'native held fast-forward engages')
+host:touchSkinHotkey('fast_forward_hold',false)
+T.eq(host.speedOverride,2,'native held fast-forward restores explicit override')
+host:touchSkinHotkey('fast_forward_toggle',true)
+T.eq(host.save.options.speed,2,'native toggle cycles persisted speed')
+T.eq(host.writes,1,'native toggle persists through engine owner')
+host.speedLocked=function() return true end
+host:touchSkinHotkey('fast_forward_toggle',true)
+T.eq(host.writes,1,'locked state blocks speed preference changes')
+-- The offered native host must supply the actual lock owner; a test-only
+-- speedLocked double cannot prove that a runtime skin action works.
+host.speedLocked=nil
+host.stack=setmetatable({},{__index=Stack});host.stack:init()
+host.stack:push({isFixedSpeed=true});host.stack:push({name='options'})
+T.eq(host:speedLocked(),true,'real native lock sees fixed-speed state under menu')
+host.speedOverride=4
+T.eq(host:logicSpeed(),1,'real lock overrides held acceleration in logic clock')
+host:touchSkinHotkey('fast_forward_toggle',true)
+T.eq(host.writes,1,'real lock blocks skin toggle persistence')
+host.stack.states={};host.linkSession={}
+T.eq(host:speedLocked(),true,'real native lock sees live link session')
+host.linkSession=nil
+T.eq(host:speedLocked(),false,'settled unlocked host can accelerate')
+host:touchSkinHotkey('fast_forward_toggle',true)
+T.eq(host.writes,2,'skin toggle reaches actual unlocked native owner')
+T.finish('native custom-skin ownership, persistence, rendering and input')
diff --git a/tests/engine/touch_skin_test.lua b/tests/engine/touch_skin_test.lua
new file mode 100644
index 000000000..c2b75ad1b
--- /dev/null
+++ b/tests/engine/touch_skin_test.lua
@@ -0,0 +1,401 @@
+-- From Gen1Recomp 535a5ec0, Copyright (C) 2026 BOIS CLUB GAMES, LLC.
+-- GPLv3 + Section 7; see licenses/GEN1RECOMP-TOUCH-SKINS.md.
+-- RetroArch-format touch skins (#1386): .cfg parsing, hitboxes, the press /
+-- hotkey path through TouchControls, and the screen viewport the renderer
+-- fits the Game Boy picture into.
+-- luajit tests/engine/touch_skin_test.lua
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+if not _G.love then _G.love = require("tests.love_stub") end
+
+local T = require("tests.harness")
+local check, eq = T.check, T.eq
+
+local TouchSkin = require("src.core.TouchSkin")
+local TouchControls = require("src.core.TouchControls")
+local Input = require("src.core.Input")
+
+local CFG = [[
+overlays = 2
+
+overlay0_name = "shell"
+overlay0_overlay = img/back.png
+overlay0_full_screen = true
+overlay0_normalized = true
+overlay0_alpha_mod = 0.001
+overlay0_range_mod = 1.5
+overlay0_viewport = "0.0,0.0,1.0,0.5"
+overlay0_viewport_fill = true
+
+overlay0_descs = 7
+overlay0_desc0 = "a,0.80,0.75,radial,0.10,0.05"
+overlay0_desc0_overlay = img/a.png
+overlay0_desc1 = "left|down,0.20,0.90,rect,0.05,0.05"
+overlay0_desc2 = "nul,0.20,0.75,rect,0.15,0.10"
+overlay0_desc2_overlay = img/dpad.png
+overlay0_desc3 = "overlay_next,0.95,0.55,radial,0.03,0.02"
+overlay0_desc3_next_target = "second"
+overlay0_desc4 = "hold_fast_forward,0.05,0.55,radial,0.03,0.02"
+overlay0_desc5 = "reset,0.50,0.98,rect,0.04,0.02"
+overlay0_desc6 = "key:f5,0.10,0.98,rect,0.04,0.02"
+
+overlay1_name = "second"
+overlay1_full_screen = true
+overlay1_normalized = true
+overlay1_descs = 1
+overlay1_desc0 = "start,0.50,0.50,rect,0.10,0.10"
+]]
+
+local skin = assert(TouchSkin.parse(CFG))
+eq(#skin.pages, 2, "two overlay pages parsed")
+
+local page = skin.pages[1]
+eq(page.name, "shell", "page name")
+eq(page.imagePath, "img/back.png", "page background path")
+check(page.fullScreen, "full_screen parsed")
+check(not page.aspectFromCfg, "a cfg without aspect_ratio does not lock aspect")
+eq(page.alphaMod, 0.001, "overlay alpha_mod parsed")
+eq(#page.controls, 7, "seven descs parsed")
+
+check(page.viewport ~= nil, "viewport parsed")
+eq(page.viewport.h, 0.5, "viewport height")
+check(page.viewportFill, "viewport_fill parsed")
+
+local a, diag, decor, nextBtn, ff, reset, keyBtn = unpack(page.controls)
+eq(a.buttons[1], "a", "desc0 binds GB a")
+eq(a.shape, "radial", "desc0 is radial")
+eq(a.alphaMod, 0.001, "desc inherits the overlay alpha_mod")
+eq(a.rangeMod, 1.5, "desc inherits the overlay range_mod")
+eq(table.concat(diag.buttons, "+"), "left+down", "pipe-separated binds are one control")
+check(decor.decorative, "nul desc is decoration only")
+eq(decor.imagePath, "img/dpad.png", "decoration still carries art")
+eq(nextBtn.hotkeys[1], "overlay_next", "overlay_next mapped")
+eq(nextBtn.nextTarget, "second", "next_target parsed")
+eq(ff.hotkeys[1], "fast_forward_hold", "hold_fast_forward mapped")
+eq(reset.hotkeys[1], "soft_reset", "reset mapped")
+eq(keyBtn.keys[1], "f5", "key: binds a keyboard key")
+
+-- geometry: x/y are the centre, range_x/range_y are half extents, both
+-- normalized to the window when full_screen is set
+local W, H = 400, 800
+local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, a, W, H)
+eq(cx, 320, "control centre x")
+eq(cy, 600, "control centre y")
+eq(halfW, 40, "control half width")
+eq(halfH, 40, "control half height")
+
+-- range_mod 1.5 widens the hitbox but not the art
+check(TouchSkin.hits(page, a, W, H, 320, 600), "centre hits")
+check(TouchSkin.hits(page, a, W, H, 320 + 55, 600), "inside range_mod hits")
+check(not TouchSkin.hits(page, a, W, H, 320 + 65, 600), "past range_mod misses")
+-- radial, so the corner of the bounding box is outside
+check(not TouchSkin.hits(page, a, W, H, 320 + 55, 600 + 55), "radial corner misses")
+-- rect hitbox does take its corner
+check(TouchSkin.hits(page, diag, W, H, 80 + 19, 720 + 19), "rect corner hits")
+
+-- ---------------------------------------------------------------- runtime
+
+TouchControls:init()
+TouchControls.active = true
+TouchControls.enabled = true
+TouchSkin.setOverlayLive(true)
+TouchSkin.setActive(skin)
+Input:init()
+
+local ww, wh = love.graphics.getDimensions()
+local function at(nx, ny) return nx * ww, ny * wh end
+
+check(TouchSkin.hasViewport(), "active skin reports a viewport")
+
+-- the renderer fits the Game Boy picture into this rect instead of the window
+local sx, sy, sw, sh, fill = TouchSkin.viewport(W, H)
+eq(sx, 0, "viewport x") eq(sy, 0, "viewport y")
+eq(sw, 400, "viewport w") eq(sh, 400, "viewport h")
+check(fill, "viewport fill flag returned")
+eq(TouchSkin.viewport(W, H), 0, "without a locked aspect, viewport tracks the window")
+
+check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured")
+check(Input:isDown("a"), "skin A presses GB a")
+TouchControls:touchreleased("f1", at(0.80, 0.75))
+check(not Input:isDown("a"), "lifting releases GB a")
+
+-- one finger inside a combined desc holds both directions
+TouchControls:touchpressed("f2", at(0.20, 0.90))
+check(Input:isDown("left") and Input:isDown("down"), "combined desc holds both buttons")
+-- sliding onto the decoration drops them without capturing anything new
+TouchControls:touchmoved("f2", at(0.20, 0.75))
+check(not Input:isDown("left") and not Input:isDown("down"),
+ "sliding off a control releases it")
+TouchControls:touchreleased("f2", at(0.20, 0.75))
+
+check(TouchControls:touchpressed("f3", at(0.20, 0.75)) == nil,
+ "a press on decoration alone is not captured")
+
+-- hotkeys reach the host once per press edge and once per release
+local fired = {}
+TouchControls:setHotkeyHandler(function(action, pressed)
+ fired[#fired + 1] = action .. ":" .. tostring(pressed)
+end)
+TouchControls:touchpressed("f4", at(0.05, 0.55))
+eq(fired[1], "fast_forward_hold:true", "held hotkey fires on press")
+TouchControls:touchreleased("f4", at(0.05, 0.55))
+eq(fired[2], "fast_forward_hold:false", "held hotkey fires on release")
+
+-- a stranded finger unwinds through reset, not just through touchreleased
+fired = {}
+TouchControls:touchpressed("f5", at(0.05, 0.55))
+TouchControls:touchpressed("f6", at(0.80, 0.75))
+check(Input:isDown("a"), "second finger holds A")
+TouchControls:reset()
+eq(fired[2], "fast_forward_hold:false", "reset releases held hotkeys")
+check(not Input:isDown("a"), "reset releases held buttons")
+
+-- overlay_next swaps the page, and the new page's controls are what hit
+eq(TouchSkin.page().name, "shell", "starts on page 1")
+TouchControls:touchpressed("f7", at(0.95, 0.55))
+eq(TouchSkin.page().name, "second", "overlay_next honours next_target")
+TouchControls:touchreleased("f7", at(0.95, 0.55))
+TouchControls:touchpressed("f8", at(0.50, 0.50))
+check(Input:isDown("start"), "page 2 control presses GB start")
+TouchControls:touchreleased("f8", at(0.50, 0.50))
+TouchSkin.setPage("shell")
+
+-- ------------------------------------------------------------ persistence
+
+local cfg = TouchControls.normalizeConfig({ enabled = true, skin = "gb_anim" })
+eq(cfg.skin, "gb_anim", "normalizeConfig keeps the skin id")
+eq(TouchControls.normalizeConfig({ skin = "" }).skin, nil, "empty skin id drops")
+eq(TouchControls.normalizeConfig({ skin = 7 }).skin, nil, "non-string skin id drops")
+
+TouchControls.skinId = "gb_anim"
+eq(TouchControls:config().skin, "gb_anim", "config() round-trips the skin id")
+
+-- ----------------------------------------------- screen-hole detection
+
+-- Border art usually ships with a transparent screen hole and no viewport
+-- key. Detection is verified against real art by
+-- tests/drivers/tv_skin_shot.lua; here it only has to fail safely.
+eq(TouchSkin.detectViewport("assets/skins/tv_crt", nil), nil,
+ "no bezel path detects nothing")
+eq(TouchSkin.detectViewport("assets/skins/tv_crt", ""), nil,
+ "empty bezel path detects nothing")
+eq(TouchSkin.detectViewport("assets/skins/tv_crt", "img/does_not_exist.png"), nil,
+ "a missing bezel detects nothing")
+
+-- Bundled third-party artwork is not shipped by this engine; asset loading and
+-- export are covered with owned in-memory fixtures in touch_skin_native.lua.
+
+-- ------------------------------------------- decorative desktop bezels
+
+-- A skin whose page binds nothing is a frame, not a pad: it draws where the
+-- touch overlay does not (desktop), and a gamepad must not hide it.
+local BEZEL = [[
+overlays = 1
+overlay0_name = "tv"
+overlay0_overlay = img/tv.png
+overlay0_full_screen = true
+overlay0_normalized = true
+overlay0_descs = 1
+overlay0_desc0 = "nul,0.5,0.5,rect,0.5,0.5"
+overlay0_viewport = "0.2,0.1,0.6,0.8"
+]]
+local bezel = assert(TouchSkin.parse(BEZEL))
+TouchSkin.setActive(bezel)
+
+TouchSkin.setOverlayLive(false)
+check(TouchSkin.decorativeOnly(), "a descs-only-nul skin is decoration")
+check(TouchSkin.drawable(), "a decorative skin draws with the overlay off (desktop)")
+check(TouchSkin.hasViewport(), "and its viewport still places the picture")
+TouchControls.active = false
+TouchControls.enabled = true
+TouchControls.controllerHidden = true
+check(TouchControls:visible(), "a gamepad does not hide a decorative bezel")
+
+-- A selected skin is also a presentation overlay on desktop. The touch
+-- input gate remains separate from whether its artwork is drawn.
+TouchSkin.setActive(skin)
+TouchSkin.setOverlayLive(false)
+check(not TouchSkin.decorativeOnly(), "a skin with binds is not decoration")
+check(TouchSkin.drawable(), "and it still draws where touch input is off")
+check(TouchSkin.hasViewport(), "so its screen placement stays available")
+check(TouchControls:visible(), "and it draws over a desktop window")
+TouchSkin.setOverlayLive(true)
+check(TouchSkin.drawable(), "with the overlay live it draws again")
+TouchControls.active = true
+TouchControls.controllerHidden = false
+
+-- ------------------------------------------------------- native format
+
+local native = TouchSkin.serialize(skin)
+check(native:find("^return {"), "serialize emits a Lua data chunk")
+local back, nerr = TouchSkin.parseNative(native)
+check(back ~= nil, "native round-trip parses: " .. tostring(nerr))
+if back then
+ eq(#back.pages, #skin.pages, "round-trip keeps the page count")
+ local bp, sp = back.pages[1], skin.pages[1]
+ eq(#bp.controls, #sp.controls, "round-trip keeps the control count")
+ eq(bp.controls[1].spec, sp.controls[1].spec, "round-trip keeps binds")
+ eq(bp.controls[1].rangeX, sp.controls[1].rangeX, "round-trip keeps half extents")
+ eq(bp.controls[1].shape, sp.controls[1].shape, "round-trip keeps the hitbox shape")
+ eq(bp.viewport.h, sp.viewport.h, "round-trip keeps the viewport")
+ check(bp.viewportFill, "round-trip keeps viewport_fill")
+ eq(bp.alphaMod, sp.alphaMod, "round-trip keeps alpha_mod")
+ eq(bp.controls[3].decorative, true, "round-trip keeps decoration")
+ eq(bp.controls[4].nextTarget, "second", "round-trip keeps next_target")
+end
+
+-- the native format carries the separate pressed image a .cfg cannot
+local twoState = assert(TouchSkin.parseNative([[
+return { pages = { { name = "p", controls = {
+ { bind = "a", x = 0.5, y = 0.5, w = 0.2, h = 0.2, shape = "radial",
+ image = "up.png", imagePressed = "down.png" },
+} } } }
+]]))
+eq(twoState.pages[1].controls[1].imagePath, "up.png", "native idle image")
+eq(twoState.pages[1].controls[1].pressedImagePath, "down.png", "native pressed image")
+eq(twoState.pages[1].controls[1].rangeX, 0.1, "native w is a full width, not a range")
+
+check(TouchSkin.parseNative("return 5") == nil, "a non-table skin.lua is rejected")
+check(TouchSkin.parseNative("return { pages = {} }") == nil, "a pageless skin.lua is rejected")
+check(TouchSkin.parseNative("this is not lua") == nil, "a broken skin.lua is rejected")
+-- skins are third-party data: the chunk must not see the host globals
+check(TouchSkin.parseNative("return { pages = { { controls = {} } }, hit = love ~= nil }")
+ ~= nil, "skin.lua loads in an empty environment")
+
+-- --------------------------------------------------------------- export
+
+local SkinZip = require("src.core.SkinZip")
+local blob = SkinZip.encode({
+ { name = "skin.lua", data = "return {}\n" },
+ { name = "img/a.png", data = "\137PNG\r\n\26\n binary \0 bytes" },
+})
+eq(blob:sub(1, 4), "PK\3\4", "zip starts with a local file header")
+check(blob:find("PK\5\6", 1, true) ~= nil, "zip ends with a central directory record")
+check(blob:find("img/a.png", 1, true) ~= nil, "zip carries the entry name")
+check(blob:find("binary", 1, true) ~= nil, "stored entries keep their bytes verbatim")
+
+
+TouchSkin.setActive(nil)
+TouchControls:setHotkeyHandler(nil)
+
+-- ------------------------------------------ auto-rotate (#1503)
+
+-- RetroArch overlays name a portrait page and a landscape page and wire
+-- overlay_next between them. Play has to pick the one that matches the
+-- window, or landscape stretches the portrait ranges into wide ovals.
+local ORIENT_CFG = [[
+overlays = 2
+overlay0_name = "portrait"
+overlay0_full_screen = true
+overlay0_normalized = true
+overlay0_aspect_ratio = 0.45
+overlay0_descs = 1
+overlay0_desc0 = "a,0.84382,0.69563,radial,0.09722,0.04375"
+
+overlay1_name = "landscape"
+overlay1_full_screen = true
+overlay1_normalized = true
+overlay1_aspect_ratio = 2.22222222222222
+overlay1_descs = 1
+overlay1_desc0 = "a,0.8307031248,0.8614400000,radial,0.0364168752,0.0813300000"
+]]
+local orient = assert(TouchSkin.parse(ORIENT_CFG))
+check(orient.pages[1].aspectFromCfg, "portrait page locks the cfg aspect_ratio")
+check(orient.pages[2].aspectFromCfg, "landscape page locks the cfg aspect_ratio")
+eq(orient.pages[1].orient, "portrait", "a portrait page name locks on import")
+eq(orient.pages[2].orient, "landscape", "a landscape page name locks on import")
+check(TouchSkin.hasOrientPair(orient), "the pair is an auto-rotate overlay")
+TouchSkin.setActive(orient)
+TouchSkin.autoOrient = true
+
+local PW, PH = 720, 1600
+TouchSkin.setSurface(0, 0, PW, PH)
+eq(TouchSkin.page().name, "portrait", "a tall window picks the portrait page")
+local _, _, pHalfW, pHalfH =
+ TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], PW, PH)
+eq(math.floor(pHalfW + 0.5), 70, "portrait A half-width follows range_x")
+eq(math.floor(pHalfH + 0.5), 70, "portrait A half-height follows range_y")
+
+local LW, LH = 1600, 720
+TouchSkin.setSurface(0, 0, LW, LH)
+eq(TouchSkin.page().name, "landscape", "a wide window picks the landscape page")
+local _, _, lHalfW, lHalfH =
+ TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], LW, LH)
+eq(math.floor(lHalfW + 0.5), 58, "landscape A half-width follows the landscape range")
+eq(math.floor(lHalfH + 0.5), 59, "landscape A half-height follows the landscape range")
+check(math.abs(lHalfW - lHalfH) < 2, "landscape A stays round instead of stretching")
+
+-- 16:9 is not the overlay's 20:9. Letterbox to aspect_ratio so A stays round
+-- instead of filling the window and turning into a tall oval (#1503).
+TouchSkin.setSurface(0, 0, 1920, 1080)
+eq(TouchSkin.page().name, "landscape", "16:9 still picks landscape")
+local _, _, boxW, boxH = TouchSkin.pageBox(TouchSkin.page(), 1920, 1080)
+eq(math.floor(boxW + 0.5), 1920, "letterbox keeps the 16:9 width")
+eq(math.floor(boxH + 0.5), 864, "and the overlay's 20:9 height")
+local _, _, sHalfW, sHalfH =
+ TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], 1920, 1080)
+check(math.abs(sHalfW - sHalfH) < 2, "A stays round on a 16:9 window")
+
+TouchSkin.setSurface(0, 0, LW, LH)
+local aCx, aCy, aHalfW, aHalfH =
+ TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], LW, LH)
+local aW, aH = aHalfW * 2, aHalfH * 2
+local sx, sy = TouchSkin.imageFit(210, 210, aW, aH)
+eq(sx, aW / 210, "desc art scales to the dest width")
+eq(sy, aH / 210, "desc art scales to the dest height")
+check(210 * sx <= aW + 1e-6 and 210 * sy <= aH + 1e-6,
+ "the whole bitmap lands inside its dest box")
+check(sx >= math.min(aW / 210, aH / 210) - 1e-6,
+ "and it is not shrunk below a contain fit")
+local lx, ly = TouchSkin.imageFit(327, 193, 218, 129)
+check(327 * lx <= 218 + 1e-6 and 193 * ly <= 129 + 1e-6,
+ "a wide shoulder PNG is not cropped by its dest box")
+check(TouchSkin.imageFit(0, 0, aW, aH) == nil, "a zero-sized image has no fit")
+local wx, wy = TouchSkin.imageFit(100, 50, 200, 200)
+eq(wx, 2, "independent X scale, not one uniform cover scale")
+eq(wy, 4, "independent Y scale")
+check(aCx > 0 and aCy > 0, "landscape A sits inside the page box")
+
+local nativeOrient = TouchSkin.parseNative(TouchSkin.serialize(orient))
+check(nativeOrient and nativeOrient.pages[2].aspectFromCfg,
+ "native export keeps the aspect lock")
+
+-- an explicit lock wins over the page name, so a studio-authored "main"
+-- page can still auto-rotate in play
+local LOCK_CFG = [[
+overlays = 2
+overlay0_name = "main"
+overlay0_full_screen = true
+overlay0_descs = 1
+overlay0_desc0 = "a,0.5,0.5,radial,0.05,0.05"
+overlay1_name = "wide"
+overlay1_full_screen = true
+overlay1_descs = 1
+overlay1_desc0 = "a,0.5,0.5,radial,0.05,0.05"
+]]
+local locked = assert(TouchSkin.parse(LOCK_CFG))
+locked.pages[1].orient = "portrait"
+locked.pages[2].orient = "landscape"
+TouchSkin.setActive(locked)
+TouchSkin.setSurface(0, 0, 1600, 720)
+eq(TouchSkin.page().name, "wide", "orient lock auto-rotates a page not named landscape")
+local roundTrip = TouchSkin.parseNative(TouchSkin.serialize(locked))
+eq(roundTrip.pages[1].orient, "portrait", "native export keeps a portrait lock")
+eq(roundTrip.pages[2].orient, "landscape", "and a landscape lock")
+
+TouchSkin.setActive(orient)
+TouchSkin.autoOrient = false
+TouchSkin.pageIndex = 1
+eq(TouchSkin.page().name, "portrait",
+ "the studio can keep a portrait page on a landscape canvas")
+TouchSkin.autoOrient = true
+TouchSkin.setSurface(nil)
+
+-- pages that are not a portrait/landscape pair (gb_anim) stay put
+TouchSkin.setActive(skin)
+TouchSkin.setSurface(0, 0, LW, LH)
+eq(TouchSkin.page().name, "shell", "a non-oriented skin does not auto-rotate")
+TouchSkin.setSurface(nil)
+TouchSkin.setActive(nil)
+
+T.finish("touch_skin")
diff --git a/tests/engine/ui_actions.lua b/tests/engine/ui_actions.lua
new file mode 100644
index 000000000..8103d644f
--- /dev/null
+++ b/tests/engine/ui_actions.lua
@@ -0,0 +1,792 @@
+-- Real native screens, Loader facades and ROM-free generation fixtures.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Loader = require("src.mods.Loader")
+local GameVersion = require("src.core.GameVersion")
+local Pokemon = require("src.pokemon.Pokemon")
+local SaveData = require("src.core.SaveData")
+local Font = require("src.render.Font")
+local Runtime = require("src.mods.Runtime")
+local Json = require("src.link.Json")
+local Intents = require("src.mods.BattleIntents")
+local original = GameVersion.get()
+local versions = GameVersion.ORDER
+local function boot(version, apiLevel)
+ GameVersion.set(version)
+ local data = T.fixtures.fresh()
+ local files = {}
+ for _, id in ipairs({ "probe", "other" }) do
+ files["mods/" .. id .. "/manifest.json"] =
+ Json.encode({ id = id, name = id, version = "1.0.0", api = apiLevel or 2, entry = "main.lua" })
+ files["mods/" .. id .. "/main.lua"] = [[return function(mod)
+ assert(mod.ui:snapshot()==nil)
+ assert(type(mod.ui.insertBefore)=="function")
+ if mod.supports then
+ assert(mod:supports("ui", "snapshot"))
+ assert(mod:supports("ui", "submit"))
+ assert(not mod:supports("ui", "unknown"))
+ end
+ end]]
+ end
+ local fs = {
+ read = function(p)
+ return files[p]
+ end,
+ load = function(p)
+ return loadstring(files[p], p)
+ end,
+ getDirectoryItems = function(p)
+ return p == "mods" and { "probe", "other" } or {}
+ end,
+ getInfo = function(p)
+ if p == "mods" or p == "mods/probe" or p == "mods/other" then
+ return { type = "directory" }
+ end
+ if files[p] then
+ return { type = "file" }
+ end
+ end,
+ }
+ local loader = Loader.new({ fs = fs, dev = false })
+ T.check(loader:load(data), "fixture mods load")
+ local game = { data = data, stack = { states = {} }, save = SaveData.newGame() }
+ loader.game = game
+ Font.load(data)
+ game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) }
+ game.input = {
+ wasPressed = function()
+ return false
+ end,
+ isDown = function()
+ return false
+ end,
+ }
+ function game.stack:top()
+ return self.states[#self.states]
+ end
+ function game.stack:push(state)
+ self.states[#self.states + 1] = state
+ end
+ function game.stack:pop()
+ return table.remove(self.states)
+ end
+ return loader.apis.probe.ui, game, loader.apis.other.ui, loader
+end
+local function button(game, screen, name)
+ local old = game.input
+ game.input = {
+ wasPressed = function(_, key)
+ return key == name
+ end,
+ isDown = function()
+ return false
+ end,
+ }
+ screen:update(1 / 60)
+ game.input = old
+end
+local function submit(ui, id, kind, fields)
+ local snap, err = ui:snapshot()
+ assert(snap, err)
+ local intent = { id = id, revision = snap.revision, kind = kind }
+ for k, v in pairs(fields or {}) do
+ intent[k] = v
+ end
+ return ui:submit(intent)
+end
+local partyClass = {
+ red = "PartyMenu",
+ crystal = "PartyMenu",
+ emerald = "Gen3PartyMenu",
+ platinum = "Gen4PartyMenu",
+ heartgold = "Gen4PartyMenu",
+ black = "Gen4PartyMenu",
+}
+local bagClass = {
+ red = "BagMenu",
+ crystal = "BagMenu",
+ emerald = "Gen3BagMenu",
+ platinum = "Gen4BagMenu",
+ heartgold = "HgssBagMenu",
+ black = "Gen5BagMenu",
+}
+for _, version in ipairs(versions) do
+ local gen = GameVersion.VERSIONS[version].generation
+ partyClass[version] = partyClass[version]
+ or (gen <= 2 and "PartyMenu" or gen == 3 and "Gen3PartyMenu" or "Gen4PartyMenu")
+ bagClass[version] = bagClass[version]
+ or (
+ gen <= 2 and "BagMenu"
+ or gen == 3 and "Gen3BagMenu"
+ or version == "soulsilver" and "HgssBagMenu"
+ or gen == 5 and "Gen5BagMenu"
+ or "Gen4BagMenu"
+ )
+ local ui, game, other = boot(version)
+ local chosen, cancelled = 0, 0
+ local screen = require("src.ui." .. partyClass[version]).new(game, {
+ pickOnly = true,
+ onSwitch = function(mon)
+ T.eq(mon, game.save.party[2], "native picker target")
+ chosen = chosen + 1
+ end,
+ onCancel = function()
+ cancelled = cancelled + 1
+ end,
+ })
+ game.stack:push(screen)
+ local snap = assert(ui:snapshot())
+ T.eq(snap.kind, "party", version .. " party recognized")
+ T.eq(snap.rows[2].species, "FIXMON_B", "portable party ID")
+ T.eq(ui:snapshot().revision, snap.revision, "unchanged read retains revision")
+ snap.rows[1].hp = -999
+ T.neq(game.save.party[1].hp, -999, "snapshot detached")
+ local stale = other:snapshot()
+ T.check(submit(ui, 1, "select", { index = 2 }), "native party selection accepted")
+ T.eq(chosen, 1, "picker callback once")
+ T.eq(#game.stack.states, 0, "picker popped")
+ T.eq(
+ other:submit({ id = 1, revision = stale.revision, kind = "back" }),
+ nil,
+ "other facade cannot use departed picker"
+ )
+ game.stack:push(require("src.ui." .. partyClass[version]).new(game, {
+ onCancel = function()
+ cancelled = cancelled + 1
+ end,
+ }))
+ T.check(submit(ui, 2, "back"), "native party back")
+ T.eq(cancelled, 1, "onCancel delivered")
+ local bag = require("src.ui." .. bagClass[version]).new(game, {})
+ game.stack:push(bag)
+ local bagSnap = assert(ui:snapshot())
+ T.eq(bagSnap.kind, "bag", version .. " bag recognized")
+ if bagSnap.canPocket then
+ T.check(submit(ui, 3, "pocket", { direction = 1 }), "native pocket switch")
+ end
+ T.check(submit(ui, 4, "back"), "native bag back")
+ T.eq(#game.stack.states, 0, "bag popped")
+end
+
+-- Walk raw entries, preserving identity and cycles, to detect any gameplay
+-- writes from observations and rejected requests (T.same is not cycle-safe).
+local function freeze(root)
+ local graph = {}
+ local function visit(t)
+ if type(t) ~= "table" or graph[t] then
+ return
+ end
+ local entry = { mt = getmetatable(t), values = {} }
+ graph[t] = entry
+ for k, v in pairs(t) do
+ entry.values[k] = v
+ visit(k)
+ visit(v)
+ end
+ end
+ visit(root)
+ return function(label)
+ local same = true
+ for t, entry in pairs(graph) do
+ same = same and getmetatable(t) == entry.mt
+ for k, v in pairs(entry.values) do
+ same = same and rawget(t, k) == v
+ end
+ for k, v in pairs(t) do
+ same = same and entry.values[k] == v
+ end
+ end
+ T.check(same, label)
+ end
+end
+local function refusal(ui, game, intent, message)
+ local unchanged = freeze(game)
+ local ok, err = ui:submit(intent)
+ T.eq(ok, nil, message)
+ T.check(type(err) == "string", message .. " explains refusal")
+ unchanged(message .. " leaves game untouched")
+ return err
+end
+for _, version in ipairs(versions) do
+ -- Compare native A/B to the facade for each party submenu and its back path.
+ local traces = {}
+ for _, native in ipairs({ true, false }) do
+ local ui, game = boot(version)
+ local party = require("src.ui." .. partyClass[version]).new(game, {})
+ game.stack:push(party)
+ local unchanged = freeze(game)
+ local snap = ui:snapshot()
+ unchanged("party snapshot is observational")
+ T.check(snap ~= nil, "native screen available")
+ if native then
+ button(game, party, "a")
+ else
+ T.check(submit(ui, 1, "select", { index = 1 }), "open party submenu")
+ end
+ local stableSubmenu = freeze(game)
+ snap = ui:snapshot()
+ stableSubmenu("submenu snapshot remains observational")
+ T.eq(snap.kind, "party_actions", "party action rows offered")
+ local swap
+ for i, r in ipairs(snap.rows) do
+ if r.action == "switch" or r.action == "switchOrder" then
+ swap = i
+ end
+ end
+ T.check(swap ~= nil, "native swap row exposed")
+ if native then
+ if GameVersion.VERSIONS[version].generation <= 3 then
+ party.subIndex = swap
+ else
+ party.submenu = swap
+ end
+ button(game, party, "a")
+ party.index = 2
+ button(game, party, "a")
+ else
+ T.check(submit(ui, 2, "select", { index = swap }), "start swap")
+ T.check(submit(ui, 3, "select", { index = 2 }), "finish swap")
+ end
+ traces[#traces + 1] = {
+ first = game.save.party[1].species,
+ second = game.save.party[2].species,
+ depth = #game.stack.states,
+ index = party.index,
+ swapFrom = party.swapFrom,
+ switchFrom = party.switchFrom,
+ }
+ if native then
+ button(game, party, "b")
+ else
+ T.check(submit(ui, 4, "back"), "back after swap")
+ end
+ T.eq(#game.stack.states, 0, "native party cancellation")
+ end
+ T.same(traces[1], traces[2], version .. " swap matches native A/B")
+
+ local ui, game, other, loader = boot(version)
+ local party = require("src.ui." .. partyClass[version]).new(game, {})
+ game.stack:push(party)
+ local snap = ui:snapshot()
+ for _, bad in ipairs({
+ false,
+ 1,
+ "select",
+ {},
+ { id = 1, revision = snap.revision, kind = "unknown" },
+ { id = 1, revision = snap.revision, kind = "select", index = 0 },
+ { id = 1, revision = snap.revision, kind = "select", index = 1.5 },
+ { id = math.huge, revision = snap.revision, kind = "back" },
+ { id = 1, revision = snap.revision, kind = "back", extra = true },
+ setmetatable({ id = 1, revision = snap.revision, kind = "back" }, {}),
+ }) do
+ refusal(ui, game, bad, "invalid intent")
+ end
+ refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 999 }, "missing row")
+ refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "party cannot advance text")
+ refusal(
+ ui,
+ game,
+ { id = 1, revision = snap.revision, kind = "pocket", direction = 1 },
+ "party has no pockets"
+ )
+ game.save.party[1].hp = game.save.party[1].hp - 1
+ T.eq(
+ refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "HP change stales snapshot"),
+ "stale UI context",
+ "stale reason"
+ )
+ snap = ui:snapshot()
+ local replacement = {}
+ for k, v in pairs(game.save.party[1]) do
+ replacement[k] = v
+ end
+ game.save.party[1] = replacement
+ refusal(
+ ui,
+ game,
+ { id = 1, revision = snap.revision, kind = "back" },
+ "identical replacement mon stales snapshot"
+ )
+ -- Different screen identity, even with identical values.
+ snap = ui:snapshot()
+ local replacementScreen = require("src.ui." .. partyClass[version]).new(game, {})
+ game.stack.states[1] = replacementScreen
+ refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "replacement screen stales snapshot")
+ party = replacementScreen
+ -- Whitelist native ownership; never infer an unrelated menu from its rows.
+ game.stack:push({ game = game, kind = "party", items = { { label = "fake" } } })
+ T.eq(ui:snapshot(), nil, "unrelated overlay unsupported")
+ refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "covered native menu refused")
+ game.stack:pop()
+ for _, flag in ipairs({ "heal", "noInput", "script", "closed" }) do
+ party[flag] = true
+ snap = ui:snapshot()
+ T.eq(snap.locked, true, flag .. " locks public UI")
+ refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, flag .. " blocks action")
+ party[flag] = nil
+ end
+ -- Shared battle/UI guard, cross-mod epoch, consumed IDs and partial failures.
+ snap = ui:snapshot()
+ Intents.state(game).busy = true
+ refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "in-progress battle/UI work blocked")
+ T.eq(ui:snapshot().locked, true, "busy snapshot locked")
+ Intents.state(game).busy = false
+ snap = ui:snapshot()
+ Intents.state(game).epoch = Intents.state(game).epoch + 1
+ refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle work expires UI snapshot")
+ local calls, during, observed = 0
+ party.onCancel = function()
+ calls = calls + 1
+ game.stack:push(party) -- keep an identical screen for the epoch test
+ during = other:snapshot()
+ observed = other:submit({ id = 9, revision = during.revision, kind = "back" })
+ error("native callback failure")
+ end
+ local otherSnap = other:snapshot()
+ snap = ui:snapshot()
+ Runtime.currentMod = "probe"
+ local ok, err = ui:submit({ id = 10, revision = snap.revision, kind = "back" })
+ T.eq(ok, nil, "partial native failure reported")
+ T.check(err:find("intent consumed", 1, true) ~= nil, "partial failure consumes id")
+ T.eq(Runtime.currentMod, "probe", "engine attribution restored after error")
+ Runtime.currentMod = nil
+ T.eq(calls, 1, "callback invoked once")
+ T.eq(observed, nil, "callback cannot submit reentrantly")
+ T.eq(during.locked, true, "snapshot during callback is locked")
+ refusal(other, game, { id = 9, revision = otherSnap.revision, kind = "back" }, "other mod snapshot expired")
+ refusal(other, game, { id = 9, revision = during.revision, kind = "back" }, "mid-callback snapshot expired")
+ refusal(
+ ui,
+ game,
+ { id = 10, revision = ui:snapshot().revision, kind = "back" },
+ "failed native action cannot replay"
+ )
+ game.stack.states = { require("src.ui." .. partyClass[version]).new(game, {}) }
+ T.check(submit(ui, 11, "back"), "guard released after failure")
+ T.eq(loader.apis.probe.ui.insertBefore, require("src.ui.ModUI").insertBefore, "builder helpers retained")
+ T.neq(ui, other, "per-mod facade identity")
+end
+
+local TextBox = require("src.render.TextBox")
+local ChoiceBox = require("src.ui.ChoiceBox")
+local function tickUntil(game, box, predicate)
+ for _ = 1, 1000 do
+ if predicate() then
+ return
+ end
+ box:update(1 / 60)
+ end
+ error("native UI did not reach expected state")
+end
+for _, version in ipairs(versions) do
+ local ui, game = boot(version)
+ local done = 0
+ local box = TextBox.new(game, "First\fSecond", function()
+ done = done + 1
+ end)
+ game.stack:push(box)
+ local snap = ui:snapshot()
+ T.eq(snap.kind, "text", "text recognized")
+ T.eq(snap.locked, true, "typing text locked")
+ refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "typing cannot be skipped")
+ tickUntil(game, box, function()
+ return box.waiting and (box.preWait or 0) <= 0
+ end)
+ T.check(submit(ui, 1, "advance"), "manual page advances")
+ T.check((box.holdFrames or 0) > 0, "page clear hold preserved")
+ snap = ui:snapshot()
+ refusal(ui, game, { id = 2, revision = snap.revision, kind = "advance" }, "page hold cannot be skipped")
+ tickUntil(game, box, function()
+ return box.done
+ end)
+ T.check(submit(ui, 2, "advance"), "final text closes")
+ T.eq(done, 1, "text callback once")
+ T.eq(game.stack:top(), nil, "text popped")
+ for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do
+ box = TextBox.new(game, "Ready", nil, opts)
+ game.stack:push(box)
+ box.done = true
+ snap = ui:snapshot()
+ T.eq(snap.canAdvance, false, "automatic/stay/choice text not manually dismissed")
+ refusal(ui, game, { id = 3, revision = snap.revision, kind = "advance" }, "script-owned text protected")
+ game.stack:pop()
+ end
+ -- Native text -> choice -> delayed answer -> both boxes popped.
+ local answers = {}
+ box = TextBox.new(game, "Question?", nil, {
+ defaultNo = true,
+ choice = function(yes)
+ answers[#answers + 1] = yes
+ end,
+ })
+ game.stack:push(box)
+ tickUntil(game, box, function()
+ return box.done
+ end)
+ box:update(1 / 60)
+ local choice = game.stack:top()
+ T.eq(getmetatable(choice), ChoiceBox, "native choice appears")
+ T.eq(ui:snapshot().index, 2, "default NO retained")
+ T.check(submit(ui, 4, "select", { index = 1 }), "YES accepted")
+ T.eq(#answers, 0, "answer callback waits for native hold")
+ T.eq(#game.stack.states, 2, "both boxes retained during answer hold")
+ snap = ui:snapshot()
+ refusal(ui, game, { id = 5, revision = snap.revision, kind = "back" }, "pending answer locked")
+ tickUntil(game, choice, function()
+ return #answers == 1
+ end)
+ T.eq(answers[1], true, "YES delivered")
+ T.eq(#game.stack.states, 0, "choice closes original text too")
+ choice = ChoiceBox.new(game, function(yes)
+ answers[#answers + 1] = yes
+ end)
+ game.stack:push(choice)
+ T.check(submit(ui, 5, "back"), "B selects NO")
+ T.eq(choice.index, 2, "NO cursor retained while pending")
+ tickUntil(game, choice, function()
+ return #answers == 2
+ end)
+ T.eq(answers[2], false, "NO delivered")
+end
+
+-- Pick an actual item through each native bag. API and physical A/B must
+-- deliver the same callback and row, including HGSS pages and BW cursors.
+for _, version in ipairs(versions) do
+ local traces = {}
+ for _, native in ipairs({ true, false }) do
+ local ui, game = boot(version)
+ game.data.items.POTION = {
+ id = "POTION",
+ name = "POTION",
+ pocket = "ITEM",
+ fieldPocket = 0,
+ bwPocket = 0,
+ fieldUse = 1,
+ fieldUseFunc = 1,
+ battlePocket = 4,
+ }
+ game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" }
+ local picked = {}
+ local bag = require("src.ui." .. bagClass[version]).new(game, {
+ pick = true,
+ onPick = function(id)
+ picked[#picked + 1] = id
+ end,
+ })
+ game.stack:push(bag)
+ local unchanged = freeze(game)
+ local snap = ui:snapshot()
+ unchanged("bag snapshot does not rebuild, sort or load graphics")
+ local itemRow
+ for i, row in ipairs(snap.rows) do
+ if row.item == "POTION" then
+ itemRow = i
+ end
+ end
+ T.check(itemRow ~= nil, version .. " item exposed")
+ if native then
+ if bagClass[version] == "HgssBagMenu" then
+ bag.page, bag.slot = math.floor((itemRow - 1) / 6), (itemRow - 1) % 6 + 1
+ elseif bagClass[version] == "Gen5BagMenu" then
+ bag.cursor = itemRow
+ else
+ bag.index = itemRow
+ if bag.clampScroll then
+ bag:clampScroll()
+ end
+ end
+ button(game, bag, "a")
+ else
+ T.check(submit(ui, 1, "select", { index = itemRow }), "native bag selection accepted")
+ end
+ -- GB's bag has no generic pick option: it opens USE/GIVE/TOSS instead.
+ local top = game.stack:top()
+ if bagClass[version] == "BagMenu" then
+ T.eq(ui:snapshot().kind, "bag_actions", "only factory-owned bag action menus offered")
+ if native then
+ button(game, top, "b")
+ else
+ T.check(submit(ui, 2, "back"), "back from native item submenu")
+ end
+ else
+ T.same(picked, { "POTION" }, "native pick callback exactly once")
+ end
+ traces[#traces + 1] = { picked = picked, count = game.save.inventory.POTION, depth = #game.stack.states }
+ end
+ T.same(traces[1], traces[2], version .. " bag confirm matches native input")
+end
+-- Real medicine effect, target picker and HP animation through the public
+-- boundary. The native flow owns consumption and its delayed completion.
+for _, version in ipairs({ "red", "crystal", "emerald" }) do
+ local ui, game = boot(version)
+ game.data.items.POTION = { id = "POTION", name = "POTION", pocket = "ITEM" }
+ game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" }
+ local mon = game.save.party[1]
+ mon.hp = mon.stats.hp - 25
+ local before = mon.hp
+ local bag = require("src.ui." .. bagClass[version]).new(game, {})
+ game.stack:push(bag)
+ local snap = ui:snapshot()
+ local slot
+ for i, r in ipairs(snap.rows) do
+ if r.item == "POTION" then
+ slot = i
+ end
+ end
+ T.check(submit(ui, 1, "select", { index = slot }), "select medicine")
+ snap = ui:snapshot()
+ if snap.kind == "bag_actions" then
+ local use
+ for i, r in ipairs(snap.rows) do
+ if r.action == "use" or r.label == "USE" then
+ use = i
+ end
+ end
+ T.check(submit(ui, 2, "select", { index = use }), "native USE action")
+ end
+ local picker = game.stack:top()
+ T.eq(ui:snapshot().kind, "party", "medicine opens native target picker")
+ T.check(submit(ui, 3, "select", { index = 1 }), "select medicine target")
+ T.eq(mon.hp, before + 20, "real native potion restores 20 HP")
+ T.eq(game.save.inventory.POTION, 1, "real native potion consumes once")
+ T.eq(picker.heal ~= nil, true, "native heal animation retained")
+ snap = ui:snapshot()
+ refusal(
+ ui,
+ game,
+ { id = 4, revision = snap.revision, kind = "select", index = 1 },
+ "cannot use again during HP fill"
+ )
+ tickUntil(game, picker, function()
+ return picker.heal == nil
+ end)
+ local box = game.stack:top()
+ T.eq(getmetatable(box), TextBox, "native result text appears after fill")
+ tickUntil(game, box, function()
+ return box.done
+ end)
+ T.check(submit(ui, 4, "advance"), "native medicine result dismissed")
+ T.eq(game.save.inventory.POTION, 1, "result dismissal cannot consume again")
+end
+-- The GBA action grid keeps its blank cells and script input lock.
+do
+ local ui, game = boot("emerald")
+ local chosen
+ local menu = require("src.ui.Gen3ItemMenu").new(game, {
+ entries = {
+ { label = "USE", kind = "use" },
+ { label = "", kind = "blank" },
+ { label = "CANCEL", kind = "cancel" },
+ },
+ onPick = function(kind)
+ chosen = kind
+ end,
+ })
+ game.stack:push(menu)
+ local snap = ui:snapshot()
+ T.eq(snap.rows[2].enabled, false, "blank grid cell disabled")
+ refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 2 }, "blank cell refused")
+ T.check(submit(ui, 1, "select", { index = 1 }), "native item grid confirm")
+ T.eq(chosen, "use", "native item action callback")
+end
+-- DS battle bags admit their native delayed press, never call its callback
+-- early. Page slots, absent items and return buttons preserve native indices.
+for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do
+ local traces = {}
+ for _, native in ipairs({ true, false }) do
+ local ui, game = boot(version)
+ game.data.items.POTION =
+ { id = "POTION", name = "POTION", fieldPocket = 0, battlePocket = 4, battleMask = 4 }
+ game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" }
+ local ds = GameVersion.VERSIONS[version].generation == 5 and "Gen5BattleBag" or "Gen4BattleBag"
+ local bag = require("src.ui." .. ds).new(game, {})
+ game.gen4CursorActive = true
+ game.stack:push(bag)
+ local snap = ui:snapshot()
+ refusal(
+ ui,
+ game,
+ { id = 1, revision = snap.revision, kind = "select", index = 5 },
+ "unavailable last-used row"
+ )
+ local function select(id, index)
+ if native then
+ bag.cursor.index = index
+ button(game, bag, "a")
+ else
+ T.check(submit(ui, id, "select", { index = index }), "battle bag native press admitted")
+ end
+ T.check(bag.press ~= nil, "native press pending")
+ T.eq(ui:snapshot().locked, true, "pending press locks UI")
+ tickUntil(game, bag, function()
+ return bag.press == nil
+ end)
+ end
+ select(1, 1)
+ T.eq(bag.screen, "pocket", "native pocket opens after animation")
+ snap = ui:snapshot()
+ T.eq(snap.rows[1].item, "POTION", "native battle item row")
+ refusal(
+ ui,
+ game,
+ { id = 2, revision = snap.revision, kind = "select", index = 6 },
+ "empty item slot refused"
+ )
+ select(2, 1)
+ T.eq(bag.screen, "use", "native use question")
+ select(3, 2)
+ T.eq(bag.screen, "pocket", "use back returns to pocket")
+ select(4, 9)
+ T.eq(bag.screen, "menu", "pocket back returns to categories")
+ if native then
+ button(game, bag, "b")
+ else
+ T.check(submit(ui, 5, "back"), "battle bag cancel")
+ end
+ T.check(bag.press ~= nil, "cancel animation pending")
+ tickUntil(game, bag, function()
+ return bag.press == nil
+ end)
+ traces[#traces + 1] = {
+ depth = #game.stack.states,
+ count = game.save.inventory.POTION,
+ page = bag.screen,
+ closed = bag.closed,
+ }
+ end
+ T.same(traces[1], traces[2], version .. " battle bag matches native A/B")
+end
+
+-- opts.auto/stay/choice govern the final page, not the manual paragraph
+-- breaks before it. Do not strand a question that spans several pages.
+for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do
+ local ui, game = boot("crystal")
+ local box = TextBox.new(game, "First\fLast", nil, opts)
+ game.stack:push(box)
+ tickUntil(game, box, function()
+ return box.waiting and (box.preWait or 0) <= 0
+ end)
+ T.eq(ui:snapshot().canAdvance, true, "script tail still allows manual intermediate page")
+ T.check(submit(ui, 1, "advance"), "intermediate question/automatic/stay page advances")
+ tickUntil(game, box, function()
+ return box.done
+ end)
+ T.eq(ui:snapshot().canAdvance, false, "final script-owned page stays protected")
+end
+-- Wrong Game, unknown overlays, script-owned bags and pending controllers.
+do
+ local ui, game = boot("platinum")
+ local bag = require("src.ui.Gen4BagMenu").new(game, {})
+ game.stack:push(bag)
+ for _, key in ipairs({ "moving", "trash", "script", "noInput" }) do
+ bag[key] = true
+ local snap = ui:snapshot()
+ T.eq(snap.locked, true, "busy bag locked")
+ refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "busy bag refusal")
+ bag[key] = nil
+ end
+ bag.battle = { demo = true }
+ local snap = ui:snapshot()
+ refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle tutorial owns bag")
+ bag.battle = nil
+ bag.game = {}
+ T.eq(ui:snapshot(), nil, "wrong game screen unavailable")
+end
+-- Existing helpers remain usable with their original dot-call contract.
+do
+ local ui, _, other = boot("red")
+ local rows = { { label = "a" }, { label = "c" } }
+ ui.insertBefore(rows, "c", { label = "b" })
+ T.eq(rows[2].label, "b", "legacy builder still works")
+ T.eq(rawget(other, "snapshot") ~= nil, true, "each mod owns its facade methods")
+end
+
+-- API 1 gets the same additive surface without changing its content format.
+for _, version in ipairs({ "red", "emerald", "black" }) do
+ local ui, game, _, loader = boot(version, 1)
+ game.stack:push(ChoiceBox.new(game, function() end))
+ T.eq(ui:snapshot().kind, "choice", "API 1 UI reader")
+ T.check(submit(ui, 1, "back"), "API 1 UI action")
+ if loader.apis.probe.supports then
+ T.eq(loader.apis.probe:supports("ui", "snapshot"), true, "optional reader capability")
+ T.eq(loader.apis.probe:supports("ui", "submit"), true, "optional action capability")
+ T.eq(loader.apis.probe:supports("ui", "unknown"), false, "unknown capability declined")
+ end
+end
+-- A real battle opens its picker via mod.battle:submit. Targeting the picker
+-- consumes the current UI context, then native callbacks resume the battle.
+do
+ local ui, game, _, loader = boot("crystal")
+ local BattleState = require("src.battle.BattleState")
+ local b = BattleState.newWild(game, "FIXMON_C", 10)
+ b:syncSides()
+ b.phase, b.queue, b.menuIndex = "menu", {}, 1
+ game.stack:push(b)
+ local api = loader.apis.probe.battle
+ local snapshot = api:snapshot()
+ T.check(
+ api:submit({ id = 1, revision = snapshot.revision, kind = "menu", choice = "party" }),
+ "battle opens native party UI"
+ )
+ for _ = 1, 100 do
+ if game.stack:top() ~= b then
+ break
+ end
+ b:updateQueue()
+ end
+ T.eq(ui:snapshot().kind, "party", "battle picker exposed by UI facade")
+ local oldBattle = api:snapshot()
+ T.check(submit(ui, 1, "select", { index = 2 }), "choose switching party slot")
+ T.eq(ui:snapshot().kind, "party_actions", "native battle switch submenu")
+ local switch
+ for i, row in ipairs(ui:snapshot().rows) do
+ if row.action == "battle_switch" then
+ switch = i
+ end
+ end
+ T.check(submit(ui, 2, "select", { index = switch }), "native switch callback dispatched")
+ T.eq(game.stack:top(), b, "native battle resumes")
+ T.check(#b.queue > 0, "native switch queues battle work")
+ T.eq(
+ api:submit({ id = 2, revision = oldBattle.revision, kind = "menu", choice = "fight" }),
+ nil,
+ "UI work expires battle context"
+ )
+end
+
+-- Link/temporary party arrays can differ from save.party. Track the actual
+-- native target, including changes made while its action submenu is open.
+do
+ local ui, game = boot("crystal")
+ local copy = { Pokemon.new(game.data, "FIXMON_C", 15), Pokemon.new(game.data, "FIXMON_B", 12) }
+ local party = require("src.ui.PartyMenu").new(game, { party = copy })
+ game.stack:push(party)
+ T.check(submit(ui, 1, "select", { index = 1 }), "open temporary party submenu")
+ local snapshot = ui:snapshot()
+ copy[1].hp = copy[1].hp - 1
+ T.eq(
+ refusal(
+ ui,
+ game,
+ { id = 2, revision = snapshot.revision, kind = "back" },
+ "temporary party HP invalidates submenu"
+ ),
+ "stale UI context",
+ "actual native party tracked"
+ )
+ snapshot = ui:snapshot()
+ local replacement = {}
+ for k, v in pairs(copy[1]) do
+ replacement[k] = v
+ end
+ copy[1] = replacement
+ refusal(
+ ui,
+ game,
+ { id = 2, revision = snapshot.revision, kind = "back" },
+ "temporary party object identity tracked"
+ )
+end
+GameVersion.set(original)
+T.finish("UI actions")
diff --git a/tests/engine/unified_content.lua b/tests/engine/unified_content.lua
new file mode 100644
index 000000000..b7695caa9
--- /dev/null
+++ b/tests/engine/unified_content.lua
@@ -0,0 +1,470 @@
+-- Synthetic extractor-shaped datasets; all mod changes go through Loader.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.harness")
+local Loader = require("src.mods.Loader")
+local Version = require("src.core.GameVersion")
+local Manifest = require("src.mods.Manifest")
+local Merge = require("src.mods.Merge")
+local DELETE = Merge.DELETE
+local Registry = require("src.mods.Registry")
+local Runtime = require("src.mods.Runtime")
+local original = Version.get()
+local function raises(fn, fragment)
+ return T.raises(fn, fragment, "rejects " .. fragment)
+end
+local versions = { "red", "crystal", "emerald", "platinum", "black" }
+local function fixture(gen)
+ local ds = gen >= 4
+ local keys = { mon = ds and 25 or gen == 2 and "SPECIES_025" or "PIKACHU",
+ into = ds and 26 or gen == 2 and "SPECIES_026" or "RAICHU",
+ move = ds and 33 or gen == 2 and "MOVE_033" or "TACKLE",
+ item = ds and 17 or gen == 2 and "ITEM_017" or "POTION",
+ trainer = ds and 7 or "OPP_PROBE" }
+ local stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 }
+ if gen > 1 then stats.spatk, stats.spdef = 50, 60 end
+ if ds then stats.spAttack, stats.spDefense = 50, 60 end
+ local mon = { id = keys.mon, name = "Pikachu", dex = 25, types = { "NORMAL" },
+ baseStats = stats, catchRate = 190, baseExp = 82, growthRate = "MEDIUM_FAST",
+ level1Moves = { keys.move }, learnset = { { level = 9, move = keys.move } }, tmhm = { keys.move },
+ evolutions = { ds and { method = "level", target = keys.into, param = 20, methodId = 4, paramKind = "level" }
+ or { method = "LEVEL", species = keys.into, level = 20 } },
+ spriteFront = "synthetic-front.png", spriteBack = "synthetic-back.png", frontSize = 7,
+ customNative = { sentinel = "retained" } }
+ if gen >= 3 then mon.evYield = { speed = 2 }; mon.abilities = { "STATIC" } end
+ local into = Merge.deepCopy(mon); into.id, into.name, into.dex, into.evolutions = keys.into, "Raichu", 26, {}
+ local move = { id = keys.move, name = "Tackle", type = "NORMAL", power = 35, accuracy = 95, pp = 35,
+ effect = "NO_ADDITIONAL_EFFECT", priority = 0, untouchedTarget = 6 }
+ if ds then move.class, move.classId, move.typeId, move.gen4Effect = "physical", 0, 0, 0 end
+ local item = { id = keys.item, name = "Potion", price = 300, tossable = true, heldEffect = 27 }
+ local party = { { species = keys.mon, level = 12, item = gen > 1 and keys.item or nil,
+ moves = { keys.move }, customIV = 31 } }
+ local trainer = { id = keys.trainer, name = "Probe", parties = { party }, baseMoney = 10, aiMods = { 9 } }
+ if ds then trainer.party = party; trainer.nativeFlag = 17 end
+ local grass = { rate = 20, slots = { { species = keys.mon, level = 5 }, { species = keys.into, level = 7 } },
+ buckets = { 128, 256 } }
+ if gen == 2 then
+ grass.byTime = { morn = Merge.deepCopy(grass), nite = Merge.deepCopy(grass) }
+ grass.byTime.nite.rate = 10
+ elseif gen == 3 then
+ grass.rateMax, grass.bucketSpan, grass.buckets = 180, 100, { 50, 100 }
+ grass.slots[1].level, grass.slots[1].min, grass.slots[1].max = nil, 4, 6
+ end
+ local data = { pokemon = { [keys.mon] = mon, [keys.into] = into }, moves = { [keys.move] = move },
+ items = { [keys.item] = item }, trainers = { [keys.trainer] = trainer },
+ encounters = { ROUTE_PROBE = { grass = grass } }, constants = { generation = gen }, audio = {} }
+ if ds then
+ data.items.ITEM_017 = item
+ data.encounters = { [1] = { grassRate = 20, grass = { { species = keys.mon, level = 5, chance = 100 } } } }
+ end
+ return data, keys
+end
+local function run(gen, body, format, extra, supplied, selected)
+ Version.set(selected or versions[gen])
+ local data, keys = fixture(gen)
+ data = supplied or data
+ local mods = { probe = { entry = body, format = format or "unified" } }
+ for id, spec in pairs(extra or {}) do mods[id] = spec end
+ local loader
+ local fs = {
+ read = function(path)
+ local id = path:match("^mods/([^/]+)/manifest.json$")
+ local mod = id and mods[id]
+ if mod then return ('{"id":"%s","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua","content_format":"%s","priority":%d}')
+ :format(id, mod.format or "unified", mod.priority or 0) end
+ end,
+ getDirectoryItems = function(path)
+ if path ~= "mods" then return {} end
+ local out = {}; for id in pairs(mods) do out[#out + 1] = id end; return out
+ end,
+ getInfo = function(path)
+ if path:match("main.lua$") or path:match("manifest.json$") then return { type = "file" } end
+ if path == "mods" or path:match("^mods/[^/]+$") then return { type = "directory" } end
+ end,
+ load = function(path)
+ local id = path:match("^mods/([^/]+)/main.lua$")
+ if mods[id] then return function(api) return mods[id].entry(api, loader) end end
+ end,
+ }
+ loader = Loader.new({ fs = fs, dev = false })
+ loader._game = function() error("content must not load Game") end
+ loader:load(data)
+ return loader.apis.probe, loader, data, keys
+end
+local function ok(value, reason) assert(value, reason) end
+local function ops(registry) local n = 0; for _, rows in pairs(registry.ops) do n = n + #rows end; return n end
+local function dataOnly(v)
+ if type(v) == "function" then return "" end
+ if type(v) ~= "table" then return v end
+ local out = {}; for k, sub in pairs(v) do out[k] = dataOnly(sub) end; return out
+end
+
+-- Native defaults, manifest rejection and registry numeric-ID confinement.
+raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 1,
+ content_format = "unified" }, "mods/bad") end, "unified content requires mod API 2")
+raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 2,
+ content_format = "unifed" }, "mods/bad") end, "content_format must be native or unified")
+raises(function() Registry.new("legacy"):patch(25, {}) end, "id is required")
+
+local savedLove = love; love = nil
+for gen = 1, 5 do
+ local api, loader, data, keys = run(gen, function(mod)
+ T.eq(mod.contentFormat, "unified", "manifest selects unified content in entry")
+ if mod.supports then
+ T.eq(mod:supports("content", "encounters"), gen <= 3, "host query follows selected content format")
+ T.eq(mod:supports("hooks", "battle.damage"), true, "other capability surfaces retain host query")
+ end
+ local pokemon = assert(mod.content.pokemon:get("PIKACHU"))
+ T.eq(pokemon.id, "PIKACHU", "canonical species identity")
+ T.eq(pokemon.level1Moves[1], "TACKLE", "canonical starting move")
+ T.eq(pokemon.evolutions[1].species, "RAICHU", "canonical evolution target")
+ T.eq(pokemon.baseStats.special, 50, "canonical Special")
+ T.eq(pokemon.baseStats.specialDefense, gen > 1 and 60 or nil, "split Special retained")
+ pokemon.baseStats.hp = 99
+ pokemon.native.customNative.sentinel = "local"
+ T.eq(mod.content.pokemon:get("PIKACHU").baseStats.hp, 35, "read is detached")
+ local _, why = mod.content.pokemon:patch("PIKACHU", pokemon)
+ T.check(why and why:find("read%-only"), "native diagnostics cannot be written")
+ ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 70 } }))
+ ok(mod.content.moves:patch("TACKLE", { power = 45 }))
+ ok(mod.content.items:patch("POTION", { price = 100 }))
+ local trainerId = gen >= 4 and "7" or "OPP_PROBE"
+ local trainer = assert(mod.content.trainers:get(trainerId))
+ T.eq(trainer.parties[1][1].species, "PIKACHU", "canonical trainer species")
+ T.eq(trainer.parties[1][1].moves[1], "TACKLE", "canonical trainer moves")
+ trainer.parties[1][1].level = 18
+ ok(mod.content.trainers:patch(trainerId, { parties = trainer.parties }))
+ local _, whyMissing = mod.content.moves:patch("ABSENT", { power = 5 })
+ T.check(whyMissing ~= nil, "absent patch declines with reason")
+ local encounter = mod.content.encounters
+ if gen <= 3 then
+ T.check(encounter:supports(), "GB/GBA encounter adapter available")
+ local row = assert(encounter:get("ROUTE_PROBE"))
+ row.grass.slots[1].species = "RAICHU"
+ ok(encounter:patch("ROUTE_PROBE", { grass = { slots = row.grass.slots } }))
+ else
+ local available, why = encounter:supports()
+ T.check(available == false and type(why) == "string", "DS encounters explicitly unsupported")
+ local result, err = encounter:patch("1", { grass = { rate = 10 } })
+ T.check(result == nil and type(err) == "string", "DS encounter writes decline safely")
+ end
+ end)
+ T.eq(loader.mods.probe.state, "loaded", versions[gen] .. ": real entry/merge succeeds")
+ T.eq(#loader.errors, 0, versions[gen] .. ": no cross-reference errors")
+ T.eq(data.pokemon[keys.mon].baseStats.special, 70, "native Special alias updated")
+ T.eq(data.pokemon[keys.mon].baseStats.spdef, gen > 1 and 70 or nil, "native Special Defense updated")
+ T.eq(data.pokemon[keys.mon].customNative.sentinel, "retained", "native extensions survive")
+ T.eq(data.moves[keys.move].power, 45, "native move power updated")
+ T.eq(data.moves[keys.move].untouchedTarget, 6, "native move targeting preserved")
+ T.eq(data.items[keys.item].price, 100, "native item price updated")
+ T.eq(data.items[keys.item].heldEffect, 27, "native held effect preserved")
+ T.eq(data.trainers[keys.trainer].parties[1][1].level, 18, "native trainer party updated")
+ T.eq(data.trainers[keys.trainer].parties[1][1].customIV, 31, "native trainer metadata preserved")
+ T.eq(api.content.pokemon:get("PIKACHU").baseStats.special, 70, "read works after merge/freeze")
+ if gen >= 4 then
+ T.eq(data.items.ITEM_017.price, 100, "DS item string alias updated together with numeric ID")
+ T.eq(api.content.items:get("POTION").price, 100, "DS alias identity survives merge")
+ T.same(data.trainers[keys.trainer].party, data.trainers[keys.trainer].parties[1], "DS party aliases stay synchronized")
+ end
+ raises(function() api.content.moves:patch("TACKLE", { power = 99 }) end, "frozen")
+ local count = 0
+ for id, row in api.content.items:each() do count = count + 1; T.eq(id, row.id, "iterator canonical ID") end
+ T.eq(count, 1, "iterator coalesces native aliases")
+end
+love = savedLove or require("tests.love_stub")
+
+-- No-op canonical round trips preserve every native field and native format.
+for gen = 1, 5 do
+ local _, baseline = run(gen, function() end, "native")
+ local _, canonical, data = run(gen, function(mod)
+ local kinds = { "pokemon", "moves", "items", "trainers" }
+ if gen <= 3 then kinds[#kinds + 1] = "encounters" end
+ for _, kind in ipairs(kinds) do
+ for id, record in mod.content[kind]:each() do ok(mod.content[kind]:override(id, record)) end
+ end
+ end)
+ T.eq(canonical.mods.probe.state, "loaded", "round-trip load succeeds")
+ T.same(dataOnly(data), dataOnly(baseline.baseData), versions[gen] .. ": full native round-trip parity")
+end
+
+-- Unsupported/malformed operations must append nothing and preserve input.
+for gen = 1, 5 do
+ local _, loader = run(gen, function(mod)
+ local pokemon = mod.content.pokemon
+ local before = mod.content.pokemon:get("PIKACHU")
+ local invalid = { baseStats = { hp = 0 } }
+ raises(function() pokemon:patch("PIKACHU", invalid) end, "invalid")
+ T.eq(invalid.baseStats.hp, 0, "invalid caller payload untouched")
+ raises(function() pokemon:patch("PIKACHU", { base_stats = {} }) end, "unknown")
+ raises(function() pokemon:patch("PIKACHU", { baseStats = DELETE }) end, "required")
+ raises(function() pokemon:patch("PIKACHU", { learnset = { [2] = { level = 4, move = "TACKLE" } } }) end, "contiguous")
+ local cyclic = {}; cyclic.foo = cyclic
+ raises(function() pokemon:patch("PIKACHU", cyclic) end, "cyclic")
+ raises(function() mod.content.moves:patch("TACKLE", { power = 0 / 0 }) end, "non-finite")
+ local result, reason = pokemon:patch("PIKACHU", { level1Moves = { "MISSING" } })
+ T.check(result == nil and reason:find("unknown moves ID", 1, true), "unknown cross-generation reference refuses")
+ T.same(pokemon:get("PIKACHU"), before, "failed operations leave effective record intact")
+ end)
+ T.eq(loader.mods.probe.state, "loaded", "caught refusals do not disable mod")
+ T.eq(ops(loader.content.pokemon), 0, "refused operations leave no Pokemon op log")
+end
+
+-- Empty lists, append/prepend, DELETE and stacking with native mods.
+local _, lists, data = run(2, function(mod)
+ ok(mod.content.pokemon:patch("PIKACHU", { learnset = {} }))
+ T.eq(#mod.content.pokemon:get("PIKACHU").learnset, 0, "empty list clears immediately")
+ ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __append = { { level = 10, move = "TACKLE" } } } }))
+ ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __prepend = { { level = 5, move = "TACKLE" } } } }))
+ ok(mod.content.pokemon:patch("PIKACHU", { tmhm = mod.DELETE }))
+end, "unified", {
+ earlier = { format = "native", priority = -1, entry = function(mod)
+ mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } })
+ end },
+ later = { format = "native", priority = 1, entry = function(mod)
+ mod.content.pokemon:patch("SPECIES_025", { baseStats = { defense = 75 } })
+ end },
+})
+T.eq(lists.mods.probe.state, "loaded", "list and native stacking load")
+T.eq(data.pokemon.SPECIES_025.learnset[1].level, 5, "prepend before append")
+T.eq(data.pokemon.SPECIES_025.learnset[2].level, 10, "append retains order")
+T.eq(#data.pokemon.SPECIES_025.tmhm, 0, "DELETE materializes empty native list")
+T.eq(data.pokemon.SPECIES_025.baseStats.attack, 80, "earlier native patch preserved")
+T.eq(data.pokemon.SPECIES_025.baseStats.defense, 75, "later native patch preserved")
+
+-- An entry failure rolls every canonical/native alias operation back.
+for gen = 1, 5 do
+ local _, loader, data, keys = run(gen, function(mod)
+ ok(mod.content.items:patch("POTION", { price = 1 }))
+ ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 100 } }))
+ error("intentional entry failure")
+ end)
+ T.eq(loader.mods.probe.state, "failed", "entry failure attributed")
+ T.eq(data.items[keys.item].price, 300, "failed item edit rolled back")
+ T.eq(data.pokemon[keys.mon].baseStats.hp, 35, "failed species edit rolled back")
+ if gen >= 4 then T.eq(data.items.ITEM_017.price, 300, "failed DS alias edit rolled back") end
+end
+
+-- Native consumers see the result, rather than merely passing schema checks.
+local _, nativeLoader, data, keys = run(2, function(mod)
+ ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 90, specialDefense = 95 } }))
+ ok(mod.content.trainers:patch("OPP_PROBE", { parties = { { { species = "RAICHU", level = 18, moves = { "TACKLE" } } } } }))
+ ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 32,
+ slots = { { species = "RAICHU", level = 8 }, { species = "PIKACHU", level = 9 } } } }))
+end)
+T.eq(nativeLoader.mods.probe.state, "loaded", "consumer probe loads")
+local Pokemon = require("src.pokemon.Pokemon")
+local mon = Pokemon.new(data, keys.mon, 10, function(a) return a end)
+T.check(mon.stats.spatk ~= nil and mon.stats.spdef ~= nil, "native Pokemon constructor keeps split stat model")
+local Encounter = require("src.world.Encounter")
+for _, time in ipairs({ "MORNING", "DAY", "NIGHT" }) do
+ local tableAtTime = Encounter.atTime(data.encounters.ROUTE_PROBE.grass, time)
+ local encountered = Encounter.rollTable(tableAtTime, function(a) return a end)
+ T.eq(encountered.species, keys.into, time .. ": native encounter chooses translated species")
+ T.eq(encountered.level, 8, time .. ": native encounter uses translated level")
+end
+local Battle = require("src.battle.BattleState")
+local game = { data = data, save = { options = {}, player = {}, party = {} } }
+local party = Battle.buildTrainerParty(game, keys.trainer, 1, data.trainers[keys.trainer].parties[1])
+T.eq(party[1].species, keys.into, "native trainer builder receives translated species")
+T.eq(party[1].level, 18, "native trainer builder receives translated level")
+
+-- Every registered version selects the same format using its own host
+-- generation. The facade captures that generation before entry execution.
+for _, version in ipairs(Version.ORDER) do
+ local gen = Version.generation(version)
+ local _, loader, data, key = run(gen, function(mod)
+ T.eq(mod.gameVersion, version, "selected host identity: " .. version)
+ mod.generation, mod.host.generation = 99, 99
+ Version.set("red")
+ ok(mod.content.moves:patch("TACKLE", { power = 42 }))
+ end, "unified", nil, nil, version)
+ T.eq(loader.mods.probe.state, "loaded", version .. ": captured host format works")
+ T.eq(data.moves[key.move].power, 42, version .. ": same portable patch reaches native data")
+end
+
+-- Canonical IDs are based on this ROM's names, not official numeric IDs.
+local hacked = fixture(2)
+hacked.pokemon.SPECIES_777 = hacked.pokemon.SPECIES_025
+hacked.pokemon.SPECIES_025 = nil
+local _, hackLoader, hackData = run(2, function(mod)
+ ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 65 } }))
+end, "unified", nil, hacked)
+T.eq(hackLoader.mods.probe.state, "loaded", "relocated ROM-hack species ID resolves")
+T.eq(hackData.pokemon.SPECIES_777.baseStats.hp, 65, "no hard-coded National Dex lookup")
+
+local ambiguous = fixture(2)
+ambiguous.pokemon.SPECIES_999 = Merge.deepCopy(ambiguous.pokemon.SPECIES_025)
+local _, ambiguity = run(2, function(mod)
+ local value, why = mod.content.pokemon:get("PIKACHU")
+ T.check(value == nil and why:find("ambiguous", 1, true), "ambiguous name has a diagnostic")
+ local changed, reason = mod.content.pokemon:patch("PIKACHU", { catchRate = 1 })
+ T.check(changed == nil and reason:find("ambiguous", 1, true), "ambiguous writes cannot choose a record")
+ raises(function() mod.content.pokemon:each() end, "ambiguous")
+end, "unified", nil, ambiguous)
+T.eq(ops(ambiguity.content.pokemon), 0, "ambiguity leaves no ops")
+
+-- New string-key records can be registered without changing legacy schema
+-- behavior. Existing numeric records can be patched, not allocated blindly.
+for gen = 1, 5 do
+ local _, loader, data = run(gen, function(mod)
+ local new = { id = "CUSTOM_ITEM", name = "Custom item", price = 42 }
+ local saved = Merge.deepCopy(new)
+ local changed, why = mod.content.items:register("CUSTOM_ITEM", new)
+ if gen < 4 then
+ T.check(changed == true, "string-key item registration succeeds")
+ T.eq(mod.content.items:get("CUSTOM_ITEM").price, 42, "registration immediately readable")
+ raises(function() mod.content.items:register("CUSTOM_ITEM", new) end, "already registered")
+ else T.check(changed == nil and why:find("storage", 1, true), "DS allocation declines without a native slot") end
+ T.same(new, saved, "registration input remains detached")
+ local payload = { price = 123 }
+ ok(mod.content.items:patch("POTION", payload))
+ payload.price = 999
+ T.eq(mod.content.items:get("POTION").price, 123, "later caller mutation cannot edit op log")
+ local record = assert(mod.content.pokemon:get("PIKACHU"))
+ local changed, reason = mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 99 } })
+ if gen == 1 then
+ T.check(changed == nil and reason:find("split", 1, true), "Gen 1 split stat rejected atomically")
+ T.same(mod.content.pokemon:get("PIKACHU"), record, "rejected split retains native record")
+ else T.eq(changed, true, "later generation accepts split stat") end
+ raises(function() mod.content.pokemon:patch("PIKACHU", { types = mod.DELETE }) end, "cannot be deleted")
+ raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { specialDefense = mod.DELETE } }) end, "cannot delete")
+ end)
+ T.eq(loader.mods.probe.state, "loaded", "registration/refusal cases survive load")
+ T.eq(data.items.CUSTOM_ITEM and data.items.CUSTOM_ITEM.price, gen < 4 and 42 or nil, "only supported registrations reach Data")
+end
+
+-- Probability units are uniform (a fraction out of 256) and lossless.
+local _, rates, rateData = run(3, function(mod)
+ local before = mod.content.encounters:get("ROUTE_PROBE")
+ T.eq(before.grass.rate, 20 * 256 / 180, "GBA rate is projected into common units")
+ local changed, why = mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 31 } })
+ T.check(changed == nil and why:find("representable", 1, true), "unrepresentable GBA rate rejected")
+ T.same(mod.content.encounters:get("ROUTE_PROBE"), before, "rejected rate leaves slots/weights intact")
+ ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 30 * 256 / 180 } }))
+end)
+T.eq(rateData.encounters.ROUTE_PROBE.grass.rate, 30, "representable rate maps exactly to native span")
+
+-- Native DS parser output is edited through the same facade. Its class
+-- selector and numeric evolution target remain consistent for real consumers.
+local parsed = fixture(4)
+local bytes = { 0, 0, 0, 35, 0, 95, 35, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
+local chars = {}; for i, byte in ipairs(bytes) do chars[i] = string.char(byte) end
+parsed.moves[33] = assert(require("src.import.Gen4Moves").parse(table.concat(chars)))
+parsed.moves[33].id, parsed.moves[33].name = 33, "Tackle"
+local _, parsedLoader, parsedData = run(4, function(mod)
+ ok(mod.content.moves:patch("TACKLE", { category = "special", power = 50 }))
+ ok(mod.content.pokemon:patch("PIKACHU", { evolutions = { { method = "LEVEL", level = 15, species = "RAICHU" } } }))
+end, "unified", nil, parsed)
+T.eq(parsedLoader.mods.probe.state, "loaded", "native parser-produced data accepts canonical edits")
+T.eq(parsedData.moves[33].class, "special", "native DS class updated")
+T.eq(parsedData.moves[33].classId, 1, "native DS class selector updated")
+local Evolution = require("src.pokemon.Evolution")
+local target = Evolution.pendingFor({ data = parsedData, save = { options = {} } },
+ { species = 25, level = 15 }, { kind = "levelup" })
+T.eq(target, 26, "native evolution consumer follows translated numeric target")
+
+-- Removing a target retains post-merge reference checks, including numeric
+-- trainer references and the otherwise hidden Gen 2 night slot table.
+for _, gen in ipairs({ 2, 4 }) do
+ local _, loader = run(gen, function(mod) ok(mod.content.pokemon:remove("RAICHU")) end)
+ T.check(#loader.errors > 0, "removed native evolution/encounter target is diagnosed")
+ T.check(table.concat(loader.errors, "\n"):find("probe", 1, true) ~= nil, "reference failure attributed to removing mod")
+end
+local _, removal, removedData = run(4, function(mod)
+ local trainer = mod.content.trainers:get("7")
+ trainer.parties[1][1].item = nil
+ ok(mod.content.trainers:patch("7", { parties = trainer.parties }))
+ ok(mod.content.items:remove("POTION"))
+ T.eq(mod.content.items:has("POTION"), false, "tombstone immediately hidden in canonical view")
+end)
+T.eq(#removal.errors, 0, "valid alias removal has no dangling reference")
+T.check(removedData.items[17] == nil and removedData.items.ITEM_017 == nil, "numeric/string aliases tombstoned together")
+
+-- Preferences must still choose original patch operations, not snapshots of
+-- earlier mods' changes captured into an adapter's replacement record.
+local _, arbitration, arbitrationData = run(2, function(mod, loader)
+ ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 75 } }))
+ loader.content.pokemon:preferOwner("SPECIES_025", "probe")
+end, "unified", {
+ earlier = { format = "native", priority = -1, entry = function(mod)
+ mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } })
+ end },
+})
+T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.hp, 75, "preferred canonical patch remains")
+T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.attack, 55, "set-aside native patch does not leak through a snapshot")
+
+-- The extractor keeps abbreviated GBA type keys, not the Gen 1 keys.
+local types = fixture(3)
+types.type_chart = { types = { NORMAL = { category = "physical" }, PSYCHC = { category = "special" } }, matchups = {} }
+types.moves.TACKLE.type = "PSYCHC"
+local _, typeLoader, typeData = run(3, function(mod)
+ T.eq(mod.content.moves:get("TACKLE").type, "PSYCHIC_TYPE", "abbreviated GBA type is canonical on read")
+ ok(mod.content.moves:patch("TACKLE", { type = "PSYCHIC_TYPE" }))
+ ok(mod.content.pokemon:patch("PIKACHU", { types = { "PSYCHIC_TYPE" } }))
+end, "unified", nil, types)
+T.eq(#typeLoader.errors, 0, "canonical type resolves to actual native registry")
+T.eq(typeData.moves.TACKLE.type, "PSYCHC", "native abbreviated move type retained")
+T.eq(typeData.pokemon.PIKACHU.types[1], "PSYCHC", "species type references translated")
+
+-- A pre-existing unmapped native move selector must not become a dangling
+-- registry ID merely because a balance mod changes its power.
+local opaque = fixture(4)
+opaque.moves[33].effect, opaque.moves[33].gen4Effect = 999, 999
+local _, opaqueLoader, opaqueData = run(4, function(mod)
+ local record = mod.content.moves:get("TACKLE")
+ T.check(record.effect == nil and record.native.effect == 999, "opaque selector exposed only in native diagnostics")
+ ok(mod.content.moves:patch("TACKLE", { power = 65 }))
+end, "unified", nil, opaque)
+T.eq(#opaqueLoader.errors, 0, "untouched numeric effect selector has no false reference error")
+T.eq(opaqueData.moves[33].gen4Effect, 999, "opaque native selector survives patch")
+
+-- Default/explicit native mode continues to expose live native records.
+local default = Manifest.validate({ id = "native", name = "Native", version = "1", entry = "main.lua", api = 2 }, "mods/native")
+T.eq(default.content_format, "native", "omitted format remains native")
+local _, native = run(2, function(mod)
+ T.eq(mod.contentFormat, "native", "native manifest uses original facade")
+ T.check(mod.content.pokemon:get("PIKACHU") == nil, "native facade does not invent aliases")
+ mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 99 } })
+end, "native")
+T.eq(native.baseData.pokemon.SPECIES_025.baseStats.attack, 99, "native patch still has original behavior")
+
+-- Full read/modify/write of one time must not flatten the others to DAY.
+local timeData = fixture(2)
+timeData.encounters.ROUTE_PROBE.grass.byTime.morn.rate = 5
+timeData.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level = 2
+local _, timeLoader, editedTimes = run(2, function(mod)
+ local record = mod.content.encounters:get("ROUTE_PROBE")
+ record.grass.times.NITE.slots[1].level = 11
+ ok(mod.content.encounters:override("ROUTE_PROBE", record))
+end, "unified", nil, timeData)
+T.eq(timeLoader.mods.probe.state, "loaded", "time-specific detached edit succeeds")
+T.eq(editedTimes.encounters.ROUTE_PROBE.grass.rate, 20, "time edit preserves day rate")
+T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.rate, 5, "time edit preserves distinct morning rate")
+T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level, 2, "time edit preserves distinct morning slots")
+T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 11, "time edit changes only requested night slot")
+
+local _, mixedTimeLoader, mixedTimeData = run(2, function(mod)
+ local record = mod.content.encounters:get("ROUTE_PROBE")
+ record.grass.rate = 30
+ record.grass.times.NITE.slots[1].level = 12
+ ok(mod.content.encounters:override("ROUTE_PROBE", record))
+end)
+T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.rate, 30, "inherited night rate cannot undo shared edit")
+T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 12, "explicit night slot edit survives shared rate")
+
+local _, conflictingStats = run(2, function(mod)
+ raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 50, specialAttack = 80 } }) end, "conflicting")
+end)
+T.eq(ops(conflictingStats.content.pokemon), 0, "conflicting stat aliases leave no ops")
+
+-- The unsupported DS writer must not disable native reference auditing.
+local seasonal = fixture(5)
+seasonal.encounters[1].seasons = { winter = { grass = { { species = 26, level = 8, chance = 100 } } } }
+local _, seasonalLoader = run(5, function(mod)
+ ok(mod.content.pokemon:patch("PIKACHU", { evolutions = {} }))
+ ok(mod.content.pokemon:remove("RAICHU"))
+end, "unified", nil, seasonal)
+T.check(table.concat(seasonalLoader.errors, "\n"):find("seasons.winter.grass", 1, true) ~= nil,
+ "DS seasonal encounter target is included in removal diagnostics")
+
+Version.set(original)
+T.finish("unified content")
diff --git a/tests/engine/unified_mod_callbacks.lua b/tests/engine/unified_mod_callbacks.lua
new file mode 100644
index 000000000..a4059d663
--- /dev/null
+++ b/tests/engine/unified_mod_callbacks.lua
@@ -0,0 +1,634 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.harness")
+local Loader = require("src.mods.Loader")
+local Manifest = require("src.mods.Manifest")
+local Version = require("src.core.GameVersion")
+local Runtime = require("src.mods.Runtime")
+local Catalog = require("tests.modkit.catalog")
+local C = require("src.mods.UnifiedCallbacks")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love }
+love = nil
+local function manifest(format, api)
+ return {
+ id = "probe",
+ name = "Probe",
+ version = "1.0.0",
+ entry = "main.lua",
+ api = api or 2,
+ callback_format = format,
+ }
+end
+T.eq(Manifest.validate(manifest()).callback_format, "native", "existing manifests keep native callbacks")
+T.eq(Manifest.validate(manifest("unified")).callback_format, "unified", "opt-in accepted")
+T.raises(function()
+ Manifest.validate(manifest("unified", 1))
+end, "requires mod API 2", "unified requires API 2")
+T.raises(function()
+ Manifest.validate(manifest("other"))
+end, "callback_format", "unknown format rejected")
+for _, invalid in ipairs({ false, 42, {} }) do
+ T.raises(function()
+ Manifest.validate(manifest(invalid))
+ end, "callback_format", "malformed format rejected")
+end
+local function set(names)
+ local out = {}
+ for _, n in ipairs(names) do
+ out[n] = true
+ end
+ return out
+end
+T.same(C.hooks, set(Catalog.hooks()), "every native hook has an explicit boundary decision")
+T.same(C.events, set(Catalog.events()), "every native event has an explicit boundary decision")
+local function boot(version, format, entry)
+ Version.set(version)
+ local numeric = Version.generation() >= 4
+ local a, b, m, n, item =
+ numeric and 501 or "ALPHA",
+ numeric and 502 or "BETA",
+ numeric and 601 or "TACKLE",
+ numeric and 602 or "EMBER",
+ numeric and 701 or "POTION"
+ local data = {
+ pokemon = {
+ [a] = {
+ id = a,
+ name = "Alpha",
+ baseStats = { hp = 40, attack = 45, defense = 40, speed = 30, special = 35 },
+ types = {
+ "NORMAL",
+ },
+ },
+ [b] = { id = b, name = "Beta", baseStats = { hp = 50 }, types = { "NORMAL" } },
+ },
+ moves = {
+ [m] = { id = m, name = "Tackle", pp = 35, power = 40 },
+ [n] = {
+ id = n,
+ name = "Ember",
+ pp = 25,
+ power = 40,
+ },
+ },
+ items = { [item] = { id = item, name = "Potion" } },
+ trainers = {},
+ encounters = {},
+ }
+ if numeric then
+ data.pokemon.SPECIES_501 = data.pokemon[a]
+ data.moves.MOVE_601 = data.moves[m]
+ end
+ local json = require("src.link.Json")
+ local files = {
+ ["mods/probe/manifest.json"] = json.encode(manifest(format)),
+ ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end",
+ }
+ local fs = {
+ read = function(p)
+ return files[p]
+ end,
+ load = function(p)
+ return loadstring(files[p], p)
+ end,
+ getInfo = function(p)
+ if p == "mods" or p == "mods/probe" then
+ return { type = "directory" }
+ end
+ if files[p] then
+ return { type = "file" }
+ end
+ end,
+ getDirectoryItems = function(p)
+ return p == "mods" and { "probe" } or {}
+ end,
+ }
+ local loader = Loader.new({ fs = fs, dev = false })
+ loader._game = function()
+ error("callback opt-in must not construct Game")
+ end
+ local loaded = loader:load(data)
+ if not entry then
+ assert(loaded)
+ assert(loader.apis.probe, tostring(loader.errors.probe))
+ end
+ return loader.apis.probe, loader, data, { a, b, m, n, item }
+end
+local function mon(ids)
+ return {
+ species = ids[1],
+ item = ids[5],
+ level = 10,
+ hp = 25,
+ status = "PSN",
+ stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24, spatk = 24, spdef = 25 },
+ moves = { { id = ids[3], pp = 12 } },
+ ivs = { hp = 17 },
+ personality = 123,
+ custom = { keep = true },
+ }
+end
+for _, version in ipairs(Version.ORDER) do
+ local api, loader, data, ids = boot(version, "unified")
+ local label = version .. ": "
+ T.eq(api.callbackFormat, "unified", label .. "format exposed before Game construction")
+ local raw = mon(ids)
+ if Version.generation() == 1 then
+ raw.stats.spatk = nil
+ raw.stats.spdef = nil
+ end
+ local unchanged = require("src.mods.Merge").deepCopy(raw)
+ local noop = api.hooks:wrap("battle.damage", function(next)
+ return next()
+ end)
+ loader.hooks:call("battle.damage", function()
+ return 5
+ end, { user = { mon = raw } })
+ T.same(raw, unchanged, label .. "no-op unified callback leaves native record exactly unchanged")
+ T.eq(raw.isEgg, nil, label .. "false view flag does not materialize native field")
+ noop()
+ local originalStats, originalMoves, originalSlot = raw.stats, raw.moves, raw.moves[1]
+ local battler =
+ { mon = raw, isPlayer = true, curStats = raw.stats, curMoves = raw.moves, stages = { attack = 0 } }
+ local battle = { tag = "native handle" }
+ local count = 0
+ local off = api.hooks:wrap("battle.damage", function(next, ctx)
+ T.eq(ctx.battle, battle, label .. "battle stays native")
+ T.eq(ctx.user.mon, ctx.target.mon, label .. "shared mon preserves public alias")
+ T.eq(ctx.user.mon.species, "ALPHA", label .. "canonical species")
+ T.eq(ctx.user.mon.item, "POTION", label .. "canonical held item")
+ T.eq(ctx.user.mon.moves[1].id, "TACKLE", label .. "canonical moves")
+ T.eq(ctx.user.mon.status, "poison", label .. "canonical status")
+ T.eq(ctx.user.mon.maxHp, 40, label .. "maxHp reads stats.hp")
+ T.eq(ctx.user.mon.stats.specialAttack, 24, label .. "canonical special attack")
+ ctx.user.mon.hp = 24
+ ctx.user.mon.status = "burn"
+ ctx.user.mon.nickname = "Test"
+ ctx.user.mon.stats.attack = 26
+ ctx.user.mon.moves[1].id = "EMBER"
+ ctx.user.mon.moves[1].pp = 20
+ local n, hole, last = next()
+ T.eq(ctx.user.mon.hp, 19, label .. "native downstream changes refreshed")
+ T.eq(select("#", next()), 3, label .. "cached next retains nil holes")
+ ctx.user.mon.hp = 18
+ return n + 1, hole, last
+ end)
+ local n, hole, last = loader.hooks:call("battle.damage", function(ctx)
+ count = count + 1
+ T.eq(ctx.user, battler, label .. "native battler identity restored")
+ T.eq(ctx.user.mon, raw, label .. "native mon identity restored")
+ T.eq(raw.hp, 24, label .. "pre-next mutation committed")
+ T.eq(raw.status, "BRN", label .. "status written in native vocabulary")
+ T.eq(raw.moves[1].id, ids[4], label .. "move ID restored for host")
+ raw.hp = 19
+ return 5, nil, "tail"
+ end, { battle = battle, user = battler, target = battler, move = data.moves[ids[3]] })
+ T.eq(n, 6, label .. "scalar result postprocessing")
+ T.eq(last, "tail", label .. "result tail preserved")
+ T.eq(hole, nil, label .. "nil preserved")
+ T.eq(count, 1, label .. "vanilla runs once")
+ T.eq(raw.hp, 18, label .. "post-next edit committed")
+ T.eq(raw.stats, originalStats, label .. "stats table identity preserved")
+ T.eq(raw.moves, originalMoves, label .. "moves list identity preserved")
+ T.eq(raw.moves[1], originalSlot, label .. "move slot identity preserved")
+ T.eq(raw.personality, 123, label .. "hidden personality retained")
+ T.eq(raw.ivs.hp, 17, label .. "hidden IVs retained")
+ off()
+
+ -- Mutations across separate bound records preflight as one transaction.
+ local other = mon(ids)
+ off = api.hooks:wrap("battle.damage", function(_, ctx)
+ ctx.user.mon.hp = 4
+ ctx.target.mon.level = 101
+ return 99
+ end)
+ count = 0
+ T.eq(
+ loader.hooks:call("battle.damage", function()
+ count = count + 1
+ return 3
+ end, { user = battler, target = { mon = other } }),
+ 3,
+ label .. "invalid edit skips wrapper"
+ )
+ T.eq(raw.hp, 18, label .. "earlier record edit rolled back")
+ T.eq(other.level, 10, label .. "invalid record unchanged")
+ T.eq(count, 1, label .. "failure before next falls through once")
+ off()
+ off = api.hooks:wrap("battle.damage", function(next, ctx)
+ local n = next()
+ ctx.user.mon.hp = -1
+ return n + 20
+ end)
+ count = 0
+ T.eq(
+ loader.hooks:call("battle.damage", function()
+ count = count + 1
+ raw.hp = 16
+ return 7
+ end, { user = battler }),
+ 7,
+ label .. "bad post-next edit keeps downstream result"
+ )
+ T.eq(raw.hp, 16, label .. "downstream mutation retained")
+ T.eq(count, 1, label .. "bad post-next never retries")
+ off()
+ off = api.hooks:wrap("battle.damage", function(next, ctx)
+ next()
+ ctx.user.mon.hp = 2
+ error("after next")
+ end)
+ T.eq(
+ loader.hooks:call("battle.damage", function()
+ return 8
+ end, { user = battler }),
+ 8,
+ label .. "throw after next preserves result"
+ )
+ T.eq(raw.hp, 16, label .. "throw after next discards late edits")
+ off()
+
+ local sentinel = { error = "vanilla" }
+ off = api.hooks:wrap("battle.damage", function(next)
+ pcall(next)
+ pcall(next)
+ return 999
+ end)
+ count = 0
+ local ok, err = pcall(function()
+ loader.hooks:call("battle.damage", function()
+ count = count + 1
+ error(sentinel)
+ end, { user = battler })
+ end)
+ T.check(not ok and err == sentinel, label .. "vanilla error identity survives swallowed next failure")
+ T.eq(count, 1, label .. "throwing vanilla never replayed")
+ off()
+ off = api.hooks:wrap("battle.damage", function(next)
+ pcall(next)
+ return 999
+ end)
+ local savedMoves = raw.moves
+ local okBroken, errBroken = pcall(function()
+ loader.hooks:call("battle.damage", function()
+ raw.moves = 42 -- a failed native operation can leave an unreadable record
+ error(sentinel)
+ end, { user = battler })
+ end)
+ T.check(not okBroken and errBroken == sentinel, label .. "refresh cannot mask a native error")
+ raw.moves = savedMoves
+ off()
+
+ -- Event records are detached. The native handles deliberately remain handles.
+ local seen = 0
+ off = api.events:once("battle.exp_gained", function(payload)
+ seen = seen + 1
+ T.eq(payload.mon.species, "ALPHA", label .. "event mon normalized")
+ payload.mon.hp = 1
+ payload.mon.native.ivs.hp = 1
+ end)
+ loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 })
+ loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 })
+ T.eq(seen, 1, label .. "once retained")
+ T.eq(raw.hp, 16, label .. "event mutation detached")
+ T.eq(raw.ivs.hp, 17, label .. "native diagnostic snapshot detached")
+ off()
+ local gift = { species = ids[1], level = 5, ctx = { opaque = true } }
+ off = api.events:on("pokemon.before_give", function(p)
+ p.species = "BETA"
+ p.level = 15
+ p.nickname = "Gift"
+ end)
+ loader.events:emit("pokemon.before_give", gift)
+ T.eq(gift.species, ids[2], label .. "gift ID restored")
+ T.eq(gift.level, 15, label .. "gift level committed")
+ T.eq(gift.nickname, "Gift", label .. "gift nickname committed")
+ off()
+ off = api.events:on("pokemon.before_give", function(p)
+ p.species = "ALPHA"
+ p.level = 101
+ end)
+ loader.events:emit("pokemon.before_give", gift)
+ T.eq(gift.species, ids[2], label .. "invalid gift atomic species")
+ T.eq(gift.level, 15, label .. "invalid gift atomic level")
+ off()
+
+ off = api.hooks:wrap("battle.turn_order", function(next, p, pm, e, em, ctx)
+ T.eq(pm.id, "TACKLE", label .. "move definition canonical ID")
+ T.eq(next(), p, label .. "native result maps to original public battler")
+ return e
+ end)
+ local enemy = { mon = other, isPlayer = false }
+ T.eq(
+ loader.hooks:call("battle.turn_order", function(p)
+ return p
+ end, battler, data.moves[ids[3]], enemy, data.moves[ids[4]], {}),
+ enemy,
+ label .. "selected battler restores identity"
+ )
+ off()
+ off = api.hooks:wrap("encounter.species", function(next, enc)
+ enc.species = "BETA"
+ enc.level = 20
+ return next()
+ end)
+ local encounter = { species = ids[1], level = 10, form = 2 }
+ T.eq(
+ loader.hooks:call("encounter.species", function(enc)
+ return enc
+ end, encounter, {}),
+ encounter,
+ label .. "encounter identity retained"
+ )
+ T.eq(encounter.species, ids[2], label .. "encounter species converted")
+ T.eq(encounter.form, 2, label .. "encounter hidden field retained")
+ off()
+ off = api.hooks:wrap("trainer.party", function(next, cl, index, party)
+ T.eq(party[1].species, "ALPHA", label .. "trainer slot canonical species")
+ T.eq(party[1].moves[1], "TACKLE", label .. "trainer slot moves are IDs")
+ party[1].species = "BETA"
+ party[1].moves = { "EMBER" }
+ party[1].level = 20
+ return next()
+ end)
+ local party = { { species = ids[1], level = 10, moves = { ids[3] }, dvs = { attack = 15 } } }
+ T.eq(
+ loader.hooks:call("trainer.party", function(_, _, p)
+ return p
+ end, "CLASS", 1, party),
+ party,
+ label .. "trainer party native identity"
+ )
+ T.eq(party[1].species, ids[2], label .. "trainer species restored")
+ T.eq(party[1].moves[1], ids[4], label .. "trainer move restored")
+ T.eq(party[1].dvs.attack, 15, label .. "trainer hidden DVs retained")
+ off()
+
+ count = 0
+ off = api.hooks:wrap("battle.exp_award", function(_, ctx)
+ local p = ctx.alive[1]
+ p.hp = 15
+ ctx.applyShare(p, 2, false)
+ T.eq(p.level, 11, label .. "applyShare refreshes public mon")
+ p.hp = 14
+ end)
+ loader.hooks:call("battle.exp_award", function()
+ error("wrapper replaces vanilla")
+ end, {
+ battle = battle,
+ alive = { raw },
+ applyShare = function(p, divisor, announce)
+ count = count + 1
+ T.eq(p, raw, label .. "helper gets native identity")
+ T.eq(p.hp, 15, label .. "helper sees pending edits")
+ T.eq(divisor, 2, label .. "helper scalar args")
+ T.eq(announce, false, label .. "helper false arg")
+ p.level = 11
+ end,
+ })
+ T.eq(count, 1, label .. "helper called once")
+ T.eq(raw.hp, 14, label .. "helper postprocessing committed")
+ off()
+
+ off = api.hooks:wrap("fieldmove.eligibility", function(next, id)
+ T.eq(id, "TACKLE", label .. "field move argument normalized")
+ local p = next()
+ T.eq(p.species, "ALPHA", label .. "field move result normalized")
+ return p
+ end)
+ T.eq(
+ loader.hooks:call("fieldmove.eligibility", function(id)
+ T.eq(id, ids[3], label .. "field move argument restored")
+ return raw
+ end, ids[3], {}),
+ raw,
+ label .. "field move mon result identity restored"
+ )
+ off()
+ off = api.hooks:wrap("battle.enemy_action", function(next)
+ local action = next()
+ T.eq(action.id, "TACKLE", label .. "enemy action normalized")
+ action.id = "EMBER"
+ return action
+ end)
+ local action = { id = ids[3], index = 1 }
+ T.eq(
+ loader.hooks:call("battle.enemy_action", function()
+ return action
+ end, battle, battler),
+ action,
+ label .. "enemy action identity restored"
+ )
+ T.eq(action.id, ids[4], label .. "enemy action ID restored")
+ off()
+ off = api.hooks:wrap("encounter.roll", function()
+ return { species = "BETA", level = 30 }
+ end)
+ local enc = loader.hooks:call("encounter.roll", function()
+ error("suppressed")
+ end, nil, {})
+ T.eq(enc.species, ids[2], label .. "new encounter result native species")
+ T.eq(enc.level, 30, label .. "new encounter level")
+ off()
+ off = api.hooks:wrap("encounter.roll", function()
+ return { species = "MISSING", level = 30 }
+ end)
+ T.eq(
+ loader.hooks:call("encounter.roll", function()
+ return "fallback"
+ end, nil, {}),
+ "fallback",
+ label .. "unknown species rejected"
+ )
+ off()
+ off = api.hooks:wrap("battle.damage", function(_, ctx)
+ ctx.move.power = 999
+ return 88
+ end)
+ T.eq(
+ loader.hooks:call("battle.damage", function()
+ return 4
+ end, { move = data.moves[ids[3]] }),
+ 4,
+ label .. "definition mutation rejected"
+ )
+ T.eq(data.moves[ids[3]].power, 40, label .. "definition unchanged")
+ off()
+ off = api.hooks:wrap("battle.damage", function(next, ctx)
+ ctx.user.mon.maxHp = 50
+ ctx.user.mon.stats.specialAttack = 28
+ next()
+ return 0
+ end)
+ loader.hooks:call("battle.damage", function()
+ T.eq(raw.stats.hp, 50, label .. "maxHp writes native stats.hp")
+ T.eq(raw.stats.special, 28, label .. "special alias kept")
+ T.eq(
+ Version.generation() == 1 and raw.stats.special or raw.stats.spatk,
+ 28,
+ label .. "split special stat writes correctly"
+ )
+ end, { user = battler })
+ off()
+ off = api.hooks:wrap("battle.damage", function(_, ctx)
+ ctx.user.mon.native.ivs.hp = 9
+ ctx.user.mon.hp = 2
+ return 88
+ end)
+ T.eq(
+ loader.hooks:call("battle.damage", function()
+ return 3
+ end, { user = battler }),
+ 3,
+ label .. "native snapshot mutation rejected"
+ )
+ T.eq(raw.hp, 14, label .. "snapshot rejection atomic")
+ off()
+ local nativeCalls = 0
+ local undoNative = loader.hooks:wrap("battle.damage", function(next, ctx)
+ nativeCalls = nativeCalls + 1
+ T.eq(ctx.user, battler, label .. "native mod sees native battler")
+ return next()
+ end, -5, "native")
+ off = api.hooks:wrap("battle.damage", function(next, ctx)
+ -- Explicit argument packs accept bound records inside a bound context.
+ ctx.extra = 123
+ return next(ctx)
+ end, 5)
+ loader.hooks:call("battle.damage", function(ctx)
+ T.eq(ctx.extra, 123, label .. "explicit next args committed")
+ end, { user = battler })
+ T.eq(nativeCalls, 1, label .. "mixed native/unified chain")
+ off()
+ undoNative()
+ local order = {}
+ local a = api.events:on("battle.started", function()
+ order[#order + 1] = "low"
+ end, -1)
+ local b = api.events:on("battle.started", function()
+ order[#order + 1] = "high"
+ end, 1)
+ loader.events:emit("battle.started", {})
+ T.same(order, { "high", "low" }, label .. "event priority retained")
+ a()
+ b()
+ local actionView
+ off = api.events:on("battle.turn_started", function(p)
+ actionView = p.playerAction
+ end)
+ loader.events:emit("battle.turn_started", { playerAction = { id = ids[3], index = 1 } })
+ T.eq(actionView.id, "TACKLE", label .. "turn event action ID normalized")
+ off()
+
+ off = api.hooks:wrap("battle.damage", function(_, ctx)
+ ctx.user.mon.moves = { [2] = { id = "TACKLE", pp = 1, ppUps = 0 } }
+ ctx.user.mon.hp = 1
+ return 99
+ end)
+ T.eq(
+ loader.hooks:call("battle.damage", function()
+ return 2
+ end, { user = battler }),
+ 2,
+ label .. "sparse move list rejected"
+ )
+ T.eq(raw.hp, 14, label .. "sparse move list fails atomically")
+ off()
+ local retainedNext, retainedShare
+ off = api.hooks:wrap("battle.exp_award", function(next, ctx)
+ retainedNext = next
+ retainedShare = ctx.applyShare
+ end)
+ loader.hooks:call("battle.exp_award", function()
+ error("must not run")
+ end, {
+ alive = { raw },
+ applyShare = function()
+ error("must not run")
+ end,
+ })
+ T.raises(function()
+ retainedNext()
+ end, "only valid during", label .. "retained next cannot run after callback")
+ T.raises(function()
+ retainedShare(raw)
+ end, "bound Pokemon", label .. "helper rejects native mon")
+ off()
+ local heldMon
+ off = api.hooks:wrap("battle.exp_award", function(_, ctx)
+ heldMon = ctx.alive[1]
+ retainedShare = ctx.applyShare
+ end)
+ loader.hooks:call("battle.exp_award", function() end, {
+ alive = { raw },
+ applyShare = function()
+ error("must not run")
+ end,
+ })
+ T.raises(function()
+ retainedShare(heldMon)
+ end, "closed", label .. "retained helper cannot write after callback")
+ off()
+
+ local custom = { mon = raw }
+ local got
+ off = api.events:on("mod.other.ready", function(p)
+ got = p
+ end)
+ loader.events:emit("mod.other.ready", custom)
+ T.eq(got, custom, label .. "custom payload identity untouched")
+ off()
+ T.raises(function()
+ api.events:emit("mod.other.ready", {})
+ end, "only emit", label .. "cannot spoof other mod")
+ T.raises(function()
+ api.events:emit("mod.probe.", {})
+ end, "only emit", label .. "empty custom suffix rejected")
+ T.raises(function()
+ api.hooks:wrap("invented.hook", function() end)
+ end, "unknown unified", label .. "unknown hook rejected")
+ T.raises(function()
+ api.events:on("invented.event", function() end)
+ end, "unknown unified", label .. "unknown event rejected")
+end
+-- The native path must not install any wrapper or copy its payloads.
+local api, loader = boot("crystal")
+T.eq(api.callbackFormat, "native", "default API native")
+local raw = { mon = { species = 42 } }
+api.events:on("custom.engine", function(p)
+ T.eq(p, raw, "native event identity unchanged")
+end)
+loader.events:emit("custom.engine", raw)
+api.hooks:wrap("custom.hook", function(next, ctx)
+ T.eq(ctx, raw, "native hook identity unchanged")
+ return next()
+end)
+T.eq(
+ loader.hooks:call("custom.hook", function(ctx)
+ return ctx
+ end, raw),
+ raw,
+ "native result identity unchanged"
+)
+-- Entry rollback removes unified subscriptions using the same native owner.
+local _, failed = boot(
+ "crystal",
+ "unified",
+ [[return function(mod)
+ mod.events:on('battle.started',function() end)
+ mod.hooks:wrap('battle.damage',function() end)
+ error('rollback probe')
+end]]
+)
+T.check(#failed.errors > 0, "failed entry attributed")
+T.check(
+ not failed.events.listeners["battle.started"] or #failed.events.listeners["battle.started"] == 0,
+ "failed entry event removed"
+)
+T.check(
+ not failed.hooks.chains["battle.damage"] or #failed.hooks.chains["battle.damage"] == 0,
+ "failed entry hook removed"
+)
+Version.set(saved[1])
+Runtime.install(saved[2], saved[3], saved[4])
+love = saved[5]
+T.finish()
diff --git a/tests/engine/unified_move_effects.lua b/tests/engine/unified_move_effects.lua
new file mode 100644
index 000000000..8d2c0c6af
--- /dev/null
+++ b/tests/engine/unified_move_effects.lua
@@ -0,0 +1,208 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Runtime = require("src.mods.Runtime")
+local Version = require("src.core.GameVersion")
+local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love }
+local E = require("src.mods.UnifiedEffects")
+local C = require("src.mods.MoveEffectContract")
+local Native = require("src.battle.MoveEffects").RECORDS
+local function registered(id)
+ return Native[id] ~= nil
+end
+local accepted = 0
+for id, bindings in pairs(C.bindings) do
+ for g, row in pairs(bindings) do
+ local gen = tonumber(g)
+ local old = { id = row.semantic == 37 and "REST" or row.semantic == 130 and "SONICBOOM" or "POUND" }
+ local plan, why = E.write(id, gen, old, registered)
+ if plan then
+ accepted = accepted + 1
+ T.check(registered(plan.effect), "accepted effect has actual handler " .. g .. ":" .. id)
+ if gen > 1 then
+ plan.effectChance = plan.probability and math.floor(plan.probability * 256 / 100 + 0.5)
+ end
+ T.eq(
+ E.read(plan.effect, gen, plan),
+ ({ HIT = "NO_ADDITIONAL_EFFECT", SLEEP = "SLEEP_EFFECT" })[id] or id,
+ "authored canonical effect read " .. g .. ":" .. id
+ )
+ else
+ T.check(type(why) == "string", "unrepresentable mapping has reason")
+ end
+ end
+end
+T.check(accepted > 300, "reference matrix exercises hundreds of native bindings")
+local Contract = require("tests.api3_contract.effects")
+for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do
+ local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v)
+ T.check(ok, v .. " shared effect contract: " .. tostring(why))
+end
+for gen = 1, 3 do
+ local custom = assert(E.write("mod:custom", gen, nil, function(id)
+ return id == "mod:custom"
+ end))
+ T.eq(custom.effect, "mod:custom", "registered custom native effect selected")
+ T.eq(E.read(custom.effect, gen, custom), "mod:custom", "custom canonical identity")
+ T.eq(E.write("unknown", gen, nil, registered), nil, "unknown handler refused")
+ T.eq(E.write("native:" .. gen .. ":made_up", gen, nil, registered), nil, "forged opaque effect refused")
+ T.eq(
+ E.write("native:" .. gen .. ":unhandled", gen, { effect = "unhandled" }, registered).effect,
+ "unhandled",
+ "original opaque identity retained"
+ )
+end
+for _, id in ipairs({
+ "FOLLOW_ME",
+ "HELPING_HAND",
+ "MAGIC_COAT",
+ "SNATCH",
+ "IMPRISON",
+ "ASSIST",
+ "NATURE_POWER",
+ "SECRET_POWER",
+}) do
+ T.eq(E.write(id, 3, nil, registered), nil, "missing native Gen3 handler refused " .. id)
+end
+for _, v in ipairs({ "gold", "firered" }) do
+ Version.set(v)
+ local gen = Version.generation()
+ local plan = assert(E.write("SPECIAL_UP1_EFFECT", gen, { id = "POUND" }, registered))
+ local user = { name = "TEST", isPlayer = true, mon = { hp = 20, stats = { hp = 20 } }, stages = {} }
+ local target = { name = "TARGET", mon = { hp = 20, stats = { hp = 20 } }, stages = {} }
+ Native[plan.effect].run({ battle = { data = {} }, user = user, target = target, move = plan })
+ T.eq(user.stages.spatk, 1, "actual handler raises Special Attack")
+ T.eq(user.stages.special, nil, "actual handler avoids Gen1 Special stage")
+ T.eq(user.stages.spdef, nil, "actual handler does not raise Special Defense")
+ plan = assert(E.write("SPECIAL_DOWN_SIDE_EFFECT", gen, { id = "POUND" }, registered))
+ plan.effectChance = 85
+ Native[plan.effect].run({ battle = {
+ data = {},
+ rng = function()
+ return 0
+ end,
+ }, user = user, target = target, move = plan })
+ T.eq(target.stages.spdef, -1, "actual handler lowers Special Defense")
+ plan = assert(E.write("BURN_SIDE_EFFECT1", gen, { id = "POUND" }, registered))
+ plan.effectChance = math.floor(plan.probability * 256 / 100 + 0.5)
+ plan.id, plan.type = "POUND", "FIRE"
+ local battle = {
+ data = {},
+ rng = function()
+ return 25
+ end,
+ }
+ Native[plan.effect].run({ battle = battle, user = user, target = target, move = plan })
+ T.eq(target.mon.status, "BRN", "native burn lands below converted threshold")
+ target.mon.status = nil
+ battle.rng = function()
+ return 26
+ end
+ Native[plan.effect].run({ battle = battle, user = user, target = target, move = plan })
+ T.eq(target.mon.status, nil, "native burn misses at converted threshold")
+end
+for gen = 2, 3 do
+ local old = { id = "POUND", effect = "BURN_SIDE_EFFECT1", effectChance = 27 }
+ local public = E.read(old.effect, gen, old)
+ local plan, changed = E.write(public, gen, old, registered)
+ T.eq(plan.effect, old.effect, "original raw effect roundtrip")
+ T.eq(changed, false, "roundtrip does not rewrite original odds")
+ T.eq(E.chance(old, gen), 11, "native fraction projected to whole percent")
+ T.eq(
+ E.write("NO_ADDITIONAL_EFFECT", 4, old, registered),
+ nil,
+ "later generation changing effect still unavailable"
+ )
+ T.eq(E.read(999, 4, {}), nil, "DS opaque selector retains diagnostic-only behavior")
+end
+-- Registry operations use the adapter before journaling; refusals are atomic.
+local function load(gen, entry, odds)
+ Version.set(gen == 1 and "red" or gen == 2 and "gold" or "firered")
+ local data = require("tests.api3_contract.run").dataFor("gen2", gen)
+ local loader
+ local fs = {
+ read = function(path)
+ if path:match("manifest.json$") then
+ return '{"id":"probe","name":"Probe","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}'
+ end
+ end,
+ getInfo = function(path)
+ if path:match("^mods/probe/.+%.%w+$") then
+ return { type = "file" }
+ end
+ if path == "mods" or path == "mods/probe" then
+ return { type = "directory" }
+ end
+ end,
+ getDirectoryItems = function(path)
+ return path == "mods" and { "probe" } or {}
+ end,
+ load = function()
+ return function(api)
+ entry(api, loader, data)
+ end
+ end,
+ }
+ data.moves.POUND.effect = "BURN_SIDE_EFFECT1"
+ data.moves.POUND.effectChance = odds or 27
+ if gen == 3 then
+ data.moves.POUND.gen3Effect = 4
+ data.moves.POUND.secondaryChance = 11
+ end
+ loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = Version.get() })
+ loader._game = function()
+ error("content must not bootstrap Game")
+ end
+ loader:load(data)
+ T.eq(#loader.errors, 0, "public effect mod loads")
+ return data
+end
+for gen = 2, 3 do
+ local data = load(gen, function(mod, loader)
+ local moves = mod.content.moves
+ local row = assert(moves:get("POUND"))
+ T.eq(row.secondaryChance, 11, "public native odds projection")
+ assert(moves:override("POUND", row))
+ T.eq(moves:get("POUND").native.effectChance, 27, "detached override keeps exact native odds")
+ local operations = #loader.content.moves.ops.POUND
+ T.eq(moves:patch("POUND", { effect = "SNATCH" }), nil, "unimplemented effect operation refused")
+ T.eq(#loader.content.moves.ops.POUND, operations, "failed mapping adds no operation")
+ T.check(not pcall(function()
+ moves:patch("POUND", { secondaryChance = 101 })
+ end), "invalid percent rejected")
+ T.eq(#loader.content.moves.ops.POUND, operations, "invalid chance adds no operation")
+ assert(moves:patch("POUND", { effect = "FLINCH_SIDE_EFFECT2", secondaryChance = 5 }))
+ T.eq(moves:get("POUND").effect, "FLINCH_SIDE_EFFECT2", "authored alias persists after odds edit")
+ assert(moves:patch("POUND", { effect = "BURN_SIDE_EFFECT1", secondaryChance = 0 }))
+ T.eq(moves:get("POUND").secondaryChance, 0, "zero probability retained")
+ end)
+ local target = { name = "TARGET", mon = { hp = 20, stats = { hp = 20 } }, stages = {} }
+ Native[data.moves.POUND.effect].run({
+ battle = {
+ data = {},
+ rng = function()
+ return 0
+ end,
+ },
+ user = {},
+ target = target,
+ move = data.moves.POUND,
+ })
+ T.eq(target.mon.status, nil, "actual handler respects explicit zero instead of native default")
+end
+for gen = 2, 3 do
+ local data = load(gen, function(mod)
+ local moves = mod.content.moves
+ assert(moves:override("POUND", assert(moves:get("POUND"))))
+ T.eq(
+ moves:get("POUND").native._api3EffectChance,
+ nil,
+ "untouched zero-byte roundtrip preserves native fallback"
+ )
+ assert(moves:patch("POUND", { secondaryChance = 0 }))
+ T.eq(moves:get("POUND").native._api3EffectChance, true, "explicit zero patch arms native zero semantics")
+ end, 0)
+end
+Version.set(saved[1])
+Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5]
+love = saved[6]
+T.finish()
diff --git a/tests/engine/version_dataset_files.lua b/tests/engine/version_dataset_files.lua
new file mode 100644
index 000000000..3df810685
--- /dev/null
+++ b/tests/engine/version_dataset_files.lua
@@ -0,0 +1,127 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Version = require("src.core.GameVersion")
+local Cache = require("src.import.CacheFs")
+local Files = require("src.import.VersionFiles")
+local originalLove, originalVersion = love, Version.get()
+local oldRoot, oldDatasetRoot = Cache.root, Cache.datasetRoot
+local root = os.tmpname()
+os.remove(root)
+local written, dirs = {}, {}
+local function mkdir(path)
+ local windows = package.config:sub(1, 1) == "\\"
+ local quoted = windows and ('"' .. path .. '"') or ("'" .. path:gsub("'", "'\\''") .. "'")
+ os.execute((windows and "mkdir " or "mkdir -p ") .. quoted)
+ dirs[#dirs + 1] = path
+end
+local function write(path, body)
+ local f = assert(io.open(path, "wb"))
+ f:write(body)
+ f:close()
+ written[#written + 1] = path
+end
+mkdir(root)
+for _, base in ipairs({ "cache", "save", "source" }) do
+ mkdir(root .. "/" .. base)
+ mkdir(root .. "/" .. base .. "/data")
+ mkdir(root .. "/" .. base .. "/data/generated")
+ mkdir(root .. "/" .. base .. "/gold")
+ mkdir(root .. "/" .. base .. "/gold/data")
+ mkdir(root .. "/" .. base .. "/gold/data/generated")
+end
+write(root .. "/cache/data/generated/probe.lua", "RED")
+write(root .. "/cache/gold/data/generated/probe.lua", "GOLD")
+write(root .. "/save/data/generated/probe.lua", "SAVE_RED")
+write(root .. "/source/data/generated/probe.lua", "SOURCE_RED")
+local chosen, ready = root .. "/cache", true
+local virtualReads = 0
+local fs = {
+ getSaveDirectory = function()
+ return root .. "/save"
+ end,
+ getSource = function()
+ return root .. "/source"
+ end,
+ getRealDirectory = function()
+ return root .. "/source"
+ end,
+ read = function()
+ virtualReads = virtualReads + 1
+ return "ACTIVE_OVERLAY"
+ end,
+ getInfo = function()
+ return { type = "file", size = 14 }
+ end,
+}
+love = { filesystem = fs }
+Cache.root = function()
+ error("observational reads must not resolve or mount roots")
+end
+Cache.datasetRoot = function()
+ return chosen, ready
+end
+Version.set("gold")
+local files = Files.new(fs)
+T.eq(files.read("red", "data/generated/probe.lua", 100), "RED", "inactive Red bypasses active Gold overlay")
+T.eq(files.read("gold", "data/generated/probe.lua", 100), "GOLD", "version-qualified native cache")
+T.eq(files.info("red", "data/generated/probe.lua").size, 3, "native info uses exact file")
+T.eq(
+ files.read("gold", "data/generated/probe.lua", 3),
+ nil,
+ "native reader refuses oversized file before loading bytes"
+)
+T.eq(virtualReads, 0, "resolved cache never consults virtual overlay")
+T.eq(files.path("red", "assets/generated/x.png"), nil, "ambiguous unprefixed asset path refused")
+T.eq(
+ files.path("gold", "assets/generated/x.png"),
+ "gold/assets/generated/x.png",
+ "prefixed asset path retained"
+)
+local identity = files.identity()
+chosen = nil
+T.check(files.identity() ~= identity, "cache-root changes have distinct identity")
+T.eq(
+ files.read("red", "data/generated/probe.lua", 100),
+ "SAVE_RED",
+ "resolved default save root takes precedence"
+)
+ready = false
+T.eq(
+ files.read("red", "data/generated/probe.lua", 100),
+ "SOURCE_RED",
+ "unresolved roots read verified source instead of guessing a save cache"
+)
+T.eq(virtualReads, 0, "physical source read bypasses overlay")
+T.eq(Version.get(), "gold", "native dataset reads leave active version untouched")
+-- A source archive is readable only when its exact provenance is verified.
+fs.getRealDirectory = function()
+ return "/another/overlay"
+end
+T.eq(
+ Cache.datasetSourceFile("data/generated/probe.lua", 100),
+ nil,
+ "foreign overlay cannot impersonate developer source"
+)
+fs.getRealDirectory = function()
+ return fs.getSource()
+end
+T.eq(
+ Cache.datasetSourceFile("data/generated/probe.lua", 100),
+ "ACTIVE_OVERLAY",
+ "verified virtual source can be read"
+)
+T.eq(
+ Cache.datasetSourceFile("data/generated/probe.lua", 3),
+ nil,
+ "source reader respects advertised size limit"
+)
+Cache.root, Cache.datasetRoot = oldRoot, oldDatasetRoot
+love = originalLove
+Version.set(originalVersion)
+for i = #written, 1, -1 do
+ os.remove(written[i])
+end
+for i = #dirs, 1, -1 do
+ os.remove(dirs[i])
+end
+T.finish()
diff --git a/tests/engine/world_actions_api.lua b/tests/engine/world_actions_api.lua
new file mode 100644
index 000000000..bd029b204
--- /dev/null
+++ b/tests/engine/world_actions_api.lua
@@ -0,0 +1,582 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.harness")
+local Loader = require("src.mods.Loader")
+local WorldAPI = require("src.world.WorldAPI")
+local Actions = require("src.world.WorldActions")
+local GameVersion = require("src.core.GameVersion")
+local Runtime = require("src.mods.Runtime")
+local Merge = require("src.mods.Merge")
+local Json = require("src.link.Json")
+local G4Fly = require("src.world.Gen4Fly")
+local Gen3Show = require("src.world.Gen3FieldMove")
+local saved = { GameVersion.get(), Runtime.events, Runtime.hooks, Runtime.errors, love, Gen3Show.show }
+love = nil
+-- Deterministic no-picture native fallback for the cross-version cases.
+Gen3Show.show = function()
+ return false
+end
+local function boot(version)
+ GameVersion.set(version)
+ local gen = GameVersion.generation()
+ local data = {
+ pokemon = {},
+ moves = {},
+ items = {},
+ constants = { badges = {} },
+ tilesets = { TEST = { blockCells = gen < 3 and 2 or 1 } },
+ maps = {
+ HOME = {
+ id = "HOME",
+ width = 20,
+ height = 20,
+ tileset = "TEST",
+ outdoor = true,
+ mapType = "ROUTE",
+ allowFly = true,
+ gen5Flags = 0x2000,
+ },
+ DEST = { id = "DEST", width = 20, height = 20, tileset = "TEST", outdoor = true, mapType = "CITY" },
+ },
+ field = { flyOrder = { "DEST" }, flyWarps = { DEST = { x = 3, y = 4 } } },
+ }
+ for i = 1, 16 do
+ data.constants.badges[i] = { id = "BADGE_" .. i }
+ end
+ local save = {
+ party = {
+ { species = "A", hp = 1, moves = { { id = gen >= 4 and 19 or "FLY" } } },
+ {
+ species = "B",
+ hp = 0,
+ moves = {},
+ },
+ },
+ visited = { DEST = true },
+ lastHeal = { map = "DEST", x = 3, y = 4 },
+ flags = {},
+ badges = {},
+ }
+ for i = 1, 16 do
+ save.badges["BADGE_" .. i] = true
+ end
+ if gen == 4 then
+ data.gen4_version =
+ { family = (version == "heartgold" or version == "soulsilver") and "hgss" or "platinum" }
+ local row = { index = 0, firstArrival = 1, fly = { map = "DEST", x = 3, y = 4 } }
+ data.field.healLocations = { row }
+ save.flags[G4Fly.rowFlagKey(data, row)] = true
+ elseif gen == 5 then
+ data.maps.Z001 = data.maps.DEST
+ data.gen5_town_map =
+ { locations = { { zone = 1, flyPoint = true, spawn = { main = true, x = 3, z = 4 } } } }
+ save.gen5FlyZones = { ["1"] = true }
+ end
+ local calls = {}
+ local ow = {
+ isOverworld = true,
+ map = { id = "HOME", def = data.maps.HOME },
+ player = { cellX = 2, cellY = 3, facing = "up" },
+ scriptMoves = {},
+ pendingScripts = {},
+ runner = {
+ isRunning = function()
+ return false
+ end,
+ },
+ }
+ ow.player.facingCell = function(p)
+ return p.cellX, p.cellY - 1
+ end
+ local game
+ ow.partyKnows = function(_, move)
+ if move == "FLY" then
+ return game.save.party[1]
+ end
+ end
+ ow.flyTo = function(_, id, mon)
+ calls[#calls + 1] = { kind = "fly", id = id, mon = mon }
+ ow.flyAnim = {}
+ end
+ ow.gen4FlyTo = function(_, dest, mon)
+ calls[#calls + 1] = { kind = "fly", id = dest.map, x = dest.x, y = dest.y, mon = mon }
+ ow.flyAnim = {}
+ end
+ local stack = { states = { ow } }
+ function stack:top()
+ return self.states[#self.states]
+ end
+ function stack:push(state)
+ self.states[#self.states + 1] = state
+ end
+ function stack:pop()
+ return table.remove(self.states)
+ end
+ game = { data = data, save = save, overworld = ow, stack = stack }
+ local files = {
+ ["mods/probe/manifest.json"] = Json.encode({
+ id = "probe",
+ name = "Probe",
+ version = "1.0.0",
+ api = 2,
+ entry = "main.lua",
+ }),
+ ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end",
+ }
+ local fs = {
+ read = function(p)
+ return files[p]
+ end,
+ load = function(p)
+ return loadstring(files[p], p)
+ end,
+ getDirectoryItems = function(p)
+ return p == "mods" and { "probe" } or {}
+ end,
+ getInfo = function(p)
+ if p == "mods" or p == "mods/probe" then
+ return { type = "directory" }
+ end
+ if files[p] then
+ return { type = "file" }
+ end
+ end,
+ }
+ local loader = Loader.new({ fs = fs, dev = false })
+ local resolved = 0
+ loader._game = function()
+ resolved = resolved + 1
+ return game
+ end
+ assert(loader:load(data), table.concat(loader.errors, "; "))
+ return loader.apis.probe, game, ow, calls, function()
+ return resolved
+ end
+end
+for _, version in ipairs(GameVersion.ORDER) do
+ local api, game, ow, calls, resolved = boot(version)
+ local label = version .. ": "
+ T.eq(resolved(), 0, label .. "building API does not construct Game")
+ if api.supports then
+ for _, name in ipairs({
+ "canReorderParty",
+ "reorderParty",
+ "availableFieldActions",
+ "useFieldAction",
+ "canFly",
+ "flyTo",
+ }) do
+ T.eq(api:supports("world", name), true, label .. "capability " .. name)
+ end
+ T.eq(resolved(), 0, label .. "capability probes do not construct Game")
+ end
+ local world = api.world
+ local before = Merge.deepCopy(game)
+ T.eq(world:canReorderParty(), true, label .. "free-roam party can reorder")
+ T.eq(world:canFly(), true, label .. "native flight checks pass")
+ T.same(game, before, label .. "availability does not alter save/world")
+ local p = game.save.party
+ local a, b = p[1], p[2]
+ T.eq(world:reorderParty(1, 2), true, label .. "swap accepted")
+ T.eq(game.save.party, p, label .. "party list identity retained")
+ T.eq(p[1], b, label .. "fainted party member may move")
+ T.eq(p[2], a, label .. "Pokemon identity retained")
+ T.eq(world:reorderParty(1, 2), true, label .. "swap back accepted")
+ T.eq(world:reorderParty(1, 1), true, label .. "same-slot no-op accepted")
+ for _, slot in ipairs({ 0, -1, 3, 7, 1.5, "1", math.huge, 0 / 0 }) do
+ before = Merge.deepCopy(game)
+ local ok, reason = world:reorderParty(slot, 1)
+ T.check(ok == nil and reason == "invalid party slot", label .. "invalid slot refused")
+ T.same(game, before, label .. "bad swap atomic")
+ end
+ local locks = {
+ "transitioning",
+ "flyAnim",
+ "teleportOut",
+ "engaging",
+ "emote",
+ "pikaHop",
+ "healAnim",
+ "whirlSpin",
+ "gen4Waterfall",
+ "gen5SeamPending",
+ "gen3Decorate",
+ "frameScriptThisTick",
+ "_modScriptLock",
+ }
+ for _, lock in ipairs(locks) do
+ ow[lock] = true
+ before = Merge.deepCopy(game)
+ T.eq(world:canReorderParty(), false, label .. lock .. " blocks reorder probe")
+ T.eq(world:canFly(), false, label .. lock .. " blocks fly probe")
+ T.eq(world:reorderParty(1, 2), nil, label .. lock .. " blocks swap")
+ T.eq(world:flyTo("DEST"), nil, label .. lock .. " blocks flight")
+ T.eq(world:useFieldAction("surf"), nil, label .. lock .. " blocks field use")
+ T.same(world:availableFieldActions(), {}, label .. lock .. " suppresses actions")
+ T.same(game, before, label .. lock .. " refusal leaves game unchanged")
+ ow[lock] = nil
+ end
+ for _, lock in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do
+ ow.player[lock] = true
+ before = Merge.deepCopy(game)
+ T.eq(world:reorderParty(1, 2), nil, label .. "player " .. lock .. " blocks swap")
+ T.eq(world:flyTo("DEST"), nil, label .. "player " .. lock .. " blocks fly")
+ T.same(game, before, label .. "player " .. lock .. " no mutation")
+ ow.player[lock] = nil
+ end
+ game.stack:push({ isBattle = true })
+ T.eq(world:reorderParty(1, 2), nil, label .. "battle/screen above world blocks swap")
+ T.eq(world:flyTo("DEST"), nil, label .. "battle/screen blocks fly")
+ game.stack:pop()
+ ow.runner.isRunning = function()
+ return true
+ end
+ T.eq(world:reorderParty(1, 2), nil, label .. "running VM blocks swap")
+ ow.runner.isRunning = function()
+ return false
+ end
+ ow.pendingScripts[1] = {}
+ T.eq(world:flyTo("DEST"), nil, label .. "pending scene blocks flight")
+ ow.pendingScripts = {}
+ ow.scriptMoves[1] = {}
+ T.eq(world:reorderParty(1, 2), nil, label .. "scripted movement blocks swap")
+ ow.scriptMoves = {}
+ game.linkSession = {}
+ T.eq(world:flyTo("DEST"), nil, label .. "link lock blocks flight")
+ game.linkSession = nil
+ game.linkNet = { closed = false }
+ T.eq(world:reorderParty(1, 2), nil, label .. "live link net blocks swap")
+ game.linkNet = nil
+ local destination = GameVersion.generation() == 5 and "Z001" or "DEST"
+ before = Merge.deepCopy(game)
+ T.eq(world:flyTo("MISSING"), nil, label .. "unknown destination refused")
+ T.eq(world:flyTo({}), nil, label .. "non-string destination refused")
+ T.same(game, before, label .. "invalid destinations leave save/world unchanged")
+ T.eq(#calls, 0, label .. "no gameplay handlers ran on refusal")
+ T.eq(world:flyTo(destination), true, label .. "flight accepted")
+ T.eq(#calls, 1, label .. "one native departure")
+ T.eq(calls[1].id, destination, label .. "native destination selected")
+ T.eq(calls[1].mon, a, label .. "native carrier identity retained")
+ T.eq(world:flyTo(destination), nil, label .. "in-flight repeat refused")
+ T.eq(#calls, 1, label .. "repeat does not replay departure")
+ ow.flyAnim = nil
+ a.isEgg = true
+ T.eq(world:canFly(), false, label .. "egg cannot carry flight")
+ a.isEgg = nil
+ if GameVersion.generation() == 4 then
+ game.save.flags.FLAG_G4_0961 = true
+ T.eq(world:canFly(), false, label .. "native partner restriction retained")
+ game.save.flags.FLAG_G4_0961 = nil
+ if version == "heartgold" or version == "soulsilver" then
+ game.save.hgssRocketCostume = true
+ T.eq(world:canFly(), false, label .. "Rocket costume restriction retained")
+ game.save.hgssRocketCostume = nil
+ end
+ end
+ game.save = nil
+ T.eq(world:canReorderParty(), false, label .. "no save probe safe")
+ T.eq(world:reorderParty(1, 2), nil, label .. "no save swap safe")
+ T.eq(world:canFly(), false, label .. "no save fly probe safe")
+ T.eq(world:flyTo(destination), nil, label .. "no save fly safe")
+end
+
+-- Exercise the actual native carrier/badge lookup and departure methods,
+-- including their input lock and animation/landing state, without graphics.
+local OW = require("src.world.OverworldController")
+love = {} -- native sound entry points expect a host table, but need no audio device
+local gameUpvalue, oldGame
+for i = 1, 30 do
+ local name, value = debug.getupvalue(OW.flyTo, i)
+ if name == "Game" then
+ gameUpvalue = i
+ oldGame = value
+ break
+ end
+end
+assert(gameUpvalue, "native Game binding")
+for _, version in ipairs(GameVersion.ORDER) do
+ local api, game, ow = boot(version)
+ debug.setupvalue(OW.flyTo, gameUpvalue, game)
+ ow.partyKnows = OW.partyKnows
+ ow.flyTo = OW.flyTo
+ ow.gen4FlyTo = OW.gen4FlyTo
+ ow.closeToMap = OW.closeToMap
+ ow.clearBikeFlags = function() end
+ ow.hasFlyBird = function()
+ return false
+ end
+ ow.syncSurfingPikachu = function() end
+ local gen = GameVersion.generation()
+ if gen <= 2 then
+ game.data.constants.hmBadges = { FLY = { badge = "FLY_ALLOWED" } }
+ game.save.flags.FLY_ALLOWED = true
+ elseif gen == 3 then
+ game.data.constants.gen3FieldMoves = { FLY = { badgeFlag = 0x820 } }
+ game.save.flags.FLAG_G3_0820 = true
+ end
+ local destination = gen == 5 and "Z001" or "DEST"
+ T.eq(api.world:canFly(), true, version .. " actual native eligibility")
+ local before = Merge.deepCopy(game)
+ if gen <= 2 then
+ game.save.flags.FLY_ALLOWED = nil
+ elseif gen == 3 then
+ game.save.flags.FLAG_G3_0820 = nil
+ elseif gen == 4 then
+ for k in pairs(game.save.badges) do
+ game.save.badges[k] = nil
+ end
+ end
+ if gen < 5 then
+ T.eq(api.world:canFly(), false, version .. " native missing badge refuses flight")
+ T.eq(api.world:flyTo(destination), nil, version .. " missing badge has no departure")
+ T.eq(ow.flyAnim, nil, version .. " denied departure has no animation")
+ end
+ game.save = Merge.deepCopy(before.save)
+ if gen <= 3 then
+ game.save.visited.DEST = nil
+ elseif gen == 4 then
+ game.save.flags = {}
+ else
+ game.save.gen5FlyZones = {}
+ end
+ T.eq(api.world:canFly(), false, version .. " unvisited/locked destinations unavailable")
+ T.eq(api.world:flyTo(destination), nil, version .. " locked destination refuses native handler")
+ game.save = Merge.deepCopy(before.save)
+ -- Validate the landing before any departure-side state is changed.
+ if gen <= 3 then
+ game.data.field.flyWarps.DEST.x = -1
+ elseif gen == 4 then
+ game.data.field.healLocations[1].fly.x = -1
+ else
+ game.data.gen5_town_map.locations[1].spawn.x = -1
+ end
+ T.eq(api.world:flyTo(destination), nil, version .. " invalid landing refused")
+ T.eq(ow.flyAnim, nil, version .. " invalid landing leaves animation absent")
+ if gen <= 3 then
+ game.data.field.flyWarps.DEST.x = 3
+ elseif gen == 4 then
+ game.data.field.healLocations[1].fly.x = 3
+ else
+ game.data.gen5_town_map.locations[1].spawn.x = 3
+ end
+ game.save.onBike = true
+ ow.player.surfing = true
+ T.eq(api.world:flyTo(destination), true, version .. " native departure accepted")
+ T.eq(ow.flyAnim.frames, 48, version .. " native flight timing preserved")
+ T.eq(ow.flyDest.map, destination, version .. " native landing map preserved")
+ T.eq(ow.flyDest.x, 3, version .. " native landing x preserved")
+ T.eq(ow.flyDest.y, 4, version .. " native landing y preserved")
+ T.eq(ow.player.inputLocked, true, version .. " native input lock applied")
+ T.eq(ow.player.surfing, false, version .. " native surfing cleared")
+ T.eq(game.save.onBike, false, version .. " native bicycle state cleared")
+end
+for _, version in ipairs({ "red", "gold", "emerald" }) do
+ local api, game, ow = boot(version)
+ debug.setupvalue(OW.flyTo, gameUpvalue, game)
+ local gen = GameVersion.generation()
+ ow.partyKnows = OW.partyKnows
+ ow.beginTeleportOut = OW.beginTeleportOut
+ ow.syncSurfingPikachu = function() end
+ ow.escapePoint = function()
+ return { map = "DEST", x = 3, y = 4 }
+ end
+ game.save.party[1].moves = { { id = "DIG" }, { id = "TELEPORT" } }
+ ow.map.def.tileset = "CAVERN"
+ ow.map.def.environment = 4
+ ow.map.def.allowEscaping = true
+ T.eq(api.world:useFieldAction("dig"), true, version .. " actual native Dig starts")
+ T.eq(ow.teleportOut.frames, 48, version .. " native Dig animation timing")
+ T.eq(ow.teleportOut.escape, gen >= 2 and true or nil, version .. " native Dig escape destination semantics")
+ T.eq(ow.player.inputLocked, true, version .. " native Dig input lock")
+ ow.teleportOut = nil
+ ow.player.inputLocked = false
+ ow.player.spinning = false
+ ow.map.def.tileset = "OVERWORLD"
+ ow.map.def.environment = 1
+ T.eq(api.world:useFieldAction("teleport"), true, version .. " actual native Teleport starts")
+ T.eq(ow.teleportOut.frames, 48, version .. " native Teleport animation timing")
+ T.eq(ow.teleportOut.escape, nil, version .. " native Teleport uses last heal point")
+end
+debug.setupvalue(OW.flyTo, gameUpvalue, oldGame)
+love = nil
+
+-- Field dispatch is tested separately from presentation, using native IDs
+-- and the exact overworld methods called by the existing party menus.
+for _, version in ipairs({ "red", "gold", "emerald", "platinum", "black" }) do
+ local api, game, ow, calls = boot(version)
+ local world = api.world
+ local gen = GameVersion.generation()
+ local mon = game.save.party[1]
+ ow.partyKnows = function()
+ return mon
+ end
+ ow.useSurfFieldMove = function()
+ return "ok"
+ end
+ ow.useCutFieldMove = function()
+ return "ok"
+ end
+ ow.trySurf = function(_, x, y, close, carrier)
+ calls[#calls + 1] = { kind = "surf", carrier = carrier, x = x, y = y }
+ game.stack:push({ isText = true })
+ end
+ ow.tryCut = function(_, x, y)
+ calls[#calls + 1] = { kind = "cut", x = x, y = y }
+ game.stack:push({ isText = true })
+ return true
+ end
+ ow.beginTeleportOut = function(_, done, opts)
+ calls[#calls + 1] = { kind = "escape", escape = opts.escape }
+ ow.teleportOut = {}
+ end
+ ow.escapePoint = function()
+ return { map = "DEST", x = 3, y = 4 }
+ end
+ local before = Merge.deepCopy(game)
+ local list = world:availableFieldActions()
+ T.same(game, before, version .. " field availability is data-only with native pure gates")
+ local names = {}
+ for _, row in ipairs(list) do
+ names[row.id] = true
+ end
+ T.eq(names.surf, gen <= 3 and true or nil, version .. " verified surf coverage")
+ T.eq(names.cut, gen <= 2 and true or nil, version .. " verified cut coverage")
+ T.eq(names.teleport, gen <= 3 and true or nil, version .. " verified teleport coverage")
+ if list[1] then
+ list[1].id = "MUTATED"
+ T.check(world:availableFieldActions()[1].id ~= "MUTATED", version .. " action rows detached")
+ end
+ T.eq(world:useFieldAction("unknown"), nil, version .. " unknown action refused")
+ T.eq(world:useFieldAction("surf", { sourceSlot = 1 }), nil, version .. " unsupported options refused")
+ if gen <= 3 then
+ T.eq(world:useFieldAction("surf"), true, version .. " native surf started")
+ T.eq(calls[#calls].carrier, mon, version .. " surf uses validated carrier")
+ T.eq(world:useFieldAction("surf"), nil, version .. " active dialogue prevents replay")
+ game.stack:pop()
+ ow.player.surfing = true
+ T.eq(world:useFieldAction("surf"), nil, version .. " no invented dismount action")
+ ow.player.surfing = false
+ T.eq(world:useFieldAction("teleport"), true, version .. " native Teleport started")
+ T.eq(calls[#calls].escape, nil, version .. " Teleport keeps heal-point semantics")
+ ow.teleportOut = nil
+ ow.map.def.tileset = "CAVERN"
+ ow.map.def.environment = 4
+ ow.map.def.allowEscaping = true
+ T.eq(world:useFieldAction("dig"), true, version .. " native Dig started")
+ T.eq(
+ calls[#calls].escape,
+ gen >= 2 and true or nil,
+ version .. " Dig keeps generation-specific destination"
+ )
+ ow.teleportOut = nil
+ if gen <= 2 then
+ T.eq(world:useFieldAction("cut"), true, version .. " native Cut started")
+ game.stack:pop()
+ end
+ else
+ T.eq(world:useFieldAction("surf"), nil, version .. " unsupported script adapter is explicit")
+ T.eq(#calls, 0, version .. " unsupported field dispatch has no effects")
+ end
+end
+
+-- Deferred Gen 3 cut-ins own the request until completion; callbacks may be
+-- invoked twice or outlive a replaced map/save, but effects must not replay.
+local api, game, ow, calls = boot("emerald")
+local world = api.world
+local finish
+Gen3Show.show = function(g, mon, done)
+ finish = done
+ g.stack:push({ isCutin = true })
+ return true
+end
+T.eq(world:flyTo("DEST"), true, "cut-in queued")
+T.eq(#calls, 0, "no departure before cut-in finishes")
+T.eq(world:reorderParty(1, 2), nil, "cut-in blocks competing action")
+game.stack:pop()
+finish()
+finish()
+T.eq(#calls, 1, "cut-in completion departs once")
+ow.flyAnim = nil
+T.eq(world:flyTo("DEST"), true, "second independent request queued")
+game.stack:pop()
+game.save = Merge.deepCopy(game.save)
+finish()
+T.eq(#calls, 1, "save replacement cancels pending effect")
+T.eq(world:canReorderParty(), true, "cancelled request releases its lock")
+T.eq(world:flyTo("DEST"), true, "request can resume after cancellation")
+game.stack:pop()
+ow.map = { id = "OTHER", def = game.data.maps.HOME }
+finish()
+T.eq(#calls, 1, "map replacement cancels pending effect")
+T.eq(world:canReorderParty(), true, "map cancellation releases lock")
+T.eq(world:flyTo("DEST"), true, "abandoned presentation queued")
+game.stack:pop() -- simulate native UI being dismissed without completion
+T.eq(world:canReorderParty(), true, "abandoned cut-in does not leave a permanent API lock")
+finish()
+T.eq(#calls, 1, "abandoned callback cannot later run")
+Gen3Show.show = function(g, mon, done)
+ done()
+ error("after completion")
+end
+local ok, err = pcall(function()
+ world:flyTo("DEST")
+end)
+T.check(
+ not ok and tostring(err):find("after completion", 1, true) ~= nil,
+ "native presentation error is propagated"
+)
+T.eq(#calls, 2, "presentation failure never retries a completed departure")
+Gen3Show.show = function()
+ return false
+end
+ow.flyAnim = nil
+-- Hooks cannot recursively start a second action or move the source unnoticed.
+ow.partyKnows = function()
+ T.eq(world:reorderParty(1, 2), nil, "nested mutation during eligibility is refused")
+ return game.save.party[1]
+end
+T.eq(world:canFly(), true, "recursive capability probe terminates")
+ow.partyKnows = function()
+ ow.player.cellX = ow.player.cellX + 1
+ return game.save.party[1]
+end
+T.eq(world:flyTo("DEST"), nil, "eligibility hook changing source position cancels action")
+T.eq(#calls, 2, "changed source never reaches native handler")
+
+local nestedApi, nestedGame, nestedWorld = boot("red")
+nestedWorld.flyTo = function()
+ T.eq(nestedApi.world:reorderParty(1, 2), nil, "native dispatch callback cannot reorder concurrently")
+ T.eq(nestedApi.world:flyTo("DEST"), nil, "native dispatch callback cannot start a second flight")
+ nestedWorld.flyAnim = {}
+end
+T.eq(nestedApi.world:flyTo("DEST"), true, "guarded native dispatch still starts once")
+nestedWorld.flyAnim = nil
+local nativeError = { native = true }
+nestedWorld.flyTo = function()
+ error(nativeError)
+end
+local okNative, errNative = pcall(function()
+ nestedApi.world:flyTo("DEST")
+end)
+T.check(not okNative and errNative == nativeError, "native error identity retained")
+T.eq(nestedApi.world:canReorderParty(), true, "throwing handler releases only the API dispatch lock")
+
+local empty = WorldAPI.new(nil, "empty")
+T.eq(empty:reorderParty(1, 2), nil, "missing game mutation safe")
+T.eq(empty:canFly(), false, "missing game probe safe")
+T.same(empty:availableFieldActions(), {}, "missing game has no field actions")
+for _, name in ipairs({
+ "canReorderParty",
+ "reorderParty",
+ "availableFieldActions",
+ "useFieldAction",
+ "canFly",
+ "flyTo",
+}) do
+ T.check(Actions.supports(name), "action capability " .. name)
+end
+T.eq(Actions.supports("party"), false, "reader capability remains separate")
+GameVersion.set(saved[1])
+Runtime.install(saved[2], saved[3], saved[4])
+love = saved[5]
+Gen3Show.show = saved[6]
+T.finish()
diff --git a/tests/engine/world_overview_api.lua b/tests/engine/world_overview_api.lua
new file mode 100644
index 000000000..dab0d78bd
--- /dev/null
+++ b/tests/engine/world_overview_api.lua
@@ -0,0 +1,660 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.modkit")
+local Map = require("src.world.Map")
+local WorldAPI = require("src.world.WorldAPI")
+local Encounter = require("src.world.Encounter")
+local Runtime = require("src.mods.Runtime")
+local Hooks = require("src.mods.Hooks")
+local GameVersion = require("src.core.GameVersion")
+local Assets = require("src.render.Assets")
+local Loader = require("src.mods.Loader")
+local Json = require("src.link.Json")
+local savedVersion, savedHooks = GameVersion.get(), Runtime.hooks
+local function copy(t, seen)
+ if type(t) ~= "table" then
+ return t
+ end
+ seen = seen or {}
+ if seen[t] then
+ return seen[t]
+ end
+ local out = {}
+ seen[t] = out
+ for k, v in pairs(t) do
+ out[copy(k, seen)] = copy(v, seen)
+ end
+ return out
+end
+-- Exact reference/key/metatable freeze; cycles (Game -> world -> Game) are real.
+local function freeze(root)
+ local graph = {}
+ local function walk(t)
+ if type(t) ~= "table" or graph[t] then
+ return
+ end
+ local keys = {}
+ graph[t] = { keys = keys, mt = getmetatable(t) }
+ for k, v in pairs(t) do
+ keys[k] = v
+ walk(k)
+ walk(v)
+ end
+ end
+ walk(root)
+ return function(label)
+ for t, before in pairs(graph) do
+ T.eq(getmetatable(t), before.mt, label .. " metatable")
+ for k, v in pairs(before.keys) do
+ T.eq(rawget(t, k), v, label .. " value")
+ end
+ for k, v in pairs(t) do
+ T.eq(before.keys[k], v, label .. " no new key")
+ end
+ end
+ end
+end
+local function pack(...)
+ local out = {}
+ for _, n in ipairs({ ... }) do
+ out[#out + 1] = string.char(n % 256, math.floor(n / 256))
+ end
+ return table.concat(out)
+end
+local function uniform(n)
+ local out = {}
+ for i = 1, 16 do
+ out[i] = n
+ end
+ return out
+end
+local function scene(version)
+ GameVersion.set(version)
+ local gen = GameVersion.generation()
+ local def = {
+ id = "ACTIVE",
+ width = 2,
+ height = 2,
+ borderBlock = 0,
+ blocks = { 1, 2, 3, 4 },
+ warps = { { x = 1, y = 1 }, { x = -1, y = 1 } },
+ signs = {
+ { x = 0, y = 1, item = "POTION", eventFlag = "FOUND" },
+ {
+ x = 1,
+ y = 0,
+ item = "POTION",
+ eventFlag = "HIDDEN",
+ },
+ },
+ }
+ local ts = {
+ blockCells = 2,
+ blockTiles = 4,
+ blocks = { uniform(0), uniform(0), uniform(1), uniform(2), uniform(3) },
+ walkable = { 0, 2 },
+ waterTiles = { 1 },
+ shoreTiles = {},
+ warpTiles = { 3 },
+ image = "overview-test.png",
+ tilesPerRow = 4,
+ }
+ if gen == 2 then
+ ts.collision = { 255, 255, 255, 255, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3 }
+ elseif gen >= 3 then
+ def.blocks = pack(1 + 1024, 2 + 4096, 3, 4)
+ def.collisionCells = { 0, 0, 1, 0 }
+ def.elevationCells = gen == 3 and { 3, 1, 3, 3 } or nil
+ ts.blockCells, ts.blockTiles, ts.blocks, ts.collision = 1, 2, nil, { 0, 0, 1, 2, 3 }
+ ts.behaviourBytes = true
+ ts.warpsAreEvents = true
+ if gen >= 4 then
+ def.generation = 4
+ def.gen5 = gen == 5 or nil
+ def.behaviorCells = string.char(0, gen == 5 and 0x3D or 0x10, 0, 0)
+ end
+ end
+ local map = Map.new(def, ts)
+ local ow = {
+ isOverworld = true,
+ map = map,
+ npcs = {
+ { def = { item = "POTION" }, cellX = 0, cellY = 0 },
+ { def = { item = "POTION" }, cellX = 1, cellY = 0, hidden = true },
+ { def = { item = "POTION", fruitTree = true }, cellX = 0, cellY = 1 },
+ { def = { item = 0 }, cellX = 0, cellY = 1 },
+ { def = { item = "POTION" }, cellX = 99, cellY = 99 },
+ },
+ tod = "NITE",
+ player = { cellX = 0, cellY = 0 },
+ }
+ local game = {
+ data = {
+ maps = { ACTIVE = def },
+ field = { hiddenItems = { ACTIVE = { { x = 0, y = 0 }, { x = 1, y = 1 } } } },
+ },
+ save = { flags = { HIDDEN = true }, hiddenTaken = { ACTIVE_1_1 = true } },
+ overworld = ow,
+ stack = { states = { ow, { isMenu = true } } },
+ }
+ ow.game = game
+ return WorldAPI.new(game, "probe"), game, ow
+end
+local savedLoveRandom, savedMathRandom = love.math.random, math.random
+local draws = 0
+love.math.random = function()
+ draws = draws + 1
+ error("query consumed love RNG")
+end
+math.random = function()
+ draws = draws + 1
+ error("query consumed math RNG")
+end
+Runtime.hooks = Hooks.new()
+local callbacks = 0
+for _, name in ipairs({ "encounter.roll", "encounter.species", "encounter.table" }) do
+ Runtime.hooks:wrap(name, function()
+ callbacks = callbacks + 1
+ error("query executed encounter hook")
+ end)
+end
+for _, version in ipairs({
+ "red",
+ "gold",
+ "silver",
+ "crystal",
+ "emerald",
+ "firered",
+ "platinum",
+ "heartgold",
+ "black",
+ "white",
+}) do
+ local api, game, ow = scene(version)
+ for _, warm in ipairs({ false, true }) do
+ if warm then
+ ow.map:blockAt(0, 0)
+ end
+ local stable = freeze(game)
+ local out = assert(api:mapOverview())
+ stable(version .. " overview")
+ T.eq(out.mapId, "ACTIVE", version .. " map ID")
+ T.eq(out.width, ow.map.widthCells, version .. " width")
+ T.eq(#out.rows, out.height, version .. " rows")
+ -- Native classifiers on an independent map give the exact terrain contract.
+ local native = Map.new(copy(ow.map.def), copy(ow.map.tileset))
+ for y = 0, out.height - 1 do
+ T.eq(#out.rows[y + 1], out.width, version .. " row width")
+ for x = 0, out.width - 1 do
+ local expected = native:isWarpTileCell(x, y) and "+"
+ or native:isWaterCell(x, y) and "~"
+ or native:isWalkableCell(x, y) and "."
+ or " "
+ T.eq(out.rows[y + 1]:sub(x + 1, x + 1), expected, version .. " native terrain")
+ end
+ end
+ T.same(out.markers, {
+ { kind = "warp", x = 1, y = 1 },
+ { kind = "item", x = 0, y = 0 },
+ { kind = "hidden", x = 0, y = 0 },
+ {
+ kind = "hidden",
+ x = 0,
+ y = 1,
+ },
+ }, version .. " only active markers")
+ out.markers[1].x = 999
+ out.rows[1] = "corrupted"
+ T.eq(api:mapOverview().markers[1].x, 1, version .. " detached markers")
+ end
+ local old = assert(api:mapOverview())
+ ow.npcs = {}
+ game.save.flags.FOUND = true
+ game.save.hiddenTaken.ACTIVE_0_0 = true
+ T.eq(#api:mapOverview().markers, 1, version .. " live removals reflected")
+ T.eq(#old.markers, 4, version .. " old snapshot remains detached")
+ ow.map:setBlock(0, 0, 0, true)
+ local stable = freeze(game)
+ local patched = assert(api:mapOverview())
+ stable(version .. " patched map")
+ T.eq(patched.rows[1]:sub(1, 1), " ", version .. " live shut cell")
+ if GameVersion.generation() >= 4 then
+ ow.map.def.behaviorCells = nil
+ local result, why = api:mapOverview()
+ T.eq(result, nil, version .. " streamed raster refused")
+ T.check(why:find("streamed", 1, true), version .. " streamed reason")
+ end
+end
+-- Literal terrain reference: whitespace is an impassable cell, not a missing row.
+do
+ local api = scene("red")
+ T.same(api:mapOverview().rows, { "..~~", "..~~", "..++", "..++" }, "GB literal terrain grid")
+end
+-- GB visuals use actual 8x8 pixel means/quadrants; no placeholder art on failure.
+do
+ local api, game, ow = scene("gold")
+ local saved = love.image
+ love.image = {}
+ local reads = 0
+ love.image.newImageData = function(path)
+ reads = reads + 1
+ T.eq(path, "overview-test.png", "resolved image path")
+ return {
+ getDimensions = function()
+ return 32, 8
+ end,
+ getPixel = function(_, x, y)
+ local shade = math.floor(x / 8)
+ if shade == 2 then
+ shade = (x % 8 < 4 and 0 or 3)
+ end
+ local v = 1 - shade / 3
+ return v, v, v, 1
+ end,
+ }
+ end
+ Assets.invalidate()
+ local stable = freeze(game)
+ local out = assert(api:mapOverview())
+ stable("visual pure")
+ T.eq(out.tileRows[1], "00001111", "literal GB tile shades")
+ T.eq(out.tileRows[5], "22223333", "literal averaged split tile")
+ T.eq(out.tileDetailRows[9], "0303030333333333", "literal GB quadrants")
+ T.eq(out.tileWidth, 8, "2x tile width")
+ T.eq(out.tileDetailWidth, 16, "4x detail width")
+ out.tileRows[1] = "broken"
+ out.tileDetailRows[1] = "broken"
+ T.eq(api:mapOverview().tileRows[1], "00001111", "detached visual rows")
+ T.eq(reads, 1, "visual cache reused")
+ Assets.invalidate()
+ api:mapOverview()
+ T.eq(reads, 2, "asset reload invalidates visual cache")
+ love.image.newImageData = function()
+ error("missing image")
+ end
+ Assets.invalidate()
+ T.eq(api:mapOverview().tileRows, nil, "unavailable art is omitted")
+ love.image = saved
+ Assets.invalidate()
+end
+local function tableDef(rate, buckets, species)
+ local slots = {}
+ for i, id in ipairs(species or { "RATTATA", "PIDGEY", "RATTATA" }) do
+ slots[i] = { species = id, min = 2, max = 7 }
+ end
+ return { rate = rate, buckets = buckets or { 51, 102, 256 }, slots = slots }
+end
+local function encounters(version)
+ GameVersion.set(version)
+ local a = tableDef(60)
+ a.byTime = {
+ morn = tableDef(80, nil, { "PIDGEY", "PIDGEY", "RATTATA" }),
+ nite = tableDef(20, nil, { "HOOTHOOT", "RATTATA", "HOOTHOOT" }),
+ }
+ local maps = {
+ A = { id = "A", label = "RouteA", encounters = GameVersion.generation() >= 4 and 7 or nil },
+ B = {
+ id = "B",
+ },
+ }
+ local data = {
+ maps = maps,
+ encounters = {
+ [maps.A.encounters or "A"] = {
+ grass = a,
+ water = tableDef(10, { 128, 256 }, { "MAGIKARP", "POLIWAG" }),
+ },
+ },
+ field = {},
+ pokemon = {
+ RATTATA = { name = "Rattata" },
+ PIDGEY = { name = "Pidgey" },
+ HOOTHOOT = { name = "Hoothoot" },
+ MAGIKARP = { name = "Magikarp" },
+ POLIWAG = { name = "Poliwag" },
+ },
+ }
+ local game = { data = data, save = { party = {}, flags = {} }, stack = { states = {} } }
+ return WorldAPI.new(game, "probe"), game, a
+end
+local function nativeParity(api, game, opts)
+ local hour = opts.daytime == "NITE" and 22 or opts.daytime == "MORNING" and 6 or 12
+ local enc = Encounter.forMap(game.data, game.data.maps.A, "A", hour, game.save)
+ local slots = Encounter.atTime(enc.grass, opts.daytime)
+ local counts = {}
+ Encounter.load(game.data)
+ for pick = 0, (slots.bucketSpan or 256) - 1 do
+ local n = 0
+ local result = Encounter.chooseTable(slots, function(lo)
+ n = n + 1
+ if n == 1 then
+ return pick
+ end
+ return lo
+ end)
+ if result then
+ counts[tostring(result.species)] = (counts[tostring(result.species)] or 0) + 1
+ end
+ end
+ local stable = freeze(game)
+ local out = assert(api:effectiveEncounters("A", "grass", opts))
+ stable("encounter graph")
+ T.same(out.dist, counts, "native exhaustive bucket distribution")
+ local passed = 0
+ for gate = 0, (slots.rateMax or 256) - 1 do
+ local n = 0
+ local result = Encounter.rollTable(slots, function(lo)
+ n = n + 1
+ if n == 1 then
+ return gate
+ end
+ return lo
+ end)
+ if result then
+ passed = passed + 1
+ end
+ end
+ T.eq(out.chance, passed / (slots.rateMax or 256), "native exhaustive rate gate")
+ return out
+end
+for _, version in ipairs({ "red", "gold", "silver", "crystal", "emerald", "firered" }) do
+ local api, game = encounters(version)
+ for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do
+ nativeParity(api, game, { daytime = period })
+ end
+ local out = api:effectiveEncounters("A", "grass", { daytime = "DAY" })
+ T.same(out.dist, { RATTATA = 205, PIDGEY = 51 }, version .. " duplicate species aggregated")
+ out.dist.RATTATA = 0
+ T.eq(api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, 205, "distribution detached")
+ T.same(
+ api:effectiveEncounters("A", "indoor", { daytime = "NITE" }),
+ api:effectiveEncounters("A", "grass", { daytime = "NITE" }),
+ "indoor grass alias"
+ )
+ T.same(
+ api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist,
+ { MAGIKARP = 128, POLIWAG = 128 },
+ "water table"
+ )
+ T.same(api:effectiveEncounters("B", "grass"), { chance = 0, dist = {} }, "known map with no encounters")
+ T.eq(api:effectiveEncounters("missing", "grass"), nil, "unknown map refused")
+ for _, bad in ipairs({ "fishing", "darkGrass", false }) do
+ T.eq(api:effectiveEncounters("A", bad), nil, "terrain refused")
+ end
+ T.eq(api:effectiveEncounters("A", "grass", "NITE"), nil, "options must be table")
+ T.eq(api:effectiveEncounters("A", "grass", { daytime = "dusk" }), nil, "invalid time refused")
+ T.same(
+ api:effectiveEncounters("A", "grass", { daytime = "night" }),
+ api:effectiveEncounters("A", "grass", { daytime = "NITE" }),
+ "time aliases"
+ )
+ game.data.encounters = nil
+ T.eq(api:effectiveEncounters("A", "grass"), nil, "unloaded data refused")
+end
+-- Gen2 outbreaks use the same native map selection for all three cartridges.
+for _, version in ipairs({ "gold", "silver", "crystal" }) do
+ local api, game = encounters(version)
+ game.data.field.gen2Swarms = {
+ maps = { RouteA = { grass = tableDef(200, nil, { "HOOTHOOT", "HOOTHOOT", "HOOTHOOT" }) } },
+ flags = { [0] = 17 },
+ }
+ game.save.g2Swarm = { kind = 0, map = "A" }
+ if version == "crystal" then
+ game.save.flags[require("src.script.Gen2Flags").scriptFlag(17)] = true
+ end
+ nativeParity(api, game, { daytime = "DAY" })
+ T.same(
+ api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist,
+ { HOOTHOOT = 256 },
+ version .. " outbreak table"
+ )
+ game.save.g2Swarm = nil
+ T.eq(
+ api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA,
+ 205,
+ "outbreak ends immediately"
+ )
+end
+-- DS rates/bucket spans, time substitutions, daily replacements and seasons.
+for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do
+ local api, game = encounters(version)
+ local slots = {}
+ for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do
+ slots[i] = { species = i % 2 == 0 and "PIDGEY" or "RATTATA", level = 5, chance = chance }
+ end
+ local raw = {
+ grassRate = 30,
+ grass = slots,
+ surf = {
+ rate = 20,
+ slots = { { species = "MAGIKARP", level = 5, chance = 100 } },
+ },
+ }
+ game.data.encounters[7] = raw
+ if version == "black" or version == "white" then
+ raw.family = "bw"
+ raw.seasons =
+ { winter = { grassRate = 50, grass = { { species = "HOOTHOOT", level = 5, chance = 100 } } } }
+ game.save.gen5Season = 3
+ end
+ for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do
+ nativeParity(api, game, { daytime = period })
+ end
+ if raw.family == "bw" then
+ T.same(api:effectiveEncounters("A", "grass").dist, { HOOTHOOT = 100 }, "BW current season")
+ game.save.gen5Season = 0
+ T.eq(api:effectiveEncounters("A", "grass").chance, 0.3, "BW season changes immediately")
+ end
+end
+-- Cold DS resolver views: timed slots and daily replacements are reflected
+-- without initializing save state or exposing mutable source slot records.
+for _, version in ipairs({ "platinum", "heartgold" }) do
+ local api, game = encounters(version)
+ local slots = {}
+ for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do
+ slots[i] = { species = "RATTATA", level = 5, chance = chance }
+ end
+ local raw = { grassRate = 30, grass = slots, day = { 25, 25 }, night = { 163, 163 }, swarm = { 129, 129 } }
+ if version == "heartgold" then
+ raw.timed = { day = {}, night = {} }
+ for i = 1, 12 do
+ raw.timed.day[i] = 25
+ raw.timed.night[i] = 163
+ end
+ end
+ game.data.pokemon[25] = { name = "Pikachu" }
+ game.data.pokemon[163] = { name = "Hoothoot" }
+ game.data.pokemon[129] = { name = "Magikarp" }
+ game.data.encounters[7] = raw
+ local stable = freeze(game)
+ local day = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" }))
+ local night = assert(api:effectiveEncounters("A", "grass", { daytime = "NITE" }))
+ T.eq(day.dist.PIKACHU, version == "heartgold" and 100 or 20, "DS time species day")
+ T.eq(night.dist.HOOTHOOT, version == "heartgold" and 100 or 20, "DS time species night")
+ stable("cold DS timed query")
+ local Swarms = require("src.world.Gen4Swarms")
+ game.data.maps.A.header = Swarms.HEADERS[1]
+ game.save.gen4Swarm = { enabled = true, daily = 0, rand = 123 }
+ stable = freeze(game)
+ local outbreak = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" }))
+ T.eq(outbreak.dist.MAGIKARP, 40, "DS daily swarm slots")
+ stable("DS daily swarm read")
+ outbreak.dist.MAGIKARP = 0
+ T.eq(
+ api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.MAGIKARP,
+ 40,
+ "DS distribution detached"
+ )
+ game.save.gen4Swarm.enabled = false
+ if version == "platinum" then
+ game.data.maps.A.header = 287
+ game.save.pokedex = { national = true }
+ game.save.gen4TrophyGarden = { slot1 = 0, slot2 = 1 }
+ game.data.gen4_special_encounters = { trophyGarden = { 25, 163 } }
+ stable = freeze(game)
+ local garden = assert(api:effectiveEncounters("A", "grass", { daytime = "MORNING" }))
+ T.eq(garden.dist.PIKACHU, 5, "garden first daily slot")
+ T.eq(garden.dist.HOOTHOOT, 5, "garden second daily slot")
+ stable("DS garden read")
+ end
+end
+-- A water table is not time-swapped by the native step path.
+do
+ local api, game = encounters("gold")
+ game.data.encounters.A.water.byTime = { nite = tableDef(255, { 256 }, { "HOOTHOOT" }) }
+ T.same(
+ api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist,
+ { MAGIKARP = 128, POLIWAG = 128 },
+ "water ignores land time selector"
+ )
+end
+-- Numeric IDs follow the shared public name contract, including loader aliases.
+do
+ local api, game = encounters("platinum")
+ game.data.pokemon[25] = { id = 25, name = "Pikachu" }
+ game.data.encounters[7] = { grass = tableDef(50, { 256 }, { 25 }) }
+ T.same(api:effectiveEncounters("A", "grass").dist, { PIKACHU = 256 }, "numeric species -> portable ID")
+end
+-- Default Gen2 period follows the clock, and queries never run world.tod hooks.
+do
+ local api, game = encounters("gold")
+ local date = os.date
+ os.date = function(format)
+ if format == "%H" then
+ return "23"
+ end
+ return date(format)
+ end
+ T.same(
+ api:effectiveEncounters("A", "grass"),
+ api:effectiveEncounters("A", "grass", { daytime = "NITE" }),
+ "live Gen2 clock"
+ )
+ Runtime.hooks:wrap("world.tod", function()
+ callbacks = callbacks + 1
+ end)
+ game.overworld = { isOverworld = true, map = { id = "A" }, tod = "MORNING" }
+ game.stack.states = { game.overworld }
+ T.same(
+ api:effectiveEncounters("A", "grass"),
+ api:effectiveEncounters("A", "grass", { daytime = "MORNING" }),
+ "cached effective mod period"
+ )
+ os.date = date
+end
+-- Malformed tables and exceptional positional tables fail explicitly.
+do
+ local api, game, a = encounters("red")
+ for _, bad in ipairs({ 0, -1, math.huge, 0 / 0 }) do
+ a.rateMax = bad
+ T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid denominator")
+ end
+ a.rateMax = nil
+ a.buckets = { 51, 50, 256 }
+ T.eq(api:effectiveEncounters("A", "grass"), nil, "descending buckets refused")
+ a.buckets = { 51, 102, 257 }
+ T.eq(api:effectiveEncounters("A", "grass"), nil, "unreachable bucket refused")
+ a.buckets = "bad"
+ T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid bucket type refused")
+ a.buckets = { 51, 102, 256 }
+ local originalSlots = a.slots
+ a.slots = "bad"
+ T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid slot type refused")
+ a.slots = originalSlots
+ a.buckets = { 51, 102, 256 }
+ a.rate = 0
+ T.same(api:effectiveEncounters("A", "grass"), { chance = 0, dist = {} }, "zero rate")
+ a.rate = 999
+ T.eq(api:effectiveEncounters("A", "grass").chance, 1, "saturated rate")
+ GameVersion.set("heartgold")
+ local S = require("src.world.HgssSafariZone")
+ game.data.maps.A.layout = S.LAYOUT
+ game.data.maps.A.cameraFamily = "hgss"
+ local stable = freeze(game)
+ T.eq(api:effectiveEncounters("A", "grass"), nil, "Safari query refused")
+ stable("Safari does not seed save")
+ GameVersion.set("emerald")
+ for _, id in ipairs({
+ require("src.script.Gen3BattlePike").WILD_ROOM,
+ require("src.script.Gen3BattlePyramid").FLOOR,
+ }) do
+ game.data.maps[id] = { id = id }
+ T.eq(api:effectiveEncounters(id, "grass"), nil, "facility query refused")
+ end
+end
+-- API1/2 native mod loading, lazy capability queries and live table patches.
+for _, apiVersion in ipairs({ 1, 2 }) do
+ GameVersion.set("gold")
+ local data = T.fixtures.fresh()
+ local files = {
+ ["mods/probe/manifest.json"] = Json.encode({
+ id = "probe",
+ name = "Probe",
+ version = "1.0.0",
+ api = apiVersion,
+ entry = "main.lua",
+ content_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified"
+ or nil,
+ callback_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified"
+ or nil,
+ script_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" or nil,
+ }),
+ ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end",
+ }
+ local fs = {
+ read = function(p)
+ return files[p]
+ end,
+ load = function(p)
+ return loadstring(files[p], p)
+ end,
+ getDirectoryItems = function(p)
+ return p == "mods" and { "probe" } or {}
+ end,
+ getInfo = function(p)
+ if p == "mods" or p == "mods/probe" then
+ return { type = "directory" }
+ elseif files[p] then
+ return { type = "file" }
+ end
+ end,
+ }
+ local loader = Loader.new({ fs = fs, dev = false })
+ local resolves = 0
+ loader._game = function(self)
+ resolves = resolves + 1
+ return self.game
+ end
+ T.check(loader:load(data), "real loader succeeds")
+ local mod = loader.apis.probe
+ if os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 then
+ T.eq(mod.contentFormat, "unified", "unified content facade")
+ T.eq(mod.callbackFormat, "unified", "unified callback facade")
+ T.eq(mod.scriptFormat, "unified", "portable script facade")
+ end
+ T.eq(resolves, 0, "entry and ID context stay lazy")
+ if mod.supports then
+ T.eq(mod:supports("world", "mapOverview"), true, "overview capability")
+ T.eq(mod:supports("world", "effectiveEncounters"), true, "encounter capability")
+ T.eq(resolves, 0, "capability query stays lazy")
+ end
+ loader.game = { data = data, save = {}, stack = { states = {} } }
+ local world = mod.world
+ T.eq(world:mapOverview(), nil, "title screen overview is quiet")
+ local out = assert(world:effectiveEncounters("FIX_ROUTE", "grass"))
+ T.check(next(out.dist) ~= nil, "query available at title screen")
+ local before = out.chance
+ local id = next(out.dist)
+ data.pokemon[id].name = "Renamed presentation"
+ T.check(
+ world:effectiveEncounters("FIX_ROUTE", "grass").dist[id] ~= nil,
+ "public identity survives name patch"
+ )
+ data.encounters.FIX_ROUTE.grass.rate = 100
+ T.check(world:effectiveEncounters("FIX_ROUTE", "grass").chance ~= before, "live data patch reflected")
+end
+T.eq(draws, 0, "all queries consume zero RNG draws")
+T.eq(callbacks, 0, "all queries execute zero hook callbacks")
+love.math.random, math.random = savedLoveRandom, savedMathRandom
+Runtime.hooks = savedHooks
+GameVersion.set(savedVersion)
+T.finish("world overview and encounter API")
diff --git a/tests/engine/world_read_api.lua b/tests/engine/world_read_api.lua
new file mode 100644
index 000000000..b7beda07f
--- /dev/null
+++ b/tests/engine/world_read_api.lua
@@ -0,0 +1,415 @@
+-- ROM-free tests through the real loader and real runtime Map objects.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local T = require("tests.harness")
+local Loader = require("src.mods.Loader")
+local WorldAPI = require("src.world.WorldAPI")
+local Map = require("src.world.Map")
+local MapLoader = require("src.world.MapLoader")
+local Merge = require("src.mods.Merge")
+local Runtime = require("src.mods.Runtime")
+local GameVersion = require("src.core.GameVersion")
+local Json = require("src.link.Json")
+local Reads = require("src.mods.WorldReads")
+local savedLove, savedVersion = love, GameVersion.get()
+local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, Runtime.errors
+love = nil
+local function pack16(...)
+ local out = {}
+ for _, v in ipairs({ ... }) do
+ out[#out + 1] = string.char(v % 256, math.floor(v / 256))
+ end
+ return table.concat(out)
+end
+local function map(id, cells, packed, ds)
+ return Map.new({
+ id = id,
+ width = 3,
+ height = 2,
+ blocks = packed and pack16(0, 1 + 3 * 1024, 2 + 9 * 4096, 3, 4, 1023) or { 0, 1, 2, 3, 4, 1023 },
+ borderBlock = 999,
+ generation = ds and 4 or nil,
+ }, { blockCells = cells, blockTiles = cells * 2, walkable = {}, blocks = {} })
+end
+local function boot(version, entry)
+ GameVersion.set(version)
+ local gen = GameVersion.generation()
+ local s, m, item = gen >= 4 and 25 or "PIKACHU", gen >= 4 and 33 or "TACKLE", gen >= 4 and 13 or "POTION"
+ local species = { id = s, name = "Pikachu" }
+ local move = { id = m, name = "Tackle", pp = 35 }
+ local data = {
+ pokemon = { [s] = species },
+ moves = { [m] = move },
+ items = {
+ [item] = { id = item, name = "Potion" },
+ },
+ }
+ if gen >= 4 then
+ data.pokemon.SPECIES_25 = species
+ data.moves.MOVE_33 = move
+ end
+ local mon = {
+ species = s,
+ item = item,
+ nickname = "Buddy",
+ name = "native name",
+ level = 7,
+ hp = 19,
+ status = "PSN",
+ stats = { hp = 23, attack = 10, defense = 11, speed = 12, special = 13 },
+ moves = { { id = m, pp = 29, ppUps = 1, hidden = false } },
+ ivs = { hp = 27 },
+ evs = { speed = 3 },
+ personality = 12345,
+ extension = { flag = false },
+ isEgg = false,
+ }
+ if gen > 1 then
+ mon.stats.spatk = 13
+ mon.stats.spdef = 14
+ end
+ local active = map("ACTIVE", gen <= 2 and 2 or 1, gen >= 3, gen >= 4)
+ local neighbor = map("NORTH", gen <= 2 and 2 or 1, gen >= 3, gen >= 4)
+ local ow = { isOverworld = true, map = active, neighbors = { { map = neighbor, cx = -2, cy = -4 } } }
+ local game = {
+ data = data,
+ save = { party = { mon }, money = 500 },
+ stack = { states = { ow, { isMenu = true } } },
+ overworld = ow,
+ }
+ local files = {
+ ["mods/probe/manifest.json"] = Json.encode({
+ id = "probe",
+ name = "Probe",
+ version = "1.0.0",
+ api = 2,
+ entry = "main.lua",
+ }),
+ ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end",
+ }
+ local fs = {
+ read = function(p)
+ return files[p]
+ end,
+ load = function(p)
+ return loadstring(files[p], p)
+ end,
+ getDirectoryItems = function(p)
+ return p == "mods" and { "probe" } or {}
+ end,
+ getInfo = function(p)
+ if p == "mods" or p == "mods/probe" then
+ return { type = "directory" }
+ end
+ if files[p] then
+ return { type = "file" }
+ end
+ end,
+ }
+ local loader = Loader.new({ fs = fs, dev = false })
+ local resolves = 0
+ loader._game = function()
+ resolves = resolves + 1
+ return game
+ end
+ assert(loader:load(data), table.concat(loader.errors, "; "))
+ local api = assert(loader.apis.probe)
+ return api, loader, game, ow, { s, m, item }, function()
+ return resolves
+ end
+end
+
+-- Fail if any reader tries to load a map, generate a Pokemon or use RNG.
+local oldMapLoad = MapLoader.load
+local Pokemon = require("src.pokemon.Pokemon")
+local oldNew = Pokemon.new
+local oldRandom = math.random
+local function forbidden()
+ error("read crossed into gameplay")
+end
+MapLoader.load, Pokemon.new, math.random = forbidden, forbidden, forbidden
+
+for _, version in ipairs(GameVersion.ORDER) do
+ local api, loader, game, ow, ids, resolves = boot(version)
+ local label = version .. ": "
+ T.eq(resolves(), 0, label .. "entry and read metadata do not instantiate Game")
+ if api.supports then
+ for _, name in ipairs({ "party", "liveMaps", "activeBlockAt" }) do
+ T.eq(api:supports("world", name), true, label .. "capability " .. name)
+ end
+ T.eq(resolves(), 0, label .. "capability probes do not instantiate Game")
+ end
+ local before = Merge.deepCopy(game)
+ local world = api.world
+ T.eq(api.world, world, label .. "same cached facade")
+ local party = world:party()
+ local mon = party[1]
+ T.eq(mon.species, "PIKACHU", label .. "symbolic species")
+ T.eq(mon.item, "POTION", label .. "symbolic item")
+ T.eq(mon.moves[1].id, "TACKLE", label .. "symbolic move")
+ T.eq(mon.moves[1].pp, 29, label .. "current PP")
+ T.eq(mon.moves[1].maxPp, 42, label .. "PP Ups use native rounding")
+ T.eq(mon.moves[1].ppUps, 1, label .. "PP Ups exposed")
+ T.eq(mon.maxHp, 23, label .. "max HP from stats.hp")
+ T.eq(mon.stats.specialAttack, 13, label .. "special attack")
+ T.eq(mon.stats.specialDefense, GameVersion.generation() == 1 and 13 or 14, label .. "special defense")
+ T.eq(mon.status, "poison", label .. "status normalized")
+ T.eq(mon.isEgg, false, label .. "false flags preserved")
+ T.eq(mon.native.extension.flag, false, label .. "native false extension preserved")
+ T.eq(mon.native.ivs.hp, 27, label .. "native IV diagnostics")
+ T.eq(getmetatable(mon), nil, label .. "plain detached mon")
+ mon.species = "OTHER"
+ mon.stats.hp = 1
+ mon.moves[1].pp = 0
+ mon.native.ivs.hp = 0
+ party[2] = { species = "INJECTED" }
+ T.same(game, before, label .. "party mutations do not reach game")
+ local nextParty = world:party()
+ T.eq(nextParty[1].stats.hp, 23, label .. "fresh nested stat snapshot")
+ T.eq(nextParty[1].moves[1].pp, 29, label .. "fresh move snapshot")
+ T.eq(#nextParty, 1, label .. "party membership detached")
+ game.save.party[1].hp = 18
+ T.eq(world:party()[1].hp, 18, label .. "reads current native HP")
+ game.save = { party = { { species = ids[1], hp = 1, stats = { hp = 2 }, moves = {} } } }
+ T.eq(world:party()[1].hp, 1, label .. "held facade follows a replaced save")
+ game.save = before.save
+
+ local cells = ow.map.blockCells
+ before = Merge.deepCopy(game)
+ local id, metadata = world:activeBlockAt("ACTIVE", 0, 0)
+ T.eq(id, 0, label .. "block zero is present")
+ T.eq(metadata.tileSize, cells * 16, label .. "block pixel size from runtime map")
+ T.eq(metadata.cellSize, 16, label .. "walk-cell pixel size")
+ T.eq(metadata.coordinateBase, 0, label .. "zero-based block coordinates")
+ T.eq(metadata.width, 3, label .. "block width")
+ T.eq(metadata.height, 2, label .. "block height")
+ local gen = GameVersion.generation()
+ T.eq(
+ metadata.kind,
+ gen >= 4 and "runtime_cell" or gen >= 3 and "metatile" or "block",
+ label .. "honest block kind"
+ )
+ T.eq(world:activeBlockAt("ACTIVE", 1, 0), 1, label .. "packed collision bits stripped")
+ T.eq(world:activeBlockAt("ACTIVE", 2, 0), 2, label .. "packed elevation bits stripped")
+ T.eq(world:activeBlockAt("ACTIVE", 2, 1), 1023, label .. "last block / full 10-bit ID")
+ T.eq(ow.map.def._blockArray, nil, label .. "cold packed grid remains undecoded")
+ metadata.width = 999
+ T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)).width, 3, label .. "metadata detached")
+ for _, xy in ipairs({
+ { -1, 0 },
+ { 0, -1 },
+ { 3, 0 },
+ { 0, 2 },
+ { 0.5, 0 },
+ { 0, 0.5 },
+ { math.huge, 0 },
+ {
+ 0,
+ 0 / 0,
+ },
+ {
+ "1",
+ 0,
+ },
+ }) do
+ local value, reason = world:activeBlockAt("ACTIVE", xy[1], xy[2])
+ T.check(value == nil and type(reason) == "string", label .. "invalid/off-map coordinates rejected")
+ end
+ local missing, reason = world:activeBlockAt("NORTH", 0, 0)
+ T.check(missing == nil and reason == "map is not active", label .. "stale map rejected")
+ T.same(game, before, label .. "block reads do not alter world or decoder cache")
+ ow.map:setBlock(1, 0, 0)
+ local patched = Merge.deepCopy(game)
+ T.eq(world:activeBlockAt("ACTIVE", 1, 0), 0, label .. "live zero-valued block patch wins")
+ T.same(game, patched, label .. "patch read remains pure")
+ ow.map.blockPatch[2] = 777
+ T.eq(world:activeBlockAt("ACTIVE", 1, 0), 777, label .. "reads current runtime patch")
+ local oldMap = ow.map
+ ow.map = ow.neighbors[1].map
+ T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "held facade rejects map after warp")
+ T.eq(world:activeBlockAt("NORTH", 0, 0), 0, label .. "held facade reads new active map")
+ ow.map = oldMap
+
+ before = Merge.deepCopy(game)
+ local legacy = world:loadedMaps()
+ local maps = world:liveMaps()
+ T.eq(#maps, 2, label .. "only loaded maps returned")
+ T.eq(maps[1].mapId, "ACTIVE", label .. "active first")
+ T.eq(maps[1].active, true, label .. "active tagged")
+ T.eq(maps[1].width, 3 * cells, label .. "map dimensions in cells")
+ T.eq(maps[2].mapId, "NORTH", label .. "loaded neighbor included")
+ T.eq(maps[2].x, -2, label .. "neighbor x offset in cells")
+ T.eq(maps[2].y, -4, label .. "neighbor y offset in cells")
+ T.eq(maps[2].block.tileSize, cells * 16, label .. "per-map block units")
+ maps[1].mapId = "MUTATED"
+ maps[2].block.width = 9
+ maps[2].x = 123
+ T.same(game, before, label .. "live map snapshots detached")
+ T.same(world:loadedMaps(), legacy, label .. "legacy loadedMaps shape unchanged")
+ T.eq(world:liveMaps()[2].x, -2, label .. "fresh map snapshots")
+
+ game.stack.states = {}
+ game.overworld = nil
+ T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "no active world block result")
+ T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)), "no overworld", label .. "no world reason")
+ T.eq(world:liveMaps(), nil, label .. "no active world maps result")
+ T.check(world:party() ~= nil, label .. "party available without world")
+ game.save = nil
+ T.eq(world:party(), nil, label .. "no save party result")
+ T.eq(select(2, world:party()), "no save", label .. "no save reason")
+end
+
+MapLoader.load, Pokemon.new, math.random = oldMapLoad, oldNew, oldRandom
+-- Trap LÖVE's RNG too when the host provides one; queries remain ordinary
+-- data reads in a host with no graphics/filesystem surface.
+love = { math = { random = forbidden } }
+local rngApi = WorldAPI.new(
+ { data = {}, save = { party = {} }, overworld = { isOverworld = true, map = map("RNG", 1, true, false) } },
+ "rng"
+)
+T.check(rngApi:party() ~= nil, "party never calls love.math.random")
+T.eq(rngApi:activeBlockAt("RNG", 0, 0), 0, "block never calls love.math.random")
+T.eq(#rngApi:liveMaps(), 1, "map list never calls love.math.random")
+love = nil
+-- Direct engine construction also produces symbolic records without a loader.
+local data = {
+ pokemon = { [999] = { name = "Mr. Mime" } },
+ moves = { [888] = { name = "Ember", pp = 25 } },
+ items = {
+ [777] = { name = "Potion" },
+ },
+}
+local raw = {
+ species = 999,
+ item = 777,
+ hp = 3,
+ stats = { hp = 9 },
+ moves = { { id = 888, pp = 5 } },
+ extension = {
+ flag = false,
+ },
+}
+local direct = WorldAPI.new({ data = data, save = { party = { raw } } }, "direct")
+local before = Merge.deepCopy(data)
+local p = direct:party()
+T.eq(p[1].species, "MR_MIME", "standalone numeric species resolves by imported name")
+T.eq(p[1].moves[1].id, "EMBER", "standalone numeric move resolves")
+T.eq(p[1].native.extension.flag, false, "standalone false diagnostics retained")
+T.same(data, before, "standalone resolver does not mutate data")
+raw.self = raw
+raw.callback = function() end
+raw[{}] = { hidden = true }
+local cyclic = direct:party()[1]
+T.eq(cyclic.native.self, cyclic.native, "cyclic diagnostics detached consistently")
+T.eq(cyclic.native.callback, nil, "diagnostics do not expose functions")
+local tableKey = false
+for k in pairs(cyclic.native) do
+ if type(k) == "table" then
+ tableKey = true
+ end
+end
+T.eq(tableKey, false, "diagnostics do not retain native table keys")
+raw.self = nil
+raw.callback = nil
+
+-- Metadata comes from the actual tileset even when its size is unusual.
+local mixed = map("MIXED", 3, false, false)
+local mixedApi = WorldAPI.new({ overworld = { isOverworld = true, map = mixed } }, "mixed")
+T.eq(
+ select(2, mixedApi:activeBlockAt("MIXED", 0, 0)).tileSize,
+ 48,
+ "custom block size is not inferred from generation"
+)
+local missingMap = map("SHORT", 1, true, false)
+missingMap.def.blocks = "\1"
+local shortApi = WorldAPI.new({ overworld = { isOverworld = true, map = missingMap } }, "short")
+T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "truncated packed block refused")
+missingMap.def.blocks = {}
+T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "missing array cell refused")
+missingMap.def.blocks = { -1 }
+T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "negative block ID refused")
+missingMap.def.blocks = pack16(1)
+missingMap.def._blockArray = { 7 }
+T.eq(shortApi:activeBlockAt("SHORT", 0, 0), 7, "warm decoder cache matches Map:blockAt")
+local cache = missingMap.def._blockArray
+T.eq(cache[1], 7, "warm cache unmodified")
+missingMap.blockTiles = 3
+T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "inconsistent runtime geometry refused")
+local placements = { isOverworld = true, map = map("HOME", 1, false, false) }
+local repeated = map("NORTH", 1, false, false)
+placements.neighbors = {
+ { map = repeated, cx = 0, cy = -2 },
+ { map = repeated, cx = 0, cy = -2 },
+ { map = repeated, cx = 3, cy = 0 },
+ { map = placements.map, cx = 0, cy = 0 },
+ { map = map("OTHER", 2, false, false), cx = -4, cy = 0 },
+}
+local list = WorldAPI.new({ overworld = placements }, "placements"):liveMaps()
+T.eq(#list, 4, "duplicate placements collapse, different placements of the same map remain")
+T.eq(list[2].mapId, list[3].mapId, "two placements share map identity")
+T.eq(list[3].x, 3, "second placement offset preserved")
+T.eq(list[4].block.tileSize, 32, "mixed-neighborhood block sizes describe each map")
+T.eq(list[4].width, 6, "mixed-neighborhood width remains in walk cells")
+local empty = WorldAPI.new(nil, "empty")
+T.eq(empty:party(), nil, "no Game party read safe")
+T.eq(empty:liveMaps(), nil, "no Game map read safe")
+T.eq(empty:activeBlockAt("MAP", 0, 0), nil, "no Game block read safe")
+
+-- IDs stay bound to pre-merge identity despite presentation patches and
+-- registrations made after the API/context was constructed.
+local api, loader, game = boot(
+ "platinum",
+ [[return function(mod)
+ mod.exports.api=mod
+ mod.content.pokemon:patch("SPECIES_25",{name="A localized display name"})
+ mod.content.pokemon:register("CUSTOMMON",{
+ id="CUSTOMMON",dex=999,name="Another display name",types={"NORMAL"},
+ catchRate=45,baseExp=64,level1Moves={},growthRate="MEDIUM_FAST",
+ learnset={},evolutions={},spriteFront="front.png",spriteBack="back.png",frontSize=5,
+ baseStats={hp=40,attack=40,defense=40,speed=40,special=40},
+ })
+end]]
+)
+game.save.party[1].species = "SPECIES_25"
+game.save.party[2] = { species = "CUSTOMMON", level = 1, stats = {}, moves = {} }
+T.eq(api.world:party()[1].species, "PIKACHU", "patched alias keeps canonical pre-merge ID")
+T.eq(api.world:party()[2].species, "CUSTOMMON", "late mod registration uses its authored ID")
+local emptyParty = game.save.party
+game.save.party = {}
+T.same(api.world:party(), {}, "empty saved party returns an empty snapshot")
+game.save.party = emptyParty
+game.save.party[1].item = 0
+game.save.party[1].status = nil
+game.save.party[1].isEgg = true
+T.eq(api.world:party()[1].item, nil, "no-item native sentinel is absent")
+T.eq(api.world:party()[1].status, nil, "healthy status is absent")
+T.eq(api.world:party()[1].isEgg, true, "egg flag exposed")
+-- Optional integration gate against #92's actual event record encoder.
+if pcall(require, "src.mods.UnifiedCallbacks") then
+ local Unified = require("src.mods.UnifiedCallbacks")
+ Unified.install(loader, api, "probe", 4)
+ local eventMon
+ api.events:on("pokemon.received", function(event)
+ eventMon = event.mon
+ end)
+ loader.events:emit("pokemon.received", { mon = game.save.party[1] })
+ T.same(api.world:party()[1], eventMon, "party/event live record shapes match exactly")
+end
+
+-- Verify both normal RNG streams independently, without relying only on the
+-- tripwire above. Lua's sequence must remain unchanged by the real readers.
+math.randomseed(24680)
+local expected = { math.random(), math.random(), math.random() }
+math.randomseed(24680)
+api.world:party()
+api.world:activeBlockAt("ACTIVE", 1, 0)
+api.world:liveMaps()
+T.same({ math.random(), math.random(), math.random() }, expected, "reader calls leave RNG sequence unchanged")
+for _, name in ipairs({ "party", "activeBlockAt", "liveMaps" }) do
+ T.check(Reads.supports(name), "new capability " .. name)
+end
+T.check(not Reads.supports("flyTo") and not Reads.supports({}), "reader capability list stays narrow")
+
+GameVersion.set(savedVersion)
+Runtime.install(savedEvents, savedHooks, savedErrors)
+love = savedLove
+T.finish()
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/gsc_species_fixture.lua b/tests/gsc_species_fixture.lua
new file mode 100644
index 000000000..ed54fdd42
--- /dev/null
+++ b/tests/gsc_species_fixture.lua
@@ -0,0 +1,19 @@
+-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
+-- Synthetic full native identity table; contains no cartridge data/artwork.
+local F={}
+function F.new()
+ local data=require('tests.modkit').fixtures.fresh()
+ local copy=require('src.mods.Merge').deepCopy
+ local prototype=data.pokemon.FIXMON_A
+ data.pokemon={};data.constants.speciesOrder={}
+ for index=1,251 do
+ local id=string.format('SPECIES_%03d',index)
+ local def=copy(prototype)
+ def.id,def.index,def.dex,def.name=id,index,index,'Localized display name'
+ def.source='ROM:'..id
+ def.evolutions={};def.eggGroups={'FIELD'};def.genderRatio=127
+ data.pokemon[id]=def;data.constants.speciesOrder[index]=id
+ end
+ return data
+end
+return F
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,
diff --git a/tests/skin_fs_fixture.lua b/tests/skin_fs_fixture.lua
new file mode 100644
index 000000000..8d9394288
--- /dev/null
+++ b/tests/skin_fs_fixture.lua
@@ -0,0 +1,50 @@
+-- Memory PhysFS fixture for archive-member tests; no real ZIP/GPU acceptance.
+local function fixture()
+ local fs, files, mounts = {}, {}, {}
+ local function resolve(path)
+ if files[path] then return path end
+ for i=#mounts,1,-1 do
+ local m=mounts[i]
+ if path==m.point then return m.archive end
+ if path:sub(1,#m.point+1)==m.point..'/' then
+ return m.archive..'/'..path:sub(#m.point+2)
+ end
+ end
+ return path
+ end
+ function fs.read(path) return files[resolve(path)] end
+ function fs.write(path,data) files[path]=data; return true end
+ function fs.remove(path) files[path]=nil; return true end
+ function fs.getInfo(path)
+ path=resolve(path)
+ if files[path] then return {type='file'} end
+ for key in pairs(files) do
+ if key:sub(1,#path+1)==path..'/' then return {type='directory'} end
+ end
+ end
+ function fs.getDirectoryItems(path)
+ path=resolve(path)
+ local out,seen={},{}
+ for key in pairs(files) do
+ if key:sub(1,#path+1)==path..'/' then
+ local name=key:sub(#path+2):match('^[^/]+')
+ if name and not seen[name] then seen[name]=true;out[#out+1]=name end
+ end
+ end
+ table.sort(out);return out
+ end
+ function fs.mount(archive,point)
+ if not files[archive] then return false end
+ for _,m in ipairs(mounts) do if m.archive==archive then return false end end
+ mounts[#mounts+1]={archive=archive,point=point};return true
+ end
+ function fs.unmount(archive)
+ for i=#mounts,1,-1 do if mounts[i].archive==archive then table.remove(mounts,i);return true end end
+ return false
+ end
+ function fs.createDirectory() return true end
+ function fs.newFileData(bytes,name) return {contents=bytes,name=name} end
+ function fs.getSaveDirectory() return '/memory-only-skin-fixture' end
+ return fs
+end
+return fixture
diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py
new file mode 100644
index 000000000..061f96aa6
--- /dev/null
+++ b/tools/api3-contract-check/check.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""Run the identical API-3 mod files on Gen2Recomped and pinned Gen1Recomp."""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+
+GEN1_REF = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36"
+VERSIONS = ("red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald")
+
+
+def git(root, *args):
+ return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip()
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--gen1", required=True, type=Path, help="clean checkout at the pinned Gen1 commit")
+ parser.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2])
+ parser.add_argument("--lua", default=os.environ.get("LUA", "luajit"))
+ parser.add_argument("--report", type=Path)
+ args = parser.parse_args()
+ gen1, gen2 = args.gen1.resolve(), args.gen2.resolve()
+ if git(gen1, "rev-parse", "HEAD") != GEN1_REF:
+ parser.error(f"Gen1 must be pinned to {GEN1_REF}")
+ if git(gen1, "status", "--porcelain", "--untracked-files=normal"):
+ parser.error("Gen1 reference has tracked modifications; use an isolated checkout")
+ fixtures = gen2 / "tests" / "api3_contract"
+ manifest = {}
+ for path in sorted((fixtures / "mods").rglob("*")):
+ if path.is_file():
+ manifest[str(path.relative_to(fixtures))] = hashlib.sha256(path.read_bytes()).hexdigest()
+ results = []
+ scaffold_temp = tempfile.TemporaryDirectory(prefix="api3-scaffold-")
+ scaffold_root = Path(scaffold_temp.name)
+ for name, options in (("portable", ["--api", "3"]), ("legacy", [])):
+ subprocess.run([sys.executable, str(gen2 / "tools/modkit.py"), "scaffold", name,
+ "--dest", str(scaffold_root / "mods"), "--quiet", *options],
+ cwd=gen2, check=True, capture_output=True, text=True, timeout=60)
+ generated = json.loads((scaffold_root / "mods/portable/manifest.json").read_text())
+ assert generated["api"] == 3 and "game_version" not in generated
+ assert generated["games"] == ["gen1", "gen2", "gen3"]
+ legacy = json.loads((scaffold_root / "mods/legacy/manifest.json").read_text())
+ assert legacy["api"] == 2 and legacy["game_version"].startswith(">=")
+ generated_hashes = {path.name: hashlib.sha256(path.read_bytes()).hexdigest()
+ for path in (scaffold_root / "mods/portable").iterdir()
+ if path.is_file()}
+ for version in VERSIONS:
+ for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio", "script_fields", "effects", "datasets", "render_output", "viewport"):
+ values = {}
+ for backend, engine in (("gen1", gen1), ("gen2", gen2)):
+ source = scaffold_root if mode == "scaffold" else fixtures
+ command = [args.lua, str(fixtures / "worker.lua"), str(source), backend, version, mode]
+ run = subprocess.run(command, cwd=engine, text=True, capture_output=True, timeout=60)
+ reports = [line.removeprefix("API3_CONTRACT ") for line in run.stdout.splitlines()
+ if line.startswith("API3_CONTRACT ")]
+ if run.returncode or len(reports) != 1:
+ raise RuntimeError(f"{backend}/{version}/{mode} failed:\n{run.stdout}{run.stderr}")
+ values[backend] = json.loads(reports[0])
+ if values["gen1"] != values["gen2"]:
+ raise RuntimeError(f"contract mismatch for {version}/{mode}: {values}")
+ results.append({"version": version, "mode": mode, "result": values["gen1"]})
+ print(f"PASS {version}/{mode}: identical mod and public result")
+ report = {"gen1_ref": GEN1_REF, "gen2_ref": git(gen2, "rev-parse", "HEAD"),
+ "gen2_tracked_changes": bool(git(gen2, "status", "--porcelain", "--untracked-files=no")),
+ "fixture_sha256": manifest, "scaffold_sha256": generated_hashes, "comparisons": results}
+ if args.report:
+ args.report.write_text(json.dumps(report, indent=2) + "\n")
+ scaffold_temp.cleanup()
+ print(f"API 3: {len(results)} cross-engine comparisons, {len(results) * 2} isolated loader runs passed")
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except (RuntimeError, subprocess.SubprocessError, OSError, ValueError, AssertionError) as error:
+ print(error, file=sys.stderr)
+ sys.exit(1)
diff --git a/tools/flag-catalog-check/generate.py b/tools/flag-catalog-check/generate.py
new file mode 100644
index 000000000..e385d9931
--- /dev/null
+++ b/tools/flag-catalog-check/generate.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""Reproduce/check flag identifier catalogs against the clean pinned reference.
+
+Exports identifiers only. No Gen1 engine code or cartridge ROM is vendored.
+"""
+import argparse
+import json
+from pathlib import Path
+import subprocess
+
+PIN = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36"
+EXPORT = r"""
+package.path = './?.lua;./?/init.lua;' .. package.path
+local out = { reference = '79ea62f45fb2ea8f65d081a109431e4eb1bbdb36', catalogs = {} }
+for _, version in ipairs({'red','yellow'}) do
+ local suffix = version == 'yellow' and '_yellow' or ''
+ local names=require('src.save_convert.data.event_flags'..suffix).byBit
+ local encoded={} for k,v in pairs(names) do encoded['n'..k]=v end
+ out.catalogs[version] = { names=encoded }
+end
+for _, version in ipairs({'firered','emerald'}) do
+ require('src.core.GameVersion').set(version)
+ local t = require('src.core.game3.scripting.flags').forVersion(version)
+ local ids,names,count={},{},t.FLAGS_COUNT or 0x900
+ for k,v in pairs(t.IDS) do
+ if type(k)=='string' and type(v)=='number' and v>=1 and v=1 and k 1 then
+ local G = love.graphics
+ G.setColor(0, 1, 0, 1)
+ G.rectangle("fill", 0, 0, 12, 12)
+ G.setColor(1, 1, 1, 1)
+ end
+ end
+ start()
+ end, debug.traceback)
+ if not ok then
+ error(why)
+ end
+end
+local function expected(pixels, rectangle, blue)
+ local pw, ph = pixels:getDimensions()
+ local dx, dy = pw / 320, ph / 288
+ for y = 0, ph - 1 do
+ for x = 0, pw - 1 do
+ local target
+ if x < 12 * dx and y < 12 * dy then
+ target = { 0, 1, 0, 1 }
+ elseif
+ rectangle
+ and (
+ x < rectangle.x * dx
+ or y < rectangle.y * dy
+ or x >= (rectangle.x + rectangle.width) * dx
+ or y >= (rectangle.y + rectangle.height) * dy
+ )
+ then
+ target = blue and { 0, 0, 1, 1 } or { 0, 0, 0, 1 }
+ else
+ local bx, by = x, y
+ if rectangle then
+ bx = math.floor((x - rectangle.x * dx) * baseline:getWidth() / (rectangle.width * dx))
+ by = math.floor((y - rectangle.y * dy) * baseline:getHeight() / (rectangle.height * dy))
+ end
+ target = { baseline:getPixel(bx, by) }
+ end
+ local got = { pixels:getPixel(x, y) }
+ for channel = 1, 4 do
+ check(near(got[channel], target[channel]), "frame pixel " .. x .. "," .. y .. " channel " .. channel)
+ end
+ end
+ end
+end
+function love.draw()
+ if waiting then
+ return
+ end
+ waiting = true
+ local ok, why = xpcall(function()
+ fixture.exports.window(nil)
+ local rectangle
+ if phase >= 2 and phase <= 5 then
+ rectangle = { x = 80, y = 72, width = 160, height = 144 }
+ elseif phase == 7 then
+ rectangle = { x = 0, y = 0, width = 320, height = 288, capture = true }
+ elseif phase == 8 then
+ rectangle = { x = 0, y = 0, width = 160, height = 144 }
+ elseif phase == 9 then
+ rectangle = { x = 20, y = 20, width = 161, height = 144 }
+ end
+ fixture.exports.request(rectangle)
+ if phase == 3 then
+ fixture.exports.window(function(next, g, ctx)
+ love.graphics.setColor(0, 0, 1, 1)
+ love.graphics.rectangle("fill", 0, 0, ctx.windowWidth, ctx.windowHeight)
+ return next(g, ctx)
+ end)
+ end
+ if phase == 4 then
+ fixture.exports.window(function()
+ local G = love.graphics
+ G.push("all")
+ G.setCanvas(foreign)
+ G.setShader(shader)
+ G.translate(50, 70)
+ error("injected window compositor failure")
+ end)
+ end
+ if phase == 5 then
+ fixture.exports.window(function(next, g, ctx)
+ V.reset()
+ return next(g, ctx)
+ end)
+ end
+ local G = love.graphics
+ local oldNew = G.newCanvas
+ if phase == 9 then
+ G.newCanvas = function()
+ error("injected viewport allocation failure")
+ end
+ end
+ local depth = G.getStackDepth()
+ local drawn, err = xpcall(function()
+ Game.draw(game)
+ end, debug.traceback)
+ G.newCanvas = oldNew
+ if not drawn then
+ error(err)
+ end
+ check(G.getCanvas() == nil, "OS window is final target")
+ check(G.getShader() == nil, "window compositor cannot leak shader")
+ check(G.getStackDepth() == depth, "graphics stack balanced")
+ local x, y = G.transformPoint(0, 0)
+ check(x == 0 and y == 0, "window transform restored")
+ if phase == 1 then
+ check(not V.active() and V.target() == nil, "default path has no viewport target")
+ end
+ if phase == 5 or phase == 9 then
+ check(not V.active(), "retirement/allocation failure clears capture")
+ end
+ G.captureScreenshot(function(pixels)
+ local good, problem = xpcall(function()
+ if phase == 1 then
+ baseline = pixels
+ else
+ local expectedRectangle = rectangle
+ if phase == 9 then
+ expectedRectangle = nil
+ end
+ expected(pixels, expectedRectangle, phase == 3)
+ pixels:release()
+ end
+ phase = phase + 1
+ if phase == 10 then
+ baseline:release()
+ index = index + 1
+ phase = 1
+ if index > #versions then
+ print("GAME VIEWPORT GPU: " .. checks .. " checks passed across Red/Gold/FireRed")
+ local a, b, c, d = G.getRendererInfo()
+ print(table.concat({ a, b, c, d }, " | "))
+ love.event.quit(0)
+ return
+ end
+ start()
+ end
+ waiting = false
+ end, debug.traceback)
+ if not good then
+ io.stderr:write(problem .. "\n")
+ love.event.quit(1)
+ end
+ end)
+ end, debug.traceback)
+ if not ok then
+ io.stderr:write(why .. "\n")
+ love.event.quit(1)
+ end
+end
diff --git a/tools/modkit.py b/tools/modkit.py
index fb0b91499..91c23ffa4 100644
--- a/tools/modkit.py
+++ b/tools/modkit.py
@@ -323,10 +323,9 @@ def check_requires(repo, mod_dir, manifest):
"id": "{{id}}",
"name": "{{name}}",
"version": "0.1.0",
- "api": 2,
- "entry": "main.lua",
- "profile": "{{profile}}",
- "game_version": ">={{game_version}} <{{next_major}}.0.0",
+ "api": {{api}},
+ "entry": "main.lua",{{games_line}}
+ "profile": "{{profile}}",{{engine_range_line}}
"category": "GAMEPLAY",
"priority": 100,
"dependencies": [],
@@ -378,7 +377,7 @@ def check_github_field(manifest):
"MK001", "error", str(err), "manifest.json"))
return findings, notes
-MAIN_CONTENT = """-- {{id}}: a content-profile mod (api 2).
+MAIN_CONTENT = """-- {{id}}: a content-profile mod (api {{api}}).
-- The 10-minute loop: edit, save, F5 in a POKEPORT_DEV=1 game, repeat.
return function(mod)
-- patch, not override: every field you do not name keeps its base value
@@ -391,7 +390,16 @@ def check_github_field(manifest):
end
"""
-MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api 2).
+MAIN_PORTABLE = """-- {{id}}: portable API {{api}} entry for Gen1Recomp and Gen2Recomped.
+return function(mod)
+ if mod.content.moves:has("POUND") then
+ local ok, reason = mod.content.moves:patch("POUND", { power = 45 })
+ if not ok then mod.log:warn("%s", reason) end
+ end
+end
+"""
+
+MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api {{api}}).
return function(mod)
mod.options:define({
{ key = "difficulty", label = "DIFFICULTY", kind = "choice",
@@ -411,7 +419,7 @@ def check_github_field(manifest):
end
"""
-MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api 2).
+MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api {{api}}).
return function(mod)
-- the new game itself: spawn, names, money (field.boot, D11)
-- mod.content.field:patch("boot", {
@@ -442,7 +450,7 @@ def check_github_field(manifest):
README_TEMPLATE = """# {{name}}
-A `{{profile}}` mod for the LOVE2D Pokemon Red engine (mod api 2).
+A `{{profile}}` mod for {{target_engine}} (mod api {{api}}).
## Layout
@@ -489,6 +497,10 @@ def cmd_scaffold(args, repo):
"{{game_version}}": engine, "{{next_major}}": str(next_major),
"{{extra}}": extra,
"{{github_line}}": github_line,
+ "{{api}}": str(args.api),
+ "{{games_line}}": '\n "games": ["gen1", "gen2", "gen3"],' if args.api == 3 else "",
+ "{{engine_range_line}}": "" if args.api == 3 else f'\n "game_version": ">={engine} <{next_major}.0.0",',
+ "{{target_engine}}": "Gen1Recomp and Gen2Recomped" if args.api == 3 else "the LOVE2D Pokemon Red engine",
"{{experimental}}": "true" if getattr(args, "experimental", False)
else "false",
}
@@ -504,6 +516,8 @@ def emit(rel, template):
main = {"content": MAIN_CONTENT, "overhaul": MAIN_OVERHAUL,
"total_conversion": MAIN_TC}[profile]
+ if args.api == 3:
+ main = MAIN_PORTABLE
layout_extra = ""
if profile == "total_conversion":
layout_extra = "- `transforms.lua` - asset transforms over the player's cache\n"
@@ -519,7 +533,7 @@ def emit(rel, template):
emit("transforms.lua", TRANSFORMS_TEMPLATE)
if not args.quiet:
- print(f"created {dest} ({profile} profile, api 2)")
+ print(f"created {dest} ({profile} profile, api {args.api})")
print(f"next: python3 tools/modkit.py validate {args.id}")
return 0
@@ -1964,7 +1978,7 @@ def main(argv):
p.add_argument("id")
p.add_argument("--profile", default="content",
choices=["content", "overhaul", "total_conversion"])
- p.add_argument("--api", type=int, default=2)
+ p.add_argument("--api", type=int, choices=(1, 2, 3), default=2)
p.add_argument("--github", default="",
help="optional owner/repo (enables launcher auto-update)")
p.add_argument("--experimental", action="store_true",
diff --git a/tools/move-effect-contract/export_reference.lua b/tools/move-effect-contract/export_reference.lua
new file mode 100644
index 000000000..47f75c4a6
--- /dev/null
+++ b/tools/move-effect-contract/export_reference.lua
@@ -0,0 +1,44 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+local E = require("src.mods.UnifiedEffects")
+local M1 = require("src.battle.g3u.EffectMap1")
+local M2 = require("src.battle.g3u.EffectMap2")
+local N = require("src.core.game3.battle.effect_ids")
+local ids = {}
+for _, t in ipairs({ M1.EFFECTS, M1.UNSUPPORTED, M2.EFFECTS, M2.UNSUPPORTED, N }) do
+ for k in pairs(t) do
+ ids[k] = true
+ end
+end
+local out = { bindings = {}, read3 = {}, read2 = {}, variants = {}, reserved = {} }
+for id in pairs(ids) do
+ out.reserved[id] = true
+end
+for id, row in pairs(M1.EFFECTS) do
+ out.variants[id] = { semantic = row.effect, chance = row.chance }
+end
+for id in pairs(ids) do
+ local row = {}
+ for gen = 1, 3 do
+ local native, chance = E.write(id, gen)
+ if native then
+ local semantic = gen == 3 and native
+ or gen == 2 and M2.EFFECTS[native] and M2.EFFECTS[native].effect
+ or nil
+ if gen == 2 and native == "EFFECT_BURN" then
+ semantic = N.WILL_O_WISP
+ end
+ row[tostring(gen)] =
+ { native = native, chance = type(chance) == "number" and chance or nil, semantic = semantic }
+ end
+ end
+ if next(row) then
+ out.bindings[id] = row
+ end
+end
+for i = 0, 255 do
+ out.read3[tostring(i)] = E.read(i, 3, {})
+end
+for id in pairs(M2.EFFECTS) do
+ out.read2[id] = E.read(id, 2, {})
+end
+print(require("src.link.Json").encode(out))
diff --git a/tools/move-effect-contract/generate.py b/tools/move-effect-contract/generate.py
new file mode 100644
index 000000000..cb53f7e34
--- /dev/null
+++ b/tools/move-effect-contract/generate.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""Project the pinned reference effect contract onto host native handler names."""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import re
+import subprocess
+
+PIN = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36"
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def lua(v):
+ if v is None:
+ return "nil"
+ if v is True:
+ return "true"
+ if v is False:
+ return "false"
+ if isinstance(v, (int, float)):
+ return str(v)
+ if isinstance(v, str):
+ return json.dumps(v, ensure_ascii=False)
+ if isinstance(v, list):
+ return "{" + ",".join(lua(x) for x in v) + "}"
+ return "{" + ",".join("[" + lua(k) + "]=" + lua(x) for k, x in sorted(v.items())) + "}"
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument("--gen1", type=Path, required=True)
+ p.add_argument("--lua", required=True)
+ p.add_argument("--check", action="store_true")
+ a = p.parse_args()
+ ref = a.gen1.resolve()
+ def git(*args):
+ return subprocess.check_output(["git", "-C", str(ref), *args], text=True).strip()
+ if git("rev-parse", "HEAD") != PIN or git("status", "--porcelain", "--untracked-files=normal"):
+ p.error("reference must be clean at " + PIN)
+ raw = subprocess.check_output([a.lua, str(Path(__file__).with_name("export_reference.lua"))], cwd=ref)
+ data = json.loads(raw)
+ src = ROOT / "src/import/RomExtractorGen3.lua"
+ block = src.read_text().split("local GEN3_MOVE_EFFECTS = {", 1)[1].split("\n}", 1)[0]
+ data["native3"] = {n: re.findall(r'"([A-Z0-9_]+)"', row)
+ for n, row in re.findall(r'\[(\d+)\]\s*=\s*\{([^}]+)\}', block)}
+ data.pop("read2")
+ files = ["src/mods/UnifiedEffects.lua", "src/battle/g3u/EffectMap1.lua",
+ "src/battle/g3u/EffectMap2.lua", "src/core/game3/battle/effect_ids.lua"]
+ data["reference"] = {"commit": PIN, "sha256": {
+ f: hashlib.sha256((ref / f).read_bytes()).hexdigest() for f in files}}
+ data["hostMapSha256"] = hashlib.sha256(block.encode()).hexdigest()
+ lines = ["-- Generated by tools/move-effect-contract/generate.py; do not hand-edit.", "return {"]
+ for group, rows in sorted(data.items()):
+ lines.append(" [" + lua(group) + "] = {")
+ for key, value in sorted(rows.items() if isinstance(rows, dict) else []):
+ lines.append(" [" + lua(key) + "] = " + lua(value) + ",")
+ if not isinstance(rows, dict):
+ lines[-1] = " [" + lua(group) + "] = " + lua(rows) + ","
+ else:
+ lines.append(" },")
+ lines.append("}")
+ text = "\n".join(lines) + "\n"
+ target = ROOT / "src/mods/MoveEffectContract.lua"
+ if a.check:
+ if target.read_text() != text:
+ raise SystemExit("effect contract differs; regenerate")
+ print("PASS pinned effect bindings and host native-name map")
+ else:
+ target.write_text(text)
+ print("Wrote", target)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/render-output-check/conf.lua b/tools/render-output-check/conf.lua
new file mode 100644
index 000000000..cb7f5b452
--- /dev/null
+++ b/tools/render-output-check/conf.lua
@@ -0,0 +1,7 @@
+function love.conf(t)
+ t.identity = "gen2-render-output-check"
+ t.window.title = "Gen2Recomped output contract GPU check"
+ t.window.width, t.window.height = 320, 288
+ t.window.highdpi = true
+ t.modules.audio = false
+end
diff --git a/tools/render-output-check/main.lua b/tools/render-output-check/main.lua
new file mode 100644
index 000000000..b3265808f
--- /dev/null
+++ b/tools/render-output-check/main.lua
@@ -0,0 +1,165 @@
+-- Actual Renderer, public API-3 mod, real GL draw/readback. No ROM or settings.
+function love.errorhandler(message)
+ io.stderr:write(tostring(message).."\n"..debug.traceback().."\n")
+ return function() love.event.quit(1) end
+end
+local root = love.filesystem.getSource():match("^(.*)/tools/render%-output%-check$")
+ or love.filesystem.getWorkingDirectory()
+package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path
+love.filesystem.mount(root, "", true)
+local Contract = require("tests.api3_contract.render_output")
+local Renderer = require("src.render.Renderer")
+local Palette = require("src.render.PaletteFX")
+local GBCFX = require("src.render.GBCFX")
+local checks, phase, waiting = 0, 1, false
+local baseline, fixture, shader, foreign
+local function check(ok, message)
+ checks = checks + 1
+ assert(ok, message)
+end
+local function near(a, b)
+ return math.abs(a - b) < 1.1 / 255
+end
+local function compare(pixels, inverse, hud)
+ check(
+ pixels:getWidth() == baseline:getWidth() and pixels:getHeight() == baseline:getHeight(),
+ "frame dimensions"
+ )
+ local pw, ph = pixels:getDimensions()
+ for y = 0, ph - 1 do
+ for x = 0, pw - 1 do
+ local a, b = { pixels:getPixel(x, y) }, { baseline:getPixel(x, y) }
+ if hud and x < 20 * (pw / 320) and y < 20 * (ph / 288) then
+ b = { 0, 1, 0, 1 }
+ elseif inverse then
+ for i = 1, 3 do
+ b[i] = 1 - b[i]
+ end
+ end
+ for i = 1, 4 do
+ check(near(a[i], b[i]), "GPU pixel " .. x .. "," .. y .. " channel " .. i .. " phase " .. phase)
+ end
+ end
+ end
+end
+local function scene()
+ Renderer:setUISize(160, 144)
+ Renderer:beginFrame()
+ local G = love.graphics
+ G.clear(1, 1, 1, 1)
+ G.setColor(0.2, 0.2, 0.2, 1)
+ G.rectangle("fill", 0, 0, 80, 72)
+ G.setColor(0.7, 0.7, 0.7, 1)
+ G.rectangle("fill", 80, 72, 80, 72)
+ G.setColor(1, 1, 1, 1)
+end
+function love.load()
+ local ok, why = xpcall(function()
+ Renderer:init()
+ Palette.setMode("dmg")
+ GBCFX.setLevel(0)
+ fixture = Contract.fixture(root .. "/tests/api3_contract", "gen2", "red")
+ shader = love.graphics.newShader(
+ [[vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){vec4 s=Texel(t,uv);return vec4(vec3(1.0)-s.rgb,s.a)*c;}]]
+ )
+ foreign = love.graphics.newCanvas(4, 4)
+ end, debug.traceback)
+ if not ok then
+ io.stderr:write(why .. "\n")
+ love.event.quit(1)
+ end
+end
+function love.draw()
+ if waiting then
+ return
+ end
+ waiting = true
+ local ok, why = xpcall(function()
+ fixture.exports.state(phase ~= 1 and phase ~= 7, true)
+ fixture.exports.gate(nil)
+ fixture.exports.callback(nil)
+ if phase == 2 then
+ fixture.exports.state(true, false)
+ end
+ if phase == 3 then
+ fixture.exports.callback(function(ctx)
+ love.graphics.draw(ctx.canvas)
+ return true
+ end)
+ end
+ if phase == 4 then
+ fixture.exports.callback(function(ctx)
+ local G = love.graphics
+ G.setShader(shader)
+ G.draw(ctx.canvas)
+ G.push("all")
+ G.translate(30, 40)
+ G.setCanvas(foreign)
+ G.setColor(1, 0, 0, 1)
+ G.setBlendMode("add")
+ return true
+ end)
+ end
+ if phase == 5 then
+ fixture.exports.callback(function()
+ local G = love.graphics
+ G.setCanvas(foreign)
+ G.setShader(shader)
+ G.translate(70, 90)
+ error("injected output draw failure")
+ end)
+ end
+ if phase == 6 then
+ fixture.exports.gate(function()
+ love.graphics.setShader(shader)
+ love.graphics.setCanvas(foreign)
+ return 1
+ end)
+ end
+ if phase == 7 then
+ fixture.loader:_rollback("render_output")
+ end
+ scene()
+ local depth = love.graphics.getStackDepth()
+ Renderer:endFrame()
+ check(love.graphics.getCanvas() == nil, "frame target restored")
+ check(love.graphics.getShader() == nil, "shader restored")
+ check(love.graphics.getStackDepth() == depth, "graphics stack balanced")
+ local x, y = love.graphics.transformPoint(0, 0)
+ check(x == 0 and y == 0, "transform restored")
+ if phase == 1 then
+ check(Renderer.presentCanvas == nil, "disabled owner creates no present target")
+ end
+ if phase == 4 then
+ love.graphics.setColor(0, 1, 0, 1)
+ love.graphics.rectangle("fill", 0, 0, 20, 20)
+ love.graphics.setColor(1, 1, 1, 1)
+ end
+ love.graphics.captureScreenshot(function(pixels)
+ local good, err = xpcall(function()
+ if phase == 1 then
+ baseline = pixels
+ else
+ compare(pixels, phase == 4, phase == 4)
+ pixels:release()
+ end
+ phase = phase + 1
+ if phase == 8 then
+ print("RENDER OUTPUT GPU: " .. checks .. " checks passed")
+ local a, b, c, d = love.graphics.getRendererInfo()
+ print(table.concat({ a, b, c, d }, " | "))
+ love.event.quit(0)
+ end
+ waiting = false
+ end, debug.traceback)
+ if not good then
+ io.stderr:write(err .. "\n")
+ love.event.quit(1)
+ end
+ end)
+ end, debug.traceback)
+ if not ok then
+ io.stderr:write(why .. "\n")
+ love.event.quit(1)
+ end
+end