diff --git a/.github/workflows/api3-contract.yml b/.github/workflows/api3-contract.yml new file mode 100644 index 000000000..f448f8484 --- /dev/null +++ b/.github/workflows/api3-contract.yml @@ -0,0 +1,61 @@ +name: API 3 contracts +on: + pull_request: + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' + - 'tools/flag-catalog-check/**' + push: + branches: [main, dev] + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' + - 'tools/flag-catalog-check/**' +permissions: + contents: read +concurrency: + group: api3-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + compare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + path: gen2 + sparse-checkout: | + src + data + tests + tools/api3-contract-check + tools/flag-catalog-check + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: gen1-reference + sparse-checkout: | + src + data + tests + assets/fonts + - name: install LuaJIT + run: sudo apt-get update && sudo apt-get install -y luajit + - name: native flag catalog provenance + run: python3 gen2/tools/flag-catalog-check/generate.py --gen1 gen1-reference + - name: identical mod contracts on both engines + run: >- + python3 gen2/tools/api3-contract-check/check.py + --gen1 gen1-reference --gen2 gen2 + --report "${{ runner.temp }}/api3-report.json" + - uses: actions/upload-artifact@v7 + with: + name: api3-contract-report + path: ${{ runner.temp }}/api3-report.json + if-no-files-found: error diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml new file mode 100644 index 000000000..94d290381 --- /dev/null +++ b/.github/workflows/gen2-picture-presentation.yml @@ -0,0 +1,35 @@ +name: Native GSC picture and animation owners +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gen2-picture-${{ github.ref }} + cancel-in-progress: true +jobs: + picture-contract: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - name: Install LuaJIT + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Native picture commands and emitted images + run: | + luajit tests/engine/gen2_picture_presentation.lua + luajit tests/engine/gen2_picture_pixels.lua + luajit tests/engine/gen2_picture_art_import.lua + luajit tests/engine/gen2_picture_art_draw.lua + luajit tests/engine/gen2_battler_rows.lua + luajit tests/engine/gen2_animation_catalog.lua + luajit tests/engine/gen2_vanish_presentation.lua + luajit tests/engine/gen2_status_presentation.lua + luajit tests/engine/gen2_sfx_arbitration.lua + luajit tests/engine/gen2_sleep_turns.lua + luajit tests/engine/gen2_sleep_cures.lua + - name: Existing command, object and background owners + run: | + luajit tests/engine/gen2_object_runtime.lua + luajit tests/engine/gen2_object_motion_pixels.lua + luajit tests/engine/gen2_bg_presenter.lua diff --git a/.github/workflows/gen2-species-runtime.yml b/.github/workflows/gen2-species-runtime.yml new file mode 100644 index 000000000..bdddc8dc1 --- /dev/null +++ b/.github/workflows/gen2-species-runtime.yml @@ -0,0 +1,19 @@ +name: Native GSC runtime identities +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit + - name: Native graph, old saves and script/breeding identities + run: | + luajit tests/engine/gen2_species_runtime.lua + luajit tests/engine/gen2_species_gameplay.lua + luajit tests/engine/gen2_appearance_identity.lua + luajit tests/engine/gen2_appearance_transactions.lua diff --git a/.gitignore b/.gitignore index 0ba18515f..61e7f1048 100644 --- a/.gitignore +++ b/.gitignore @@ -178,3 +178,6 @@ mobile/ios/bundle_id.local # secrets as public -- but GitHub's secret scanning flags it, so it is kept out # of the repository and injected into release builds instead. /cloud_clients.lua + +# Owned memory-only custom-skin fixture. +!/tests/skin_fs_fixture.lua diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..fbcffd47c --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, Gen1Recomp / BOIS CLUB GAMES +work, other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 000000000..476e8a9fa --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,21 @@ +# Native touch-skin provenance + +The TouchSkin, DeltaSkin, SkinZip and PdfImage modules, their identified +TouchControls integration and adapted format tests originate in Gen1Recomp +commit 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 and its Section 7 terms are +retained in licenses/GEN1RECOMP-TOUCH-SKINS.md. + +Native host integration and tests: Contributions by Roxas2712 — +https://github.com/Roxas2712 + +Existing upstream and Gen1 rights and licence grants remain effective; this +notice assigns no other author's work to Roxas2712. No proprietary Gen1 +launcher code or third-party bundled skin art is included. + +Roxas2712's independently owned additions are offered under +[LICENSE-ROXAS2712.md](LICENSE-ROXAS2712.md), subject to prior grants and +the preserved Gen1 GPL terms. Existing GPL-covered code is not relicensed. + +The identified haptic controls in TouchControls.lua are also adapted from the +same Gen1Recomp source under the same preserved GPL/Section7 grant. diff --git a/README.md b/README.md index 4cd31e7c0..978b1191c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,19 @@ +Based on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC +(https://github.com/bryanthaboi/gen1recomp). + +GSC BG-effect source provenance and its retained licence are recorded in +[compat/gen1-gsc/provenance.json](compat/gen1-gsc/provenance.json). + # gen2recomp +Based on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC +(https://github.com/bryanthaboi/gen1recomp). + +Contributions by Roxas2712 — https://github.com/Roxas2712. +Newly adapted touch-skin/studio code retains the original GPLv3/Section7 +terms in `licenses/GEN1RECOMP-TOUCH-SKINS.md`. Existing author rights and +earlier grants remain effective; see `NOTICE.md` and `LICENSE-ROXAS2712.md`. +

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