Compare commits
940 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e40a7a1f4 | |||
| fe6a580e20 | |||
| a910b65434 | |||
| 1151c188a7 | |||
| 65128e13a4 | |||
| d74662ba99 | |||
| 5f2b2c616e | |||
| 32e6e4cef0 | |||
| fc841f7525 | |||
| 12fdfa1e88 | |||
| a34d8611bf | |||
| 73fbaaa250 | |||
| 9469e39926 | |||
| 179048a58e | |||
| e1f5c2b217 | |||
| 1d8ac1e692 | |||
| 39df5bdfa6 | |||
| 4b7a4daf2c | |||
| e1d233d026 | |||
| c22888a7fd | |||
| 0e4fc3c54a | |||
| 82b91e36ca | |||
| 3588a5f3fe | |||
| 992dc80aa7 | |||
| 6e7073d32b | |||
| 000e691966 | |||
| fdbc51c5d1 | |||
| 3e3566d1b4 | |||
| fcb5d1d348 | |||
| b45d783dee | |||
| d99072b44e | |||
| 0ac55b0f9e | |||
| ab48572852 | |||
| 5d2c13ed2b | |||
| d87f6b8ad1 | |||
| 24cf367758 | |||
| 829d398a94 | |||
| 3ee50a27c5 | |||
| 871087a16b | |||
| 180ce6b2e7 | |||
| cf335f67de | |||
| 7e0d81a431 | |||
| 10314bcdcf | |||
| b8ec4fe6b5 | |||
| 099a4266a8 | |||
| cec1f196be | |||
| e24410f0fb | |||
| b29b6fd7bd | |||
| f06c4d4584 | |||
| 3a997e8a62 | |||
| e6ccdd57eb | |||
| 927507f8f7 | |||
| 9bf15c33fd | |||
| 52efdabf61 | |||
| ef208035ec | |||
| 18d61779eb | |||
| d573878a2f | |||
| a66efe207d | |||
| 5198b35945 | |||
| 40977337b1 | |||
| 1598f34954 | |||
| 43cbc554c3 | |||
| 3c3e2c54c5 | |||
| 00c72c441b | |||
| 7804ef9793 | |||
| 673d8b3ad8 | |||
| 62e1296ced | |||
| a3bbd78e7b | |||
| 8dfbd1daae | |||
| 4046b28a8c | |||
| a86d57ac44 | |||
| 72f126ae26 | |||
| 5d7a8c9291 | |||
| c48fc578ca | |||
| dfc216f974 | |||
| 542856c83d | |||
| 545a99d86d | |||
| 2b6473ae03 | |||
| df0be1cba6 | |||
| c9d67582ae | |||
| 1cfd91a503 | |||
| fb738fa1ce | |||
| c0ad6d0328 | |||
| 84de8c9cb1 | |||
| 6307bc92f6 | |||
| 052dd26b3e | |||
| c8f6c7241b | |||
| b3928388ef | |||
| a38fae5a97 | |||
| 9fab992d42 | |||
| dcc388a942 | |||
| 797a6bebfe | |||
| 97a9c0f58f | |||
| 407f649e9d | |||
| 84635cdfdf | |||
| 3de45b671c | |||
| 4395792226 | |||
| a77210799f | |||
| 6f67292b0e | |||
| 78e8a31ead | |||
| b6388013ec | |||
| ba7cd8fabf | |||
| c8f7f396ce | |||
| a94fecfec8 | |||
| 2010ba71a6 | |||
| e90dff6764 | |||
| f4658b89ae | |||
| da63163216 | |||
| fceaa2b54c | |||
| be2ef46ebc | |||
| d8e86e6878 | |||
| 7e57e3174e | |||
| 5192106730 | |||
| dfeacc36b0 | |||
| 1586aec9f6 | |||
| 35d44efb8b | |||
| 500d8c2c07 | |||
| 09204daa28 | |||
| 01329758ff | |||
| 7b8cb127a8 | |||
| 8e4d5b6807 | |||
| 26e9e1d597 | |||
| f7f32d2786 | |||
| c5192eaea4 | |||
| c2906744ce | |||
| 8e6b5ae73d | |||
| 0b96ac0ab6 | |||
| f6a035947f | |||
| 941181d31c | |||
| 9ceb1a8940 | |||
| 37051a26b5 | |||
| b12a0dac91 | |||
| e4786a78d9 | |||
| bde606f966 | |||
| a722d24759 | |||
| 833388c235 | |||
| 2ac527ae3c | |||
| df35193ad3 | |||
| 9b6365c9cc | |||
| 285a98533a | |||
| f952edf77a | |||
| ae8360b2ae | |||
| 52e36ad7e4 | |||
| 62b9f04191 | |||
| 1010d0584a | |||
| 6864173f35 | |||
| e9d431b3ff | |||
| f138827917 | |||
| a68c47e7e1 | |||
| d648990a81 | |||
| 6ff7dd0c38 | |||
| ee00728e9e | |||
| 0aab11b690 | |||
| c2d9af693d | |||
| 1642113d1f | |||
| d64627e062 | |||
| 5fea0b05a4 | |||
| c3136bf8f7 | |||
| 517170f1c8 | |||
| e5bebea437 | |||
| 3320eacc26 | |||
| b40ff167e7 | |||
| c0f654f8c2 | |||
| f4497b4dbb | |||
| b9684ef162 | |||
| f394f56395 | |||
| 6731937841 | |||
| e44769a48a | |||
| b2819d04e0 | |||
| 52fac8ced6 | |||
| d38faab03c | |||
| 83682f011d | |||
| 1176a269e5 | |||
| 96af652d3b | |||
| 29a2b9a123 | |||
| 238af263d6 | |||
| dbd8abe14c | |||
| 04490c9b9a | |||
| 49d094b14d | |||
| cd7985e4ac | |||
| 61edf3470f | |||
| cd02cd6f33 | |||
| 127e3da909 | |||
| 8fa702e715 | |||
| 3aaaf9936e | |||
| af33c6e810 | |||
| e8eccfd4df | |||
| 241c3345bd | |||
| 20e0692486 | |||
| 0136429d3e | |||
| 01aab1d763 | |||
| 3d2d53362d | |||
| 03838ee1d4 | |||
| 2fabc03841 | |||
| ae6cac89e1 | |||
| 41f02ecfbc | |||
| cef286b170 | |||
| ab94b96a79 | |||
| cc43bd77d2 | |||
| d4dc72d0f4 | |||
| 6d841526b1 | |||
| 8e48bb4e32 | |||
| a0279f5be2 | |||
| 2c8c800584 | |||
| ccb5358aad | |||
| 4db97164bb | |||
| 882763cfe1 | |||
| 798f3c25c0 | |||
| d21a0edffc | |||
| 02024fef58 | |||
| a4c51eccea | |||
| a9767e5df1 | |||
| e59175fc89 | |||
| 59725c0ead | |||
| 4e20f4585f | |||
| 1c37867e52 | |||
| 67491e2dac | |||
| 2d85c9595d | |||
| b8138ef850 | |||
| e3fc8380cf | |||
| b9afaaae60 | |||
| 4d03a38067 | |||
| 79ed37699e | |||
| abf0f9e98a | |||
| 3955721bfc | |||
| c3855cebec | |||
| 41e6507665 | |||
| 5482ac590d | |||
| 28440eb936 | |||
| f606840216 | |||
| 6a990bf79f | |||
| ab5fed60fe | |||
| e3a215e081 | |||
| 7fa758602a | |||
| bb979911c8 | |||
| f225f8a6e7 | |||
| ee891fb8fa | |||
| 9d73ff4110 | |||
| 0b7ecf1599 | |||
| aa45f05c63 | |||
| 9234f12ed0 | |||
| 26e0bd227e | |||
| 64ce66a619 | |||
| 9302bbcbf3 | |||
| 1855f838ef | |||
| 11af06a752 | |||
| 05668591ef | |||
| e74beec32d | |||
| 3efb2c9a6c | |||
| d3af63e013 | |||
| ca6bee64b9 | |||
| b52d183dba | |||
| 23e1877a62 | |||
| d8736fe33e | |||
| a74365a4fd | |||
| 1c2163bb5f | |||
| 12c2677dc2 | |||
| ae1b59ce7f | |||
| 02c4de897d | |||
| 3c6242717e | |||
| 3c71afb9fa | |||
| e79107c644 | |||
| 9f54734f62 | |||
| 0afce96c8d | |||
| d881997acd | |||
| 8906133b93 | |||
| b9e3c4a689 | |||
| 81f18e244d | |||
| ff91481018 | |||
| fdb3184c71 | |||
| aa3b2a18ec | |||
| d498016a47 | |||
| 492e0344b5 | |||
| a710c64909 | |||
| 943ba5dcbf | |||
| 371edd1a02 | |||
| 9fc6ba5b01 | |||
| 983bea61aa | |||
| ab74f7adf7 | |||
| bdd0bf45bd | |||
| 4b950fc23d | |||
| af183d95ad | |||
| 1d4d5d2337 | |||
| 653b72d86a | |||
| 12b6ef6350 | |||
| c979685a1f | |||
| 5b1d0261ff | |||
| 5bffc89a2f | |||
| 2ce612d5b1 | |||
| 6999e5aecf | |||
| 44a7910c69 | |||
| 85c6fde443 | |||
| 67b6dcc293 | |||
| 24d9f279ec | |||
| 83f93c27a1 | |||
| d756b7cd43 | |||
| 1e6f504a84 | |||
| abb4f3f9f0 | |||
| 5b0209a7ce | |||
| c1e0685e97 | |||
| 3c57b28c6d | |||
| cab62ff7b3 | |||
| 5b6dfed359 | |||
| 91a85a065b | |||
| 32e2c51365 | |||
| 1992fdf398 | |||
| 59ac27971a | |||
| cd0ace2a39 | |||
| 274c728e77 | |||
| 8547127f54 | |||
| 45d799aa98 | |||
| 660c90626d | |||
| d9dc42895f | |||
| 02fd21bcdb | |||
| af00d6ad14 | |||
| 0d4a518a08 | |||
| 05b43ca258 | |||
| 9d6ea845d7 | |||
| bbca4e8e39 | |||
| 49954ec4ad | |||
| 6e94625f2a | |||
| 0399ad040f | |||
| 726ed1102e | |||
| 5bc7d203a5 | |||
| ce204aaf16 | |||
| ed8a89c5ce | |||
| 66e7d9432e | |||
| d59a9522ee | |||
| f1661b62b3 | |||
| a98562f46b | |||
| 365bd24740 | |||
| d50ea4d067 | |||
| 52c96437fa | |||
| 2a7c8ec81b | |||
| bd6c606303 | |||
| 112120e8fe | |||
| 00f6e3a7b4 | |||
| 9cc72f951b | |||
| 0f484682d8 | |||
| 60cf07fb0a | |||
| 493caee715 | |||
| aa6217e581 | |||
| a640f5504f | |||
| e01672b97d | |||
| 9e3d487a08 | |||
| 506cd2c5c5 | |||
| 6cb0ac5666 | |||
| b7d0ff2e16 | |||
| 984cefdc7b | |||
| 6c892cb7c1 | |||
| 52d1ddc5af | |||
| 75cd28435c | |||
| 9aa8b4e371 | |||
| e94d8e32ef | |||
| 3fae3088ca | |||
| cbb08f76ae | |||
| dd503706b0 | |||
| eeb181bcc0 | |||
| 287534cf26 | |||
| d08fce5fd5 | |||
| 0ae29744c8 | |||
| 84e1a5177b | |||
| c7e72149f1 | |||
| ecfca19f11 | |||
| 90a53e509a | |||
| 15fc97a996 | |||
| 35ed326dda | |||
| f4a1dd9e16 | |||
| 6f75a64c46 | |||
| dc04a4e848 | |||
| cd37aa175c | |||
| cb6cfb5556 | |||
| 439e97aee6 | |||
| 5528525888 | |||
| a49ba66c36 | |||
| 9de4db8531 | |||
| dbc48f377a | |||
| 1d77d42fe2 | |||
| 683fee8028 | |||
| a83d18fc51 | |||
| b9e8b00af0 | |||
| e2820e02c5 | |||
| f92364a002 | |||
| 9696c8d3e2 | |||
| 18b2bcd0a7 | |||
| f56970350a | |||
| 863f371e68 | |||
| 1bfc89fdd4 | |||
| 24c5114745 | |||
| 7def560726 | |||
| 6cbd0de77d | |||
| 9a8101df6a | |||
| 78744a6853 | |||
| efce1bb116 | |||
| 104c95a942 | |||
| f6392e8932 | |||
| 88e34ef65c | |||
| 8f0117a145 | |||
| 1f878c3098 | |||
| ae5276b2d1 | |||
| 4dd4c5c463 | |||
| cafa6f3d61 | |||
| f0ed2efe07 | |||
| bbf48c7e9e | |||
| 99d54b82b6 | |||
| 49776ca1eb | |||
| 272305f3a4 | |||
| 89f023c7dd | |||
| afc5a52978 | |||
| 12a04f4188 | |||
| 0fe7a96781 | |||
| a06a49bfed | |||
| 86b90fd4b8 | |||
| d18824cfae | |||
| 12b4c93279 | |||
| af47e19e1a | |||
| 4e666303d0 | |||
| 0fa8206321 | |||
| 78ac154998 | |||
| bbcaac7b71 | |||
| 719ba49a85 | |||
| 54ae20c69b | |||
| f864837cbe | |||
| 21191b8e80 | |||
| 6426e913b6 | |||
| 3d3e42c6a3 | |||
| 6fe106f55c | |||
| ba8ac3d143 | |||
| 1963a5453e | |||
| 59a383736c | |||
| 1820f411ae | |||
| 8327942a9c | |||
| aec7f6001a | |||
| d377518632 | |||
| ad9b03fe56 | |||
| 3b490bc42f | |||
| dacd9abe73 | |||
| f74e21782b | |||
| 8fbe819493 | |||
| 626080d228 | |||
| f56e82de81 | |||
| eec396e388 | |||
| 7d3950658a | |||
| 3c1246ee06 | |||
| d69965e3b1 | |||
| 30edac03a6 | |||
| dd4aface93 | |||
| b1350fdecd | |||
| f2d9b74490 | |||
| 84d09d0894 | |||
| e61f231316 | |||
| 33998aab5d | |||
| 1ef0d8c2c0 | |||
| 0f24105686 | |||
| d35b9e5c3e | |||
| ee5c168983 | |||
| 9ce7379946 | |||
| b692474e24 | |||
| 6cfc85ca83 | |||
| 7b060c19f4 | |||
| 604b9338f9 | |||
| 3a6557ffe2 | |||
| fe139ca7f4 | |||
| 5c617d3e32 | |||
| 8d11c04d43 | |||
| 92f73d8bed | |||
| 8da51aa7ba | |||
| 78f87111de | |||
| e3fcdd0776 | |||
| 6ea59f1a79 | |||
| 51fb4db82e | |||
| 7f78de8718 | |||
| 76aab74bf1 | |||
| a2bf08c6ff | |||
| b831076839 | |||
| 45575a1fd8 | |||
| 56d92246cb | |||
| 4a00f2335c | |||
| e9cb830d71 | |||
| c2666d2b43 | |||
| 9ceec3070d | |||
| f89977a8e7 | |||
| 8d512b95cb | |||
| 0914387c16 | |||
| 105672c132 | |||
| 0dd6ecc219 | |||
| 9ef5c8dead | |||
| 12ff4dba2e | |||
| ff9992dff8 | |||
| 98c08e4b22 | |||
| 0bd3b26acc | |||
| 59b55a8f37 | |||
| 67e6b1fb04 | |||
| 17243a7d8b | |||
| 16b6b98ede | |||
| e4ce1063a1 | |||
| f099593136 | |||
| f944507520 | |||
| 6824949247 | |||
| 4f54255518 | |||
| cfe482e1fc | |||
| 0e5ea26e36 | |||
| d5886e69aa | |||
| 32fa215243 | |||
| dd5d8afd82 | |||
| f19f4e9341 | |||
| 3aec2ca89b | |||
| 0f7261dd92 | |||
| 0886573d34 | |||
| 5c041ea857 | |||
| 30cd9afc92 | |||
| cceb74a025 | |||
| 42540076c1 | |||
| a528868656 | |||
| c82af28fd4 | |||
| d72e5db0a5 | |||
| 9907cfb0a0 | |||
| a3cd5a132e | |||
| 0971b47bec | |||
| f2ea250364 | |||
| d86ac70d9e | |||
| be8023a994 | |||
| a0ab949399 | |||
| 30ed68ae22 | |||
| f2aa376304 | |||
| 1b48862923 | |||
| 25417ffac8 | |||
| 4dfbc5a828 | |||
| f0f5bc7551 | |||
| f002db2929 | |||
| c5791574f5 | |||
| 8c1fbfb429 | |||
| 8e5501a23b | |||
| 50fd0e9c7d | |||
| 17843c2f7a | |||
| 54c8d2706b | |||
| 44bfb1b93f | |||
| 7a78992881 | |||
| 6ed41c3b87 | |||
| 7414c176d9 | |||
| d7581dbece | |||
| eeb8c89d3d | |||
| 2591fde764 | |||
| a8f3a3155c | |||
| 2a927651c5 | |||
| 62ef647811 | |||
| fe4491a87b | |||
| 105bf65e02 | |||
| 8120f113af | |||
| 7e0c64cb78 | |||
| 4afb54c54f | |||
| 74f6b68034 | |||
| 5d1e7ff3c1 | |||
| 3c628140f7 | |||
| 01f68d4038 | |||
| 8bf99c3318 | |||
| f0829ed54f | |||
| 21276e20d1 | |||
| 9fb9648e4c | |||
| af7689a1bc | |||
| 59dba325b9 | |||
| d13064ee64 | |||
| 669c9f4d8b | |||
| 228306f883 | |||
| 0a2ef854e6 | |||
| 61c471343a | |||
| 175bff4b29 | |||
| 95eb1c3a35 | |||
| cf50976d65 | |||
| 3fcee7f951 | |||
| 17a4c83970 | |||
| 9712fb1e9b | |||
| cbf622f4aa | |||
| 7c1cdea2eb | |||
| 2bfa491b6a | |||
| 41fbfc9ba6 | |||
| 1fa7c0eb43 | |||
| 839b238e74 | |||
| ec22d57cd1 | |||
| 23ef283272 | |||
| 327579f9b6 | |||
| e5f9d2903d | |||
| 1f3aa4e111 | |||
| 3f88d1c49a | |||
| 5348ba1c65 | |||
| 2f35fd44a1 | |||
| 470fe70f07 | |||
| 399bf557f3 | |||
| fc2ca6cc26 | |||
| d9d42ec956 | |||
| 5e41c74682 | |||
| 02ad846dfa | |||
| 4e7eda65ed | |||
| 3fabe4f591 | |||
| 81234ef2ab | |||
| 4bd28390ea | |||
| fa886899fc | |||
| a81126a03a | |||
| 164c555bb4 | |||
| e5926893a9 | |||
| dbcaf705c2 | |||
| f6809be81d | |||
| 3ddf70888e | |||
| fe7dcf33ec | |||
| 733450bf86 | |||
| 1dae9622e1 | |||
| da0fa5c9ad | |||
| e576dea676 | |||
| 4cac51a831 | |||
| fef844e220 | |||
| 0f45bb5792 | |||
| c195c5da7d | |||
| cb26367f46 | |||
| 31dc89c0d7 | |||
| c95c3003f7 | |||
| 35b3fa6d9c | |||
| e217d62f42 | |||
| e62801caaf | |||
| e6008bb299 | |||
| 9a49646d8a | |||
| 9934e4d765 | |||
| b6a397460e | |||
| 2b75c07571 | |||
| ebbc55c4d0 | |||
| 4e6ee3629f | |||
| 46bd0f6709 | |||
| e6c1ed8753 | |||
| 2b92562538 | |||
| 078bb1ade6 | |||
| f205464aa4 | |||
| b820d3917c | |||
| fea81f03fc | |||
| 75aa71a886 | |||
| 398eefc380 | |||
| b549e319c2 | |||
| 1a57b67e14 | |||
| 378246728b | |||
| 41e326f376 | |||
| f9adeca5ca | |||
| 838f87c224 | |||
| 5b519165fc | |||
| 0bb0518093 | |||
| 41f549b271 | |||
| 6b012a31ce | |||
| 1a2b23a066 | |||
| f109530c5f | |||
| 4cb132199c | |||
| fe16b3259c | |||
| 2e46954fe0 | |||
| aedc63c40d | |||
| 00d13d3c35 | |||
| 1b8b3ad538 | |||
| 5f75cfd691 | |||
| 5f89def2ce | |||
| 7c26eb9a24 | |||
| d951fe8fc5 | |||
| ac01135ca8 | |||
| 5e89e35e02 | |||
| 7e7afeaf82 | |||
| c6fa6d294b | |||
| fc2d17fc78 | |||
| 6fb5aa08ff | |||
| 9f072285a6 | |||
| c58ebe4b9b | |||
| 0dd187fe30 | |||
| ea78792c03 | |||
| ba8aa7b263 | |||
| 243cb6a477 | |||
| c987fefede | |||
| 9954225701 | |||
| 2a038f7112 | |||
| 6bb2e078c0 | |||
| 323c59e54f | |||
| d26d63ed38 | |||
| 2009df3dd1 | |||
| 548e75a15e | |||
| 339c85b590 | |||
| 0f0420314b | |||
| a08b5e65f7 | |||
| 99b89c4e5e | |||
| 20586e0c48 | |||
| b54d0c605a | |||
| 2f75af902c | |||
| 9147a6413e | |||
| 9a467d4bf1 | |||
| e40f9337b6 | |||
| 1f0712d2d5 | |||
| 345bc9519a | |||
| 0b30bb2eff | |||
| f0e88aa581 | |||
| 317893bd61 | |||
| bd181271d9 | |||
| 4df09010a8 | |||
| 1a5b2b96dc | |||
| 43d89813fc | |||
| 5bc76ce6fe | |||
| f0f3e9634b | |||
| 1fe35335a5 | |||
| ca1beaefe7 | |||
| 24cf1b3317 | |||
| 88e2ec2042 | |||
| be60c7c1d5 | |||
| 9be21380c7 | |||
| 22fd196045 | |||
| d5293dffb8 | |||
| 9326b07583 | |||
| 90437a5d8d | |||
| c4ec5dc1c5 | |||
| 3b19826f1e | |||
| 95e4715bc3 | |||
| 81edc97d86 | |||
| 92c8bac714 | |||
| 1df04aa18d | |||
| 8654444d1a | |||
| 772c39edb8 | |||
| 74e7474349 | |||
| 3aefe41ea1 | |||
| 2223c31e93 | |||
| 568f0fa9eb | |||
| e91cf0f0c2 | |||
| 8a1f583d88 | |||
| 7518319c80 | |||
| 304df44742 | |||
| e92bab6f4f | |||
| 43755c5457 | |||
| d4455c508f | |||
| 7d01242cd1 | |||
| 9d77189cec | |||
| c8adbdcc64 | |||
| ac6dfe7134 | |||
| b1ad7c7254 | |||
| 218b9249a2 | |||
| 6fb5602cb0 | |||
| b942d37944 | |||
| b7ee191b6c | |||
| 4ea24a0a6f | |||
| 6ad8c8d73d | |||
| 8982952fa4 | |||
| b135878824 | |||
| e9463d39aa | |||
| dcee7ca8e4 | |||
| 1653aa0a5d | |||
| 2699c9a2f9 | |||
| efd81d8e34 | |||
| 925b224eda | |||
| 7504753ea8 | |||
| da60f40dfc | |||
| 560ebc5997 | |||
| 361d4b81df | |||
| 83cf1fdb00 | |||
| 228a0baf81 | |||
| df7cea4387 | |||
| 13b55a797e | |||
| 1ec33b1374 | |||
| c06991e03d | |||
| 2fcd7abbfd | |||
| 5ca17cfc99 | |||
| 05f1e3852c | |||
| 3b22a45a23 | |||
| f172d3129f | |||
| a7a84b6fab | |||
| d4afa09e03 | |||
| 50947eec2a | |||
| 2624bb0210 | |||
| 6e9685f302 | |||
| 898bf0c71e | |||
| 5a7add8eaa | |||
| 66334dfacd | |||
| a33f3b1ceb | |||
| 6e724cedf7 | |||
| e949c78639 | |||
| 9bcfdb1f0d | |||
| fccb122c59 | |||
| e2b2a5fb9f | |||
| f2a3e5f05b | |||
| 5f855568c8 | |||
| 24696e3be2 | |||
| b17dce232e | |||
| e1b20723fd | |||
| fdab15a6dc | |||
| 33899c237e | |||
| 0fad222569 | |||
| 9c5f33e187 | |||
| ddcce5b95a | |||
| 6fd9741fab | |||
| 4916fc8f4b | |||
| fc19e58678 | |||
| 9610bcfb72 | |||
| 1bc252741a | |||
| caa65182dc | |||
| 97e3fb296c | |||
| 74eeb411ba | |||
| 040ca3f332 | |||
| 5ec440792e | |||
| 9d3ee6a597 | |||
| 6c4c9d14e2 | |||
| d165189032 | |||
| 84bf25fcd5 | |||
| 3945b9d078 | |||
| 6796804593 | |||
| 858c5bc9b8 | |||
| 71bdbb7e48 | |||
| c346c78697 | |||
| ba2bac5bed | |||
| bcfc94d448 | |||
| bebfc87cd4 | |||
| c8e035d332 | |||
| e58929428c | |||
| a389416b7b | |||
| 0a8bca1a6d | |||
| efad8b325b | |||
| 4c5f776128 | |||
| c1495e0d5e | |||
| a97168a4f0 | |||
| a50ad30176 | |||
| c1f65106de | |||
| 4250e3aa32 | |||
| cecb24bb0e | |||
| b99b497893 | |||
| e16a45d6ba | |||
| 0e29046c31 | |||
| 5cbada493a | |||
| 4c79db3d5d | |||
| c7e46b6563 | |||
| fe1714d50e | |||
| 2765e3d145 | |||
| c62f1334bc | |||
| eea0dec4a6 | |||
| 25d241f12c | |||
| 0b55ab083a | |||
| 6b9d4f2470 | |||
| b9a89c1fd2 | |||
| 8a8340ffc8 | |||
| 0af69e2df6 | |||
| 4b9a326f6b | |||
| a92761dc13 | |||
| 8a649e8d95 | |||
| 5c5c970e81 | |||
| 7b208043b9 | |||
| 26dfad0682 | |||
| 902f0d73d4 | |||
| f6ae967796 | |||
| 29afbdaec7 | |||
| 350402b179 | |||
| 0f581e2f69 | |||
| 17eee1ca0e | |||
| 7aa06942be | |||
| d3010dc060 | |||
| 53e1a535dd | |||
| e63fa70700 | |||
| 27fb6eedc6 | |||
| 5c0a7d1456 | |||
| e10f6e2032 | |||
| c61643edaa | |||
| 0b7b0b48d1 | |||
| 2ff459e2c4 | |||
| 8c877a64f0 | |||
| 8fdfd5c843 | |||
| 579c1744bd | |||
| 118c45f8f6 | |||
| beaa502ff9 | |||
| 730b7b842c | |||
| 784832ca95 | |||
| bbe894fb4f | |||
| 4c17e606fd | |||
| 73d81a72b8 | |||
| 8b0f770c57 | |||
| a89d43f315 | |||
| 96a64d439e | |||
| 401f79877c | |||
| c52a35288a | |||
| a37d1a0676 | |||
| 0e56e0a539 | |||
| cc67be341f | |||
| ff829569b0 | |||
| bc4afcba71 | |||
| dde25ec7d0 | |||
| 9eb80575e5 | |||
| 590e082328 | |||
| 2f52a0651a | |||
| e393ec06e3 | |||
| cd33858020 | |||
| 0dddb32305 | |||
| f3165ceb71 | |||
| d6e36d457f | |||
| 52e3a0d23f | |||
| fb58fa788b | |||
| d09cf80da5 | |||
| aab980b05a | |||
| f1950348d4 | |||
| 5603477787 | |||
| 0ca1defb66 | |||
| 370ddea0b8 | |||
| 6a9e77cde0 | |||
| a03f69926e | |||
| 04ece522d7 | |||
| 4943d3e5f4 | |||
| 5a48a61f2e | |||
| fd66a04686 | |||
| b69f8c27c9 | |||
| 66f0326492 | |||
| dbab4a2116 | |||
| 3e8da9b232 | |||
| 223202f230 | |||
| 3078c20e1d | |||
| 617a87a47b | |||
| 8539a6b268 | |||
| f9f38d161f | |||
| 8b85cda84c | |||
| 1deba07106 | |||
| c24c7cbcb7 | |||
| c2334f2f91 | |||
| bfac5fe327 | |||
| 3b1032cc63 | |||
| 72f83760b7 | |||
| 63a179b77a | |||
| 37ba5c3879 | |||
| 00f86ca192 | |||
| b0868e7571 | |||
| 9ec3ff26d8 | |||
| 726e1d4ec5 | |||
| 5b3c5f5da8 | |||
| c45f6be8f2 | |||
| 3b2e489325 | |||
| 10acfe0710 | |||
| 5113cfd951 | |||
| 43158d724b | |||
| f0a88ea473 | |||
| 31365d8dfd | |||
| 531a719794 | |||
| ff90062741 | |||
| 803f86d5b3 | |||
| 3448fae3f5 | |||
| 25df838b45 | |||
| be90e5b42c | |||
| 08e8dfc416 | |||
| 6a27be54de | |||
| d4055209e6 | |||
| 043f6b2426 | |||
| f7695308b7 |
@@ -18,17 +18,59 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: game
|
||||
attributes:
|
||||
label: Which game were you playing
|
||||
description: Pick every version you saw the bug in. Use N/A if it isn't game-specific.
|
||||
multiple: true
|
||||
options:
|
||||
- Red
|
||||
- Blue
|
||||
- Yellow
|
||||
- Gold
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Which build are you running
|
||||
description: Official release targets. Pick Multiple platforms if you saw it on more than one.
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Both
|
||||
- Linux
|
||||
- Android
|
||||
- iOS
|
||||
- Nintendo Switch
|
||||
- Xbox
|
||||
- Anbernic RG34XXSP
|
||||
- Multiple platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: mods_enabled
|
||||
attributes:
|
||||
label: Were any mods on
|
||||
description: Check the MODS tab in the launcher if you're not sure.
|
||||
options:
|
||||
- "No"
|
||||
- "Yes"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: mods_which
|
||||
attributes:
|
||||
label: Which mods (if any were on)
|
||||
description: List the enabled mods. Leave blank if none were on.
|
||||
placeholder: nuzlocke 1.0.0, running-shoes 0.3
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
@@ -38,6 +80,17 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
label: Discord username (optional)
|
||||
description: >
|
||||
So maintainers can ping you on Discord if they need a quick follow-up.
|
||||
Leave blank if you'd rather keep everything on GitHub.
|
||||
placeholder: yourname
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
|
||||
@@ -1,35 +1,68 @@
|
||||
name: Feature request
|
||||
description: Ask for something new, or a change to how something already works.
|
||||
description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod.
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this for **engine / launcher / platform** work (ports, video options, save
|
||||
tooling, networking, mod API seams, docs).
|
||||
|
||||
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
|
||||
could ship — running shoes, alternate sprites, day/night, shiny indicators,
|
||||
Gen 2-like battle toggles, soundtrack packs — open a
|
||||
**[Mod request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=mod_request.yml)**
|
||||
instead.
|
||||
|
||||
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
||||
and how you picture it working. A request with real detail is one someone can
|
||||
actually build without guessing at what you meant.
|
||||
and how you picture it working.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What you want, in a sentence.
|
||||
placeholder: Add a run toggle so you can move at bike speed without the bike
|
||||
placeholder: Add Linux AppImage releases next to the macOS and Windows builds
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: game
|
||||
attributes:
|
||||
label: Which game is this about
|
||||
description: Pick every version it applies to. Use N/A if it isn't game-specific.
|
||||
multiple: true
|
||||
options:
|
||||
- Red
|
||||
- Blue
|
||||
- Yellow
|
||||
- Gold
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
label: Discord username (optional)
|
||||
description: >
|
||||
So maintainers can ping you on Discord if they need a quick follow-up.
|
||||
Leave blank if you'd rather keep everything on GitHub.
|
||||
placeholder: yourname
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What do you want
|
||||
description: >
|
||||
Describe it properly. What is it, where does it live in the game, what does the
|
||||
player see or do. If it changes something that already exists, say what it does today
|
||||
and what it should do instead.
|
||||
Describe it properly. What is it, where does it live (launcher, options,
|
||||
engine), what does the player see or do. If it changes something that already
|
||||
exists, say what it does today and what it should do instead.
|
||||
placeholder: |
|
||||
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
|
||||
foot and the player moves at bike speed. Release and you go back to walking.
|
||||
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
|
||||
Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
|
||||
with the same save folder layout and mod discovery path.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -41,9 +74,8 @@ body:
|
||||
What's annoying or missing right now. What does this fix. If it's just because you
|
||||
think it would be fun, say that, it's a real answer.
|
||||
placeholder: |
|
||||
Backtracking across routes you've already cleared is slow, and the bike is a menu
|
||||
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
|
||||
without making the game easier.
|
||||
LÖVE already runs on Linux; without a packaged build, players have to assemble
|
||||
it themselves and miss release notes / update checks.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -52,14 +84,12 @@ body:
|
||||
attributes:
|
||||
label: How should it work
|
||||
description: >
|
||||
The specifics. Which button, which menu, what happens in the edge cases. If you don't
|
||||
The specifics. Which menu, what happens in the edge cases. If you don't
|
||||
know, say what you'd expect as a player and leave the rest open.
|
||||
placeholder: |
|
||||
- Hold B on the overworld to run
|
||||
- Same speed as the bike
|
||||
- Disabled anywhere the bike is disabled
|
||||
- Sprite should probably need a running animation, not sure what that takes
|
||||
- No effect on encounter rates
|
||||
- GitHub Releases asset next to the .dmg / .exe
|
||||
- Same options.lua / mods/ layout as desktop
|
||||
- Documented in the README install section
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
name: Mod request
|
||||
description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod.
|
||||
labels: ["mod request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
This tracker is for ideas that should ship as **mods**, not as core engine
|
||||
features — alternate sprites, running shoes, day/night, shiny indicators,
|
||||
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
|
||||
|
||||
The engine already exposes a lot of this through registries and hooks
|
||||
([modding wiki](https://github.com/bryanthaboi/gen1recomp/wiki)).
|
||||
Use a **Feature request** instead for launcher / ports / video options /
|
||||
networking / save tooling / new API seams.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What the mod should do, in a sentence.
|
||||
placeholder: Hold B to run at bike speed on the overworld
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: game
|
||||
attributes:
|
||||
label: Which game is this for
|
||||
description: Pick every version the mod should cover. Use N/A if it isn't game-specific.
|
||||
multiple: true
|
||||
options:
|
||||
- Red
|
||||
- Blue
|
||||
- Yellow
|
||||
- Gold
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
label: Discord username (optional)
|
||||
description: >
|
||||
So maintainers or mod authors can ping you on Discord if they pick this up.
|
||||
Leave blank if you'd rather keep everything on GitHub.
|
||||
placeholder: yourname
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What should the mod do
|
||||
description: >
|
||||
Describe the player-facing behavior. What changes, where, what does the
|
||||
player see or press. If it toggles from Options or a START-menu entry, say so.
|
||||
placeholder: |
|
||||
Hold B while walking outdoors to move at bike speed. Release to walk again.
|
||||
Same places the bike is allowed; no effect in battles or menus.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why is this worth doing as a mod
|
||||
description: >
|
||||
Why optional/modded rather than a core option. Who wants it on, who wants
|
||||
vanilla left alone.
|
||||
placeholder: |
|
||||
Great for replaying and backtracking, but some people want a strict Gen 1
|
||||
pace. A mod (or an opt-in mod option) keeps both camps happy.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how
|
||||
attributes:
|
||||
label: How should it work
|
||||
description: >
|
||||
Buttons, menus, edge cases, whether it needs new art/audio. If you know a
|
||||
hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …),
|
||||
mention it — otherwise leave it open.
|
||||
placeholder: |
|
||||
- Hold B on the overworld
|
||||
- Same step timing as the bike
|
||||
- Disabled where the bike is disabled
|
||||
- Prefer hooks:wrap("movement.speed") if that still fits
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: vanilla
|
||||
attributes:
|
||||
label: With the mod off, is vanilla unchanged
|
||||
options:
|
||||
- Yes — parity when disabled
|
||||
- No — it would replace something always-on
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Best-fit mod category
|
||||
description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …).
|
||||
options:
|
||||
- GAMEPLAY / QoL
|
||||
- GRAPHICS
|
||||
- AUDIO
|
||||
- BALANCE / ruleset
|
||||
- CONTENT (maps, encounters, trainers)
|
||||
- UI / TOOL
|
||||
- TOTAL_CONVERSION-ish
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: >
|
||||
Reference screenshots, other games/hacks that do it, related issues, or
|
||||
"I'd like to try writing this myself." Leave blank if nothing comes to mind.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 582 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 730 KiB |
@@ -10,11 +10,11 @@ name: ci
|
||||
# The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects
|
||||
# data/generated/ is absent and skips it rather than failing.
|
||||
#
|
||||
# Runs alongside release.yml, which is untouched by this file.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Integration branch + release branch. PRs already run via pull_request
|
||||
# (any base); this list is only for post-merge push runs.
|
||||
branches: [dev, main]
|
||||
pull_request:
|
||||
|
||||
# a force-push while CI is mid-run should cancel the stale run, not queue
|
||||
@@ -22,12 +22,360 @@ concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ios-changes:
|
||||
name: detect iOS changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.paths.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(mobile/ios/|scripts/build_ios\.sh$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
ios-build:
|
||||
name: iOS build
|
||||
needs: ios-changes
|
||||
if: needs.ios-changes.outputs.changed == 'true'
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
outputs:
|
||||
ipa_url: ${{ steps.upload-ipa.outputs.artifact-url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: import signing certificate
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
keychain_path="$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db"
|
||||
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
|
||||
p12="$ci_dir/signing.p12"
|
||||
passfile="$ci_dir/signing.pass"
|
||||
[ -f "$p12" ] && [ -f "$passfile" ] || exit 1
|
||||
p12pw="$(cat "$passfile")"
|
||||
kcpw="$(openssl rand -base64 24)"
|
||||
echo "::add-mask::$kcpw"
|
||||
security delete-keychain "$keychain_path" 2>/dev/null || true
|
||||
security create-keychain -p "$kcpw" "$keychain_path"
|
||||
security set-keychain-settings "$keychain_path"
|
||||
security unlock-keychain -p "$kcpw" "$keychain_path"
|
||||
security import "$p12" -P "$p12pw" -k "$keychain_path" -T /usr/bin/codesign -T /usr/bin/security
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kcpw" "$keychain_path" >/dev/null
|
||||
existing="$(security list-keychains -d user | sed -e 's/^[[:space:]]*//' -e 's/"//g')"
|
||||
security list-keychains -d user -s "$keychain_path" $existing
|
||||
- name: install xcbeautify
|
||||
run: brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
|
||||
- name: build iOS release
|
||||
env:
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
||||
scripts/build_ios.sh --fetch --device --release
|
||||
else
|
||||
scripts/build_ios.sh --fetch --release
|
||||
fi
|
||||
- name: upload iOS release artifact
|
||||
id: upload-ipa
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp++-ios-ipa
|
||||
path: dist/ios/gen1recomp++.ipa
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: clean up signing keychain
|
||||
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: security delete-keychain "$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true
|
||||
|
||||
switch-changes:
|
||||
name: detect Switch changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.paths.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
switch-selftest:
|
||||
name: Switch offline selftest
|
||||
needs: switch-changes
|
||||
if: needs.switch-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: install luajit
|
||||
run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
- name: Switch offline selftest
|
||||
run: bash scripts/switch/selftest_build_switch.sh
|
||||
- name: verify_payload self-test
|
||||
run: bash scripts/switch/verify_payload.sh --self-test
|
||||
- name: Switch CI workflow content gate
|
||||
run: luajit tests/switch_ci_workflows_test.lua
|
||||
- name: Switch transfer docs content gate
|
||||
run: luajit tests/switch_transfer_docs_test.lua
|
||||
# NX runtime regressions gate this job via switch-changes; run the NX
|
||||
# engine suites here too so a PR touching them gets feedback on the
|
||||
# fork-safe ubuntu runner before the self-hosted Mac build.
|
||||
- name: NX engine suites (headless)
|
||||
run: |
|
||||
luajit tests/engine/assets_version_fallback_test.lua
|
||||
luajit tests/engine/nx_generated_guard_test.lua
|
||||
luajit tests/engine/nx_yellow_boot_test.lua
|
||||
|
||||
switch-build:
|
||||
name: Switch fused build
|
||||
needs: [switch-changes, switch-selftest]
|
||||
if: |
|
||||
always()
|
||||
&& needs.switch-changes.outputs.changed == 'true'
|
||||
&& needs.switch-selftest.result == 'success'
|
||||
&& github.repository == 'bryanthaboi/gen1recomp'
|
||||
&& (github.event_name != 'pull_request'
|
||||
|| github.event.pull_request.head.repo.full_name == github.repository)
|
||||
runs-on: ["self-hosted", "macOS"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build Switch fused NRO
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="$(printf '%s' "$GITHUB_SHA" | cut -c1-7)"
|
||||
scripts/build_switch.sh --fetch --fused --version "$VER"
|
||||
echo "SWITCH_VER=$VER" >> "$GITHUB_ENV"
|
||||
- name: upload Switch NRO artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-switch-nro
|
||||
path: |
|
||||
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro
|
||||
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
xbox-uwp-changes:
|
||||
name: detect Xbox UWP changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.paths.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|LauncherView|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_baseroms|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
xbox-uwp-selftest:
|
||||
name: Xbox UWP offline selftest
|
||||
needs: xbox-uwp-changes
|
||||
if: needs.xbox-uwp-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Xbox UWP offline selftest
|
||||
run: bash scripts/xbox-uwp/selftest_build_xbox_uwp.sh
|
||||
- name: Build shared payload
|
||||
run: |
|
||||
scripts/pack_love.sh \
|
||||
--output .bazinga/work/ci-game.love \
|
||||
--listing .bazinga/work/ci-love-listing.txt \
|
||||
--version 0.0.0
|
||||
- name: Upload shared payload
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-payload
|
||||
path: .bazinga/work/ci-game.love
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
xbox-uwp-build:
|
||||
name: Xbox UWP build
|
||||
needs: [xbox-uwp-changes, xbox-uwp-selftest]
|
||||
if: |
|
||||
always()
|
||||
&& needs.xbox-uwp-changes.outputs.changed == 'true'
|
||||
&& needs.xbox-uwp-selftest.result == 'success'
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-payload
|
||||
path: .bazinga/work
|
||||
- name: Build Xbox UWP package
|
||||
shell: bash
|
||||
run: |
|
||||
bash scripts/build_xbox_uwp.sh \
|
||||
--release \
|
||||
--version 0.0.0 \
|
||||
--game-love .bazinga/work/ci-game.love
|
||||
- name: Upload Xbox UWP package
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp
|
||||
path: |
|
||||
dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip
|
||||
dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
linux-arm64-changes:
|
||||
name: detect Linux arm64 changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.paths.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_linux_arm64\.sh$|scripts/linux-arm64/|scripts/pack_love\.sh$|docs/linux-arm64-build\.md$|\.github/workflows/(ci|release)\.yml$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
linux-arm64-selftest:
|
||||
name: Linux arm64 offline selftest
|
||||
needs: linux-arm64-changes
|
||||
if: needs.linux-arm64-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Deliberately on x86_64: everything this gate checks (pins, the
|
||||
# host-arch guard, the dependency exclude list, the AppRun fusion
|
||||
# contract) is answerable without a container or an aarch64 machine,
|
||||
# so the slow native job below only ever starts on a sane tree.
|
||||
- name: Linux arm64 offline selftest
|
||||
run: bash scripts/linux-arm64/selftest_build_linux_arm64.sh
|
||||
|
||||
linux-arm64-build:
|
||||
name: Linux arm64 AppImage build
|
||||
needs: [linux-arm64-changes, linux-arm64-selftest]
|
||||
if: |
|
||||
always()
|
||||
&& needs.linux-arm64-changes.outputs.changed == 'true'
|
||||
&& needs.linux-arm64-selftest.result == 'success'
|
||||
# No fork restriction, unlike switch-build: this needs no secrets and no
|
||||
# self-hosted hardware, just GitHub's free arm64 runner for public repos,
|
||||
# so contributors get the same coverage on their own PRs.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build the aarch64 AppImage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh --version 0.0.0
|
||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
|
||||
|
||||
# --appimage-extract needs no FUSE, so this works on a runner
|
||||
# without /dev/fuse and still exercises the real payload.
|
||||
"$image" --appimage-extract >/dev/null
|
||||
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
||||
[ -e "squashfs-root/$required" ] \
|
||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
||||
done
|
||||
|
||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
||||
# applied; an unresolved soname here is a user-visible launch crash.
|
||||
#
|
||||
# This runs on a HEADLESS runner on purpose, and that is the point.
|
||||
# The first version of this build bundled Debian's SDL2, which
|
||||
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
||||
# started on a full desktop -- a bare runner is what exposed it.
|
||||
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
||||
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep 'not found' || true)"
|
||||
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
||||
|
||||
# Nothing may hard-link a driver, session or audio-stack library:
|
||||
# those must be reached through dlopen so the AppImage runs on a box
|
||||
# with only ALSA, only Wayland, or only KMSDRM.
|
||||
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
||||
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
||||
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
||||
[ -z "$linked" ] \
|
||||
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
||||
|
||||
# The whole point of compiling on bullseye. If a future change moves
|
||||
# the builder to a newer base, the glibc floor silently rises and
|
||||
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
||||
# it here instead of in a release.
|
||||
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
||||
echo "highest required glibc symbol version: $floor"
|
||||
[ -n "$floor" ] \
|
||||
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
||||
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
||||
[ "$highest" = "GLIBC_2.31" ] \
|
||||
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
||||
- name: Upload the AppImage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-arm64
|
||||
path: |
|
||||
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
headless:
|
||||
name: headless suites (no ROM)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is
|
||||
# written to Lua 5.1 semantics, so CI must run the interpreter the
|
||||
@@ -35,6 +383,9 @@ jobs:
|
||||
- name: install luajit
|
||||
run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
|
||||
- name: install Pillow
|
||||
run: python3 -m pip install --upgrade pillow
|
||||
|
||||
- name: interpreter version
|
||||
run: luajit -v
|
||||
|
||||
@@ -45,7 +396,7 @@ jobs:
|
||||
name: fixture dataset integrity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
@@ -87,7 +438,7 @@ jobs:
|
||||
name: screenshot differ (capture not yet wired)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
# 21-testing-and-ci §"Testing & acceptance criteria": compare_shots
|
||||
@@ -135,7 +486,7 @@ jobs:
|
||||
name: mod lint (no ROM-derived content)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# the MK305 dump check key-diffs shipped tables through luajit, and
|
||||
# modkit treats a missing interpreter as a fatal MK100 -- without
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: iOS artifact comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ci]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
run: |
|
||||
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp++-ios-ipa") | .id')"
|
||||
[ -n "$artifact_id" ] || exit 0
|
||||
head_owner="${HEAD_REPOSITORY%%/*}"
|
||||
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
|
||||
[ -n "$pr_number" ] || exit 0
|
||||
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
# Upsert via comment-tag only — do not delete-all bot comments (clobbers Switch).
|
||||
- name: Get build info
|
||||
id: build-info
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
|
||||
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
|
||||
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
|
||||
echo "time=$build_time" >> "$GITHUB_OUTPUT"
|
||||
- name: comment iOS artifact
|
||||
if: steps.artifact.outputs.pr_number != ''
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
message: |
|
||||
[gen1recomp++.ipa](${{ steps.artifact.outputs.artifact_url }})
|
||||
|
||||
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
|
||||
**Build Time**: `${{ steps.build-info.outputs.time }}`
|
||||
|
||||
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
|
||||
pr-number: ${{ steps.artifact.outputs.pr_number }}
|
||||
comment-tag: ios-build-result
|
||||
github-token: ${{ github.token }}
|
||||
@@ -1,7 +1,10 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
|
||||
# on the self-hosted Mac runner, and publishes them as a GitHub Release.
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, the Anbernic
|
||||
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) and Linux ARM SBC PortMaster
|
||||
# handheld ports on the self-hosted Mac runner, and publishes them as a
|
||||
# GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -10,6 +13,9 @@ name: Release
|
||||
# - To force a specific version, either:
|
||||
# * run this workflow manually (Actions tab) and type it into "version", or
|
||||
# * put "[release X.Y.Z]" anywhere in the commit message.
|
||||
#
|
||||
# Branch model: day-to-day work merges to `dev`. Releases stay on `main` only
|
||||
# so promoting `dev` -> `main` is the ship gate that cuts a build.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -20,6 +26,7 @@ on:
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
- '**.md'
|
||||
- 'mobile/ios/app-repo.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -37,16 +44,17 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: [self-hosted, macOS]
|
||||
|
||||
version:
|
||||
name: determine release version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
tag: ${{ steps.ver.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
env:
|
||||
@@ -54,7 +62,6 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
semver_re='^[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
|
||||
# 1) Explicit override from a manual run.
|
||||
@@ -84,7 +91,6 @@ jobs:
|
||||
| grep -E "$semver_re" \
|
||||
| sort -t. -k1,1n -k2,2n -k3,3n \
|
||||
| tail -1 || true)"
|
||||
|
||||
if [ -z "$latest" ]; then
|
||||
version="0.1.0"
|
||||
echo "No existing release tag; starting at $version"
|
||||
@@ -112,11 +118,197 @@ jobs:
|
||||
echo "::error::Release $tag already exists. Pick a different version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
love-payload:
|
||||
name: build release game.love
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build shared payload
|
||||
run: |
|
||||
scripts/pack_love.sh \
|
||||
--output dist/payload/game.love \
|
||||
--listing dist/payload/love-listing.txt \
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
- name: Upload shared payload
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: dist/payload/game.love
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
linux-arm64:
|
||||
name: build Linux arm64 AppImage
|
||||
needs: [version, love-payload]
|
||||
# GitHub's free arm64 runner for public repos. It has to be arm64: the
|
||||
# AppImage compiles LÖVE natively inside a Debian bullseye arm64
|
||||
# container, and the qemu-emulated alternative takes hours.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Build Linux arm64 AppImage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Upload Linux arm64 release
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-arm64-release
|
||||
path: |
|
||||
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
xbox-uwp:
|
||||
name: build Xbox UWP release
|
||||
needs: [version, love-payload]
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Prepare signing certificate
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTIFICATE_BASE64: ${{ secrets.XBOX_UWP_SIGNING_CERTIFICATE }}
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }}
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
if ($env:CANONICAL_REPOSITORY -eq 'true' -and
|
||||
[string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) {
|
||||
throw 'XBOX_UWP_SIGNING_CERTIFICATE is not configured.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) {
|
||||
"UWP_PUBLISHER=CN=Gen1Recomp" | Out-File $env:GITHUB_ENV -Append
|
||||
exit 0
|
||||
}
|
||||
$pfx = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.pfx'
|
||||
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:CERTIFICATE_BASE64))
|
||||
$flags = [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet
|
||||
$cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
|
||||
$pfx, $env:CERTIFICATE_PASSWORD, $flags)
|
||||
$cer = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.cer'
|
||||
[IO.File]::WriteAllBytes(
|
||||
$cer,
|
||||
$cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert))
|
||||
Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null
|
||||
"UWP_PFX=$pfx" | Out-File $env:GITHUB_ENV -Append
|
||||
"UWP_CERT_THUMBPRINT=$($cert.Thumbprint)" | Out-File $env:GITHUB_ENV -Append
|
||||
"UWP_PUBLISHER=$($cert.Subject)" | Out-File $env:GITHUB_ENV -Append
|
||||
- name: Build Xbox UWP package
|
||||
shell: bash
|
||||
run: |
|
||||
bash scripts/build_xbox_uwp.sh \
|
||||
--release \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--publisher "$UWP_PUBLISHER" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Sign and stage Xbox UWP release
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }}
|
||||
run: |
|
||||
if (-not $env:UWP_PFX) {
|
||||
exit 0
|
||||
}
|
||||
scripts/xbox-uwp/stage_release.ps1 `
|
||||
-Version '${{ needs.version.outputs.version }}' `
|
||||
-Configuration Release `
|
||||
-BuildInfo .bazinga/work/xbox-uwp-build-info.json `
|
||||
-CertificatePath $env:UWP_PFX `
|
||||
-CertificatePassword $env:CERTIFICATE_PASSWORD
|
||||
- name: Upload Xbox UWP release
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-release
|
||||
path: |
|
||||
dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip
|
||||
dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
- name: Remove signing certificate
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ($env:UWP_CERT_THUMBPRINT) {
|
||||
Remove-Item "Cert:\LocalMachine\TrustedPeople\$env:UWP_CERT_THUMBPRINT" -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($env:UWP_PFX) {
|
||||
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Windows Native AOT TLS dialer. The Mac release runner fuses the win64 zip
|
||||
# from LÖVE's prebuilt binaries and cannot cross-compile this DLL, so build
|
||||
# it here and inject it in the release job before scripts/build.sh win.
|
||||
native-tls-win:
|
||||
name: build Windows gen1tls.dll
|
||||
needs: version
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup .NET 8
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
- name: Publish gen1tls (win-x64 Native AOT)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$out = "dist/native/win-x64"
|
||||
New-Item -ItemType Directory -Force -Path $out | Out-Null
|
||||
dotnet publish native/tls_dial/Gen1Tls.csproj `
|
||||
-c Release -r win-x64 -o $out
|
||||
if (-not (Test-Path "$out/gen1tls.dll")) {
|
||||
throw "gen1tls.dll missing after publish"
|
||||
}
|
||||
Get-Item "$out/gen1tls.dll" | Format-List Name, Length, LastWriteTime
|
||||
- name: Upload gen1tls.dll
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64/gen1tls.dll
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
# The self-hosted runner lives under the machine owner's home
|
||||
# directory; mask it first so absolute paths in every later step's
|
||||
# output show up as *** in the public workflow logs.
|
||||
- name: Mask runner paths
|
||||
run: echo "::add-mask::$HOME"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Download Windows gen1tls dialer
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64
|
||||
|
||||
- name: Import signing certificate into a temporary keychain
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/pokemon-signing.keychain-db"
|
||||
@@ -153,20 +345,79 @@ jobs:
|
||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||
|
||||
- name: Build macOS + Windows + Linux
|
||||
env:
|
||||
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Sign in-build (identity auto-detected from the temp keychain);
|
||||
# notarize separately below so it uses secret credentials, not a
|
||||
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||
# which needs no signing/notarization.
|
||||
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
|
||||
if [ ! -f "$GEN1TLS_DLL" ]; then
|
||||
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
||||
exit 1
|
||||
fi
|
||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||
|
||||
- name: Build Android
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Install xcbeautify
|
||||
run: |
|
||||
set -euo pipefail
|
||||
brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
|
||||
|
||||
- name: Build iOS
|
||||
env:
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
||||
scripts/build_ios.sh --fetch --device --release \
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
else
|
||||
scripts/build_ios.sh --fetch --release \
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
fi
|
||||
|
||||
- name: Build Switch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Hard-fail gate: Switch ships with every release (never soft-fail).
|
||||
# PR CI is path-gated (ubuntu selftest + canonical fused); release
|
||||
# always builds Switch regardless of which files changed.
|
||||
# Needs native switch-tools (nacptool/elf2nro) and/or Docker on the
|
||||
# Mac self-hosted runner; see docs/switch-build.md.
|
||||
scripts/build_switch.sh --fetch --fused \
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
||||
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Build Linux ARM SBC PortMaster port
|
||||
env:
|
||||
# The release workflow must package the commit being released. The
|
||||
# script defaults to the latest published release for standalone
|
||||
# builds, while this explicit local override keeps CI source-aligned.
|
||||
GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }}
|
||||
GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build
|
||||
# keeps its own cache because the two scripts use different staging
|
||||
# layouts and runtime package paths.
|
||||
./build-linux-arm-sbc.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
|
||||
@@ -200,21 +451,70 @@ jobs:
|
||||
ditto -c -k --sequesterRsrc --keepParent "$app" "$zip"
|
||||
echo "Notarized + stapled ✓"
|
||||
|
||||
- name: Download Xbox UWP release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-release
|
||||
path: dist/xbox-uwp
|
||||
|
||||
- name: Download Linux arm64 release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-linux-arm64-release
|
||||
path: dist/linux-arm64
|
||||
|
||||
- name: Stage release assets
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
id: assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
v="${{ needs.version.outputs.version }}"
|
||||
outdir="dist/release"
|
||||
rm -rf "$outdir"
|
||||
mkdir -p "$outdir"
|
||||
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
|
||||
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
||||
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
||||
|
||||
# arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on
|
||||
# its own runner because LÖVE publishes no aarch64 binary and the
|
||||
# AppImage has to be compiled natively; ships as a runnable
|
||||
# AppImage rather than a zip so `chmod +x && ./it` just works.
|
||||
arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
||||
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
ipa="dist/ios/gen1recomp++.ipa"
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
|
||||
cp "$ipa" "$outdir/gen1recomp++-${v}-ios.ipa"
|
||||
|
||||
swzip="dist/switch/gen1recomp-${v}-switch.zip"
|
||||
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
|
||||
cp "$swzip" "$outdir/gen1recomp-${v}-switch.zip"
|
||||
# Local fused .nro stays under dist/switch/ for PR CI / debug; release
|
||||
# publishes the SD-ready zip only.
|
||||
|
||||
uwp="dist/xbox-uwp/gen1recomp-${v}-xbox-uwp.zip"
|
||||
[ -f "$uwp" ] || { echo "::error::$uwp not found (expected from the Xbox UWP job)"; exit 1; }
|
||||
cp "$uwp" "$outdir/gen1recomp-${v}-xbox-uwp.zip"
|
||||
|
||||
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||
# future RG35XX/other-CFW pack can ship alongside it).
|
||||
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
|
||||
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||
|
||||
# Linux ARM SBC PortMaster handheld port.
|
||||
sbc="dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip"
|
||||
[ -f "$sbc" ] || { echo "::error::$sbc not found (expected from ./build-linux-arm-sbc.sh)"; exit 1; }
|
||||
cp "$sbc" "$outdir/gen1recomp-${v}-sbc-portmaster.zip"
|
||||
|
||||
# Platform-independent update payload, built alongside the desktop
|
||||
# apps above (same game.love that gets fused into each of them).
|
||||
love_file=".bazinga/work/game.love"
|
||||
@@ -230,26 +530,45 @@ jobs:
|
||||
cat "$outdir/sha256sums.txt"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
tag="${{ steps.ver.outputs.tag }}"
|
||||
v="${{ needs.version.outputs.version }}"
|
||||
tag="${{ needs.version.outputs.tag }}"
|
||||
|
||||
# Issues this release closes, per GitHub's own "closing issues" links
|
||||
# (works for squash, rebase, and merge commits alike). Scans every
|
||||
# commit since the previous tag so a skipped release run doesn't drop
|
||||
# issues on the floor.
|
||||
# Issues this release closes. Three sources, deduped by number:
|
||||
# 1. GitHub's own "closing issues" links on every PR whose
|
||||
# commits are in the range (works for squash, rebase, and
|
||||
# merge commits alike) -- including PRs that were merged
|
||||
# into a branch this release PR is itself merging in.
|
||||
# 2. CLOSES/fixes/resolves text in those PRs' titles + bodies
|
||||
# that GitHub didn't turn into a closing link (a PR into a
|
||||
# non-default branch never gets one).
|
||||
# 3. The same text in raw commit messages, so a direct push
|
||||
# with "CLOSES #N #M" still lands in the notes.
|
||||
# Scans every commit since the previous tag so a skipped release
|
||||
# run doesn't drop issues on the floor.
|
||||
prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)"
|
||||
range="${prev_tag:+${prev_tag}..}$GITHUB_SHA"
|
||||
|
||||
closed=""
|
||||
for pr in $(git log --pretty=%H "$range" \
|
||||
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
|
||||
--jq '.[].number' 2>/dev/null \
|
||||
| sort -un || true); do
|
||||
closed+="$(gh api graphql \
|
||||
# every #N on a line that carries a closing keyword (handles the
|
||||
# multi-issue "CLOSES #1 #2 #3" form the tracker uses)
|
||||
scan_closes() {
|
||||
grep -iE '\b(close[sd]?|fix(es|ed)?|resolve[sd]?)\b' \
|
||||
| grep -oE '#[0-9]+' | tr -d '#' || true
|
||||
}
|
||||
|
||||
nums=""
|
||||
nums+=" $(git log --pretty=%B "$range" | scan_closes | tr '\n' ' ')"
|
||||
|
||||
prs="$(git log --pretty=%H "$range" \
|
||||
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
|
||||
--jq '.[].number' 2>/dev/null \
|
||||
| sort -un || true)"
|
||||
for pr in $prs; do
|
||||
nums+=" $(gh api graphql \
|
||||
-f owner="${GITHUB_REPOSITORY%/*}" \
|
||||
-f name="${GITHUB_REPOSITORY#*/}" \
|
||||
-F pr="$pr" \
|
||||
@@ -257,35 +576,140 @@ jobs:
|
||||
query($owner:String!, $name:String!, $pr:Int!) {
|
||||
repository(owner:$owner, name:$name) {
|
||||
pullRequest(number:$pr) {
|
||||
closingIssuesReferences(first:50) { nodes { number title } }
|
||||
closingIssuesReferences(first:50) { nodes { number } }
|
||||
}
|
||||
}
|
||||
}' \
|
||||
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[]
|
||||
| "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n'
|
||||
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[].number' \
|
||||
2>/dev/null | grep -E '^[0-9]+$' | tr '\n' ' ' || true)"
|
||||
nums+=" $(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr" \
|
||||
--jq '.title + " " + (.body // "")' 2>/dev/null \
|
||||
| scan_closes | tr '\n' ' ' || true)"
|
||||
done
|
||||
|
||||
# dedupe, drop anything that is a PR or does not exist, keep
|
||||
# titles (gh api prints the response body to stdout on an HTTP
|
||||
# error, so only a zero exit counts)
|
||||
closed=""
|
||||
for n in $(printf '%s' "$nums" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -un); do
|
||||
if title="$(gh api "repos/$GITHUB_REPOSITORY/issues/$n" \
|
||||
--jq 'if .pull_request then empty else .title end' \
|
||||
2>/dev/null)"; then
|
||||
[ -n "$title" ] && closed+="- #$n $title"$'\n'
|
||||
fi
|
||||
done
|
||||
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)"
|
||||
|
||||
# Everyone whose commits are in the range: GitHub login when the
|
||||
# commit is linked to an account, the raw git author name when not;
|
||||
# CI bots filtered out.
|
||||
base="${prev_tag:-$(git rev-list --max-parents=0 "$GITHUB_SHA" | tail -1)}"
|
||||
contributors="$(gh api --paginate \
|
||||
"repos/$GITHUB_REPOSITORY/compare/${base}...${GITHUB_SHA}" \
|
||||
--jq '.commits[] | if .author and .author.login
|
||||
then "@" + .author.login
|
||||
else .commit.author.name end' 2>/dev/null \
|
||||
| grep -viE '\[bot\]$' | sort -uf | sed 's/^/- /' || true)"
|
||||
|
||||
notes="Download the correct version for your computer below."
|
||||
if [ -n "$closed" ]; then
|
||||
notes+=$'\n\n## Issues closed\n\n'"$closed"
|
||||
fi
|
||||
if [ -n "$contributors" ]; then
|
||||
notes+=$'\n\n## Contributors\n\n'"$contributors"
|
||||
fi
|
||||
printf 'Release notes:\n%s\n' "$notes"
|
||||
|
||||
release_files=(
|
||||
"dist/release/gen1recomp-${v}-macos.zip"
|
||||
"dist/release/gen1recomp-${v}-windows.zip"
|
||||
"dist/release/gen1recomp-${v}-linux.zip"
|
||||
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
"dist/release/gen1recomp++-${v}-ios.ipa"
|
||||
"dist/release/gen1recomp-${v}-switch.zip"
|
||||
"dist/release/gen1recomp-${v}-xbox-uwp.zip"
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||
"dist/release/gen1recomp-${v}-sbc-portmaster.zip"
|
||||
"dist/release/gen1recomp-${v}.love"
|
||||
"dist/release/sha256sums.txt"
|
||||
)
|
||||
|
||||
gh release create "$tag" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$v" \
|
||||
--notes "$notes" \
|
||||
"dist/release/gen1recomp-${v}-macos.zip" \
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk" \
|
||||
"dist/release/gen1recomp-${v}.love" \
|
||||
"dist/release/sha256sums.txt"
|
||||
"${release_files[@]}"
|
||||
|
||||
echo "Published release $tag"
|
||||
|
||||
- name: Update iOS app repository
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ needs.version.outputs.version }}"
|
||||
ipa="dist/release/gen1recomp++-${v}-ios.ipa"
|
||||
app_repo="mobile/ios/app-repo.json"
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
|
||||
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
|
||||
|
||||
date="$(date -u +"%Y-%m-%d")"
|
||||
size="$(wc -c < "$ipa" | tr -d '[:space:]')"
|
||||
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp++-${v}-ios.ipa"
|
||||
bundle_id="com.theboisclub.gen1recompplusplus"
|
||||
localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
|
||||
release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
|
||||
if [ -n "$release_notes" ]; then
|
||||
localized_description="$release_notes"
|
||||
fi
|
||||
entry="$(jq -n \
|
||||
--arg version "$v" \
|
||||
--arg date "$date" \
|
||||
--arg download_url "$download_url" \
|
||||
--arg localized_description "$localized_description" \
|
||||
--argjson size "$size" \
|
||||
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
|
||||
|
||||
if jq -e --arg bundle_id "$bundle_id" --arg version "$v" \
|
||||
'any(.apps[] | select(.bundleIdentifier == $bundle_id).versions[]?; .version == $version)' \
|
||||
"$app_repo" >/dev/null; then
|
||||
jq --arg bundle_id "$bundle_id" --arg version "$v" --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= map(if .version == $version then $entry else . end)' \
|
||||
"$app_repo" > "$app_repo.tmp"
|
||||
else
|
||||
jq --arg bundle_id "$bundle_id" --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= [$entry] + .' \
|
||||
"$app_repo" > "$app_repo.tmp"
|
||||
fi
|
||||
mv "$app_repo.tmp" "$app_repo"
|
||||
|
||||
# main is PR-only for everyone except deploy keys (the "main protection"
|
||||
# ruleset's bypass actor), so this push must authenticate with the
|
||||
# RELEASE_DEPLOY_KEY deploy key over SSH; the workflow's GITHUB_TOKEN
|
||||
# would be rejected by the branch protection.
|
||||
- name: Commit iOS app repository
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
env:
|
||||
DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add mobile/ios/app-repo.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "app-repo.json unchanged; nothing to push"
|
||||
exit 0
|
||||
fi
|
||||
key="$RUNNER_TEMP/release-deploy-key"
|
||||
printf '%s\n' "$DEPLOY_KEY" > "$key"
|
||||
chmod 600 "$key"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -m "chore(ios): update app-repo.json [skip ci]"
|
||||
git -c core.sshCommand="ssh -i $key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
|
||||
push "git@github.com:${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}"
|
||||
rm -f "$key"
|
||||
|
||||
- name: Clean up signing keychain
|
||||
if: always()
|
||||
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true
|
||||
rm -f "$RUNNER_TEMP/release-deploy-key"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Switch artifact comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ci]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
run: |
|
||||
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-switch-nro") | .id')"
|
||||
[ -n "$artifact_id" ] || exit 0
|
||||
head_owner="${HEAD_REPOSITORY%%/*}"
|
||||
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
|
||||
[ -n "$pr_number" ] || exit 0
|
||||
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
# Upsert via comment-tag only — do not delete-all bot comments (clobbers iOS).
|
||||
- name: Get build info
|
||||
id: build-info
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
|
||||
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
|
||||
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
|
||||
echo "time=$build_time" >> "$GITHUB_OUTPUT"
|
||||
- name: comment Switch artifact
|
||||
if: steps.artifact.outputs.pr_number != ''
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
message: |
|
||||
[gen1recomp-switch.nro](${{ steps.artifact.outputs.artifact_url }})
|
||||
|
||||
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
|
||||
**Build Time**: `${{ steps.build-info.outputs.time }}`
|
||||
|
||||
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
|
||||
pr-number: ${{ steps.artifact.outputs.pr_number }}
|
||||
comment-tag: switch-build-result
|
||||
github-token: ${{ github.token }}
|
||||
@@ -3,8 +3,9 @@
|
||||
data/generated/
|
||||
assets/generated/
|
||||
|
||||
# LÖVE packages
|
||||
# LÖVE packages & archives
|
||||
*.love
|
||||
*.zip
|
||||
|
||||
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
|
||||
save/
|
||||
@@ -16,6 +17,7 @@ __pycache__/
|
||||
.*
|
||||
!.github/
|
||||
!.gitignore
|
||||
!.luacheckrc
|
||||
|
||||
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
|
||||
mobile/android/app/build/
|
||||
@@ -30,8 +32,56 @@ mobile/ios/love-src/
|
||||
mobile/ios/cache/
|
||||
mobile/ios/build/
|
||||
|
||||
# Final packaged build artifacts (mac/win/web/android/ios) — see scripts/build.sh
|
||||
# love-nx vendor binaries (fetch per docs/switch-build.md; also covered by .*)
|
||||
.bazinga/love-nx/
|
||||
|
||||
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
|
||||
/dist/
|
||||
|
||||
# Switch OTA launcher build outputs
|
||||
ports/switch/ota-launcher/build/
|
||||
ports/switch/ota-launcher/build-host/
|
||||
ports/switch/ota-launcher/*.nro
|
||||
ports/switch/ota-launcher/*.nacp
|
||||
ports/switch/ota-launcher/*.elf
|
||||
ports/switch/ota-launcher/*.map
|
||||
ports/switch/ota-launcher/romfs/logo.rgba
|
||||
ports/switch/ota-launcher/romfs/logo.png
|
||||
ports/switch/ota-launcher/romfs/cacert.pem
|
||||
ports/switch/ota-launcher/romfs/ota-bootstrap.nro
|
||||
ports/switch/ota-bootstrap/build/
|
||||
ports/switch/ota-bootstrap/*.nro
|
||||
ports/switch/ota-bootstrap/*.nacp
|
||||
ports/switch/ota-bootstrap/*.elf
|
||||
ports/switch/ota-bootstrap/*.map
|
||||
|
||||
# Legacy manual convenience-copy location (superseded by /dist/android/)
|
||||
mobile/dist/
|
||||
|
||||
# Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported
|
||||
# ROM cache exactly like data/generated/, so it is never committable.
|
||||
/build/tiled/
|
||||
|
||||
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||
mobile/ios/bundle_id.local
|
||||
|
||||
# Xbox UWP build output
|
||||
/ports/uwp/build/
|
||||
/ports/uwp/third_party/*/source/
|
||||
/ports/uwp/third_party/angle/depot_tools/
|
||||
|
||||
# Native TLS dialer build output (dotnet publish)
|
||||
/native/tls_dial/bin/
|
||||
/native/tls_dial/obj/
|
||||
/dist/native/
|
||||
/dist/win/
|
||||
/.bazinga/
|
||||
|
||||
# Local options / preferences
|
||||
/options.lua*
|
||||
|
||||
# User-owned ROMs imported for individual mods. Manifests declare the
|
||||
# destinations, but source checkouts and packaged mods never ship the files.
|
||||
/mods/*/baseroms/
|
||||
/imports/baseroms/
|
||||
/imports/baseroms-recovery/
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Static-analysis config for `luacheck` (https://luacheck.readthedocs.io).
|
||||
--
|
||||
-- Run it over the engine with: luacheck src (or scripts/lint.sh)
|
||||
--
|
||||
-- The point is a high-signal baseline: the categories left on are the ones
|
||||
-- that catch real defects -- undefined globals/locals (the class that hid a
|
||||
-- `music.volume` crash: applyVolume read a `state` that was still the nil
|
||||
-- global), unused values, unreachable code, redefinitions. The cosmetic
|
||||
-- categories the codebase deliberately lives with (a `self`/`dt` an
|
||||
-- interface requires but a given method ignores, documented empty
|
||||
-- fall-through branches, the odd long line) are muted so they don't drown
|
||||
-- the signal.
|
||||
|
||||
std = "luajit"
|
||||
|
||||
-- LÖVE exposes `love` as a mutable table: games assign their callbacks onto
|
||||
-- it (love.wheelmoved, love.run, ...), so it is a regular global, not
|
||||
-- read-only -- otherwise every callback registration reads as a violation.
|
||||
globals = { "love" }
|
||||
|
||||
read_globals = {
|
||||
"jit",
|
||||
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
||||
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||
table = { fields = { "unpack" } },
|
||||
}
|
||||
|
||||
-- Vendored/native trees and the test suites have their own conventions.
|
||||
exclude_files = {
|
||||
"mobile/",
|
||||
"tests/",
|
||||
"tools/save-editor/",
|
||||
}
|
||||
|
||||
ignore = {
|
||||
"212", -- unused argument -- self/dt kept for a shared method signature
|
||||
"213", -- unused loop variable -- `for _, v in` where only v is wanted
|
||||
"421", -- shadowing a local -- deliberate re-use in a few tight scopes
|
||||
"431", -- shadowing an upvalue
|
||||
"432", -- shadowing an argument
|
||||
"542", -- empty if branch -- documented fall-throughs, not gaps
|
||||
"631", -- line is too long
|
||||
}
|
||||
@@ -118,6 +118,7 @@ not a hard error, so the list can grow without breaking old mods.
|
||||
| `QUEST` | New story, NPCs, dialogue, cutscenes | content |
|
||||
| `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul |
|
||||
| `GRAPHICS` | Sprite / tileset / palette / font changes | content |
|
||||
| `LANGUAGE` | A translation: `text`, `strings` and the glyphs it needs | content |
|
||||
| `AUDIO` | Music, sfx, cries | content |
|
||||
| `UI` | New or modified screens, menus, overlays | content / overhaul |
|
||||
| `TOOL` | Dev/QoL utilities, overlays, inter-mod libraries | content |
|
||||
@@ -128,10 +129,195 @@ not a hard error, so the list can grow without breaking old mods.
|
||||
keeps validating with the value it has shipped since before the taxonomy
|
||||
existed.
|
||||
|
||||
### 4. `mod.card`
|
||||
A translation may also set `"language": true` in the manifest. That is the
|
||||
one claim online play acts on: an install running nothing but verified
|
||||
translations may take an ONLINE MATCH or a TOURNAMENT instead of being
|
||||
asked to restart vanilla. The claim is checked, not taken -- the mod
|
||||
qualifies only if every record it writes lands in `text`, `strings` or
|
||||
`font`, it wraps no hook, subscribes to no event and requests no
|
||||
permission. Anything else and it is an ordinary content mod that happens to
|
||||
ship text.
|
||||
|
||||
### 4. `games` (and the legacy `gen2compat`)
|
||||
|
||||
Pokemon Gold is Gen 2, and it runs its own battle engine, overworld, script
|
||||
VM and save format. The mod API is shared across both generations (same hook
|
||||
names, same event names, same registry names) but Gold cannot serve all of it
|
||||
yet, so Gen 2 is opt-in. Say which games the mod is for:
|
||||
|
||||
```json
|
||||
"games": ["gen1", "gen2"]
|
||||
```
|
||||
|
||||
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`), a
|
||||
generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
||||
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
||||
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
||||
`mods/<id>/`, shared by every game -- targeting is declared, never filed.
|
||||
|
||||
Absent means Gen 1 only, which is what every mod written before the key existed
|
||||
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
||||
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
||||
already ran on. On a Gold boot a mod claiming no Gen 2 game is not loaded at
|
||||
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
||||
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
||||
run your mod on Gold.
|
||||
|
||||
Every token is enforced, per game: the loader gates on the same
|
||||
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
||||
really does not load on Red and the skip line is the launcher's line, `For
|
||||
Blue, not Red`, and `"games": ["gold"]` alone does not load on Red either. A
|
||||
manifest with neither key still covers every Gen 1 game, so nothing written
|
||||
before the key existed changes behavior; list both generations or say `"all"`
|
||||
when you mean everywhere.
|
||||
|
||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
||||
today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
|
||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
||||
writes with a report, and which hooks and events are still to come.
|
||||
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
||||
Gen 1 mod, and it is the one to start from.
|
||||
|
||||
Two consequences worth knowing before you claim Gen 2.
|
||||
|
||||
**Dependencies are contagious.** A mod whose hard dependency does not run here
|
||||
is left out too, with the dependency's own wording (`depends on X, which does
|
||||
not run here (For Blue, not Red)`). It is reported as a skip, not as a failure,
|
||||
and neither mod lands on the boot error list, but the mod does not run, so
|
||||
every hard dependency has to cover the same games.
|
||||
|
||||
**The player can override you.** The claim is yours, and a mod written before
|
||||
the key existed can never carry one, so the manager's detail pane offers
|
||||
`TRY HERE ANYWAY` for any mod that does not claim the game being played. It
|
||||
persists per game in `options.modsGen2[id][version]` and takes effect on the
|
||||
next boot; forcing a mod onto Red does not force it onto Gold. A forced mod
|
||||
loads normally and keeps a note saying its author never verified it here.
|
||||
|
||||
**Prefer the API on Gold, but the Gen 1 names still work.** Gen 2 is a
|
||||
parallel module tree behind `src/core/Game2.lua`. In new code take the live
|
||||
game from `mod.game` (or the `game.ready` payload, or any `ui.*` hook's first
|
||||
argument) and the world from `mod.world`; both resolve per generation, and
|
||||
neither needs `engine_internals`.
|
||||
|
||||
For the mods written before Gold existed, a require made from a mod's own file
|
||||
is answered on a Gold boot by an adapter presenting the Gen 1 API over Gen 2
|
||||
internals. Fifteen names are served -- `src.core.Game`,
|
||||
`src.world.OverworldController`, `src.world.Map`, `src.world.NPC`,
|
||||
`src.world.Collision`, `src.world.WorldAPI`, `src.world.PikachuFollower`,
|
||||
`src.world.FieldDefaults`, `src.pokemon.Boxes`, `src.script.ScriptRunner`,
|
||||
`src.ui.PartyMenu`, `src.ui.StartMenu`, `src.ui.OptionsMenu`, `src.ui.BoxMenu`
|
||||
and `src.battle.BattleState`. `src/mods/Gen2Compat.lua` is the full table and
|
||||
publishes what it covers through `Gen2Compat.coverage(name)`, whose members are
|
||||
`backed`, `warned` or `absent`. A name with no adapter (`src.script.Commands`,
|
||||
`src.ui.OptionRows`) is reported against the mod that required it, and a member
|
||||
an adapter cannot back is absent or logs once rather than answering wrongly.
|
||||
|
||||
Things no adapter can fix, all mod-side: a hardcoded version allow-list
|
||||
(`GameVersion.get() == "red" or ...`) excludes you from Gold by construction;
|
||||
Gold's builtin screen ids carry a `Gen2` prefix, so a string match on
|
||||
`"BoxMenu"` matches nothing there; a write to a field on a live Gen 2 menu
|
||||
instance is inert; and `map.warpAt` is a table on Gen 1 and a method on Gold,
|
||||
so indexing it raises. Each has a route that works on both generations, in
|
||||
`docs/preparing-your-mod-for-gen2.md`.
|
||||
|
||||
Check it statically, then load it headless:
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py gen2check mods/my_mod
|
||||
```
|
||||
|
||||
```lua
|
||||
local run = T.sdk.loadMod("mods/my_mod", { generation = 2 })
|
||||
T.eq(run.mod and run.mod.state, "loaded",
|
||||
"runs on gen 2: " .. tostring(run.mod and run.mod.skipReason))
|
||||
T.eq(#run.errors, 0, "and loads with no boot errors")
|
||||
```
|
||||
|
||||
Assert the state, not only the error count: a gate skip is deliberately not an
|
||||
error, so `#run.errors == 0` passes for a mod that never ran a line.
|
||||
|
||||
`gen2check` answers `will load`, `will load but degrade` or `will not work`,
|
||||
with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
|
||||
line, for every reach a static scan could not follow. Neither substitutes for a
|
||||
real Gold boot.
|
||||
|
||||
### 5. What a mod's code can reach
|
||||
|
||||
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
|
||||
engine's globals. Every chunk you author gets it: `main.lua`, your
|
||||
`options_schema`, and anything you `load()` yourself.
|
||||
|
||||
The globals the sandbox took away are still *reachable*, as compat
|
||||
stand-ins (`src/mods/LegacyCompat.lua`) that answer with the new API
|
||||
underneath. A mod written before the sandbox keeps working; it logs one
|
||||
warning per call it should migrate, and the mod manager lists them. What
|
||||
each stand-in actually does:
|
||||
|
||||
| Pre-sandbox call | What it does now | Migrate to |
|
||||
| --- | --- | --- |
|
||||
| `io.open`, `io.lines`, `love.filesystem.read`/`lines`/`newFile` | reads your own shipped files, then your overlay, then `mod.storage` | `mod:read`, `mod.storage` |
|
||||
| `love.filesystem.write`/`append`, `io.open(…, "w")`, `os.remove`, `os.rename` | writes to a private per-mod overlay under `mod_compat/<your id>/` | `mod.storage` |
|
||||
| `love.filesystem.getDirectoryItems`/`getInfo` | your own directory plus your overlay | `mod:list`, `mod:info` |
|
||||
| `love.filesystem.getSaveDirectory` and friends | a virtual root; anything joined to it lands in your overlay | `mod.storage` |
|
||||
| `os.getenv` | `nil`, except home-like names, which answer with that same virtual root | nothing |
|
||||
| `love.filesystem.load`, `dofile`, `loadfile` | compiles the chunk into your sandbox | `require`, `mod:read` plus `load` |
|
||||
| `love.system` | `getOS`/`getPowerInfo`/`getProcessorCount` read through; clipboard and `openURL` do nothing | `mod.device:powerInfo()`, `mod.steps` |
|
||||
| `love.event` | passes through, except `quit`, which does nothing | `mod.events`, `mod.hooks` |
|
||||
| `love.mousemoved = fn` and the other callbacks | installs on the real `love` table, the way it always did | `mod.hooks`, `mod.events` |
|
||||
| `package` | an inert stub, so `package.path = …` does not crash | `require` |
|
||||
|
||||
What has no stand-in, because there is nothing honest to reroute it to:
|
||||
|
||||
| Still refused | Why |
|
||||
| --- | --- |
|
||||
| `love.thread` | a LÖVE thread is a fresh Lua state with the full standard library, which no environment-based sandbox in this state can reach. Use `mod.fetch` for background HTTP (`network`) or `mod.job` for background compute (`background`) — both run your code inside the sandbox instead of outside it |
|
||||
| `require("ffi")` | arbitrary C |
|
||||
| `debug`, `getfenv`, `setfenv` | each one undoes the sandbox from inside |
|
||||
| `io.popen`, `os.execute` | spawning a process |
|
||||
| `love.run`, `love.errorhandler` | the engine's own loop and its crash path |
|
||||
| replacing a `love` module table (`love.filesystem = {}`) | the engine reads those tables too |
|
||||
|
||||
The rest of `love` passes through unchanged, so graphics, audio, timers and
|
||||
input work as they always have.
|
||||
|
||||
Three consequences worth knowing before you write against it:
|
||||
|
||||
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
|
||||
mods no longer share a namespace, and neither can reach the engine's. To
|
||||
publish something to another mod, put it on `mod.exports` and let them
|
||||
`mod.find("your_id").exports` — the channel that was always the intended
|
||||
one. The same goes for the standard library: `string`, `table` and `math`
|
||||
are per-mod copies, so patching one is a local decision.
|
||||
- **Paths cannot climb.** `mod:read`, `mod:list`, `mod:info`, `mod.assets:path`
|
||||
and `mod.assets:image` join to your own directory, and `..`, absolute paths
|
||||
and drive letters are refused. So are `entry` and `options_schema` in your
|
||||
manifest. `mod:list("assets")` is the sandboxed `getDirectoryItems` for a
|
||||
folder you shipped; `mod:info` tells file from directory so a walk can
|
||||
recurse.
|
||||
- **Ship source, not bytecode.** A precompiled entry file is refused.
|
||||
|
||||
`permissions` in the manifest is still a disclosure the manager shows the
|
||||
player. `network` gates `require("socket")` and friends plus `mod.fetch`
|
||||
(non-blocking HTTP), and `background` gates `mod.job` (compute on a worker
|
||||
thread). Those two are the sanctioned ways to work off the main thread now
|
||||
that `love.thread` is refused. There is no
|
||||
permission that grants raw filesystem access, because no mod needs one:
|
||||
everything a mod legitimately writes is already scoped by
|
||||
`mod.storage` or the asset-transform derived root.
|
||||
|
||||
If your mod used one of the rerouted globals, the fix is almost always
|
||||
`mod.storage`. The overlay is a compatibility floor, not a second storage
|
||||
system: it is not scoped per playthrough, it does not migrate, and it is
|
||||
the first thing that will be dropped once the mods on the index have
|
||||
moved off it. Open an issue if you have a case `mod.storage` does not
|
||||
cover.
|
||||
|
||||
### 6. `mod.card`
|
||||
|
||||
The manifest is the *engine's* contract: identity, load order, dependencies,
|
||||
permissions, profile. The card is the *human-facing* one: who made this,
|
||||
permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)).
|
||||
The card is the *human-facing* one: who made this,
|
||||
what it changes, what it does not do yet. It is never read by the loader's
|
||||
merge — only by tooling and the manager's detail pane — so an absent or
|
||||
malformed card can never break a load.
|
||||
@@ -149,7 +335,7 @@ Two fields deserve their own note:
|
||||
distributed mod never carries ROM-derived bytes, not even in its preview
|
||||
images.
|
||||
|
||||
### 5. Tags
|
||||
### 7. Tags
|
||||
|
||||
Lowercase kebab strings, open vocabulary. The showcase generator
|
||||
lowercases and de-dupes. A recommended starting set: `beginner`,
|
||||
@@ -207,12 +393,15 @@ registry or a new schema field lands with its catalog entry in the same PR
|
||||
and the generator runs clean:
|
||||
|
||||
```sh
|
||||
luajit tools/gen_registry_docs.lua # in-repo default
|
||||
luajit tools/gen_registry_docs.lua ../project.wiki # the wiki checkout
|
||||
luajit tools/gen_registry_docs.lua # docs/modding/reference/registries.md
|
||||
luajit tools/gen_registry_docs.lua ../project.wiki # Reference-Registries.md in a wiki checkout
|
||||
```
|
||||
|
||||
The prose reference lives in the GitHub wiki; the generated pages are
|
||||
written into a checkout of it, so they cannot drift from the engine.
|
||||
With no argument it writes inside the repo, which is the copy `python3
|
||||
tools/modkit.py docs` regenerates and `--out` copies from. Pass a directory
|
||||
(or set `POKEPORT_DOCS_DIR`) to write the wiki's flat page name into a wiki
|
||||
checkout instead. The prose reference lives in the GitHub wiki; both copies
|
||||
come off `src/mods/Schemas.lua`, so neither can drift from the engine.
|
||||
|
||||
### 5. Deprecation etiquette
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2026 BOIS CLUB GAMES, LLC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,78 +1,188 @@
|
||||
# Pokemon Gen 1 Recompilation Project
|
||||
# Gen1Recomp
|
||||
|
||||
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
|
||||
A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
|
||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||
supplied by the player.
|
||||
|
||||
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
||||
> [!CAUTION]
|
||||
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
|
||||
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
||||
|
||||
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
|
||||
|
||||
<p align="center">
|
||||
|
||||
<a href="https://www.youtube.com/@bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/YouTube-FF0000?style=for-the-badge&logo=youtube&logoColor=white" alt="YouTube">
|
||||
</a>
|
||||
<a href="https://www.tiktok.com/@bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/TikTok-000000?style=for-the-badge&logo=tiktok&logoColor=white" alt="TikTok">
|
||||
</a>
|
||||
<a href="https://x.com/bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/X-000000?style=for-the-badge&logo=x&logoColor=white" alt="X">
|
||||
</a>
|
||||
<a href="https://bsky.app/profile/bryanthaboi.live">
|
||||
<img src="https://img.shields.io/badge/Bluesky-0285FF?style=for-the-badge&logo=bluesky&logoColor=white" alt="Bluesky">
|
||||
</a>
|
||||
<a href="https://www.instagram.com/bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/Instagram-E4405F?style=for-the-badge&logo=instagram&logoColor=white" alt="Instagram">
|
||||
</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center"> <a href="https://www.polygon.com/pokemon-red-blue-3d-voxel-mod-battle-pixels-gameplay-footage-remake/"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-POLYGON-ea2e49?style=for-the-badge" alt="As seen on Polygon"> </a>
|
||||
<a href="https://kotaku.com/pokemon-red-blue-recompilation-project-voxel-3d-mod-2000720281"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-KOTAKU-ea2e49?style=for-the-badge" alt="As seen on KOTAKU"> </a>
|
||||
|
||||
<a href="https://www.digitalfoundry.net/news/2026/07/pokemon-yellow-voxel-mod-turns-the-original-gameboy-code-into-a-stunning-world">
|
||||
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-DIGITAL%20FOUNDRY-ea2e49?style=for-the-badge" alt="As seen on Digital Foundry">
|
||||
</a>
|
||||
<a href="https://www.androidauthority.com/unofficial-android-port-pokemon-red-blue-yellow-3692724/">
|
||||
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-ANDROID%20AUTHORITY-ea2e49?style=for-the-badge" alt="As seen on Android Authority">
|
||||
</a>
|
||||
|
||||
<a href="https://www.xda-developers.com/this-amazing-pokemon-red-and-blue-voxel-mod-adds-a-3d-perspective-without-an-emulator/">
|
||||
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-XDA%20DEVELOPERS-ea2e49?style=for-the-badge" alt="As seen on XDA Developers">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
### Watch the latest update video
|
||||
|
||||
[](https://www.youtube.com/watch?v=8IOgqbe4YvA)
|
||||
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
|
||||
game content input.
|
||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, or Gold ROM
|
||||
is the only game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
not copied into the cache. Later launches load the private generated cache and
|
||||
do not ask for the ROM again. Red and Blue can both be imported and played
|
||||
side by side.
|
||||
do not ask for the ROM again. Red, Blue, Yellow, and Gold can all be imported
|
||||
side by side. Gold is Gen 2 Phase 1 (import + launcher; see
|
||||
`docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Open the desktop app. On first boot, choose your legally obtained `.gb` file
|
||||
or drop it onto the window. Import takes a few seconds and the game starts
|
||||
automatically.
|
||||
Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||
game starts automatically.
|
||||
|
||||
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
|
||||
verifies SHA-1 before creating any game data:
|
||||
Only the canonical US Red, Blue, Yellow (1 MiB), and Gold (2 MiB) ROMs are
|
||||
accepted. The importer verifies SHA-1 before creating any game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
||||
|
||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
audio channel programs copied out of the verified ROM.
|
||||
|
||||
### A note on Windows Defender warnings
|
||||
|
||||
Windows Defender sometimes flags the Windows build with a generic
|
||||
machine-learning detection such as `Trojan:Win32/Wacatac!ml` (#621). This is
|
||||
a known false positive: the exe is the official LÖVE runtime with the game
|
||||
archive appended (the standard way LÖVE games ship), and Defender's
|
||||
heuristics distrust unsigned executables with appended data. Every release
|
||||
publishes SHA-256 checksums (`sha256sums.txt`) so you can verify your
|
||||
download, and you can confirm a flagged file yourself on
|
||||
[VirusTotal](https://www.virustotal.com), where these builds come back clean
|
||||
on every engine except Defender's heuristic. False positives are reported to
|
||||
Microsoft as they come up.
|
||||
|
||||
## Controls
|
||||
|
||||
| Action | Keyboard | Controller |
|
||||
|--------|----------|------------|
|
||||
| Move | Arrow keys / WASD | D-pad / left stick |
|
||||
| A | Z / Enter / Space | A |
|
||||
| B | X / Backspace | B |
|
||||
| Start | Escape | Start |
|
||||
| Select | Tab / Shift | Back / Select |
|
||||
|
||||
| Action | Keyboard | Controller |
|
||||
| ------ | ----------------- | ------------------ |
|
||||
| Move | Arrow keys / WASD | D-pad / left stick |
|
||||
| A | Z / Enter / Space | A |
|
||||
| B | X / Backspace | B |
|
||||
| Start | Escape | Start |
|
||||
| Select | Tab / Shift | Back / Select |
|
||||
|
||||
|
||||
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
|
||||
supported out of the box.
|
||||
|
||||
### Hotkeys
|
||||
|
||||
| Key | What it does |
|
||||
|-----|----------------|
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
and persist in `options.lua`.
|
||||
| Key | What it does |
|
||||
| --------- | ---------------------------------------------------- |
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `1` | Cycle GAME SPEED up (controller: R2 faster, L2 slower) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the
|
||||
Options menu and persist in `options.lua`.
|
||||
|
||||
### Low-end devices
|
||||
|
||||
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
|
||||
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
|
||||
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
|
||||
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
|
||||
normal desktops → HIGH, unchanged). It only scales presentation; the
|
||||
fixed-step game logic is identical on every tier, and a lower tier hides
|
||||
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
|
||||
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
### Rulesets
|
||||
|
||||
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
|
||||
Both rulesets share the same damage formulas; they differ only in whether
|
||||
the original's quirks are kept. The setting persists in `options.lua`, and
|
||||
mods can register their own.
|
||||
|
||||
`gen1_faithful` is the default and reproduces the original cartridge,
|
||||
famous bugs included:
|
||||
|
||||
| Rule | Behavior |
|
||||
| --------------------------- | ----------------------------------------------------- |
|
||||
| `oneIn256Miss` | A 100%-accurate move still misses on a roll of 255 |
|
||||
| `critUsesBaseSpeed` | Crit rate reads base speed, not the current stat |
|
||||
| `critIgnoresStages` | Crit rate ignores stat stages |
|
||||
| `focusEnergyBug` | FOCUS ENERGY quarters the crit rate instead of x4 |
|
||||
| `enemyUnlimitedPP` | Enemies never spend PP, so they never Struggle |
|
||||
| `hyperBeamSkipRechargeOnKO` | HYPER BEAM skips its recharge when the target faints |
|
||||
| `randMin` / `randMax` | Damage random factor 217-255 |
|
||||
|
||||
`modern_clean` keeps the formulas but removes the notorious quirks:
|
||||
|
||||
| Rule | Behavior |
|
||||
| --------------------------- | ----------------------------------------------------- |
|
||||
| `oneIn256Miss` | Off: a 100%-accurate move always hits |
|
||||
| `critUsesBaseSpeed` | Unchanged: crit rate still reads base speed |
|
||||
| `critIgnoresStages` | Off: stat stages count toward the crit rate |
|
||||
| `focusEnergyBug` | Off: FOCUS ENERGY raises the crit rate as intended |
|
||||
| `enemyUnlimitedPP` | Off: enemies deplete PP and Struggle when empty |
|
||||
| `hyperBeamSkipRechargeOnKO` | Off: HYPER BEAM always recharges, like Gen 2+ |
|
||||
| `randMin` / `randMax` | Damage random factor 217-255, same as faithful |
|
||||
|
||||
## Running From Source
|
||||
|
||||
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
||||
Requires LÖVE 11.x. Place a Red, Blue, or Yellow ROM in the project folder and
|
||||
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||
|
||||
```sh
|
||||
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
|
||||
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Blue.gb / Yellow.gbc
|
||||
scripts/run.sh
|
||||
```
|
||||
|
||||
then `love .` for later launches. Windows PowerShell scripts, the optional
|
||||
developer data build, test suites, and cache management are covered in
|
||||
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
|
||||
[Developer Setup](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Developer-Setup).
|
||||
|
||||
## Portable Mode
|
||||
|
||||
@@ -80,7 +190,7 @@ By default the game keeps your save, options, and the private ROM-derived
|
||||
data cache in your OS's normal per-user app data folder. To keep everything
|
||||
next to the game instead (handy for a USB stick or portable drive you carry
|
||||
between computers), drop an empty file named `portable.txt` next to the app
|
||||
(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
||||
(next to `gen1recomp.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
||||
running from source), then launch the game. Portable mode is desktop-only
|
||||
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
|
||||
runs from a read-only package.
|
||||
@@ -88,14 +198,120 @@ runs from a read-only package.
|
||||
With `portable.txt` present:
|
||||
|
||||
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
|
||||
that same folder instead of the OS save directory.
|
||||
that same folder instead of the OS save directory.
|
||||
- A ROM import writes the generated `data/generated` and `assets/generated`
|
||||
cache straight into that folder too (nothing is left in the OS save
|
||||
directory), so a later launch reuses it without asking for the ROM again
|
||||
even on a different computer, as long as the same folder comes along.
|
||||
cache straight into that folder too (nothing is left in the OS save
|
||||
directory), so a later launch reuses it without asking for the ROM again
|
||||
even on a different computer, as long as the same folder comes along.
|
||||
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## Launch Options
|
||||
|
||||
By default the app opens the launcher so you can pick a game. Launch options
|
||||
skip it and start one game directly, which is what you want for a one-click
|
||||
entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
|
||||
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||
|
||||
|
||||
## Linux on arm64 (Raspberry Pi)
|
||||
|
||||
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships
|
||||
`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry
|
||||
Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon:
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-arm64.AppImage
|
||||
./gen1recomp-*-linux-arm64.AppImage
|
||||
```
|
||||
|
||||
LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the
|
||||
engine — and SDL2, OpenAL and the codecs — from source inside a Debian
|
||||
bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and
|
||||
zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all
|
||||
dlopened, so the same image runs on a full desktop, a Wayland-only session or
|
||||
a KMSDRM handheld with no X server. Build instructions and the reasoning are
|
||||
in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
|
||||
|
||||
|
||||
## iOS
|
||||
|
||||
Every release ships `gen1recomp++-*-ios.ipa`. Sideload it with AltStore
|
||||
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
|
||||
build and install from source on a Mac instead, see
|
||||
[docs/ios-install.md](docs/ios-install.md).
|
||||
|
||||
<div>
|
||||
<a href="https://intradeus.github.io/http-protocol-redirector?r=sidestore://source?url=https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/sidestore-badge.png" alt="Add to SideStore" height="60"></a>
|
||||
|
||||
<a href="https://intradeus.github.io/http-protocol-redirector?r=feather://source/https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/feather-badge.png" alt="Add to Feather" height="60"></a>
|
||||
|
||||
<a href="https://intradeus.github.io/http-protocol-redirector?r=altstore://source?url=https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/altstore-badge.png" alt="Add to AltStore" height="60"></a>
|
||||
|
||||
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
|
||||
</div>
|
||||
|
||||
## Xbox Dev Mode
|
||||
|
||||
Every release ships `gen1recomp-*-xbox-uwp.zip` for Xbox One and Xbox Series
|
||||
consoles in Developer Mode. It cannot be installed in retail mode.
|
||||
|
||||
Extract the archive, then use Xbox Device Portal to install the `.msix` and
|
||||
the x64 package under `Dependencies`.
|
||||
|
||||
### External setup
|
||||
|
||||
1. Put your legally obtained Red, Blue, or Yellow ROMs on an external drive.
|
||||
Mod ZIPs can go on the same drive.
|
||||
2. Connect the drive to the Xbox and open Gen1Recomp.
|
||||
3. Select **Import ROM** or **Import Mod**, then choose the file with the Xbox
|
||||
file picker.
|
||||
4. Repeat the ROM import for each version you want to use.
|
||||
|
||||
### Internal setup
|
||||
|
||||
1. Create a folder named `baseroms` on your PC and place your legally obtained
|
||||
Red, Blue, or Yellow ROMs inside it.
|
||||
2. ZIP the folder, keeping `baseroms` at the top level of the archive.
|
||||
3. Launch Gen1Recomp once, then close it.
|
||||
4. Open Xbox Device Portal and upload the ZIP to
|
||||
`Gen1Recomp/LocalState/pokemon-love2d/`.
|
||||
5. Choose **Yes** when Device Portal asks whether to extract the archive.
|
||||
6. Open Gen1Recomp. The launcher checks baseroms once at startup. When it finds a compatible ROM, that game’s tab shows ROM FOUND and an Import detected ROM button.
|
||||
|
||||
ROMs, generated game data, saves, and mods remain in LocalState and are not
|
||||
included in the app.
|
||||
|
||||
Source builds and package details are covered in
|
||||
[the Xbox UWP build notes](ports/uwp/BUILD.md).
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
|
||||
Install steps, controls, and troubleshooting live in
|
||||
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
|
||||
|
||||
## Nintendo Switch
|
||||
|
||||
Releases ship an SD-ready `gen1recomp-*-switch.zip`. Runtime target is pinned
|
||||
[love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`. Requires a
|
||||
console that can run Switch homebrew.
|
||||
|
||||
- Players: [docs/switch-install.md](docs/switch-install.md). Download the
|
||||
zip, extract at the microSD root (install or update), title-override
|
||||
launch, import your own legal ROM, Joy-Con controls and shortcuts.
|
||||
- Builders: [docs/switch-build.md](docs/switch-build.md). `--fetch` /
|
||||
`--loose` / `--fused`, toolchain, Docker fallback, and CI vs release
|
||||
(path-gated ubuntu selftest, fused PR artifact on the main repo, release
|
||||
hard-fail).
|
||||
- File transfer (MTP / SD / FTP): [docs/switch-transfer.md](docs/switch-transfer.md).
|
||||
|
||||
## Modding
|
||||
|
||||
@@ -103,36 +319,45 @@ The game ships a native mod platform: content registries, events and hooks,
|
||||
per-mod saves and options, and an in-game manager. The full modding book —
|
||||
getting started, a twelve-rung tutorial ladder, a cookbook, and the generated
|
||||
reference — lives on the
|
||||
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
|
||||
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
|
||||
|
||||
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
|
||||
Shipped example mods, one per kind of author, live in `[mods/](mods/)`.
|
||||
|
||||
Maps can be edited in our own build of [Tiled](https://www.mapeditor.org),
|
||||
[bryanthaboi/tiled_gen1recomp](https://github.com/bryanthaboi/tiled_gen1recomp/releases),
|
||||
and exported back out as a mod; see
|
||||
[docs/tiled-map-editing.md](docs/tiled-map-editing.md).
|
||||
|
||||
## Bugs and Ideas
|
||||
|
||||
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
|
||||
that looks wrong, text in the wrong box, anything that does not match the
|
||||
original game.
|
||||
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml).
|
||||
[Open a bug report](https://github.com/bryanthaboi/gen1recomp/issues/new?template=bug_report.yml).
|
||||
Attach a screenshot if you can. It saves a lot of back and forth, and if you
|
||||
can't get one, the form asks you to describe what you saw instead.
|
||||
|
||||
Thought of a feature that could be good, or a way to improve one that already
|
||||
exists?
|
||||
[Open a feature request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=feature_request.yml).
|
||||
[Open a feature request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=feature_request.yml).
|
||||
Say what you want, why it is worth doing, and how you picture it working. A
|
||||
request with real detail is one that can actually get built.
|
||||
|
||||
## More
|
||||
|
||||
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
|
||||
— START > LINK connects two copies directly over UDP.
|
||||
- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor)
|
||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||
- [Link play](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Link-Play)
|
||||
— START > LINK connects two copies directly over UDP.
|
||||
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
|
||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||
- `docs/architecture.md` — runtime details;
|
||||
`docs/behavior-porting-notes.md` — formula provenance.
|
||||
`docs/behavior-porting-notes.md` — formula provenance.
|
||||
|
||||
|
||||
|
||||
## Special Thanks
|
||||
|
||||
This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Plain Pixel font
|
||||
|
||||
"Plain Pixel Font" by Douglas Vautour (Burpy Fresh) is licensed under
|
||||
CC-BY 4.0: https://burpyfresh.itch.io
|
||||
|
||||
Version 0.009 (CJK character additions), unmodified. Characters for most
|
||||
languages have a 5x11 base but can extend vertically; double-width
|
||||
characters such as Hiragana and Katakana are 11x11.
|
||||
|
||||
Bundled so a translation mod can opt into TTF text rendering
|
||||
(`mod.content.font:register("ttf", {})`; see the Translation support
|
||||
section of docs/new-features.md) instead of drawing hundreds of glyph-page
|
||||
tiles. The tile font extracted from the player's ROM stays the default.
|
||||
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a PortMaster aarch64 port of gen1recomp for Linux ARM SBC handhelds.
|
||||
# The package uses PortMaster control hooks and a self-contained LÖVE runtime,
|
||||
# while keeping paths relative to the launcher for broad CFW compatibility.
|
||||
#
|
||||
# The launcher uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64
|
||||
# runtime so the device does not need a separate runtime download on first launch.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-linux-arm-sbc.sh [--version X.Y.Z]
|
||||
# GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version X.Y.Z
|
||||
# ./build-linux-arm-sbc.sh --source /path/to/gen1recomp --version X.Y.Z
|
||||
#
|
||||
# Output:
|
||||
# dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip
|
||||
#
|
||||
# Install on device:
|
||||
# 1. Install PortMaster for the handheld firmware.
|
||||
# 2. Unzip into the device's PortMaster ports folder so you have:
|
||||
# Roms/Ports (PORTS)/gen1recomp-sbc.sh
|
||||
# Roms/Ports (PORTS)/gen1recomp-sbc/...
|
||||
# 3. Copy a legal US Red or Blue .gb into Roms/Ports (PORTS)/gen1recomp-sbc/lovegame/
|
||||
# 4. Launch "gen1recomp-sbc" from the Ports list; press Choose ROM (scans that
|
||||
# folder when zenity is missing).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
HERE="$ROOT/.bazinga"
|
||||
CACHE="$HERE/cache/linux-arm-sbc"
|
||||
WORK="$HERE/work/linux-arm-sbc"
|
||||
DIST="$ROOT/dist/linux-arm-sbc"
|
||||
|
||||
APP_NAME="gen1recomp-sbc"
|
||||
# Artifact suffix identifies this as the generic PortMaster SBC package.
|
||||
# Release uploads stage it as gen1recomp-<ver>-sbc-portmaster.zip.
|
||||
ARTIFACT_SUFFIX="portmaster"
|
||||
PORT_DIR_NAME="gen1recomp-sbc"
|
||||
LAUNCHER_NAME="gen1recomp-sbc.sh"
|
||||
LOVE_VERSION="11.5"
|
||||
# By default the pack is reproducible from the latest published GitHub release,
|
||||
# not whatever happens to be in the caller's checkout. Development builds can
|
||||
# point this at a local checkout with GEN1RECOMP_SOURCE_DIR=/path/to/repo.
|
||||
SOURCE_DIR_OVERRIDE="${GEN1RECOMP_SOURCE_DIR:-}"
|
||||
SOURCE_TAG_OVERRIDE="${GEN1RECOMP_RELEASE_TAG:-}"
|
||||
VERSION="${GEN1RECOMP_VERSION:-}"
|
||||
|
||||
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
|
||||
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
|
||||
RELEASES_LATEST_URL="https://github.com/bryanthaboi/gen1recomp/releases/latest"
|
||||
RELEASE_TARBALL_BASE="https://github.com/bryanthaboi/gen1recomp/archive/refs/tags"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) [ $# -ge 2 ] || fail "--version needs X.Y.Z"; VERSION="$2"; shift ;;
|
||||
--source) [ $# -ge 2 ] || fail "--source needs a directory"; SOURCE_DIR_OVERRIDE="$2"; shift ;;
|
||||
--release-tag) [ $# -ge 2 ] || fail "--release-tag needs a tag"; SOURCE_TAG_OVERRIDE="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,24p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
command -v curl >/dev/null || fail "curl is required"
|
||||
command -v zip >/dev/null || fail "zip is required"
|
||||
command -v unzip >/dev/null || fail "unzip is required"
|
||||
command -v tar >/dev/null || fail "tar is required"
|
||||
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST"
|
||||
|
||||
download() {
|
||||
local url="$1" dest="$2"
|
||||
if [ -f "$dest" ] && [ -s "$dest" ]; then
|
||||
return 0
|
||||
fi
|
||||
say "downloading $(basename "$dest")"
|
||||
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|
||||
|| fail "download failed: $url"
|
||||
mv "$dest.tmp" "$dest"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- source + game tree
|
||||
# Release builds use the latest published source archive. A local checkout is
|
||||
# an explicit override for development and for CI's just-built release source.
|
||||
if [ -n "$SOURCE_DIR_OVERRIDE" ]; then
|
||||
SOURCE_DIR_OVERRIDE="$(cd "$SOURCE_DIR_OVERRIDE" 2>/dev/null && pwd)" \
|
||||
|| fail "source directory does not exist: $SOURCE_DIR_OVERRIDE"
|
||||
SOURCE_DIR="$SOURCE_DIR_OVERRIDE"
|
||||
SOURCE_TAG="${SOURCE_TAG_OVERRIDE:-local}"
|
||||
if [ "$SOURCE_TAG" != "local" ]; then
|
||||
printf '%s' "$SOURCE_TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
|| fail "release tag must look like vX.Y.Z: $SOURCE_TAG"
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="$(git -C "$SOURCE_DIR" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
fi
|
||||
else
|
||||
if [ -z "$SOURCE_TAG_OVERRIDE" ]; then
|
||||
latest_location="$(curl -fsSI "$RELEASES_LATEST_URL" \
|
||||
| awk 'tolower($1) == "location:" { print $2 }' | tail -1 | tr -d '\r')" \
|
||||
|| fail "could not resolve latest published release"
|
||||
SOURCE_TAG_OVERRIDE="${latest_location##*/}"
|
||||
fi
|
||||
printf '%s' "$SOURCE_TAG_OVERRIDE" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
|| fail "release tag must look like vX.Y.Z: $SOURCE_TAG_OVERRIDE"
|
||||
SOURCE_TAG="$SOURCE_TAG_OVERRIDE"
|
||||
SOURCE_ARCHIVE="$CACHE/gen1recomp-${SOURCE_TAG}.tar.gz"
|
||||
download "$RELEASE_TARBALL_BASE/$SOURCE_TAG.tar.gz" "$SOURCE_ARCHIVE"
|
||||
SOURCE_EXTRACT="$WORK/source-$SOURCE_TAG"
|
||||
rm -rf "$SOURCE_EXTRACT"
|
||||
mkdir -p "$SOURCE_EXTRACT"
|
||||
tar -xzf "$SOURCE_ARCHIVE" -C "$SOURCE_EXTRACT"
|
||||
SOURCE_DIR="$(find "$SOURCE_EXTRACT" -mindepth 1 -maxdepth 1 -type d -print -quit)"
|
||||
[ -n "$SOURCE_DIR" ] || fail "release archive had no source directory"
|
||||
if [ -z "$VERSION" ]; then VERSION="${SOURCE_TAG#v}"; fi
|
||||
fi
|
||||
|
||||
say "staging lovegame/ from $SOURCE_TAG"
|
||||
GAME_SRC="$WORK/lovegame"
|
||||
rm -rf "$GAME_SRC"
|
||||
mkdir -p "$GAME_SRC"
|
||||
|
||||
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
|
||||
# tools/save-editor is part of that payload: the launcher's Edit button on a
|
||||
# save row opens it in-process (main.lua).
|
||||
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "payload unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
# Stamp release version into the staged tree only (never the working tree).
|
||||
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
say "stamping engine version $VERSION"
|
||||
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||
"$SOURCE_DIR/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
|
||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||
"$GAME_SRC/src/core/Version.lua" \
|
||||
|| fail "version stamp failed"
|
||||
else
|
||||
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
|
||||
fi
|
||||
|
||||
# Portable marker: saves + ROM cache live next to the game on the SD card.
|
||||
: > "$GAME_SRC/portable.txt"
|
||||
|
||||
# --------------------------------------------------------------- love runtime
|
||||
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
|
||||
LOVE_BIN="$CACHE/love.aarch64"
|
||||
LOVE_LIB="$CACHE/liblove-11.5.so"
|
||||
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
|
||||
MODPLUG_LIB="$CACHE/libmodplug.so.1"
|
||||
OGG_LIB="$CACHE/libogg.so.0"
|
||||
|
||||
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
|
||||
|
||||
# Sanity: love stub must be an aarch64 ELF.
|
||||
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|
||||
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
|
||||
|
||||
# --------------------------------------------------------------- port tree
|
||||
say "assembling port package"
|
||||
PORT_ROOT="$WORK/port"
|
||||
rm -rf "$PORT_ROOT"
|
||||
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/conf"
|
||||
|
||||
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
|
||||
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
|
||||
|
||||
# Drop a short license pointer for the bundled LÖVE bits.
|
||||
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
|
||||
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
|
||||
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
|
||||
see https://love2d.org/ for full terms.
|
||||
EOF
|
||||
|
||||
# --------------------------------------------------------------- launcher
|
||||
# Resolve the game directory from the launcher so this works with both
|
||||
# PortMaster-managed ports directories.
|
||||
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
|
||||
#!/bin/bash
|
||||
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher
|
||||
# Uses SHDIR-relative paths so firmware-specific mount points do not matter.
|
||||
|
||||
export HOME="${HOME:-/root}"
|
||||
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
SHDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster/" ]; then
|
||||
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Data/PortMaster/" ]; then
|
||||
controlfolder="/mnt/SDCARD/Data/PortMaster"
|
||||
elif [ -d "$SHDIR/PortMaster" ]; then
|
||||
controlfolder="$SHDIR/PortMaster"
|
||||
elif [ -d "/opt/system/Tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/system/Tools/PortMaster"
|
||||
elif [ -d "/opt/tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/tools/PortMaster"
|
||||
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
|
||||
controlfolder="$XDG_DATA_HOME/PortMaster"
|
||||
elif [ -d "/roms/ports/PortMaster" ]; then
|
||||
controlfolder="/roms/ports/PortMaster"
|
||||
else
|
||||
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
|
||||
fi
|
||||
|
||||
if [ ! -f "$controlfolder/control.txt" ]; then
|
||||
echo "PortMaster control.txt not found under $controlfolder" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$controlfolder/control.txt"
|
||||
get_controls
|
||||
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
fi
|
||||
|
||||
GAMEDIR="$SHDIR/gen1recomp-sbc"
|
||||
CONFDIR="$GAMEDIR/conf"
|
||||
mkdir -p "$CONFDIR"
|
||||
|
||||
cd "$GAMEDIR" || exit 1
|
||||
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
|
||||
|
||||
export XDG_DATA_HOME="$CONFDIR"
|
||||
export XDG_CONFIG_HOME="$CONFDIR"
|
||||
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
||||
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||
# GLES is the common path on ARM SBC handhelds; firmware may override it.
|
||||
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||
|
||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||
|
||||
if [ -n "${GPTOKEYB:-}" ]; then
|
||||
$GPTOKEYB "love.aarch64" &
|
||||
fi
|
||||
if type pm_platform_helper >/dev/null 2>&1; then
|
||||
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
|
||||
fi
|
||||
|
||||
./bin/love.aarch64 "$GAMEDIR/lovegame"
|
||||
|
||||
if type pm_finish >/dev/null 2>&1; then
|
||||
pm_finish
|
||||
else
|
||||
if [ -n "${ESUDO:-}" ]; then
|
||||
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
else
|
||||
kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
EOF
|
||||
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
|
||||
|
||||
# --------------------------------------------------------------- metadata
|
||||
cat > "$PORT_ROOT/port.json" <<EOF
|
||||
{
|
||||
"version": 2,
|
||||
"name": "gen1recomp-sbc.zip",
|
||||
"items": [
|
||||
"$LAUNCHER_NAME",
|
||||
"$PORT_DIR_NAME"
|
||||
],
|
||||
"items_opt": null,
|
||||
"attr": {
|
||||
"title": "gen1recomp-sbc",
|
||||
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
|
||||
"source": "https://github.com/bryanthaboi/gen1recomp/releases/tag/$SOURCE_TAG",
|
||||
"inst": "Requires a 64-bit Linux ARM handheld with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp-sbc/lovegame/, then launch and press Choose ROM.",
|
||||
"genres": ["adventure", "rpg"],
|
||||
"porter": ["gen1recomp-sbc"],
|
||||
"image": {},
|
||||
"rtr": true,
|
||||
"runtime": null,
|
||||
"reqs": [],
|
||||
"arch": ["aarch64"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<gameList>
|
||||
<game>
|
||||
<path>./$LAUNCHER_NAME</path>
|
||||
<name>gen1recomp-sbc</name>
|
||||
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
|
||||
<releasedate>20250101T000000</releasedate>
|
||||
<developer>the bois club</developer>
|
||||
<publisher>the bois club</publisher>
|
||||
<genre>RPG</genre>
|
||||
</game>
|
||||
</gameList>
|
||||
EOF
|
||||
|
||||
cat > "$PORT_ROOT/README.md" <<'EOF'
|
||||
## gen1recomp-sbc (Linux ARM SBC / PortMaster)
|
||||
|
||||
Native LÖVE 11.5 aarch64 PortMaster port of gen1recomp for compatible Linux ARM SBC handhelds, including H700-class devices. This pack was built from source release **__SOURCE_TAG__**.
|
||||
|
||||
### Install
|
||||
|
||||
1. Install PortMaster for your handheld firmware.
|
||||
2. Unzip so `gen1recomp-sbc.sh` and the `gen1recomp-sbc/` folder are siblings in the device's PortMaster ports directory.
|
||||
3. Copy a legal US Pokémon Red or Blue `.gb` into `gen1recomp-sbc/lovegame/`.
|
||||
4. Refresh the launcher and launch **gen1recomp-sbc** from Ports.
|
||||
|
||||
### Controls
|
||||
|
||||
| Input | Action |
|
||||
|--|--|
|
||||
| D-pad | Move cursor |
|
||||
| A | Click |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Start / Select | Play or choose ROM |
|
||||
|
||||
Controls use the normal PortMaster / SDL pad map. Device-specific power/suspend behavior is supplied by the firmware and PortMaster runtime.
|
||||
|
||||
### First run
|
||||
|
||||
Put the `.gb` in `lovegame/`, then press **Choose ROM**. After import, the ROM-derived cache and saves stay beside the game (`portable.txt`).
|
||||
|
||||
### Thanks
|
||||
|
||||
LÖVE runtime binaries from [PortMaster](https://portmaster.games/). PortMaster device support and runtime integration are maintained by the PortMaster team.
|
||||
EOF
|
||||
sed -i.bak "s/__SOURCE_TAG__/$SOURCE_TAG/g" "$PORT_ROOT/README.md"
|
||||
rm -f "$PORT_ROOT/README.md.bak"
|
||||
|
||||
# --------------------------------------------------------------- zip
|
||||
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
|
||||
rm -f "$ZIP_OUT"
|
||||
say "packing $ZIP_OUT"
|
||||
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
|
||||
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
|
||||
|
||||
say "done."
|
||||
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
|
||||
say "copy into the device PortMaster ports folder, then drop your .gb into gen1recomp-sbc/lovegame/"
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a PortMaster-style aarch64 port of gen1recomp for Anbernic RG34XXSP
|
||||
# on Stock OS 64-bit MOD (H700; cbepx-me StockOS MOD / official V1.0.6 base
|
||||
# + PortMaster).
|
||||
#
|
||||
# The stock Anbernic firmware resolves ports relative to the launch script
|
||||
# (roms/PORTS/...), not PortMaster's /$directory/ports/... path. This pack
|
||||
# uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64 runtime so the
|
||||
# device does not need a separate love_11.5 runtime download on first launch.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-rg34xxsp.sh [--version X.Y.Z]
|
||||
#
|
||||
# Output:
|
||||
# dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
|
||||
#
|
||||
# Install on device:
|
||||
# 1. Flash / run Stock OS 64-bit MOD with PortMaster installed.
|
||||
# 2. Unzip into the SD card's roms/PORTS/ folder so you have:
|
||||
# roms/PORTS/Gen1recomp.sh
|
||||
# roms/PORTS/gen1recomp/...
|
||||
# 3. Copy a legal US Red or Blue .gb into roms/PORTS/gen1recomp/lovegame/
|
||||
# 4. Launch "Gen1recomp" from the Ports list; press Choose ROM (scans that
|
||||
# folder when zenity is missing).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
HERE="$ROOT/.bazinga"
|
||||
CACHE="$HERE/cache/rg34xxsp"
|
||||
WORK="$HERE/work/rg34xxsp"
|
||||
DIST="$ROOT/dist/rg34xxsp"
|
||||
|
||||
APP_NAME="gen1recomp"
|
||||
# Artifact suffix matches the CFW this port targets (Anbernic H700 Stock OS
|
||||
# 64-bit MOD for RG34XXSP). Release uploads stage it as
|
||||
# gen1recomp-<ver>-rg34xxsp-stockos64-mod.zip.
|
||||
ARTIFACT_SUFFIX="rg34xxsp-stockos64-mod"
|
||||
PORT_DIR_NAME="gen1recomp"
|
||||
LAUNCHER_NAME="Gen1recomp.sh"
|
||||
LOVE_VERSION="11.5"
|
||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
|
||||
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
|
||||
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,24p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
command -v curl >/dev/null || fail "curl is required"
|
||||
command -v zip >/dev/null || fail "zip is required"
|
||||
command -v unzip >/dev/null || fail "unzip is required"
|
||||
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST"
|
||||
|
||||
download() {
|
||||
local url="$1" dest="$2"
|
||||
if [ -f "$dest" ] && [ -s "$dest" ]; then
|
||||
return 0
|
||||
fi
|
||||
say "downloading $(basename "$dest")"
|
||||
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|
||||
|| fail "download failed: $url"
|
||||
mv "$dest.tmp" "$dest"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- game tree
|
||||
# Unpacked directory (not a .love zip) so the player can drop a .gb next to
|
||||
# main.lua and RomImporter can scan it without zenity/kdialog.
|
||||
say "staging lovegame/"
|
||||
GAME_SRC="$WORK/lovegame"
|
||||
rm -rf "$GAME_SRC"
|
||||
mkdir -p "$GAME_SRC"
|
||||
|
||||
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
|
||||
# tools/save-editor is part of that payload: the launcher's Edit button on a
|
||||
# save row opens it in-process (main.lua).
|
||||
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||
printf '%s\n' "$payload_list" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \
|
||||
&& fail "payload unexpectedly contains generated ROM data"
|
||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_gold.json"
|
||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
# Stamp release version into the staged tree only (never the working tree).
|
||||
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
say "stamping engine version $VERSION"
|
||||
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||
"$ROOT/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
|
||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||
"$GAME_SRC/src/core/Version.lua" \
|
||||
|| fail "version stamp failed"
|
||||
else
|
||||
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
|
||||
fi
|
||||
|
||||
# Portable marker: saves + ROM cache live next to the game on the SD card.
|
||||
: > "$GAME_SRC/portable.txt"
|
||||
|
||||
# --------------------------------------------------------------- love runtime
|
||||
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
|
||||
LOVE_BIN="$CACHE/love.aarch64"
|
||||
LOVE_LIB="$CACHE/liblove-11.5.so"
|
||||
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
|
||||
MODPLUG_LIB="$CACHE/libmodplug.so.1"
|
||||
OGG_LIB="$CACHE/libogg.so.0"
|
||||
|
||||
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
|
||||
|
||||
# Sanity: love stub must be an aarch64 ELF.
|
||||
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|
||||
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
|
||||
|
||||
# --------------------------------------------------------------- port tree
|
||||
say "assembling port package"
|
||||
PORT_ROOT="$WORK/port"
|
||||
rm -rf "$PORT_ROOT"
|
||||
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/conf"
|
||||
|
||||
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
|
||||
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
|
||||
|
||||
# Drop a short license pointer for the bundled LÖVE bits.
|
||||
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
|
||||
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
|
||||
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
|
||||
see https://love2d.org/ for full terms.
|
||||
EOF
|
||||
|
||||
# --------------------------------------------------------------- launcher
|
||||
# Stock OS fix: resolve GAMEDIR from this script's directory (SHDIR), not
|
||||
# PortMaster's $directory variable — Anbernic uses roms/PORTS with different
|
||||
# casing and mount points (mmc / sdcard / TF1 / TF2).
|
||||
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
|
||||
#!/bin/bash
|
||||
# gen1recomp — Anbernic RG34XXSP stock OS / PortMaster launcher
|
||||
# Uses SHDIR-relative paths so stock firmware finds the game folder.
|
||||
|
||||
export HOME="${HOME:-/root}"
|
||||
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
|
||||
if [ -d "/opt/system/Tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/system/Tools/PortMaster"
|
||||
elif [ -d "/opt/tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/tools/PortMaster"
|
||||
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
|
||||
controlfolder="$XDG_DATA_HOME/PortMaster"
|
||||
elif [ -d "/mnt/mmc/Roms/PORTS/PortMaster" ]; then
|
||||
controlfolder="/mnt/mmc/Roms/PORTS/PortMaster"
|
||||
elif [ -d "/mnt/sdcard/Roms/PORTS/PortMaster" ]; then
|
||||
controlfolder="/mnt/sdcard/Roms/PORTS/PortMaster"
|
||||
elif [ -d "/roms/ports/PortMaster" ]; then
|
||||
controlfolder="/roms/ports/PortMaster"
|
||||
else
|
||||
controlfolder="/roms/PORTS/PortMaster"
|
||||
fi
|
||||
|
||||
SHDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck disable=SC1090
|
||||
source "$controlfolder/control.txt"
|
||||
get_controls
|
||||
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
|
||||
GAMEDIR="$SHDIR/gen1recomp"
|
||||
CONFDIR="$GAMEDIR/conf"
|
||||
mkdir -p "$CONFDIR"
|
||||
|
||||
cd "$GAMEDIR" || exit 1
|
||||
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
|
||||
|
||||
export XDG_DATA_HOME="$CONFDIR"
|
||||
export XDG_CONFIG_HOME="$CONFDIR"
|
||||
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
||||
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||
# Mali / H700: prefer GLES where available
|
||||
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||
# Same GPU class as a phone, but getOS() here says "Linux", so the Android
|
||||
# gate (issue #136) would not fire on its own: refuse GBC FX explicitly.
|
||||
# Hides the OPTIONS row, pins the level to OFF, and heals a level already
|
||||
# persisted in options.lua.
|
||||
export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}"
|
||||
|
||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||
|
||||
if [ -n "${GPTOKEYB:-}" ]; then
|
||||
$GPTOKEYB "love.aarch64" &
|
||||
fi
|
||||
if type pm_platform_helper >/dev/null 2>&1; then
|
||||
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
|
||||
fi
|
||||
|
||||
./bin/love.aarch64 "$GAMEDIR/lovegame"
|
||||
|
||||
if type pm_finish >/dev/null 2>&1; then
|
||||
pm_finish
|
||||
else
|
||||
if [ -n "${ESUDO:-}" ]; then
|
||||
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
else
|
||||
kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
EOF
|
||||
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
|
||||
|
||||
# --------------------------------------------------------------- metadata
|
||||
cat > "$PORT_ROOT/port.json" <<EOF
|
||||
{
|
||||
"version": 2,
|
||||
"name": "gen1recomp.zip",
|
||||
"items": [
|
||||
"$LAUNCHER_NAME",
|
||||
"$PORT_DIR_NAME"
|
||||
],
|
||||
"items_opt": null,
|
||||
"attr": {
|
||||
"title": "gen1recomp",
|
||||
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
|
||||
"inst": "Requires Anbernic RG34XXSP Stock OS 64-bit MOD with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp/lovegame/, then launch and press Choose ROM.",
|
||||
"genres": ["adventure", "rpg"],
|
||||
"porter": ["gen1recomp"],
|
||||
"image": {},
|
||||
"rtr": true,
|
||||
"runtime": null,
|
||||
"reqs": [],
|
||||
"arch": ["aarch64"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<gameList>
|
||||
<game>
|
||||
<path>./$LAUNCHER_NAME</path>
|
||||
<name>gen1recomp</name>
|
||||
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
|
||||
<releasedate>20250101T000000</releasedate>
|
||||
<developer>the bois club</developer>
|
||||
<publisher>the bois club</publisher>
|
||||
<genre>RPG</genre>
|
||||
</game>
|
||||
</gameList>
|
||||
EOF
|
||||
|
||||
cat > "$PORT_ROOT/README.md" <<'EOF'
|
||||
## gen1recomp (RG34XXSP / Stock OS 64-bit MOD)
|
||||
|
||||
Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
|
||||
**Stock OS 64-bit MOD** (H700, PortMaster).
|
||||
|
||||
### Install
|
||||
|
||||
1. Use **Stock OS 64-bit MOD** with PortMaster installed (TF1).
|
||||
2. Unzip so `Gen1recomp.sh` and the `gen1recomp/` folder sit in `roms/PORTS/`.
|
||||
3. Copy a legal US Pokemon Red or Blue `.gb` into `gen1recomp/lovegame/`.
|
||||
4. Refresh Ports / restart EmulationStation and launch **Gen1recomp**.
|
||||
|
||||
### Controls (launcher)
|
||||
|
||||
| Input | Action |
|
||||
|--|--|
|
||||
| D-pad / left stick | Move cursor |
|
||||
| A | Click |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Right stick | Scroll lists |
|
||||
| Start / Select | Play or Choose ROM |
|
||||
|
||||
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
|
||||
|
||||
### Display options
|
||||
|
||||
GBC FX is disabled on this device (the H700's Mali GPU compiles that present
|
||||
pass and then shows a black frame), so the OPTIONS row is hidden. COLORS,
|
||||
TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with
|
||||
`POKEPORT_GBCFX=1`.
|
||||
|
||||
### First run
|
||||
|
||||
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
|
||||
**Choose ROM** — the game scans that folder. After import, the ROM-derived
|
||||
cache and saves stay beside the game (`portable.txt`).
|
||||
|
||||
Canonical SHA-1 (1 MiB US carts only):
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
|
||||
### Thanks
|
||||
|
||||
LÖVE runtime binaries from [PortMaster](https://portmaster.games/).
|
||||
Stock OS 64-bit MOD: [cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
|
||||
EOF
|
||||
|
||||
# --------------------------------------------------------------- zip
|
||||
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
|
||||
rm -f "$ZIP_OUT" "$DIST/$APP_NAME-rg34xxsp.zip"
|
||||
say "packing $ZIP_OUT"
|
||||
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
|
||||
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
|
||||
|
||||
say "done."
|
||||
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
|
||||
say "copy onto the RG34XXSP SD card under roms/PORTS/ (Stock OS 64-bit MOD), then drop your .gb into gen1recomp/lovegame/"
|
||||
@@ -1,4 +1,9 @@
|
||||
function love.conf(t)
|
||||
-- PhysFS ignores symlinks unless told otherwise, so a mod dev-linked into
|
||||
-- mods/ (ln -s, matching the mklink /J workflow on Windows) is invisible
|
||||
-- to love.filesystem.getDirectoryItems without this.
|
||||
love.filesystem.setSymlinksEnabled(true)
|
||||
|
||||
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
||||
local developer = os.getenv("POKEPORT_DEV") == "1"
|
||||
if arg then
|
||||
@@ -13,7 +18,12 @@ function love.conf(t)
|
||||
_G.POKEPORT_DEV_MODE = developer
|
||||
|
||||
if editor then
|
||||
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
|
||||
-- Same identity as the game, deliberately: the editor edits the game's
|
||||
-- saves and reads the game's ROM cache, both of which live under this
|
||||
-- folder. A private editor identity would point love.filesystem at an
|
||||
-- empty directory in a packaged build, so `--editor` could not find
|
||||
-- data/generated at all (SaveIO.defaultPath already assumed this name).
|
||||
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d"
|
||||
t.window.title = "Pokemon Save Editor"
|
||||
t.window.width = 1280
|
||||
t.window.height = 800
|
||||
@@ -30,8 +40,16 @@ function love.conf(t)
|
||||
-- starting size, not the game's resolution.
|
||||
t.window.width = 1024
|
||||
t.window.height = 768
|
||||
-- Floor for the resizable desktop window. The launcher's single-column
|
||||
-- layout is laid out against ~420 logical px of content, and the game
|
||||
-- canvas letterboxes fine below that, so this only stops a drag that would
|
||||
-- squeeze the cards past the point where their buttons still read. Kept
|
||||
-- well under the smallest supported desktop display; mobile ignores it
|
||||
-- (fullscreen), so the handheld ports are unaffected.
|
||||
t.window.minwidth = 480
|
||||
t.window.minheight = 360
|
||||
end
|
||||
t.version = "11.5"
|
||||
t.version = love._os == "iOS" and "12.0" or "11.5"
|
||||
t.window.vsync = 1
|
||||
t.modules.joystick = true
|
||||
t.modules.physics = false
|
||||
@@ -40,7 +58,17 @@ function love.conf(t)
|
||||
-- engine before conf runs (LÖVE 11.x / 11.5).
|
||||
local osName = love._os
|
||||
local mobile = osName == "Android" or osName == "iOS"
|
||||
if mobile then
|
||||
local nx = osName == "NX"
|
||||
if nx then
|
||||
-- Switch (love-nx): hint handheld 720p. SDL auto-switches portable↔dock
|
||||
-- (720p↔1080p) only when the window is resizable and not exclusive
|
||||
-- fullscreen; NxDisplay.sync also applies the size on boot and dock change.
|
||||
t.window.width = 1280
|
||||
t.window.height = 720
|
||||
t.window.fullscreen = false
|
||||
t.window.resizable = true
|
||||
t.window.highdpi = false
|
||||
elseif mobile then
|
||||
-- resizable is what unlocks orientation. SDL's Android backend, given no
|
||||
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
|
||||
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
|
||||
@@ -49,7 +77,14 @@ function love.conf(t)
|
||||
-- the game follow the device. The renderer letterboxes the 160x144
|
||||
-- viewport into whatever size results, and the on-screen touch controls
|
||||
-- re-lay themselves out from the new window size, so both orientations
|
||||
-- just work. iOS follows the Info.plist orientations
|
||||
-- just work. FULL_SENSOR ignores the device's rotation lock, so
|
||||
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
|
||||
-- run: same orientations allowed, but auto-rotate being off now wins.
|
||||
-- A persisted ORIENTATION lock (#592) overrides all of this after boot:
|
||||
-- src/core/Orientation.lua sets SDL_HINT_ORIENTATIONS over the FFI and
|
||||
-- re-triggers the request, from main.lua for the launcher and from
|
||||
-- Game:applyOptions in game.
|
||||
-- iOS follows the Info.plist orientations
|
||||
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
||||
t.window.resizable = true
|
||||
-- Starting size is a tall portrait hint; the OS resizes to the real
|
||||
@@ -65,6 +100,14 @@ function love.conf(t)
|
||||
-- player to copy their ROM there instead of needing a native file
|
||||
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
|
||||
t.externalstorage = osName == "Android"
|
||||
-- LOVE 11.x exposes the phone's accelerometer as a 3-axis joystick by
|
||||
-- default. Gravity keeps one axis pinned near +/-1.0 whenever the phone
|
||||
-- is held upright, so it streams past-deadzone joystickaxis events that
|
||||
-- both hid the touch overlay every instant (it reads as "a controller is
|
||||
-- in use") and, via the generic-joystick axis mapping (#459), walked the
|
||||
-- player around by tilt. Nothing in the game reads the accelerometer,
|
||||
-- so drop the device entirely (#468).
|
||||
t.accelerometerjoystick = false
|
||||
else
|
||||
t.window.resizable = true
|
||||
end
|
||||
|
||||
@@ -1683,158 +1683,172 @@ return {
|
||||
{ 0, 0, 0 },
|
||||
},
|
||||
},
|
||||
-- Species -> palette NAME, taken from pokered-gbc data/pokemon/palettes.asm
|
||||
-- ELSE branch -- the one that is NOT gated on GEN_2_GRAPHICS.
|
||||
--
|
||||
-- That file has two tables. The IF GEN_2_GRAPHICS branch assigns a
|
||||
-- PER-SPECIES palette (PAL_BULBASAUR, PAL_SQUIRTLE, ...) authored for Gen 2
|
||||
-- sprite art; the ELSE branch keeps Gen 1's own assignments (GREENMON,
|
||||
-- CYANMON, ...). This port extracts Gen 1 pics from the ROM, so the Gen 1
|
||||
-- branch is the matching one. The per-species table was imported here by
|
||||
-- mistake, which pointed every mon at colours shaded for different art:
|
||||
-- Bulbasaur wore PAL_BULBASAUR's red-orange over a sprite that has no red
|
||||
-- on it, and Squirtle wore PAL_SQUIRTLE's shell brown on his head.
|
||||
--
|
||||
-- The palette VALUES below are still pokered-gbc's, so ADVANCED keeps its
|
||||
-- richer colours -- only which palette each species points at changed.
|
||||
pokemon = {
|
||||
ABRA = "ABRA",
|
||||
AERODACTYL = "AERODACTYL",
|
||||
ALAKAZAM = "ALAKAZAM",
|
||||
ARBOK = "ARBOK",
|
||||
ARCANINE = "ARCANINE",
|
||||
ARTICUNO = "ARTICUNO",
|
||||
BEEDRILL = "BEEDRILL",
|
||||
BELLSPROUT = "BELLSPROUT",
|
||||
BLASTOISE = "BLASTOISE",
|
||||
BULBASAUR = "BULBASAUR",
|
||||
BUTTERFREE = "BUTTERFREE",
|
||||
CATERPIE = "CATERPIE",
|
||||
CHANSEY = "CHANSEY",
|
||||
CHARIZARD = "CHARIZARD",
|
||||
CHARMANDER = "CHARMANDER",
|
||||
CHARMELEON = "CHARMELEON",
|
||||
CLEFABLE = "CLEFABLE",
|
||||
CLEFAIRY = "CLEFAIRY",
|
||||
CLOYSTER = "CLOYSTER",
|
||||
CUBONE = "CUBONE",
|
||||
DEWGONG = "DEWGONG",
|
||||
DIGLETT = "DIGLETT",
|
||||
DITTO = "DITTO",
|
||||
DODRIO = "DODRIO",
|
||||
DODUO = "DODUO",
|
||||
DRAGONAIR = "DRAGONAIR",
|
||||
DRAGONITE = "DRAGONITE",
|
||||
DRATINI = "DRATINI",
|
||||
DROWZEE = "DROWZEE",
|
||||
DUGTRIO = "DUGTRIO",
|
||||
EEVEE = "EEVEE",
|
||||
EKANS = "EKANS",
|
||||
ELECTABUZZ = "ELECTABUZZ",
|
||||
ELECTRODE = "ELECTRODE",
|
||||
EXEGGCUTE = "EXEGGCUTE",
|
||||
EXEGGUTOR = "EXEGGUTOR",
|
||||
FARFETCHD = "FARFETCH_D",
|
||||
FEAROW = "FEAROW",
|
||||
FLAREON = "FLAREON",
|
||||
GASTLY = "GASTLY",
|
||||
GENGAR = "GENGAR",
|
||||
GEODUDE = "GEODUDE",
|
||||
GLOOM = "GLOOM",
|
||||
GOLBAT = "GOLBAT",
|
||||
GOLDEEN = "GOLDEEN",
|
||||
GOLDUCK = "GOLDUCK",
|
||||
GOLEM = "GOLEM",
|
||||
GRAVELER = "GRAVELER",
|
||||
GRIMER = "GRIMER",
|
||||
GROWLITHE = "GROWLITHE",
|
||||
GYARADOS = "GYARADOS",
|
||||
HAUNTER = "HAUNTER",
|
||||
HITMONCHAN = "HITMONCHAN",
|
||||
HITMONLEE = "HITMONLEE",
|
||||
HORSEA = "HORSEA",
|
||||
HYPNO = "HYPNO",
|
||||
IVYSAUR = "IVYSAUR",
|
||||
JIGGLYPUFF = "JIGGLYPUFF",
|
||||
JOLTEON = "JOLTEON",
|
||||
JYNX = "JYNX",
|
||||
KABUTO = "KABUTO",
|
||||
KABUTOPS = "KABUTOPS",
|
||||
KADABRA = "KADABRA",
|
||||
KAKUNA = "KAKUNA",
|
||||
KANGASKHAN = "KANGASKHAN",
|
||||
KINGLER = "KINGLER",
|
||||
KOFFING = "KOFFING",
|
||||
KRABBY = "KRABBY",
|
||||
LAPRAS = "LAPRAS",
|
||||
LICKITUNG = "LICKITUNG",
|
||||
MACHAMP = "MACHAMP",
|
||||
MACHOKE = "MACHOKE",
|
||||
MACHOP = "MACHOP",
|
||||
MAGIKARP = "MAGIKARP",
|
||||
MAGMAR = "MAGMAR",
|
||||
MAGNEMITE = "MAGNEMITE",
|
||||
MAGNETON = "MAGNETON",
|
||||
MANKEY = "MANKEY",
|
||||
MAROWAK = "MAROWAK",
|
||||
MEOWTH = "MEOWTH",
|
||||
METAPOD = "METAPOD",
|
||||
MEW = "MEW",
|
||||
MEWTWO = "MEWTWO",
|
||||
MOLTRES = "MOLTRES",
|
||||
MR_MIME = "MR_MIME",
|
||||
MUK = "MUK",
|
||||
NIDOKING = "NIDOKING",
|
||||
NIDOQUEEN = "NIDOQUEEN",
|
||||
NIDORAN_F = "NIDORAN_F",
|
||||
NIDORAN_M = "NIDORAN_M",
|
||||
NIDORINA = "NIDORINA",
|
||||
NIDORINO = "NIDORINO",
|
||||
NINETALES = "NINETALES",
|
||||
ODDISH = "ODDISH",
|
||||
OMANYTE = "OMANYTE",
|
||||
OMASTAR = "OMASTAR",
|
||||
ONIX = "ONIX",
|
||||
PARAS = "PARAS",
|
||||
PARASECT = "PARASECT",
|
||||
PERSIAN = "PERSIAN",
|
||||
PIDGEOT = "PIDGEOT",
|
||||
PIDGEOTTO = "PIDGEOTTO",
|
||||
PIDGEY = "PIDGEY",
|
||||
PIKACHU = "PIKACHU",
|
||||
PINSIR = "PINSIR",
|
||||
POLIWAG = "POLIWAG",
|
||||
POLIWHIRL = "POLIWHIRL",
|
||||
POLIWRATH = "POLIWRATH",
|
||||
PONYTA = "PONYTA",
|
||||
PORYGON = "PORYGON",
|
||||
PRIMEAPE = "PRIMEAPE",
|
||||
PSYDUCK = "PSYDUCK",
|
||||
RAICHU = "RAICHU",
|
||||
RAPIDASH = "RAPIDASH",
|
||||
RATICATE = "RATICATE",
|
||||
RATTATA = "RATTATA",
|
||||
RHYDON = "RHYDON",
|
||||
RHYHORN = "RHYHORN",
|
||||
SANDSHREW = "SANDSHREW",
|
||||
SANDSLASH = "SANDSLASH",
|
||||
SCYTHER = "SCYTHER",
|
||||
SEADRA = "SEADRA",
|
||||
SEAKING = "SEAKING",
|
||||
SEEL = "SEEL",
|
||||
SHELLDER = "SHELLDER",
|
||||
SLOWBRO = "SLOWBRO",
|
||||
SLOWPOKE = "SLOWPOKE",
|
||||
SNORLAX = "SNORLAX",
|
||||
SPEAROW = "SPEAROW",
|
||||
SQUIRTLE = "SQUIRTLE",
|
||||
STARMIE = "STARMIE",
|
||||
STARYU = "STARYU",
|
||||
TANGELA = "TANGELA",
|
||||
TAUROS = "TAUROS",
|
||||
TENTACOOL = "TENTACOOL",
|
||||
TENTACRUEL = "TENTACRUEL",
|
||||
VAPOREON = "VAPOREON",
|
||||
VENOMOTH = "VENOMOTH",
|
||||
VENONAT = "VENONAT",
|
||||
VENUSAUR = "VENUSAUR",
|
||||
VICTREEBEL = "VICTREEBEL",
|
||||
VILEPLUME = "VILEPLUME",
|
||||
VOLTORB = "VOLTORB",
|
||||
VULPIX = "VULPIX",
|
||||
WARTORTLE = "WARTORTLE",
|
||||
WEEDLE = "WEEDLE",
|
||||
WEEPINBELL = "WEEPINBELL",
|
||||
WEEZING = "WEEZING",
|
||||
WIGGLYTUFF = "WIGGLYTUFF",
|
||||
ZAPDOS = "ZAPDOS",
|
||||
ZUBAT = "ZUBAT",
|
||||
ABRA = "YELLOWMON",
|
||||
AERODACTYL = "GRAYMON",
|
||||
ALAKAZAM = "YELLOWMON",
|
||||
ARBOK = "PURPLEMON",
|
||||
ARCANINE = "REDMON",
|
||||
ARTICUNO = "BLUEMON",
|
||||
BEEDRILL = "YELLOWMON",
|
||||
BELLSPROUT = "GREENMON",
|
||||
BLASTOISE = "CYANMON",
|
||||
BULBASAUR = "GREENMON",
|
||||
BUTTERFREE = "CYANMON",
|
||||
CATERPIE = "GREENMON",
|
||||
CHANSEY = "PINKMON",
|
||||
CHARIZARD = "REDMON",
|
||||
CHARMANDER = "REDMON",
|
||||
CHARMELEON = "REDMON",
|
||||
CLEFABLE = "PINKMON",
|
||||
CLEFAIRY = "PINKMON",
|
||||
CLOYSTER = "GRAYMON",
|
||||
CUBONE = "GRAYMON",
|
||||
DEWGONG = "BLUEMON",
|
||||
DIGLETT = "BROWNMON",
|
||||
DITTO = "GRAYMON",
|
||||
DODRIO = "BROWNMON",
|
||||
DODUO = "BROWNMON",
|
||||
DRAGONAIR = "BLUEMON",
|
||||
DRAGONITE = "BROWNMON",
|
||||
DRATINI = "GRAYMON",
|
||||
DROWZEE = "YELLOWMON",
|
||||
DUGTRIO = "BROWNMON",
|
||||
EEVEE = "GRAYMON",
|
||||
EKANS = "PURPLEMON",
|
||||
ELECTABUZZ = "YELLOWMON",
|
||||
ELECTRODE = "YELLOWMON",
|
||||
EXEGGCUTE = "PINKMON",
|
||||
EXEGGUTOR = "GREENMON",
|
||||
FARFETCHD = "BROWNMON",
|
||||
FEAROW = "BROWNMON",
|
||||
FLAREON = "REDMON",
|
||||
GASTLY = "PURPLEMON",
|
||||
GENGAR = "PURPLEMON",
|
||||
GEODUDE = "GRAYMON",
|
||||
GLOOM = "REDMON",
|
||||
GOLBAT = "BLUEMON",
|
||||
GOLDEEN = "REDMON",
|
||||
GOLDUCK = "CYANMON",
|
||||
GOLEM = "GRAYMON",
|
||||
GRAVELER = "GRAYMON",
|
||||
GRIMER = "PURPLEMON",
|
||||
GROWLITHE = "BROWNMON",
|
||||
GYARADOS = "BLUEMON",
|
||||
HAUNTER = "PURPLEMON",
|
||||
HITMONCHAN = "BROWNMON",
|
||||
HITMONLEE = "BROWNMON",
|
||||
HORSEA = "CYANMON",
|
||||
HYPNO = "YELLOWMON",
|
||||
IVYSAUR = "GREENMON",
|
||||
JIGGLYPUFF = "PINKMON",
|
||||
JOLTEON = "YELLOWMON",
|
||||
JYNX = "MEWMON",
|
||||
KABUTO = "BROWNMON",
|
||||
KABUTOPS = "BROWNMON",
|
||||
KADABRA = "YELLOWMON",
|
||||
KAKUNA = "YELLOWMON",
|
||||
KANGASKHAN = "BROWNMON",
|
||||
KINGLER = "REDMON",
|
||||
KOFFING = "PURPLEMON",
|
||||
KRABBY = "REDMON",
|
||||
LAPRAS = "CYANMON",
|
||||
LICKITUNG = "PINKMON",
|
||||
MACHAMP = "GRAYMON",
|
||||
MACHOKE = "GRAYMON",
|
||||
MACHOP = "GRAYMON",
|
||||
MAGIKARP = "REDMON",
|
||||
MAGMAR = "REDMON",
|
||||
MAGNEMITE = "GRAYMON",
|
||||
MAGNETON = "GRAYMON",
|
||||
MANKEY = "BROWNMON",
|
||||
MAROWAK = "GRAYMON",
|
||||
MEOWTH = "YELLOWMON",
|
||||
METAPOD = "GREENMON",
|
||||
MEW = "MEWMON",
|
||||
MEWTWO = "MEWMON",
|
||||
MOLTRES = "REDMON",
|
||||
MR_MIME = "PINKMON",
|
||||
MUK = "PURPLEMON",
|
||||
NIDOKING = "PURPLEMON",
|
||||
NIDOQUEEN = "BLUEMON",
|
||||
NIDORAN_F = "BLUEMON",
|
||||
NIDORAN_M = "PURPLEMON",
|
||||
NIDORINA = "BLUEMON",
|
||||
NIDORINO = "PURPLEMON",
|
||||
NINETALES = "YELLOWMON",
|
||||
ODDISH = "GREENMON",
|
||||
OMANYTE = "BLUEMON",
|
||||
OMASTAR = "BLUEMON",
|
||||
ONIX = "GRAYMON",
|
||||
PARAS = "REDMON",
|
||||
PARASECT = "REDMON",
|
||||
PERSIAN = "YELLOWMON",
|
||||
PIDGEOT = "BROWNMON",
|
||||
PIDGEOTTO = "BROWNMON",
|
||||
PIDGEY = "BROWNMON",
|
||||
PIKACHU = "YELLOWMON",
|
||||
PINSIR = "BROWNMON",
|
||||
POLIWAG = "BLUEMON",
|
||||
POLIWHIRL = "BLUEMON",
|
||||
POLIWRATH = "BLUEMON",
|
||||
PONYTA = "REDMON",
|
||||
PORYGON = "GRAYMON",
|
||||
PRIMEAPE = "BROWNMON",
|
||||
PSYDUCK = "YELLOWMON",
|
||||
RAICHU = "YELLOWMON",
|
||||
RAPIDASH = "REDMON",
|
||||
RATICATE = "GRAYMON",
|
||||
RATTATA = "GRAYMON",
|
||||
RHYDON = "GRAYMON",
|
||||
RHYHORN = "GRAYMON",
|
||||
SANDSHREW = "BROWNMON",
|
||||
SANDSLASH = "BROWNMON",
|
||||
SCYTHER = "GREENMON",
|
||||
SEADRA = "CYANMON",
|
||||
SEAKING = "REDMON",
|
||||
SEEL = "BLUEMON",
|
||||
SHELLDER = "GRAYMON",
|
||||
SLOWBRO = "PINKMON",
|
||||
SLOWPOKE = "PINKMON",
|
||||
SNORLAX = "PINKMON",
|
||||
SPEAROW = "BROWNMON",
|
||||
SQUIRTLE = "CYANMON",
|
||||
STARMIE = "GRAYMON",
|
||||
STARYU = "REDMON",
|
||||
TANGELA = "BLUEMON",
|
||||
TAUROS = "GRAYMON",
|
||||
TENTACOOL = "CYANMON",
|
||||
TENTACRUEL = "CYANMON",
|
||||
VAPOREON = "CYANMON",
|
||||
VENOMOTH = "PURPLEMON",
|
||||
VENONAT = "PURPLEMON",
|
||||
VENUSAUR = "GREENMON",
|
||||
VICTREEBEL = "GREENMON",
|
||||
VILEPLUME = "REDMON",
|
||||
VOLTORB = "YELLOWMON",
|
||||
VULPIX = "REDMON",
|
||||
WARTORTLE = "CYANMON",
|
||||
WEEDLE = "YELLOWMON",
|
||||
WEEPINBELL = "GREENMON",
|
||||
WEEZING = "PURPLEMON",
|
||||
WIGGLYTUFF = "PINKMON",
|
||||
ZAPDOS = "YELLOWMON",
|
||||
ZUBAT = "BLUEMON",
|
||||
},
|
||||
source = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm + color/**",
|
||||
world = {
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
-- Generated by tools/build_data.py. DO NOT EDIT.
|
||||
-- Pokemon Yellow palettes (COLORS=OG YELLOW): SuperPalettes +
|
||||
-- CGBBasePalettes (authentic GBC look) as 4 8-bit RGB colors
|
||||
-- per palette (color 0 first), plus MonsterPalettes.
|
||||
return {
|
||||
cgbBase = {
|
||||
["0F"] = {
|
||||
{ 255, 255, 255 },
|
||||
{ 107, 8, 255 },
|
||||
{ 0, 74, 255 },
|
||||
{ 8, 8, 8 },
|
||||
},
|
||||
BADGE = {
|
||||
{ 255, 255, 255 },
|
||||
{ 189, 66, 0 },
|
||||
{ 140, 115, 90 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
BLACK = {
|
||||
{ 255, 255, 255 },
|
||||
{ 25, 25, 25 },
|
||||
{ 25, 25, 25 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
BLUEMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 132, 148, 255 },
|
||||
{ 0, 8, 206 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
BROWNMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 239, 148, 82 },
|
||||
{ 140, 74, 41 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
CAVE = {
|
||||
{ 255, 255, 255 },
|
||||
{ 189, 66, 0 },
|
||||
{ 140, 115, 90 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
CELADON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 41, 255, 41 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
CERULEAN = {
|
||||
{ 255, 255, 255 },
|
||||
{ 41, 66, 255 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
CINNABAR = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 66, 66 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
CYANMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 132, 214, 255 },
|
||||
{ 0, 140, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
FUCHSIA = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 123, 123 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
GAMEFREAK = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 156, 0 },
|
||||
{ 156, 156, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
GRAYMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 165, 189, 82 },
|
||||
{ 90, 90, 41 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
GREENBAR = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 0, 255, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
GREENMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 140, 255, 90 },
|
||||
{ 8, 181, 49 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
INDIGO = {
|
||||
{ 255, 255, 255 },
|
||||
{ 90, 66, 255 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
LAVENDER = {
|
||||
{ 255, 255, 255 },
|
||||
{ 206, 33, 255 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
LOGO1 = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 255, 0, 0 },
|
||||
{ 255, 0, 0 },
|
||||
},
|
||||
LOGO2 = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 58, 58, 206 },
|
||||
{ 0, 0, 140 },
|
||||
},
|
||||
MEWMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 255, 8, 8 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PALLET = {
|
||||
{ 255, 255, 255 },
|
||||
{ 189, 140, 255 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PEWTER = {
|
||||
{ 255, 255, 255 },
|
||||
{ 148, 148, 123 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PIKACHUS_BEACH = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PIKACHUS_BEACH_TITLE = {
|
||||
{ 255, 255, 255 },
|
||||
{ 74, 74, 74 },
|
||||
{ 255, 173, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PIKACHU_PORTRAIT = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 148, 0 },
|
||||
{ 156, 58, 8 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PINKMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 123, 148 },
|
||||
{ 255, 0, 49 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PURPLEMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 206, 123, 255 },
|
||||
{ 156, 0, 181 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
REDBAR = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 255, 0, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
REDMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 140, 0 },
|
||||
{ 255, 0, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
ROUTE = {
|
||||
{ 255, 255, 255 },
|
||||
{ 132, 255, 33 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
SAFFRON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
SLOTS1 = {
|
||||
{ 255, 255, 255 },
|
||||
{ 206, 8, 255 },
|
||||
{ 255, 0, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
SLOTS2 = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 33, 156 },
|
||||
{ 255, 255, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
SLOTS3 = {
|
||||
{ 255, 255, 255 },
|
||||
{ 66, 255, 0 },
|
||||
{ 255, 255, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
SLOTS4 = {
|
||||
{ 255, 255, 255 },
|
||||
{ 0, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
TOWNMAP = {
|
||||
{ 255, 255, 255 },
|
||||
{ 0, 173, 255 },
|
||||
{ 82, 230, 0 },
|
||||
{ 8, 8, 8 },
|
||||
},
|
||||
VERMILION = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 156, 0 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
VIRIDIAN = {
|
||||
{ 255, 255, 255 },
|
||||
{ 156, 255, 0 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
YELLOWBAR = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 255, 148, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
YELLOWMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 230, 115, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
},
|
||||
order = {
|
||||
"ROUTE",
|
||||
"PALLET",
|
||||
"VIRIDIAN",
|
||||
"PEWTER",
|
||||
"CERULEAN",
|
||||
"LAVENDER",
|
||||
"VERMILION",
|
||||
"CELADON",
|
||||
"FUCHSIA",
|
||||
"CINNABAR",
|
||||
"INDIGO",
|
||||
"SAFFRON",
|
||||
"TOWNMAP",
|
||||
"LOGO1",
|
||||
"LOGO2",
|
||||
"0F",
|
||||
"MEWMON",
|
||||
"BLUEMON",
|
||||
"REDMON",
|
||||
"CYANMON",
|
||||
"PURPLEMON",
|
||||
"BROWNMON",
|
||||
"GREENMON",
|
||||
"PINKMON",
|
||||
"YELLOWMON",
|
||||
"GRAYMON",
|
||||
"SLOTS1",
|
||||
"SLOTS2",
|
||||
"SLOTS3",
|
||||
"SLOTS4",
|
||||
"BLACK",
|
||||
"GREENBAR",
|
||||
"YELLOWBAR",
|
||||
"REDBAR",
|
||||
"BADGE",
|
||||
"CAVE",
|
||||
"GAMEFREAK",
|
||||
"PIKACHUS_BEACH",
|
||||
"PIKACHU_PORTRAIT",
|
||||
"PIKACHUS_BEACH_TITLE",
|
||||
},
|
||||
palettes = {
|
||||
["0F"] = {
|
||||
{ 255, 255, 247 },
|
||||
{ 197, 165, 247 },
|
||||
{ 90, 165, 247 },
|
||||
{ 25, 16, 16 },
|
||||
},
|
||||
BADGE = {
|
||||
{ 255, 255, 247 },
|
||||
{ 165, 123, 90 },
|
||||
{ 181, 173, 165 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
BLACK = {
|
||||
{ 255, 255, 247 },
|
||||
{ 49, 49, 49 },
|
||||
{ 49, 49, 49 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
BLUEMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 173, 181, 255 },
|
||||
{ 74, 82, 165 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
BROWNMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 214, 189, 148 },
|
||||
{ 148, 115, 82 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
CAVE = {
|
||||
{ 255, 255, 247 },
|
||||
{ 165, 123, 90 },
|
||||
{ 181, 173, 165 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
CELADON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 181, 255, 181 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
CERULEAN = {
|
||||
{ 255, 255, 247 },
|
||||
{ 181, 189, 255 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
CINNABAR = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 123, 115 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
CYANMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 214, 230, 255 },
|
||||
{ 58, 197, 230 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
FUCHSIA = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 214, 214 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
GAMEFREAK = {
|
||||
{ 255, 255, 247 },
|
||||
{ 230, 197, 115 },
|
||||
{ 165, 165, 90 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
GRAYMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 206, 206, 148 },
|
||||
{ 132, 132, 115 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
GREENBAR = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 255, 156 },
|
||||
{ 0, 173, 0 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
GREENMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 197, 230, 148 },
|
||||
{ 107, 173, 123 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
INDIGO = {
|
||||
{ 255, 255, 247 },
|
||||
{ 140, 140, 206 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
LAVENDER = {
|
||||
{ 255, 255, 247 },
|
||||
{ 222, 189, 239 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
LOGO1 = {
|
||||
{ 255, 255, 247 },
|
||||
{ 247, 247, 140 },
|
||||
{ 173, 0, 33 },
|
||||
{ 173, 0, 33 },
|
||||
},
|
||||
LOGO2 = {
|
||||
{ 255, 255, 247 },
|
||||
{ 247, 247, 140 },
|
||||
{ 148, 148, 197 },
|
||||
{ 58, 58, 132 },
|
||||
},
|
||||
MEWMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 247, 181 },
|
||||
{ 222, 132, 132 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PALLET = {
|
||||
{ 255, 255, 247 },
|
||||
{ 230, 222, 255 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PEWTER = {
|
||||
{ 255, 255, 247 },
|
||||
{ 189, 189, 181 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PIKACHUS_BEACH = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 247, 181 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PIKACHUS_BEACH_TITLE = {
|
||||
{ 255, 255, 247 },
|
||||
{ 132, 132, 132 },
|
||||
{ 255, 206, 74 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PIKACHU_PORTRAIT = {
|
||||
{ 255, 255, 247 },
|
||||
{ 230, 189, 74 },
|
||||
{ 148, 115, 82 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PINKMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 197, 214 },
|
||||
{ 255, 148, 173 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PURPLEMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 222, 181, 247 },
|
||||
{ 181, 123, 189 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
REDBAR = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 255, 156 },
|
||||
{ 214, 74, 49 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
REDMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 197, 90 },
|
||||
{ 214, 74, 49 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
ROUTE = {
|
||||
{ 255, 255, 247 },
|
||||
{ 189, 214, 156 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
SAFFRON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 255, 156 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
SLOTS1 = {
|
||||
{ 255, 255, 247 },
|
||||
{ 222, 181, 247 },
|
||||
{ 214, 74, 49 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
SLOTS2 = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 189, 214 },
|
||||
{ 239, 239, 66 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
SLOTS3 = {
|
||||
{ 255, 255, 247 },
|
||||
{ 189, 255, 165 },
|
||||
{ 239, 239, 66 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
SLOTS4 = {
|
||||
{ 255, 255, 247 },
|
||||
{ 189, 239, 255 },
|
||||
{ 239, 239, 66 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
TOWNMAP = {
|
||||
{ 255, 255, 247 },
|
||||
{ 165, 214, 255 },
|
||||
{ 140, 189, 82 },
|
||||
{ 25, 16, 16 },
|
||||
},
|
||||
VERMILION = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 206, 132 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
VIRIDIAN = {
|
||||
{ 255, 255, 247 },
|
||||
{ 214, 255, 173 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
YELLOWBAR = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 255, 156 },
|
||||
{ 230, 189, 74 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
YELLOWMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 255, 156 },
|
||||
{ 230, 189, 74 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
},
|
||||
pokemon = {
|
||||
ABRA = "YELLOWMON",
|
||||
AERODACTYL = "GRAYMON",
|
||||
ALAKAZAM = "YELLOWMON",
|
||||
ARBOK = "PURPLEMON",
|
||||
ARCANINE = "REDMON",
|
||||
ARTICUNO = "BLUEMON",
|
||||
BEEDRILL = "YELLOWMON",
|
||||
BELLSPROUT = "GREENMON",
|
||||
BLASTOISE = "CYANMON",
|
||||
BULBASAUR = "GREENMON",
|
||||
BUTTERFREE = "CYANMON",
|
||||
CATERPIE = "GREENMON",
|
||||
CHANSEY = "PINKMON",
|
||||
CHARIZARD = "REDMON",
|
||||
CHARMANDER = "REDMON",
|
||||
CHARMELEON = "REDMON",
|
||||
CLEFABLE = "PINKMON",
|
||||
CLEFAIRY = "PINKMON",
|
||||
CLOYSTER = "GRAYMON",
|
||||
CUBONE = "GRAYMON",
|
||||
DEWGONG = "BLUEMON",
|
||||
DIGLETT = "BROWNMON",
|
||||
DITTO = "GRAYMON",
|
||||
DODRIO = "BROWNMON",
|
||||
DODUO = "BROWNMON",
|
||||
DRAGONAIR = "BLUEMON",
|
||||
DRAGONITE = "BROWNMON",
|
||||
DRATINI = "GRAYMON",
|
||||
DROWZEE = "YELLOWMON",
|
||||
DUGTRIO = "BROWNMON",
|
||||
EEVEE = "GRAYMON",
|
||||
EKANS = "PURPLEMON",
|
||||
ELECTABUZZ = "YELLOWMON",
|
||||
ELECTRODE = "YELLOWMON",
|
||||
EXEGGCUTE = "PINKMON",
|
||||
EXEGGUTOR = "GREENMON",
|
||||
FARFETCHD = "BROWNMON",
|
||||
FEAROW = "BROWNMON",
|
||||
FLAREON = "REDMON",
|
||||
GASTLY = "PURPLEMON",
|
||||
GENGAR = "PURPLEMON",
|
||||
GEODUDE = "GRAYMON",
|
||||
GLOOM = "REDMON",
|
||||
GOLBAT = "BLUEMON",
|
||||
GOLDEEN = "REDMON",
|
||||
GOLDUCK = "CYANMON",
|
||||
GOLEM = "GRAYMON",
|
||||
GRAVELER = "GRAYMON",
|
||||
GRIMER = "PURPLEMON",
|
||||
GROWLITHE = "BROWNMON",
|
||||
GYARADOS = "BLUEMON",
|
||||
HAUNTER = "PURPLEMON",
|
||||
HITMONCHAN = "BROWNMON",
|
||||
HITMONLEE = "BROWNMON",
|
||||
HORSEA = "CYANMON",
|
||||
HYPNO = "YELLOWMON",
|
||||
IVYSAUR = "GREENMON",
|
||||
JIGGLYPUFF = "PINKMON",
|
||||
JOLTEON = "YELLOWMON",
|
||||
JYNX = "MEWMON",
|
||||
KABUTO = "BROWNMON",
|
||||
KABUTOPS = "BROWNMON",
|
||||
KADABRA = "YELLOWMON",
|
||||
KAKUNA = "YELLOWMON",
|
||||
KANGASKHAN = "BROWNMON",
|
||||
KINGLER = "REDMON",
|
||||
KOFFING = "PURPLEMON",
|
||||
KRABBY = "REDMON",
|
||||
LAPRAS = "CYANMON",
|
||||
LICKITUNG = "PINKMON",
|
||||
MACHAMP = "GRAYMON",
|
||||
MACHOKE = "GRAYMON",
|
||||
MACHOP = "GRAYMON",
|
||||
MAGIKARP = "REDMON",
|
||||
MAGMAR = "REDMON",
|
||||
MAGNEMITE = "GRAYMON",
|
||||
MAGNETON = "GRAYMON",
|
||||
MANKEY = "BROWNMON",
|
||||
MAROWAK = "GRAYMON",
|
||||
MEOWTH = "YELLOWMON",
|
||||
METAPOD = "GREENMON",
|
||||
MEW = "MEWMON",
|
||||
MEWTWO = "MEWMON",
|
||||
MOLTRES = "REDMON",
|
||||
MR_MIME = "PINKMON",
|
||||
MUK = "PURPLEMON",
|
||||
NIDOKING = "PURPLEMON",
|
||||
NIDOQUEEN = "BLUEMON",
|
||||
NIDORAN_F = "BLUEMON",
|
||||
NIDORAN_M = "PURPLEMON",
|
||||
NIDORINA = "BLUEMON",
|
||||
NIDORINO = "PURPLEMON",
|
||||
NINETALES = "YELLOWMON",
|
||||
ODDISH = "GREENMON",
|
||||
OMANYTE = "BLUEMON",
|
||||
OMASTAR = "BLUEMON",
|
||||
ONIX = "GRAYMON",
|
||||
PARAS = "REDMON",
|
||||
PARASECT = "REDMON",
|
||||
PERSIAN = "YELLOWMON",
|
||||
PIDGEOT = "BROWNMON",
|
||||
PIDGEOTTO = "BROWNMON",
|
||||
PIDGEY = "BROWNMON",
|
||||
PIKACHU = "YELLOWMON",
|
||||
PINSIR = "BROWNMON",
|
||||
POLIWAG = "BLUEMON",
|
||||
POLIWHIRL = "BLUEMON",
|
||||
POLIWRATH = "BLUEMON",
|
||||
PONYTA = "REDMON",
|
||||
PORYGON = "GRAYMON",
|
||||
PRIMEAPE = "BROWNMON",
|
||||
PSYDUCK = "YELLOWMON",
|
||||
RAICHU = "YELLOWMON",
|
||||
RAPIDASH = "REDMON",
|
||||
RATICATE = "GRAYMON",
|
||||
RATTATA = "GRAYMON",
|
||||
RHYDON = "GRAYMON",
|
||||
RHYHORN = "GRAYMON",
|
||||
SANDSHREW = "BROWNMON",
|
||||
SANDSLASH = "BROWNMON",
|
||||
SCYTHER = "GREENMON",
|
||||
SEADRA = "CYANMON",
|
||||
SEAKING = "REDMON",
|
||||
SEEL = "BLUEMON",
|
||||
SHELLDER = "GRAYMON",
|
||||
SLOWBRO = "PINKMON",
|
||||
SLOWPOKE = "PINKMON",
|
||||
SNORLAX = "PINKMON",
|
||||
SPEAROW = "BROWNMON",
|
||||
SQUIRTLE = "CYANMON",
|
||||
STARMIE = "GRAYMON",
|
||||
STARYU = "REDMON",
|
||||
TANGELA = "BLUEMON",
|
||||
TAUROS = "GRAYMON",
|
||||
TENTACOOL = "CYANMON",
|
||||
TENTACRUEL = "CYANMON",
|
||||
VAPOREON = "CYANMON",
|
||||
VENOMOTH = "PURPLEMON",
|
||||
VENONAT = "PURPLEMON",
|
||||
VENUSAUR = "GREENMON",
|
||||
VICTREEBEL = "GREENMON",
|
||||
VILEPLUME = "REDMON",
|
||||
VOLTORB = "YELLOWMON",
|
||||
VULPIX = "REDMON",
|
||||
WARTORTLE = "CYANMON",
|
||||
WEEDLE = "YELLOWMON",
|
||||
WEEPINBELL = "GREENMON",
|
||||
WEEZING = "PURPLEMON",
|
||||
WIGGLYTUFF = "PINKMON",
|
||||
ZAPDOS = "YELLOWMON",
|
||||
ZUBAT = "BLUEMON",
|
||||
},
|
||||
source = "pokeyellow data/sgb/sgb_palettes.asm + data/pokemon/palettes.asm",
|
||||
}
|
||||
@@ -11,8 +11,70 @@
|
||||
-- equivalent, kept for save compatibility: rows 1-4 also self-heal
|
||||
-- older saves (flag set before the port hid the ball) by hiding the
|
||||
-- leftover ball on the next interaction.
|
||||
--
|
||||
-- The room's two readables (data/events/hidden_events.asm,
|
||||
-- hidden_events_for CELADON_MANSION_ROOF_HOUSE):
|
||||
-- hidden_text_predef 3, 0 / 4, 0 PrintBlackboardLinkCableText, LinkCableHelp
|
||||
-- hidden_text_predef 3, 4 PrintNotebookText, TMNotebook
|
||||
-- tools/extract/field.py only parses `hidden_event` rows, so no
|
||||
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
|
||||
-- were dead A presses (#391). Same hook shape as the bedroom SNES in
|
||||
-- data/scripts/flavor/reds_house_2f.lua (#135). hidden_text_predef puts
|
||||
-- the predef id in the facing byte, so neither tile gates on facing.
|
||||
|
||||
local Menu = require("src.ui.Menu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
|
||||
-- extractor never collects it and the pamphlet's text is inlined.
|
||||
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
|
||||
.. "There are 50 TMs\nin all.\f"
|
||||
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
||||
.. "SILPH CO."
|
||||
|
||||
-- LinkCableHelp (engine/events/hidden_events/school_blackboard.asm):
|
||||
-- HowToLinkText's four headings in the 15x10 box at the top left; picking
|
||||
-- a heading prints its LinkCableInfoText and returns to the menu, B or
|
||||
-- STOP READING closes.
|
||||
local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
|
||||
|
||||
local function linkCableHelp(game)
|
||||
local text = game.data.text or {}
|
||||
local items, showMenu, askHeading
|
||||
function showMenu()
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
|
||||
end
|
||||
function askHeading()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
|
||||
showMenu))
|
||||
end
|
||||
items = {}
|
||||
for i, label in ipairs(LINK_HEADINGS) do
|
||||
items[i] = { label = label, onSelect = function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text["_LinkCableInfoText" .. i] or label, askHeading))
|
||||
end }
|
||||
end
|
||||
items[#items + 1] = { label = "STOP READING" }
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
|
||||
askHeading))
|
||||
end
|
||||
|
||||
return {
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if fy == 0 and (fx == 3 or fx == 4) then
|
||||
linkCableHelp(game)
|
||||
return true
|
||||
end
|
||||
if fx == 3 and fy == 4 then
|
||||
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
talk = {
|
||||
TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = {
|
||||
{ "check_flag", "EVENT_GOT_EEVEE" }, -- 1
|
||||
@@ -22,11 +84,16 @@ return {
|
||||
{ "jump", 13 }, -- 4
|
||||
{ "give_pokemon", "EEVEE", 25 }, -- 5
|
||||
{ "jump_if_false", 12 }, -- 6 (party+box full)
|
||||
{ "play_sound", "Get_Item1" }, -- 7 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 9
|
||||
-- flag + HideObject before the jingle and GotMonText: the nickname
|
||||
-- prompt inside give_pokemon and the text row both yield, and a
|
||||
-- script that dies there would leave the EEVEE taken with the ball
|
||||
-- still on the table and the gift claimable again (#426). The row
|
||||
-- count is unchanged, so the numeric jump targets still hold.
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 10
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
||||
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||
},
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
-- BikeShop (BIKE_SHOP) flavor dialogue
|
||||
-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm
|
||||
--
|
||||
-- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle
|
||||
-- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent
|
||||
-- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the
|
||||
-- scope of these two flavor NPCs and is left unported here.
|
||||
-- TEXT_BIKESHOP_CLERK lives in data/scripts/story2.lua (M.BIKE_SHOP): the
|
||||
-- voucher exchange and the BICYCLE/CANCEL price window need more than
|
||||
-- command rows (#568).
|
||||
|
||||
return {
|
||||
BIKE_SHOP = {
|
||||
@@ -20,12 +19,15 @@ return {
|
||||
-- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike
|
||||
-- before the player owns a bike -> TheseBikesAreExpensiveText
|
||||
-- after the player owns a bike -> CoolBikeText
|
||||
-- The check reads the bag, not the event: the port hands out the
|
||||
-- BICYCLE itself (a key item, so it cannot be tossed), and that also
|
||||
-- reads right on saves written before the clerk set the event (#567).
|
||||
TEXT_BIKESHOP_YOUNGSTER = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_BICYCLE" }, -- 2
|
||||
{ "jump_if_true", 5 }, -- 3
|
||||
{ "check_item", "BICYCLE" }, -- 2
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4
|
||||
{ "jump", 6 }, -- 5
|
||||
{ "jump", "end" }, -- 5
|
||||
{ "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,33 +1,88 @@
|
||||
-- Celadon Mansion 3F Game Designer (pokered/scripts/CeladonMansion3F.asm
|
||||
-- CeladonMansion3FGameDesignerText): text_asm counts set bits in
|
||||
-- wPokedexOwned and compares against NUM_POKEMON - 1 (discounts Mew).
|
||||
-- If the player owns >= 150 species, shows the "completed" text
|
||||
-- (originally followed by DisplayDiploma, which has no equivalent UI
|
||||
-- in this port, so we just show the congratulatory text); otherwise
|
||||
-- shows the normal encouragement text.
|
||||
-- Celadon Mansion 3F, the GAME FREAK dev floor
|
||||
-- (pokered+pokeyellow/scripts/CeladonMansion3F.asm). Every dev's
|
||||
-- text_asm counts set bits in wPokedexOwned against NUM_POKEMON - 1
|
||||
-- (150, discounting Mew). The game designer shows the diploma
|
||||
-- (DisplayDiploma -> src/ui/Diploma.lua) on a completed dex; Yellow's
|
||||
-- graphic artist then offers the printed copy (PrintDiploma, stood in
|
||||
-- by src/core/Printer.lua's PNG export like the Pokédex PRNT item).
|
||||
|
||||
local function ownedCount(game)
|
||||
local dex = game.save.pokedex
|
||||
local owned = 0
|
||||
if dex and dex.owned then
|
||||
for _ in pairs(dex.owned) do owned = owned + 1 end
|
||||
end
|
||||
return owned
|
||||
end
|
||||
|
||||
return {
|
||||
CELADON_MANSION_3F = {
|
||||
talk = {
|
||||
TEXT_CELADONMANSION3F_GAME_DESIGNER = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
local dex = game.save.pokedex
|
||||
local owned = 0
|
||||
if dex and dex.owned then
|
||||
for _ in pairs(dex.owned) do
|
||||
owned = owned + 1
|
||||
end
|
||||
end
|
||||
-- NUM_POKEMON - 1 = 150 (discounts Mew, per pokered)
|
||||
local label, fallback
|
||||
if owned >= 150 then
|
||||
label, fallback = "_CeladonMansion3FGameDesignerCompletedDexText",
|
||||
"Wow! Excellent!\nYou completed\nyour POKeDEX!\nCongratulations!"
|
||||
else
|
||||
label, fallback = "_CeladonMansion3FGameDesignerText",
|
||||
"Is that right?\nI'm the game\ndesigner!\nFilling up your\nPOKeDEX is tough,\nbut don't quit!\nWhen you finish,\ncome tell me!"
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, done))
|
||||
if ownedCount(game) < 150 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._CeladonMansion3FGameDesignerText
|
||||
or "Is that right?\nI'm the game\ndesigner!\fFilling up your\nPOKéDEX is tough,\nbut don't quit!",
|
||||
done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._CeladonMansion3FGameDesignerCompletedDexText
|
||||
or "Wow! Excellent!\nYou completed\nyour POKéDEX!\nCongratulations!",
|
||||
function()
|
||||
local Diploma = require("src.ui.Diploma")
|
||||
game.stack:push(Diploma.new(game, function()
|
||||
-- Yellow tags on the unlocked-printing line (CompletedDexText2)
|
||||
local after = require("src.core.GameVersion").isYellow()
|
||||
and (t._CeladonMansion3FGameDesignerCompletedDexText2
|
||||
or "You can print out\nyour diploma with\nthe GAME BOY\nPrinter!")
|
||||
or nil
|
||||
if after then
|
||||
game.stack:push(TextBox.new(game, after, done))
|
||||
else
|
||||
done()
|
||||
end
|
||||
end))
|
||||
end))
|
||||
end,
|
||||
|
||||
TEXT_CELADONMANSION3F_GRAPHIC_ARTIST = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local yellow = require("src.core.GameVersion").isYellow()
|
||||
if not (yellow and ownedCount(game) >= 150) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._CeladonMansion3FGraphicArtistText
|
||||
or "I'm the graphic\nartist!\nI drew you!", done))
|
||||
return
|
||||
end
|
||||
-- _CeladonMansion3FGraphicArtistText2: offer to print the diploma
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._CeladonMansion3FGraphicArtistText2
|
||||
or "I'm the graphic\nartist!\fShould I print\nyour diploma?",
|
||||
function()
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._CeladonMansion3FGraphicArtistText3
|
||||
or "Oh. But it's a\nspecial diploma!", done))
|
||||
return
|
||||
end
|
||||
local Printer = require("src.core.Printer")
|
||||
local Diploma = require("src.ui.Diploma")
|
||||
local Strings = require("src.core.Strings")
|
||||
local saved, err = Printer.save("diploma", 160, 144, function()
|
||||
Diploma.render(game)
|
||||
end)
|
||||
-- the PRNT stand-in always reports where the PNG landed
|
||||
game.stack:push(TextBox.new(game, saved
|
||||
and Strings("There you go!\fSaved as\n%s\vin the save\nfolder.", saved)
|
||||
or Strings("Printer error!\n%s", tostring(err)), done))
|
||||
end))
|
||||
end))
|
||||
end,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,13 +35,25 @@ local function middleAgedMan(game, ow, npc, done)
|
||||
-- .loop: print WhichBadgeText, then show the badge list menu again
|
||||
push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function()
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Strings = require("src.core.Strings")
|
||||
local items = {}
|
||||
for _, id in ipairs(BADGE_ORDER) do
|
||||
local idef = game.data.items[id]
|
||||
items[#items + 1] = { label = idef and idef.name or id, value = id }
|
||||
end
|
||||
-- PrintListMenuEntries prints ListMenuCancelText once it hits the
|
||||
-- list's $FF terminator (home/list_menu.asm), so every
|
||||
-- DisplayListMenuID list ends in an on-screen CANCEL row; picking it
|
||||
-- takes DisplayListMenuIDLoop's ExitListMenu path, the same .done
|
||||
-- exit as B (#569)
|
||||
items[#items + 1] = { label = Strings("CANCEL") }
|
||||
local menu = ListMenu.new(game, "", items, {
|
||||
onChoose = function(item)
|
||||
game.stack:pop()
|
||||
if not item.value then
|
||||
push(game, t._CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText, done)
|
||||
return
|
||||
end
|
||||
push(game, t[BADGE_TEXT[item.value]], loop)
|
||||
end,
|
||||
onCancel = function()
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
-- Both NPCs use text_asm with an hRandomAdd roll to pick one of several
|
||||
-- flavor lines (no flags, no branching outcome) -- ported as a weighted
|
||||
-- math.random pick each time the NPC is talked to.
|
||||
--
|
||||
-- Yellow renames Slowbro -> Electrode in the text labels (see
|
||||
-- tools/yellow_symbol_aliases.py); look up both so Red/Blue and Yellow
|
||||
-- share this script.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -12,6 +16,29 @@ local function push(game, ow, npc, done, text)
|
||||
game.stack:push(TextBox.new(game, text, done))
|
||||
end
|
||||
|
||||
-- CeruleanCitySlowbroText / CeruleanCityElectrodeText
|
||||
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
|
||||
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
|
||||
local function talkMon(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
local roll = math.random(0, 255)
|
||||
local text
|
||||
if roll >= 180 then
|
||||
text = t._CeruleanCitySlowbroTookASnoozeText
|
||||
or t._CeruleanCityElectrodeTookASnoozeText
|
||||
elseif roll >= 120 then
|
||||
text = t._CeruleanCitySlowbroIsLoafingAroundText
|
||||
or t._CeruleanCityElectrodeIsLoafingAroundText
|
||||
elseif roll >= 60 then
|
||||
text = t._CeruleanCitySlowbroTurnedAwayText
|
||||
or t._CeruleanCityElectrodeTurnedAwayText
|
||||
else
|
||||
text = t._CeruleanCitySlowbroIgnoredOrdersText
|
||||
or t._CeruleanCityElectrodeIgnoredOrdersText
|
||||
end
|
||||
push(game, ow, npc, done, text)
|
||||
end
|
||||
|
||||
M.CERULEAN_CITY = {
|
||||
talk = {
|
||||
-- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393)
|
||||
@@ -23,32 +50,20 @@ M.CERULEAN_CITY = {
|
||||
local text
|
||||
if roll >= 180 then
|
||||
text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText
|
||||
or t._CeruleanCityCooltrainerF1ElectrodeUseSonicboomText
|
||||
elseif roll >= 100 then
|
||||
text = t._CeruleanCityCooltrainerF1SlowbroPunchText
|
||||
or t._CeruleanCityCooltrainerF1ElectrodePunchText
|
||||
else
|
||||
text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText
|
||||
or t._CeruleanCityCooltrainerF1ElectrodeWithdrawText
|
||||
end
|
||||
push(game, ow, npc, done, text)
|
||||
end,
|
||||
|
||||
-- CeruleanCitySlowbroText (scripts/CeruleanCity.asm:395-436)
|
||||
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
|
||||
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
|
||||
TEXT_CERULEANCITY_SLOWBRO = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
local roll = math.random(0, 255)
|
||||
local text
|
||||
if roll >= 180 then
|
||||
text = t._CeruleanCitySlowbroTookASnoozeText
|
||||
elseif roll >= 120 then
|
||||
text = t._CeruleanCitySlowbroIsLoafingAroundText
|
||||
elseif roll >= 60 then
|
||||
text = t._CeruleanCitySlowbroTurnedAwayText
|
||||
else
|
||||
text = t._CeruleanCitySlowbroIgnoredOrdersText
|
||||
end
|
||||
push(game, ow, npc, done, text)
|
||||
end,
|
||||
TEXT_CERULEANCITY_SLOWBRO = talkMon,
|
||||
-- Yellow map object uses TEXT_CERULEANCITY_ELECTRODE
|
||||
TEXT_CERULEANCITY_ELECTRODE = talkMon,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ return {
|
||||
-- else -> .WhatsLostIsLostText (player has TM_DIG)
|
||||
TEXT_CERULEANTRASHEDHOUSE_FISHING_GURU = {
|
||||
{ "check_item", "TM_DIG" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_CeruleanTrashedHouseFishingGuruTheyStoleATMText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_CeruleanTrashedHouseFishingGuruWhatsLostIsLostText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Fuchsia City (pokered/scripts/FuchsiaCity.asm)
|
||||
--
|
||||
-- The exhibit signs are text_asm bodies: PrintText of a single text_far,
|
||||
-- then DisplayPokedex for the exhibited species. The preview marks the
|
||||
-- species seen but not owned, same as the S.S. Anne passenger's Snorlax.
|
||||
--
|
||||
-- The fossil sign branches on the Mt. Moon fossil events. The exhibit
|
||||
-- holds the fossil the player did NOT take: taking the Dome Fossil puts
|
||||
-- Omanyte on display, taking the Helix Fossil puts Kabuto. With neither
|
||||
-- event set the sign only prints its undetermined line and no dex entry
|
||||
-- opens.
|
||||
--
|
||||
-- The other text pointers (city sign, Safari Game signs, mart/center/gym
|
||||
-- and warden signs, the four NPCs, the exhibited-mon FuchsiaCityPokemonText
|
||||
-- rows) are plain text_far wrappers that resolve through Data:resolveText,
|
||||
-- so they are not ported here.
|
||||
return {
|
||||
FUCHSIA_CITY = {
|
||||
talk = {
|
||||
-- FuchsiaCityChanseySignText: PrintText(_FuchsiaCityChanseySignText),
|
||||
-- then DisplayPokedex CHANSEY.
|
||||
TEXT_FUCHSIACITY_CHANSEY_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityChanseySignText" },
|
||||
{ "mark_seen", "CHANSEY" },
|
||||
{ "push_screen", "DexEntryMenu", "CHANSEY" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityVoltorbSignText: PrintText(_FuchsiaCityVoltorbSignText),
|
||||
-- then DisplayPokedex VOLTORB.
|
||||
TEXT_FUCHSIACITY_VOLTORB_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityVoltorbSignText" },
|
||||
{ "mark_seen", "VOLTORB" },
|
||||
{ "push_screen", "DexEntryMenu", "VOLTORB" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityKangaskhanSignText: PrintText(_FuchsiaCityKangaskhanSignText),
|
||||
-- then DisplayPokedex KANGASKHAN.
|
||||
TEXT_FUCHSIACITY_KANGASKHAN_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityKangaskhanSignText" },
|
||||
{ "mark_seen", "KANGASKHAN" },
|
||||
{ "push_screen", "DexEntryMenu", "KANGASKHAN" },
|
||||
},
|
||||
|
||||
-- FuchsiaCitySlowpokeSignText: PrintText(_FuchsiaCitySlowpokeSignText),
|
||||
-- then DisplayPokedex SLOWPOKE.
|
||||
TEXT_FUCHSIACITY_SLOWPOKE_SIGN = {
|
||||
{ "show_text", "_FuchsiaCitySlowpokeSignText" },
|
||||
{ "mark_seen", "SLOWPOKE" },
|
||||
{ "push_screen", "DexEntryMenu", "SLOWPOKE" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityLaprasSignText: PrintText(_FuchsiaCityLaprasSignText),
|
||||
-- then DisplayPokedex LAPRAS.
|
||||
TEXT_FUCHSIACITY_LAPRAS_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityLaprasSignText" },
|
||||
{ "mark_seen", "LAPRAS" },
|
||||
{ "push_screen", "DexEntryMenu", "LAPRAS" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityFossilSignText: CheckEvent EVENT_GOT_DOME_FOSSIL /
|
||||
-- CheckEventReuseA EVENT_GOT_HELIX_FOSSIL pick the text and the
|
||||
-- displayed entry; with neither set only the undetermined line prints.
|
||||
TEXT_FUCHSIACITY_FOSSIL_SIGN = {
|
||||
{ "check_flag", "EVENT_GOT_DOME_FOSSIL" }, -- 1
|
||||
{ "jump_if_true", 7 }, -- 2
|
||||
{ "check_flag", "EVENT_GOT_HELIX_FOSSIL" }, -- 3
|
||||
{ "jump_if_true", 11 }, -- 4
|
||||
{ "show_text", "_FuchsiaCityFossilSignUndeterminedText" }, -- 5
|
||||
{ "jump", "end" }, -- 6
|
||||
{ "show_text", "_FuchsiaCityFossilSignOmanyteText" }, -- 7
|
||||
{ "mark_seen", "OMANYTE" }, -- 8
|
||||
{ "push_screen", "DexEntryMenu", "OMANYTE" }, -- 9
|
||||
{ "jump", "end" }, -- 10
|
||||
{ "show_text", "_FuchsiaCityFossilSignKabutoText" }, -- 11
|
||||
{ "mark_seen", "KABUTO" }, -- 12
|
||||
{ "push_screen", "DexEntryMenu", "KABUTO" }, -- 13
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -10,10 +10,10 @@
|
||||
local function coinGiver(opts)
|
||||
return function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Sound = require("src.core.Sound")
|
||||
local t = game.data.text
|
||||
local function push(label, fallback, onDone)
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done))
|
||||
local function push(label, fallback, onDone, popts)
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
|
||||
popts))
|
||||
end
|
||||
if game.save.flags[opts.event] then
|
||||
push(opts.alreadyGotLabel, opts.alreadyGotFallback)
|
||||
@@ -30,9 +30,10 @@ local function coinGiver(opts)
|
||||
end
|
||||
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
|
||||
game.save.flags[opts.event] = true
|
||||
Sound.play(game.data, "Get_Item1")
|
||||
-- the ReceivedNCoinsText strings carry sound_get_item_1
|
||||
push(opts.receivedLabel,
|
||||
("{PLAYER} received\n%d coins!"):format(opts.amount))
|
||||
("{PLAYER} received\n%d coins!"):format(opts.amount), nil,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -85,6 +86,47 @@ return {
|
||||
alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText",
|
||||
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
|
||||
}),
|
||||
|
||||
-- Yellow keeps all three giveaways with the same events and amounts
|
||||
-- but renames the objects and their text labels: FISHING_GURU ->
|
||||
-- FISHING_GURU1, CLERK2 -> MIDDLE_AGED_MAN2, GENTLEMAN ->
|
||||
-- FISHING_GURU2 (pokeyellow/scripts/GameCorner.asm). The Red/Blue
|
||||
-- keys above never match there, so Yellow needs its own three (#552).
|
||||
TEXT_GAMECORNER_FISHING_GURU1 = coinGiver({
|
||||
event = "EVENT_GOT_10_COINS",
|
||||
amount = 10,
|
||||
askLabel = "_GameCornerFishingGuru1WantToPlayText",
|
||||
askFallback = "Kid, do you want\nto play?",
|
||||
receivedLabel = "_GameCornerFishingGuru1Received10CoinsText",
|
||||
coinCaseFullLabel = "_GameCornerFishingGuru1DontNeedMyCoinsText",
|
||||
coinCaseFullFallback = "You don't need my\ncoins!",
|
||||
alreadyGotLabel = "_GameCornerFishingGuru1WinsComeAndGoText",
|
||||
alreadyGotFallback = "Wins seem to come\nand go.",
|
||||
}),
|
||||
|
||||
TEXT_GAMECORNER_MIDDLE_AGED_MAN2 = coinGiver({
|
||||
event = "EVENT_GOT_20_COINS_2",
|
||||
amount = 20,
|
||||
askLabel = "_GameCornerMiddleAgedMan2WantSomeCoinsText",
|
||||
askFallback = "What's up? Want\nsome coins?",
|
||||
receivedLabel = "_GameCornerMiddleAgedMan2Received20CoinsText",
|
||||
coinCaseFullLabel = "_GameCornerMiddleAgedMan2YouHaveLotsOfCoinsText",
|
||||
coinCaseFullFallback = "You have lots of\ncoins!",
|
||||
alreadyGotLabel = "_GameCornerMiddleAgedMan2INeedMoreCoinsText",
|
||||
alreadyGotFallback = "Darn! I need more\ncoins for the\vPOKéMON I want!",
|
||||
}),
|
||||
|
||||
TEXT_GAMECORNER_FISHING_GURU2 = coinGiver({
|
||||
event = "EVENT_GOT_20_COINS",
|
||||
amount = 20,
|
||||
askLabel = "_GameCornerFishingGuru2ThrowingMeOffText",
|
||||
askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!",
|
||||
receivedLabel = "_GameCornerFishingGuru2Received20CoinsText",
|
||||
coinCaseFullLabel = "_GameCornerFishingGuru2YouGotYourOwnCoinsText",
|
||||
coinCaseFullFallback = "You've got your\nown coins!",
|
||||
alreadyGotLabel = "_GameCornerFishingGuru2CloselyWatchTheReelsText",
|
||||
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ return {
|
||||
TEXT_LAVENDERMART_COOLTRAINER_M = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_RESCUED_MR_FUJI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_LavenderMartCooltrainerMReviveText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_LavenderMartCooltrainerMNuggetText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,9 +13,9 @@ M.MR_FUJIS_HOUSE = {
|
||||
TEXT_MRFUJISHOUSE_SUPER_NERD = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2
|
||||
{ "jump_if_true", 5 }, -- 3
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_MrFujisHouseSuperNerdMrFujiIsntHereText" }, -- 4
|
||||
{ "jump", 6 }, -- 5
|
||||
{ "jump", "end" }, -- 5
|
||||
{ "show_text", "_MrFujisHouseSuperNerdMrFujiHadBeenPrayingText" }, -- 6
|
||||
},
|
||||
|
||||
@@ -25,9 +25,9 @@ M.MR_FUJIS_HOUSE = {
|
||||
TEXT_MRFUJISHOUSE_LITTLE_GIRL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2
|
||||
{ "jump_if_true", 5 }, -- 3
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_MrFujisHouseLittleGirlThisIsMrFujisHouseText" }, -- 4
|
||||
{ "jump", 6 }, -- 5
|
||||
{ "jump", "end" }, -- 5
|
||||
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
|
||||
},
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ return {
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Commands = require("src.script.Commands")
|
||||
local t = game.data.text
|
||||
local function say(label, cb)
|
||||
game.stack:push(TextBox.new(game, t[label] or label, cb))
|
||||
local function say(label, cb, sopts)
|
||||
game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
|
||||
end
|
||||
|
||||
if game.save.flags.EVENT_GOT_OLD_AMBER then
|
||||
@@ -39,8 +39,9 @@ return {
|
||||
game.save.flags.EVENT_GOT_OLD_AMBER = true
|
||||
Commands.hide_object({ save = game.save, overworld = ow, game = game },
|
||||
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
say("_Museum1FScientist2ReceivedOldAmberText", done)
|
||||
-- .ReceivedOldAmberText carries sound_get_item_1
|
||||
say("_Museum1FScientist2ReceivedOldAmberText", done,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end,
|
||||
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
-- Hand-ported flavor text for OaksLab (registry id OAKS_LAB).
|
||||
-- Source: pokered/scripts/OaksLab.asm. These five text_asm bodies are
|
||||
-- all simple "PrintText; jp TextScriptEnd" -- no flag branches, no
|
||||
-- YES/NO menu -- so a one-row talk script showing the real extracted
|
||||
-- text is a faithful port. (The rest of OaksLab.asm's TEXT_OAKSLAB_*
|
||||
-- constants -- OAK1, the three starter poke balls, RIVAL -- are already
|
||||
-- ported with full branching logic in data/scripts/oaks_lab.lua.)
|
||||
-- Hand-ported OAKS_LAB flavor: the simple talk texts (scripts/OaksLab.asm;
|
||||
-- OAK1, the starter balls and RIVAL live in data/scripts/oaks_lab.lua).
|
||||
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
return {
|
||||
OAKS_LAB = {
|
||||
-- data/events/hidden_events.asm:147
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
local t = game.data.text or {}
|
||||
-- engine/events/hidden_events/oaks_lab_posters.asm:1
|
||||
if fy == 0 and fx == 4 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PushStartText or "Push START to\nopen the MENU!"))
|
||||
return true
|
||||
end
|
||||
if fy == 0 and fx == 5 then
|
||||
local owned = 0
|
||||
for _ in pairs(game.save.pokedex.owned or {}) do owned = owned + 1 end
|
||||
game.stack:push(TextBox.new(game,
|
||||
owned >= 2
|
||||
and (t._StrengthsAndWeaknessesText
|
||||
or "All POKéMON types\nhave strong and\vweak points\vagainst others.")
|
||||
or (t._SaveOptionText
|
||||
or "The SAVE option is\non the MENU\vscreen.")))
|
||||
return true
|
||||
end
|
||||
-- engine/events/hidden_events/oaks_lab_email.asm:1
|
||||
if fy == 1 and (fx == 0 or fx == 1) then
|
||||
if ow.player.facing ~= "up" then return false end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._OakLabEmailText or "There's an e-mail\nmessage here!"))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
talk = {
|
||||
-- OaksLabGirlText (scripts/OaksLab.asm)
|
||||
TEXT_OAKSLAB_GIRL = {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
-- guide and SUPER_NERD2 garden nerd.
|
||||
--
|
||||
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
|
||||
-- story5.lua so the lockstep RLE walk is not overwritten by this
|
||||
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
|
||||
-- YES/NO-branched flavor text is here.
|
||||
-- story5.lua; SUPER_NERD1's museum escort (scripts/PewterCity.asm:47-113)
|
||||
-- is below.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -16,16 +15,147 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204)
|
||||
local museumGuySteps = {
|
||||
"up", "up", "up", "up", "up", "up",
|
||||
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||
"left", "left", "left", "left", "left",
|
||||
"up", "up", "up",
|
||||
"left",
|
||||
}
|
||||
|
||||
-- RLEList_PewterMuseumPlayer (engine/overworld/auto_movement.asm:192-197)
|
||||
local museumPlayerRle = {
|
||||
"NO",
|
||||
"up", "up", "up",
|
||||
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||
"left", "left", "left", "left", "left",
|
||||
"up", "up", "up", "up", "up", "up",
|
||||
}
|
||||
|
||||
-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75)
|
||||
local museumPreambles = {
|
||||
["27,18"] = { "up", "up" },
|
||||
["27,16"] = { "right", "left" },
|
||||
["26,17"] = { "up", "right" },
|
||||
["28,17"] = { "up", "left" },
|
||||
}
|
||||
|
||||
-- PewterGuys (engine/events/pewter_guys.asm:1-49), same transform as
|
||||
-- pewterEscort.playerPlan in story5.lua
|
||||
local function museumPlan(x, y)
|
||||
local pre = museumPreambles[x .. "," .. y]
|
||||
if not pre then return nil end
|
||||
local buf = {}
|
||||
for i, d in ipairs(museumPlayerRle) do buf[i] = d end
|
||||
buf[#buf] = pre[1]
|
||||
for i = 2, #pre do buf[#buf + 1] = pre[i] end
|
||||
local path = {}
|
||||
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
|
||||
local head = 0
|
||||
while path[head + 1] == "NO" do head = head + 1 end
|
||||
local tail = #path
|
||||
while tail > head and path[tail] == "NO" do tail = tail - 1 end
|
||||
local steps = {}
|
||||
for i = head + 1, tail do steps[#steps + 1] = path[i] end
|
||||
return { steps = steps, guyHeadStart = math.floor(head / 8) }
|
||||
end
|
||||
|
||||
-- PewterCitySuperNerd1ShowsPlayerMuseumScript (scripts/PewterCity.asm:47-113)
|
||||
local function museumEscortWalk(game, ow)
|
||||
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
|
||||
local plan = museumPlan(ow.player.cellX, ow.player.cellY)
|
||||
if not plan then return false end
|
||||
local Music = require("src.core.Music")
|
||||
local t = text(game)
|
||||
local guy = ow:npcByIndex(3) -- PEWTERCITY_SUPER_NERD1
|
||||
local head = plan.guyHeadStart
|
||||
|
||||
-- SetSpritePosition2 + ShowObject back on his spawn (27,17), the same
|
||||
-- snap walkHome does in story5.lua (scripts/PewterCity.asm:102-113)
|
||||
local function walkOut()
|
||||
if not guy then return end
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
if i > 4 then
|
||||
guy.cellX, guy.cellY = 27, 17
|
||||
guy.px, guy.py = 27 * 16, 17 * 16
|
||||
guy.moving = false
|
||||
guy.targetX, guy.targetY = nil, nil
|
||||
guy.facing = "down"
|
||||
return
|
||||
end
|
||||
ow:scriptMove(guy, "down", 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
|
||||
-- SetSpritePosition1 pins him beside the museum door (map (17,12) minus
|
||||
-- the +4 border offset = (13,8)), then MovementData_PewterMuseumGuyExit
|
||||
local function afterWalk()
|
||||
if guy then
|
||||
guy.stepFrames = nil
|
||||
guy.cellX, guy.cellY = 13, 8
|
||||
guy.px, guy.py = 13 * 16, 8 * 16
|
||||
guy.moving = false
|
||||
guy.targetX, guy.targetY = nil, nil
|
||||
guy.facing = "up"
|
||||
end
|
||||
Music.playMap(game.data, "PEWTER_CITY")
|
||||
push(game, t._PewterCitySuperNerd1ItsRightHereText
|
||||
or "It's right here!", walkOut)
|
||||
end
|
||||
|
||||
local function lockstep()
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
local ps = plan.steps[i]
|
||||
if not ps then
|
||||
afterWalk()
|
||||
return
|
||||
end
|
||||
local gs = museumGuySteps[head + i]
|
||||
if guy and gs then ow:scriptMove(guy, gs, 1) end
|
||||
ow:scriptMove(ow.player, ps, 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
|
||||
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||
if guy then
|
||||
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||
end
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
if guy and head > 0 then
|
||||
local h = 0
|
||||
local function headTick()
|
||||
h = h + 1
|
||||
if h > head then lockstep(); return end
|
||||
ow:scriptMove(guy, museumGuySteps[h], 1, headTick)
|
||||
end
|
||||
headTick()
|
||||
else
|
||||
lockstep()
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
M.PEWTER_CITY = {
|
||||
museumEscort = { plan = museumPlan, guySteps = museumGuySteps },
|
||||
talk = {
|
||||
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm): asks if you
|
||||
-- checked out the museum; YES -> fossils comment, NO -> "you have
|
||||
-- to go" (which in pokered also kicks off the escort script).
|
||||
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm:209-237): YES ->
|
||||
-- fossils comment, NO -> "you have to go" and the museum escort
|
||||
TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText
|
||||
@@ -35,7 +165,10 @@ M.PEWTER_CITY = {
|
||||
or "Weren't those\nfossils from MT.\nMOON amazing?", done)
|
||||
else
|
||||
push(game, t._PewterCitySuperNerd1YouHaveToGoText
|
||||
or "Really?\nYou absolutely\nhave to go!", done)
|
||||
or "Really?\nYou absolutely\nhave to go!", function()
|
||||
museumEscortWalk(game, ow)
|
||||
if done then done() end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end,
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
-- pokered/scripts/PewterNidoranHouse.asm
|
||||
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
|
||||
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending.
|
||||
-- No sound-effect command exists in Commands.lua, so we port the dialogue line
|
||||
-- only; the cry SFX is cosmetic and has no gameplay effect.
|
||||
-- play_cry only stashes ctx.pendingCry for the very next show_text row, so it
|
||||
-- has to sit immediately before that row (src/script/Commands.lua); the cry
|
||||
-- then sounds once the box has typed out, matching the asm's print-then-cry
|
||||
-- order. Its waitForButton form keeps DisplayTextID's trailing
|
||||
-- WaitForTextScrollButtonPress -- PewterNidoranHouse_Script is
|
||||
-- `jp EnableAutoTextBoxDrawing`, which zeroes
|
||||
-- wDoNotWaitForButtonPressAfterDisplayingText -- so the box still holds until
|
||||
-- A/B. The cry row was missing, leaving the NIDORAN silent (#247).
|
||||
return {
|
||||
PEWTER_NIDORAN_HOUSE = {
|
||||
talk = {
|
||||
TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
|
||||
{ "face_player" },
|
||||
-- text_asm: ld a, NIDORAN_M / call PlayCry
|
||||
{ "play_cry", "NIDORAN_M", true },
|
||||
{ "show_text", "_PewterNidoranHouseNidoranText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,9 +12,9 @@ return {
|
||||
TEXT_ROUTE16GATE1F_GUARD = {
|
||||
{ "face_player" },
|
||||
{ "check_item", "BICYCLE" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_Route16Gate1FGuardNoPedestriansAllowedText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_Route16Gate1FGuardCyclingRoadExplanationText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,9 +14,9 @@ return {
|
||||
TEXT_ROUTE18GATE1F_GUARD = {
|
||||
{ "face_player" },
|
||||
{ "check_item", "BICYCLE" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_Route18Gate1FGuardYouNeedABicycleText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_Route18Gate1FGuardCyclingRoadUphillText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -28,7 +28,29 @@ local function badgeGuard(badge, passFlag)
|
||||
}
|
||||
end
|
||||
|
||||
-- Route23SetVictoryRoadBoulders (pokered scripts/Route23.asm:8): every entry
|
||||
-- to Route 23 resets the Victory Road boulder puzzle behind you. The 2F/3F
|
||||
-- switch events clear (so those barriers are closed again on the next floor
|
||||
-- load) and the boulder that fell through 3F's hole goes back upstairs:
|
||||
-- ShowObject TOGGLE_VICTORY_ROAD_3F_BOULDER / HideObject
|
||||
-- TOGGLE_VICTORY_ROAD_2F_BOULDER, which are VICTORYROAD3F_BOULDER4 and
|
||||
-- VICTORYROAD2F_BOULDER3 (data/maps/toggleable_objects.asm). 1F's event is
|
||||
-- reset by the Indigo Plateau lobby and by Victory Road 2F, not here. This
|
||||
-- is what makes the puzzle "completely reset" on a return trip (#258).
|
||||
-- onEnter is the port's BIT_CUR_MAP_LOADED_2 equivalent: setMap runs it on
|
||||
-- every map entry, connection crossings included, like EnterMap.
|
||||
M.ROUTE_23 = {
|
||||
onEnter = function(game, ow)
|
||||
local f = game.save.flags
|
||||
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = nil
|
||||
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = nil
|
||||
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = nil
|
||||
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = nil
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, overworld = ow, game = game }
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4")
|
||||
Commands.hide_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
|
||||
end,
|
||||
talk = {
|
||||
-- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK
|
||||
-- -> wWhichBadge = EARTHBADGE
|
||||
|
||||
@@ -12,9 +12,9 @@ return {
|
||||
TEXT_SILPHCO10F_SILPH_WORKER_F = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo10FSilphWorkerFImScaredText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo10FSilphWorkerFQuietAboutMyCryingText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ return {
|
||||
-- not set: _SilphCo3FSilphWorkerMWhatShouldIDoText
|
||||
TEXT_SILPHCO3F_SILPH_WORKER_M = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo3FSilphWorkerMWhatShouldIDoText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo3FSilphWorkerMYouSavedUsText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,9 +7,9 @@ return {
|
||||
TEXT_SILPHCO4F_SILPH_WORKER_M = {
|
||||
{"face_player"},
|
||||
{"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"},
|
||||
{"jump_if_true", 5},
|
||||
{"jump_if_true", 6},
|
||||
{"show_text", "_SilphCo4FSilphWorkerMImHidingText"},
|
||||
{"jump", 6},
|
||||
{"jump", "end"},
|
||||
{"show_text", "_SilphCo4FSilphWorkerMTeamRocketIsGoneText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,9 +7,9 @@ return {
|
||||
TEXT_SILPHCO5F_SILPH_WORKER_M = {
|
||||
{"face_player"},
|
||||
{"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"},
|
||||
{"jump_if_true", 5},
|
||||
{"jump_if_true", 6},
|
||||
{"show_text", "_SilphCo5FSilphWorkerMThatsYouRightText"},
|
||||
{"jump", 6},
|
||||
{"jump", "end"},
|
||||
{"show_text", "_SilphCo5FSilphWorkerMYoureOurHeroText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,9 +11,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_M1 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM1TookOverTheBuildingText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM1BackToWorkText" },
|
||||
},
|
||||
|
||||
@@ -21,9 +21,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_M2 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerMHelpMePleaseText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerMWeGotEngagedText" },
|
||||
},
|
||||
|
||||
@@ -31,9 +31,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_F1 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF1SuchACowardText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF1HaveToMarryHimText" },
|
||||
},
|
||||
|
||||
@@ -41,9 +41,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_F2 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF2TeamRocketConquerWorldText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF2TeamRocketRanText" },
|
||||
},
|
||||
|
||||
@@ -51,9 +51,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_M3 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM3TargetedSilphText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM3WorkForSilphText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ return {
|
||||
-- set: _SilphCo7FSilphWorkerM2CancelledMasterBallText
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M2 = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM2AfterTheMasterBallText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM2CancelledMasterBallText" },
|
||||
},
|
||||
|
||||
@@ -22,9 +22,9 @@ return {
|
||||
-- set: _SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M3 = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM3ItWouldBeBadText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText" },
|
||||
},
|
||||
|
||||
@@ -34,9 +34,9 @@ return {
|
||||
-- set: _SilphCo7FSilphWorkerM4SafeAtLastText
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M4 = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM4ItsReallyDangerousHereText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM4SafeAtLastText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ return {
|
||||
-- set: _SilphCo8FSilphWorkerMThanksForSavingUsText
|
||||
TEXT_SILPHCO8F_SILPH_WORKER_M = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo8FSilphWorkerMSilphIsFinishedText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo8FSilphWorkerMThanksForSavingUsText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,19 +7,19 @@
|
||||
-- CheckFightingMapTrainers/TalkToTrainer via the existing trainer
|
||||
-- system, and the item pickups carry no talk text of their own here.
|
||||
--
|
||||
-- TEXT_SSANNE2FROOMS_GENTLEMAN3 also opens the POKéDEX entry for
|
||||
-- SNORLAX after the text box (DisplayPokedex in the original), which
|
||||
-- has no equivalent talk-script hook in this port (DexEntryMenu has
|
||||
-- no done-callback the way other pushed UI states do) -- only the
|
||||
-- flavor line is ported here.
|
||||
-- TEXT_SSANNE2FROOMS_GENTLEMAN3 opens SNORLAX's POKéDEX entry after its
|
||||
-- text box, like DisplayPokedex in the original. The preview marks it seen
|
||||
-- but does not mark it owned.
|
||||
return {
|
||||
SS_ANNE_2F_ROOMS = {
|
||||
talk = {
|
||||
-- SSAnne2FRoomsGentleman3Text: PrintText(_SSAnne2FRoomsGentleman3Text)
|
||||
-- (then DisplayPokedex SNORLAX -- not ported, see note above)
|
||||
-- SSAnne2FRoomsGentleman3Text: PrintText(_SSAnne2FRoomsGentleman3Text),
|
||||
-- then DisplayPokedex SNORLAX.
|
||||
TEXT_SSANNE2FROOMS_GENTLEMAN3 = {
|
||||
{ "face_player" },
|
||||
{ "show_text", "_SSAnne2FRoomsGentleman3Text" },
|
||||
{ "mark_seen", "SNORLAX" },
|
||||
{ "push_screen", "DexEntryMenu", "SNORLAX" },
|
||||
},
|
||||
|
||||
-- SSAnne2FRoomsGentleman4Text: PrintText(_SSAnne2FRoomsGentleman4Text)
|
||||
|
||||
@@ -23,9 +23,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
@@ -59,13 +63,17 @@ M.VIRIDIAN_CITY = {
|
||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
||||
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
||||
-- and aren't present in data/generated/text.lua, so we fall back to
|
||||
-- the literal strings from pokered.
|
||||
-- the literal strings from pokered. Those fallbacks have to carry the
|
||||
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
|
||||
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
|
||||
-- one page with nothing to wait on, so the whole speech scrolled past
|
||||
-- without a button press (#250).
|
||||
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
||||
or "You want to know\nabout the 2 kinds\nof caterpillar\nPOKéMON?", function(yes)
|
||||
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
||||
if yes then
|
||||
push(game, "CATERPIE has no\npoison, but\nWEEDLE does.\n\nWatch out for its\nPOISON STING!", done)
|
||||
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
||||
else
|
||||
push(game, "Oh, OK then!", done)
|
||||
end
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
return {
|
||||
VIRIDIAN_NICKNAME_HOUSE = {
|
||||
talk = {
|
||||
-- ViridianNicknameHouseSpearowText: text_asm that shows the text
|
||||
-- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The
|
||||
-- cry playback isn't in the talk-script command vocabulary, so
|
||||
-- only the flavor line is ported here.
|
||||
-- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the
|
||||
-- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish)
|
||||
-- before TextScriptEnd. play_cry only arms the show_text row that
|
||||
-- follows it, so the cry sounds once the box has typed out; its
|
||||
-- waitForButton form keeps DisplayTextID's trailing
|
||||
-- WaitForTextScrollButtonPress, so the box then holds until A/B like
|
||||
-- any other NPC line rather than popping itself (#251).
|
||||
TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = {
|
||||
{ "show_text", "_ViridianNicknameHouseSpearowText" },
|
||||
{ "play_cry", "SPEAROW", true }, -- 1 PlayCry
|
||||
{ "show_text", "_ViridianNicknameHouseSpearowText" }, -- 2 PrintText
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
-- Viridian School House's two readables (data/events/hidden_events.asm,
|
||||
-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE):
|
||||
-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard
|
||||
-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook
|
||||
-- tools/extract/field.py only parses `hidden_event` rows, so neither
|
||||
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
|
||||
-- were dead A presses (#503). Same hook shape, and the same sibling asm
|
||||
-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391);
|
||||
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
|
||||
-- tile gates on facing.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
|
||||
-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left
|
||||
-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its
|
||||
-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop,
|
||||
-- QUIT or B falls through to .exitBlackboard.
|
||||
local STATUS_LABELS = {
|
||||
{ " SLP", "_ViridianBlackboardSleepText" },
|
||||
{ " PSN", "_ViridianBlackboardPoisonText" },
|
||||
{ " PAR", "_ViridianBlackboardPrlzText" },
|
||||
{ " BRN", "_ViridianBlackboardBurnText" },
|
||||
{ " FRZ", "_ViridianBlackboardFrozenText" },
|
||||
}
|
||||
|
||||
-- The headings list is a two-column menu, which src/ui/Menu.lua does not do
|
||||
-- (it stacks one column), so the layout lives here (#591). .blackboardLoop:
|
||||
-- TextBoxBorder at hlcoord 0, 0 with `lb bc, 6, 10` is the 12x8 box,
|
||||
-- StatusAilmentText1 (" SLP"/" PSN"/" PAR") is placed at hlcoord 1, 2 and
|
||||
-- StatusAilmentText2 (" BRN"/" FRZ"/" QUIT") at hlcoord 6, 2. LEFT/RIGHT
|
||||
-- move wTopMenuItemX between those two columns and swap wMenuItemOffset
|
||||
-- between 0 and 3 while leaving wCurrentMenuItem (the row) alone; UP/DOWN
|
||||
-- are not in wMenuWatchedKeys, so they only slide the cursor and loop.
|
||||
local BOARD_LABELS = {}
|
||||
for i, row in ipairs(STATUS_LABELS) do BOARD_LABELS[i] = row[1] end
|
||||
BOARD_LABELS[#BOARD_LABELS + 1] = " QUIT"
|
||||
local BOARD_COL_X = { 1, 6 }
|
||||
local BOARD_ROW_Y = 2
|
||||
local BOARD_ROWS = 3
|
||||
|
||||
local StatusBoard = {}
|
||||
StatusBoard.__index = StatusBoard
|
||||
|
||||
function StatusBoard.new(game, onPick, onQuit)
|
||||
return setmetatable({ game = game, col = 1, row = 1, labels = BOARD_LABELS,
|
||||
onPick = onPick, onQuit = onQuit }, StatusBoard)
|
||||
end
|
||||
|
||||
-- flat index = pokered's wMenuItemOffset (0 or 3) + wCurrentMenuItem (0..2),
|
||||
-- so 1..5 are the statuses in ViridianBlackboardStatusPointers order and 6
|
||||
-- is QUIT
|
||||
function StatusBoard:selection()
|
||||
return (self.col - 1) * BOARD_ROWS + self.row
|
||||
end
|
||||
|
||||
function StatusBoard:update()
|
||||
local input = self.game.input
|
||||
if input:wasPressed("up") then
|
||||
-- wMenuWrappingEnabled is never set here, so both ends are hard stops
|
||||
if self.row > 1 then self.row = self.row - 1 end
|
||||
elseif input:wasPressed("down") then
|
||||
if self.row < BOARD_ROWS then self.row = self.row + 1 end
|
||||
elseif input:wasPressed("left") then
|
||||
self.col = 1
|
||||
elseif input:wasPressed("right") then
|
||||
self.col = 2
|
||||
elseif input:wasPressed("a") or input:wasPressed("b") then
|
||||
-- HandleMenuInput_ (home/window.asm) beeps for the PAD_A | PAD_B branch,
|
||||
-- and B and QUIT share .exitBlackboard
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
local sel = self:selection()
|
||||
if input:wasPressed("b") or sel > #STATUS_LABELS then
|
||||
self.onQuit()
|
||||
else
|
||||
self.onPick(sel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function StatusBoard:draw()
|
||||
Font.drawBox(0, 0, 12, 8)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, label in ipairs(BOARD_LABELS) do
|
||||
local col = i <= BOARD_ROWS and 1 or 2
|
||||
local row = i - (col - 1) * BOARD_ROWS
|
||||
Font.draw(label, BOARD_COL_X[col] * 8, (BOARD_ROW_Y + row - 1) * 8)
|
||||
end
|
||||
-- wTopMenuItemX equals the column PlaceString started at, so the cursor
|
||||
-- covers the blank each label leads with
|
||||
Font.drawCode(Theme.cursor, BOARD_COL_X[self.col] * 8,
|
||||
(BOARD_ROW_Y + self.row - 1) * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
local function blackboard(game)
|
||||
local text = game.data.text or {}
|
||||
local openBoard
|
||||
-- wCurrentMenuItem / wMenuItemOffset are zeroed once, above .blackboardLoop,
|
||||
-- and nothing inside the loop clears them again: after a status blurb
|
||||
-- `jp .blackboardLoop` comes back with the cursor still on the row and
|
||||
-- column the player just picked. One StatusBoard lives for the whole
|
||||
-- reading and is re-pushed each pass, so only entering the blackboard
|
||||
-- resets to the left column / top row (#591).
|
||||
local board
|
||||
-- .blackboardLoop reprints ViridianSchoolBlackboardText2 and only then
|
||||
-- calls HandleMenuInput, so the prompt is on screen for exactly as long as
|
||||
-- the headings list is. That text ends in `done`, not `prompt`
|
||||
-- (data/text/text_2.asm:646), so PrintText returns with the box still up
|
||||
-- and never waits for a button: TextBox opts.stay holds it open under the
|
||||
-- list and these callbacks pop the pair together (#591).
|
||||
local function closeBoard()
|
||||
game.stack:pop() -- the headings list
|
||||
game.stack:pop() -- the held "Which heading" box under it
|
||||
end
|
||||
local function pick(i)
|
||||
closeBoard()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text[STATUS_LABELS[i][2]] or STATUS_LABELS[i][1], openBoard))
|
||||
end
|
||||
function openBoard()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
|
||||
nil, { stay = { onShown = function()
|
||||
board = board or StatusBoard.new(game, pick, closeBoard)
|
||||
game.stack:push(board)
|
||||
end } }))
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText1
|
||||
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
|
||||
openBoard))
|
||||
end
|
||||
|
||||
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
|
||||
-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice)
|
||||
-- and NO stops the read; page 4 turns without asking and runs straight into
|
||||
-- page 5, the girl catching you at it.
|
||||
local function notebook(game)
|
||||
local text = game.data.text or {}
|
||||
local function page(n, after)
|
||||
return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after)
|
||||
end
|
||||
local function turnPage(nextPage)
|
||||
return function()
|
||||
game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?",
|
||||
nil, { choice = function(yes)
|
||||
if yes then game.stack:push(nextPage()) end
|
||||
end }))
|
||||
end
|
||||
end
|
||||
local function page5() return page(5) end
|
||||
local function page4() return page(4, function() game.stack:push(page5()) end) end
|
||||
local function page3() return page(3, turnPage(page4)) end
|
||||
local function page2() return page(2, turnPage(page3)) end
|
||||
game.stack:push(page(1, turnPage(page2)))
|
||||
end
|
||||
|
||||
return {
|
||||
VIRIDIAN_SCHOOL_HOUSE = {
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if fx == 3 and fy == 0 then
|
||||
blackboard(game)
|
||||
return true
|
||||
end
|
||||
if fx == 3 and fy == 4 then
|
||||
notebook(game)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -15,6 +15,7 @@ local files = {
|
||||
"data.scripts.flavor.cerulean_trashed_house",
|
||||
"data.scripts.flavor.copycats_house_1f",
|
||||
"data.scripts.flavor.copycats_house_2f",
|
||||
"data.scripts.flavor.fuchsia_city",
|
||||
"data.scripts.flavor.game_corner",
|
||||
"data.scripts.flavor.lavender_cubone_house",
|
||||
"data.scripts.flavor.lavender_mart",
|
||||
@@ -60,6 +61,7 @@ local files = {
|
||||
"data.scripts.flavor.victory_road_2f",
|
||||
"data.scripts.flavor.viridian_city",
|
||||
"data.scripts.flavor.viridian_nickname_house",
|
||||
"data.scripts.flavor.viridian_school_house", -- #503
|
||||
"data.scripts.flavor.wardens_house",
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,26 @@ local M = {
|
||||
VIRIDIAN_GYM = { city = "VIRIDIAN CITY", leader = "GIOVANNI", badge = "EARTHBADGE" },
|
||||
}
|
||||
|
||||
-- The originals' middle branch shared by every leader's text_asm: beaten
|
||||
-- but EVENT_GOT_TM* unset means the bag was full when the victory script
|
||||
-- ran GiveItem, so talking to the leader re-runs the ReceiveTM script.
|
||||
-- Returns true when the retry took over the talk. A save from before
|
||||
-- #797 already holds the TM without the flag; treat the owned TM as
|
||||
-- received so those saves fall through to the advice text instead of
|
||||
-- collecting a second copy.
|
||||
local function retryTmGive(game, ow, victoryKey, done)
|
||||
local reward = require("data.scripts.victories")[victoryKey]
|
||||
if not (reward and reward.gotFlag) then return false end
|
||||
if game.save.flags[reward.gotFlag] then return false end
|
||||
local owned = game.save.inventory and game.save.inventory[reward.item] or 0
|
||||
if owned > 0 then
|
||||
game.save.flags[reward.gotFlag] = true
|
||||
return false
|
||||
end
|
||||
ow:offerGymTm(reward, done)
|
||||
return true
|
||||
end
|
||||
|
||||
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||
@@ -25,12 +45,14 @@ local M = {
|
||||
-- badge/TM34 rewards and EVENT_BEAT_BROCK come from
|
||||
-- data/scripts/victories.lua OPP_BROCK#1). After the badge his
|
||||
-- .afterBeat branch prints _PewterGymBrockPostBattleAdviceText ("Go to
|
||||
-- the GYM in CERULEAN..."). The original's middle branch (beat but
|
||||
-- TM34 not yet handed over, CheckEventReuseA EVENT_GOT_TM34) is
|
||||
-- unreachable in the port: the TM is granted with the victory.
|
||||
-- the GYM in CERULEAN..."). The middle branch (beat but TM34 not yet
|
||||
-- handed over, CheckEventReuseA EVENT_GOT_TM34 -> call
|
||||
-- PewterGymScriptReceiveTM34) retries the TM give when the bag was full
|
||||
-- at the victory (#797).
|
||||
M.PEWTER_GYM.talk = {
|
||||
TEXT_PEWTERGYM_BROCK = function(game, ow, npc, done)
|
||||
if game.save.flags.EVENT_BEAT_BROCK then
|
||||
if retryTmGive(game, ow, "OPP_BROCK#1", done) then return end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||
@@ -48,16 +70,17 @@ M.PEWTER_GYM.talk = {
|
||||
-- (engageTrainer shows that same pre-battle text via resolveText; the
|
||||
-- badge/TM rewards and the beat flag come from data/scripts/victories.lua)
|
||||
-- -- and once beaten print the post-battle advice text. As with Brock,
|
||||
-- the originals' middle branch (beaten but the TM not yet handed over,
|
||||
-- CheckEventReuseA EVENT_GOT_TM*) is unreachable in the port: the TM is
|
||||
-- granted with the victory.
|
||||
-- the middle branch (beaten but the TM not yet handed over,
|
||||
-- CheckEventReuseA EVENT_GOT_TM*) retries the TM give when the bag was
|
||||
-- full at the victory.
|
||||
-- afterAdvice, when given, takes over `done`: it is handed (game, ow, npc,
|
||||
-- done) and must call done() itself once whatever it's doing (e.g. a fade
|
||||
-- around a HideObject) finishes, rather than having it invoked
|
||||
-- automatically. Only Giovanni's farewell uses this.
|
||||
local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice)
|
||||
local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryKey)
|
||||
return function(game, ow, npc, done)
|
||||
if game.save.flags[beatFlag] then
|
||||
if victoryKey and retryTmGive(game, ow, victoryKey, done) then return end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local finish = done
|
||||
if afterAdvice then
|
||||
@@ -79,42 +102,42 @@ end
|
||||
M.CERULEAN_GYM.talk = {
|
||||
TEXT_CERULEANGYM_MISTY = leaderTalk("EVENT_BEAT_MISTY",
|
||||
"_CeruleanGymMistyTM11ExplanationText",
|
||||
"TM11 teaches\nBUBBLEBEAM!"),
|
||||
"TM11 teaches\nBUBBLEBEAM!", nil, "OPP_MISTY#1"),
|
||||
}
|
||||
|
||||
-- scripts/VermilionGym.asm VermilionGymLTSurgeText .got_tm24_already
|
||||
M.VERMILION_GYM.talk = {
|
||||
TEXT_VERMILIONGYM_LT_SURGE = leaderTalk("EVENT_BEAT_LT_SURGE",
|
||||
"_VermilionGymLTSurgePostBattleAdviceText",
|
||||
"A little word of\nadvice, kid!"),
|
||||
"A little word of\nadvice, kid!", nil, "OPP_LT_SURGE#1"),
|
||||
}
|
||||
|
||||
-- scripts/CeladonGym.asm CeladonGymErikaText .afterBeat
|
||||
M.CELADON_GYM.talk = {
|
||||
TEXT_CELADONGYM_ERIKA = leaderTalk("EVENT_BEAT_ERIKA",
|
||||
"_CeladonGymErikaPostBattleAdviceText",
|
||||
"You are cataloging\nPOKéMON? I must\nsay I'm impressed."),
|
||||
"You are cataloging\nPOKéMON? I must\nsay I'm impressed.", nil, "OPP_ERIKA#1"),
|
||||
}
|
||||
|
||||
-- scripts/FuchsiaGym.asm FuchsiaGymKogaText .afterBeat
|
||||
M.FUCHSIA_GYM.talk = {
|
||||
TEXT_FUCHSIAGYM_KOGA = leaderTalk("EVENT_BEAT_KOGA",
|
||||
"_FuchsiaGymKogaPostBattleAdviceText",
|
||||
"When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!"),
|
||||
"When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!", nil, "OPP_KOGA#1"),
|
||||
}
|
||||
|
||||
-- scripts/SaffronGym.asm SaffronGymSabrinaText .afterBeat
|
||||
M.SAFFRON_GYM.talk = {
|
||||
TEXT_SAFFRONGYM_SABRINA = leaderTalk("EVENT_BEAT_SABRINA",
|
||||
"_SaffronGymSabrinaPostBattleAdviceText",
|
||||
"Everyone has\npsychic power!\nPeople just don't\nrealize it!"),
|
||||
"Everyone has\npsychic power!\nPeople just don't\nrealize it!", nil, "OPP_SABRINA#1"),
|
||||
}
|
||||
|
||||
-- scripts/CinnabarGym.asm CinnabarGymBlaineText .afterBeat
|
||||
M.CINNABAR_GYM.talk = {
|
||||
TEXT_CINNABARGYM_BLAINE = leaderTalk("EVENT_BEAT_BLAINE",
|
||||
"_CinnabarGymBlainePostBattleAdviceText",
|
||||
"FIRE BLAST is the\nultimate fire\ntechnique!"),
|
||||
"FIRE BLAST is the\nultimate fire\ntechnique!", nil, "OPP_BLAINE#1"),
|
||||
}
|
||||
|
||||
-- scripts/ViridianGym.asm ViridianGymGiovanniText .afterBeat: after the
|
||||
@@ -142,7 +165,7 @@ M.VIRIDIAN_GYM.talk = {
|
||||
"VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI")
|
||||
end
|
||||
end, done))
|
||||
end),
|
||||
end, "OPP_GIOVANNI#3"),
|
||||
}
|
||||
|
||||
return M
|
||||
|
||||
@@ -12,11 +12,18 @@
|
||||
-- different files can each add NPCs to the same map), and mod
|
||||
-- contributions from the map_scripts registry compose on top of it.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
|
||||
-- OaksLab is a full Yellow rewrite (one Eevee ball + forced Pikachu);
|
||||
-- Red/Blue keep the three-starter choose flow.
|
||||
local oaksLab = GameVersion.isYellow()
|
||||
and "data.scripts.oaks_lab_yellow"
|
||||
or "data.scripts.oaks_lab"
|
||||
|
||||
for _, mapEntry in ipairs({
|
||||
{ "PALLET_TOWN", "data.scripts.pallet_town" },
|
||||
{ "OAKS_LAB", "data.scripts.oaks_lab" },
|
||||
{ "OAKS_LAB", oaksLab },
|
||||
{ "REDS_HOUSE_1F", "data.scripts.reds_house" },
|
||||
{ "CELADON_MANSION_ROOF_HOUSE", "data.scripts.celadon_eevee" },
|
||||
}) do
|
||||
@@ -36,6 +43,19 @@ for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
|
||||
end
|
||||
end
|
||||
|
||||
-- Yellow-only content on top of the shared tables (talk keys merge per
|
||||
-- TEXT constant): the Kanto-starter gift quests and Jessie & James.
|
||||
if GameVersion.isYellow() then
|
||||
for _, file in ipairs({ "data.scripts.yellow_gifts",
|
||||
"data.scripts.yellow_jessie_james",
|
||||
"data.scripts.yellow_beach_house",
|
||||
"data.scripts.yellow_viridian_old_man" }) do
|
||||
for mapId, mod in pairs(require(file)) do
|
||||
MapScripts.attachBase(mapId, mod)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local M = {}
|
||||
|
||||
function M.get(mapId)
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
-- takes it ("I'll take this one, then!") and both balls disappear.
|
||||
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
|
||||
-- OaksLabRivalTakePokeBallScript.
|
||||
-- * Rival (object 1): before starter -> "gramps isn't around"; with
|
||||
-- * Leftover ball (after the pick): Oak turns and reads the last-mon
|
||||
-- line instead of re-offering the starter (OaksLabLastMonScript, #601).
|
||||
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
|
||||
-- has walked you in, else "gramps isn't around" (#218); with
|
||||
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
||||
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
|
||||
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
|
||||
@@ -19,10 +22,10 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
rivalBallX, rivalBall)
|
||||
return {
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
|
||||
{ "jump_if_true", 20 }, -- 2
|
||||
{ "jump_if_true", 23 }, -- 2
|
||||
-- no picking until Oak has walked you in (OaksLabScript gating)
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||
{ "jump_if_false", 20 }, -- 4
|
||||
{ "jump_if_false", 26 }, -- 4
|
||||
-- the Pokédex "new species" entry shows before the ask (predef
|
||||
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
||||
-- sets the owned bits so ShowPokedexData prints height/weight/text;
|
||||
@@ -30,30 +33,39 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ "push_screen", "DexEntryMenu",
|
||||
{ species = species, forceOwned = true } }, -- 5
|
||||
{ "ask", askText }, -- 6
|
||||
{ "jump_if_false", 21 }, -- 7
|
||||
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
|
||||
{ "give_pokemon", species, 5 }, -- 9
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
|
||||
{ "set_flag", choseFlag }, -- 11
|
||||
-- POKé BALLs are not handed out here in the original -- Oak gives
|
||||
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
||||
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
|
||||
{ "jump_if_false", "end" }, -- 7
|
||||
-- scripts/OaksLab.asm:919
|
||||
{ "show_text", "_OaksLabMonEnergeticText" }, -- 8
|
||||
-- OaksLab.asm: ReceivedMon (sound_get_key_item) then AddPartyMon; the
|
||||
-- jingle fires once the box has typed and holds it (#668)
|
||||
{ "text_sound", "Get_Key_Item" }, -- 9
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10
|
||||
{ "give_pokemon", species, 5 }, -- 11
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 12
|
||||
{ "set_flag", choseFlag }, -- 13
|
||||
-- POKé BALLs come later, at OaksLabOak1Text's .give_poke_balls beat
|
||||
-- once the Route 22 rival is beaten (see TEXT_OAKSLAB_OAK1 below)
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 14
|
||||
-- the rival walks to the countering ball (around the furniture)
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
|
||||
{ "face_object", 1, "up" }, -- 14
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 15
|
||||
{ "face_object", 1, "up" }, -- 16
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 17
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 18
|
||||
{ "text_sound", "Get_Key_Item" }, -- 19 (sound_get_key_item)
|
||||
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
or "BULBASAUR" } }, -- 17
|
||||
{ "jump", 21 }, -- 18
|
||||
{ "jump", 21 }, -- 19 (spacer)
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
|
||||
or "BULBASAUR" } }, -- 20
|
||||
{ "jump", "end" }, -- 21
|
||||
{ "jump", "end" }, -- 22 (spacer)
|
||||
-- leftover ball: Oak reads the last-mon line (scripts/OaksLab.asm
|
||||
-- OaksLabSelectedPokeBallScript -> OaksLabLastMonScript, #601)
|
||||
{ "face_object", 5, "down" }, -- 23
|
||||
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 24
|
||||
-- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall
|
||||
-- through into the pre-pick line below (#601 remnant, reported on #600)
|
||||
{ "jump", "end" }, -- 25
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 26
|
||||
}
|
||||
end
|
||||
|
||||
@@ -61,10 +73,22 @@ return {
|
||||
talk = {
|
||||
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
|
||||
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
|
||||
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
|
||||
-- (DisplayDexRating) is still skipped.
|
||||
-- in, full Pokédex speech, rival exit, Route 22 arm).
|
||||
TEXT_OAKSLAB_OAK1 = {
|
||||
{ "face_player" },
|
||||
-- OaksLabOak1Text leads with the dex-rating branch (#600): with
|
||||
-- EVENT_PALLET_AFTER_GETTING_POKEBALLS set (converted saves), or
|
||||
-- 2+ species owned once the Pokédex is in hand, Oak asks how it is
|
||||
-- coming and rates it (predef DisplayDexRating). Red keeps the
|
||||
-- GOT_POKEDEX gate that Yellow's copy of this text drops
|
||||
-- (data/scripts/oaks_lab_yellow.lua).
|
||||
{ "check_flag", "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
|
||||
{ "jump_if_true", "dex_rating" },
|
||||
{ "check_dex_owned", 2 },
|
||||
{ "jump_if_false", "no_rating" },
|
||||
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "jump_if_true", "dex_rating" },
|
||||
{ "label", "no_rating" },
|
||||
{ "check_item", "POKE_BALL" },
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
|
||||
@@ -76,8 +100,8 @@ return {
|
||||
{ "check_item", "OAKS_PARCEL" },
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
@@ -98,8 +122,8 @@ return {
|
||||
{ "face_object", 1, "up" },
|
||||
{ "face_object", 5, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", 1, "up" },
|
||||
@@ -150,6 +174,14 @@ return {
|
||||
|
||||
{ "label", "come_see" },
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
-- .HowIsYourPokedexComingText ends on `prompt` and OaksLabOak1Text
|
||||
-- sets wDoNotWaitForButtonPressAfterDisplayingText, so the seen/owned
|
||||
-- tally follows with no button wait (engine/events/pokedex_rating.asm)
|
||||
{ "label", "dex_rating" },
|
||||
{ "show_text", "_OaksLabOak1HowIsYourPokedexComingText" },
|
||||
{ "dex_rating" },
|
||||
},
|
||||
|
||||
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
||||
@@ -162,36 +194,45 @@ return {
|
||||
starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR",
|
||||
"OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"),
|
||||
|
||||
-- Talking to the rival only ever prints a line: the lab battle is a
|
||||
-- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6;
|
||||
-- see onStep below), never a talk action. The handler used to fall
|
||||
-- through from the "looks stronger" taunt straight into start_battle,
|
||||
-- so talking to Blue at the table launched the rival fight before the
|
||||
-- player ever stepped onto the trigger (#219). scripts/OaksLab.asm
|
||||
-- OaksLabText8 branches text only:
|
||||
-- * got starter, not yet battled -> _OaksLabRivalMyPokemonLooksStronger
|
||||
-- * already battled -> _OaksLabRivalFedUpWithWaitingText (a Route 22
|
||||
-- line; normally unreachable since the rival is hidden after the
|
||||
-- lab battle in OaksLabRivalEndBattleScript)
|
||||
-- * no starter yet -> the FOLLOWED_OAK pre-starter fork (#218)
|
||||
TEXT_OAKSLAB_RIVAL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
||||
{ "jump_if_false", 21 }, -- 3
|
||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
|
||||
{ "jump_if_true", 19 }, -- 5
|
||||
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
|
||||
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
|
||||
{ "jump_if_false", 11 }, -- 8
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 3 }, -- 9 Charmander
|
||||
{ "jump", 16 }, -- 10
|
||||
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
|
||||
{ "jump_if_false", 15 }, -- 12
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
|
||||
{ "jump", 16 }, -- 14
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
|
||||
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
|
||||
{ "heal_party" }, -- 16
|
||||
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
|
||||
{ "jump", 23 }, -- 18
|
||||
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
|
||||
{ "jump", "end" }, -- 20
|
||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
|
||||
{ "jump", "end" }, -- 22
|
||||
-- win: sulk text then exit; loss: Rival1WinText already played in
|
||||
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
|
||||
{ "jump_if_false", 25 }, -- 23
|
||||
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
|
||||
{ "move_npc_to", 1, 4, 11 }, -- 25
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_GOT_STARTER" },
|
||||
{ "jump_if_false", "pre_starter" },
|
||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
|
||||
{ "jump_if_true", "after_battle" },
|
||||
-- has a starter, has not fought yet: just the taunt. The battle is
|
||||
-- the coordinate trigger in onStep, not this talk (#219)
|
||||
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "after_battle" },
|
||||
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
-- pre-starter fork (scripts/OaksLab.asm OaksLabText8 rival handler):
|
||||
-- Oak has already escorted you in (three balls on the table) -> he
|
||||
-- waves you on to choose; not yet escorted (very early game) -> the
|
||||
-- "Gramps isn't around" line. #218
|
||||
{ "label", "pre_starter" },
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" },
|
||||
{ "jump_if_false", "gramps_gone" },
|
||||
{ "show_text", "_OaksLabRivalGoAheadAndChooseText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "gramps_gone" },
|
||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -199,9 +240,17 @@ return {
|
||||
-- the table sprites; re-entering the lab applies the same HideObject
|
||||
-- the gift script now does (OaksLab.asm OakGivesPokedex).
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
local flags = game.save.flags or {}
|
||||
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
|
||||
local rival = ow:npcByIndex(1)
|
||||
if rival then
|
||||
rival.cellX = flags.EVENT_CHOSE_CHARMANDER and 7
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and 8 or 6
|
||||
rival.cellY = 4
|
||||
rival.px, rival.py = rival.cellX * 16, rival.cellY * 16
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_POKEDEX then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||
@@ -213,9 +262,16 @@ return {
|
||||
-- OaksLabScript8 / OaksLabRivalChallenge)
|
||||
onStep = function(game, ow, x, y)
|
||||
local flags = game.save.flags
|
||||
-- Oak blocks the exit mats (4,11)/(5,11) until you take a starter
|
||||
-- Oak stops you leaving without a starter at the bookshelf row (cell
|
||||
-- y == 6): this is the same wYCoord == 6 coordinate script the rival
|
||||
-- challenge just below uses (scripts/OaksLab.asm, both gated on
|
||||
-- EVENT_GOT_STARTER), so Oak halts you level with the shelves, not one
|
||||
-- corridor length later on the exit mat. At y>=6 only the x=4,5
|
||||
-- corridor is walkable and the up-push keeps the player above y=7, so
|
||||
-- this only ever fires at y=6 in the corridor -- matching the rival
|
||||
-- trigger's shape. (#232)
|
||||
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
|
||||
and y == 11 and (x == 4 or x == 5) then
|
||||
and y >= 6 then
|
||||
ow.runner:run({
|
||||
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
|
||||
{ "move_player", "up", 1 },
|
||||
@@ -228,7 +284,14 @@ return {
|
||||
and y >= 6 then
|
||||
local rival = ow:npcByIndex(1)
|
||||
if not rival then return false end
|
||||
-- OaksLabRivalChallengesPlayerScript swaps in the rival encounter
|
||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||
local rows = {
|
||||
{ "face_object", 1, "down" }, -- scripts/OaksLab.asm:347-351
|
||||
{ "face_player_dir", "up" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
|
||||
}
|
||||
-- the rival routes to a free cell beside the player
|
||||
@@ -250,16 +313,22 @@ return {
|
||||
local base = #rows
|
||||
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
|
||||
table.insert(rows, { "save_end_battle_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
|
||||
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
||||
table.insert(rows, { "heal_party" })
|
||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
|
||||
-- already shown in-battle via Rival1WinText)
|
||||
table.insert(rows, { "jump_if_false", base + 6 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||
-- walk out past the player. The fanfare was dropped here (#683) -- the
|
||||
-- parcel scene above already plays Music_MeetRival on both arrival and
|
||||
-- departure (lines 144-146), and this exit should match (#596).
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
ow.runner:run(rows, { npc = rival })
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
-- Hand-ported from pret/pokeyellow scripts/OaksLab.asm.
|
||||
-- Yellow: one Eevee ball on the table. Rival snatches it; Oak then
|
||||
-- gives the player the wild Pikachu he caught earlier (STARTER_PIKACHU).
|
||||
-- Object indices differ from Red (no Charmander/Squirtle/Bulbasaur balls):
|
||||
-- 1 RIVAL (4,3), 2 EEVEE_POKE_BALL (7,3), 3 OAK1 (5,2),
|
||||
-- 4-5 POKEDEX (2,1)/(3,1), 6 OAK2 (door), 7 GIRL, 8-9 SCIENTIST.
|
||||
--
|
||||
-- wRivalStarter rides in save.rivalStarter (RIVAL_STARTER_* 1 JOLTEON /
|
||||
-- 2 FLAREON / 3 VAPOREON): JOLTEON baseline at the snatch
|
||||
-- (OaksLabRivalTakesPokeballScript), FLAREON on a lab win / VAPOREON on
|
||||
-- a lab loss (OaksLabRivalEndBattleScript), and Route 22's first battle
|
||||
-- upgrades FLAREON back to JOLTEON (Route22Rival1AfterBattleScript).
|
||||
|
||||
local OAK1 = 3
|
||||
local RIVAL = 1
|
||||
|
||||
return {
|
||||
talk = {
|
||||
TEXT_OAKSLAB_OAK1 = {
|
||||
{ "face_player" },
|
||||
-- Yellow's OaksLabOak1Text leads with the dex-rating branch: once
|
||||
-- EVENT_PALLET_AFTER_GETTING_POKEBALLS is set (converted saves) or
|
||||
-- 2+ species are owned, Oak asks how the Pokédex is coming and
|
||||
-- rates it (predef DisplayDexRating)
|
||||
{ "check_flag", "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
|
||||
{ "jump_if_true", "dex_rating" },
|
||||
{ "check_dex_owned", 2 },
|
||||
{ "jump_if_true", "dex_rating" },
|
||||
{ "check_item", "POKE_BALL" },
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
|
||||
{ "jump_if_true", "give_balls" },
|
||||
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "jump_if_true", "around_world" },
|
||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
|
||||
{ "jump_if_false", "pre_lab_battle" },
|
||||
{ "check_item", "OAKS_PARCEL" },
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- .DeliverParcelText: parcel handover, then the Pokédex scene
|
||||
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalGrampsText" },
|
||||
-- callfar OaksLabPikachuMovementScript, before ShowObject (#1021)
|
||||
{ "pikachu_make_way" },
|
||||
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||
{ "place_npc", RIVAL, 4, 7, "up" },
|
||||
{ "move_npc_to", RIVAL, 4, 3 },
|
||||
{ "play_music", "Music_OaksLab" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
-- Yellow opens with the rival bragging, not Red's "what did you
|
||||
-- call me for" (OaksLabOakGivesPokedexScript text order)
|
||||
{ "show_text", "_OaksLabRivalMyPokemonHasGrownStrongerText" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "show_text", "_OaksLabOakIHaveARequestText" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "show_text", "_OaksLabOakThatWasMyDreamText" },
|
||||
{ "face_object", RIVAL, "right" },
|
||||
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
|
||||
{ "set_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
|
||||
-- OaksLabOakGivesPokedexScript: HideObject TOGGLE_LYING_OLD_MAN /
|
||||
-- ShowObject TOGGLE_OLD_MAN_2 -- Yellow's tutorial old man stands
|
||||
-- on the sleeper's cell (18,9); the Red/Blue walker OLD_MAN at
|
||||
-- (17,5) never appears in Yellow (#617)
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN2" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "move_npc_to", RIVAL, 4, 7 },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||
{ "play_music", "Music_OaksLab" },
|
||||
{ "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
|
||||
{ "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
|
||||
{ "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
|
||||
{ "show_object", "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "raise_young" },
|
||||
-- Yellow: talk-to-it (starter Pikachu) instead of Red raise-young line.
|
||||
{ "show_text", "_OaksLabOak1YouShouldTalkToIt" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "pre_lab_battle" },
|
||||
{ "check_flag", "EVENT_GOT_STARTER" },
|
||||
{ "jump_if_true", "can_fight" },
|
||||
{ "show_text", "_OaksLabOak1GoAheadItsYours" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "can_fight" },
|
||||
{ "show_text", "_OaksLabOak1YourPokemonCanFightText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "around_world" },
|
||||
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "give_balls" },
|
||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "give_item", "POKE_BALL", 5, false },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "come_see" },
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "dex_rating" },
|
||||
{ "show_text", "_OaksLabOak1HowIsYourPokedexComingText" },
|
||||
{ "dex_rating" },
|
||||
},
|
||||
|
||||
-- OaksLabEeveePokeBallText / OaksLabRivalExclamationScript ->
|
||||
-- OaksLabChoseStarterScript..OaksLabPlayerReceivesPikachuScript:
|
||||
-- before Oak's choose speech the ball is just flavor; after it, the
|
||||
-- rival "!"s, shoves the player off the table, snatches the ball,
|
||||
-- then the player is walked over to Oak and handed Pikachu.
|
||||
TEXT_OAKSLAB_EEVEE_POKE_BALL = function(game, ow, npc, done)
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_GOT_STARTER then
|
||||
done()
|
||||
return
|
||||
end
|
||||
if not flags.EVENT_OAK_ASKED_TO_CHOOSE_MON then
|
||||
ow.runner:run({
|
||||
{ "show_text", "_OaksLabThatsAPokeball" },
|
||||
}, { onDone = done })
|
||||
return
|
||||
end
|
||||
local px, py = ow.player.cellX, ow.player.cellY
|
||||
local rows = {
|
||||
-- OaksLabRivalExclamationScript: "!" over the rival
|
||||
{ "emote", RIVAL, "shock" },
|
||||
}
|
||||
-- .RivalPushesPlayerAwayFromEeveeBall is DOWN then RIGHT x3 (the $07
|
||||
-- bytes are Yellow's own step-right encoding, decoded by
|
||||
-- engine/overworld/movement.asm Func_5288 -> Func_532b), and the
|
||||
-- PAD_RIGHT x2 shove is NOT queued alongside it:
|
||||
-- OaksLabRivalTakesPokeballScript .asm_1c564 polls every frame and
|
||||
-- only simulates the pair once wNPCNumScriptedSteps reads 1 -- i.e.
|
||||
-- as the rival begins the LAST byte, the step onto the tile the
|
||||
-- player is standing on. Starting both on one row had Red stroll
|
||||
-- off the Eevee while the rival was still at the top of the table
|
||||
-- (#559).
|
||||
if py == 4 then
|
||||
rows[#rows + 1] = { "walk_npc", RIVAL, { "down", "right", "right" } }
|
||||
-- this one runs concurrently with the shove below
|
||||
rows[#rows + 1] = { "walk_npc", RIVAL, { "right" }, { wait = false } }
|
||||
rows[#rows + 1] = { "face_player_dir", "left" }
|
||||
rows[#rows + 1] = { "move_player", "right", 2 }
|
||||
-- move_player blocks for both tiles, so the rival has already
|
||||
-- landed on (7,4); this is just the beat before he turns up
|
||||
rows[#rows + 1] = { "wait", 20 }
|
||||
else
|
||||
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
|
||||
end
|
||||
rows[#rows + 1] = { "face_object", RIVAL, "up" }
|
||||
rows[#rows + 1] = { "hide_object", "OAKS_LAB", "OAKSLAB_EEVEE_POKE_BALL" }
|
||||
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
|
||||
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
|
||||
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText5" }
|
||||
-- OaksLabRLE_PlayerWalksToOak: the simulated-joypad buffer plays
|
||||
-- its RLE list BACKWARDS (StartSimulatingJoypadStates consumes
|
||||
-- from wSimulatedJoypadStatesEnd), so the real order is LEFT 1,
|
||||
-- DOWN 1, LEFT 3, UP 2 -- from the shove spot (9,4) the player
|
||||
-- rounds the BOTTOM of the table to (5,3), directly below Oak
|
||||
if py == 4 then
|
||||
rows[#rows + 1] = { "walk_npc", "player",
|
||||
{ "left", "down", "left", "left", "left", "up", "up" } }
|
||||
else
|
||||
rows[#rows + 1] = { "walk_npc", "player", { "left" } }
|
||||
end
|
||||
rows[#rows + 1] = { "face_player_dir", "up" }
|
||||
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
||||
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
|
||||
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
|
||||
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
|
||||
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
|
||||
rows[#rows + 1] = { "set_field", "pikachuInBall", true }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
|
||||
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end,
|
||||
|
||||
TEXT_OAKSLAB_RIVAL = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_GOT_STARTER" },
|
||||
{ "jump_if_false", "pre_starter" },
|
||||
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "pre_starter" },
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB_2" },
|
||||
{ "jump_if_false", "gramps_gone" },
|
||||
{ "show_text", "_OaksLabRivalIllGetABetterPokemonThanYou" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "gramps_gone" },
|
||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
|
||||
},
|
||||
},
|
||||
|
||||
onEnter = function(game, ow)
|
||||
local flags = game.save.flags or {}
|
||||
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
|
||||
local rival = ow:npcByIndex(RIVAL)
|
||||
if rival then
|
||||
rival.cellX, rival.cellY = 7, 4
|
||||
rival.px, rival.py = 7 * 16, 4 * 16
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_POKEDEX then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2")
|
||||
end,
|
||||
|
||||
onStep = function(game, ow, x, y)
|
||||
local flags = game.save.flags
|
||||
-- OaksLabPlayerDontGoAwayScript: y==6 without the starter walks the
|
||||
-- player back up a tile
|
||||
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
|
||||
and y >= 6 then
|
||||
ow.runner:run({
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "face_object", RIVAL, "down" },
|
||||
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
|
||||
{ "move_player", "up", 1 },
|
||||
}, {})
|
||||
return true
|
||||
end
|
||||
-- OaksLabRivalChallengesPlayerScript..OaksLabPikachuDislikesPokeballsScript:
|
||||
-- heading for the door with Pikachu starts the rival battle, his
|
||||
-- exit walk, then Pikachu pops out of its ball.
|
||||
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB
|
||||
and y >= 6 then
|
||||
local rival = ow:npcByIndex(RIVAL)
|
||||
if not rival then return false end
|
||||
local rows = {
|
||||
{ "face_object", RIVAL, "down" }, -- pokeyellow scripts/OaksLab.asm:311-315
|
||||
{ "face_player_dir", "up" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalIllTakeYouOnText" },
|
||||
}
|
||||
-- FindPathToPlayer with the Y distance decremented: the rival
|
||||
-- stops one tile above the player
|
||||
local target
|
||||
for _, c in ipairs({ { x, y - 1 }, { x - 1, y }, { x + 1, y },
|
||||
{ x, y + 1 } }) do
|
||||
if ow.map:inBounds(c[1], c[2]) and ow.map:isWalkableCell(c[1], c[2]) then
|
||||
target = c
|
||||
break
|
||||
end
|
||||
end
|
||||
if target then
|
||||
table.insert(rows, { "move_npc_to", RIVAL, target[1], target[2] })
|
||||
end
|
||||
table.insert(rows, { "face_object", RIVAL,
|
||||
target and target[2] < y and "down"
|
||||
or target and target[2] > y and "up"
|
||||
or target and target[1] < x and "right" or "left" })
|
||||
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", 1 })
|
||||
table.insert(rows, { "heal_party" })
|
||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||
-- OaksLabRivalEndBattleScript: his Eevee's future evolution is
|
||||
-- decided here -- FLAREON if the player won, VAPOREON otherwise
|
||||
table.insert(rows, { "check_battle_result", "win" })
|
||||
table.insert(rows, { "jump_if_false", "lost_lab" })
|
||||
table.insert(rows, { "set_field", "rivalStarter", 2 })
|
||||
table.insert(rows, { "jump", "exit" })
|
||||
table.insert(rows, { "label", "lost_lab" })
|
||||
table.insert(rows, { "set_field", "rivalStarter", 3 })
|
||||
table.insert(rows, { "label", "exit" })
|
||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||
-- walk out past the player (#683).
|
||||
table.insert(rows, { "wait", 20 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } })
|
||||
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
|
||||
table.insert(rows, { "face_player_dir", "up" })
|
||||
table.insert(rows, { "set_field", "pikachuInBall", false })
|
||||
table.insert(rows, { "spawn_pikachu_follower" })
|
||||
table.insert(rows, { "play_cry", "PIKACHU" })
|
||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
||||
ow.runner:run(rows, { npc = rival })
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
}
|
||||
@@ -4,9 +4,11 @@
|
||||
-- (.PlayerNextToSafariZoneWorker1CoordsArray). Paying ¥500 hands over
|
||||
-- 30 SAFARI BALLs and starts the 502-step game
|
||||
-- (SafariZoneGateWouldYouLikeToJoinScript: wSafariSteps = 502,
|
||||
-- wNumSafariBalls = SAFARI_BALLS_RECEIVED). Declining walks you back
|
||||
-- so you can't slip past. Returning to the gate ends the game and the
|
||||
-- worker takes the leftover balls back.
|
||||
-- wNumSafariBalls = SAFARI_BALLS_RECEIVED), then auto-walks the player
|
||||
-- up through the north warp into the zone. Declining walks you back
|
||||
-- so you can't slip past. Returning to the gate ends the game, the
|
||||
-- worker takes the leftover balls back and the auto-walk drops you 3
|
||||
-- cells below the warp you came in by (#540).
|
||||
--
|
||||
-- Step/ball bookkeeping lives in src/world/OverworldController.lua
|
||||
-- (safariStep/safariGameOver, from
|
||||
@@ -19,17 +21,87 @@ local FEE = 500
|
||||
local BALLS = 30
|
||||
local STEPS = 502
|
||||
|
||||
local function startGame(game, t, done)
|
||||
game.save.money = game.save.money - FEE
|
||||
game.save.safari = { balls = BALLS, steps = STEPS }
|
||||
-- SafariZoneGateSafariZoneWorker1WouldYouLikeToJoinText .success closes with
|
||||
-- `ld a, PAD_UP / ld c, 3 / SafariZoneEntranceAutoWalk`: paying walks the
|
||||
-- player up out of the gate and through the north warp, it is never left to
|
||||
-- the player. EVENT_IN_SAFARI_ZONE is already set when that walk runs, so
|
||||
-- the two gate steps taken before the warp fires are charged against
|
||||
-- wSafariSteps (home/overworld.asm:307-310) -- which is why the counter
|
||||
-- reads 500/500 on arrival even though the script wrote 502 (#540). The
|
||||
-- port's counter only runs on the nine interior maps (FieldDefaults
|
||||
-- safari.stepMaps, OverworldState:inSafariStepZone), so charge those two
|
||||
-- steps here instead.
|
||||
local function walkIntoZone(game, ow)
|
||||
local p = ow.player
|
||||
-- only from the two trigger cells in front of the worker, which are the
|
||||
-- columns the north warps sit on; a player who paid after TALKING to him
|
||||
-- from somewhere else walks in on their own, as they do today
|
||||
local w = p.cellY == 2 and ow.map:warpAtCell(p.cellX, 0) or nil
|
||||
if not w then return end
|
||||
ow:scriptMove(p, "up", 2, function()
|
||||
local st = game.save.safari
|
||||
if st then st.steps = st.steps - 2 end
|
||||
-- scripted steps skip onStepComplete (and with it CheckWarpsNoCollision),
|
||||
-- so take that warp explicitly once the walk lands on it
|
||||
ow:takeWarp(w.def)
|
||||
end)
|
||||
end
|
||||
|
||||
local function startGame(game, ow, t, done, balls, introText)
|
||||
game.save.safari = { balls = balls or BALLS, steps = STEPS }
|
||||
game.save.safariNags = nil
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local paid = (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText
|
||||
or "That'll be ¥500\nplease!\f{PLAYER} received\n30 SAFARI BALLs!")
|
||||
:gsub("{PLAYER}", game.save.player.name)
|
||||
local paid = introText
|
||||
or (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText
|
||||
or "That'll be ¥500\nplease!\f{PLAYER} received\n30 SAFARI BALLs!")
|
||||
:gsub("{NUM:[^}]*}", "500")
|
||||
paid = paid:gsub("{PLAYER}", game.save.player.name)
|
||||
local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
|
||||
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
|
||||
local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!"
|
||||
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done))
|
||||
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, function()
|
||||
if done then done() end
|
||||
walkIntoZone(game, ow)
|
||||
end))
|
||||
end
|
||||
|
||||
-- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of
|
||||
-- the full fee still gets in.
|
||||
-- 0 < money < 500: SafariZoneEntranceCalculateLowCostAdmission takes
|
||||
-- everything and hands over min(money/23 + 1, 29) balls.
|
||||
-- money == 0: SafariZoneEntranceGetLowCostAdmissionText nags four times
|
||||
-- (LowCostText5/6/7/8), then relents -- free entry, one ball.
|
||||
local function yellowLowCost(game, ow, t, done, back)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
if game.save.money > 0 then
|
||||
local balls = math.min(math.floor(game.save.money / 23) + 1, 29)
|
||||
game.save.money = 0
|
||||
local intro =
|
||||
(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
|
||||
or "Oops! Not enough\nmoney!")
|
||||
.. (t._SafariZoneLowCostText1
|
||||
or "\fOh, all right, pay\nme what you have.")
|
||||
.. "\f" .. (t._SafariZoneLowCostText2
|
||||
or "But, I can't give\nyou all 30 BALLs.")
|
||||
startGame(game, ow, t, done, balls, intro)
|
||||
return
|
||||
end
|
||||
local nag = game.save.safariNags or 0
|
||||
game.save.safariNags = nag + 1
|
||||
if nag >= 3 then
|
||||
local intro =
|
||||
(t._SafariZoneLowCostText8 or "Read my lips, NO!\nGet it?")
|
||||
.. (t._SafariZoneLowCostText3
|
||||
or "\fYou're persistent,\naren't you?\fOK, you can go in\nfor free, but\njust this once!")
|
||||
startGame(game, ow, t, done, 1, intro)
|
||||
return
|
||||
end
|
||||
local nags = {
|
||||
t._SafariZoneLowCostText5 or "I'm sorry, but you\nhave to pay to\nenter.",
|
||||
t._SafariZoneLowCostText6 or "You can't enter\nwithout paying!",
|
||||
t._SafariZoneLowCostText7 or "I said, no money,\nno entry!",
|
||||
}
|
||||
back(nags[nag + 1])
|
||||
end
|
||||
|
||||
local function joinPrompt(game, ow, done)
|
||||
@@ -51,10 +123,15 @@ local function joinPrompt(game, ow, done)
|
||||
back(t._SafariZoneGateSafariZoneWorker1PleaseComeAgainText
|
||||
or "OK! Please come\nagain!")
|
||||
elseif game.save.money < FEE then
|
||||
back(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
|
||||
or "Oops! Not enough\nmoney!")
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
yellowLowCost(game, ow, t, done, back)
|
||||
else
|
||||
back(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
|
||||
or "Oops! Not enough\nmoney!")
|
||||
end
|
||||
else
|
||||
startGame(game, t, done)
|
||||
game.save.money = game.save.money - FEE
|
||||
startGame(game, ow, t, done)
|
||||
end
|
||||
end))
|
||||
end))
|
||||
@@ -89,27 +166,38 @@ M.SAFARI_ZONE_GATE = {
|
||||
-- no walks you back into the zone
|
||||
onEnter = function(game, ow)
|
||||
if not game.save.safari or ow.player.cellY > 1 then return end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local t = game.data.text
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SafariZoneGateSafariZoneWorker1LeavingEarlyText or "Leaving early?",
|
||||
function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
-- back into the zone through the entrance warp
|
||||
local w = game.data.maps.SAFARI_ZONE_CENTER.warps[1]
|
||||
ow:startWarpTo("SAFARI_ZONE_CENTER", w.x, w.y, "up")
|
||||
return
|
||||
end
|
||||
game.save.safari = nil
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SafariZoneGateSafariZoneWorker1ReturnSafariBallsText
|
||||
or "Please return any\nSAFARI BALLs you\nhave left.")
|
||||
.. "\f" .. (t._SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText
|
||||
or "Did you get a\ngood haul?\fCome again!")))
|
||||
end))
|
||||
end))
|
||||
-- QUEUED, never pushed: onEnter runs inside the arriving warp's
|
||||
-- Transition midpoint, and Transition:finish pops whatever is on top
|
||||
-- the same frame (Timing.WARP_FADE_IN is 0) -- so a box pushed here is
|
||||
-- swallowed, and on a build where it survived it drew over a screen
|
||||
-- still faded to black (#540). Same contract as M.HALL_OF_FAME in
|
||||
-- data/scripts/story.lua.
|
||||
--
|
||||
-- SafariZoneGateLeavingSafariScript .leaving_early: YES prints the
|
||||
-- return-balls text, faces the player down and runs
|
||||
-- SafariZoneEntranceAutoWalk with `PAD_DOWN, c = 3`, landing on the
|
||||
-- counter row 3 cells below the warp you came in by; NO prints
|
||||
-- "Good Luck!" and walks one step back up through that same warp.
|
||||
local rightSide = ow.player.cellX ~= 3
|
||||
local dest = game.data.maps.SAFARI_ZONE_CENTER.warps[rightSide and 2 or 1]
|
||||
ow:queueScript({
|
||||
{ "ask", "_SafariZoneGateSafariZoneWorker1LeavingEarlyText" },
|
||||
{ "jump_if_false", "stay" },
|
||||
-- the port never reaches SafariZoneGateLeavingSafariScript's own
|
||||
-- GOOD_HAUL_COME_AGAIN branch (safariGameOver warps straight to the
|
||||
-- counter), so the sign-off rides on this path
|
||||
{ "show_text", "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText" },
|
||||
{ "show_text", "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText" },
|
||||
-- no value: set_field assigns nil, which is how save.safari is cleared
|
||||
{ "set_field", "safari" },
|
||||
-- move_player runs through scriptMove, which skips onStepComplete, so
|
||||
-- walking back down past (x,2) cannot re-fire the join trigger
|
||||
{ "move_player", "down", 3 },
|
||||
{ "jump", "end" },
|
||||
{ "label", "stay" },
|
||||
{ "show_text", "_SafariZoneGateSafariZoneWorker1GoodLuckText" },
|
||||
{ "warp", "SAFARI_ZONE_CENTER", dest.x, dest.y, "up" },
|
||||
})
|
||||
end,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
-- field.seafoam (SEAFOAM_ISLANDS_1F/B1F/B3F holes+holeDestination and
|
||||
-- B3F's pluggedByHolesOn) plus the generic
|
||||
-- OverworldState:boulderIntoHole in src/world/OverworldController.lua;
|
||||
-- no per-map onEnter hook is needed here.
|
||||
-- no per-map onEnter hook is needed for the boulders.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -23,4 +23,46 @@ M.VERMILION_GYM = {
|
||||
end,
|
||||
}
|
||||
|
||||
-- The PLAYER falling down those same holes (#599). field.seafoam's holes
|
||||
-- carry only the BOULDER's object cell on the floor below (landsAt), not
|
||||
-- the player's landing, so the player's landing is spelled out here: it
|
||||
-- comes from data/maps/special_warps.asm DungeonWarpList/DungeonWarpData,
|
||||
-- which the importer does not extract. Same shape as MANSION_HOLES in
|
||||
-- data/scripts/story6.lua and VICTORY_ROAD_3F.onStep in
|
||||
-- data/scripts/story.lua; CAVERN $22 is a walkable tile, so the fall has
|
||||
-- to be an onStep, not a collision block.
|
||||
--
|
||||
-- Sources: scripts/SeafoamIslands1F.asm Seafoam1HolesCoords (17,6)/(24,6),
|
||||
-- B1F.asm Seafoam2HolesCoords (18,6)/(23,6), B2F.asm Seafoam3HolesCoords
|
||||
-- (19,6)/(22,6), B3F.asm Seafoam4HolesCoords (3,16)/(6,16). Each floor
|
||||
-- sets wDungeonWarpDestinationMap and calls IsPlayerOnDungeonWarp, and
|
||||
-- wCoordIndex picks that floor's DungeonWarpData row. Unconditional in
|
||||
-- the original: a plugged hole still drops the player. The B3F and B4F
|
||||
-- landings are water; setMap's CheckForceBikeOrSurf pass
|
||||
-- (OverworldState:checkForcedMovement) mounts SURF on arrival.
|
||||
local HOLE_FALLS = {
|
||||
SEAFOAM_ISLANDS_1F = { { 17, 6, "SEAFOAM_ISLANDS_B1F", 18, 7 },
|
||||
{ 24, 6, "SEAFOAM_ISLANDS_B1F", 23, 7 } },
|
||||
SEAFOAM_ISLANDS_B1F = { { 18, 6, "SEAFOAM_ISLANDS_B2F", 19, 7 },
|
||||
{ 23, 6, "SEAFOAM_ISLANDS_B2F", 22, 7 } },
|
||||
SEAFOAM_ISLANDS_B2F = { { 19, 6, "SEAFOAM_ISLANDS_B3F", 18, 7 },
|
||||
{ 22, 6, "SEAFOAM_ISLANDS_B3F", 19, 7 } },
|
||||
SEAFOAM_ISLANDS_B3F = { { 3, 16, "SEAFOAM_ISLANDS_B4F", 4, 14 },
|
||||
{ 6, 16, "SEAFOAM_ISLANDS_B4F", 5, 14 } },
|
||||
}
|
||||
|
||||
for mapId, holes in pairs(HOLE_FALLS) do
|
||||
M[mapId] = M[mapId] or {}
|
||||
M[mapId].onStep = function(game, ow, x, y)
|
||||
for _, h in ipairs(holes) do
|
||||
if x == h[1] and y == h[2] then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -16,6 +16,14 @@ M.VIRIDIAN_MART = {
|
||||
-- StartSimulatingJoypadStates walks the player to the counter
|
||||
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
||||
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
||||
-- never presses A.
|
||||
--
|
||||
-- The RLE list is consumed back to front: DecodeRLEList fills forward
|
||||
-- from wSimulatedJoypadStatesEnd, the script seeds wSimulatedJoypadStates-
|
||||
-- Index with the count, and home/overworld.asm decrements that index
|
||||
-- before each read. So PAD_LEFT 1, PAD_UP 2 actually plays up, up, left,
|
||||
-- and the walk ends facing the clerk. Replaying the list front to back
|
||||
-- put the player at the counter facing up instead (#235). The player
|
||||
-- never presses A. This matters beyond convenience: the parcel gates
|
||||
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
||||
-- it on entry rather than letting you walk out without it.
|
||||
@@ -28,8 +36,8 @@ M.VIRIDIAN_MART = {
|
||||
if not f.EVENT_GOT_STARTER then return end
|
||||
ow:queueScript({
|
||||
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
||||
{ "move_player", "left", 1 },
|
||||
{ "move_player", "up", 2 },
|
||||
{ "move_player", "left", 1 },
|
||||
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
||||
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
||||
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
||||
@@ -86,6 +94,27 @@ M.VIRIDIAN_CITY = {
|
||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
|
||||
},
|
||||
},
|
||||
-- Re-apply the Pokedex old-man swap for a save that already holds the flag
|
||||
-- but was never standing here when it fired: a vanilla .sav imported
|
||||
-- through src/save_convert, whose codec does not model pokered's
|
||||
-- wToggleableObjectFlags array, so save.objectToggles arrives empty and
|
||||
-- both objects fall back to their compiled-in defaults -- the sleeper ON
|
||||
-- (data/maps/toggleable_objects.asm toggleable_objects_for VIRIDIAN_CITY),
|
||||
-- still lying across the north path (#234).
|
||||
-- OaksLabOakGivesPokedexScript (scripts/OaksLab.asm) sets EVENT_GOT_POKEDEX
|
||||
-- and, with no branch in between, runs HideObject TOGGLE_LYING_OLD_MAN /
|
||||
-- ShowObject TOGGLE_OLD_MAN, so that one flag settles both toggles exactly.
|
||||
-- Same shape as M.OAKS_LAB.onEnter in data/scripts/oaks_lab.lua, which
|
||||
-- re-applies the same script's other two HideObjects on entry (#106).
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
|
||||
Commands.show_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
|
||||
end,
|
||||
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
|
||||
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
|
||||
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
|
||||
@@ -111,22 +140,29 @@ M.VIRIDIAN_CITY = {
|
||||
-- Daisy hands over the TOWN MAP once Oak's errand is under way
|
||||
-- (scripts/BluesHouse.asm BluesHouseDaisySittingText)
|
||||
M.BLUES_HOUSE = {
|
||||
-- scripts/BluesHouse.asm:12-16
|
||||
onEnter = function(game, ow)
|
||||
game.save.flags.EVENT_ENTERED_BLUES_HOUSE = true
|
||||
end,
|
||||
talk = {
|
||||
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TOWN_MAP" }, -- 2
|
||||
{ "jump_if_true", 10 }, -- 3
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 4
|
||||
{ "jump_if_false", 12 }, -- 5
|
||||
{ "show_text", "_BluesHouseDaisyOfferMapText" }, -- 6
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_GOT_TOWN_MAP" },
|
||||
{ "jump_if_true", "got_map" },
|
||||
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "jump_if_false", "too_early" },
|
||||
{ "show_text", "_BluesHouseDaisyOfferMapText" },
|
||||
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
|
||||
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
|
||||
{ "give_item", "TOWN_MAP", 1, "_GotMapText" }, -- 7
|
||||
{ "set_flag", "EVENT_GOT_TOWN_MAP" }, -- 8
|
||||
{ "jump", 13 }, -- 9
|
||||
{ "show_text", "_BluesHouseDaisyUseMapText" }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BluesHouseDaisyRivalAtLabText" }, -- 12
|
||||
{ "give_item", "TOWN_MAP", 1, "_GotMapText" },
|
||||
{ "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" },
|
||||
{ "set_flag", "EVENT_GOT_TOWN_MAP" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "got_map" },
|
||||
{ "show_text", "_BluesHouseDaisyUseMapText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "too_early" },
|
||||
{ "show_text", "_BluesHouseDaisyRivalAtLabText" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -154,11 +190,17 @@ M.BILLS_HOUSE = {
|
||||
overworld = ow },
|
||||
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||
require("src.world.PikachuFollower").onBillEnteredMachine(game, ow)
|
||||
done()
|
||||
end
|
||||
if ow.player.facing == "down" then
|
||||
-- the player is standing on his straight path: walk around
|
||||
-- (.PokemonWalkAroundPlayerMovement)
|
||||
-- (.PokemonWalkAroundPlayerMovement). BillsHouseScript2 runs
|
||||
-- BillsHousePikachuWatchPlayer first on this branch, so a
|
||||
-- Pikachu that is still following steps clear of Bill's detour
|
||||
-- and turns to watch the player (#455).
|
||||
require("src.world.PikachuFollower")
|
||||
.onBillWalksAroundPlayer(game, ow)
|
||||
ow:scriptMove(npc, "right", 1, function()
|
||||
ow:scriptMove(npc, "up", 2, function()
|
||||
ow:scriptMove(npc, "left", 1, function()
|
||||
@@ -244,6 +286,9 @@ M.BILLS_HOUSE = {
|
||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||
end
|
||||
if not flags.EVENT_MET_BILL_2 then
|
||||
require("src.world.PikachuFollower").onBillsHouseEnter(game, ow)
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -256,6 +301,7 @@ M.BILLS_HOUSE = {
|
||||
|
||||
M.ROUTE_25 = {
|
||||
onEnter = function(game, ow)
|
||||
game.save.pikachuMapScriptActive = nil
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
@@ -288,6 +334,7 @@ M.VERMILION_CITY = {
|
||||
-- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
|
||||
-- reachable through this map, so a fresh visit always re-rolls).
|
||||
onEnter = function(game, ow)
|
||||
game.save.pikachuMapScriptActive = nil
|
||||
local puz = game.save.trashPuzzle or {}
|
||||
game.save.trashPuzzle = puz
|
||||
puz.first = love.math.random(0, 7) * 2
|
||||
@@ -395,7 +442,93 @@ M.SS_ANNE_CAPTAINS_ROOM = {
|
||||
-- MrFujisHouse.asm; the teleport back to his house is a warp)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- scripts/PokemonTower7F.asm: after each Rocket loses,
|
||||
-- PokemonTower7FEndBattleScript shows its EndBattle text (EndTrainerBattle),
|
||||
-- prints its AfterBattle text (DisplayTextID), then
|
||||
-- PokemonTower7FRocketLeaveMovementScript walks the grunt off toward the
|
||||
-- (9,16) stairs (MoveSprite) and PokemonTower7FHideNPCScript despawns it
|
||||
-- (HideObject). Without this the beaten grunts stood in the corridor
|
||||
-- forever (#200).
|
||||
local POKEMON_TOWER_7F_ROCKETS = {
|
||||
{ index = 1, name = "POKEMONTOWER7F_ROCKET1",
|
||||
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0",
|
||||
after = "_PokemonTower7FRocket1AfterBattleText" },
|
||||
{ index = 2, name = "POKEMONTOWER7F_ROCKET2",
|
||||
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1",
|
||||
after = "_PokemonTower7FRocket2AfterBattleText" },
|
||||
{ index = 3, name = "POKEMONTOWER7F_ROCKET3",
|
||||
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2",
|
||||
after = "_PokemonTower7FRocket3AfterBattleText" },
|
||||
}
|
||||
|
||||
-- PokemonTower7FNPCCoordMovementTable, keyed by the PLAYER's tile ("x,y")
|
||||
-- at the engagement (both the talk and the sight walk-up leave the player on
|
||||
-- the tile the vanilla table indexes). Each list is that entry's
|
||||
-- NPC_MOVEMENT_* exit, applied from the grunt's current tile. The exact
|
||||
-- tables are one per grunt (dbmapcoord bytes decoded back to x,y).
|
||||
local POKEMON_TOWER_7F_EXITS = {
|
||||
[1] = { -- ROCKET1 @ (9,11), faces RIGHT
|
||||
["9,12"] = { "right", "down", "down", "down", "down", "down", "left" },
|
||||
["10,11"] = { "down", "right", "down", "down", "down", "down" },
|
||||
["11,11"] = { "down", "down", "down", "down", "down" },
|
||||
["12,11"] = { "down", "down", "down", "down", "down" },
|
||||
},
|
||||
[2] = { -- ROCKET2 @ (12,9), faces LEFT
|
||||
["12,10"] = { "left", "down", "down", "down", "down", "down", "down" },
|
||||
["11,9"] = { "down", "down", "down", "left", "down", "down" },
|
||||
["10,9"] = { "down", "down", "down", "down", "down" },
|
||||
["9,9"] = { "down", "down", "down", "down", "down" },
|
||||
},
|
||||
[3] = { -- ROCKET3 @ (9,7), faces RIGHT
|
||||
["9,8"] = { "right", "down", "down", "down", "down", "down", "down" },
|
||||
["10,7"] = { "down", "down", "down", "down", "down" },
|
||||
["11,7"] = { "down", "down", "down", "down", "down" },
|
||||
["12,7"] = { "down", "down", "down", "down", "down" },
|
||||
},
|
||||
}
|
||||
-- The vanilla table only lists the sight/talk tiles the map geometry allows;
|
||||
-- a plain down-walk keeps any other engagement tile (e.g. talking from the
|
||||
-- side) safe -- it still clears the corridor and despawns the grunt.
|
||||
local POKEMON_TOWER_7F_EXIT_FALLBACK =
|
||||
{ "down", "down", "down", "down", "down" }
|
||||
|
||||
M.POKEMON_TOWER_7F = {
|
||||
-- Repair saves that beat a grunt before the exit walk was ported: in
|
||||
-- vanilla HideObject persists (wMissableObjectFlags), so a beaten grunt is
|
||||
-- still gone on re-entry. hide_object below writes objectToggles for new
|
||||
-- wins; this hides any grunt whose BEAT flag is already set.
|
||||
onEnter = function(game, ow)
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
|
||||
if game.save.flags[r.beat] then
|
||||
Commands.hide_object(ctx, "POKEMON_TOWER_7F", r.name)
|
||||
end
|
||||
end
|
||||
end,
|
||||
-- runVictoryHook fires after every trainer win on the map (both the sight
|
||||
-- and talk paths route through engageTrainer -> checkVictoryRewards), so
|
||||
-- this walks off whichever grunt was just beaten but is still standing.
|
||||
-- The AfterBattle text + walk queue UNDER the EndBattle box engageTrainer
|
||||
-- pushes right after, giving the vanilla order: EndBattle, AfterBattle,
|
||||
-- walk, despawn.
|
||||
onVictory = function(game, ow)
|
||||
if ow.runner:isRunning() then return end
|
||||
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
|
||||
local npc = ow:npcByIndex(r.index)
|
||||
if npc and game.save.flags[r.beat] then
|
||||
local key = ow.player.cellX .. "," .. ow.player.cellY
|
||||
local dirs = (POKEMON_TOWER_7F_EXITS[r.index] or {})[key]
|
||||
or POKEMON_TOWER_7F_EXIT_FALLBACK
|
||||
ow.runner:run({
|
||||
{ "show_text", r.after }, -- DisplayTextID
|
||||
{ "walk_npc", r.index, dirs }, -- MoveSprite
|
||||
{ "hide_object", "POKEMON_TOWER_7F", r.name }, -- HideObject
|
||||
}, { npc = npc })
|
||||
return
|
||||
end
|
||||
end
|
||||
end,
|
||||
talk = {
|
||||
TEXT_POKEMONTOWER7F_MR_FUJI = {
|
||||
{ "face_player" }, -- 1
|
||||
@@ -481,12 +614,34 @@ local function snorlaxWake(mapId, objName, beatFlag, wokeUpText, calmedText)
|
||||
}
|
||||
end
|
||||
|
||||
-- A beaten Snorlax is gone for good: Route12/Route16DefaultScript run
|
||||
-- HideObject in the same breath as the battle that sets
|
||||
-- EVENT_BEAT_ROUTEnn_SNORLAX, so "event set, object still on the map" is a
|
||||
-- state the asm cannot produce. Here it can (a mod's world:toggleObject, a
|
||||
-- save edited or migrated from a build older than the flag), and it is a
|
||||
-- dead end: ItemEffects' adjacentSleepingSnorlax refuses to wake a Snorlax
|
||||
-- whose beat flag is set (ItemUsePokeFlute's CheckEvent, engine/items/
|
||||
-- item_effects.asm), so the sleeper sits in the road forever and Cycling
|
||||
-- Road is unreachable (#585). Reconcile the toggle from the flag on every
|
||||
-- entry -- the mirror of SaveData.lua's toggle -> flag backfill, and the
|
||||
-- same repair shape the Silph Co. floors use below.
|
||||
local function hideBeatenSnorlax(mapId, objName, beatFlag)
|
||||
return function(game, ow)
|
||||
if not game.save.flags[beatFlag] then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
||||
mapId, objName)
|
||||
end
|
||||
end
|
||||
|
||||
-- snorlaxWake is looked up by ItemEffects.lua/BagMenu.lua (via
|
||||
-- data/scripts/init.lua's M.get) and run when the flute wakes Snorlax;
|
||||
-- objName/beatFlag let ItemEffects find the NPC and check whether it's
|
||||
-- already been beaten before allowing the wake.
|
||||
M.ROUTE_12 = {
|
||||
talk = { TEXT_ROUTE12_SNORLAX = { { "show_text", "_Route12SnorlaxText" } } },
|
||||
onEnter = hideBeatenSnorlax("ROUTE_12", "ROUTE12_SNORLAX",
|
||||
"EVENT_BEAT_ROUTE12_SNORLAX"),
|
||||
snorlaxWake = {
|
||||
objName = "ROUTE12_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE12_SNORLAX",
|
||||
script = snorlaxWake("ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX",
|
||||
@@ -495,6 +650,8 @@ M.ROUTE_12 = {
|
||||
}
|
||||
M.ROUTE_16 = {
|
||||
talk = { TEXT_ROUTE16_SNORLAX = { { "show_text", "_Route16Text7" } } },
|
||||
onEnter = hideBeatenSnorlax("ROUTE_16", "ROUTE16_SNORLAX",
|
||||
"EVENT_BEAT_ROUTE16_SNORLAX"),
|
||||
snorlaxWake = {
|
||||
objName = "ROUTE16_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE16_SNORLAX",
|
||||
script = snorlaxWake("ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX",
|
||||
@@ -526,11 +683,18 @@ M.SAFARI_ZONE_SECRET_HOUSE = {
|
||||
M.WARDENS_HOUSE = {
|
||||
talk = {
|
||||
TEXT_WARDENSHOUSE_WARDEN = {
|
||||
-- Labelled rather than hand-numbered: the branches here have been
|
||||
-- re-pointed twice now (#535, #645), and every insert used to mean
|
||||
-- renumbering three jumps that had no way of announcing they were stale.
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
|
||||
{ "jump_if_true", 13 }, -- 3
|
||||
-- #535: previously jumped to the same silent-end target as the
|
||||
-- give-then-thank fallthrough, so the warden said nothing on every
|
||||
-- visit after the trade. pokered's .got_item branch
|
||||
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText.
|
||||
{ "jump_if_true", "got_hm04" }, -- 3
|
||||
{ "check_item", "GOLD_TEETH" }, -- 4
|
||||
{ "jump_if_false", 15 }, -- 5
|
||||
{ "jump_if_false", "no_teeth" }, -- 5
|
||||
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
|
||||
{ "take_item", "GOLD_TEETH", 1 }, -- 7
|
||||
{ "set_flag", "EVENT_GAVE_GOLD_TEETH" }, -- 8
|
||||
@@ -539,9 +703,27 @@ M.WARDENS_HOUSE = {
|
||||
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
|
||||
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
|
||||
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
|
||||
{ "jump", 16 }, -- 13 (already got it)
|
||||
{ "jump", 16 }, -- 14 (unused)
|
||||
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
|
||||
{ "jump", "end" }, -- 13 (jp .done)
|
||||
|
||||
-- #645: WardensHouseWardenText prints Gibberish1, then YesNoChoice,
|
||||
-- and the warden answers the same gibberish either way -- Gibberish2
|
||||
-- on yes, Gibberish3 on no (scripts/WardensHouse.asm). The port
|
||||
-- printed the question and walked off before the answer.
|
||||
{ "label", "no_teeth" }, -- 14
|
||||
{ "ask", "_WardensHouseWardenGibberish1Text" }, -- 15
|
||||
{ "jump_if_true", "gibberish_yes" }, -- 16
|
||||
{ "show_text", "_WardensHouseWardenGibberish3Text" }, -- 17
|
||||
{ "jump", "end" }, -- 18
|
||||
{ "label", "gibberish_yes" }, -- 19
|
||||
{ "show_text", "_WardensHouseWardenGibberish2Text" }, -- 20
|
||||
{ "jump", "end" }, -- 21
|
||||
|
||||
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
|
||||
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
|
||||
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
|
||||
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
|
||||
{ "label", "got_hm04" }, -- 22
|
||||
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 23
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -551,6 +733,70 @@ M.WARDENS_HOUSE = {
|
||||
-- generic OPP_GIOVANNI#2 battle)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- SilphCo11FTeamRocketLeavesScript (scripts/SilphCo11F.asm) hides every
|
||||
-- ROCKET and rocket-aligned SCIENTIST toggle on 2F-11F the moment Giovanni
|
||||
-- falls; the item balls, the 2F/10F rescued workers and the 7F rival keep
|
||||
-- theirs. Names and order are data/maps/toggleable_objects.asm's
|
||||
-- TOGGLE_SILPH_CO_<floor>_n entries. Saffron City's half of the same
|
||||
-- script lives in M.SAFFRON_CITY (story4.lua) (#392).
|
||||
local SILPH_ROCKET_OBJECTS = {
|
||||
{ "SILPH_CO_2F", { "SILPHCO2F_SCIENTIST1", "SILPHCO2F_SCIENTIST2",
|
||||
"SILPHCO2F_ROCKET1", "SILPHCO2F_ROCKET2" } },
|
||||
{ "SILPH_CO_3F", { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" } },
|
||||
{ "SILPH_CO_4F", { "SILPHCO4F_ROCKET1", "SILPHCO4F_SCIENTIST",
|
||||
"SILPHCO4F_ROCKET2" } },
|
||||
{ "SILPH_CO_5F", { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST",
|
||||
"SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" } },
|
||||
{ "SILPH_CO_6F", { "SILPHCO6F_ROCKET1", "SILPHCO6F_SCIENTIST",
|
||||
"SILPHCO6F_ROCKET2" } },
|
||||
{ "SILPH_CO_7F", { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST",
|
||||
"SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" } },
|
||||
{ "SILPH_CO_8F", { "SILPHCO8F_ROCKET1", "SILPHCO8F_SCIENTIST",
|
||||
"SILPHCO8F_ROCKET2" } },
|
||||
{ "SILPH_CO_9F", { "SILPHCO9F_ROCKET1", "SILPHCO9F_SCIENTIST",
|
||||
"SILPHCO9F_ROCKET2" } },
|
||||
{ "SILPH_CO_10F", { "SILPHCO10F_ROCKET", "SILPHCO10F_SCIENTIST" } },
|
||||
{ "SILPH_CO_11F", { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1",
|
||||
"SILPHCO11F_ROCKET2" } },
|
||||
}
|
||||
|
||||
-- hide_object writes save.objectToggles, so the single pass at the win
|
||||
-- covers floors the player is not standing on; onlyMap is the per-floor
|
||||
-- repair path below.
|
||||
local function silphRocketsLeave(game, ow, onlyMap)
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||
if not onlyMap or onlyMap == floor[1] then
|
||||
for _, name in ipairs(floor[2]) do
|
||||
Commands.hide_object(ctx, floor[1], name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- SilphCo11FGiovanniAfterBattleScript (scripts/SilphCo11F.asm) is the whole
|
||||
-- aftermath: DisplayTextID TEXT_SILPHCO11F_GIOVANNI_YOU_RUINED_OUR_PLANS,
|
||||
-- GBFadeOutToBlack, SilphCo11FTeamRocketLeavesScript, Delay3,
|
||||
-- GBFadeInFromBlack, then SetEvent. The port had only the hide pass, so the
|
||||
-- speech never played and every rocket blinked out in front of the player
|
||||
-- (#722). Same hide list as silphRocketsLeave, spelled as script rows so the
|
||||
-- fade can hold over it.
|
||||
local function silphAftermathRows()
|
||||
local rows = {
|
||||
{ "show_text", "_SilphCo11FGiovanniYouRuinedOurPlansText" },
|
||||
{ "fade", "out" },
|
||||
}
|
||||
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||
for _, name in ipairs(floor[2]) do
|
||||
rows[#rows + 1] = { "hide_object", floor[1], name }
|
||||
end
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 3 } -- Delay3
|
||||
rows[#rows + 1] = { "fade", "in" }
|
||||
return rows
|
||||
end
|
||||
|
||||
M.SILPH_CO_11F = {
|
||||
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
||||
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
||||
@@ -563,9 +809,13 @@ M.SILPH_CO_11F = {
|
||||
-- line) would touch, and the whole Silph ending -- the flag, the Master
|
||||
-- Ball, the Saffron streets clearing -- silently never happened.
|
||||
--
|
||||
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
|
||||
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
|
||||
-- sets nothing, so the trigger re-arms exactly as vanilla does.
|
||||
-- SilphCo11FDefaultScript orders it DisplayTextID TEXT_SILPHCO11F_GIOVANNI
|
||||
-- FIRST, then MoveSprite .GiovanniMovement: he speaks from behind the desk
|
||||
-- and only then walks the three tiles down. Moving him before the box made
|
||||
-- him cross the room in silence and deliver the speech point-blank (#869),
|
||||
-- so the box comes first here and engageTrainer skips its own battle text.
|
||||
-- victories.lua OPP_GIOVANNI#2 sets the event on a win; a loss sets
|
||||
-- nothing, so the trigger re-arms exactly as vanilla does.
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
|
||||
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
|
||||
@@ -574,45 +824,72 @@ M.SILPH_CO_11F = {
|
||||
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
|
||||
end
|
||||
if not gio or ow:trainerDefeated(gio) then return false end
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
|
||||
-- after the loss (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||
end
|
||||
end)
|
||||
end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._SilphCo11FGiovanniText
|
||||
or "Ah {PLAYER}!\nSo we meet again!",
|
||||
function()
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!"
|
||||
-- speech, then SilphCo11FTeamRocketLeavesScript behind a fade so
|
||||
-- every Silph rocket leaves off-screen (the street rockets are
|
||||
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
||||
-- run here: the battle's own callbacks are still unwinding, so
|
||||
-- queueScript starts it on the first idle overworld frame --
|
||||
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
|
||||
-- pushes (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end, nil, true)
|
||||
end)
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||
silphRocketsLeave(game, ow)
|
||||
end
|
||||
end,
|
||||
talk = {
|
||||
-- SilphCo11FSilphPresidentText branches on EVENT_GOT_MASTER_BALL only:
|
||||
-- the teleport pads reach him without passing Giovanni's trigger, and
|
||||
-- afterwards he describes the ball forever. The old rows fell off the
|
||||
-- end of the script on both branches, so a second talk was silence
|
||||
-- (#392).
|
||||
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
|
||||
{ "jump_if_false", 10 }, -- 3
|
||||
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 4
|
||||
{ "jump_if_true", 10 }, -- 5
|
||||
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 6
|
||||
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 4
|
||||
-- give-then-print like scripts/SilphCo11F.asm
|
||||
{ "give_item", "MASTER_BALL", 1, false }, -- 7
|
||||
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 9
|
||||
{ "jump", 11 }, -- 10
|
||||
{ "give_item", "MASTER_BALL", 1, false }, -- 5
|
||||
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 6
|
||||
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 7
|
||||
{ "jump", "end" }, -- 8
|
||||
{ "show_text",
|
||||
"_SilphCo11FSilphPresidentMasterBallDescriptionText" }, -- 9
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Floors 2F-10F carry no other onEnter, so this repairs saves that beat
|
||||
-- Giovanni before the hide list existed (same shape as
|
||||
-- M.POKEMON_TOWER_7F.onEnter); 11F hides its own inside the table above.
|
||||
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||
local mapId = floor[1]
|
||||
if mapId ~= "SILPH_CO_11F" then
|
||||
M[mapId] = M[mapId] or {}
|
||||
M[mapId].onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
silphRocketsLeave(game, ow, mapId)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
|
||||
-- a boulder resting on a switch removes a barrier block; the 3F hole
|
||||
@@ -625,11 +902,20 @@ local function boulderAt(ow, x, y)
|
||||
return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil
|
||||
end
|
||||
|
||||
-- Each barrier is stamped BOTH ways on entry. pokered only ever stamps the
|
||||
-- OPEN block (ReplaceTileBlock edits the loaded block map, which the next map
|
||||
-- load throws away, so a fresh entry always shows the .blk's closed barrier),
|
||||
-- but OverworldState:replaceBlock writes through Map:setBlock into the SHARED
|
||||
-- map record in Game.data, so the port has to put the closed block back
|
||||
-- itself or a solved barrier stays open for the rest of the session (#258).
|
||||
-- The closed ids are the shipped .blk bytes: VictoryRoad1F.blk (4,6) = $25,
|
||||
-- VictoryRoad2F.blk (3,4) = $37 and (11,7) = $25, VictoryRoad3F.blk
|
||||
-- (3,5) = $25. Same both-ways pattern as the card-key doors in
|
||||
-- OverworldState:setMap.
|
||||
M.VICTORY_ROAD_1F = {
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH then
|
||||
ow:replaceBlock(4, 6, 0x1D)
|
||||
end
|
||||
ow:replaceBlock(4, 6,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH and 0x1D or 0x25)
|
||||
end,
|
||||
onBoulderMoved = function(game, ow, npc)
|
||||
if npc.cellX == 17 and npc.cellY == 13
|
||||
@@ -642,12 +928,14 @@ M.VICTORY_ROAD_1F = {
|
||||
|
||||
M.VICTORY_ROAD_2F = {
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 then
|
||||
ow:replaceBlock(3, 4, 0x15)
|
||||
end
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 then
|
||||
ow:replaceBlock(11, 7, 0x1D)
|
||||
end
|
||||
-- VictoryRoad2FResetBoulderEventScript (scripts/VictoryRoad2F.asm:19):
|
||||
-- entering 2F clears the 1F switch event, so 1F's barrier is closed
|
||||
-- again the next time you climb down (#258)
|
||||
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = nil
|
||||
ow:replaceBlock(3, 4,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 and 0x15 or 0x37)
|
||||
ow:replaceBlock(11, 7,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 and 0x1D or 0x25)
|
||||
end,
|
||||
onBoulderMoved = function(game, ow, npc)
|
||||
if npc.cellX == 1 and npc.cellY == 16
|
||||
@@ -665,9 +953,8 @@ M.VICTORY_ROAD_2F = {
|
||||
|
||||
M.VICTORY_ROAD_3F = {
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 then
|
||||
ow:replaceBlock(3, 5, 0x1D)
|
||||
end
|
||||
ow:replaceBlock(3, 5,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 and 0x1D or 0x25)
|
||||
end,
|
||||
onBoulderMoved = function(game, ow, npc)
|
||||
if npc.cellX == 3 and npc.cellY == 5
|
||||
@@ -675,12 +962,23 @@ M.VICTORY_ROAD_3F = {
|
||||
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true
|
||||
ow:replaceBlock(3, 5, 0x1D)
|
||||
end
|
||||
-- the hole at (23,15): the boulder drops to 2F next to switch 2
|
||||
if npc.cellX == 23 and npc.cellY == 15 then
|
||||
-- the hole at (23,15): the boulder drops to 2F next to switch 2.
|
||||
-- VictoryRoad3FDefaultScript .handle_hole gates the swap on
|
||||
-- CheckAndSetEvent EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2, then
|
||||
-- HideObject TOGGLE_VICTORY_ROAD_3F_BOULDER / ShowObject
|
||||
-- TOGGLE_VICTORY_ROAD_2F_BOULDER. Those two toggles are
|
||||
-- VICTORYROAD3F_BOULDER4 and VICTORYROAD2F_BOULDER3
|
||||
-- (data/maps/toggleable_objects.asm); the old "VICTORYROAD2F_BOULDER"
|
||||
-- matched no object_event name, so the toggle landed under a key
|
||||
-- objectVisible never reads: harmless only while nothing hid that
|
||||
-- boulder, and fatal once Route 23's reset does (#258).
|
||||
if npc.cellX == 23 and npc.cellY == 15
|
||||
and not game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 then
|
||||
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = true
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, overworld = ow, game = game }
|
||||
Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name)
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
|
||||
end
|
||||
end,
|
||||
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
|
||||
@@ -690,6 +988,7 @@ M.VICTORY_ROAD_3F = {
|
||||
-- fall is onStep, not a collision block.
|
||||
onStep = function(game, ow, x, y)
|
||||
if x == 23 and y == 15 then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
|
||||
return true
|
||||
end
|
||||
@@ -728,40 +1027,63 @@ M.VICTORY_ROAD_3F = {
|
||||
local championsRoomRivalScript = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
|
||||
{ "jump_if_true", 25 }, -- 3 past end
|
||||
-- "end" rather than a row number past the tail: this script grew by a row
|
||||
-- when the follow-Oak walk landed (#704), which silently turned the old
|
||||
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
|
||||
-- it, so a returning champion warped straight into the induction.
|
||||
{ "jump_if_true", "end" }, -- 3
|
||||
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
|
||||
{ "jump_if_false", 25 }, -- 6 past end
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
|
||||
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
|
||||
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
|
||||
-- playBattle("final") then no-ops on the same song, so the theme stays
|
||||
-- continuous into the fight
|
||||
{ "play_music", "Music_FinalBattle" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6
|
||||
-- losing halts here; the numeric target this replaced pointed at the
|
||||
-- closing warp, which inducted a player who had just lost the fight (#704)
|
||||
{ "jump_if_false", "end" }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 8
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 9
|
||||
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
|
||||
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
|
||||
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
|
||||
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
|
||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
|
||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
|
||||
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
|
||||
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
|
||||
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
|
||||
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 11
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
|
||||
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
|
||||
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in.
|
||||
-- audio/alternate_tempo.asm Music_Cities1AlternateTempo is not a plain
|
||||
-- PlayMusic: it fades the current song out (wAudioFadeOutControl = 10),
|
||||
-- waits 100 frames for the fade, then restarts Cities1 with channel 1
|
||||
-- pointed at Music_Cities1_Ch1_AlternateTempo -- `tempo 232` where the
|
||||
-- normal Music_Cities1_Ch1 opens `tempo 144`, i.e. the slower, heavier
|
||||
-- reading of the town theme this scene is known for (#847).
|
||||
{ "fade_music", 10 }, -- 11
|
||||
{ "wait", 100 }, -- 12
|
||||
{ "play_music", "Music_Cities1", { keep = true, tempo = 232 } }, -- 13
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 14
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 15
|
||||
{ "move_npc", 2, "up", 5 }, -- 16 OakEntranceAfterVictoryMovement
|
||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||
{ "face_object", 1, "left" }, -- 14
|
||||
{ "face_object", 2, "down" }, -- 15
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
|
||||
{ "face_object", 1, "left" }, -- 17
|
||||
{ "face_object", 2, "down" }, -- 18
|
||||
{ "load_player_starter_name" },
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 17
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
|
||||
{ "face_object", 2, "right" }, -- 20
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 21
|
||||
-- OakComeWithMeScript: Oak faces down again, then exits up
|
||||
{ "face_object", 2, "down" }, -- 19
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
|
||||
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
|
||||
{ "face_object", 2, "down" }, -- 22
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
||||
-- scripts/ChampionsRoom.asm WalkToHallOfFame_RLEMovement
|
||||
{ "move_player", "left", 1 },
|
||||
{ "move_player", "up", 3 }, -- 27
|
||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 23
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 28
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 29
|
||||
}
|
||||
|
||||
M.CHAMPIONS_ROOM = {
|
||||
@@ -921,6 +1243,7 @@ local function pokemonTower2FRivalScript(playerX)
|
||||
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 1, exitDirs }, -- 9
|
||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||
{ "jump", "end" }, -- 11
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
-- More hand-ported events: the Pallet Town intro, the thirsty Saffron
|
||||
-- gate guards, the Bike Voucher chain, fossils and the day-care.
|
||||
-- Registered via data/scripts/init.lua; each cites its pokered source.
|
||||
-- Registered via data/scripts/init.lua; Red/Blue cite pokered, Yellow
|
||||
-- cites pokeyellow (Pallet stop row, Oak spawn, walk RLE, and the
|
||||
-- wild-Pikachu beat before the lab escort).
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -35,72 +39,91 @@ function escort.findPath(fromX, fromY, toX, toY)
|
||||
return path
|
||||
end
|
||||
|
||||
-- PalletTownOakWalksToPlayerScript: Oak appears at his object spot
|
||||
-- (8,5) and zigzags to one tile below the player (hNPCPlayerYDistance
|
||||
-- is pre-decremented before predef FindPathToPlayer).
|
||||
-- Red: Oak object (8,5) -> one tile below the player at y=1.
|
||||
-- Yellow: Oak object (10,4); stop fires at y=0 (pokeyellow PalletTown).
|
||||
function escort.oakApproach(playerX)
|
||||
if GameVersion.isYellow() then
|
||||
return escort.findPath(10, 4, playerX, 1)
|
||||
end
|
||||
return escort.findPath(8, 5, playerX, 2)
|
||||
end
|
||||
|
||||
-- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm):
|
||||
-- DOWN x5, LEFT, DOWN x5, RIGHT x3, UP -- Oak's last step lands on the
|
||||
-- lab door (12,11). (The trailing NPC_CHANGE_FACING is a march-in-
|
||||
-- place beat on the mat; here Oak just stands his final beat.)
|
||||
escort.oakSteps = {
|
||||
"down", "down", "down", "down", "down",
|
||||
"left",
|
||||
"down", "down", "down", "down", "down",
|
||||
"right", "right", "right",
|
||||
"up",
|
||||
}
|
||||
-- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm).
|
||||
-- Yellow differs: first DOWN is x6 (Oak starts one tile farther north).
|
||||
local function buildOakSteps()
|
||||
if GameVersion.isYellow() then
|
||||
return {
|
||||
"down", "down", "down", "down", "down", "down",
|
||||
"left",
|
||||
"down", "down", "down", "down", "down",
|
||||
"right", "right", "right",
|
||||
"up",
|
||||
}
|
||||
end
|
||||
return {
|
||||
"down", "down", "down", "down", "down",
|
||||
"left",
|
||||
"down", "down", "down", "down", "down",
|
||||
"right", "right", "right",
|
||||
"up",
|
||||
}
|
||||
end
|
||||
|
||||
-- RLEList_PlayerWalkToLab decodes to UP x2, RIGHT x3, DOWN x5, LEFT,
|
||||
-- DOWN x6 and plays in REVERSE buffer order (wSimulatedJoypadStatesEnd
|
||||
-- grows downward): DOWN x6, LEFT, DOWN x5, RIGHT x3, UP x2 -- Oak's
|
||||
-- exact path one step behind. The 17th press (the second UP) is eaten
|
||||
-- by the door-warp frame (WarpFound clears hJoyHeld via EnterMap), so
|
||||
-- only 16 real steps happen; the walk ends on the door at (12,11).
|
||||
escort.oakSteps = buildOakSteps()
|
||||
|
||||
-- Player stays one step behind Oak (simplified reverse-RLE port).
|
||||
escort.playerSteps = { "down" }
|
||||
for _, d in ipairs(escort.oakSteps) do
|
||||
escort.playerSteps[#escort.playerSteps + 1] = d
|
||||
end
|
||||
|
||||
local function npcNamed(ow, name)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
M.PALLET_TOWN = {
|
||||
talk = require("data.scripts.pallet_town").talk,
|
||||
escort = escort,
|
||||
-- Oak stops you at the north row (PalletTownDefaultScript's
|
||||
-- `wYCoord == 1` check), walks up from (8,5), and leads you to his
|
||||
-- lab with the player one step behind (scripts/PalletTown.asm +
|
||||
-- PalletMovementScriptPointerTable in
|
||||
-- engine/overworld/auto_movement.asm), then the lab walk-in and the
|
||||
-- choose-mon exchange (scripts/OaksLab.asm OaksLabDefaultScript ..
|
||||
-- OaksLabOakChooseMonSpeechScript).
|
||||
-- scripts/PalletTown.asm:133-144
|
||||
onEnter = function(game, ow)
|
||||
local f = game.save.flags
|
||||
if f.EVENT_GOT_TOWN_MAP and f.EVENT_ENTERED_BLUES_HOUSE
|
||||
and not f.EVENT_DAISY_WALKING then
|
||||
f.EVENT_DAISY_WALKING = true
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY1")
|
||||
Commands.show_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY2")
|
||||
end
|
||||
end,
|
||||
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
|
||||
-- then a wild Pikachu battle before the lab escort (pokeyellow
|
||||
-- PalletTownPikachuBattleScript).
|
||||
onStep = function(game, ow, x, y)
|
||||
if y ~= 1 or game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB
|
||||
local yellow = GameVersion.isYellow()
|
||||
local stopY = yellow and 0 or 1
|
||||
if y ~= stopY or game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB
|
||||
or game.save.flags.EVENT_GOT_STARTER then
|
||||
return false
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Commands = require("src.script.Commands")
|
||||
local Music = require("src.core.Music")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local t = game.data.text
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
|
||||
-- PalletTownDefaultScript: stop the player, turn them around
|
||||
-- (wPlayerMovingDirection = PLAYER_DIR_DOWN applies on the very
|
||||
-- next frame, before the text box opens) and strike up the "oak
|
||||
-- appears" theme (MUSIC_MEET_PROF_OAK)
|
||||
ow.player.facing = "down"
|
||||
-- Red turns the player down; Yellow faces up at the north exit.
|
||||
ow.player.facing = yellow and "up" or "down"
|
||||
Music.play(game.data, "Music_MeetProfOak")
|
||||
|
||||
-- DelayFrames-style hold: the world pauses (input stays locked)
|
||||
-- for `frames` frames, then cb runs. Reuses the emote pause slot;
|
||||
-- with an `npc` the "!" bubble draws above it (EmotionBubble).
|
||||
local function hold(frames, npc, cb)
|
||||
ow.emote = { frames = frames, npc = npc, onDone = cb }
|
||||
end
|
||||
|
||||
-- chain single-tile scriptMoves through a direction list
|
||||
local function walkList(entity, steps, done)
|
||||
local i = 0
|
||||
local function nextStep()
|
||||
@@ -114,10 +137,8 @@ M.PALLET_TOWN = {
|
||||
nextStep()
|
||||
end
|
||||
|
||||
-- ---- Oak's Lab side (scripts/OaksLab.asm) ----------------------
|
||||
|
||||
-- OaksLabOakChooseMonSpeechScript: the fed-up / choose-mon /
|
||||
-- what-about-me / be-patient exchange, Delay3 between boxes
|
||||
-- OaksLabOakChooseMonSpeechScript. Yellow's OakChooseMon text is
|
||||
-- the single-ball speech, not Red's "there are 3 POKéMON".
|
||||
local function chooseMonSpeech()
|
||||
local function say(key, fb, next)
|
||||
game.stack:push(TextBox.new(game, t[key] or fb, next))
|
||||
@@ -126,7 +147,9 @@ M.PALLET_TOWN = {
|
||||
"{RIVAL}: Gramps!\nI'm fed up with\nwaiting!", function()
|
||||
hold(3, nil, function()
|
||||
say("_OaksLabOakChooseMonText",
|
||||
"OAK: Here, {PLAYER}!\fThere are 3\nPOKéMON here!\fYou can have one!\nChoose!", function()
|
||||
yellow
|
||||
and "OAK: Look, {PLAYER}! Do\nyou see that ball\non the table?"
|
||||
or "OAK: Here, {PLAYER}!\fThere are 3\nPOKéMON here!\fYou can have one!\nChoose!", function()
|
||||
hold(3, nil, function()
|
||||
say("_OaksLabRivalWhatAboutMeText",
|
||||
"{RIVAL}: Hey!\nGramps! What\nabout me?", function()
|
||||
@@ -143,30 +166,18 @@ M.PALLET_TOWN = {
|
||||
end)
|
||||
end
|
||||
|
||||
-- entering the lab: the door Oak (OAKSLAB_OAK2, (5,10)) walks up 3
|
||||
-- ahead of the player (OaksLabOakEntersLabScript OakEntryMovement),
|
||||
-- swaps for the desk Oak (OAKSLAB_OAK1, (5,2)), then the player
|
||||
-- walks up 8 from the mat (PlayerEntryMovementRLE) while the rival
|
||||
-- and Oak turn with them (OaksLabPlayerEntersLabScript /
|
||||
-- OaksLabFollowedOakScript)
|
||||
-- Door Oak is OAKSLAB_OAK2: Red index 8, Yellow index 6 (one ball).
|
||||
local function labWalkIn()
|
||||
local oak2 = ow:npcByIndex(8)
|
||||
local oak2 = npcNamed(ow, "OAKSLAB_OAK2") or ow:npcByIndex(yellow and 6 or 8)
|
||||
local function swapOaks()
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
||||
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK1")
|
||||
hold(3, nil, function() -- Delay3
|
||||
Commands.face_object(ctx, 1, "down") -- rival watches you pass
|
||||
hold(3, nil, function()
|
||||
Commands.face_object(ctx, 1, "down")
|
||||
ow:scriptMove(ow.player, "up", 8, function()
|
||||
-- OaksLabFollowedOakScript: flags only after the walk-in, so a
|
||||
-- stray step on the door mat can't fire the "don't go away"
|
||||
-- push-up (oaks_lab.lua onStep) mid-cutscene. Outdoor escort
|
||||
-- still re-arms on F1 mid-escort -- these flags stay clear
|
||||
-- until the lab walk finishes.
|
||||
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
|
||||
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true
|
||||
Commands.face_object(ctx, 1, "up")
|
||||
-- res BIT_NO_MAP_MUSIC + PlayDefaultMusic: the lab theme
|
||||
-- only starts once the walk-in is done
|
||||
Music.playMap(game.data, "OAKS_LAB")
|
||||
chooseMonSpeech()
|
||||
end)
|
||||
@@ -179,10 +190,8 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
end
|
||||
|
||||
-- PalletMovementScript_Done + the door warp: Oak is hidden as the
|
||||
-- player steps into the doorway; PALLET_TOWN warp 3 -> OAKS_LAB
|
||||
-- warp 2 = (5,11), with the door SFX (WarpFound -> SFX_GO_INSIDE)
|
||||
local function enterLab()
|
||||
local function enterLab(oak)
|
||||
if oak then oak.stepFrames = nil end
|
||||
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
|
||||
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
||||
ow.doorWarp = true
|
||||
@@ -190,25 +199,23 @@ M.PALLET_TOWN = {
|
||||
{ keepMusic = true })
|
||||
end
|
||||
|
||||
-- PalletMovementScript_WalkToLab: Oak's NPC movement and the
|
||||
-- player's simulated joypad run simultaneously, in lockstep; the
|
||||
-- player retraces Oak's path one step behind and follows him into
|
||||
-- the doorway on the final beat
|
||||
local function walkToLab(oak)
|
||||
-- lockstep half runs Oak on the player's own frames per cell
|
||||
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||
local i = 0
|
||||
if oak then
|
||||
oak.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||
end
|
||||
local function tick()
|
||||
i = i + 1
|
||||
local playerStep = escort.playerSteps[i]
|
||||
if not playerStep then
|
||||
enterLab()
|
||||
enterLab(oak)
|
||||
return
|
||||
end
|
||||
if oak and escort.oakSteps[i] then
|
||||
ow:scriptMove(oak, escort.oakSteps[i], 1)
|
||||
elseif oak then
|
||||
-- RLEList_ProfOakWalkToLab's trailing NPC_CHANGE_FACING beat:
|
||||
-- Oak marches in place on the door mat while the player takes
|
||||
-- the final step up behind him (movement.asm ChangeFacingDirection)
|
||||
ow:marchInPlace(oak)
|
||||
end
|
||||
ow:scriptMove(ow.player, playerStep, 1, tick)
|
||||
@@ -216,10 +223,14 @@ M.PALLET_TOWN = {
|
||||
tick()
|
||||
end
|
||||
|
||||
-- PalletMovementScript_OakMoveLeft/_PlayerMoveLeft: from the right
|
||||
-- tile (x == 11) Oak sidesteps left first, then the player follows
|
||||
-- left (wNumStepsToTake = wXCoord - 10), and only then both walk
|
||||
local function escortToLab(oak)
|
||||
-- PalletMovementScript_OakMoveLeft
|
||||
-- (engine/overworld/auto_movement.asm) starts MUSIC_MUSEUM_GUY
|
||||
-- when the escort begins in Yellow. Until then, Pallet Town plays
|
||||
-- after the battle; Red/Blue leave MUSIC_MEET_PROF_OAK playing.
|
||||
if yellow then
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
end
|
||||
local numSteps = x - 10
|
||||
if oak and numSteps > 0 then
|
||||
ow:scriptMove(oak, "left", numSteps, function()
|
||||
@@ -232,37 +243,75 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- Pallet Town side (scripts/PalletTown.asm) -----------------
|
||||
-- After Oak reaches the player: Red goes straight to "It's unsafe!".
|
||||
-- Yellow (PalletTownOakGreetsPlayerScript..AfterPikachuBattleScript):
|
||||
-- ThatWasClose -> Oak faces the grass patch -> BATTLE_TYPE_PIKACHU
|
||||
-- (the old-man-style simulated battle where PROF.OAK throws the ball
|
||||
-- and always catches the lv5 Pikachu) -> Whew -> ComeWithMe.
|
||||
local function afterOakArrives(oak)
|
||||
if not yellow then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PalletTownOakItsUnsafeText
|
||||
or "OAK: It's unsafe!\nWild POKéMON\nlive in tall grass!",
|
||||
function() escortToLab(oak) end))
|
||||
return
|
||||
end
|
||||
local function comeWithMe()
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PalletTownOakComeWithMe
|
||||
or "OAK: Here, come with\nme!",
|
||||
function() escortToLab(oak) end))
|
||||
end
|
||||
local function afterPikaBattle()
|
||||
if oak then oak.facing = "up" end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PalletTownOakWhewText or "OAK: Whew...",
|
||||
comeWithMe))
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PalletTownOakThatWasCloseText
|
||||
or "OAK: That was\nclose!\fWild POKéMON live\nin tall grass!",
|
||||
function()
|
||||
-- Oak turns toward the horizontally adjacent grass (left exit
|
||||
-- looks right, right exit looks left -- the
|
||||
-- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch).
|
||||
-- In pokeyellow, PalletTownOakGreetsPlayerScript turns Oak and
|
||||
-- PalletTownPikachuBattleScript arms the battle on the next
|
||||
-- overworld iteration. OverworldLoopLessDelay
|
||||
-- (home/overworld.asm) burns two DelayFrame calls at the top
|
||||
-- of each iteration and calls RunMapScript before checking
|
||||
-- wCurOpponent, so those two DelayFrame calls are what keep
|
||||
-- Oak's turn on screen before the battle check fires.
|
||||
if oak then oak.facing = x == 10 and "right" or "left" end
|
||||
hold(2, nil, function()
|
||||
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||
battle:makeOldManDemo("PROF.OAK")
|
||||
battle.onFinish = function()
|
||||
afterPikaBattle()
|
||||
end
|
||||
-- Use the standard wild-battle entry transition.
|
||||
Commands.pushBattle(ctx, battle)
|
||||
end)
|
||||
end))
|
||||
end
|
||||
|
||||
-- PalletTownOakWalksToPlayerScript: Oak appears at (8,5), faces up
|
||||
-- (SetSpriteFacingDirectionAndDelay + Delay3), then zigzags to the
|
||||
-- player; the "It's unsafe!" text follows and the escort begins
|
||||
local function oakAppearsAndWalks()
|
||||
Commands.show_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
|
||||
local oak = ow:npcByIndex(1)
|
||||
local oak = npcNamed(ow, "PALLETTOWN_OAK") or ow:npcByIndex(1)
|
||||
if oak then oak.facing = "up" end
|
||||
hold(6, nil, function()
|
||||
walkList(oak, escort.oakApproach(x), function()
|
||||
-- PalletTownOakNotSafeComeWithMeScript: the second text waits
|
||||
-- for a button, then the escort starts
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PalletTownOakItsUnsafeText
|
||||
or "OAK: It's unsafe!\nWild POKéMON\nlive in tall grass!",
|
||||
function() escortToLab(oak) end))
|
||||
afterOakArrives(oak)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
-- The "Hey! Wait!" box ends without a button wait (auto), then the
|
||||
-- "!" bubble shows over the player WHILE the box is still on screen
|
||||
-- (PalletTownOakText: DelayFrames 10 then EmotionBubble, box not yet
|
||||
-- cleared). onOverlap sets the bubble during the box's last frames;
|
||||
-- the box pops after `overlap`, and the bubble's 60-frame hold then
|
||||
-- runs to Oak's appearance (the bubble is static while the box is up,
|
||||
-- since the overworld pauses under it, so 10 overlap + 50 = 60).
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PalletTownOakHeyWaitDontGoOutText or "OAK: Hey! Wait!\nDon't go out!",
|
||||
nil, { auto = { delay = 10, overlap = 10, onOverlap = function()
|
||||
-- .HeyWaitDontGoOutText turns the player to face down (toward
|
||||
-- the approaching Oak) before the exclamation bubble
|
||||
ow.player.facing = "down"
|
||||
ow.emote = { npc = ow.player, frames = 50, onDone = oakAppearsAndWalks }
|
||||
end } }))
|
||||
return true
|
||||
@@ -382,39 +431,170 @@ M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, tr
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.POKEMON_FAN_CLUB = {
|
||||
onEnter = function(game, ow)
|
||||
require("src.world.PikachuFollower").onFanClubEntered(game, ow)
|
||||
end,
|
||||
talk = {
|
||||
TEXT_POKEMONFANCLUB_CHAIRMAN = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "show_text", "_PokemonFanClubChairmanIntroText" }, -- 4
|
||||
{ "show_text", "_PokemonFanClubChairmanStoryText" }, -- 5
|
||||
{ "jump_if_true", "nothing_left" }, -- 3
|
||||
-- YesNoChoice (scripts/PokemonFanClub.asm): NO forfeits the voucher (#1050)
|
||||
{ "ask", "_PokemonFanClubChairmanIntroText" }, -- 4
|
||||
{ "jump_if_false", "no_story" }, -- 5
|
||||
{ "show_text", "_PokemonFanClubChairmanStoryText" }, -- 6
|
||||
-- give-then-print like scripts/PokemonFanClub.asm (GiveItem
|
||||
-- fills wStringBuffer; the received text reads it)
|
||||
{ "give_item", "BIKE_VOUCHER", 1, false }, -- 6
|
||||
{ "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 7
|
||||
{ "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 8
|
||||
{ "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 9
|
||||
{ "give_item", "BIKE_VOUCHER", 1, false }, -- 7
|
||||
{ "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 8
|
||||
{ "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 9
|
||||
{ "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 10
|
||||
{ "jump", "end" }, -- 11
|
||||
{ "label", "no_story" }, -- 12
|
||||
{ "show_text", "_PokemonFanClubNoStoryText" }, -- 13
|
||||
{ "jump", "end" }, -- 14
|
||||
-- .nothingleft: the gift is done, he only reminisces now
|
||||
{ "label", "nothing_left" }, -- 15
|
||||
{ "show_text", "_PokemonFanClubChairFinalText" }, -- 16
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- BikeShopClerkText (scripts/BikeShop.asm) runs three ways: the BICYCLE is
|
||||
-- already yours, you are carrying the BIKE VOUCHER, or you get the sales
|
||||
-- pitch. The pitch draws its own window (TextBoxBorder hlcoord 0,0, b=4
|
||||
-- c=15) holding BikeShopMenuText and BikeShopMenuPrice, and leaves it up
|
||||
-- while the clerk keeps talking in the bottom box: the original never
|
||||
-- erases it before TextScriptEnd (#568).
|
||||
local BikeShopWindow = {}
|
||||
BikeShopWindow.__index = BikeShopWindow
|
||||
|
||||
function BikeShopWindow.new(game, footer, onChoose)
|
||||
local self = setmetatable({}, BikeShopWindow)
|
||||
self.game = game
|
||||
self.onChoose = onChoose
|
||||
self.index = 1
|
||||
self.active = true
|
||||
-- BikeShopClerkDoYouLikeItText stays on screen under the window for as
|
||||
-- long as the menu is up. The text box pushed on top types it out and
|
||||
-- pops itself; this copy of its last page takes over from there, so the
|
||||
-- bottom box never blanks between the pitch and the answer. Paginated
|
||||
-- with the themed column budget so the copy breaks where the box did.
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local box = require("src.ui.Theme").textBox or {}
|
||||
local pages = TextBox.paginate(TextBox.substitute(game, footer), box.maxCols)
|
||||
self.footer = pages[#pages]
|
||||
return self
|
||||
end
|
||||
|
||||
function BikeShopWindow:update()
|
||||
-- one answer only: the boxes pushed by onChoose sit on top of this
|
||||
-- state, but a second A on the same frame must not fire it twice
|
||||
if not self.active then return end
|
||||
local input = self.game.input
|
||||
-- HandleMenuInput with wMenuWrappingEnabled clear: the two rows clamp
|
||||
if input:wasPressed("up") then
|
||||
self.index = 1
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = 2
|
||||
elseif input:wasPressed("a") or input:wasPressed("b") then
|
||||
local cancelled = input:wasPressed("b") -- bit B_PAD_B -> .cancel
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
self.active = false
|
||||
self.onChoose(not cancelled and self.index == 1)
|
||||
end
|
||||
end
|
||||
|
||||
function BikeShopWindow:draw()
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Theme = require("src.ui.Theme")
|
||||
Font.drawBox(0, 0, 17, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local bike = self.game.data.items.BICYCLE
|
||||
Font.draw(bike and bike.name or "BICYCLE", 16, 16) -- hlcoord 2, 2
|
||||
Font.draw("¥1000000", 64, 24) -- hlcoord 8, 3
|
||||
Font.draw(Strings("CANCEL"), 16, 32) -- `next` skips a row
|
||||
-- wTopMenuItemX 1, wTopMenuItemY 2, rows two apart
|
||||
Font.drawCode(Theme.cursor, 8, self.index == 1 and 16 or 32)
|
||||
if self.footer then
|
||||
-- same geometry TextBox resolves against, so a themed box matches
|
||||
local box = Theme.textBox or {}
|
||||
local tx, ty = box.tx or 0, box.ty or 12
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(tx, ty, box.tw or 20, box.th or 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, line in ipairs(self.footer) do
|
||||
Font.draw(line, (tx + 1) * 8, (ty + 2 * i) * 8)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
M.BIKE_SHOP = {
|
||||
talk = {
|
||||
TEXT_BIKESHOP_CLERK = function(game, ow, npc, done)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Flags = require("src.script.Flags")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
if (game.save.inventory.BICYCLE or 0) > 0 then
|
||||
game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done))
|
||||
elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
|
||||
game.save.inventory.BIKE_VOUCHER = nil
|
||||
game.save.inventory.BICYCLE = 1
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Oh, that's a\nBIKE VOUCHER!\f%s exchanged\nit for a BICYCLE!")
|
||||
:format(game.save.player.name), done))
|
||||
else
|
||||
game.stack:push(TextBox.new(game,
|
||||
"A BICYCLE costs\n¥1000000. Sorry,\nno instalments!", done))
|
||||
local t = game.data.text
|
||||
local function say(text, after, opts)
|
||||
game.stack:push(TextBox.new(game, text, after, opts))
|
||||
end
|
||||
|
||||
-- CheckEvent EVENT_GOT_BICYCLE. Saves made before the clerk started
|
||||
-- setting the event still have the bike in the bag, so either counts.
|
||||
if (game.save.inventory.BICYCLE or 0) > 0
|
||||
or Flags.get(game.save, "EVENT_GOT_BICYCLE") then
|
||||
say(t._BikeShopClerkHowDoYouLikeYourBicycleText, done)
|
||||
return
|
||||
end
|
||||
|
||||
-- .dontHaveBike: IsItemInBag BIKE_VOUCHER
|
||||
if (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
|
||||
say(t._BikeShopClerkOhThatsAVoucherText, function()
|
||||
-- GiveItem's `jr nc, .BagFull`: the voucher is only spent once
|
||||
-- the BICYCLE is actually in the bag
|
||||
if not Bag.add(game.save, "BICYCLE", 1) then
|
||||
say(t._BikeShopBagFullText, done)
|
||||
return
|
||||
end
|
||||
Bag.remove(game.save, "BIKE_VOUCHER", 1)
|
||||
Flags.set(game.save, "EVENT_GOT_BICYCLE")
|
||||
-- BikeShopExchangedVoucherText carries sound_get_key_item; the
|
||||
-- map runs EnableAutoTextBoxDrawing, so the box still waits for
|
||||
-- a button once the jingle has played (auto.wait, #247)
|
||||
say(t._BikeShopExchangedVoucherText, done, {
|
||||
auto = { wait = true, sound = function()
|
||||
return require("src.core.Sound").play(game.data, "Get_Key_Item")
|
||||
end },
|
||||
})
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- .dontHaveVoucher: welcome, then the BICYCLE/CANCEL window
|
||||
say(t._BikeShopClerkWelcomeText, function()
|
||||
local pitch = t._BikeShopClerkDoYouLikeItText
|
||||
game.stack:push(BikeShopWindow.new(game, pitch, function(bought)
|
||||
local function comeAgain()
|
||||
say(t._BikeShopComeAgainText, function()
|
||||
game.stack:pop() -- the window, still up under the text
|
||||
done()
|
||||
end)
|
||||
end
|
||||
if bought then
|
||||
-- a million is out of anyone's reach: BikeShopCantAffordText
|
||||
say(t._BikeShopCantAffordText, comeAgain)
|
||||
else
|
||||
comeAgain()
|
||||
end
|
||||
end))
|
||||
-- PrintText BikeShopClerkDoYouLikeItText, then straight into
|
||||
-- HandleMenuInput: the box types out and hands over without
|
||||
-- waiting, leaving the window's copy of the line on screen
|
||||
say(pitch, nil, { auto = { delay = 0 } })
|
||||
end)
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -486,8 +666,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
end
|
||||
local idef = game.data.items[itemId]
|
||||
game.stringBuffer = idef and idef.name or itemId
|
||||
require("src.core.Sound").play(game.data, "Get_Key_Item")
|
||||
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
|
||||
-- MtMoonB2FReceivedFossilText: text_far, sound_get_key_item,
|
||||
-- text_waitbutton -- the jingle plays after the box has typed and
|
||||
-- the button wait comes after it
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._MtMoonB2FReceivedFossilText
|
||||
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
|
||||
@@ -500,11 +682,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
ow.runner:run({
|
||||
{ "walk_npc", 1, dirs },
|
||||
{ "text_opts", { auto = true } },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "MT_MOON_B2F", otherName },
|
||||
}, { onDone = done })
|
||||
end))
|
||||
end, TextBox.soundOpts(game, "Get_Key_Item")))
|
||||
end }))
|
||||
end
|
||||
end
|
||||
@@ -527,33 +709,37 @@ M.MT_MOON_B2F = {
|
||||
},
|
||||
}
|
||||
|
||||
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text):
|
||||
-- Y50, once; declining at the rope walks you back out.
|
||||
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text): Y50, once.
|
||||
-- Declining at the rope shoves the player one tile south (#151)
|
||||
local function museumClerk(game, ow, done, onDecline)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local t = game.data.text or {}
|
||||
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
game.stack:push(TextBox.new(game,
|
||||
"Take your time,\nand enjoy it all!", done))
|
||||
return
|
||||
end
|
||||
-- scripts/Museum1F.asm:72
|
||||
local money = function() return game.save.money end
|
||||
game.stack:push(TextBox.new(game,
|
||||
"It's ¥50 for a\nchild's ticket.\fWould you like to\ncome in?", function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
t._Museum1FScientist1WouldYouLikeToComeInText
|
||||
or "It's ¥50 for a\nchild's ticket.\fWould you like to\ncome in?",
|
||||
nil, { money = money, choice = function(yes)
|
||||
if yes and game.save.money >= 50 then
|
||||
game.save.money = game.save.money - 50
|
||||
game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
|
||||
-- scripts/Museum1F.asm:106
|
||||
game.stack:push(TextBox.new(game,
|
||||
"Right, ¥50!\nThank you!", done))
|
||||
t._Museum1FScientist1ThankYouText or "Right, ¥50!\nThank you!", done,
|
||||
{ money = money }))
|
||||
elseif yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
"You don't have\nenough money.", onDecline or done))
|
||||
"You don't have\nenough money.", onDecline or done, { money = money }))
|
||||
else
|
||||
game.stack:push(TextBox.new(game,
|
||||
"Come again!", onDecline or done))
|
||||
"Come again!", onDecline or done, { money = money }))
|
||||
end
|
||||
end))
|
||||
end))
|
||||
end }))
|
||||
end
|
||||
|
||||
M.MUSEUM_1F = {
|
||||
@@ -563,7 +749,7 @@ M.MUSEUM_1F = {
|
||||
if y == 4 and (x == 9 or x == 10)
|
||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
museumClerk(game, ow, nil, function()
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
ow:scriptMove(ow.player, "down", 1)
|
||||
end)
|
||||
return true
|
||||
end
|
||||
@@ -759,7 +945,7 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
|
||||
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
|
||||
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
|
||||
-- mon.exp once and clear them so a second talk cannot re-apply the same
|
||||
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here —
|
||||
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here --
|
||||
-- TextBox.TOKENS.RAM only knows wStringBuffer.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -111,8 +111,9 @@ M.POKEMON_TOWER_5F = {
|
||||
--
|
||||
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
|
||||
-- Silph Scope check at the trigger -- the scope only decides whether the
|
||||
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
|
||||
-- balls dodged). An earlier version of this port turned the player back
|
||||
-- disguise sticks (IsGhostBattle -> makeGhost: "too scared to move", balls
|
||||
-- dodged) or comes off in the unveil (makeUnveiledGhost, #492). An earlier
|
||||
-- version of this port turned the player back
|
||||
-- without the scope and never opened the battle, which made 6F
|
||||
-- impassable on any route that skips Rocket Hideout; vanilla lets the
|
||||
-- battle open and a POKE_DOLL end it (see wBattleResult below).
|
||||
@@ -128,7 +129,18 @@ M.POKEMON_TOWER_6F = {
|
||||
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "MAROWAK", 30)
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
-- ItemUseBall .notOldManBattle (item_effects.asm:166-175): a ball
|
||||
-- thrown on POKEMON_TOWER_6F at the RESTLESS SOUL is dodged whether
|
||||
-- or not the scope revealed it, so the "can't be caught" state rides
|
||||
-- the battle instead of IsGhostBattle alone (#444)
|
||||
battle.noCatch = true
|
||||
-- InitWildBattle enters disguised for the RESTLESS SOUL either way
|
||||
-- (core.asm:6698-6700). The scope does not skip the disguise, it
|
||||
-- buys the unveil PrintBeginningBattleText .isMarowak plays over it
|
||||
-- before the battle proceeds as an ordinary wild one (#492).
|
||||
if game.save.inventory.SILPH_SCOPE then
|
||||
battle:makeUnveiledGhost()
|
||||
else
|
||||
battle:makeGhost()
|
||||
end
|
||||
battle.onFinish = function(result)
|
||||
@@ -139,9 +151,27 @@ M.POKEMON_TOWER_6F = {
|
||||
-- trick, and the speedrun route this bot follows depends on it.
|
||||
if result == "win" or battle.pokeDollEscape then
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
|
||||
-- PokemonTower6FMarowakDepartedText (scripts/PokemonTower6F.asm)
|
||||
-- is two texts, not one: the CUBONE's-mother line first, then
|
||||
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
|
||||
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
|
||||
-- calmed line; the port dropped the first text and the cry
|
||||
-- (#867). play_cry arms the next show_text, so the cry rides
|
||||
-- the calmed box's open with the button prompt kept, and the
|
||||
-- wait row stands in for the asm's 30-frame gap.
|
||||
local rows = {
|
||||
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
|
||||
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
|
||||
{ "play_cry", "MAROWAK", true },
|
||||
{ "wait", 30 },
|
||||
{ "show_text", t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
|
||||
}
|
||||
if ow.runner then
|
||||
ow.runner:run(rows)
|
||||
elseif ow.queueScript then
|
||||
ow:queueScript(rows)
|
||||
end
|
||||
elseif result ~= "lose" then
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
@@ -182,17 +212,16 @@ M.POKEMON_TOWER_6F = {
|
||||
-- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s
|
||||
-- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another
|
||||
-- Delay3 (12) while CeladonMartElevatorShakeScript farjps straight in
|
||||
-- The panel is a bg_event, not a map-entry script: data/maps/objects/
|
||||
-- CeladonMartElevator.asm has `bg_event 3, 0, TEXT_CELADONMARTELEVATOR`
|
||||
-- and CeladonMartElevatorText is what runs DisplayElevatorFloorMenu, so
|
||||
-- the menu waits for the player to face the panel and press A (#395).
|
||||
-- Map entry only stores the car's exit warps (CeladonMartElevator
|
||||
-- StoreWarpEntriesScript), which is elevatorSeedExit below.
|
||||
-- After the ride the original does NOT jump-cut to the floor: choosing a
|
||||
-- floor in engine/events/elevator.asm DisplayElevatorFloorMenu rewrites
|
||||
-- the elevator car's own warp entries (wWarpEntries, via .UpdateWarp) to
|
||||
-- the chosen floor's exit warp, then the player walks out of the car onto
|
||||
-- that warp themselves (scripts/SilphCoElevator.asm etc.). Reproduce
|
||||
-- that here: rewrite the car's exit warps, then drive a short scripted
|
||||
-- walk-out onto an exit tile and take the (now rewritten) warp, reusing
|
||||
-- ow:scriptMove / ow:takeWarp (the Oak-escort primitives).
|
||||
local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local WALK_ORDER = { "up", "down", "left", "right" }
|
||||
-- floor rewrites the elevator car's own warp entries (wWarpEntries, via
|
||||
-- .UpdateWarp) to the chosen floor's exit warp and returns control, and
|
||||
-- the player walks out of the car onto that warp themselves.
|
||||
|
||||
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
|
||||
-- door (warp id, map id). Shared generated map data, but the car's
|
||||
@@ -205,46 +234,6 @@ local function elevatorSetExit(ow, floor)
|
||||
end
|
||||
end
|
||||
|
||||
local function elevatorWalkOut(ow, floor)
|
||||
local m, p = ow.map, ow.player
|
||||
elevatorSetExit(ow, floor)
|
||||
-- leave by the exit tile under the player (they warped in onto one),
|
||||
-- else the nearest
|
||||
local door
|
||||
for _, w in ipairs(m.def.warps) do
|
||||
if w.x == p.cellX and w.y == p.cellY then door = w break end
|
||||
end
|
||||
if not door then
|
||||
local best
|
||||
for _, w in ipairs(m.def.warps) do
|
||||
local d = math.abs(w.x - p.cellX) + math.abs(w.y - p.cellY)
|
||||
if not best or d < best then best, door = d, w end
|
||||
end
|
||||
end
|
||||
-- the car interior sits on the door's walkable side; "out" is the
|
||||
-- doorway direction (the map edge for Silph/Celadon, the top doorway
|
||||
-- for the Rocket car). Fixed direction order keeps the step
|
||||
-- deterministic when a door tile has several walkable neighbours
|
||||
-- (Silph/Celadon step up into the car, the Rocket car steps down).
|
||||
local into
|
||||
for _, dir in ipairs(WALK_ORDER) do
|
||||
local v = WALK_DIRVEC[dir]
|
||||
local nx, ny = door.x + v[1], door.y + v[2]
|
||||
if m:inBounds(nx, ny) and m:isWalkableCell(nx, ny) then into = dir break end
|
||||
end
|
||||
into = into or "up"
|
||||
local out = WALK_OPP[into]
|
||||
local function leave()
|
||||
ow:takeWarp(door) -- door SFX + warp to the rewritten floor target
|
||||
end
|
||||
-- step one tile into the car, then walk back through the doorway onto
|
||||
-- the exit tile and take the warp: a visible walk-out on valid tiles
|
||||
-- for either door orientation, instead of a jump cut
|
||||
ow:scriptMove(p, into, 1, function()
|
||||
ow:scriptMove(p, out, 1, leave)
|
||||
end)
|
||||
end
|
||||
|
||||
local function elevatorFloors(elevatorMapId, game)
|
||||
local floors = {}
|
||||
for mapId, def in pairs(game.data.maps) do
|
||||
@@ -272,9 +261,9 @@ local function elevatorFloors(elevatorMapId, game)
|
||||
end
|
||||
|
||||
local function elevatorSeedExit(ow, floors, fromMapId)
|
||||
-- Seed a walk-out destination before the menu (or key-gate text):
|
||||
-- entry floor when known, else the first listed floor (1F). Choosing
|
||||
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
|
||||
-- Seed a walk-out destination on entry (before the panel is ever
|
||||
-- read): entry floor when known, else the first listed floor (1F).
|
||||
-- Choosing a floor rewrites it again; B-cancel / no-key leave
|
||||
-- keeps this seed so walking out of the car cannot hit a missing ROM
|
||||
-- placeholder (#123) or the car's static default floor (#90: Rocket
|
||||
-- Hideout defaults to B1F even when entered from B2F/B4F).
|
||||
@@ -288,68 +277,68 @@ local function elevatorSeedExit(ow, floors, fromMapId)
|
||||
return exitFloor
|
||||
end
|
||||
|
||||
local function elevator(elevatorMapId, keyGate, preFrames)
|
||||
local function elevator(elevatorMapId, panelText, keyGate, preFrames)
|
||||
-- the panel bg_event: DisplayElevatorFloorMenu runs from this text
|
||||
-- script, never from map entry (#395)
|
||||
local function panel(game, ow, npc, done)
|
||||
done = done or function() end
|
||||
local floors = elevatorFloors(elevatorMapId, game)
|
||||
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||
-- a-key line and shows no floor menu
|
||||
-- (scripts/RocketHideoutElevator.asm).
|
||||
if keyGate and not game.save.inventory[keyGate.item] then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[keyGate.text] or "It appears to\nneed a key.", done))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for _, f in ipairs(floors) do
|
||||
table.insert(items, { label = f.token, value = f })
|
||||
end
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
|
||||
onChoose = function(item, list)
|
||||
list:close()
|
||||
-- the whole ShakeElevator ride runs in place -- music stop, 100
|
||||
-- collision-thud scroll bounces, the PA chime -- and only then
|
||||
-- does .UpdateWarp's rewrite land, with the player still stood
|
||||
-- at the panel: they walk out to the car door themselves
|
||||
local ElevatorShake = require("src.world.ElevatorShake")
|
||||
game.stack:push(ElevatorShake.new(game, ow, {
|
||||
preFrames = preFrames,
|
||||
onDone = function()
|
||||
elevatorSetExit(ow, item.value)
|
||||
done()
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
onCancel = function()
|
||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||
-- happens, the player just stays in the car (exit warps were
|
||||
-- already seeded on entry)
|
||||
done()
|
||||
end,
|
||||
}))
|
||||
end
|
||||
return {
|
||||
-- fromMapId: the floor the player just left (setMap passes it), so a
|
||||
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
|
||||
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
|
||||
-- asserted and hard-crashed (#123).
|
||||
onEnter = function(game, ow, fromMapId)
|
||||
local floors = elevatorFloors(elevatorMapId, game)
|
||||
elevatorSeedExit(ow, floors, fromMapId)
|
||||
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
|
||||
-- still seeded above so walking out returns to the entry floor
|
||||
-- instead of the car's ROM default (B1F) — #90 / #105.
|
||||
if keyGate and not game.save.inventory[keyGate.item] then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[keyGate.text] or "It appears to\nneed a key."))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for _, f in ipairs(floors) do
|
||||
table.insert(items, { label = f.token, value = f })
|
||||
end
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
|
||||
onChoose = function(item, list)
|
||||
list:close()
|
||||
-- the map-entry Transition (startWarpTo) calls onEnter from
|
||||
-- its OWN midpoint callback, so this menu was pushed on top
|
||||
-- of that still-active Transition; only the top state
|
||||
-- updates, so it froze mid-fade instead of finishing. Left
|
||||
-- alone it would resurface after the ride and play a stray
|
||||
-- fade at the wrong time -- pop it now, before it can happen.
|
||||
local Transition = require("src.render.Transition")
|
||||
if getmetatable(game.stack:top()) == Transition then
|
||||
game.stack:pop()
|
||||
end
|
||||
-- the whole ShakeElevator ride runs in place -- music stop,
|
||||
-- 100 collision-thud scroll bounces, the PA chime -- and only
|
||||
-- then does the player walk out of the car onto the chosen
|
||||
-- floor (elevatorWalkOut rewrites the car's exit warps first)
|
||||
local ElevatorShake = require("src.world.ElevatorShake")
|
||||
game.stack:push(ElevatorShake.new(game, ow, {
|
||||
preFrames = preFrames,
|
||||
onDone = function()
|
||||
elevatorWalkOut(ow, item.value)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
onCancel = function()
|
||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||
-- happens, the player just stays in the car (exit warps were
|
||||
-- already seeded to the entry floor above)
|
||||
end,
|
||||
}))
|
||||
elevatorSeedExit(ow, elevatorFloors(elevatorMapId, game), fromMapId)
|
||||
end,
|
||||
talk = { [panelText] = panel },
|
||||
}
|
||||
end
|
||||
|
||||
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR")
|
||||
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
|
||||
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR",
|
||||
"TEXT_SILPHCOELEVATOR_ELEVATOR")
|
||||
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR",
|
||||
"TEXT_CELADONMARTELEVATOR", nil, 9)
|
||||
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
||||
"TEXT_ROCKETHIDEOUTELEVATOR",
|
||||
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -389,6 +378,38 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
end))
|
||||
end,
|
||||
|
||||
-- Yellow swaps Rocket1/Rocket2 for Jessie & James and keeps a single
|
||||
-- grunt, spelled ROCKETHIDEOUTB4F_ROCKET / TEXT_ROCKETHIDEOUTB4F_ROCKET
|
||||
-- (pokeyellow/scripts/RocketHideoutB4F.asm), so the Red/Blue key above
|
||||
-- never matched there and the LIFT KEY never dropped (#552, a
|
||||
-- regression of #105). Yellow also moves the drop off the second
|
||||
-- talk: RocketHideoutB4FRocketEndBattleText is text_promptbutton +
|
||||
-- text_asm, so its SetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
|
||||
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5 run the instant the battle ends.
|
||||
TEXT_ROCKETHIDEOUTB4F_ROCKET = function(game, ow, npc, done)
|
||||
if not ow:trainerDefeated(npc) then
|
||||
ow:engageTrainer(npc, function()
|
||||
-- engageTrainer records the win before it calls back, so this
|
||||
-- is the end-battle text's SetEvent + ShowObject
|
||||
if ow:trainerDefeated(npc)
|
||||
and not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
|
||||
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.show_object(
|
||||
{ game = game, save = game.save, overworld = ow },
|
||||
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
|
||||
end
|
||||
done()
|
||||
end)
|
||||
return
|
||||
end
|
||||
-- RocketHideoutB4FRocketAfterBattleText: later talks only reprint
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._RocketHideoutB4FRocketAfterBattleText
|
||||
or "Oh no! I dropped\nthe LIFT KEY!", done))
|
||||
end,
|
||||
|
||||
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
|
||||
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
|
||||
-- owns both the engage and the BeatGiovanniScript aftermath.
|
||||
@@ -514,6 +535,14 @@ M.GAME_CORNER = {
|
||||
done()
|
||||
return
|
||||
end
|
||||
-- GameCornerRocketText hands the battle its own loss line through
|
||||
-- SaveEndBattleTextPointers (.BattleEndText ->
|
||||
-- _GameCornerRocketBattleEndText, "Dang!"), and PrintEndBattleText
|
||||
-- prints it ON the battle screen between TrainerDefeatedText and
|
||||
-- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory).
|
||||
-- He is a text_asm trainer with no def_trainers header, so there is no
|
||||
-- header.won for engageTrainer to find and the line has to be handed
|
||||
-- over here or it never shows at all (#862).
|
||||
ow:engageTrainer(npc, function()
|
||||
if not ow:trainerDefeated(npc) then
|
||||
done()
|
||||
@@ -524,129 +553,329 @@ M.GAME_CORNER = {
|
||||
game.data.text._GameCornerRocketAfterBattleText
|
||||
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||
function()
|
||||
hideRocket()
|
||||
done()
|
||||
-- #198/#862: GameCornerRocketBattleScript (scripts/GameCorner.asm)
|
||||
-- picks the exit walk from where the player is standing, because
|
||||
-- the grunt on (9,5) has to get past him: wYCoord == 6 (talked to
|
||||
-- from the south) or wXCoord == 8 (from the west) leaves the row
|
||||
-- clear and takes GameCornerMovement_Rocket_WalkDirect, five steps
|
||||
-- RIGHT; otherwise the player is east of him on (10,5) and
|
||||
-- GameCornerMovement_Rocket_WalkAroundPlayer steps DOWN, right, UP
|
||||
-- and right again to go AROUND him. pokeyellow's copy of the
|
||||
-- around-path takes one extra RIGHT on the lower row before coming
|
||||
-- back up (it also has to clear Pikachu); both versions end on
|
||||
-- (15,5). He never steps UP: (9,4) is the poster wall, which is
|
||||
-- where the old single UP step sent him.
|
||||
local px = ow.player and ow.player.cellX
|
||||
local py = ow.player and ow.player.cellY
|
||||
local path
|
||||
if py == 6 or px == 8 then
|
||||
path = { { "right", 5 } }
|
||||
elseif require("src.core.GameVersion").isYellow() then
|
||||
path = { { "down", 1 }, { "right", 3 }, { "up", 1 }, { "right", 3 } }
|
||||
else
|
||||
path = { { "down", 1 }, { "right", 2 }, { "up", 1 }, { "right", 4 } }
|
||||
end
|
||||
-- GameCornerRocketExitScript only HideObjects him once
|
||||
-- BIT_SCRIPTED_NPC_MOVEMENT clears, i.e. after the last step.
|
||||
-- scriptMove locks player input (#scriptMoves>0) and ignores
|
||||
-- collision, so the despawn + unfreeze (done) ride the final step.
|
||||
local function step(i)
|
||||
if i > #path then
|
||||
hideRocket()
|
||||
done()
|
||||
return
|
||||
end
|
||||
ow:scriptMove(npc, path[i][1], path[i][2],
|
||||
function() step(i + 1) end)
|
||||
end
|
||||
step(1)
|
||||
end))
|
||||
end)
|
||||
end, game.data.text._GameCornerRocketBattleEndText or "Dang!")
|
||||
end,
|
||||
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
|
||||
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
|
||||
-- object const and from every one of his text labels
|
||||
-- (GameCornerClerkText, pokeyellow/scripts/GameCorner.asm) with an
|
||||
-- identical body, so each line resolves under both spellings and the
|
||||
-- handler is bound to both text ids just below (#552).
|
||||
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local t = game.data.text
|
||||
local function line(suffix, fallback)
|
||||
return t["_GameCornerClerk1" .. suffix]
|
||||
or t["_GameCornerClerk" .. suffix]
|
||||
or fallback
|
||||
end
|
||||
-- GameCornerDrawCoinBox (scripts/GameCorner.asm; pokeyellow's copy is
|
||||
-- identical): TextBoxBorder at hlcoord 11,0 with b=5 c=7, a 9x7-tile
|
||||
-- window in the top right holding MONEY at (12,2) over the amount on
|
||||
-- row 3 and COIN at (12,4) over the count on row 5. Both
|
||||
-- PrintBCDNumber calls pass LEADING_ZEROES, whose bit 7 SUPPRESSES
|
||||
-- leading zeroes (home/print_bcd.asm), and neither passes LEFT_ALIGN,
|
||||
-- so both numbers read plain and right-aligned against the inner edge
|
||||
-- at column 18. The asm draws the box before the offer and redraws it
|
||||
-- after the purchase, so it stands for the whole exchange: a draw-only
|
||||
-- state under the dialogue gets that lifetime, since StateStack draws
|
||||
-- every state above the last opaque one and updates only the top
|
||||
-- (src/core/StateStack.lua), and reading save each frame is the
|
||||
-- redraw (#624).
|
||||
local coinBox = { draw = function()
|
||||
Font.drawBox(11, 0, 9, 7)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("MONEY"), 96, 16)
|
||||
local money = ("¥%d"):format(game.save.money or 0)
|
||||
Font.draw(money, 152 - Font.width(money), 24)
|
||||
Font.draw(Strings("COIN"), 96, 32)
|
||||
local coins = ("%d"):format(game.save.coins or 0)
|
||||
Font.draw(coins, 152 - Font.width(coins), 40)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end }
|
||||
game.stack:push(coinBox)
|
||||
-- Every branch below finishes here. A TextBox pops itself before its
|
||||
-- onDone runs, so the coin box is top of the stack again by then and
|
||||
-- this pop takes it down, never someone else's state.
|
||||
local function finish()
|
||||
game.stack:pop()
|
||||
done()
|
||||
end
|
||||
-- YesNoChoice is called with the offer still printed, so the prompt
|
||||
-- has to ride the open text box (opts.choice) instead of being pushed
|
||||
-- after it closes, which is what made the question vanish (#624).
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._GameCornerClerk1DoYouNeedSomeGameCoinsText
|
||||
or "Do you need some\ngame coins?\f¥1000 for 50."), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
line("DoYouNeedSomeGameCoinsText",
|
||||
"Do you need some\ngame coins?\f¥1000 for 50."),
|
||||
nil, { choice = function(yes)
|
||||
if not yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1PleaseComePlaySometimeText
|
||||
or "No? Please come\nplay sometime!", done))
|
||||
line("PleaseComePlaySometimeText",
|
||||
"No? Please come\nplay sometime!"), finish))
|
||||
return
|
||||
end
|
||||
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
|
||||
-- the COIN CASE and room for at least 9 coins (Has9990Coins)
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1DontHaveCoinCaseText
|
||||
or "You don't have a\nCOIN CASE!", done))
|
||||
line("DontHaveCoinCaseText",
|
||||
"You don't have a\nCOIN CASE!"), finish))
|
||||
return
|
||||
end
|
||||
if (game.save.coins or 0) >= 9990 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1CoinCaseIsFullText
|
||||
or "Oops! Your COIN\nCASE is full.", done))
|
||||
line("CoinCaseIsFullText",
|
||||
"Oops! Your COIN\nCASE is full."), finish))
|
||||
return
|
||||
end
|
||||
if game.save.money < 1000 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1CantAffordTheCoinsText
|
||||
or "You can't afford\nthe coins!", done))
|
||||
line("CantAffordTheCoinsText",
|
||||
"You can't afford\nthe coins!"), finish))
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money - 1000
|
||||
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
|
||||
-- the thanks text is the plain _GameCornerClerk1ThanksHereAre50-
|
||||
-- CoinsText; the new count belongs in the coin box the asm
|
||||
-- redraws here, not appended to the line (#624)
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._GameCornerClerk1ThanksHereAre50CoinsText
|
||||
or "Thanks! Here are\nyour 50 coins!")
|
||||
.. ("\fCOINS: %d"):format(game.save.coins), done))
|
||||
end))
|
||||
end))
|
||||
line("ThanksHereAre50CoinsText",
|
||||
"Thanks! Here are\nyour 50 coins!"), finish))
|
||||
end }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
-- Yellow's coin clerk is GAMECORNER_CLERK / TEXT_GAMECORNER_CLERK where Red
|
||||
-- and Blue spell him CLERK1, and the two text_asm bodies are identical, so
|
||||
-- one handler answers both ids. Without the alias the Yellow clerk fell
|
||||
-- through to his extracted offer line with no yes/no box behind it (#552).
|
||||
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
|
||||
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
|
||||
|
||||
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
|
||||
-- The six mon prizes differ between Red and Blue; the three TM prizes are
|
||||
-- identical, so they are shared and appended to each version's mon list.
|
||||
-- Each counter owns ONE window of three prizes, not the whole catalogue:
|
||||
-- GetPrizeMenuId (engine/events/prize_menu.asm) subtracts
|
||||
-- TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 from hTextID and indexes
|
||||
-- PrizeDifferentMenuPtrs with the result, so vendor 1 sells
|
||||
-- PrizeMenuMon1Entries, vendor 2 PrizeMenuMon2Entries and vendor 3
|
||||
-- PrizeMenuTMsEntries (#623). The mon windows and their levels differ per
|
||||
-- version; the TM window is identical in all three, so it is shared.
|
||||
local PRIZE_TMS = {
|
||||
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
|
||||
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
|
||||
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
|
||||
}
|
||||
local RED_PRIZES = {
|
||||
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
||||
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
||||
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
||||
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||
local RED_PRIZE_WINDOWS = {
|
||||
{
|
||||
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
||||
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
||||
},
|
||||
{
|
||||
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
||||
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
},
|
||||
PRIZE_TMS,
|
||||
}
|
||||
local BLUE_PRIZES = {
|
||||
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
|
||||
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
|
||||
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
|
||||
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
|
||||
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
|
||||
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||
local BLUE_PRIZE_WINDOWS = {
|
||||
{
|
||||
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
|
||||
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
|
||||
},
|
||||
{
|
||||
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
|
||||
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
|
||||
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
|
||||
},
|
||||
PRIZE_TMS,
|
||||
}
|
||||
-- Yellow keeps the three windows but restocks both mon counters
|
||||
-- (pokeyellow/data/events/prizes.asm, prize_mon_levels.asm)
|
||||
local YELLOW_PRIZE_WINDOWS = {
|
||||
{
|
||||
{ kind = "mon", species = "ABRA", level = 15, cost = 230 },
|
||||
{ kind = "mon", species = "VULPIX", level = 18, cost = 1000 },
|
||||
{ kind = "mon", species = "WIGGLYTUFF", level = 22, cost = 2680 },
|
||||
},
|
||||
{
|
||||
{ kind = "mon", species = "SCYTHER", level = 30, cost = 6500 },
|
||||
{ kind = "mon", species = "PINSIR", level = 30, cost = 6500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
},
|
||||
PRIZE_TMS,
|
||||
}
|
||||
|
||||
local function activePrizes()
|
||||
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
|
||||
local function prizeWindow(n)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local windows = RED_PRIZE_WINDOWS
|
||||
if GameVersion.isBlue() then
|
||||
windows = BLUE_PRIZE_WINDOWS
|
||||
elseif GameVersion.isYellow() then
|
||||
windows = YELLOW_PRIZE_WINDOWS
|
||||
end
|
||||
return windows[n]
|
||||
end
|
||||
|
||||
local function prizeCounter(game, ow, npc, done)
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Commands = require("src.script.Commands")
|
||||
local items = {}
|
||||
for _, p in ipairs(activePrizes()) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
else
|
||||
label = game.data.items[p.item].name
|
||||
-- Prize counters (engine/events/prize_menu.asm CeladonPrizeMenu; the prize
|
||||
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
|
||||
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
|
||||
-- with no case prints RequireCoinCaseText and returns without ever opening a
|
||||
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
|
||||
-- show the prizes. #194: the port used to open the window unconditionally and
|
||||
-- skip both text boxes. wMaxMenuItem is 3, i.e. this window's three prizes
|
||||
-- plus the NO THANKS row, and HandlePrizeChoice confirms the pick with
|
||||
-- SoYouWantPrizeText + YesNoChoice before any coins move; every branch then
|
||||
-- rets out of CeladonPrizeMenu, so one transaction ends the conversation and
|
||||
-- buying again means talking to the counter again (#623).
|
||||
local function prizeCounter(window)
|
||||
return function(game, ow, npc, done)
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Commands = require("src.script.Commands")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
-- IsItemInBag COIN_CASE: without the case, deny and open no window
|
||||
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
|
||||
return
|
||||
end
|
||||
table.insert(items, { label = label, right = tostring(p.cost), value = p })
|
||||
-- ExchangeCoinsForPrizesText plays before the prize window opens.
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
|
||||
function()
|
||||
local items = {}
|
||||
for _, p in ipairs(prizeWindow(window)) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
else
|
||||
label = game.data.items[p.item].name
|
||||
end
|
||||
table.insert(items,
|
||||
{ label = label, right = tostring(p.cost), value = p })
|
||||
end
|
||||
-- NoThanksText (data/events/prizes.asm) sits under the three prizes
|
||||
table.insert(items, { label = "NO THANKS" })
|
||||
local list
|
||||
-- close the window first: every ending in HandlePrizeChoice leaves
|
||||
-- the menu for good, and the closing line belongs over the map
|
||||
local function finish(msg)
|
||||
list:close()
|
||||
game.stack:push(TextBox.new(game, msg, done))
|
||||
end
|
||||
local function buy(p)
|
||||
if (game.save.coins or 0) < p.cost then
|
||||
finish(t._SorryNeedMoreCoinsText or "Sorry, you need\nmore coins.")
|
||||
return
|
||||
end
|
||||
-- HasEnoughCoins passed, so hand the prize over first and only
|
||||
-- subtract once it landed: the asm rets before .subtractCoins when
|
||||
-- the bag is full, or when both the party and every box are full
|
||||
local roomless = t._OopsYouDontHaveEnoughRoomText
|
||||
or "Oops! You don't\nhave enough room."
|
||||
if p.kind == "mon" then
|
||||
-- no runner here, so give_pokemon reports through ctx.lastCheck
|
||||
-- and skips the AskName prompt (Commands.give_pokemon)
|
||||
local ctx = { save = game.save, game = game }
|
||||
Commands.give_pokemon(ctx, p.species, p.level)
|
||||
if not ctx.lastCheck then
|
||||
finish(roomless)
|
||||
return
|
||||
end
|
||||
elseif not require("src.inventory.Bag").add(
|
||||
game.save, p.item, 1, game.data) then
|
||||
finish(roomless)
|
||||
return
|
||||
end
|
||||
game.save.coins = game.save.coins - p.cost
|
||||
-- no thank-you line: HereYouGoText is unreferenced in the asm,
|
||||
-- which just redraws the coin box (PrintPrizePrice) and returns
|
||||
list:close()
|
||||
done()
|
||||
end
|
||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||
onChoose = function(item)
|
||||
local p = item.value
|
||||
if not p then -- NO THANKS is the B exit (cp 3 -> .noChoice)
|
||||
list:close()
|
||||
done()
|
||||
return
|
||||
end
|
||||
local name = (p.kind == "mon")
|
||||
and game.data.pokemon[p.species].name
|
||||
or game.data.items[p.item].name
|
||||
-- SoYouWantPrizeText names the prize out of wNameBuffer, which
|
||||
-- is not one of TextBox's RAM tokens, so fill it in here
|
||||
local ask = (t._SoYouWantPrizeText
|
||||
or "So, you want\n{RAM:wNameBuffer}?")
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
game.stack:push(TextBox.new(game, ask, nil, {
|
||||
choice = function(yes)
|
||||
if not yes then
|
||||
finish(t._OhFineThenText or "Oh, fine then.")
|
||||
return
|
||||
end
|
||||
buy(p)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
onCancel = done,
|
||||
})
|
||||
game.stack:push(list)
|
||||
end))
|
||||
end
|
||||
local list
|
||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||
onChoose = function(item)
|
||||
local p = item.value
|
||||
if (game.save.coins or 0) < p.cost then
|
||||
list.footer = "Not enough coins!"
|
||||
return
|
||||
end
|
||||
game.save.coins = game.save.coins - p.cost
|
||||
if p.kind == "mon" then
|
||||
Commands.give_pokemon({ save = game.save, game = game },
|
||||
p.species, p.level)
|
||||
else
|
||||
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
|
||||
end
|
||||
list.footer = ("Got it! COINS %d"):format(game.save.coins)
|
||||
end,
|
||||
onCancel = done,
|
||||
})
|
||||
game.stack:push(list)
|
||||
end
|
||||
|
||||
M.GAME_CORNER_PRIZE_ROOM = {
|
||||
talk = { -- the three prize counters are bg events
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter,
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter,
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter,
|
||||
talk = { -- the three prize counters are bg events, one window each
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter(1),
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter(2),
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter(3),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -664,6 +893,16 @@ local DOCK_SHIP_BLOCKS = {
|
||||
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
||||
}
|
||||
|
||||
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
|
||||
-- and the open-water ids of the rows she sits in
|
||||
local DOCK_SHIP_COLUMNS = {
|
||||
{ bx = 5, top = 4, bottom = 8 },
|
||||
{ bx = 6, top = 5, bottom = 9 },
|
||||
{ bx = 7, top = 6, bottom = 10 },
|
||||
{ bx = 8, top = 7, bottom = 11 },
|
||||
}
|
||||
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
|
||||
|
||||
M.VERMILION_DOCK = {
|
||||
onEnter = function(game, ow)
|
||||
local Flags = require("src.script.Flags")
|
||||
@@ -698,17 +937,34 @@ M.VERMILION_DOCK = {
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
end
|
||||
puff(3, 15)
|
||||
-- scripts/VermilionDock.asm:182-203
|
||||
local rows = {}
|
||||
rows[#rows + 1] = { "wait", 100 }
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
||||
local function setBlock(bx, by, block)
|
||||
if bx < 1 or bx > 8 then return end
|
||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 30 }
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
-- .shift_columns_up slides her tile columns west behind a mid-frame
|
||||
-- rSCX split; with no split scroll here she sails one block per beat
|
||||
-- and the water closes in astern (#360)
|
||||
for step = 1, 8 do
|
||||
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
|
||||
setBlock(col.bx - step, 1, col.top)
|
||||
setBlock(col.bx - step, 2, col.bottom)
|
||||
end
|
||||
setBlock(9 - step, 1, DOCK_WATER_TOP)
|
||||
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
|
||||
rows[#rows + 1] = { "wait", 20 }
|
||||
end
|
||||
-- the second horn as she clears the dock, then EraseSSAnne's 120
|
||||
-- frames before the walk out
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
-- keep Music_Surfing across the city warp (pokered stays on it
|
||||
-- through the exit-ship walk)
|
||||
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
|
||||
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
||||
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
||||
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
ow:queueScript(rows)
|
||||
|
||||
@@ -8,21 +8,35 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done)
|
||||
local function push(game, s, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- The question stays on screen under the YES/NO menu. The dojo prize
|
||||
-- balls are the clearest case: FightingDojoHitmonleePokeBallText
|
||||
-- (scripts/FightingDojo.asm) is `call PrintText` on a text_end string --
|
||||
-- no prompt, so no WaitForTextScrollButtonPress -- immediately followed
|
||||
-- by `call YesNoChoice`, and InitYesNoTextBoxParameters
|
||||
-- (engine/menus/text_box.asm) puts the menu above the dialogue box
|
||||
-- rather than replacing it. Ride TextBox's opts.choice, the same as
|
||||
-- Commands.ask, instead of popping the box with an A press and leaving a
|
||||
-- bare ChoiceBox over the overworld (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER})
|
||||
-- fill text placeholders; key on the hram/wram symbol first, since one
|
||||
-- string can carry two different NUM slots (#1006)
|
||||
local function fill(s, subs)
|
||||
s = s:gsub("{PLAYER}", subs.player or "")
|
||||
s = s:gsub("{NUM:[^}]*}", function() return tostring(subs.num or "") end)
|
||||
s = s:gsub("{RAM:[^}]*}", function() return subs.ram or "" end)
|
||||
s = s:gsub("{NUM:([%w_]*)[^}]*}", function(name)
|
||||
return tostring(subs[name] or subs.num or "")
|
||||
end)
|
||||
s = s:gsub("{RAM:([%w_]*)[^}]*}", function(name)
|
||||
return subs[name] or subs.ram or ""
|
||||
end)
|
||||
return s
|
||||
end
|
||||
|
||||
@@ -37,16 +51,15 @@ local function countOwned(save)
|
||||
return n
|
||||
end
|
||||
|
||||
local function oaksAide(threshold, itemId)
|
||||
local function oaksAide(threshold, itemId, repeatText)
|
||||
return function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
local flags = game.save.flags
|
||||
local itemName = game.data.items[itemId].name
|
||||
local flag = "EVENT_GOT_" .. itemId
|
||||
if flags[flag] then
|
||||
push(game, fill(t._OaksAideComeBackText or
|
||||
"I already gave\nyou the {RAM:}!",
|
||||
{ num = threshold, ram = itemName }), done)
|
||||
push(game, t[repeatText] or
|
||||
fill("I already gave\nyou the {RAM:}!", { ram = itemName }), done)
|
||||
return
|
||||
end
|
||||
ask(game, fill(t._OaksAideHiText or
|
||||
@@ -74,22 +87,28 @@ local function oaksAide(threshold, itemId)
|
||||
{ ram = itemName, player = game.save.player.name }), done)
|
||||
end)
|
||||
else
|
||||
-- .notEnoughOwnedMons prints owned then requirement, two counts
|
||||
push(game, fill(t._OaksAideUhOhText or
|
||||
"You have only\ncaught {NUM:}!",
|
||||
{ num = owned, ram = itemName }), done)
|
||||
{ num = owned, ram = itemName,
|
||||
hOaksAideNumMonsOwned = owned,
|
||||
hOaksAideRequirement = threshold }), done)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
M.ROUTE_2_GATE = {
|
||||
talk = { TEXT_ROUTE2GATE_OAKS_AIDE = oaksAide(10, "HM_FLASH") },
|
||||
talk = { TEXT_ROUTE2GATE_OAKS_AIDE = oaksAide(10, "HM_FLASH",
|
||||
"_Route2GateOaksAideFlashExplanationText") },
|
||||
}
|
||||
M.ROUTE_11_GATE_2F = {
|
||||
talk = { TEXT_ROUTE11GATE2F_OAKS_AIDE = oaksAide(30, "ITEMFINDER") },
|
||||
talk = { TEXT_ROUTE11GATE2F_OAKS_AIDE = oaksAide(30, "ITEMFINDER",
|
||||
"_Route11Gate2FOaksAideItemfinderDescriptionText") },
|
||||
}
|
||||
M.ROUTE_15_GATE_2F = {
|
||||
talk = { TEXT_ROUTE15GATE2F_OAKS_AIDE = oaksAide(50, "EXP_ALL") },
|
||||
talk = { TEXT_ROUTE15GATE2F_OAKS_AIDE = oaksAide(50, "EXP_ALL",
|
||||
"_Route15Gate2FOaksAideExpAllText") },
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -123,7 +142,7 @@ M.MT_MOON_POKECENTER = {
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
||||
"MAGIKARP", 5)
|
||||
push(game, ("%s got a\nMAGIKARP!"):format(game.save.player.name), done)
|
||||
push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done)
|
||||
end)
|
||||
end,
|
||||
},
|
||||
@@ -134,32 +153,70 @@ M.MT_MOON_POKECENTER = {
|
||||
-- Karate Master, take HITMONLEE or HITMONCHAN (the other disappears)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- ownBall/otherBall keep their spots for signature symmetry; only ownBall
|
||||
-- is ever hidden (Gen1 removes just the chosen ball).
|
||||
local function dojoBall(species, ownBall, otherBall, askKey)
|
||||
return function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
local flags = game.save.flags
|
||||
-- Already took one prize: the OTHER ball is still on the mat, so
|
||||
-- talking to it now gives the "Better not get greedy..." refusal
|
||||
-- (FightingDojo.asm .gotThePokemon / _FightingDojoBetterNotGetGreedyText),
|
||||
-- not a silent no-op (#197).
|
||||
if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then
|
||||
done()
|
||||
push(game, t._FightingDojoBetterNotGetGreedyText
|
||||
or "Better not get\ngreedy...", done)
|
||||
return
|
||||
end
|
||||
if not flags.EVENT_BEAT_KARATE_MASTER then
|
||||
push(game, "You'll have to\nbeat the master\nfirst!", done)
|
||||
return
|
||||
end
|
||||
ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes)
|
||||
if not yes then done() return end
|
||||
flags["EVENT_GOT_" .. species] = true
|
||||
flags.EVENT_DEFEATED_FIGHTING_DOJO = true
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.give_pokemon(ctx, species, 30)
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", otherBall)
|
||||
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
||||
end)
|
||||
-- Examining a ball shows that species' POKéDEX entry first
|
||||
-- (DisplayPokedex in FightingDojo.asm, which also marks it seen),
|
||||
-- then the yes/no take-it prompt (#853).
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.mark_seen(ctx, species)
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
game.stack:push(DexEntryMenu.new(game, species, function()
|
||||
ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes)
|
||||
if not yes then done() return end
|
||||
flags["EVENT_GOT_" .. species] = true
|
||||
flags.EVENT_DEFEATED_FIGHTING_DOJO = true
|
||||
Commands.give_pokemon(ctx, species, 30)
|
||||
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
|
||||
-- just the picked object's index) and routes to the greedy line above
|
||||
-- when talked to (#197).
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
||||
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
||||
end)
|
||||
end))
|
||||
end
|
||||
end
|
||||
|
||||
-- FightingDojoDefaultScript does not use the Master's facing direction for
|
||||
-- this battle. It checks exactly the cell left of him, turns both sprites
|
||||
-- toward one another, and then displays his trainer text. Keeping this out
|
||||
-- of trainer sight also lets him keep his original downward-facing pose.
|
||||
local function dojoMasterGate(game, ow, x, y)
|
||||
if x ~= 4 or y ~= 3 or game.save.flags.EVENT_BEAT_KARATE_MASTER then
|
||||
return false
|
||||
end
|
||||
local master
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == "FIGHTINGDOJO_KARATE_MASTER" then
|
||||
master = npc
|
||||
break
|
||||
end
|
||||
end
|
||||
if not master or ow:trainerDefeated(master) then return false end
|
||||
ow.player.facing = "right"
|
||||
master:facePlayer(ow.player)
|
||||
ow:engageTrainer(master)
|
||||
return true
|
||||
end
|
||||
|
||||
M.FIGHTING_DOJO = {
|
||||
talk = {
|
||||
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
|
||||
@@ -171,6 +228,22 @@ M.FIGHTING_DOJO = {
|
||||
"FIGHTINGDOJO_HITMONLEE_POKE_BALL",
|
||||
"_FightingDojoHitmonchanPokeBallText"),
|
||||
},
|
||||
-- The two dojo posters on the north wall (FightingDojo.asm bg_events at
|
||||
-- the top of the room, cells (4,0)/(5,0) directly above the prize balls)
|
||||
-- print _EnemiesOnEverySideText. The map extractor dropped the dojo
|
||||
-- bg_events (signs = {}), so wire the poster read here, the same
|
||||
-- facing-up + coord shape the mansion switches use. Reachable only once
|
||||
-- a claimed prize frees the ball cell below the poster (#197).
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if ow.player.facing ~= "up" then return false end
|
||||
if fy == 0 and (fx == 4 or fx == 5) then
|
||||
push(game, text(game)._EnemiesOnEverySideText
|
||||
or "Enemies on every\nside!")
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
onStep = dojoMasterGate,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -180,27 +253,32 @@ M.FIGHTING_DOJO = {
|
||||
|
||||
M.SILPH_CO_7F = {
|
||||
talk = {
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M1 = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
if game.save.flags.EVENT_GOT_LAPRAS then
|
||||
push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText
|
||||
or "How is LAPRAS\ndoing?", done)
|
||||
return
|
||||
end
|
||||
push(game, t._SilphCo7FSilphWorkerM1ThankYouText
|
||||
or "Thank you for\nsaving us!\fI want you to\nhave this LAPRAS!",
|
||||
function()
|
||||
game.save.flags.EVENT_GOT_LAPRAS = true
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
||||
"LAPRAS", 15)
|
||||
push(game, ("%s got\nLAPRAS!"):format(game.save.player.name),
|
||||
function()
|
||||
push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText
|
||||
or "It's a good\nswimmer!", done)
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
-- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1049)
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M1 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_GOT_LAPRAS" },
|
||||
{ "jump_if_true", "has_lapras" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM1HaveThisPokemonText" },
|
||||
{ "give_pokemon", "LAPRAS", 15 },
|
||||
{ "jump_if_false", "box_full" },
|
||||
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
|
||||
{ "set_flag", "EVENT_GOT_LAPRAS" },
|
||||
{ "text_sound", "Get_Item1" },
|
||||
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "box_full" },
|
||||
{ "show_text", "_BoxIsFullText" },
|
||||
{ "jump", "end" },
|
||||
-- SilphCo7F.asm .saved_silph gates the thanks on Giovanni
|
||||
{ "label", "has_lapras" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", "saved" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM1IsOurPresidentOkText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "saved" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM1SavedText" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -229,12 +307,11 @@ M.COPYCATS_HOUSE_2F = {
|
||||
return
|
||||
end
|
||||
game.stringBuffer = game.data.items.TM_MIMIC.name
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
Bag.remove(game.save, "POKE_DOLL", 1)
|
||||
game.save.flags.EVENT_GOT_TM31 = true
|
||||
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
|
||||
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
@@ -400,7 +477,6 @@ M.CELADON_MART_ROOF = {
|
||||
return
|
||||
end
|
||||
game.save.flags[g.flag] = true
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
local subs = { player = game.save.player.name,
|
||||
ram = game.data.items[g.tm].name }
|
||||
local explain = fill(t[g.explain] or "", subs)
|
||||
@@ -412,7 +488,7 @@ M.CELADON_MART_ROOF = {
|
||||
else
|
||||
done()
|
||||
end
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end,
|
||||
onCancel = done,
|
||||
@@ -434,25 +510,28 @@ M.ROUTE_24 = {
|
||||
local flags = game.save.flags
|
||||
local function battleOrDone()
|
||||
if ow:trainerDefeated(npc) then
|
||||
push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done)
|
||||
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
done)
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_NUGGET then
|
||||
push(game, "Congratulations!\nYou beat our 5\ncontest trainers!\f"
|
||||
.. "You just earned a\nfabulous prize!", function()
|
||||
local t = text(game)
|
||||
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
|
||||
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
|
||||
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
|
||||
game.data) then
|
||||
push(game, t._Route24CooltrainerM1NoRoomText, done)
|
||||
return
|
||||
end
|
||||
flags.EVENT_GOT_NUGGET = true
|
||||
require("src.inventory.Bag").add(game.save, "NUGGET", 1)
|
||||
push(game, ("%s received\na NUGGET!"):format(game.save.player.name),
|
||||
function()
|
||||
ask(game, "By the way, would\nyou like to join\nTEAM ROCKET?",
|
||||
function()
|
||||
push(game, "Arrgh! You are\nnot convinced?\fThen I'll show\n"
|
||||
.. "you my power!", battleOrDone)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
game.stringBuffer = game.data.items.NUGGET.name
|
||||
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
|
||||
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
|
||||
battleOrDone)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
return
|
||||
end
|
||||
battleOrDone()
|
||||
|
||||
@@ -4,9 +4,9 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done)
|
||||
local function push(game, s, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- fill the extracted text placeholders ({RAM:...}, {PLAYER})
|
||||
@@ -25,8 +25,8 @@ local function gift(opts)
|
||||
local t = text(game)
|
||||
local itemName = game.data.items[opts.item].name
|
||||
local subs = { ram = itemName, player = game.save.player.name }
|
||||
local function say(label, fallback, cb)
|
||||
push(game, fill(t[label] or fallback, subs), cb)
|
||||
local function say(label, fallback, cb, sopts)
|
||||
push(game, fill(t[label] or fallback, subs), cb, sopts)
|
||||
end
|
||||
if game.save.flags[opts.flag] then
|
||||
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
|
||||
@@ -39,17 +39,18 @@ local function gift(opts)
|
||||
end
|
||||
game.save.flags[opts.flag] = true
|
||||
local idef = game.data.items[opts.item]
|
||||
require("src.core.Sound").play(game.data,
|
||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
-- the received texts carry sound_get_item_1 / sound_get_key_item, so
|
||||
-- the jingle only fires once that box has typed out
|
||||
say(opts.received, "{PLAYER} received\n{RAM:}!", function()
|
||||
if opts.explain then
|
||||
say(opts.explain, "", done)
|
||||
else
|
||||
done()
|
||||
end
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game,
|
||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
|
||||
end
|
||||
if opts.pre then say(opts.pre, "", give) else give() end
|
||||
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -118,11 +119,21 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
|
||||
},
|
||||
}
|
||||
|
||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher; no pre text)
|
||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
||||
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
|
||||
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
|
||||
-- leading underscore, and on Red it sits outside the extractor's symbol
|
||||
-- set, so the literal from text/ViridianCity.asm rides along as the
|
||||
-- fallback; Yellow resolves the ROM string instead.
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
TEXT_VIRIDIANCITY_FISHER = gift({
|
||||
flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER",
|
||||
pre = "ViridianCityFisherYouCanHaveThisText",
|
||||
preFallback = "Yawn!\nI must have dozed\voff in the sun."
|
||||
.. "\fI had this dream\nabout a DROWZEE\veating my dream."
|
||||
.. "\vWhat's this?\vWhere did this TM\vcome from?"
|
||||
.. "\fThis is spooky!\nHere, you can\vhave this TM.",
|
||||
received = "_ViridianCityFisherReceivedTM42Text",
|
||||
explain = "_ViridianCityFisherTM42ExplanationText",
|
||||
noRoom = "_ViridianCityFisherTM42NoRoomText",
|
||||
@@ -135,6 +146,9 @@ M.SILPH_CO_2F = {
|
||||
talk = {
|
||||
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
||||
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
||||
-- the label carries no leading underscore: pokered keeps this one in
|
||||
-- the script bank, not the far-text bank (#393)
|
||||
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
|
||||
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
||||
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
||||
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
||||
@@ -173,6 +187,20 @@ M.ROUTE_18_GATE_2F = {
|
||||
{ "face_player" },
|
||||
{ "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- MARC
|
||||
},
|
||||
-- Yellow replaces the youngster with a cook trading SPIKE
|
||||
-- (TANGELA -> PARASECT): pokeyellow/scripts/Route18Gate2F.asm
|
||||
-- Route18Gate2FCookText runs TRADE_FOR_SPIKE, index 6 in the Yellow
|
||||
-- TradeMons table that Data:applyVersionedFieldData swaps in. Red
|
||||
-- maps have no COOK object here and Yellow maps have no YOUNGSTER,
|
||||
-- so each version only ever fires its own row (#651). Both rows
|
||||
-- share the Red-flavoured done flag on purpose: a .sav tracks
|
||||
-- "trade slot 6 completed" in one wCompletedInGameTradeFlags bit
|
||||
-- either version reads, and the save codec maps that bit to this
|
||||
-- flag name (src/save_convert/GenSave.lua EXTRA_FLAG_BITS).
|
||||
TEXT_ROUTE18GATE2F_COOK = {
|
||||
{ "face_player" },
|
||||
{ "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- SPIKE (Yellow)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -196,14 +224,24 @@ local function inCoords(coords, x, y)
|
||||
return false
|
||||
end
|
||||
|
||||
-- coordinate block: show a line and push the player back one step
|
||||
-- coordinate block: show a line and shove the player back one step. pokered
|
||||
-- shoves with a SIMULATED JOYPAD press (scripts/ViridianCity.asm ViridianCity-
|
||||
-- CheckGymOpenScript), which runs the normal overworld step pipeline including
|
||||
-- HandleLedges (engine/overworld/ledges.asm), so the shove must HOP a ledge
|
||||
-- when one sits in front: the tile below the Viridian Gym door is a down-ledge
|
||||
-- (#151). Gates with no ledge in front (Cinnabar gym lock at (18,4)) get
|
||||
-- checkLedgeHop == false and fall back to the plain forced step, unchanged.
|
||||
local function stepGate(opts)
|
||||
return function(game, ow, x, y)
|
||||
if not inCoords(opts.coords, x, y) then return false end
|
||||
if not opts.blocked(game) then return false end
|
||||
require("src.core.Sound").play(game.data, "Denied")
|
||||
push(game, text(game)[opts.text] or opts.fallback,
|
||||
function() ow:scriptMove(ow.player, opts.push, 1) end)
|
||||
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||
ow.player.facing = opts.push
|
||||
if not ow:checkLedgeHop(opts.push) then
|
||||
ow:scriptMove(ow.player, opts.push, 1)
|
||||
end
|
||||
end)
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -283,7 +321,11 @@ pewterEscort.guySteps = {
|
||||
"right", "right", "right",
|
||||
}
|
||||
|
||||
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
|
||||
-- Reverse of guySteps with opposite facings, i.e. the gym-to-spawn
|
||||
-- mirror of RLEList_PewterGymGuy. Kept as the documented inverse that
|
||||
-- tests/parity_pewter_escort.lua checks; the youngster does NOT walk it
|
||||
-- home any more, because its first step is LEFT through the player
|
||||
-- parked on (11,18) (#241). See walkHome below.
|
||||
do
|
||||
local opp = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local ret = {}
|
||||
@@ -361,26 +403,50 @@ local function pewterGymEscort(game, ow)
|
||||
local head = plan.guyHeadStart
|
||||
|
||||
-- After the walk: face the player, restore map music, "Go take on
|
||||
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
|
||||
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
|
||||
-- the same route home instead. Brock victory still HideObject's him.)
|
||||
-- BROCK", then MovementData_PewterGymGuyExit -- five steps RIGHT out of
|
||||
-- (12,18), the cell PewterCityYoungsterShowsPlayerGymScript pins him to
|
||||
-- with SetSpritePosition1 (hSpriteMapXCoord 16 / hSpriteMapYCoord 22,
|
||||
-- minus the +4 border offset object_event coords carry per
|
||||
-- macros/scripts/maps.asm) and exactly where the escort leaves him.
|
||||
-- That lands him on (17,18), the last walkable cell before the fence at
|
||||
-- (18,18) and one column past the screen edge with the player parked on
|
||||
-- (11,18). PewterCityHideYoungsterScript then HideObject's him and
|
||||
-- PewterCityResetYoungsterScript's SetSpritePosition2 + ShowObject put
|
||||
-- him back on his object_event spawn (35,16) facing DOWN, which is the
|
||||
-- snap below (the vanish/reappear is off screen, same as the original).
|
||||
--
|
||||
-- The old code retraced guyReturnSteps instead, whose first step is
|
||||
-- LEFT into (11,18) -- the cell the player is standing on -- and
|
||||
-- scriptMove is a pure tween with no entity test, so he walked straight
|
||||
-- through Red (#241). There is no honest route home: (17,18) is a
|
||||
-- dead-end pocket ((18,18) fence, (17,17) wall), which is precisely why
|
||||
-- the original teleports. guyReturnSteps stays as the documented
|
||||
-- mirror of RLEList_PewterGymGuy that parity_pewter_escort asserts.
|
||||
-- Brock victory still HideObject's him.
|
||||
local function walkHome()
|
||||
if not guy then return end
|
||||
local ret = pewterEscort.guyReturnSteps
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
if not ret[i] then
|
||||
if i > 5 then
|
||||
-- SetSpritePosition2: same field writes as Commands.place_npc,
|
||||
-- including the target clear -- a stale targetX/targetY would
|
||||
-- leave OverworldState:npcAtCell reserving (17,18) forever and
|
||||
-- silently wall the player out of that pocket.
|
||||
guy.cellX, guy.cellY = 35, 16
|
||||
guy.px, guy.py = 35 * 16, 16 * 16
|
||||
guy.moving = false
|
||||
guy.targetX, guy.targetY = nil, nil
|
||||
guy.facing = "down"
|
||||
return
|
||||
end
|
||||
ow:scriptMove(guy, ret[i], 1, tick)
|
||||
ow:scriptMove(guy, "right", 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
|
||||
local function afterWalk()
|
||||
if guy then guy.facing = "left" end
|
||||
if guy then guy.stepFrames, guy.facing = nil, "left" end
|
||||
Music.playMap(game.data, "PEWTER_CITY")
|
||||
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
||||
or "Go take on BROCK\nat the GYM first!", walkHome)
|
||||
@@ -403,6 +469,11 @@ local function pewterGymEscort(game, ow)
|
||||
end
|
||||
|
||||
local function beginWalk()
|
||||
-- the escort runs the youngster on the player's own frames per cell
|
||||
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||
if guy then
|
||||
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||
end
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
if guy and head > 0 then
|
||||
local h = 0
|
||||
@@ -443,12 +514,12 @@ M.PEWTER_CITY = {
|
||||
-- Rival ambush: show the hidden rival, walk him up to the player, run
|
||||
-- the battle rows, march him back and hide him. On a loss the walk is
|
||||
-- skipped (the blackout rebuilds the map mid-script).
|
||||
local function runAmbush(game, ow, rows, playerFacing)
|
||||
local function runAmbush(game, ow, rows, playerFacing, musicOpts)
|
||||
if ow.runner:isRunning() then return false end
|
||||
ow.player.facing = playerFacing
|
||||
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
|
||||
-- takes over and the map theme returns after the victory jingle
|
||||
require("src.core.Music").play(game.data, "Music_MeetRival")
|
||||
require("src.core.Music").play(game.data, "Music_MeetRival", nil, musicOpts)
|
||||
ow.runner:run(rows)
|
||||
return true
|
||||
end
|
||||
@@ -458,48 +529,82 @@ end
|
||||
-- Gym) and again in Viridian Gym; we derive the same windows from the
|
||||
-- surrounding story flags so old saves work too.
|
||||
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
|
||||
-- Both exit lists are keyed on wSavedCoordIndex -- WHICH entry of
|
||||
-- Route22DefaultScript.Route22RivalBattleCoords the player matched -- not
|
||||
-- on where the rival ended up. CheckCoords (home/map_objects.asm:107)
|
||||
-- zeroes wCoordIndex and `inc [hl]` BEFORE each compare, so the first
|
||||
-- entry reports 1: index 1 is (29,4), index 2 is (29,5).
|
||||
-- Route22Rival1AfterBattleScript (Route22.asm:175) takes
|
||||
-- ...ExitMovementData1 on index 1 and ...Data2 otherwise. From the top
|
||||
-- tile the rival stands BELOW the player on (29,5) and leaves east along
|
||||
-- row 5; from the bottom tile he stands LEFT of him on (28,5) and has to
|
||||
-- step UP to row 4 to get around him. The port had the two branches
|
||||
-- swapped, so the top tile started the walk with UP out of (28,4) into
|
||||
-- the cliff cell (28,3), which is not walkable (#236).
|
||||
local function route22ExitDirs(n, py)
|
||||
if n == 2 then
|
||||
if py == 5 then return { "left", "left", "left", "left" } end
|
||||
-- Route22Rival2ExitMovementData1 falls through into ...Data2: LEFT x4
|
||||
-- from (29,5), LEFT x3 from (28,5), both back onto the spawn (25,5).
|
||||
if py == 4 then return { "left", "left", "left", "left" } end
|
||||
return { "left", "left", "left" }
|
||||
end
|
||||
if py == 5 then
|
||||
-- Route22Rival1ExitMovementData1: (29,5) -> (31,5) -> (31,10)
|
||||
if py == 4 then
|
||||
return { "right", "right", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
-- Route22Rival1ExitMovementData2: (28,5) -> (28,4) -> (31,4) -> (31,10)
|
||||
return { "up", "right", "right", "right",
|
||||
"down", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
|
||||
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
|
||||
-- Route22MoveRivalRightScript (Route22.asm:39) walks him RIGHT along his
|
||||
-- own row from the object_event spawn (25,5): the full four-RIGHT
|
||||
-- Route22RivalMovementData on coord index 1 (player on (29,4)), so he
|
||||
-- stops BELOW the player on (29,5); `inc de` drops one RIGHT on index 2
|
||||
-- (player on (29,5)), so he stops LEFT of him on (28,5). He never
|
||||
-- leaves row 5. Route22Rival{1,2}StartBattleScript (Route22.asm:110)
|
||||
-- then faces him UP on index 1 and RIGHT otherwise (#236).
|
||||
local rx = (py == 4) and 29 or 28
|
||||
local rivalFacing = (py == 4) and "up" or "right"
|
||||
return {
|
||||
{ "show_object", "ROUTE_22", objName }, -- 1
|
||||
{ "move_npc_to", objIndex, 28, py }, -- 2
|
||||
{ "face_object", objIndex, "right" }, -- 3
|
||||
{ "move_npc_to", objIndex, rx, 5 }, -- 2
|
||||
{ "face_object", objIndex, rivalFacing }, -- 3
|
||||
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
||||
{ "rival_battle", oppClass, baseParty }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 13 }, -- 6
|
||||
{ "set_flag", beatFlag }, -- 7
|
||||
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
|
||||
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
|
||||
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
|
||||
{ "hide_object", "ROUTE_22", objName }, -- 11
|
||||
{ "play_music", "Music_MeetRival", { start = "rival",
|
||||
tempo = n == 2 and 100 or nil } }, -- 10
|
||||
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 11
|
||||
{ "play_default_music" }, -- scripts/Route22.asm:230
|
||||
{ "hide_object", "ROUTE_22", objName }, -- 13
|
||||
}
|
||||
end
|
||||
|
||||
-- Route22Rival{1,2}StartBattleScript turns the player toward him too:
|
||||
-- PLAYER_DIR_DOWN on coord index 1 (the (29,4) tile, rival below him),
|
||||
-- and the PLAYER_DIR_LEFT Route22DefaultScript already set on index 2
|
||||
-- (the (29,5) tile, rival to his left) (#236).
|
||||
M.ROUTE_22 = {
|
||||
onStep = function(game, ow, x, y)
|
||||
if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end
|
||||
local f = game.save.flags
|
||||
local playerFacing = (y == 4) and "down" or "left"
|
||||
if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK
|
||||
and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
|
||||
return runAmbush(game, ow,
|
||||
route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4,
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), "left")
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), playerFacing)
|
||||
end
|
||||
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
||||
return runAmbush(game, ow,
|
||||
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), "left")
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing,
|
||||
{ tempo = 100 })
|
||||
end
|
||||
return false
|
||||
end,
|
||||
@@ -523,19 +628,21 @@ local function ceruleanRivalScene(px, py)
|
||||
{ "face_object", 1, "down" }, -- 3
|
||||
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 13 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
||||
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
||||
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
||||
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
|
||||
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 11
|
||||
{ "play_default_music" }, -- scripts/CeruleanCity.asm:230
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 13
|
||||
}
|
||||
end
|
||||
|
||||
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
|
||||
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
|
||||
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
|
||||
-- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object.
|
||||
-- Hide ROCKET → GBFadeInFromBlack -- not a bare hide_object.
|
||||
local rocketRows = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||
@@ -554,7 +661,7 @@ local rocketRows = {
|
||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||
-- door neighbour — the swap reconnects the city (Bill's ticket does
|
||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||
-- the same in story.lua; either route is enough).
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
@@ -618,13 +725,89 @@ M.MUSEUM_1F = {
|
||||
}
|
||||
|
||||
-- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm
|
||||
-- plays MUSIC_JIGGLYPUFF_SONG, then the map theme resumes)
|
||||
-- PewterPokecenterJigglypuffText). The text_asm sets
|
||||
-- wDoNotWaitForButtonPressAfterDisplayingText before PrintText, so the box
|
||||
-- prints with no A prompt and the script keeps running underneath it:
|
||||
-- SFX_STOP_ALL_MUSIC, DelayFrames 32, PlayMusic MUSIC_JIGGLYPUFF_SONG,
|
||||
-- then .spinMovementLoop writes the next of DOWN -> LEFT -> UP -> RIGHT (a
|
||||
-- clockwise turn) every 24 frames for as long as the song's CHAN1/CHAN2
|
||||
-- still sound, DelayFrames 48, PlayDefaultMusic, TextScriptEnd. Only that
|
||||
-- last step closes the box, so the whole dance plays out unskippably
|
||||
-- (#249). Music.playOnce's pendingRestore stands in for both the channel
|
||||
-- poll and PlayDefaultMusic, so the Center's theme comes back the frame
|
||||
-- the song ends rather than 48 frames later.
|
||||
local JIGGLYPUFF_SPIN = { "down", "left", "up", "right" }
|
||||
local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
|
||||
|
||||
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
|
||||
-- page has typed out (PrintText returning), and auto.tick then runs once
|
||||
-- per frame while the gate it returns still reads as playing.
|
||||
local function jigglypuffDance(game, npc, ow)
|
||||
local Music = require("src.core.Music")
|
||||
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
|
||||
-- matching the sprite's current facing (showMapText has just turned it
|
||||
-- toward the player), and the first write is that same facing, so the
|
||||
-- first visible quarter turn lands 24 frames in
|
||||
local step = 1
|
||||
for i, dir in ipairs(JIGGLYPUFF_SPIN) do
|
||||
if npc and npc.facing == dir then step = i end
|
||||
end
|
||||
local frames, phase = 0, "silence"
|
||||
return {
|
||||
sound = function()
|
||||
Music.stop() -- SFX_STOP_ALL_MUSIC
|
||||
return { isPlaying = function() return phase ~= "done" end }
|
||||
end,
|
||||
tick = function()
|
||||
frames = frames + 1
|
||||
if phase == "silence" then
|
||||
if frames < JIGGLYPUFF_SILENCE then return end
|
||||
frames = 0
|
||||
if Music.playOnce(game.data, "Music_JigglypuffSong") then
|
||||
phase = "spin"
|
||||
else
|
||||
-- no song def (or headless): Music.stop above already took the
|
||||
-- map theme down and nothing armed pendingRestore, so put it
|
||||
-- back by hand and fall through to the tail rather than hold
|
||||
-- the box on a poll that would never clear
|
||||
Music.restoreMap(game.data)
|
||||
phase = "tail"
|
||||
end
|
||||
return
|
||||
end
|
||||
if phase == "spin" then
|
||||
if frames < JIGGLYPUFF_STEP then return end
|
||||
frames = 0
|
||||
-- the loop tests the channels after the delay and before the next
|
||||
-- write, so a song that just ended costs no extra quarter turn
|
||||
if not Music.oneShotPlaying() then
|
||||
phase = "tail"
|
||||
return
|
||||
end
|
||||
step = step % #JIGGLYPUFF_SPIN + 1
|
||||
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
|
||||
return
|
||||
end
|
||||
if frames >= JIGGLYPUFF_TAIL then
|
||||
phase = "done"
|
||||
if require("src.core.GameVersion").isYellow()
|
||||
and require("src.world.PikachuFollower").starterInParty(game.save) then
|
||||
ow.pikachuPewterSleepScene = true
|
||||
end
|
||||
end
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
M.PEWTER_POKECENTER = {
|
||||
talk = {
|
||||
TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done)
|
||||
require("src.core.Music").playOnce(game.data, "Music_JigglypuffSong")
|
||||
push(game, text(game)._PewterPokecenterJigglypuffText
|
||||
or "JIGGLYPUFF: Puu\npupuu!", done)
|
||||
-- not the local push() helper: this box needs opts.auto, which is
|
||||
-- also what suppresses the blinking arrow and the A dismissal
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
||||
done, { auto = jigglypuffDance(game, npc, ow) }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -649,8 +832,19 @@ local function bikeGateGuard(coords, stopText, explainText)
|
||||
local t = text(game)
|
||||
push(game, t[stopText] or "Hey! Wait up!", function()
|
||||
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
|
||||
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript
|
||||
-- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one
|
||||
-- PAD_RIGHT step after the refusal text, and only clear
|
||||
-- wJoyIgnore / hand control back once that step finishes
|
||||
-- (PlayerMovingRightScript). Without it the player was left
|
||||
-- parked beside the guard's counter with no way past. #518
|
||||
local function shoveRight()
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
end
|
||||
if dist > 0 then
|
||||
ow:scriptMove(ow.player, "up", dist)
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||
else
|
||||
shoveRight()
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -688,16 +882,31 @@ M.SILPH_CO_7F = {
|
||||
{ "face_object", 9, "up" }, -- 4
|
||||
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 7
|
||||
{ "jump_if_false", 14 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
|
||||
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
|
||||
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
|
||||
{ "move_npc_to", 9, 5, y + 1 }, -- 11
|
||||
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 11
|
||||
{ "move_npc_to", 9, 5, y + 1 }, -- 12
|
||||
{ "play_default_music" }, -- scripts/SilphCo7F.asm:261
|
||||
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 14
|
||||
}, "down")
|
||||
end,
|
||||
}
|
||||
|
||||
-- SSAnne2FRivalAfterBattleScript's exit walk is keyed on the player's X,
|
||||
-- not on where the rival stopped: from (37,8) the rival stands below him on
|
||||
-- (36,8) and takes .RivalDownFourMovement straight down out of the room;
|
||||
-- from (36,8) he stands above him on (36,7) and takes
|
||||
-- .RivalWalkAroundPlayerMovement, which steps RIGHT and then falls through
|
||||
-- into the same four DOWNs, so five downs in all (#360).
|
||||
local function ssAnne2FRivalExitDirs(onLeft)
|
||||
if onLeft then
|
||||
return { "right", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
return { "down", "down", "down", "down" }
|
||||
end
|
||||
|
||||
-- S.S. Anne 2F rival ambush (scripts/SSAnne2F.asm; coords 36/37,8)
|
||||
M.SS_ANNE_2F = {
|
||||
onStep = function(game, ow, x, y)
|
||||
@@ -710,11 +919,14 @@ M.SS_ANNE_2F = {
|
||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 10 }, -- 6
|
||||
{ "jump_if_false", 13 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
{ "move_npc_to", 2, 36, 4 }, -- 9
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 10
|
||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
|
||||
{ "play_default_music" }, -- scripts/SSAnne2F.asm:175
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 13
|
||||
}, onLeft and "up" or "left")
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -12,9 +12,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -118,6 +122,7 @@ local MANSION_HOLES = {
|
||||
M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y)
|
||||
for _, h in ipairs(MANSION_HOLES) do
|
||||
if x == h[1] and y == h[2] then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
|
||||
return true
|
||||
end
|
||||
@@ -173,7 +178,46 @@ local function syncGymGatesAfterBattle(game, ow)
|
||||
applyGymGates(game, ow)
|
||||
end
|
||||
|
||||
-- Yellow's quiz-first rule (scripts/CinnabarGym.asm SuperNerd2..7): a
|
||||
-- gate guardian refuses to battle until his quiz was attempted -- a
|
||||
-- wrong answer sics him on you (the onInteract path below), a right one
|
||||
-- opens his gate; walking up and talking first just gets the room's
|
||||
-- CinnabarGymText_N lecture (Func_f2150's pointer table).
|
||||
local function yellowQuizTalk(machineIndex)
|
||||
return function(game, ow, npc, done)
|
||||
local yellow = require("src.core.GameVersion").isYellow()
|
||||
local defeated = ow:trainerDefeated(npc)
|
||||
local gateOpen = game.save.flags[gymGateFlag(machineIndex)]
|
||||
if yellow and not defeated and not gateOpen then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
game.stack:push(TextBox.new(game,
|
||||
t["_CinnabarGymText_" .. machineIndex]
|
||||
or "You have to take\nthe quiz first!", done))
|
||||
return
|
||||
end
|
||||
-- gate open (or Red/Blue): the ordinary trainer engagement /
|
||||
-- after-battle line, mirroring talkTo's generic trainer branch
|
||||
npc:facePlayer(ow.player)
|
||||
if not defeated then
|
||||
ow:engageTrainer(npc, done)
|
||||
return
|
||||
end
|
||||
local header = game.data:trainerHeader(ow.map.def.label, npc.def.index)
|
||||
local after = header and header.after and game.data.text[header.after]
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, after or "...", done))
|
||||
end
|
||||
end
|
||||
|
||||
M.CINNABAR_GYM = {
|
||||
talk = (function()
|
||||
local talk = {}
|
||||
for i = 1, 6 do
|
||||
talk["TEXT_CINNABARGYM_SUPER_NERD" .. (i + 1)] = yellowQuizTalk(i)
|
||||
end
|
||||
return talk
|
||||
end)(),
|
||||
onEnter = applyGymGates,
|
||||
onVictory = syncGymGatesAfterBattle,
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
|
||||
@@ -16,8 +16,26 @@
|
||||
-- `dialogue` is the end-battle + post-battle text chain each gym leader
|
||||
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
|
||||
-- script). Leaders are not def_trainers entries, so engageTrainer has
|
||||
-- no header.won — checkVictoryRewards shows this chain instead of a
|
||||
-- no header.won -- checkVictoryRewards shows this chain instead of a
|
||||
-- synthetic "received badge/TM" stub.
|
||||
--
|
||||
-- Gym entries split the TM hand-over out of `dialogue`, mirroring the
|
||||
-- originals' GiveItem check (`call GiveItem` / `jr nc, .BagFull`):
|
||||
-- `tmPre` is the ReceiveTM script's lead-in (badge info / "Wait! Take
|
||||
-- this!"), shown at the victory and again when a beaten leader retries
|
||||
-- the hand-over; `tmDialogue` shows only when the TM actually goes in
|
||||
-- the bag; `noRoom` is the "make room" line shown instead when the bag
|
||||
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
|
||||
-- successful give, which is what makes the leader's talk script retry
|
||||
-- later (gyms.lua).
|
||||
--
|
||||
-- `badgeSound` / `tmSound` are the text sound command each gym's reward
|
||||
-- text carries right after its FIRST label -- home/text.asm TextCommand_SOUND
|
||||
-- plays it once that page has typed out and then blocks on
|
||||
-- WaitForSoundToFinish, so the jingle sits between the pages rather than
|
||||
-- under them. macros/scripts/text.asm defines sound_level_up as
|
||||
-- sound_get_item_1, so Pewter's and Viridian's badge lines are Get_Item1
|
||||
-- too. Vermilion, Celadon and Fuchsia carry no sound on the badge text.
|
||||
|
||||
local function range(prefix, first, last)
|
||||
local t = {}
|
||||
@@ -33,90 +51,146 @@ return {
|
||||
-- escort NPC and the first Route 22 rival stay gone after the badge.
|
||||
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
||||
item = "TM_BIDE",
|
||||
gotFlag = "EVENT_GOT_TM34",
|
||||
noRoom = "_PewterGymTM34NoRoomText",
|
||||
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
|
||||
hide = {
|
||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||
},
|
||||
badgeSound = "Get_Item1", -- sound_level_up
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||
"_PewterGymBrockBoulderBadgeInfoText",
|
||||
"_PewterGymBrockWaitTakeThisText",
|
||||
},
|
||||
tmPre = { "_PewterGymBrockWaitTakeThisText" },
|
||||
tmDialogue = {
|
||||
"_PewterGymReceivedTM34Text",
|
||||
"_TM34ExplanationText",
|
||||
} },
|
||||
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
||||
item = "TM_BUBBLEBEAM",
|
||||
gotFlag = "EVENT_GOT_TM11",
|
||||
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||
"_CeruleanGymMistyCascadeBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_CeruleanGymMistyCascadeBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_CeruleanGymMistyReceivedTM11Text",
|
||||
} },
|
||||
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
|
||||
item = "TM_THUNDERBOLT",
|
||||
gotFlag = "EVENT_GOT_TM24",
|
||||
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||
tmSound = "Get_Key_Item",
|
||||
dialogue = {
|
||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||
"_VermilionGymLTSurgeThunderBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_VermilionGymLTSurgeThunderBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_VermilionGymLTSurgeReceivedTM24Text",
|
||||
"_TM24ExplanationText",
|
||||
} },
|
||||
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
|
||||
item = "TM_MEGA_DRAIN",
|
||||
gotFlag = "EVENT_GOT_TM21",
|
||||
noRoom = "_CeladonGymTM21NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||
"_CeladonGymRainbowBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_CeladonGymRainbowBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_CeladonGymReceivedTM21Text",
|
||||
"_TM21ExplanationText",
|
||||
} },
|
||||
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
||||
item = "TM_TOXIC",
|
||||
gotFlag = "EVENT_GOT_TM06",
|
||||
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||
tmSound = "Get_Key_Item",
|
||||
dialogue = {
|
||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||
"_FuchsiaGymKogaSoulBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_FuchsiaGymKogaSoulBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_FuchsiaGymKogaReceivedTM06Text",
|
||||
"_FuchsiaGymKogaTM06ExplanationText",
|
||||
} },
|
||||
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
||||
item = "TM_PSYWAVE",
|
||||
gotFlag = "EVENT_GOT_TM46",
|
||||
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||
"_SaffronGymSabrinaMarshBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_SaffronGymSabrinaMarshBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_SaffronGymSabrinaReceivedTM46Text",
|
||||
"_TM46ExplanationText",
|
||||
} },
|
||||
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
|
||||
item = "TM_FIRE_BLAST",
|
||||
gotFlag = "EVENT_GOT_TM38",
|
||||
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||
"_CinnabarGymBlaineVolcanoBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_CinnabarGymBlaineVolcanoBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_CinnabarGymBlaineReceivedTM38Text",
|
||||
"_CinnabarGymBlaineTM38ExplanationText",
|
||||
} },
|
||||
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
||||
item = "TM_FISSURE",
|
||||
gotFlag = "EVENT_GOT_TM27",
|
||||
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||
badgeSound = "Get_Item1", -- sound_level_up
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||
"_ViridianGymGiovanniEarthBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_ViridianGymGiovanniEarthBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_ViridianGymGiovanniReceivedTM27Text",
|
||||
"_ViridianGymGiovanniTM27ExplanationText",
|
||||
} },
|
||||
|
||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
|
||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift.
|
||||
-- SilphCo11FGiovanniStartBattleScript (scripts/SilphCo11F.asm) hands the
|
||||
-- battle SilphCo10FGiovanniILostAgainText through SaveEndBattleTextPointers,
|
||||
-- but he has no def_trainers header on 11F, so engageTrainer finds no
|
||||
-- header.won to give it -- this chain is the port's stand-in for that loss
|
||||
-- line (#722). The "Blast it all!" speech, the fade and the rockets
|
||||
-- leaving are SilphCo11FGiovanniAfterBattleScript, ported in M.SILPH_CO_11F
|
||||
-- (data/scripts/story.lua).
|
||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI",
|
||||
dialogue = { "_SilphCo10FGiovanniILostAgainText" } },
|
||||
|
||||
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
|
||||
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
|
||||
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
|
||||
-- only him (data/maps/objects/FightingDojo.asm).
|
||||
-- only him (data/maps/objects/FightingDojo.asm). `dialogue` is the
|
||||
-- concede + prize offer he speaks after the win (his header supplies the
|
||||
-- "Hwa! Arrgh! Beaten!" won line first; #197).
|
||||
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
|
||||
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
|
||||
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3),
|
||||
dialogue = { "_FightingDojoKarateMasterIWillGiveYouAPokemonText" } },
|
||||
|
||||
-- Elite Four progress flags (their rooms' door logic isn't ported, but
|
||||
-- the flags make the Hall of Fame checkable)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
-- Summer Beach House (scripts/SummerBeachHouse.asm), Yellow's Route 19
|
||||
-- surf shack. The Surfin' Dude only lets a party Pikachu that knows
|
||||
-- SURF ride (IsSurfingPikachuInParty, home/map_objects.asm); saying yes
|
||||
-- runs the Surfing Pikachu minigame (src/ui/SurfingMinigame.lua). The
|
||||
-- corner printer shows/prints the high score once you have surfed this
|
||||
-- visit (BIT_PIKACHU_MAP_SURF_SELECT is a per-map-load flag, so the
|
||||
-- session markers live on the overworld state, not the save).
|
||||
|
||||
local function surfingPikachu(game)
|
||||
for _, mon in ipairs(game.save.party or {}) do
|
||||
if mon.species == "PIKACHU" then
|
||||
for _, mv in ipairs(mon.moves or {}) do
|
||||
if mv.id == "SURF" then return mon end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function push(game, text, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, text, done))
|
||||
end
|
||||
|
||||
-- the two-variant posters: the surf-capable line once a surfing
|
||||
-- Pikachu is along, the plain one otherwise
|
||||
local function poster(n)
|
||||
return function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
local key = ("_SummerBeachHousePoster%dText%d"):format(
|
||||
n, surfingPikachu(game) and 1 or 2)
|
||||
push(game, t[key] or "A surfing poster.", done)
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
SUMMER_BEACH_HOUSE = {
|
||||
talk = {
|
||||
TEXT_SUMMERBEACHHOUSE_SURFINDUDE = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
if not surfingPikachu(game) then
|
||||
push(game, t._SummerBeachHouseSurfinDudeText4
|
||||
or "Dogs and burgers\non special today!", done)
|
||||
return
|
||||
end
|
||||
-- Text1 on the first ask each visit, the short Text3 after
|
||||
local ask = ow.surfinDudeAsked
|
||||
and (t._SummerBeachHouseSurfinDudeText3 or "Wanna go SURF?")
|
||||
or (t._SummerBeachHouseSurfinDudeText1
|
||||
or "Whoa!\nYour PIKACHU knows\nhow to SURF!\fGive it a go?")
|
||||
ow.surfinDudeAsked = true
|
||||
push(game, ask, function()
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
push(game, t._SummerBeachHouseSurfinDudeText2
|
||||
or "Come SURF anytime,\nmy friend!", done)
|
||||
return
|
||||
end
|
||||
local SurfingMinigame = require("src.ui.SurfingMinigame")
|
||||
game.stack:push(SurfingMinigame.new(game, function()
|
||||
ow.surfedThisVisit = true -- BIT_PIKACHU_MAP_SURF_SELECT
|
||||
require("src.core.Music").playMap(game.data, ow.map.id)
|
||||
done()
|
||||
end))
|
||||
end))
|
||||
end)
|
||||
end,
|
||||
|
||||
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
|
||||
function()
|
||||
require("src.core.Sound").playCry(game.data, "PIKACHU")
|
||||
done()
|
||||
end)
|
||||
end,
|
||||
|
||||
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
|
||||
TEXT_SUMMERBEACHHOUSE_POSTER2 = poster(2),
|
||||
TEXT_SUMMERBEACHHOUSE_POSTER3 = poster(3),
|
||||
|
||||
TEXT_SUMMERBEACHHOUSE_PRINTER = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
if not surfingPikachu(game) then
|
||||
push(game, t._SummerBeachHousePrinterText1
|
||||
or "It's some sort of\na machine...", done)
|
||||
return
|
||||
end
|
||||
push(game, t._SummerBeachHousePrinterText2
|
||||
or "SUMMER BEACH HOUSE\nPRINTER, it says.", function()
|
||||
if not ow.surfedThisVisit then
|
||||
done()
|
||||
return
|
||||
end
|
||||
push(game, t._SummerBeachHousePrinterText3
|
||||
or "The Hi-Score is\nshown.\fPRINT it out?", function()
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
done()
|
||||
return
|
||||
end
|
||||
-- PrintSurfingMinigameHighScore -> PNG stand-in
|
||||
local Printer = require("src.core.Printer")
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local hi = game.save.surfingHighScore or 0
|
||||
local name = game.save.player.name or "RED"
|
||||
local saved, err = Printer.save("surf_hiscore", 160, 64,
|
||||
function()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 64)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", 2.5, 2.5, 155, 59)
|
||||
Font.draw(Strings("SUMMER BEACH HOUSE"), 8, 10)
|
||||
Font.draw(Strings("SURFING Hi-Score"), 8, 24)
|
||||
Font.draw(name, 8, 40)
|
||||
Font.draw(Strings("%d pts", hi), 96, 40)
|
||||
end)
|
||||
push(game, saved
|
||||
and Strings("Printed!\fSaved as\n%s\vin the save\nfolder.",
|
||||
saved)
|
||||
or Strings("Printer error!\n%s", tostring(err)), done)
|
||||
end))
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
-- Yellow's three Kanto-starter side-quests, hand-ported from
|
||||
-- scripts/CeruleanMelaniesHouse.asm, scripts/Route24.asm
|
||||
-- (Route24CooltrainerM4Text) and scripts/VermilionCity_2.asm
|
||||
-- (VermilionCityPrintOfficerJennyText). Registered on top of the shared
|
||||
-- Red map scripts by data/scripts/init.lua on a Yellow boot only, so the
|
||||
-- table keys compose with story.lua / story4.lua's existing entries.
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Melanie hands over her Bulbasaur once the starter Pikachu trusts you
|
||||
-- (wPikachuHappiness >= 147).
|
||||
M.CERULEAN_MELANIES_HOUSE = {
|
||||
talk = {
|
||||
TEXT_CERULEANMELANIESHOUSE_MELANIE = function(game, ow, npc, done)
|
||||
local rows = { { "face_player" } }
|
||||
if game.save.flags.EVENT_GOT_BULBASAUR_IN_CERULEAN then
|
||||
rows[#rows + 1] = { "show_text", "MelanieText4" }
|
||||
elseif (game.save.pikachuHappiness or 90) < 147 then
|
||||
rows[#rows + 1] = { "show_text", "MelanieText1" }
|
||||
else
|
||||
rows[#rows + 1] = { "show_text", "MelanieText1" }
|
||||
rows[#rows + 1] = { "ask", "MelanieText2" }
|
||||
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
|
||||
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
|
||||
-- Event and HideObject before MelanieText3: give_pokemon's nickname
|
||||
-- prompt and the received text both yield, and a script that dies
|
||||
-- in that window used to leave the BULBASAUR given with the event
|
||||
-- clear, so Melanie offered a second one (#426). The flag is only
|
||||
-- read at script entry, so the order is invisible in play.
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
|
||||
rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE",
|
||||
"CERULEANMELANIESHOUSE_BULBASAUR" }
|
||||
rows[#rows + 1] = { "show_text", "MelanieText3" }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
||||
end
|
||||
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end,
|
||||
-- pet flavor: the text with the species' cry over it
|
||||
TEXT_CERULEANMELANIESHOUSE_BULBASAUR = {
|
||||
{ "play_cry", "BULBASAUR" },
|
||||
{ "show_text", "MelanieBulbasaurText" },
|
||||
},
|
||||
TEXT_CERULEANMELANIESHOUSE_ODDISH = {
|
||||
{ "play_cry", "ODDISH" },
|
||||
{ "show_text", "MelanieOddishText" },
|
||||
},
|
||||
TEXT_CERULEANMELANIESHOUSE_SANDSHREW = {
|
||||
{ "play_cry", "SANDSHREW" },
|
||||
{ "show_text", "MelanieSandshrewText" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Damian gives away the Charmander he thinks is too weak (EVENT_54F).
|
||||
M.ROUTE_24 = {
|
||||
talk = {
|
||||
TEXT_ROUTE24_COOLTRAINER_M4 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_54F" },
|
||||
{ "jump_if_true", "after" },
|
||||
{ "ask", "_Route24DamianText1" },
|
||||
{ "jump_if_false", "declined" },
|
||||
{ "give_pokemon", "CHARMANDER", 10 },
|
||||
{ "jump_if_false", "end" },
|
||||
-- SetEvent hoisted ahead of the received text (Route24.asm writes it
|
||||
-- after Route24Text_515e3, where nothing in between can fail): the
|
||||
-- nickname prompt and the text both yield, and a script death in that
|
||||
-- window left the CHARMANDER given with EVENT_54F clear, so Damian
|
||||
-- handed out a second one on the next talk (#426).
|
||||
{ "set_flag", "EVENT_54F" },
|
||||
{ "show_text", "_Route24DamianText2" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "declined" },
|
||||
{ "show_text", "_Route24DamianText3" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "after" },
|
||||
{ "show_text", "_Route24DamianText4" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Officer Jenny's Squirtle: kept until you carry the Thunder Badge.
|
||||
M.VERMILION_CITY = {
|
||||
talk = {
|
||||
TEXT_VERMILIONCITY_OFFICER_JENNY = function(game, ow, npc, done)
|
||||
local rows = { { "face_player" } }
|
||||
if game.save.flags.EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY then
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText5" }
|
||||
elseif not (game.save.inventory
|
||||
and game.save.inventory.THUNDERBADGE) then
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText1" }
|
||||
else
|
||||
rows[#rows + 1] = { "ask", "_OfficerJennyText2" }
|
||||
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
|
||||
rows[#rows + 1] = { "jump_if_false", "end" }
|
||||
-- event before the received text, as above (#426)
|
||||
rows[#rows + 1] = { "set_flag",
|
||||
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
|
||||
end
|
||||
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,308 @@
|
||||
-- Jessie & James, Yellow's Team Rocket duo, at all four ambush sites:
|
||||
-- Mt Moon B2F (scripts/MtMoonB2F.asm), Rocket Hideout B4F
|
||||
-- (scripts/RocketHideoutB4F.asm), Pokemon Tower 7F
|
||||
-- (scripts/PokemonTower7F.asm) and Silph Co 11F (scripts/SilphCo11F.asm).
|
||||
-- Every site shares one shape: a coordinate trigger swaps the map theme
|
||||
-- for Music_MeetJessieJames, the motto plays, the duo closes in, one
|
||||
-- battle against the shared OPP_ROCKET party fights them both, and after
|
||||
-- their parting lines they vanish together under a second sting of the
|
||||
-- theme before the map theme resumes (PlayDefaultMusic ->
|
||||
-- play_default_music).
|
||||
--
|
||||
-- Registered on top of the shared tables by data/scripts/init.lua on a
|
||||
-- Yellow boot; MT_MOON_B2F's onStep chains story2's Super Nerd / fossil
|
||||
-- trigger and SILPH_CO_11F's chains story's Giovanni trigger, since
|
||||
-- non-talk hooks replace rather than merge.
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Capture the FUNCTION, not the table: attachBase stores the module
|
||||
-- table itself, so once this file's onStep is attached the table's slot
|
||||
-- points back here -- delegating through the table would self-recurse.
|
||||
local baseMtMoonStep = require("data.scripts.story2").MT_MOON_B2F.onStep
|
||||
local baseSilph11Step = require("data.scripts.story").SILPH_CO_11F.onStep
|
||||
|
||||
M.MT_MOON_B2F = {
|
||||
talk = {
|
||||
TEXT_MTMOONB2F_JESSIE = {
|
||||
{ "face_player" }, { "show_text", "_MtMoonJessieJamesText1" },
|
||||
},
|
||||
TEXT_MTMOONB2F_JAMES = {
|
||||
{ "face_player" }, { "show_text", "_MtMoonJessieJamesText1" },
|
||||
},
|
||||
},
|
||||
|
||||
onStep = function(game, ow, x, y)
|
||||
if baseMtMoonStep and baseMtMoonStep(game, ow, x, y) then
|
||||
return true
|
||||
end
|
||||
local f = game.save.flags
|
||||
if x ~= 3 or y ~= 5 then return false end
|
||||
if f.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES then return false end
|
||||
if not (f.EVENT_GOT_DOME_FOSSIL or f.EVENT_GOT_HELIX_FOSSIL) then
|
||||
return false
|
||||
end
|
||||
ow.runner:run({
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||
{ "show_text", "_MtMoonJessieJamesText1" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
-- MtMoonB2FScript_49e15 simulates PAD_UP for one player step, then
|
||||
-- Script6/Script9 walk Jessie (object 2, MovementData_f9e65: six
|
||||
-- $06) and James (object 6, f9e66: five $06); both objects' movement
|
||||
-- byte 2 is LEFT, so .determineDirection makes those LEFT steps --
|
||||
-- Jessie (9,3)->(3,3) above the player at (3,4), James (9,4)->(4,4)
|
||||
-- beside him. #423
|
||||
{ "walk_npc", "player", { "up" } },
|
||||
{ "walk_npc", 2, { "left", "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 2, "down" },
|
||||
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 6, "left" },
|
||||
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||
-- MtMoonB2FScript12 arms _MtMoonJessieJamesText3 with
|
||||
-- SaveEndBattleTextPointers before it sets wCurOpponent, so
|
||||
-- TrainerBattleVictory prints it on the battle screen as "ROCKET: A
|
||||
-- brat beat us?" between TrainerDefeatedText and MoneyForWinningText.
|
||||
-- Its one-word first line only reads right behind that tag (#866).
|
||||
{ "save_end_battle_text", "_MtMoonJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
|
||||
}, {})
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Rocket Hideout B4F (RocketHideoutB4FScript_455a5..Script13): the
|
||||
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
|
||||
-- (24,10) and whichever of them shares the player's column ($18=24 or
|
||||
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
|
||||
-- tiles down to loom over the player while the other walks four and ends
|
||||
-- up beside him. A loss
|
||||
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
|
||||
-- trigger re-arms clean.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.ROCKET_HIDEOUT_B4F = {
|
||||
talk = {
|
||||
TEXT_ROCKETHIDEOUTB4F_JESSIE = {
|
||||
{ "face_player" }, { "show_text", "_RocketHideoutJessieJamesText1" },
|
||||
},
|
||||
TEXT_ROCKETHIDEOUTB4F_JAMES = {
|
||||
{ "face_player" }, { "show_text", "_RocketHideoutJessieJamesText1" },
|
||||
},
|
||||
},
|
||||
|
||||
onStep = function(game, ow, x, y)
|
||||
local f = game.save.flags
|
||||
if y ~= 14 or (x ~= 24 and x ~= 25) then return false end
|
||||
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
|
||||
-- ON_LEFT: player under James's column (25); movement data pairs
|
||||
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
|
||||
-- column-mate walks 3, the other 4.
|
||||
local onLeft = (x == 25)
|
||||
ow.runner:run({
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText1" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||
-- James (object 2) then Jessie (object 3), Script4..Script9 order.
|
||||
-- RocketHideoutB4FJessieJamesMovementData_45605 is a lone $4 that FALLS
|
||||
-- THROUGH into _45606 ($4 $4 $4 $ff), so MoveSprite_ (home/pathfinding.asm)
|
||||
-- reads _45605 as FOUR steps and _45606 as three; $4 is DOWN in Yellow's
|
||||
-- Func_5288 lookup (engine/overworld/movement.asm), which walks with no
|
||||
-- collision test. From (25,10)/(24,10) against a player on y=14 the
|
||||
-- column-mate stops three down, right above him, and the other walks the
|
||||
-- full four to stand alongside -- which is what the facings below assume.
|
||||
-- Reading _45605 as a single step stranded whoever was off-column three
|
||||
-- tiles away, so James never reached the player (#865).
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
|
||||
or { "down", "down", "down", "down" } },
|
||||
{ "face_object", 2, onLeft and "down" or "left" },
|
||||
{ "walk_npc", 3, onLeft and { "down", "down", "down", "down" }
|
||||
or { "down", "down", "down" } },
|
||||
{ "face_object", 3, onLeft and "right" or "down" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText2" },
|
||||
-- RocketHideoutB4FScript10 saves _RocketHideoutJessieJamesText3 as the
|
||||
-- end-battle text, so it prints as "ROCKET: Such a dreadful twerp!" on
|
||||
-- the battle screen ahead of MoneyForWinningText (#866).
|
||||
{ "save_end_battle_text", "_RocketHideoutJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "lost" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "lost" },
|
||||
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||
}, {})
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Pokemon Tower 7F (PokemonTower7FScript_60d2a..Script10): same beat
|
||||
-- one floor below Fuji, except the duo pops in BEFORE the motto and
|
||||
-- Jessie ($a=10 column) leads the walk-down; ON_LEFT ($b=11) hands the
|
||||
-- three-tile walk to James instead. On a loss vanilla only resets the
|
||||
-- script counter (the blackout warp reloads the map anyway).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.POKEMON_TOWER_7F = {
|
||||
talk = {
|
||||
TEXT_POKEMONTOWER7F_JESSIE = {
|
||||
{ "face_player" }, { "show_text", "_PokemonTowerJessieJamesText1" },
|
||||
},
|
||||
TEXT_POKEMONTOWER7F_JAMES = {
|
||||
{ "face_player" }, { "show_text", "_PokemonTowerJessieJamesText1" },
|
||||
},
|
||||
},
|
||||
|
||||
onStep = function(game, ow, x, y)
|
||||
local f = game.save.flags
|
||||
if y ~= 12 or (x ~= 10 and x ~= 11) then return false end
|
||||
if f.EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES then return false end
|
||||
local onLeft = (x == 11) -- EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT
|
||||
ow.runner:run({
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JESSIE" },
|
||||
{ "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JAMES" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText1" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
-- Jessie (object 1) then James (object 2), Script1..Script6 order.
|
||||
-- Same fall-through blob as the hideout: PokemonTower7FMovementData_60d7a
|
||||
-- is a lone $4 running into _60d7b ($4 $4 $4 $FF), so _60d7a is FOUR
|
||||
-- steps and _60d7b is three. From (10,8)/(11,8) against a player on
|
||||
-- y=12 the column-mate halts one tile above him and the other closes the
|
||||
-- full four to his side; the single-step reading is why James only
|
||||
-- "moved a bit" here (#865).
|
||||
{ "walk_npc", 1, onLeft and { "down", "down", "down", "down" }
|
||||
or { "down", "down", "down" } },
|
||||
{ "face_object", 1, onLeft and "right" or "down" },
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
|
||||
or { "down", "down", "down", "down" } },
|
||||
{ "face_object", 2, onLeft and "down" or "left" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText2" },
|
||||
-- PokemonTower7FScript7 saves _PokemonTowerJessieJamesText3 as the
|
||||
-- end-battle text: "ROCKET: You will regret this!" on the battle screen,
|
||||
-- before the prize money (#866).
|
||||
{ "save_end_battle_text", "_PokemonTowerJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
{ "hide_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JESSIE" },
|
||||
{ "hide_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JAMES" },
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES" },
|
||||
}, {})
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Silph Co 11F (SilphCo11FScript_6229c..Script14): the only site where
|
||||
-- the duo starts VISIBLE (toggleable_objects.asm keeps SILPHCO11F_JAMES
|
||||
-- / _JESSIE ON), flanking Giovanni at (2,8)/(3,8), so they are talkable
|
||||
-- before the ambush (SilphCo11FJessieJamesText = the full motto). The
|
||||
-- trigger is the top row (y=3, x<4); EVENT_780/EVENT_781 pick one of
|
||||
-- three approach paths (SilphCo11FMovementData_622f5..62311, $5=up
|
||||
-- $6=left) that route James then Jessie up to the player without ever
|
||||
-- crossing the player's tile. Their duo text lives in
|
||||
-- text/SilphCo10F.asm (_SilphCoJessieJamesText*).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.SILPH_CO_11F = {
|
||||
talk = {
|
||||
TEXT_SILPHCO11F_JESSIE = {
|
||||
{ "face_player" }, { "show_text", "_SilphCoJessieJamesText1" },
|
||||
},
|
||||
TEXT_SILPHCO11F_JAMES = {
|
||||
{ "face_player" }, { "show_text", "_SilphCoJessieJamesText1" },
|
||||
},
|
||||
},
|
||||
|
||||
onStep = function(game, ow, x, y)
|
||||
if baseSilph11Step and baseSilph11Step(game, ow, x, y) then
|
||||
return true
|
||||
end
|
||||
local f = game.save.flags
|
||||
if y ~= 3 or x > 3 then return false end
|
||||
if f.EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES then return false end
|
||||
-- x==3 -> base path, x==2 -> EVENT_780 variant, x<=1 -> EVENT_781
|
||||
local jamesDirs, jamesFace, jessieDirs, jessieFace
|
||||
if x == 3 then
|
||||
jamesDirs, jamesFace = { "up", "up", "up", "up", "up" }, "right"
|
||||
jessieDirs, jessieFace = { "up", "up", "up", "up" }, "up"
|
||||
elseif x == 2 then
|
||||
jamesDirs, jamesFace = { "up", "up", "up", "up" }, "up"
|
||||
jessieDirs, jessieFace = { "up", "up", "up", "up", "up" }, "left"
|
||||
else
|
||||
jamesDirs = { "up", "up", "left", "up", "up" }
|
||||
jamesFace = "up"
|
||||
jessieDirs = { "up", "up", "up", "left", "up", "up" }
|
||||
jessieFace = "left"
|
||||
end
|
||||
ow.runner:run({
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "show_text", "_SilphCoJessieJamesText1" },
|
||||
{ "face_player_dir", "down" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
-- James (object 4) then Jessie (object 6), Script5..Script10 order
|
||||
{ "walk_npc", 4, jamesDirs },
|
||||
{ "face_object", 4, jamesFace },
|
||||
{ "walk_npc", 6, jessieDirs },
|
||||
{ "face_object", 6, jessieFace },
|
||||
{ "show_text", "_SilphCoJessieJamesText2" },
|
||||
-- SilphCo11FScript11 saves _SilphCoJessieJamesText3 (SilphCo11FText_624c2)
|
||||
-- as the end-battle text: "ROCKET: Like always..." before the money (#866).
|
||||
{ "save_end_battle_text", "_SilphCoJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_SilphCoJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
{ "hide_object", "SILPH_CO_11F", "SILPHCO11F_JAMES" },
|
||||
{ "hide_object", "SILPH_CO_11F", "SILPHCO11F_JESSIE" },
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES" },
|
||||
}, {})
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,152 @@
|
||||
-- The Viridian City old-man catch tutorial, Yellow's way
|
||||
-- (pokeyellow scripts/ViridianCity.asm, scripts/ViridianCity_2.asm,
|
||||
-- scripts/OaksLab.asm). Registered on top of the shared tables by
|
||||
-- data/scripts/init.lua on a Yellow boot.
|
||||
--
|
||||
-- Yellow has TWO gambler objects where Red/Blue have one:
|
||||
-- * VIRIDIANCITY_OLD_MAN at (17,5) -- a Red/Blue leftover; its
|
||||
-- toggle stays OFF forever in Yellow, no script ever shows it.
|
||||
-- * VIRIDIANCITY_OLD_MAN2 at (18,9) -- replaces the sleeper the
|
||||
-- moment the Pokédex is given (OaksLabOakGivesPokedexScript:
|
||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2).
|
||||
--
|
||||
-- This is the tutorial old man. The Red/Blue "Are you in a hurry?"
|
||||
-- yes/no script must NOT run against him: Yellow's
|
||||
-- _ViridianCityOldManHadMyCoffeeNowText is the apology speech ("I've had
|
||||
-- my coffee now ... I'll show you how to catch POKéMON as my apology"),
|
||||
-- and the shared story.lua TEXT_VIRIDIANCITY_OLD_MAN rows hang an
|
||||
-- invented yes/no over it -- YES printed the TimeIsMoney alias
|
||||
-- (_ViridianCityOldManLosingMyTouchText) and NO ran the demo, every
|
||||
-- talk, forever (#617).
|
||||
--
|
||||
-- The real flow (ViridianCityCheckWaitingOldMan + ViridianCityOldMan2Text
|
||||
-- + ViridianCityOldManInitialCatchTrainingScript + ...EndInitial... +
|
||||
-- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap
|
||||
-- east of the sleeper's cell -- faces the old man right and the player
|
||||
-- left, prints the apology, and without any choice runs the demo battle
|
||||
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5), which he FAILS -- the ball shakes
|
||||
-- three times and breaks open. After it, the same text pointer
|
||||
-- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work!
|
||||
-- I must be losing my touch."), the old man walks off (down 6 with the
|
||||
-- player on (19,9), right 1 otherwise, Pikachu nudged out of the way
|
||||
-- first) and TOGGLE_OLD_MAN_2 hides. A direct talk does the same.
|
||||
|
||||
local M = {}
|
||||
|
||||
local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
|
||||
|
||||
-- Capture the FUNCTION, not the table: attachBase stores the module
|
||||
-- table itself, so once this file's onStep is attached the table's slot
|
||||
-- points back here -- delegating through the table would self-recurse.
|
||||
-- story5's VIRIDIAN_CITY.onStep chains story.lua's sleeping-old-man
|
||||
-- gate and its own gym-lock step (same pattern as yellow_jessie_james).
|
||||
local baseViridianStep = require("data.scripts.story5").VIRIDIAN_CITY.onStep
|
||||
|
||||
-- pokeyellow text/ViridianCity.asm, _ViridianCityOldManHadMyCoffeeNowText
|
||||
-- and _ViridianCityOldManLosingMyTouchText, spelled with the extractor's
|
||||
-- markers (line -> \n, cont -> \v, para -> \f)
|
||||
local function text(game)
|
||||
return {
|
||||
apology = game.data.text._ViridianCityOldManHadMyCoffeeNowText
|
||||
or "Ahh, I've had my\ncoffee now and I\vfeel great!\fSure, you can go\n"
|
||||
.. "through!\fI'm sorry I was\nso rude to you!\fI see you're using\n"
|
||||
.. "a POKéDEX.\fI'll show you how\nto catch POKéMON\vas my apology.",
|
||||
losingMyTouch = game.data.text._ViridianCityOldManLosingMyTouchText
|
||||
or "That didn't work!\nI must be losing\vmy touch.\fI've run out of\n"
|
||||
.. "POKé BALLs too.\fI have to get some\nat POKéMON MART.",
|
||||
}
|
||||
end
|
||||
|
||||
-- The row list for the initial-tutorial branch, keyed by where the
|
||||
-- player stands when the battle ends (ViridianCityPostInitialCatchTraining
|
||||
-- reads wXCoord: (19,9) walks the old man down the corridor, anywhere
|
||||
-- else walks him right 1 after moving the follower Pikachu aside).
|
||||
local function oldMan2Rows(game, ow, npc)
|
||||
local rows = {
|
||||
{ "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
|
||||
-- ViridianCityOldManInitialCatchTrainingScript sets
|
||||
-- EVENT_INITIAL_CATCH_TRAINING before the battle runs, and
|
||||
-- ItemUseBall's .oldManBattle branch turns that event into anim data
|
||||
-- $63: three shakes, then the ball breaks open. The losing-my-touch
|
||||
-- line below only follows a throw that failed (#636).
|
||||
{ "old_man_demo", "fail" },
|
||||
{ "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" },
|
||||
{ "show_text", "_ViridianCityOldManLosingMyTouchText" },
|
||||
}
|
||||
if ow.player and ow.player.cellX == 19 then
|
||||
rows[#rows + 1] =
|
||||
{ "walk_npc", npc.def.index,
|
||||
{ "down", "down", "down", "down", "down", "down" } }
|
||||
else
|
||||
-- ViridianCityMovePikachu (scripts/ViridianCity_2.asm): Pikachu
|
||||
-- steps out of the old man's way before he turns right
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local pika = ow and PikachuFollower.current(ow)
|
||||
if pika then
|
||||
rows[#rows + 1] = { "walk_npc", pika.def.index, { "right" } }
|
||||
end
|
||||
rows[#rows + 1] = { "walk_npc", npc.def.index, { "right" } }
|
||||
end
|
||||
rows[#rows + 1] =
|
||||
{ "hide_object", "VIRIDIAN_CITY", OLD_MAN2 }
|
||||
return rows
|
||||
end
|
||||
|
||||
-- The shared talk handler: TEXT_VIRIDIANCITY_OLD_MAN2's text_asm branch
|
||||
-- (ViridianCityOldMan2Text) on EVENT_COMPLETED_CATCH_TRAINING.
|
||||
local function oldMan2Talk(game, ow, npc, done)
|
||||
if game.save.flags and game.save.flags.EVENT_COMPLETED_CATCH_TRAINING then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
|
||||
return
|
||||
end
|
||||
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
TEXT_VIRIDIANCITY_OLD_MAN2 = oldMan2Talk,
|
||||
},
|
||||
|
||||
-- Re-apply the Pokédex swap for a save that already holds the flag but
|
||||
-- was never standing here when it fired (converted .sav imports, same
|
||||
-- shape as story.lua's VIRIDIAN_CITY.onEnter). Yellow shows OLD_MAN2,
|
||||
-- not the Red/Blue OLD_MAN at (17,5), and also puts away a stray
|
||||
-- OLD_MAN a save made by the pre-#617 build left standing.
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
|
||||
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
|
||||
Commands.show_object(ctx, "VIRIDIAN_CITY", OLD_MAN2)
|
||||
end,
|
||||
|
||||
-- ViridianCityCheckWaitingOldMan: with the Pokédex held and the
|
||||
-- tutorial undone, (19,9) -- the gap east of the old man, the same
|
||||
-- cell the sleeper used to gate -- faces him right, turns the player
|
||||
-- left and starts the OLD_MAN2 flow with no choice.
|
||||
onStep = function(game, ow, x, y)
|
||||
if baseViridianStep and baseViridianStep(game, ow, x, y) then
|
||||
return true
|
||||
end
|
||||
local flags = game.save.flags
|
||||
if not flags.EVENT_GOT_POKEDEX then return false end
|
||||
if flags.EVENT_COMPLETED_CATCH_TRAINING then return false end
|
||||
if x ~= 19 or y ~= 9 then return false end
|
||||
local man
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == OLD_MAN2 then man = npc break end
|
||||
end
|
||||
if not man then return false end
|
||||
man.facing = "right"
|
||||
ow.player.facing = "left"
|
||||
oldMan2Talk(game, ow, man, nil)
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,98 @@
|
||||
# Anbernic RG34XXSP (Stock OS 64-bit MOD)
|
||||
|
||||
Download `gen1recomp-*-rg34xxsp-stockos64-mod.zip` from
|
||||
[Releases](https://github.com/bryanthaboi/gen1recomp/releases). This build
|
||||
targets **Stock OS 64-bit MOD** on the RG34XXSP with PortMaster installed
|
||||
(TF1).
|
||||
|
||||
## Install
|
||||
|
||||
1. Unzip the release on your computer. You get `Gen1recomp.sh` and a
|
||||
`gen1recomp/` folder.
|
||||
2. Copy **both** onto the SD card under **`Roms/PORTS/`** so the layout is:
|
||||
|
||||
```
|
||||
Roms/PORTS/Gen1recomp.sh
|
||||
Roms/PORTS/gen1recomp/
|
||||
```
|
||||
|
||||
On the device that path is `/mnt/mmc/Roms/PORTS/`. Keep the launcher and
|
||||
the `gen1recomp/` folder as siblings — do not nest the `.sh` inside the
|
||||
folder.
|
||||
3. Put your legal US Red and/or Blue `.gb` files inside the game folder:
|
||||
|
||||
```
|
||||
Roms/PORTS/gen1recomp/lovegame/
|
||||
```
|
||||
|
||||
Example: `Roms/PORTS/gen1recomp/lovegame/Pokemon - Red Version.gb`
|
||||
4. Eject the card, boot the handheld, open **Ports → Gen1recomp**.
|
||||
5. On the launcher, move the cursor with the D-pad or left stick, press **A**
|
||||
to click. Choose the Red or Blue tab, then **Choose ROM** — with no file
|
||||
picker on stock OS, that scans `lovegame/` for the `.gb` you dropped in.
|
||||
|
||||
The port ships with `portable.txt` already in place, so after import the
|
||||
saves and ROM-derived cache stay next to the game on the SD card. See
|
||||
[Portable Mode](../README.md#portable-mode) for what that means.
|
||||
|
||||
Only the canonical 1 MiB US carts import:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
|
||||
## Launcher controls
|
||||
|
||||
| Input | Action |
|
||||
| ------------------ | ------------------ |
|
||||
| D-pad / left stick | Move cursor |
|
||||
| A | Click |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Right stick | Scroll lists |
|
||||
| Start / Select | Play or Choose ROM |
|
||||
|
||||
In-game controls use the normal PortMaster / SDL pad map, rebindable under
|
||||
**OPTIONS → CONTROLS**.
|
||||
|
||||
## Notes
|
||||
|
||||
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
|
||||
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
|
||||
level carried over in an `options.lua` from another machine. The H700's Mali
|
||||
GPU is in the same class as the phone GPUs that compile that present pass and
|
||||
then show a black frame (issue #136), and `love.system.getOS()` reports
|
||||
`"Linux"` here, so the Android gate would not have caught it. Every other
|
||||
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
|
||||
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
||||
put the row back.
|
||||
|
||||
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
|
||||
to AUTO, which reads this device as an ARM Linux handheld and resolves to
|
||||
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
|
||||
so the overworld runs smoothly on the H700 out of the box. Bump it to
|
||||
BALANCED or HIGH from OPTIONS if you want the extras and your device keeps
|
||||
up; see [Performance tier](new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
The pack bundles the LÖVE 11.5 aarch64 runtime from
|
||||
[PortMaster](https://portmaster.games/), so the device does not need a
|
||||
separate `love_11.5` runtime download on first launch. The launcher resolves
|
||||
paths relative to its own directory rather than PortMaster's `$directory`,
|
||||
because stock Anbernic firmware runs ports out of `roms/PORTS/` with its own
|
||||
casing and mount points.
|
||||
|
||||
If a launch fails, `Roms/PORTS/gen1recomp/log.txt` holds the output of the
|
||||
last run.
|
||||
|
||||
## Building the port
|
||||
|
||||
Release runs build this automatically (see `.github/workflows/release.yml`).
|
||||
To build it by hand:
|
||||
|
||||
```sh
|
||||
./build-rg34xxsp.sh --version 0.1.0 # -> dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
|
||||
```
|
||||
|
||||
`install-rg34xxsp.sh` copies that pack straight onto a mounted SD card for
|
||||
local testing.
|
||||
|
||||
Stock OS 64-bit MOD comes from
|
||||
[cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
|
||||
@@ -38,7 +38,7 @@ the same core data and graphics into the source tree for verification.
|
||||
| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
|
||||
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling |
|
||||
| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring |
|
||||
| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing |
|
||||
| | `src/render/SpriteRenderer.lua` | variable-size anchored sprite sheets, 6-frame walkers and flipped right facing |
|
||||
| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) |
|
||||
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
|
||||
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
|
||||
@@ -59,7 +59,7 @@ the same core data and graphics into the source tree for verification.
|
||||
| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI |
|
||||
| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` |
|
||||
| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists |
|
||||
| | `tools/save-editor/` | Standalone save editor (`love . --editor`) |
|
||||
| | `tools/save-editor/` | Save editor: shipped in every build, opened from the launcher's Edit button or standalone with `love . --editor` |
|
||||
|
||||
## Map scripts
|
||||
|
||||
|
||||
@@ -1,515 +0,0 @@
|
||||
# Behavior porting notes
|
||||
|
||||
What was ported from pokered's engine code and where it came from.
|
||||
|
||||
## Overworld
|
||||
|
||||
- **Collision rule** (`home/overworld.asm` tile-in-front checks): a 16x16
|
||||
cell is passable when its bottom-left 8x8 tile is in the tileset's
|
||||
`coll_tiles` list. Verified against Pallet Town's fences/houses/water
|
||||
and Oak's Lab furniture.
|
||||
- **Warp activation** (`home/overworld.asm` CheckWarpsNoCollision /
|
||||
ExtraWarpCheck): a warp fires when arriving on a warp whose standing
|
||||
tile is in the tileset's door or warp tile list, or when standing on a
|
||||
warp and walking off the map edge (interior exit mats). Both paths are
|
||||
data-driven from `door_tile_ids.asm` / `warp_tile_ids.asm`.
|
||||
- **LAST_MAP warps** return to the remembered outdoor map/position, like
|
||||
`wLastMap`.
|
||||
- **Connections** (`map_header` connection directives): crossing an edge
|
||||
places the player at `destCoord = curCoord - offset*2` cells on the
|
||||
destination's opposite edge.
|
||||
- **Movement**: tile-by-tile, 1 px/frame at 60 fps (16 frames per step),
|
||||
tap-to-turn without stepping, hold-to-walk, input locked mid-step.
|
||||
- **Wild encounters** (`engine/battle/wild_encounters.asm`): per grass
|
||||
step, encounter iff `rand(0..255) < rate`; slot picked via the
|
||||
cumulative buckets 51/102/141/166/191/216/229/242/253/256.
|
||||
- **Initial object visibility** from `toggleable_objects.asm` (e.g. Oak
|
||||
hidden in his lab), with `show_object`/`hide_object` script commands
|
||||
persisting to the save like the missable-object bits.
|
||||
|
||||
## Pokémon math (`engine/pokemon/calc_stats.asm`, `experience.asm`)
|
||||
|
||||
- `stat = floor(((base + DV)*2 + floor(sqrt(statExp)/4)) * L / 100) + 5`
|
||||
(HP: `+ L + 10`); HP DV from the low bits of the other four DVs.
|
||||
- Growth curves use the exact cubic coefficients (MEDIUM_SLOW =
|
||||
1.2n^3 - 15n^2 + 100n - 140, etc).
|
||||
- Exp gain = `floor(baseExp * level / 7)` (x1.5 for trainer battles);
|
||||
defeated species' base stats accumulate as stat experience.
|
||||
|
||||
## Battle core (`engine/battle/core.asm`)
|
||||
|
||||
- Damage: `floor(floor(2L(x2 crit)/5 + 2) * power * atk / def / 50)`
|
||||
capped at 997, `+2`, STAB x1.5, per-matchup type multipliers applied
|
||||
sequentially (x10 fixed point), then `rand(217..255)/255` when
|
||||
damage > 1.
|
||||
- Critical hits: `rand(0..255) < baseSpeed/2` (x4 for Karate Chop, Razor
|
||||
Leaf, Crabhammer, Slash, capped 255); crits double level and ignore
|
||||
stat stages (gen1_faithful ruleset).
|
||||
- Accuracy: `rand(0..255) < floor(acc*255/100)` after accuracy/evasion
|
||||
stages, including the 1/256 miss at 100% accuracy (toggleable via the
|
||||
`modern_clean` ruleset).
|
||||
- Stat stages use the 25/28/33/40/50/66/100/150/.../400 multiplier table
|
||||
(`data/battle/stat_modifiers.asm`).
|
||||
- Physical/special split by type (special = Water/Grass/Fire/Ice/
|
||||
Electric/Psychic/Dragon).
|
||||
- Status: paralysis speed/4 and 25% full para, burn halves physical
|
||||
attack, poison/burn residual = maxHP/16, sleep 1-7 turns waking on the
|
||||
lost turn, freeze permanent (as in Gen 1).
|
||||
- Turn order: effective speed, coin-flip ties; Quick Attack first,
|
||||
Counter last (Gen 1's only priorities).
|
||||
- Run formula (`TryRunningFromBattle`): always escape if faster,
|
||||
otherwise `floor(pSpd*32 / (eSpd/4)) + 30*attempts` vs `rand(0..255)`.
|
||||
- Catching (`ItemUseBall`): ball-specific rand ranges (255/200/150),
|
||||
status bonus 25/12, second roll `floor(maxHP*255/ballFactor) /
|
||||
floor(HP/4)` capped 255.
|
||||
- Prize money: class base money x last defeated mon's level
|
||||
(`pic_pointers_money.asm`).
|
||||
|
||||
## Battle move effects (engine/battle/core.asm, move_effects/*)
|
||||
|
||||
- Mimic via Metronome (effects.asm:1203-1273): MimicEffect's
|
||||
.letPlayerChooseMove branch snapshots wCurrentMenuItem before the
|
||||
copy-picker menu opens and restores it afterward as the write index
|
||||
into wBattleMonMoves. Since SelectMenuItem always writes
|
||||
wCurrentMenuItem/wPlayerMoveListIndex together at the FIGHT-menu
|
||||
confirm and nothing (including MetronomePickMove) touches either
|
||||
variable during mid-move resolution, the reused value is always the
|
||||
calling move's own slot, BattleState.lua's applyMimic fallback uses
|
||||
self.moveIndex, frozen the same way, so a called Mimic (e.g. from
|
||||
METRONOME in slot 3) overwrites the calling move's own slot, keeping
|
||||
its PP, matching the Gen 1 quirk exactly.
|
||||
- Multi-hit distribution 2/2/2/3/3/3/4/5 over rand(0..7); all hits reuse
|
||||
the first damage roll (faithful).
|
||||
- Recoil = damage/4 (Struggle /2); drain/Dream Eater heal = damage/2;
|
||||
Dream Eater requires sleep.
|
||||
- Fixed damage: SonicBoom 20, Dragon Rage 40, Seismic Toss/Night Shade =
|
||||
level, Psywave rand(1 .. 1.5xlevel-1).
|
||||
- OHKO deals 65535, fails against faster targets; Swift skips accuracy;
|
||||
Jump Kick crash = 1 damage on miss; Explosion halves defense and
|
||||
faints the user even on a miss; Hyper Beam skips recharge if it KOs.
|
||||
- Charge moves (incl. Fly's invulnerable turn), trapping moves locking
|
||||
the victim out of its turns, Thrash's 3-4 turn lock ending in
|
||||
confusion, Bide's 2-3 turn store-and-double, Rage's permanent lock
|
||||
with attack-up on being hit, Counter/Quick Attack priority.
|
||||
- Side-effect chances: 26/256 (10%), 77/256 (30%), stat-down side
|
||||
effects 85/256; Twineedle 20% poison.
|
||||
- Substitute costs 1/4 max HP, absorbs damage, blocks status/stat/side
|
||||
effects; screens double effective defense (bypassed by crits); Focus
|
||||
Energy keeps the Gen 1 quarter-rate bug under gen1_faithful.
|
||||
- Status: sleep 1-7 turns (wake turn is lost), freeze permanent, burn
|
||||
halves physical attack, paralysis speed/4 + 25% full para, Toxic's
|
||||
rising counter, Leech Seed transfer, confusion 2-5 turns with 50%
|
||||
40-power typeless self-hit.
|
||||
- Trainer Pokémon use fixed DVs 9/8/8/8 (TrainerAI.asm convention).
|
||||
|
||||
## Items (engine/items/item_effects.asm)
|
||||
|
||||
- Potion family 20/50/200/full; drinks 50/60/80; status heals per item;
|
||||
Revive half HP; Rare Candy = exact next-level exp with HP delta kept;
|
||||
evolution stones use the extracted evos data; TMs single-use / HMs
|
||||
reusable, gated by the species' real tmhm list; Repel 100/200/250
|
||||
steps blocking wilds below the lead's level; Escape Rope returns to
|
||||
the last heal point.
|
||||
- Snorlax (Route 12/16) only wakes via `ItemUsePokeFlute` (item-use
|
||||
menu, adjacent to it, not yet beaten), talking to it with the POKé
|
||||
FLUTE merely in the bag has no effect (`engine/items/item_effects.asm`,
|
||||
`scripts/Route12.asm`/`Route16.asm`).
|
||||
- Mart inventories come from the script_mart lists per clerk; selling
|
||||
pays half price; TM prices from tm_prices.asm.
|
||||
|
||||
## Overworld field systems
|
||||
|
||||
- Ledges from ledge_tiles.asm (facing + standing tile + ledge tile +
|
||||
input direction -> two-cell hop).
|
||||
- Counter talk-through uses the tileset's counter tiles
|
||||
(tileset_headers.asm), which is how mart clerks and nurses work.
|
||||
- Trainer sight (`home/trainers.asm` CheckFightingMapTrainers +
|
||||
`engine/overworld/trainer_sight.asm`): extracted per-trainer range,
|
||||
inclusive tiles along the facing line; detection runs only on
|
||||
tile-aligned frames, before input handling, so on detection the d-pad
|
||||
is dead (wJoyIgnore) and the player freezes on the spotted tile; the
|
||||
"!" holds 60 frames (EmotionBubble), then the trainer walks
|
||||
distance−1 steps to the adjacent tile (none if already adjacent) and
|
||||
uses the real battle/won/after dialogue from the trainer headers.
|
||||
Sight is a pure screen-coordinate comparison with no line-of-sight
|
||||
obstruction check (TrainerEngage / CheckSpriteCanSeePlayer): an
|
||||
aligned in-range trainer engages through interposed NPCs and
|
||||
unwalkable tiles, and the walk-up (TrainerWalkUpToPlayer, a fixed
|
||||
distance−1 MoveSprite_ script) has no collision either, so the
|
||||
trainer simply walks/overlaps through anything on the line, as OAM
|
||||
sprites overlap on hardware.
|
||||
- Elevator rides (`engine/overworld/elevator.asm` ShakeElevator →
|
||||
`src/world/ElevatorShake.lua`): choosing a floor stops the music,
|
||||
bounces the BG scroll ±1 px around rest for 100 two-frame cycles with
|
||||
SFX_COLLISION retriggered every cycle, restores the scroll, plays
|
||||
SFX_SAFARI_ZONE_PA to completion, and restarts the map theme before
|
||||
the floor warp. Lead-in delays kept per script: 9 frames of Delay3s
|
||||
inside ShakeElevator (Celadon farjps in), 12 with the Silph/Rocket
|
||||
scripts' extra Delay3. The offset applies to the BG layer only,
|
||||
sprites are OAM and stay put. After the ride the port no longer
|
||||
jump-cuts: choosing a floor rewrites the car's own exit-warp entries
|
||||
to that floor (`engine/events/elevator.asm` DisplayElevatorFloorMenu
|
||||
.UpdateWarp, per scripts/SilphCoElevator.asm /
|
||||
CeladonMartElevator.asm / RocketHideoutElevator.asm), then the player
|
||||
is walked out through the doorway onto that warp (ow:scriptMove →
|
||||
ow:takeWarp), like the original.
|
||||
- Field-move gates (engine/overworld/field_move_messages.asm +
|
||||
start_sub_menus.asm): IsSurfingAllowed ported exactly, SURF refuses
|
||||
with _CyclingIsFunText while the Cycling Road's BIT_ALWAYS_ON_BIKE is
|
||||
armed (save.forcedBike: set on the Route 16/18 forced-bike tiles,
|
||||
cleared by the gates, Fly, dungeon/blackout warps; the forced mount
|
||||
itself is silent, as in CheckForceBikeOrSurf) and with
|
||||
_CurrentTooFastText on Seafoam B4F's stairs square (7,11) until both
|
||||
EVENT_SEAFOAM4 boulders are down. Re-selecting SURF while surfing is
|
||||
ItemUseSurfboard's dismount attempt: steps ashore silently if the
|
||||
facing tile is land-passable and unoccupied, else "There's no place
|
||||
to get off!", and the menu closes either way (wActionResult stays 1).
|
||||
STRENGTH's first page auto-advances after the cry + Delay3 (no
|
||||
prompt); "can move boulders." prompts. The GBPalWhiteOutWithDelay3
|
||||
white blink plays on every .goBackToMap closer: Strength, surf
|
||||
mount/dismount/no-place, Flash (after its text), and Dig/Teleport
|
||||
(Cut closes without a blink, per the asm).
|
||||
- Wild slot table + rate per map; water encounter tables used while
|
||||
surfing.
|
||||
- Cut-tree block swaps from cut_tree_blocks.asm; surfable tilesets from
|
||||
water_tilesets.asm (water tile $14, plus $32 on SHIP_PORT).
|
||||
|
||||
## Story events (data/scripts/story.lua and friends)
|
||||
|
||||
- Every hand-ported script cites its scripts/*.asm source and reuses the
|
||||
real extracted text and event-flag names.
|
||||
- Custom flag names (audited equivalent): three port-internal flag
|
||||
families have no pokered EVENT constant but mirror the original's
|
||||
state exactly. EVENT_TRADED_* are per-trade names for
|
||||
wCompletedInGameTradeFlags bits (engine/events/in_game_trades.asm:
|
||||
FLAG_TEST before the offer → after-trade text, FLAG_SET on completion;
|
||||
dialogset text families, party-menu pick, the received mon joins the
|
||||
end of the party, ConnectCable→anim→TradedFor→Thanks all ported).
|
||||
EVENT_GOT_EEVEE is bookkeeping alongside the real guard, the hidden
|
||||
ball object (scripts/CeladonMansionRoofHouse.asm HideObject, ≡
|
||||
save.objectToggles), and self-heals older saves; a full party+box
|
||||
keeps the ball claimable (_BoxIsFullText). EVENT_BEAT_SS_ANNE_RIVAL
|
||||
stands in for scripts/SSAnne2F.asm's saved wSSAnne2FCurScript NOOP
|
||||
progression, including the lose-and-retrigger path (flag only set on
|
||||
victory). Names are kept for save compatibility. Coverage:
|
||||
tests/parity_trade_gift.lua.
|
||||
- The Pallet Town intro follows pokered exactly: the trigger is
|
||||
PalletTownDefaultScript's wYCoord==1 check, Oak appears at (8,5) and
|
||||
takes FindPathToPlayer's zigzag to one tile below the player, and the
|
||||
escort is RLEList_ProfOakWalkToLab against the reverse-order playback
|
||||
of RLEList_PlayerWalkToLab (the 17th simulated press is eaten by the
|
||||
door-warp frame), followed by the OaksLab walk-in and choose-mon
|
||||
exchange with map music deferred like BIT_NO_MAP_MUSIC. Oak's speech
|
||||
ends with the real shrink: RedPicFront collapses through the extracted
|
||||
ShrinkPic1/ShrinkPic2 into the overworld walking sprite on
|
||||
OakSpeech.asm's frame timings (SFX_SHRINK, 4/4/20/50-frame beats, fade
|
||||
to white), with the closing text box held on screen. The escort's
|
||||
scripted steps run 16 frames/tile (chained single-tile scriptMoves
|
||||
start back-to-back, no idle frame); Oak marches in place on the door
|
||||
mat for RLEList_ProfOakWalkToLab's trailing NPC_CHANGE_FACING beat
|
||||
(movement.asm ChangeFacingDirection → zero-delta TryWalking); the "!"
|
||||
EmotionBubble overlaps the still-shown "Hey! Wait!" box
|
||||
(PalletTownOakText prints without a button wait, then DelayFrames 10 →
|
||||
EmotionBubble before the box clears); and the shrink beat ramps the
|
||||
music to silence over ~70 frames (wAudioFadeOutControl = 10;
|
||||
home/fade_audio.asm FadeOutAudio steps rAUDVOL 7→0) rather than
|
||||
hard-stopping.
|
||||
- The 12 disguised static wild battles (Power Plant Voltorb/Electrode +
|
||||
Zapdos, Articuno, Moltres, Mewtwo) follow TalkToTrainer/
|
||||
EndTrainerBattle exactly: cry + battle text, after-battle text without
|
||||
a rematch once EVENT_BEAT_* is set, and the flag/HideObject on any
|
||||
non-blackout result (fleeing loses the legendary, as in Gen 1).
|
||||
Snorlax hides before its battle and only shows the calmed-down/
|
||||
returned line when not caught. Zapdos/Articuno/Moltres/Mewtwo's
|
||||
battle text is a text_far string ending in a bare "...@" terminator
|
||||
(no <DONE>/<PROMPT>) followed by text_asm PlayCry + WaitForSoundToFinish:
|
||||
the box types with no ▼ prompt and auto-closes only once the cry
|
||||
finishes, never on a button press, ported via `Commands.play_cry`
|
||||
stashing the pending cry for the following `Commands.show_text` to
|
||||
consume as the TextBox's auto-close sound. Voltorb/Electrode's battle
|
||||
text has no PlayCry call in the ROM at all and keeps the ordinary
|
||||
button-wait close.
|
||||
- Gym leader repeat dialogue (data/scripts/gyms.lua): each leader's
|
||||
text_asm branches on EVENT_BEAT_<LEADER>, pre-badge talk prints the
|
||||
pre-battle text and engages the leader battle (badge/TM via
|
||||
data/scripts/victories.lua); post-badge talk prints the leader's
|
||||
post-battle advice text (Misty's is her TM11 explanation). The
|
||||
originals' middle branch (beaten but TM not handed over) is
|
||||
unreachable since the TM is granted with the victory. Giovanni's
|
||||
farewell (`ViridianGymGiovanniText` .afterBeat) hides him inside a
|
||||
fade-to-black/fade-in Transition matching ViridianGym.asm's
|
||||
GBFadeOutToBlack → HideObject → GBFadeInFromBlack, persisted
|
||||
permanently via TOGGLE_VIRIDIAN_GYM_GIOVANNI in save.objectToggles.
|
||||
- Cable Club receptionists (TX_SCRIPT_CABLE_CLUB_RECEPTIONIST →
|
||||
CableClubNPC, all 12 Pokémon Centers): welcome, pre-Pokédex "making
|
||||
preparations" brush-off, and the apply/save YES-NO are ported;
|
||||
accepting saves the game and opens the link menu, declining prints
|
||||
"Please come again!".
|
||||
- Cinnabar fossil deposit follows GiveFossilToCinnabarLab: a menu of
|
||||
carried fossils (FossilsList order), SeesFossilText with a Yes/No
|
||||
confirm, ComeAgainText on either cancel.
|
||||
- Hall of Fame induction: each party mon's front sprite scrolls in from
|
||||
the left at 4px/frame, matching HoFShowMonOrPlayer's .ScrollPic
|
||||
front-pic phase (engine/movie/hall_of_fame.asm); the back-pic's
|
||||
enlarged/blurred pre-wipe is a VRAM-scroll-register trick not
|
||||
replicated in this sprite-based renderer. The finale
|
||||
(HoFDisplayPlayerStats) shows trainer name, play time, money, POKéDEX
|
||||
seen/owned, and Prof. Oak's rating text (engine/events/
|
||||
pokedex_rating.asm DexRatingsTable) from real save data.
|
||||
- End credits + post-game reset (engine/movie/credits.asm,
|
||||
scripts/HallOfFame.asm): screen-by-screen CreditsOrder pages (hlcoord
|
||||
9,6 + signed columns), FadeInCredits' 4x5-frame ramp, 90/110/120/140-
|
||||
frame holds, DisplayCreditsMon's 27-frame 8px/frame silhouette wipe,
|
||||
LoadCopyrightTiles' three-row block, THE END at (4,8). While THE END
|
||||
is up the HoF script autosaves (wLastBlackoutMap := PALLET_TOWN; the
|
||||
player is saved in the HALL_OF_FAME room), waits 600 frames, then A/B
|
||||
triggers `jp Init`, the boot sequence replays into the title screen.
|
||||
- Victory Road's boulder switches replicate the original's
|
||||
ReplaceTileBlock data: 1F boulder at (17,13) -> block $1D at (4,6);
|
||||
2F boulders at (1,16)/(9,16) -> $15 at (3,4) and $1D at (11,7); 3F
|
||||
boulder at (3,5) -> $1D at (3,5), and the (23,15) hole drops the
|
||||
boulder to 2F (hide/show toggle). Barriers are re-applied from flags
|
||||
on map entry, exactly like the originals' map-load scripts.
|
||||
- Item balls, static legendary encounters and trainer rewards
|
||||
(badges + gym TMs, the Silph Giovanni flag) are generic systems driven
|
||||
by the extracted object args and a hand-ported reward table
|
||||
(data/scripts/victories.lua).
|
||||
- In-game trades use the real data/events/trades.asm table (species in,
|
||||
species out, original nickname).
|
||||
|
||||
## Safari game (engine/events/hidden_events/safari_game.asm + engine/battle)
|
||||
|
||||
- ¥500 buys 30 SAFARI BALLs and 502 steps (scripts/SafariZoneGate.asm
|
||||
sets `wSafariSteps = 502`); steps count down on the four outdoor zone
|
||||
maps and hitting 0 (or throwing the last ball) ends the game at the
|
||||
gate.
|
||||
- Safari battles offer BALL / BAIT / ROCK / RUN; no player Pokémon
|
||||
acts. The working catch rate starts at the species rate; BAIT halves
|
||||
it and adds 1-5 to the bait factor (zeroing the escape factor); ROCK
|
||||
doubles it (cap 255) and adds 1-5 to the escape factor (zeroing bait)
|
||||
-- ItemUseBait/ItemUseRock in engine/items/item_effects.asm.
|
||||
- Each turn one factor decays ("is eating!" / "is angry!"); when the
|
||||
escape factor decays to 0 the catch rate resets to the species rate
|
||||
(PrintSafariZoneBattleText, engine/battle/safari_zone.asm).
|
||||
- Flee check (engine/battle/core.asm): `b = 2 * (speed % 256)`; the mon
|
||||
always flees when speed > 127; while eating `b /= 4`, while angry
|
||||
`b = min(255, 2b)`; it flees when `rand(0,255) < b`.
|
||||
- The SAFARI BALL rolls the ULTRA_BALL rand range (0-150) in the Gen 1
|
||||
catch formula, against the BAIT/ROCK-modified rate.
|
||||
|
||||
## Slot machines (engine/slots/slot_machine.asm)
|
||||
|
||||
- The three reels are the extracted 18-symbol wheel sequences
|
||||
(data/events/slot_machine_wheels.asm); bet 1 plays the middle row,
|
||||
bet 2 adds top+bottom, bet 3 adds both diagonals.
|
||||
- Payouts: 7-7-7 = 300, BAR = 100, CHERRY = 8, MOUSE/FISH/BIRD = 15
|
||||
(SlotRewardPointers).
|
||||
- Per-wheel stop/slip rules ported exactly: wheel 1 spends up to 4 slip
|
||||
charges, slipping past a centred CHERRY (in seven-and-bar mode it
|
||||
always slips all 4 via pokered's `cp HIGH(SLOTS7)` bug); wheel 2 stops
|
||||
as soon as wheels 1+2 line up any potential match (pairs checked b/b,
|
||||
b/m, m/m, t/m, t/t) or, in seven-and-bar mode, on 7/BAR; wheel 3 rolls
|
||||
past forbidden matches free and burns wSlotMachineRerollCounter
|
||||
charges on winnable no-match spins, animated tile-by-tile. Luck flags
|
||||
(SetFlags): seven-and-bar mode is sticky across spins; r==0 arms 60
|
||||
allow-matches charges; a BAR win clears flags; a 300 win zeroes the
|
||||
counter and clears flags with probability 128/256; 8/15 wins burn one
|
||||
charge. Lines are checked in asm order with the first match taken;
|
||||
A-presses are ignored while a prior wheel's slip counter is nonzero.
|
||||
Machine and COIN CASE texts are byte-identical
|
||||
(_GameCorner*Text; AbleToPlaySlotsCheck's no-coins gate included).
|
||||
- Flow brackets: PromptUserToPlaySlots "A slot machine! Want to play?"
|
||||
(YesNoChoice) and MainSlotMachineLoop's "One more go?" (TwoOptionMenu);
|
||||
the x3/x2/x1 coin menu (CoinMultiplierSlotMachineText) defaults its
|
||||
cursor to x3, bet = 3 - menu item. Static frame: the real
|
||||
SlotMachineMap (gfx/slots/slots.tilemap, 20x12 tile ids < $25) blitted
|
||||
from red_slots_1.png, extracted as field.slotSymbols.tilemap
|
||||
(tools/extract/gfx.py extract_slots). Win flash:
|
||||
SlotMachine_CheckForMatches.flashScreenLoop flips rBGP (shade 3->2) b
|
||||
times at 5 frames each, b = 20/8/4/2 for the 300/100/15/8 rewards
|
||||
(SlotReward{300,100,8,15}Func). Payout drip:
|
||||
SlotMachine_PayCoinsToPlayer credits one coin every 8 frames (4 for a
|
||||
7/BAR), SFX_SLOTS_REWARD per coin, rOBP0 symbol flicker every 5 coins.
|
||||
|
||||
## Spinner arrow tiles (scripts/*.asm arrow movement tables)
|
||||
|
||||
- Viridian Gym and Rocket Hideout B2F/B3F keep per-coordinate RLE
|
||||
movement lists (map_coord_movement); each list executes backwards
|
||||
from its terminator (DecodeArrowMovementRLE), sliding the player and
|
||||
chaining onto further arrows.
|
||||
|
||||
## Cries (data/pokemon/cries.asm, audio/engine_1.asm)
|
||||
|
||||
- Each species = a base cry (one of 38 SFX_CryXX streams) + a frequency
|
||||
modifier added to every note's frequency register
|
||||
(Audio1_ApplyFrequencyModifier) + a tempo modifier
|
||||
(`sfx tempo = $80 + length`, Audio1_SetSfxTempo). All 151 cries are
|
||||
rendered offline with those modifiers applied and play on battle
|
||||
entry and Pokédex pages.
|
||||
|
||||
## Hidden events & facility puzzles
|
||||
|
||||
- Card key doors (engine/events/card_key.asm): door tiles $18/$24
|
||||
(SILPH_CO_11F: $5e) replaced with block $0e ($03 on 11F).
|
||||
- Vermilion trash cans
|
||||
(engine/events/hidden_events/vermilion_gym_trash.asm): the first-lock
|
||||
can re-rolls on every Vermilion City map load (VermilionCity_Script's
|
||||
Random & $e, even cans) and after every failed second-can guess; the
|
||||
second lock uses the GymTrashCans table verbatim, including the
|
||||
underflow bug that can place it in can 0 regardless of adjacency; a
|
||||
wrong pick resets EVENT_1ST_LOCK_OPENED and re-rolls immediately; only
|
||||
SuccessText3 prints on completion; the gym door block at (2,2) is
|
||||
$24 closed / $5 open (scripts/VermilionGym.asm). SuccessText1/
|
||||
SuccessText3/FailText play SFX_SWITCH/GO_INSIDE/DENIED from each
|
||||
text's text_asm tail after the text prints (DisplayTextID's
|
||||
WaitForTextScrollButtonPress then holds the box), so the port fires
|
||||
them from an onDone on the TextBox, landing the beep as the box
|
||||
closes rather than as it opens.
|
||||
- Menu close-keys follow pokered's per-menu wMenuWatchedKeys mask, not
|
||||
a single global rule: the shared Menu base (src/ui/Menu.lua) closes
|
||||
on B only, and START-close is opt-in via opts.startCloses. Only the
|
||||
start menu sets it, matching engine/menus/draw_start_menu.asm's
|
||||
PAD_DOWN|PAD_UP|PAD_START|PAD_B|PAD_A; OptionsMenu also closes on
|
||||
START via its own loop, matching engine/menus/main_menu.asm
|
||||
DisplayOptionMenu's explicit B_PAD_B/B_PAD_START checks. Every other
|
||||
menu (bag/PC item lists PAD_A|PAD_B|PAD_SELECT, party menu /
|
||||
BUY-SELL-QUIT / USE-TOSS submenu / PC menus / Pokedex side menu
|
||||
PAD_A|PAD_B) leaves PAD_START unwatched, so START does not close
|
||||
them. START never replays SFX_PRESS_AB (HandleMenuInput_ beeps only
|
||||
for the PAD_A|PAD_B branch).
|
||||
- Old man tutorial hollow cursor: the item list is itself scripted in
|
||||
pokered (DisplayListMenuID's old-man branch, home/list_menu.asm:65-91)
|
||||
, no input is read; the filled '▶' hovers POKé BALL for 80 frames,
|
||||
auto-presses A, then PlaceUnfilledArrowMenuCursor leaves the hollow
|
||||
'▷' on that row until ItemUseBall tears the list down for the throw.
|
||||
Ported via ListMenu's opts.script hook (src/ui/ListMenu.lua) and
|
||||
BattleState:openOldManBag driving the same beats. The MissingNo./
|
||||
wGrassRate side effects of the OLD MAN name swap are not modeled,
|
||||
see docs/gameboy-hardware-limitations.md.
|
||||
- Gym statues (gym_statues.asm): plaque with the city/leader from each
|
||||
gym's script; the player joins WINNING TRAINERS with the badge.
|
||||
- Route 22 gate / Route 23 guards: real trigger rows, badge order
|
||||
(EARTH down to CASCADE) and EVENT_PASSED_*_CHECK skip flags.
|
||||
- Game Corner poster (scripts/GameCorner.asm): block (8,2) $2a -> $43
|
||||
on EVENT_FOUND_ROCKET_HIDEOUT.
|
||||
- Seafoam Islands (scripts/SeafoamIslandsB3F/B4F.asm): reversed-RLE
|
||||
current paths, Seafoam4HolesCoords boulder holes setting the
|
||||
EVENT_SEAFOAM*_BOULDER*_DOWN_HOLE pairs, the forced pool exit rows.
|
||||
- Rock Tunnel darkness: wMapPalOffset = 6 on entry, cleared by Flash
|
||||
(BOULDERBADGE) or leaving (home/overworld.asm).
|
||||
|
||||
## Battle extras
|
||||
|
||||
- GROWL/ROAR (GetMoveSound/IsCryMove, engine/battle/animations.asm
|
||||
~2196): the move's own MoveSoundTable tempo byte (Growl $c0, Roar
|
||||
$40, both pitch $00) layers onto the cry via `Sound.playMoveCry`'s
|
||||
`Source:setPitch(256/(128+tempoMod))`. Transform (engine/gfx/
|
||||
palettes.asm DeterminePaletteID, bit TRANSFORMED): the swapped-in pic
|
||||
is tinted PAL_GRAYMON via `PaletteFX.monPal(data, species,
|
||||
transformed)`, not the copied species' own palette, in
|
||||
`BattleState:speciesSprite`. Growl (DoGrowlSpecialEffects,
|
||||
animations.asm ~928): AnimPlayer's GROWL frame-block branch keeps a
|
||||
`growlNoteTrail` snapshot so each block's emitted sprites include the
|
||||
previous block's note copy alongside the current one (GROWL skips
|
||||
AnimationCleanOAM between blocks per the `cp GROWL` check ~line 145);
|
||||
ROAR is unaffected since the asm never applies this quirk to it.
|
||||
- Master/Ultra ball tosses flicker the OBJ palette: DoBallTossSpecial
|
||||
Effects (engine/battle/animations.asm:685) XORs rOBP0 with %00111100
|
||||
after every frame block while wCurItem <= ULTRA_BALL, so the 11 toss
|
||||
blocks alternate the $F0/$CC shade maps starting normal; PlayAnimation
|
||||
pushes/pops rOBP0 around each subanimation row, so the ambient
|
||||
palette returns when the toss ends. GREAT/POKE/SAFARI balls never
|
||||
flicker, and the toss arc always follows wCurItem via
|
||||
TossBallAnimation, including the ghost-dodge throw.
|
||||
- Anim-layer OBJ colorization is per 8x8 attribute cell: the SGB's
|
||||
ATTR_BLK regions color the composited DMG picture per cell, not per
|
||||
OAM entry, so an anim sprite overlapping a zone boundary takes each
|
||||
cell's palette on the pixels inside it, AnimPlayer samples the zone
|
||||
under every cell an 8x8 tile touches and repaints differing cells
|
||||
through a cell-clipped scissor (aligned tiles stay one draw).
|
||||
- Ball wobbles (ItemUseBall): Z = X*Y/255 + status2 with
|
||||
Y = rate*100/ballFactor2; <10/<30/<70 -> 0/1/2 shakes, else 3, with
|
||||
the matching ItemUseBallText01-04 lines.
|
||||
- Trainer class AI (data/trainers/ai_pointers.asm +
|
||||
engine/battle/trainer_ai.asm): per-class item/switch routines with
|
||||
wAICount uses per Pokémon, ported to data/scripts/ai_classes.lua.
|
||||
- Exp (engine/battle/experience.asm): baseExp*level/7 divided by the
|
||||
participant count, x1.5 for trainers, x1.5 for traded mons; stat exp
|
||||
in full to each participant.
|
||||
- Move sounds: data/moves/sfx.asm (sound + pitch/tempo per move). The
|
||||
pitch/tempo modifiers are applied at synthesis time
|
||||
(Audio2_ApplyFrequencyModifier adds pitch to every frequency write;
|
||||
Audio2_SetSfxTempo scales tone-channel note lengths, noise skips it),
|
||||
128 variant WAVs keyed "<sfx>@<pitch><tempo>" that Sound.playMove
|
||||
selects, exact rather than a playback-rate approximation. Per-row
|
||||
sounds fire as PlayAnimation does; GROWL/ROAR (IsCryMove) play the
|
||||
attacker's cry. Hit sounds by effectiveness (Damage/Super/NotVery).
|
||||
- Screen-effect animations (engine/battle/animations.asm +
|
||||
engine/gfx/screen_effects.asm): every SE_* is implemented per-routine,
|
||||
FlashScreen/FlashScreenLong (the FlashScreenLongSGB 12-entry table),
|
||||
Dark/Light/DarkenMon/Reset palette ops (shade-map permutations of the
|
||||
SGB zone palettes), all SlideMon variants, ShakeBackAndForth,
|
||||
BoundUpAndDown, SquishMonPic, Minimize (real MinimizedMonSprite),
|
||||
spiral/shoot-balls/water-droplets/leaves emitters compiled from the
|
||||
asm trajectories, per-animation-id frame-block flashes (Explosion,
|
||||
Rock Slide's rumbles, Blizzard's cadence...), AnimationWavyScreen with
|
||||
true per-scanline offsets, PredefShakeScreenHorizontally/Vertically
|
||||
and ShakeEnemyHUD. SE rows carry the faithful blocking durations.
|
||||
- SGB battle colorization (SetPal_Battle, BlkPacket_Battle,
|
||||
SetAnimationPalette): the battle screen is colorized by zone, player
|
||||
HUD, enemy HUD, player mon + message box, enemy mon; trainer front
|
||||
pics and the player/old-man back pics take PAL_MEWMON (both species
|
||||
IDs are zero at the intro, so MonsterPalettes[0]); the ghost keeps the
|
||||
disguised species' palette; attack animation sprites and thrown balls
|
||||
are colored through the OBJ palettes (wAnimPalette $F0 on SGB, ambient
|
||||
$E4, OBP1 $6C). Headless/no-shader environments fall back to the flat
|
||||
pipeline.
|
||||
- Mimic resolves mid-move (MimicEffect): accuracy first, then the
|
||||
player's copy menu (enemy/link copy a random slot); the copy
|
||||
overwrites only the slot's move ID, PP is shared with Mimic's slot,
|
||||
and reverts on switch/battle end.
|
||||
- Old man tutorial (DisplayBattleMenu's BATTLE_TYPE_OLD_MAN branch): the
|
||||
real scripted cursor, ▶ beside FIGHT for 80 frames, beside ITEM for
|
||||
50, ITEM force-selected into the POKé BALL x50 list; the throw always
|
||||
catches at full HP (item_effects.asm jumps straight to .captured, 3
|
||||
shakes, no party/dex add, no ball consumed); backing out of the bag
|
||||
replays the script. The old man never attacks, the original tutorial
|
||||
is menu navigation + a guaranteed catch, nothing more.
|
||||
|
||||
## Link battles (lockstep)
|
||||
|
||||
- Both sides simulate with a shared Park-Miller RNG stream (host deals
|
||||
the seed), identical pack/unpack-clamped party copies, no badge
|
||||
boosts, and a mirrored speed-tie roll (the guest inverts it); a
|
||||
canonical host-side-first state hash is exchanged per turn and any
|
||||
mismatch ends the match as a draw.
|
||||
|
||||
## Music (audio/engine_1.asm)
|
||||
|
||||
- Note duration: `frames = length * speed * tempo / 0x100` with
|
||||
fractional carry, at 60 fps (Audio1_note_length / CalculateDelay).
|
||||
- Frequency: `reg = pitches[note] asr (octave - 1)` (CalculateFrequency;
|
||||
the octave byte stores `8 - octave`), `f = 131072/(2048 - reg)` for
|
||||
squares, halved for channel 3.
|
||||
- note_type volume/fade renders as an NRx2-style envelope (step every
|
||||
`fade/64` s); duty_cycle maps to 12.5/25/50/75% pulse widths;
|
||||
sound_call/sound_loop honor the engine's one-level call stack and
|
||||
loop counters.
|
||||
|
||||
## Text & font
|
||||
|
||||
- The Pokédex height row uses the real ′/″ tiles: gfx/pokedex/pokedex.png
|
||||
tiles 0/1 are patched over font-extra slots $60/$61 exactly as
|
||||
engine/gfx/load_pokedex_tiles.asm loads them over vChars2 (they replace
|
||||
glyphs charmap.asm marks unused); ASCII `"` aliases to the closing-
|
||||
quote glyph $73 so stray hand-written quotes render.
|
||||
|
||||
## Validation against the original
|
||||
|
||||
- `tests/run_tests.lua` pins hand-checked values: L5 Bulbasaur 19 HP /
|
||||
9 Atk at 0 DVs, L100 Mewtwo 415 HP / 406 Spc at max DVs+statExp,
|
||||
MEDIUM_SLOW(5) = 135, type chart spot checks, deterministic damage
|
||||
rolls, Route 1 slot 1 = L3 Pidgey.
|
||||
- The autopilot run reproduces the original's early flow on real map
|
||||
data: Pallet sign text, lab door warp target (5,11), Oak's Lab exit by
|
||||
walking off the mat, connection into Route 1 at matching x.
|
||||
@@ -1,131 +0,0 @@
|
||||
# Building a Blue Version of the port
|
||||
|
||||
pokered builds Red and Blue from one source tree: the Makefile
|
||||
assembles everything twice with `rgbasm -D _RED` or `-D _BLUE`, and
|
||||
every in-game difference sits in an `IF DEF(_RED)` / `IF DEF(_BLUE)`
|
||||
block. Our extraction pipeline resolves those conditionals the same
|
||||
way (`tools/extract/util.py` `ASM_DEFINES`), so most of a Blue build
|
||||
falls out of re-running the extractors. Only three things are
|
||||
hand-ported on the Lua side and need swapping by hand.
|
||||
|
||||
## What differs between Red and Blue
|
||||
|
||||
| Where (pokered) | What |
|
||||
| --- | --- |
|
||||
| `data/wild/maps/*.asm` (34 files) | Version-exclusive encounters (Red: Ekans, Oddish, Growlithe, Mankey, Scyther, Electabuzz, Blue: Sandshrew, Bellsprout, Vulpix, Meowth, Pinsir, Magmar) |
|
||||
| `data/pokemon/title_mons.asm` | The 16 title-screen Pokémon |
|
||||
| `engine/movie/title.asm` + `gfx/version.asm` | The "Red Version" / "Blue Version" ribbon |
|
||||
| `constants/player_constants.asm` | Preset names (Red: RED/ASH/JACK + BLUE/GARY/JOHN) |
|
||||
| `data/sgb/sgb_palettes.asm`, `sgb_border.asm` | Super Game Boy palettes and border |
|
||||
| `data/events/prizes.asm`, `prize_mon_levels.asm` | Game Corner prize mons, costs and levels |
|
||||
| `engine/movie/intro.asm`, `data/credits/credits_text.asm`, `engine/slots/slot_machine.asm`, `engine/battle/animations.asm`, `audio/sfx/save_3.asm` | Small gated tweaks (credits say "BLUE VERSION STAFF", etc.) |
|
||||
|
||||
## Step 1, flip the extraction define
|
||||
|
||||
`tools/extract/util.py`:
|
||||
|
||||
```python
|
||||
ASM_DEFINES = {"_RED"} # -> {"_BLUE"}
|
||||
```
|
||||
|
||||
Then regenerate everything (same as scripts/setup.sh does):
|
||||
|
||||
```sh
|
||||
cd tools
|
||||
../.venv/bin/python3 build_data.py \
|
||||
--pokered /Users/bryanbassett/Documents/development/pokered \
|
||||
--out ../data/generated --assets ../assets/generated
|
||||
```
|
||||
|
||||
This alone switches the wild encounters, preset names, SGB palettes
|
||||
and credits text. **Caveat:** `parse_preset_names` sanity-checks in
|
||||
`tools/extract/field.py:1193` expect "RED" in the player presets,
|
||||
relax that check for a Blue build (Blue's presets are BLUE/GARY/JOHN
|
||||
for the player and RED/ASH/JACK for the rival).
|
||||
|
||||
`tools/extract/palettes.py` uses its own raw reader with hardcoded
|
||||
`IF DEF(_BLUE)` skipping (around line 44), invert that too, or port
|
||||
it to `read_asm` so `ASM_DEFINES` covers it.
|
||||
|
||||
## Step 2, title screen (hand-ported)
|
||||
|
||||
`src/ui/TitleState.lua`:
|
||||
|
||||
1. **Ribbon art.** The extractor writes
|
||||
`assets/generated/title/red_version.png`; add `blue_version.png`
|
||||
to `gfx.extract_title` in `tools/extract/gfx.py` (source:
|
||||
`gfx/title/blue_version.png`, 64×8). In `TitleState:draw()`, the
|
||||
Red strip needs two quads (tiles 0–1 "Red", skip, tiles 5–9
|
||||
"Version", `title.asm` `VersionOnTitleScreenText`). Blue's strip
|
||||
prints its tiles contiguously (`db $61..$68`), so draw the whole
|
||||
64×8 image at px (56, 64) and verify with the title driver
|
||||
screenshot.
|
||||
|
||||
2. **Title mons.** Replace `CYCLE_SPECIES` with Blue's list from
|
||||
`data/pokemon/title_mons.asm`:
|
||||
|
||||
```lua
|
||||
local CYCLE_SPECIES = {
|
||||
"SQUIRTLE", "CHARMANDER", "BULBASAUR", "MANKEY", "HITMONLEE",
|
||||
"VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX",
|
||||
"GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU",
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3, Game Corner prizes (hand-ported)
|
||||
|
||||
`data/scripts/story3.lua` (~line 209) carries the Red prize tables.
|
||||
Blue's values (`prizes.asm` + `prize_mon_levels.asm`):
|
||||
|
||||
| Prize | Cost | Level |
|
||||
| --- | --- | --- |
|
||||
| ABRA | 120 | 6 |
|
||||
| CLEFAIRY | 750 | 12 |
|
||||
| NIDORINO | 1200 | 17 |
|
||||
| PINSIR | 2500 | 20 |
|
||||
| DRATINI | 4600 | 24 |
|
||||
| PORYGON | 6500 | 18 |
|
||||
|
||||
(TM prizes are identical in both versions.)
|
||||
|
||||
## Step 4, verify
|
||||
|
||||
```sh
|
||||
luajit tests/run_tests.lua
|
||||
```
|
||||
|
||||
Plus two spot checks:
|
||||
|
||||
```sh
|
||||
# every grass table must have exactly 10 slots (a conditional-parsing
|
||||
# regression shows up as 19–20 slots)
|
||||
luajit -e 'local e=dofile("data/generated/encounters.lua")
|
||||
for m,d in pairs(e) do if type(d)=="table" and d.grass and #d.grass.slots>0
|
||||
and #d.grass.slots~=10 then print("BAD",m,#d.grass.slots) end end'
|
||||
|
||||
# a Blue exclusive should now appear (and Growlithe should not)
|
||||
grep -c VULPIX data/generated/encounters.lua
|
||||
grep -c GROWLITHE data/generated/encounters.lua
|
||||
```
|
||||
|
||||
Title screenshot: run the driver in
|
||||
`tests/drivers/` style (`SHOT_DIR=... POKEPORT_DRIVER=... love .`) and
|
||||
eyeball the ribbon, title mon, and copyright row.
|
||||
|
||||
## What you get for free / what to skip
|
||||
|
||||
- Free after re-extraction: encounters (incl. Super Rod groups), SGB
|
||||
palettes, preset names, credits text, the gated sfx/animation
|
||||
tweaks.
|
||||
- Trades, gift Pokémon, story scripts, gym data: identical in Western
|
||||
Red/Blue, nothing to touch.
|
||||
- Save files: a Red save loads fine, but dex AREA nests and new
|
||||
encounters will be Blue's. Trainer parties are identical.
|
||||
|
||||
## Making it a runtime toggle instead
|
||||
|
||||
If you want one build with both versions, extraction would need to
|
||||
emit both branches keyed by version (e.g. `slots` / `slotsBlue`) and
|
||||
the three hand-ported spots would read a `save.version` or
|
||||
`conf.lua` flag. That is a bigger change than the rebuild above,
|
||||
the flip-and-regenerate route needs no engine changes at all.
|
||||
@@ -1,50 +0,0 @@
|
||||
# ROM Extraction Notes
|
||||
|
||||
There are two ROM-only extraction paths:
|
||||
|
||||
- The packaged app uses `src/import/RomImporter.lua` and
|
||||
`src/import/RomExtractor.lua` on first boot.
|
||||
- Developers can run `tools/build_data.py --rom <path> [--clean]` to generate
|
||||
data in the source tree for audit and parity work.
|
||||
|
||||
Both paths read only the supplied ROM and the checked-in
|
||||
`tools/rom_manifest.json`. Neither invokes RGBDS, Git, or a disassembly.
|
||||
|
||||
## Validation
|
||||
|
||||
Only the canonical US Pokemon Red ROM is supported. SHA-1 is checked before
|
||||
any cached output is removed or written.
|
||||
|
||||
## Decoded Data
|
||||
|
||||
| Area | ROM data |
|
||||
| --- | --- |
|
||||
| world | map headers, block maps, connections, warps, signs, objects |
|
||||
| tiles | tileset graphics, blocksets, collision, door and warp tile lists |
|
||||
| text | 2,584 text command streams and RAM/number substitutions |
|
||||
| Pokemon | names, stats, evolutions, learnsets, Dex data, compressed pictures |
|
||||
| battle | moves, detailed animations, OAM frames/tiles, effects, type chart, palettes, trainer parties/AI/pictures |
|
||||
| inventory | item names, prices, key-item flags, TM/HM data |
|
||||
| encounters | grass and water wild tables |
|
||||
| UI | fonts, icons, title/intro, trainer card, town map, slots, field effects |
|
||||
| audio | music, SFX and cry headers, channel programs, wave instruments |
|
||||
|
||||
The Python and Lua picture decompressors implement the Gen 1 `pic` format.
|
||||
Graphics are converted to RGBA PNGs. OAM artwork uses transparent color 0;
|
||||
battle pictures use edge-connected white matting so white interior details
|
||||
remain visible.
|
||||
|
||||
The in-app importer stores three audio ROM banks as a 48 KiB
|
||||
`programs.bin`. `src/core/ChipAudio.lua` interprets the channel bytecode and
|
||||
synthesizes music as a queueable stream; SFX and cries are synthesized on
|
||||
demand. This avoids shipping or generating a large WAV/OGG tree.
|
||||
|
||||
## Metadata Boundary
|
||||
|
||||
Names, dimensions, enum ordering, Lua script hooks, and hand-ported field
|
||||
behavior do not survive compilation in a form the Lua runtime can infer.
|
||||
Those relationships are bundled in `rom_manifest.json`. The manifest stores
|
||||
no dialogue strings, images, audio samples, or ROM bytes.
|
||||
|
||||
`tools/make_rom_manifest.py` and `tools/verify_rom_data.py` are developer audit
|
||||
tools. They are not used by the packaged game.
|
||||
@@ -12,7 +12,7 @@ this port.
|
||||
|
||||
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
|
||||
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
|
||||
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
|
||||
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
|
||||
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
|
||||
@@ -38,6 +38,10 @@ this port.
|
||||
|
||||
## Notes
|
||||
|
||||
- Mods may patch `constants.bagSize` through the public content registry. The
|
||||
native `save.lua` format keeps every existing item when the configured
|
||||
limit changes; exporting to a cartridge `.sav` still writes only the first
|
||||
20 bag slots because the original SRAM layout has no room for more.
|
||||
- PC Box **overflow handling** was deliberately changed even though the
|
||||
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
|
||||
or blocks the deposit," this port spills into the next box with room.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Build & install on your iPhone — step by step
|
||||
|
||||
Don't want to install Xcode? Download the release IPA and sideload it with
|
||||
AltStore instead — see [ios-sideload.md](ios-sideload.md).
|
||||
|
||||
This guide assumes **zero** programming experience. Follow it top to
|
||||
bottom and you'll have the game running on your own iPhone in roughly an
|
||||
hour (most of it is waiting for downloads).
|
||||
|
||||
## What you need
|
||||
|
||||
- A **Mac** (any Apple-silicon or recent Intel Mac on macOS 14 or newer)
|
||||
- An **iPhone** and its **charging cable**
|
||||
- A free **Apple ID** (the same account you use for the App Store)
|
||||
- Your **own, legally obtained** Pokémon Red or Blue ROM file (a 1 MB
|
||||
`.gb` file). This project ships no game data — the app rebuilds
|
||||
everything from *your* cartridge dump and verifies it before use.
|
||||
- About **15 GB free disk space** (Xcode is enormous; the game itself is
|
||||
tiny)
|
||||
|
||||
> **Free vs. paid Apple account:** a free Apple ID works. The only catch:
|
||||
> apps signed with a free account stop launching after **7 days** — just
|
||||
> re-run the install command (step 5) to re-sign; your save data is kept.
|
||||
> A paid Apple Developer account ($99/yr) extends that to a year.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Install Xcode (once)
|
||||
|
||||
1. On the Mac, open the **App Store**, search **Xcode**, click **Get**.
|
||||
It's a ~10 GB download — go make tea.
|
||||
2. Open Xcode once. Accept the license. If it offers to install extra
|
||||
components or the **iOS platform**, say yes and let it finish.
|
||||
|
||||
## Step 2 — Sign Xcode into your Apple ID (once)
|
||||
|
||||
1. In Xcode's menu bar: **Xcode → Settings → Accounts**.
|
||||
2. Click the **+** in the bottom-left → **Apple Account** → sign in.
|
||||
3. Close the window. (This quietly creates the "signing certificate" the
|
||||
build uses — you never have to touch it again.)
|
||||
|
||||
## Step 3 — Get this project onto the Mac
|
||||
|
||||
If you received it as a folder, put it somewhere easy like your home
|
||||
folder. If it's on GitHub, click the green **Code** button → **Download
|
||||
ZIP**, then double-click the zip to unpack it.
|
||||
|
||||
## Step 4 — Build it (one command)
|
||||
|
||||
1. Open the **Terminal** app (press ⌘-space, type `terminal`, press
|
||||
return).
|
||||
2. Type `cd ` (c, d, space — don't press return yet), then **drag the
|
||||
project folder** from Finder onto the Terminal window — it fills in
|
||||
the path — and press **return**.
|
||||
3. Paste this and press return:
|
||||
|
||||
```sh
|
||||
scripts/build_ios.sh --fetch
|
||||
```
|
||||
|
||||
The first run downloads the LÖVE engine and compiles everything
|
||||
(5–15 minutes). Lines of build output scrolling by is normal. You're
|
||||
done when you see **`==> done`**.
|
||||
|
||||
## Step 5 — Put it on your iPhone
|
||||
|
||||
1. Plug the iPhone into the Mac with the cable. **Unlock it** and keep it
|
||||
unlocked. If it asks **"Trust This Computer?" → Trust**.
|
||||
2. Paste this and press return:
|
||||
|
||||
```sh
|
||||
scripts/build_ios.sh --device --install
|
||||
```
|
||||
|
||||
The script finds your signing identity and your phone by itself. If it
|
||||
complains, it tells you exactly what to fix (usually: the phone was
|
||||
locked — unlock and re-run).
|
||||
|
||||
3. First time only, the iPhone will want two approvals:
|
||||
- **Developer Mode**: Settings → **Privacy & Security** → scroll to
|
||||
**Developer Mode** → turn on → restart the phone → confirm.
|
||||
- **Trust the developer**: Settings → **General** → **VPN & Device
|
||||
Management** → tap the entry under *Developer App* → **Trust**.
|
||||
|
||||
Then re-run the command in step 5.2 if the install had failed.
|
||||
|
||||
## Step 6 — Give it your ROM and play
|
||||
|
||||
1. Get your `.gb` file onto the phone — AirDrop it to yourself, or save
|
||||
it in iCloud Drive / Files.
|
||||
2. Open the app. On the **RED** tab, tap **Import ROM** and pick your
|
||||
`.gb` file in the file browser that appears. (Alternative: in the
|
||||
**Files** app, drop the `.gb` into **On My iPhone → the game's
|
||||
folder** and just reopen the app — it imports automatically.)
|
||||
3. Import takes ~10 seconds, the button turns into **Play Red** — tap it.
|
||||
|
||||
## Optional goodies
|
||||
|
||||
- **Mods**: launcher → **MODS** tab → **Import mod .zip**.
|
||||
- **Save import/export**: buttons on each game's tab, using the normal
|
||||
iOS file picker.
|
||||
|
||||
## When things go wrong
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| `xcodebuild not found` | Xcode isn't installed or wasn't opened once — do Step 1 |
|
||||
| `no Apple signing identity found` | Do Step 2, then re-run |
|
||||
| `no iPhone/iPad found` | Cable in? Phone unlocked? Tapped "Trust"? |
|
||||
| Install fails with "locked" | Unlock the phone, keep it unlocked, re-run |
|
||||
| App icon appears then won't open | Do the two approvals in Step 5.3 |
|
||||
| App stops launching after a week | Free-account 7-day limit — re-run Step 5.2 |
|
||||
| "That ROM could not be imported" | The file isn't a canonical 1 MB US Red/Blue dump — the importer checks its fingerprint |
|
||||
|
||||
Every build/install command is safe to re-run; your saves live on the
|
||||
phone and survive reinstalls.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Known differences from the original game
|
||||
|
||||
Only genuine remaining divergences live here: behavior that is still
|
||||
**missing, wrong, or approximated for convenience** and would need more
|
||||
work for true parity. Faithfully-ported behavior is documented in
|
||||
docs/behavior-porting-notes.md; deliberate additions beyond the original
|
||||
are in docs/new-features.md.
|
||||
|
||||
## Reimplemented unused Prof. Oak and Rocket Chief battles
|
||||
|
||||
The original ROM defines trainer data for `PROF_OAK` and `CHIEF`
|
||||
(`data/trainers/parties.asm`) but never attaches either to an NPC, so
|
||||
both battles are unreachable in the real game. This project makes them
|
||||
fightable after the Hall of Fame:
|
||||
|
||||
- Prof. Oak battles you in Pallet Town once `EVENT_BEAT_CHAMPION_RIVAL`
|
||||
is set, using `ProfOakData`'s three starter-matched teams (the team is
|
||||
picked by the type that counters your starter, mirroring the rival).
|
||||
- The Celadon Game Corner Chief battles you in his house post-game.
|
||||
`ChiefData` is empty in the ROM, so `OPP_CHIEF` is given a
|
||||
reconstructed party.
|
||||
|
||||
This is an intentional divergence: neither battle can be triggered in the
|
||||
original game.
|
||||
|
||||
## Reimplemented unused Silph Co. card-key doors
|
||||
|
||||
`engine/events/card_key.asm` and the unused `CardKeyTable1/2/3` coordinate
|
||||
lists (`data/events/card_key_coords.asm`) describe locked doors for Silph
|
||||
Co. floors 2F-11F, but no retail `.blk` map layout ever places the closed
|
||||
door block at those coordinates, so the card key check is dead code in
|
||||
the original game. This project stamps the closed door block (`$54`/`$5f`
|
||||
on floors 2F-10F, `$20` on 11F) over each of the 20 door coordinates on
|
||||
map load, and swaps it for the open block once that door's
|
||||
`EVENT_SILPH_CO_n_UNLOCKED_DOORn` flag is set (using the key from a Team
|
||||
Rocket grunt, as in the original's unused design).
|
||||
|
||||
This is an intentional divergence: the doors are not visible or
|
||||
functional in the original game. The door layout lives in
|
||||
`tools/rom_manifest.json` (`field.cardKeyDoors.closedDoors`), hand-ported
|
||||
since no retail ROM data encodes it; `src/import/RomExtractor.lua` copies
|
||||
it straight through on ROM import.
|
||||
@@ -1,163 +0,0 @@
|
||||
# Launcher
|
||||
|
||||
The launcher is `src/import/RomImporter.lua`, the first-run / title screen
|
||||
that runs before `Game:load`. Besides ROM import (see the file's own header)
|
||||
it hosts a tabbed shell covering per-game save slots and a mod manager. This
|
||||
file documents the runtime model; the visual spec lives separately.
|
||||
|
||||
## Android multi-ROM import
|
||||
|
||||
On Android, `love.system.pickFile()` opens the Storage Access Framework
|
||||
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
|
||||
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
|
||||
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
|
||||
to a version that is **not** yet ready counts as pending. A leftover
|
||||
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
|
||||
After a successful import the consumed save-dir `.gb` is removed.
|
||||
|
||||
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
|
||||
system file picker must appear (not a silent Red re-extract) → pick Blue →
|
||||
Blue becomes ready beside Red.
|
||||
|
||||
## Tab structure
|
||||
|
||||
`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar
|
||||
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
|
||||
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
|
||||
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
|
||||
|
||||
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
|
||||
Play button, and the SAVE SLOT card in a responsive two-column grid (see
|
||||
Responsiveness). The MODS tab (`_drawModsPanel`) shows the mod list instead.
|
||||
- The self-updater banner (`self.Check`, see `docs/updater.md`) draws as a
|
||||
centered pill in a reserved band just above the footer, on every tab. That
|
||||
position is unchanged by this redesign, so `docs/updater.md` needed no edits.
|
||||
|
||||
## Save slot model
|
||||
|
||||
All slot I/O lives in `src/core/SaveData.lua` and goes through the same fs
|
||||
abstraction (`persistFs`) every other save/options call uses, so portable
|
||||
mode (an `io.*` filesystem used when `portable.txt` marks the install)
|
||||
keeps working unchanged.
|
||||
|
||||
- **Files.** A version's playthroughs live under `saves/<version>/`, one file
|
||||
per slot: `saves/<version>/slot1.lua` plus a rolling `.bak` and staged
|
||||
`.tmp` witness (`slotNames`), mirroring the write/recovery discipline
|
||||
`SaveData.save`/`load` already use for the flat legacy file. Slot ids match
|
||||
`slot%d+`; `createSlot` allocates one past the highest existing number so a
|
||||
reused id can never collide with a lingering file.
|
||||
- **Registry.** The ordered slot list and which one is active persist in
|
||||
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
|
||||
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
|
||||
- **Active slot resolution.** `saveNames(version)`, the function every
|
||||
existing caller (`TitleState` hasSave/load/save, recovery order) already
|
||||
goes through, now resolves the *active* slot instead of a fixed flat name.
|
||||
Resolved once per version per process (`ensureVersionSlots`, cached in
|
||||
`activeSlotCache`/`slotsChecked`): a registry entry wins; otherwise a lazy
|
||||
legacy migration may create one; otherwise the flat legacy path is used
|
||||
(`save.lua` / `save_blue.lua`), so a pre-slots install keeps working as before.
|
||||
- **Legacy migration.** One-time per version, lazy on first
|
||||
`listSlots`/`load`/`saveNames` call (`tryMigrateLegacy`): if a flat legacy
|
||||
file exists and no `saves/<version>/` registry does, its main + `.bak` are
|
||||
copied into `saves/<version>/slot1.lua(.bak)`, verified readable
|
||||
(`decodeSlot`: main, then `.tmp`, then `.bak`), and only then are the
|
||||
originals removed and `slot1` registered as active. A copy that fails to
|
||||
verify leaves the originals in place; migration never loses data.
|
||||
|
||||
The launcher-facing API:
|
||||
- `SaveData.listSlots(version)` -> array of `{id, exists, name, meta}` for
|
||||
every registered slot. `name` is the save's player name, or `nil` for an
|
||||
empty slot; `meta` is `{badges, timeText, dexCount}` (the same fields the
|
||||
title screen's `ContinueInfo` shows) or `nil`. The pure part,
|
||||
`SaveData.slotSummary(save)`, is unit-testable with no filesystem.
|
||||
- `SaveData.setActiveSlot(version, slotId)` registers the id if new, persists
|
||||
it as active, and updates the process cache so the very next save/load
|
||||
lands there. The launcher calls this the moment a slot row is clicked
|
||||
(`RomImporter:_selectSlot`); pressing Play needs no signature change, since
|
||||
`Game.lua`/`main.lua` still just call `SaveData.load()`/`save()`.
|
||||
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
|
||||
save file written**. An empty slot means the title screen offers NEW GAME
|
||||
only, which needs no further changes.
|
||||
|
||||
## Launcher mod manager
|
||||
|
||||
`src/mods/LauncherMods.lua` is a launcher-only read of the mod set. It runs
|
||||
before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||
`manifest.json` is read and validated (`src/mods/Manifest.validate`), the way
|
||||
`Loader:_discover` finds mods without running them. The real loader
|
||||
(`src/mods/Loader.lua`) still owns the actual load at boot.
|
||||
|
||||
- `LauncherMods.list()` scans `mods/` one level deep (first id wins on a
|
||||
duplicate) and returns one row per mod:
|
||||
`{id, name, version, badge, description, enabled, status, statusDetail}`.
|
||||
`badge` is the manifest's `category`, falling back to `profile`, then
|
||||
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
|
||||
enabled, matching the loader's own default).
|
||||
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
|
||||
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
|
||||
and the validated manifests: `conflict` when enabling this mod collides
|
||||
with another enabled one; `warn` for an out-of-range `game_version` or an
|
||||
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
|
||||
`love.*` calls, this half is table-driven by the test suite on its own.
|
||||
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
|
||||
boolean, the exact shape `Loader:_saveState` writes, so the running game
|
||||
and the in-game `ManagerState` see the change on next boot. The mods panel
|
||||
calls this on every toggle and re-derives the list right away
|
||||
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
|
||||
shows without waiting for a reload.
|
||||
- `LauncherMods.installZip(path)` mounts the archive with
|
||||
`love.filesystem.mount`, locates the mod root via `locateRoot` (manifest at
|
||||
the zip root, or inside one top-level folder), validates its manifest, and
|
||||
copies the tree into the save-dir `mods/<id>/` before unmounting. Rejects a
|
||||
duplicate of an already-installed mod id, and accepts either an external
|
||||
path string or a LOVE `DroppedFile`, staging a dropped file into a save-dir
|
||||
temp first (mount only reaches save-dir-relative paths), the same way
|
||||
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
|
||||
back, and every path unmounts and clears the staged temp file.
|
||||
|
||||
## Import / Export save
|
||||
|
||||
The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
|
||||
through `src/import/SaveFileIO.lua`, which sits on top of
|
||||
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
|
||||
|
||||
- **Import save** is live once the game's ROM is imported (playable). It opens
|
||||
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`;
|
||||
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
|
||||
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
|
||||
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
|
||||
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
|
||||
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
|
||||
The meta stamp is re-stamped off `gen1_import` to the current numeric format
|
||||
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
|
||||
panel is refreshed with the new slot selected.
|
||||
- **Export save** is live only when the active slot actually holds a save
|
||||
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
|
||||
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
|
||||
`rawImport`, so this is a zero-filled template export, which is valid), and
|
||||
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
|
||||
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
|
||||
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
|
||||
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
||||
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
|
||||
currently active game tab; when a non-game tab (mods, or the locked yellow
|
||||
placeholder) is showing it defaults to red, the always-present first game
|
||||
(`_savedropTarget`). `.gb` (ROM) and `.zip` (mod) routing is unchanged.
|
||||
- **Failure UX.** Every error path (wrong size, bad checksum, write failure,
|
||||
nothing to export, ROM not imported yet) surfaces as a red notice line on the
|
||||
card. Nothing raises and nothing silently no-ops.
|
||||
|
||||
`SaveFileIO` is love-free enough to unit-test through the same in-memory
|
||||
filesystem stub the slot backend uses (`tests/engine/save_file_io_tests.lua`).
|
||||
|
||||
## Responsiveness
|
||||
|
||||
Every measurement derives from `love.graphics.getDimensions()` each frame
|
||||
plus the existing global scale `s = clamp(height / 768, 0.7, 1.6)`; nothing
|
||||
assumes a fixed window size. The game panel's two-column grid (ROM/SAVE
|
||||
FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked
|
||||
column, slot card below Play, when the window is too narrow for both
|
||||
`~300 * s`-wide columns. The save-slot list and the mod list both scroll
|
||||
(wheel, or drag on touch/desktop) clamped to their own content extent,
|
||||
recomputed every draw. The tab bar labels only the active chip so it stays
|
||||
narrow-safe, and content caps out at `~1440 * s` wide, centered.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Linux ARM SBC Handhelds (PortMaster)
|
||||
|
||||
Download `gen1recomp-*-sbc-portmaster.zip` from the [Gen1Recomp releases](https://github.com/bryanthaboi/gen1recomp/releases). This build targets 64-bit Linux ARM handhelds with PortMaster, including compatible H700 devices.
|
||||
|
||||
## Install
|
||||
|
||||
1. Unzip the release. It contains `gen1recomp-sbc.sh` and a `gen1recomp-sbc/` folder.
|
||||
2. Copy both as siblings into your device's PortMaster ports directory, commonly `Roms/Ports (PORTS)/` or `Roms/PORTS/`.
|
||||
3. Install PortMaster for your firmware and refresh the Ports list.
|
||||
4. Copy your legally owned canonical US Red or Blue `.gb` file into `gen1recomp-sbc/lovegame/`.
|
||||
5. Launch **gen1recomp-sbc** from Ports and choose the ROM.
|
||||
|
||||
The pack includes `portable.txt`, so saves and ROM-derived cache remain beside the game on the SD card. The build never ships ROM-derived bytes.
|
||||
|
||||
Canonical US cart SHA-1 values:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
|
||||
## Controls
|
||||
|
||||
| Input | Action |
|
||||
| --- | --- |
|
||||
| D-pad | Move cursor |
|
||||
| A | Click / confirm |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Start / Select | Play or choose ROM |
|
||||
|
||||
In-game controls use the normal PortMaster/SDL mapping and can be rebound in **OPTIONS → CONTROLS**.
|
||||
|
||||
## Runtime and suspend
|
||||
|
||||
The package bundles PortMaster's LÖVE 11.5 aarch64 runtime. The launcher sources `control.txt`, calls `get_controls`, applies an optional CFW override, invokes `pm_platform_helper`, and calls `pm_finish` on exit. Paths are relative to the launcher, allowing different firmware mount points.
|
||||
|
||||
Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device.
|
||||
|
||||
## Building
|
||||
|
||||
Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default:
|
||||
|
||||
```sh
|
||||
./build-linux-arm-sbc.sh --version 0.1.75
|
||||
```
|
||||
|
||||
For development, package a local checkout explicitly:
|
||||
|
||||
```sh
|
||||
GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version 0.1.0
|
||||
# or: ./build-linux-arm-sbc.sh --source "$PWD" --version 0.1.0
|
||||
```
|
||||
|
||||
The generated `port.json` records the source release tag. `install-linux-arm-sbc.sh` is a macOS helper for copying a built pack to a mounted SD card.
|
||||
|
||||
PortMaster device support and runtime integration are maintained in the [PortMaster](https://github.com/PortsMaster/PortMaster-New) ecosystem.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Linux arm64 (aarch64) AppImage
|
||||
|
||||
Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the
|
||||
existing x86_64 `gen1recomp-<version>-linux.zip`. It targets 64-bit ARM
|
||||
desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other
|
||||
SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the
|
||||
aarch64 handhelds that run a full distro.
|
||||
|
||||
> The Anbernic RG34XXSP has its own PortMaster-style pack
|
||||
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
|
||||
> [anbernic-rg34xxsp.md](anbernic-rg34xxsp.md)). That one bundles PortMaster's
|
||||
> LÖVE runtime and expects the device's own SDL; this AppImage is the generic
|
||||
> desktop-Linux artifact and shares nothing with it but the `game.love`.
|
||||
|
||||
## For players
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-arm64.AppImage
|
||||
./gen1recomp-*-linux-arm64.AppImage
|
||||
```
|
||||
|
||||
Then use **Import ROM** in the launcher to point it at your own legal Red /
|
||||
Blue / Yellow cartridge dump, exactly as on every other platform.
|
||||
|
||||
If your system has no FUSE (`dlopen(): error loading libfuse.so.2`), either
|
||||
install it (`sudo apt install libfuse2`) or run without it:
|
||||
|
||||
```sh
|
||||
./gen1recomp-*-linux-arm64.AppImage --appimage-extract-and-run
|
||||
```
|
||||
|
||||
### What the host has to provide
|
||||
|
||||
Very little, and this is enforced by an assertion in the build rather than by
|
||||
good intentions. The only libraries the AppImage requires at startup are:
|
||||
|
||||
```
|
||||
glibc 2.29+ libstdc++ libfreetype6 zlib
|
||||
```
|
||||
|
||||
Everything else — OpenGL/Mesa, X11, Wayland, KMSDRM, ALSA, PulseAudio — is
|
||||
**dlopened**, so it is used when present and skipped when absent. That means
|
||||
one image runs on a full desktop, on a Wayland-only session, on a
|
||||
KMSDRM-only handheld with no X server, and on a box with ALSA but no
|
||||
PulseAudio, without a different build for each.
|
||||
|
||||
That property does not come for free from Debian's packages, and getting it
|
||||
is most of what the build below is doing; see
|
||||
[Why five libraries are built from source](#why-five-libraries-are-built-from-source).
|
||||
|
||||
## For builders
|
||||
|
||||
```sh
|
||||
scripts/build_linux_arm64.sh --version 0.1.0
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage.sha256
|
||||
```
|
||||
|
||||
Useful flags: `--game-love PATH` reuses an already-packed payload (CI does
|
||||
this so every platform ships identical bytes), `--rebuild-image` forces the
|
||||
builder container to rebuild, `--clean-cache` throws away the pinned
|
||||
downloads and the compiled LÖVE prefix.
|
||||
|
||||
### Requirements
|
||||
|
||||
An **aarch64 host** with **docker or podman**. A Raspberry Pi 5 is the
|
||||
reference machine (a cold build takes about 10 minutes on one — six libraries
|
||||
plus the engine; rebuilds reuse the cached prefix and take seconds). Apple Silicon with Docker
|
||||
Desktop and GitHub's `ubuntu-24.04-arm` runner both work too.
|
||||
|
||||
The script refuses to run on x86_64 rather than falling back to qemu-user
|
||||
emulation: that path takes hours and has produced miscompiled LuaJIT.
|
||||
|
||||
### Why this is not just another `scripts/build.sh` target
|
||||
|
||||
`scripts/build.sh linux` downloads LÖVE's official `love-11.5-x86_64.AppImage`,
|
||||
unpacks its squashfs, drops `game.love` in, and glues it back together. That
|
||||
trick is not available here — **LÖVE publishes no aarch64 binary at all.** The
|
||||
11.5 release has win32, win64, macOS, Android, iOS and one x86_64 AppImage,
|
||||
and that is the entire list.
|
||||
|
||||
So this build compiles LÖVE 11.5 from the official `linux-src` tarball and
|
||||
assembles the AppImage from scratch. Every pinned input — the LÖVE source, the
|
||||
five libraries built alongside it, and the AppImage type-2 runtime — is
|
||||
SHA-256 verified on the host before the container ever sees it, and the
|
||||
container itself runs with no network access.
|
||||
|
||||
### Why the build happens in a Debian bullseye container
|
||||
|
||||
glibc is backward compatible but not forward compatible: a binary linked
|
||||
against glibc 2.41 will not start on a system with 2.31, and there is no way
|
||||
to fix that after the fact. Compiling on the oldest base we support is
|
||||
therefore the only thing that makes one artifact work everywhere.
|
||||
|
||||
Bullseye (glibc 2.31) is that base. The resulting binaries actually come out
|
||||
needing only **glibc 2.29** and **GLIBCXX_3.4.21**, so the AppImage covers
|
||||
everything from Ubuntu 20.04 and Raspberry Pi OS bullseye through current
|
||||
trixie.
|
||||
|
||||
This is a statement about the *compile environment*, not about where the
|
||||
artifact runs — building on your own newer distro would silently raise that
|
||||
floor and strand every user on an older one, with no symptom until they
|
||||
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
|
||||
required glibc symbol version climbs above 2.31.
|
||||
|
||||
### Why five libraries are built from source
|
||||
|
||||
SDL2, OpenAL, libtheora, libogg/libvorbis and libmpg123 are compiled rather
|
||||
than installed from bullseye. In every case the reason is *correctness*, not
|
||||
a newer version number — Debian builds these for a system where every
|
||||
dependency is installed and co-versioned, which is the opposite of an
|
||||
AppImage's situation. Each one broke the build in a different way, and all
|
||||
three failure modes are now assertions that fail the build instead of
|
||||
shipping.
|
||||
|
||||
**1. Hard-linked backends (SDL2, OpenAL).** Debian's `libSDL2` lists
|
||||
`libpulse`, `libasound`, `libX11` and `libwayland-client` as `DT_NEEDED` —
|
||||
resolved by the loader at startup, not dlopened. An AppImage bundling it
|
||||
refuses to start unless the host has *all four*. It appeared to work in
|
||||
testing only because a desktop Pi has all four; a headless CI runner is what
|
||||
exposed it. Debian's OpenAL does the same via `libsndio`, which itself
|
||||
hard-links `libasound`. Built from source with `--enable-*-shared` and
|
||||
`ALSOFT_DLOPEN`, both dlopen their backends instead.
|
||||
|
||||
**2. A stray link (libtheora).** Debian's `libtheoradec.so.1` is linked
|
||||
against `libcairo.so.2` — a packaging artifact, since a video decoder has no
|
||||
business drawing vector graphics — and cairo drags in X11, xcb, fontconfig
|
||||
and freetype. `--disable-examples` produces a `libtheoradec` needing only
|
||||
`libogg`.
|
||||
|
||||
**3. SONAME collision with the host (ogg, vorbis, mpg123).** The subtle one.
|
||||
OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that
|
||||
plugin pulls the *host's* `libsndfile` into our process. `libsndfile` links
|
||||
`libogg`, `libvorbis` and `libmpg123` — the same three we bundle. The loader
|
||||
resolves a SONAME exactly once per process, so the host's `libsndfile` binds
|
||||
to *our* copies:
|
||||
|
||||
```
|
||||
openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host, new)
|
||||
`-> mpg123_info2 -> libmpg123 (ours, bullseye 1.26)
|
||||
```
|
||||
|
||||
`mpg123_info2` arrived in mpg123 1.32, so the plugin failed to relocate, ALSA
|
||||
config collapsed, and the game ran with **no audio device at all**. Not
|
||||
bundling these instead would make `libogg`/`libvorbis`/`libmpg123` mandatory
|
||||
host packages; building them current means our copies *satisfy* the host's
|
||||
`libsndfile` rather than starving it.
|
||||
|
||||
The same collision is why the font stack — freetype, fontconfig, libpng,
|
||||
brotli, zlib — is left to the host entirely. Bundling a bullseye freetype
|
||||
2.10.4 meant a host `libcairo` could not find `FT_Get_Transform` (added in
|
||||
2.11) and the game died at startup. Leaving the whole stack to the host keeps
|
||||
it self-consistent, while `liblove` — compiled against 2.10.4 — only ever
|
||||
asks for symbols every supported host already has.
|
||||
|
||||
The general rule this all reduces to: **never bundle a library the host's own
|
||||
stack may also load, unless yours is at least as new as theirs.**
|
||||
|
||||
### CI
|
||||
|
||||
Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
|
||||
`scripts/linux-arm64/`, `scripts/pack_love.sh` and this document:
|
||||
|
||||
- **`linux-arm64-selftest`** (`ubuntu-latest`, x86_64) — offline gate. Checks
|
||||
the pins are real digests on a dated tag rather than the moving
|
||||
`continuous` one, that the Dockerfile still builds on bullseye, that the
|
||||
exclude list still classifies known sonames correctly, that AppRun still
|
||||
launches `game.love` with `--fused`, and that the host-arch guard actually
|
||||
fires. Needs no container and no arm64 machine.
|
||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
|
||||
the artifact and asserts the layout, that every bundled object resolves
|
||||
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
|
||||
Uploads the AppImage for 7 days.
|
||||
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
||||
`game.love` from the `love-payload` job, and the AppImage is staged and
|
||||
published like every other release asset.
|
||||
|
||||
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
||||
it runs on fork PRs too.
|
||||
|
||||
### Updating the pins
|
||||
|
||||
Both pins live in `scripts/linux-arm64/common.sh`:
|
||||
|
||||
- `LOVE_VERSION` / `LOVE_SRC_SHA256` — bumping any version invalidates the
|
||||
cached prefix automatically (its name is keyed by every source version at
|
||||
once, so a partial rebuild cannot mix vintages). Check that bullseye still
|
||||
has `-dev` packages new enough for the new release; `build_appimage.sh`
|
||||
asserts every optional module actually linked, because LÖVE's `configure`
|
||||
exits 0 and silently drops a module when one is missing.
|
||||
- `SDL2_*`, `OPENAL_*`, `THEORA_*`, `OGG_*`, `VORBIS_*`, `MPG123_*` — the
|
||||
source-built libraries. Bumping these is usually safe and occasionally
|
||||
necessary: `libmpg123` in particular must stay at least as new as what a
|
||||
target host's `libsndfile` expects, which is asserted for `mpg123_info2`.
|
||||
- `APPIMAGE_RUNTIME_TAG` / `APPIMAGE_RUNTIME_SHA256` — always a dated tag
|
||||
from [AppImage/type2-runtime](https://github.com/AppImage/type2-runtime/releases).
|
||||
The selftest fails the build if this ever points at `continuous`.
|
||||
@@ -0,0 +1,862 @@
|
||||
# Mods and Gen 2 (Gold)
|
||||
|
||||
The mod API is one API across both generations. Hook names, event names,
|
||||
registry names and the `mod.*` facade are shared on purpose: a mod that runs on
|
||||
Red should be able to run on Gold without learning a second vocabulary.
|
||||
|
||||
What differs is how much of it Gold can actually serve, and that is why Gen 2
|
||||
support is something a mod **declares** rather than something it inherits.
|
||||
|
||||
## What you can rely on today
|
||||
|
||||
The short version, for an author deciding what to write:
|
||||
|
||||
- **Every registry name, hook name and event name means the same thing in both
|
||||
games.** Nothing is prefixed, renamed or repurposed per generation. Where Gen
|
||||
2 genuinely carries more, the record or the payload gains a *field*.
|
||||
- **40 of the 46 registries are available on Gold.** 17 keep their Gen 1 target
|
||||
outright (`commands`, `tokens`, `growth_rates`, `battle_sprite_scales` and
|
||||
`render_pipelines` among them), 16 route to a Gen 2 table under the same
|
||||
name, 6 are Gen 2-only systems Red has no counterpart for, and `migrations`
|
||||
is a code registry with no data target in either game. The other 6 are gated,
|
||||
and are listed below with the consumer change each one still needs.
|
||||
- **A registry with no home in a generation is reported, never silently
|
||||
merged.** The write is taken, dropped, and named once per mod in the same
|
||||
error feed the mod manager shows -- in both directions, so a Red boot writing
|
||||
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
|
||||
- **40 event names and 43 hook names have a call site in both generations**, so
|
||||
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
|
||||
reads those names back out of the source and fails if a site is renamed or
|
||||
deleted on either side, and fails again if a new shared site appears without
|
||||
being listed here.
|
||||
- **24 further names are Gen 2-only** (friendship, breeding, the Pokegear, the
|
||||
radio, Pokerus, the roamers, Kurt, the Bug Contest, the Unown puzzle, mail,
|
||||
held items, shininess, gender, and the five cards of the GS boot cinema).
|
||||
They are plain names, not a `gen2.` namespace, so if Red ever grows the
|
||||
system the name is already right.
|
||||
- **Every Gen 2 seam is guarded** by `Runtime.wants` / `Runtime.wantsHook`, so
|
||||
a boot with no mod subscribed allocates nothing at any of them.
|
||||
- **A mod is loaded on Gold only if it says so.** See `gen2compat` below.
|
||||
|
||||
`src/mods/Schemas.lua` is authoritative for routing;
|
||||
`tests/engine/gate_gen2_mod_api.lua` holds this document to it.
|
||||
|
||||
## Declaring which games a mod is for
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_mod",
|
||||
"name": "My Mod",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"games": ["gen1", "gen2"]
|
||||
}
|
||||
```
|
||||
|
||||
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
||||
`"gold"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or `"all"`.
|
||||
`src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` and
|
||||
`GameVersion.generation`, so nothing anywhere restates the game list.
|
||||
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
||||
and **derives** `manifest.gen2compat` from them, which is the one field the
|
||||
loader's gate reads.
|
||||
|
||||
Nothing moves on disk for any of this. A mod is installed once, into
|
||||
`mods/<id>/`, and that directory serves every game: there is no `mods/gen1/`
|
||||
and no per-generation copy. Targeting is declared, not filed.
|
||||
|
||||
`"gen2compat": true` is the legacy spelling and is still accepted. It is purely
|
||||
additive -- it *adds* the Gen 2 games to whatever `games` says -- so no shipped
|
||||
manifest can lose a game it already ran on. A manifest with neither key is Gen
|
||||
1 only, which is exactly what it always meant. An unknown token warns and is
|
||||
dropped under `api` 1 and refuses the manifest under `api` 2; a `games` array
|
||||
that names no game this engine knows falls back to the default rather than
|
||||
orphaning the mod; a non-array `games` is a hard error.
|
||||
|
||||
Every token is enforced, per game. `Loader:_gateGeneration` gates on
|
||||
`ModTargets.supports(manifest, version, generation)`, the same call both mod
|
||||
surfaces make, so `"games": ["blue"]` really does not load on Red and the
|
||||
loader's skip line is the launcher's line, `For Blue, not Red`. A manifest with
|
||||
no `games` and no `gen2compat` still covers every Gen 1 game, so nothing
|
||||
written before the key existed changes behavior.
|
||||
|
||||
On a Gold boot, a mod claiming no Gen 2 game is **not loaded at all**: no
|
||||
registrations, no subscriptions, no entry chunk. The manager still lists it,
|
||||
showing `ENABLED (NOT THIS GAME)` and the reason, and the player's enable flag
|
||||
is left alone so it comes straight back on Red.
|
||||
|
||||
Both mod surfaces derive what they show from `ModTargets` rather than from
|
||||
their own copy of the rule. The launcher's mod panel carries a `Show for:` game
|
||||
chip row and a per-mod tag (`GEN 1`, `GEN 1+2`, `RED/GOLD`), greyed with `Not
|
||||
for this game` and the detail `For Gen 1, not Gold` when the mod does not run
|
||||
on the selected game; the in-game manager shows the same verdict as
|
||||
`ENABLED (NOT THIS GAME)` plus an inert `FOR GEN 1+2` row on the detail screen.
|
||||
The launcher asks the same question of a mod's dependencies: one whose hard
|
||||
dependency does not run on the selected game reads `Needs <id> (not for Gold)`,
|
||||
matching the loader's contagious skip.
|
||||
|
||||
A separate overlay, `options.modsByVersion[version][id]`, holds each game's
|
||||
enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
|
||||
for every installed mod, and the loader and in-game manager read the same
|
||||
game-specific answer on the next boot. On the first launch after this feature,
|
||||
the existing shared state is copied to every game, so a mod that was enabled
|
||||
remains enabled everywhere; after that, changing one checkbox affects only
|
||||
that game. New mods still default to enabled on every game (experimental mods
|
||||
retain their explicit opt-in default).
|
||||
|
||||
That is deliberate. Gold reimplements the battle engine, the overworld, the
|
||||
script VM and the save format, so a Gen 1 mod dropped into a Gold boot would
|
||||
find a small fraction of its call sites live. A mod that half-applies reads to
|
||||
a player as a broken mod. Not running is the honest state, and naming a Gen 2
|
||||
game is the author saying "I have tested this there".
|
||||
|
||||
Adding a Gen 2 game does not opt out of anything on Gen 1, because `games` is a
|
||||
union: `["gen1", "gen2"]` covers everything it covered before. What does change
|
||||
is that the gate now runs on a Gen 1 boot too, so a manifest that names *only*
|
||||
Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both
|
||||
generations if you want both.
|
||||
|
||||
Two riders. **A hard dependency that does not run here takes the dependent down
|
||||
with it** (unless scoped to specific games, e.g.
|
||||
`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
|
||||
failure and carrying the dependency's own wording (`depends on X, which does not
|
||||
run here (For Blue, not Red)`), so the whole chain has to cover the same games.
|
||||
And **the claim is yours, not the last word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
|
||||
whose author never opted in, which is the only route for a mod written before
|
||||
the field existed. The override is per game -- `options.modsGen2[id]` is a
|
||||
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto
|
||||
Gold, and a legacy `options.modsGen2[id] = true` reads as "the Gen 2 games",
|
||||
the only set it could ever have affected. It applies on the next boot; a forced
|
||||
mod loads normally and keeps a note saying it was never verified here. Where
|
||||
the choice cannot be persisted the manager says `COULD NOT SAVE` instead of
|
||||
promising a restart.
|
||||
|
||||
If you are writing new code, still prefer the API: take the live game from
|
||||
`mod.game` (or the `game.ready` payload, or a `ui.*` hook's first argument) and
|
||||
the world from `mod.world`. Those are the names that mean the same thing in
|
||||
both games. What follows is for the mods that were written before Gold existed
|
||||
and reach past it.
|
||||
|
||||
## Gen 1 module facades
|
||||
|
||||
A mod with `engine_internals` reaches engine modules by name, and under Gold
|
||||
those names used to resolve to Gen 1 modules nothing instantiates -- so the
|
||||
patch landed on dead code and the mod was inert with no symptom but silence.
|
||||
|
||||
On a Gen 2 boot, **a require made from a mod's own chunk is answered by an
|
||||
adapter**: the Gen 1 API, backed by Gen 2 internals. `src/mods/Gen2Compat.lua`
|
||||
is the table, `src/mods/Loader.lua`'s require shim is where the swap happens,
|
||||
and `tests/engine/gate_gen2_mod_facade.lua` holds both to it. Engine code is
|
||||
not affected -- the shim only substitutes when the calling chunk is outside the
|
||||
engine tree, so `src/render/PaletteFX.lua` still gets the real Gen 1 module on
|
||||
both generations.
|
||||
|
||||
Fifteen names are served. **alias** means the adapter *is* the Gen 2 module, so
|
||||
a monkey-patch, a `rawset` sentinel and a `getmetatable(x) == M` check all land
|
||||
on the table Gold runs; **facade** means a translating wrapper over it.
|
||||
|
||||
| the Gen 1 name a mod requires | kind | what it gets on Gold |
|
||||
| --- | --- | --- |
|
||||
| `src.core.Game` | facade | a live proxy onto the Game2 instance |
|
||||
| `src.world.OverworldController` | facade | over `src/world/gen2/World.lua`; `World:step` / `:interact` / `:interactBody` dispatch through it |
|
||||
| `src.world.Map` | alias | `src/world/gen2/Map.lua`, grown Gen 1's statics and instance methods |
|
||||
| `src.world.NPC` | alias | `src/world/gen2/Npc.lua`; `NPC.new` sniffs the Gen 1 argument order |
|
||||
| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua`, plus Gen 1's `COUNT` / `CAPACITY` / `ensure` / `active` / `deposit` |
|
||||
| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua`, write-through |
|
||||
| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua`, write-through |
|
||||
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` |
|
||||
| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` |
|
||||
| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` |
|
||||
| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua`, write-through |
|
||||
| `src.world.FieldDefaults` | facade | the `playerSprites` answer, and a named refusal for the rest |
|
||||
| `src.world.Collision` | facade | `DELTA` / `target` / `occupied` / `canMove` |
|
||||
| `src.ui.StartMenu` | facade | over `src/ui/gen2/StartMenu.lua`, write-through |
|
||||
| `src.ui.BoxMenu` | alias | `src/ui/gen2/PcMenu.lua` |
|
||||
|
||||
Two entries in that table are not the pairing they look like.
|
||||
`src.ui.BoxMenu` resolves to `PcMenu`, not to `src/ui/gen2/BoxMenu.lua`: Gen 1's
|
||||
`BoxMenu` is Bill's PC *top menu*, whose Gold counterpart is `PcMenu`, while
|
||||
Gold's `BoxMenu` is the withdraw/deposit *list* Gen 1 builds inline. And
|
||||
`src.script.ScriptRunner` is served narrowly rather than fully: `scanLabels`
|
||||
and `validate` forward verbatim, with the default verb lookup swapped to
|
||||
`game.data.commands` so a script of Gen 1 built-ins cannot validate clean and
|
||||
then run as nothing, while the lifecycle half is a thin handle onto the one
|
||||
`world.vm` with `resume` and `update` refused rather than double-driving it.
|
||||
The `script.started` / `script.ended` / `script.command` seams are the
|
||||
supported route and already work on Gold.
|
||||
|
||||
`src.script.Commands` and `src.ui.OptionRows` have **no** adapter and are the
|
||||
two names a require of which still lands in the boot error feed the manager
|
||||
shows, with the module named. Both load fine under Gold and both are traps: the
|
||||
first hands back 61 Gen 1 verbs none of which Gold can run, the second paints
|
||||
Red's four-box options chrome over Gold's single 18x16 one.
|
||||
|
||||
`docs/preparing-your-mod-for-gen2.md` is the migration guide for an author
|
||||
working through this, and `python3 tools/modkit.py gen2check <id>` reports a
|
||||
mod's own findings against the coverage table below.
|
||||
|
||||
Three rules the adapters keep, because a plausible wrong answer is worse than
|
||||
the module being missing:
|
||||
|
||||
- **Live, never a snapshot.** A mod captures `require("src.core.Game")` at file
|
||||
scope, before a save or a world exists. The facade is a proxy that reads the
|
||||
live instance on every touch, so `Game.save` is nil during the entry chunk
|
||||
and correct forever after. It aliases the two names Gold spells differently
|
||||
(`Game.overworld` is `Game2.world`, `Game.writeOptions` is
|
||||
`Game2:persistOptions`) and the one data table that was renamed
|
||||
(`game.data.sprites` is `data.gen2Sprites`).
|
||||
- **A member with no backing says so.** `game.data.field` does not exist on
|
||||
Gold, so it reads nil *and* logs once, naming the mods holding the facade.
|
||||
`BattleState.newWild` is absent rather than invented, because a `newWild`
|
||||
that took a species and a level would be a lie about what Gold's battle
|
||||
screen is.
|
||||
- **One stable table for the run.** Where the Gen 2 arm can serve the name
|
||||
outright the adapter *is* that module, so a mod's monkey-patch, its
|
||||
`rawset` sentinel and its `==` idempotency check all land on the table Gold
|
||||
actually runs.
|
||||
|
||||
### What the adapter says it covers
|
||||
|
||||
The adapter publishes its own coverage, versioned by
|
||||
`Gen2Compat.COVERAGE_VERSION` (1), and `modkit gen2check` consumes that table
|
||||
rather than a second copy of the same knowledge:
|
||||
|
||||
```lua
|
||||
Gen2Compat.modules() -- the 15 names, sorted
|
||||
Gen2Compat.serves(name) -- boolean
|
||||
Gen2Compat.memberStatus(name, member) -- "backed" | "warned" | "absent" | nil
|
||||
Gen2Compat.coverage(name) -- a fresh table per call:
|
||||
-- { module, kind = "facade"|"alias", target, members = { [name] = status },
|
||||
-- notes = { [name-or-topic] = "one line" } }
|
||||
```
|
||||
|
||||
The status vocabulary is frozen at three values, and a member listed as both
|
||||
resolves to the weaker claim:
|
||||
|
||||
| status | means |
|
||||
| --- | --- |
|
||||
| `backed` | present, and it does the Gen 1 job on Gold |
|
||||
| `warned` | present, answers nil or degrades, and names itself once with the mod attributed |
|
||||
| `absent` | deliberately not served; a nil read is the honest failure |
|
||||
|
||||
Today that is 291 backed, 32 warned and 161 absent across the fifteen modules.
|
||||
`notes` keys are documentation topics rather than a member list -- dotted paths
|
||||
(`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and
|
||||
bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is
|
||||
the authoritative set, and a member it does not record is not a promise either
|
||||
way: on an alias it resolves to whatever the Gen 2 module has, on a
|
||||
write-through facade it falls to the Gen 2 class, on the `src.core.Game` facade
|
||||
it reads nil and says so, and on the `src.world.OverworldController` facade it
|
||||
reads nil silently.
|
||||
|
||||
**The follower.** Gold's cart has no trailing companion at all, so
|
||||
`src/world/gen2/Follower.lua` is new Gen 2 code rather than a facade: the
|
||||
entity, the trail loop, and a `shouldSpawn` a mod replaces. `World:step` calls
|
||||
`Follower.update(game, world)` once per logic frame after the body, and
|
||||
`World:setMap` calls `Follower.onMapEntered` before it emits `map.entered` --
|
||||
the same two call sites `src/world/OverworldController.lua` gives the Gen 1
|
||||
arm, which is what makes a Gen 1 follower mod's wrappers tick.
|
||||
|
||||
Vanilla never spawns one: `shouldSpawn` answers false until something replaces
|
||||
it. `Follower.setShouldSpawn(fn)` is the supported way, and it writes the same
|
||||
file-local the Gen 1 mods reach through `debug.setupvalue` on the upvalue named
|
||||
`shouldSpawn`, so the two cannot disagree.
|
||||
|
||||
Two Gen 2 engine changes came with it, both general rather than follower-only:
|
||||
an entity with `passable` set never blocks a step (the Gen 1 name and meaning,
|
||||
`src/world/Collision.lua`), and `World:rebuildPeople` now preserves **guests** --
|
||||
anything in the people list it did not put there. A rebuild runs on every zoom
|
||||
and every time-of-day roll, so without that a follower vanished at the top of
|
||||
the hour.
|
||||
|
||||
**What the facades cannot fix.** A mod that allow-lists version strings
|
||||
(`GameVersion.get() == "red" or ...`) excludes itself from Gold by construction,
|
||||
and no adapter should special-case it. Neither is a Gen 1 screen id: Gold's
|
||||
builtins carry a `Gen2` prefix, so a mod matching `id == "BoxMenu"` matches
|
||||
nothing. A write to a field on a live Gen 2 menu instance is inert where Gen 1
|
||||
read it back (`menu.onSwitch`, `menu.swapFrom`, `StartMenu`'s box geometry),
|
||||
and `map.warpAt` is a name collision rather than a rename -- Gen 1's is a table
|
||||
keyed by cell, Gold's is a method, so indexing or iterating it raises. All of
|
||||
these are mod-side edits, each with a route that works on both generations;
|
||||
`docs/preparing-your-mod-for-gen2.md` walks through them.
|
||||
|
||||
## What works on Gold today
|
||||
|
||||
**Screens.** The `screens` registry serves both generations. Gold's screens
|
||||
are registered under `Gen2`-prefixed ids so a mod that replaces Gold's party
|
||||
menu does not also replace Red's; `Screens.GEN2_IDS` in `src/ui/Screens.lua`
|
||||
is the full list. Every screen Gold opens goes through an id, including the
|
||||
boot cinema and the START menu.
|
||||
|
||||
**Asset overrides.** `overrides/` shadowing and asset transforms work
|
||||
unchanged: Gold's screens load art through `src/render/Assets.lua`, the same
|
||||
choke point Gen 1 uses.
|
||||
|
||||
**Content registries at the shared path.** `pokemon`, `moves`, `items`,
|
||||
`type_chart`, `strings`, `font`, `screens`, `commands`, `tokens`,
|
||||
`growth_rates`, `battle_sprite_scales`, `render_pipelines`, and the audio
|
||||
family (`audio`, `music`, `sfx`, `cries`, `map_songs`). These keep their Gen 1
|
||||
target path, so one mod source targets both generations.
|
||||
|
||||
The last two are the newest and each carries one caveat worth stating before
|
||||
you write against it:
|
||||
|
||||
- **`battle_sprite_scales`.** `src/ui/gen2/BattleState.lua:imageScale` walks
|
||||
the merged table for a record whose `path` matches the pic being drawn,
|
||||
skipping the registry's own `_owners` row, and `picScale` falls through to
|
||||
the species record's `battleScaleFront` / `battleScaleBack` after it -- the
|
||||
same image-then-species-then-default order Gen 1 resolves in. Because the key
|
||||
is the asset path it also reaches the pics that are nobody's species: the
|
||||
player's trainer back, the DUDE's, an opponent's frontpic. The **default**
|
||||
differs and is not a registry record either side: Red's 32x32 back pics draw
|
||||
at 2x, Gold's 48x48 ones fill their 6x6 box at 1x, so a scale that looks
|
||||
right on Red is twice as large on Gold. At any scale the pic stays centred in
|
||||
its box and standing on the same ground line.
|
||||
- **`render_pipelines`.** `src/core/Game2.lua:load` installs
|
||||
`src/render/Pipelines.lua` on Gold's dataset *after* `mods:load`, so the
|
||||
merged table is the one it walks, and `Game2:draw` composites the
|
||||
whole-frame half through `Pipelines.wantsPresent` / `Pipelines.present` with
|
||||
the Gen 1 ctx keys (`width`, `height`, `scale`, `dpi`, `dpiX`, `dpiY`). The
|
||||
**`drawWorld` half is inert on Gold**: its overworld draws straight to the
|
||||
window rather than into a canvas the way `src/world/OverworldController.lua`
|
||||
hands one to `Pipelines.drawWorld`. A drawWorld-only pipeline is not left
|
||||
switched on and drawing nothing -- `Game2:load` retires a restored level for
|
||||
one, leaving `options.pipelines` untouched so the mode comes back the day
|
||||
Gold grows a world canvas. Gold also has no OPTION row for a pipeline
|
||||
(`Pipelines.rows` is read only from `src/ui/OptionsMenu.lua`), so a Gold
|
||||
player reaches one by its `hotkey`.
|
||||
|
||||
**Content registries at a Gen 2 path.** `maps`, `tilesets`, `sprites`, `text`,
|
||||
`encounters`, `trainers`, `palettes`, `icons`, `battle_anims`, `constants`,
|
||||
`statuses`, `move_effects`, `item_effects`, `balls`, `ai_classes` and
|
||||
`evolution_methods`. Same registry name, same verbs, a Gen 2 table underneath
|
||||
(`data.gen2Maps`, `data.gen2Encounters`, `data.gen2Statuses`, ...).
|
||||
`src/core/Game2.lua` loads the extracted ones into `game.data` before it
|
||||
calls `mods:load`, and every consumer takes them by reference and never
|
||||
copies, so what a mod merges is what the game walks: a registered map is a map
|
||||
Gold can warp into, a patched tileset is the one `Map.new` reads, a patched
|
||||
encounter table is the one the grass rolls.
|
||||
|
||||
The battle-rule six are the newer half and work slightly differently: there is
|
||||
no table on disk for them at all. They come into existence *as* the merge, and
|
||||
each consumer reads a record through a lookup that falls back to its own module
|
||||
records when no loader ran, so a mod-free Gold boot behaves identically:
|
||||
|
||||
| registry | who reads it |
|
||||
| --- | --- |
|
||||
| `statuses` | `Battle.statusRecordFor` / `statusPenaltyFor`, `Catching.statusBonus`, `ItemEffects.healClassOf` |
|
||||
| `move_effects` | `Battle.moveEffectRecordFor` (`useMove`'s dispatch) |
|
||||
| `balls` | `Catching.recordFor` |
|
||||
| `ai_classes` | `Ai.layersFor` (the ten `scoring.asm` passes, plus mod layers) |
|
||||
| `evolution_methods` | `Evolution.methodFor` |
|
||||
| `item_effects` | `ItemEffects.recordFor` / `partyAction` |
|
||||
|
||||
`src/mods/Builtins.lua` seeds those six with **Gold's** records under Gen 2
|
||||
rather than Red's. It has to: both games call it `GREAT_BALL`, and Red's record
|
||||
carries no `multiplier`, so seeding Red's would leave Gold's x1.5 reading nil.
|
||||
|
||||
**Content registries that exist because Gold does.** Six systems Red has no
|
||||
counterpart for, so there is no Gen 1 table to share and none of these carries
|
||||
a Gen 1 target at all. The routed Gen 2 path is their only home, and
|
||||
`Schemas.GEN1` gates them on a Red boot the way `Schemas.GEN2` gates
|
||||
`map_scripts` on a Gold one -- reported, not silently merged.
|
||||
|
||||
| registry | id space | who reads it |
|
||||
| --- | --- | --- |
|
||||
| `held_items` | item ids | `ItemEffects.heldItemFor`; the merged rows are written back onto `data.items` for `Battle:itemDef` |
|
||||
| `phone_contacts` | `PHONE_*` (`data.gen2Constants.phoneContactOrder`) | `Phone.useRegistry`, folded onto the contact table |
|
||||
| `decorations` | `"deco:<n>"` | `Decorations.attributes`, the single read point for an attribute row |
|
||||
| `apricorns` | apricorn item ids | `Apricorns.useRegistry`, which rebuilds all three lookups and Kurt's menu order |
|
||||
| `landmarks` | `LANDMARK_*` | `Nests.landmarkId` / `Nests.landmark`, which resolve a map header's landmark byte |
|
||||
| `radio_channels` | station ids | `MapRadio.channelRecord`, which puts a registered station on the dial |
|
||||
|
||||
`Game2:load` calls `Phone.useRegistry`, `Decorations.useRegistry`,
|
||||
`Apricorns.useRegistry` and `ItemEffects.applyHeldItems` immediately after
|
||||
`mods:load`, so the merge is live before the first frame. `landmarks` and
|
||||
`radio_channels` need no such call: their consumers take `data` at call time.
|
||||
|
||||
`landmarks` merges onto the cache's own `gen2Landmarks.landmarks` and
|
||||
`held_items` onto the view `Game2` builds from `data.items`, so both fold
|
||||
against the vanilla row -- a `register` for an existing id collides, a
|
||||
`patch` stacks. The other four come into existence as the merge, seeded from
|
||||
their module's literals by `src/mods/Builtins.lua`.
|
||||
|
||||
Four honest limits on that surface:
|
||||
|
||||
- `held_items` reaches the battle by being written back onto `data.items`, so a
|
||||
held row for an id with no `data.items` record lands nowhere. To invent a
|
||||
held item, register the `items` record too. The write-back is a diff against
|
||||
a pre-merge snapshot, which is what lets `items` and `held_items` compose
|
||||
instead of one reverting the other.
|
||||
- `decorations` ids are `"deco:<n>"`, not `DECO_*` names: the cart's decoration
|
||||
constants are a bare `const_def` block with no name table behind them, so
|
||||
there is nothing in the ROM to spell them by. `battle_anims` addresses its
|
||||
unnamed rows the same way. `n` is the attribute row's index, which is
|
||||
`wMenuSelection`.
|
||||
- `phone_contacts` does not register the four `PHONE_UNUSED` `const_skip` holes
|
||||
(contact bytes 8, 9, 10 and 25). The manifest gives all four the same id, and
|
||||
one id cannot key four rows. They stay copies of the wrong-number filler,
|
||||
which is what the cart does with them.
|
||||
- `radio_channels` and `phone_contacts` register *content*, not new UI: a
|
||||
registered station gets a dial position and a name, and a registered contact
|
||||
gets a row the Pokegear indexes, but neither invents a screen.
|
||||
|
||||
**Record shapes.** A registry whose Gen 2 records genuinely differ carries a
|
||||
Gen 2 schema beside its Gen 1 one (`gen2Fields` / `gen2Keys` / `gen2Write` in
|
||||
`src/mods/Schemas.lua`, resolved by `Schemas.shapeFor`). The registry name, the
|
||||
verbs and wherever possible the ids stay shared; only the record changes. The
|
||||
differences an author meets:
|
||||
|
||||
- **`pokemon`.** Gen 2 splits `special` into `specialAttack` /
|
||||
`specialDefense`, names the level-up table `levelMoves` and the pic size
|
||||
`picSize`, has no separate `level1Moves`, and points an evolution at `into`
|
||||
rather than `species`. It also carries the breeding block (`eggGroups`,
|
||||
`eggMoves`, `eggSteps`, `genderRatio`) and the wild held-item pair.
|
||||
- **`encounters`.** The id is the encounter *kind*, not the map:
|
||||
`mod.content.encounters:patch("grass", { ROUTE_29 = { rates = { NITE = 40 } } })`.
|
||||
A map's row carries a `rates` set per time of day and one slot list.
|
||||
`fishGroups`, `trees` / `treeSets`, `rocks`, `bugContest` and `roamMaps` are
|
||||
ids of their own.
|
||||
- **`trainers`.** The id is the trainer *class*, and the record is
|
||||
`{ name, index, attributes, baseMoney, encounterMusic, trainers, items }`,
|
||||
with one entry per named trainer of the class. The registry writes one level
|
||||
in, into `data.gen2Trainers.classes`, so the call shape is unchanged.
|
||||
- **`icons`.** Two id forms in one registry, routed by the `ICON_` prefix a
|
||||
sheet name carries: a species id names an assignment (a string, the sheet's
|
||||
name), an `ICON_*` id names a sheet.
|
||||
- **`palettes`, `battle_anims`, `constants`.** The id is a subtable of the
|
||||
target: `pokemon` / `trainers` / `bg` / `objects` / `roofs` for palettes,
|
||||
`scripts` / `moves` / `objects` / `framesets` / `oamsets` / `gfx` for
|
||||
battle_anims, and one of Gold's 42 ordered ROM name lists (plus `mapGroups`,
|
||||
`trainerClassMembers`, `types`) for constants. Those lists are ordered and
|
||||
position *is* the id a script byte resolves through, so they replace rather
|
||||
than append.
|
||||
|
||||
Four more id-space notes, because the records at those paths came out of a
|
||||
Gen 2 ROM:
|
||||
|
||||
- Gold's `text` ids are ROM pointer strings such as `"55:4067"`, not the
|
||||
`TEXT_*` names Red uses. `override` them by pointer; there is no name table.
|
||||
- A Gen 2 tileset carries its walkability as `collision` where Gen 1 says
|
||||
`walkable`. Both fields validate; only `collision` is read on Gold.
|
||||
- A Gen 2 warp row carries `destGroup` / `destMapNum` beside the `destMap` /
|
||||
`destWarp` pair Gen 1 also has. Both are optional in the shared schema, so a
|
||||
Gen 1 warp row and a Gen 2 one both validate, and patching one of Gold's own
|
||||
maps does not mean restating the ROM's map-group numbers.
|
||||
- Gold writes `"burn"` / `"sleep"` into `mon.status` where Red writes `BRN` /
|
||||
`SLP`. The `statuses` registry is the same registry; only the ids differ, and
|
||||
they have to.
|
||||
|
||||
**`mod.commands`.** Works on Gold. `src/script/gen2/Vm.lua` runs the cart's own
|
||||
bytecode, so there is no opcode byte to hand a mod -- the seam is a row the
|
||||
cart cannot write. `Opcodes.MOD_COMMAND` (`"modcommand"`) is an op *name* with
|
||||
no byte behind it, and the VM dispatches it through the same merged
|
||||
`data.commands` table Gen 1's runner resolves by name. Two row shapes reach it:
|
||||
|
||||
```lua
|
||||
{ op = "modcommand", verb = "mymod:shake", args = { 4, 2 } } -- native
|
||||
{ "mymod:shake", 4, 2 } -- Gen 1 row
|
||||
```
|
||||
|
||||
The second is the Gen 1 row shape verbatim, so one row list can serve both
|
||||
games as long as every row in it is the mod's own verb. The handler is called
|
||||
`fn(ctx, unpack(args))` with `ctx.vm` where Gen 1 has `ctx.runner`; it may
|
||||
block on `ctx.vm:showText` / `:waitFrames`, and its return value speaks Gen 1's
|
||||
control vocabulary (`"end"`, a row number, or nil). A missing or raising verb
|
||||
is warned once per name and the rest of the list still runs. The engine's own
|
||||
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
|
||||
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
|
||||
|
||||
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.**
|
||||
Generation-agnostic; nothing to adapt.
|
||||
|
||||
**`mod.world`.** Same method set, resolved against Gold's world
|
||||
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
||||
documented on the module: Gold's world is not a stack state, and Gen 2 event
|
||||
flags are numeric ids into `wEventFlags` rather than string keys.
|
||||
`mapOverview` returns the same read-only terrain, tile-shading, and marker
|
||||
shape, using Gold's live object masks and event flags to omit collected items.
|
||||
`spawnNpc` / `removeNpc` append onto the map def's own object list, the way the
|
||||
Gen 1 arm does, so a spawned actor is pooled, drawn, walked and talked to like
|
||||
an extracted one and survives a map reload; it is not serialized, so a mod
|
||||
respawns on `map.entered`. `queueScript` takes a small allowlist of verbs Gold
|
||||
has its own entry points for (`start_battle "wild" species level`, `warp`,
|
||||
`text`, `setflag`, `clearflag`) and refuses a list containing anything else
|
||||
**by name, before the first row runs**, so a mod never gets a half-run queue.
|
||||
`marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no
|
||||
byte for it) and returns `nil, reason` rather than approximating one.
|
||||
`availableFieldActions` and `useFieldAction` expose the same contextual field
|
||||
item and move records in both games. Gold extends the shared ids with its own
|
||||
`headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and `squirtbottle`
|
||||
actions. Each engine keeps ownership of its inventory, badges, terrain,
|
||||
surfing, bike, fishing, and field-move rules.
|
||||
|
||||
**Hooks and events that fire on Gold.** Every name below is the Gen 1 name
|
||||
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
|
||||
than defining a parallel vocabulary; where Gen 2 carries more, the payload
|
||||
gains a field instead of the name gaining a prefix.
|
||||
|
||||
- *Engine-wide, from the shared modules:* `game.ready`, `screen.pushed`,
|
||||
`screen.popped`, `screen.render_visible`, `music.started`, `music.stopped`,
|
||||
`music.select`, `music.volume`, `sound.played`, `zoom.range`,
|
||||
`assets.transformed`, `mods.loaded`, `mod.options_changed`.
|
||||
- *Overworld (`src/world/gen2/`):* `map.entered`, `map.exited`,
|
||||
`map.reloaded`, `player.warped`, `world.stepped`, `world.interacted`,
|
||||
`world.npc_spawned`, `world.trainer_engaged`, `world.blacked_out`,
|
||||
`world.block_replaced`, `world.boulder_moved`, `world.tod_changed`,
|
||||
`world.object_toggled`, `flag.changed`; hooks `warp.destination`,
|
||||
`movement.collision`, `movement.speed`, `encounter.roll`,
|
||||
`encounter.species`, `encounter.fishing`, `world.tod`, `map.palette`,
|
||||
`fieldmove.eligibility`. `flag.changed` carries the numeric `wEventFlags`
|
||||
id under Gen 1's `name` key, which is the one payload difference the
|
||||
numeric flag space forces.
|
||||
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
|
||||
`ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`,
|
||||
`ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script
|
||||
menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
|
||||
menus draw with does not raise it yet, so those two are composed through
|
||||
their own hooks only.
|
||||
- *The Oak speech (`src/ui/gen2/OakSpeech.lua`):* `intro.oak_speech.started`,
|
||||
`intro.oak_speech.step`, `intro.oak_speech.answered`,
|
||||
`intro.oak_speech.finished`, and the `intro.oak_speech.build` hook. Gold has
|
||||
a real Oak speech, so it is the same extension point rather than a second
|
||||
one: same names, same payload keys, same moments in the sequence. The beats
|
||||
are a data table with the same step vocabulary (`say` / `pic` / `name` /
|
||||
`choice` / `yesno` / `shrink` / `fn`, plus Gold's own `initclock` and
|
||||
`demo`), and the step *ids* match Gen 1's wherever the moment is the same --
|
||||
`oak_welcome`, `demo_mon`, `world_spiel`, `ask_player_name`, `name_player`,
|
||||
`legend`, `shrink` -- so `ModUI.insertStepBefore(steps, "name_player", ...)`
|
||||
lands in the right place in both games. The two ids with no Gen 1
|
||||
counterpart are Gold's own beats, `init_clock` (the `farcall InitClock` the
|
||||
speech opens with) and `oak_study` (the return to Oak for `_OakText5`). Gold
|
||||
has no rival-naming or name-confirmation beats, so it raises no anchors for
|
||||
them: the rival is named by `CopScript` in `maps/ElmsLab.asm`, hours later.
|
||||
- *Battle (`src/battle/gen2/`):* `battle.started`, `battle.ended`,
|
||||
`battle.turn_started`, `battle.turn_ended`, `battle.move_used`,
|
||||
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
||||
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible` and
|
||||
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
|
||||
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||
`ctx.battle.data` instead gets nil on Gold.
|
||||
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
||||
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
||||
`pokemon.caught` once the mon is in the party or the box, and
|
||||
`src/core/gen2/Evolution.lua` emits `pokemon.evolved` from `apply` and wraps
|
||||
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
|
||||
passes `game`; positions 2-4 (mon, row, trigger) match.
|
||||
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
|
||||
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
|
||||
`render.letterbox`, `render.hud`. Each sits
|
||||
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
||||
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
||||
first refusal on, the palette zone list handed to the present pass, the
|
||||
composed frame before GBCFX, the letterbox, and the finished playfield rect
|
||||
-- and carries the same payload.
|
||||
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
|
||||
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
||||
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
|
||||
computes no zone of its own there) and the engine's own one-rect list in
|
||||
CLASSIC mode; a rect that clamps to nothing is skipped rather than throwing,
|
||||
which is what `src/render/Renderer.lua:scissorClamped` does on the Gen 1 side.
|
||||
- *Sprites (`src/pokemon/Sprites.lua`, shared):* `pokemon.sprite`,
|
||||
`pokemon.icon` and `player.sprite`. `pokemon.icon` is reached from
|
||||
`src/ui/gen2/PartyMenu.lua` through the shared module, so it is one call site
|
||||
serving both games. `player.sprite` is raised by `Sprites.playerPic`, which
|
||||
Gold's battle back pic (`src/ui/gen2/BattleState.lua`), Hall of Fame and
|
||||
intro call with an already-resolved path: Gold's trainer art is not in
|
||||
`field.playerPics`, so the path is found first and the hook raised over it,
|
||||
with the Gen 1 `ctx` keys (`side`, `kind`, `demo`, `battle`, `data`)
|
||||
unchanged. The Gen 2 trainer card is the one player-art read still outside
|
||||
it: its portrait is a tile sheet that also carries the frame tiles, not a
|
||||
swappable pic.
|
||||
`pokemon.sprite` has a second site of its own in
|
||||
`src/ui/gen2/BattleState.lua`, which adds `letter` (Unown) and `shiny` to the
|
||||
Gen 1 ctx keys -- both concepts Red does not have.
|
||||
- *Save and the script VM:* `save.created`, `save.loaded`, `save.loading`,
|
||||
`save.writing`; hooks `save.write`, `save.new_game`, `script.command`, and
|
||||
the `script.started` / `script.ended` pair off `src/script/gen2/Vm.lua`.
|
||||
`script.command` reports a mod's own row under the name `"modcommand"` with
|
||||
the row's real operands, and may rewrite them, on the same path it wraps a
|
||||
cart row.
|
||||
|
||||
## New in Gen 2
|
||||
|
||||
These have no Gen 1 analogue -- Red has no friendship byte, no day care egg,
|
||||
no Pokegear, no radio, no held items -- so they are the only places a new name
|
||||
is justified. They are **live**, guarded by `Runtime.wants` /
|
||||
`Runtime.wantsHook`, and each is driven through a real bus by
|
||||
`tests/engine/gen2_new_seams.lua`.
|
||||
|
||||
### Events
|
||||
|
||||
| event | raised from | payload |
|
||||
| --- | --- | --- |
|
||||
| `happiness.changed` | `Happiness` (`ChangeHappiness`, `StepHappiness`) | `mon`, `event`, `reason` (`"event"` / `"step"`), `delta`, `from`, `to` |
|
||||
| `breeding.egg_created` | `Breeding` (`DayCare_InitBreeding`) | `egg`, `mother`, `father`, `compatibility`, `stepsToEgg` |
|
||||
| `egg.hatched` | `Breeding` | `mon`, `egg`, `slot`, `species`, `nickname` |
|
||||
| `phone.call_received` | `PhoneRing.script` | `call`, `contact`, `name`, `className`, `special`, `scriptKey` |
|
||||
| `clock.day_changed` | `Clock` | `day`, `previous`, `reason` |
|
||||
| `pokerus.infected` | `Pokerus` | `party`, `slot`, `mon`, `strain`, `days`, `source` |
|
||||
| `roamer.moved` | `Roamers` | `index`, `slot`, `species`, `from`, `to`, `reason` |
|
||||
| `roamer.encountered` | `Roamers` | `index`, `slot`, `species`, `level`, `mapId` |
|
||||
| `apricorn.converted` | `Apricorns` (Kurt) | `apricorn`, `ball`, `event` |
|
||||
| `bug_contest.scored` | `BugContest` | `mon`, `score`, `place`, `results` |
|
||||
| `unown.unlocked` | `Unown` (`UpdateUnownDex`) | `letter`, `name`, `word`, `count` |
|
||||
| `radio.channel` | `MapRadio` | `station`, `channel`, `name`, `source` |
|
||||
| `mail.written` | `Mail` | `entry`, `slot`, `mon`, `message`, `author`, `source` |
|
||||
| `mail.read` | `Mail` | `entry`, `message`, `author`, `top`, `bottom` |
|
||||
| `intro.boot.copyright` | `CopyrightSplash:enter` | `screen`, `game` |
|
||||
| `intro.boot.gamefreak` | `GameFreakPresents:enter` | `screen`, `game` |
|
||||
| `intro.boot.movie` | `GoldSilverIntro:enter` | `screen`, `game` |
|
||||
| `intro.boot.movie_ended` | `GoldSilverIntro:finish` | `screen`, `game`, `skipped`, `frames` |
|
||||
| `intro.boot.title` | `TitleState:enter` | `screen`, `game` |
|
||||
|
||||
The four `intro.boot.*` cards are the GS boot cinema, and they are the one part
|
||||
of Gold's intro with no Gen 1 moment to share a name with: Red boots into
|
||||
`IntroMovie` with no copyright card, no GAME FREAK splash and no attract movie.
|
||||
The Oak speech immediately after them is the opposite case and reuses
|
||||
`intro.oak_speech.*` verbatim (see the shared table above).
|
||||
|
||||
Each card raises its name the frame it comes up, because that is the moment a
|
||||
mod can act on. Only the movie has an `_ended` name, and only because it
|
||||
carries a fact nothing downstream does -- `skipped` is the difference between a
|
||||
player who watched all 2335 frames and one who pressed START. The other three
|
||||
cards chain straight into the next card, whose own event is their end.
|
||||
|
||||
`delta` on `happiness.changed` is `to - from`, not the table's column, because
|
||||
the 0 and $ff carry clamps are part of what the cart applied: a mon at 254
|
||||
gaining "5" gained 1.
|
||||
|
||||
`clock.day_changed` compares against a process-local latch, so the first read
|
||||
after a boot has nothing to compare against and raises nothing. That is by
|
||||
design; it is a day *change*, not a day report.
|
||||
|
||||
`unown.unlocked` is raised from `UpdateUnownDex` -- a form first entering the
|
||||
`#DEX` list -- not from the four `ENGINE_UNLOCKED_UNOWNS_*` puzzle flags. Those
|
||||
flags are written by the cart's own `setflag`, so there is no Lua transition at
|
||||
the puzzle solve to hang a second event on yet.
|
||||
|
||||
`mail.read` rides `Mail.lines` with a per-struct latch, because the read page
|
||||
redraws every frame. The latch is re-armed by `Mail.get` / `Mail.mailbox`,
|
||||
which is how both readers pick the letter they are about to open, so reopening
|
||||
the same letter raises a second event.
|
||||
|
||||
### Hooks
|
||||
|
||||
| hook | wraps | ctx | vanilla answer |
|
||||
| --- | --- | --- | --- |
|
||||
| `held_item.trigger` | `Battle:heldEffect` | `battle`, `mon`, `item`, `def`, `effect`, `parameter`, `trigger` | `ctx.effect, ctx.parameter` |
|
||||
| `breeding.compatibility` | `Breeding.compatibility` | `data`, `mon1`, `mon2`, `dayCare` | the vanilla byte |
|
||||
| `phone.contact_list` | `Phone`'s `wPhoneList` read | called `(save, list)`, the shape the other list hooks use | the same list |
|
||||
| `shiny.roll` | `Mon` | `dvs`, `species`, `def`, `level` | the DV-derived boolean |
|
||||
| `gender.roll` | `Mon` | `def`, `dvs`, `ratio`, `species`, `level` | the DV-derived gender |
|
||||
|
||||
`held_item.trigger` is one hook over eight call sites, because on the cart
|
||||
those eight *are* one routine (`GetUserItem` / `GetOpponentItem` loading b and
|
||||
c, and the caller comparing b against the `HELD_*` it cares about). `trigger`
|
||||
says which comparison is about to happen: `"priority"` (Quick Claw),
|
||||
`"damage"` (Scope Lens and the type-boost family), `"endure"` (Focus Band),
|
||||
`"flinch"` (King's Rock), `"accuracy"` (BrightPowder), `"confuse"`,
|
||||
`"residual"` (the end-of-turn Leftovers / Berry / cure arm), and `"check"` for
|
||||
any other read. Return nil to make the item do nothing at that trigger, or
|
||||
another `HELD_*` name to substitute one -- every call site compares against a
|
||||
name, so substitution is the whole mechanism.
|
||||
|
||||
`held_item.trigger` wraps the *read*, so a mod can suppress or substitute an
|
||||
effect from any item. Defining a **new** held item is the `held_items`
|
||||
registry's job, and the two compose: register the row, then steer it from the
|
||||
hook.
|
||||
|
||||
`phone.contact_list` refuses an answer of the wrong length or with an unknown
|
||||
contact id (unknown ids blank to 0 on purpose, so the Pokegear never indexes a
|
||||
nil). It reorders and blanks the ten save slots; registering a contact id the
|
||||
game does not know is `phone_contacts`' job.
|
||||
|
||||
`shiny.roll` does not override a forced-shiny battle (`opts.shiny`), which is
|
||||
how the cart's own scripted shiny Gyarados stays shiny.
|
||||
|
||||
## Registries with no Gen 2 home
|
||||
|
||||
Writing to one of these while Gold is running takes the write, drops it, and
|
||||
reports it once per mod into the same error feed the manager shows. It is not
|
||||
fatal: a mod that supports both generations registers its Gen 1 content
|
||||
unconditionally and still loads the half that applies. The report is worded
|
||||
from the boot's own generation, because the gating runs both ways.
|
||||
|
||||
`rulesets`, `transitions`, `field`, `text_pointers`, `link_fields`,
|
||||
`map_scripts`.
|
||||
|
||||
`Schemas.GEN2` in `src/mods/Schemas.lua` is the authoritative table, and
|
||||
`tests/engine/gate_gen2_mod_api.lua` holds it to the catalog.
|
||||
|
||||
The list used to have three causes behind it and now has one. "No Data path
|
||||
exists" closed when the overworld tables stopped loading off disk into World
|
||||
fields. "The shape differs" closed when a registry gained the option of
|
||||
carrying a Gen 2 record schema beside its Gen 1 one. What is left is one cause:
|
||||
|
||||
**Gold reimplements the system without reading a registry.** The Gen 1 target
|
||||
is still built and merged into, but nothing in a Gold boot ever looks at it, so
|
||||
routing the registry would be a merge into a table with no reader -- exactly
|
||||
the silent no-op the gate exists to prevent. Closing one of these is a consumer
|
||||
change in the Gen 2 module first and a routing row second:
|
||||
|
||||
- `rulesets`: no Gen 2 ruleset dispatch exists.
|
||||
- `transitions`: Gold draws its own battle intro
|
||||
(`src/ui/gen2/BattleTransition.lua`), and its `STYLES` is a boolean *set* of
|
||||
the four cart wipes (`spin`, `speckle`, `zoom`, `sine`) rather than the
|
||||
`{ frames, draw, sound, flash }` record this registry carries. There is no
|
||||
styleDef lookup for a registered id to reach, so a mod style would fail the
|
||||
`STYLES` membership test and fall back to vanilla -- routing it would be the
|
||||
silent no-op, not the fix.
|
||||
- `field`: the Gen 1 overworld's data grab bag. Gold's equivalents live in
|
||||
`data.gen2Maps` and the VM's own tables.
|
||||
- `text_pointers`: Gen 1's `TEXT_*` indirection. Gold's text *is* pointers.
|
||||
- `link_fields`: link play is Gen 1 only.
|
||||
- `map_scripts`: `data.gen2Scripts` is the cart's bytecode pool keyed by ROM
|
||||
pointer, and a Lua row list merged into it is not something
|
||||
`src/script/gen2/Vm.lua` can run. Routing it needs a Gen 2 side dispatcher in
|
||||
`World`, not just the verb table `mod.commands` already has. The
|
||||
`script.started` / `script.ended` / `script.command` seams do fire, so a mod
|
||||
observes and can veto a script it cannot yet author whole.
|
||||
|
||||
Four of this list closed after it was written, and how they closed is the
|
||||
pattern for the rest:
|
||||
|
||||
- **`growth_rates`** now routes to the SHARED Gen 1 target. Gold's curves are
|
||||
coefficient rows in the extracted `pokemon.lua`, so `src/mods/Builtins.lua`'s
|
||||
Gen 2 registrant wraps each as the `{ expForLevel }` record Gen 1's registry
|
||||
uses, and `src/battle/gen2/Mon.lua:growthFor` is the one accessor all six
|
||||
readers go through (`Mon` twice, `BattleState`, `SummaryMenu`, `Breeding`,
|
||||
`ItemEffects`). One record shape, one id space, one mod source for both
|
||||
games. Because it is routed, the `pokemon` schema's `growthRate` reference is
|
||||
now checked rather than skipped, and it resolves: both sides say
|
||||
`GROWTH_MEDIUM_SLOW`.
|
||||
- **`tokens`** was on the list by mistake rather than by cause. `TextBox.new`
|
||||
runs `TextBox.substitute` on every box in both generations and `substitute`
|
||||
reads `game.data.tokens`, so the shared target was live on Gold the whole
|
||||
time. A `{NAME}` a mod registers expands in the world, the menus and the VM's
|
||||
pages alike.
|
||||
- **`battle_sprite_scales`** closed consumer-first, the `growth_rates` way:
|
||||
`src/ui/gen2/BattleState.lua` grew `imageScale` / `picScale`, a faithful
|
||||
mirror of Gen 1's `BattleState.imageBattleScale` / `resolveBattleScale` down
|
||||
to skipping `_owners` and the image-then-species-then-default order, so the
|
||||
registry now routes to the SHARED Gen 1 path and one record serves both
|
||||
games. Only the default is generation-specific, and neither side reads that
|
||||
from the registry.
|
||||
- **`render_pipelines`** closed because the reader moved, not the registry:
|
||||
`src/core/Game2.lua:load` installs `src/render/Pipelines.lua` on Gold's
|
||||
merged dataset after `mods:load` and `Game2:draw` composites `present`. The
|
||||
`drawWorld` half is still inert, which is why this one is worth reading the
|
||||
caveat above for -- it is routed on the strength of the half that works, and
|
||||
Gold retires a drawWorld-only level rather than pretending.
|
||||
|
||||
## Hooks and events Gold does not raise yet
|
||||
|
||||
Gold has its own draw path, intro, evolution and sprite lookups, so the call
|
||||
sites in those Gen 1 modules are not on Gold's path. The names are not taken
|
||||
and not reserved for Gen 1: when a Gen 2 call site lands it uses the existing
|
||||
name and the existing payload, plus fields where Gen 2 genuinely carries more
|
||||
(the split special stats, held items on a trainer roster).
|
||||
|
||||
The list is much shorter than it was. What is outstanding, in descending value:
|
||||
|
||||
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
|
||||
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
|
||||
preparation boundary or a battle-local player-party view. Gen 1 mods can use
|
||||
the hook documented in `docs/modding.md`; do not claim Gold compatibility
|
||||
when that selection is required.
|
||||
- `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its
|
||||
own yet.
|
||||
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
|
||||
Gen 2 fingerprint and handshake exist (`src/link/Fingerprint.lua` hashes a
|
||||
Gen 2 surface and a cross-generation pairing is refused by name), but nothing
|
||||
in `src/ui/gen2/` opens onto the protocol, so these raise nowhere.
|
||||
|
||||
Four groups that used to sit here have since landed and moved to the shared
|
||||
table above: the frame seams (`render.compose` / `render.hud` /
|
||||
`render.letterbox` / `render.zones`, `input.step` / `input.pointer`), the three
|
||||
battle seams (`battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`), the two sprite lookups (`pokemon.sprite`,
|
||||
`pokemon.icon`), and the catch/evolution trio (`pokemon.caught`,
|
||||
`pokemon.evolved`, `evolution.check` -- `src/ui/gen2/BattleState.lua` emits
|
||||
`pokemon.caught` from `pushCaught` once the mon is in the party or the box, and
|
||||
`src/core/gen2/Evolution.lua` emits `pokemon.evolved` from `apply` and wraps
|
||||
each row's decision in `evolution.check`).
|
||||
|
||||
Three partial coverages worth knowing about, because "the hook exists" is not
|
||||
the same as "the hook sees everything":
|
||||
|
||||
- `encounter.roll` / `encounter.species` are wired into the grass/water step,
|
||||
`randomwildmon`, the Bug Contest and SWEET SCENT, but **not** into
|
||||
`World:tryHeadbutt`, `World:rockMonEncounter` or `Roamers.checkEncounter`.
|
||||
Those three read row shapes that are not `{ species, level }` slot lists, so
|
||||
a mod that reskins encounters misses headbutt trees, rock smash and the
|
||||
roamers.
|
||||
- `src/ui/gen2/BattleState.lua` builds a flat `opts` for `Catching.attempt`
|
||||
with no `data` in it, so a mod-registered ball is readable through
|
||||
`Catching.recordFor` but is not yet resolved at the real throw site.
|
||||
- Three Gold UI files carry their own copy of the status HUD labels the merged
|
||||
`statuses` records now hold as `hudLabel`, so a mod status shows no label in
|
||||
the battle HUD, the party menu or the summary page until they read
|
||||
`Battle.statusRecordFor(data, status).hudLabel`. The values are identical
|
||||
today, so nothing vanilla is affected.
|
||||
|
||||
## Gen 2 tables with no registry
|
||||
|
||||
`Game2:load` assigns 24 `data.gen2*` tables and 12 of them are registry-backed,
|
||||
so twelve sit in `game.data` on a Gold boot with no registry pointing at them:
|
||||
`gen2Marts`, `gen2Roofs`, `gen2StdScripts`, `gen2EventTables` (the phone book,
|
||||
in-game trades, elevator labels, decoration descriptions), `gen2InitialEvents`,
|
||||
`gen2Pokedex`, `gen2MenuGfx`, `gen2Intro`, `gen2Credits`, `gen2Diploma`,
|
||||
`gen2Trade`, and `gen2Scripts` (which the `map_scripts` registry does reach, so
|
||||
it is the one of the twelve that is not out of reach). Naming registries for the
|
||||
rest is new API surface rather than a routing change, so it is deliberately not
|
||||
done yet.
|
||||
|
||||
## Testing a Gen 2 mod
|
||||
|
||||
Static first. `gen2check` reads the manifest, scans every `.lua` the package
|
||||
carries and cross-references what it finds against the coverage table above:
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py gen2check my_mod # or a path
|
||||
python3 tools/modkit.py gen2check my_mod --notes # + the caveat on each backed member
|
||||
```
|
||||
|
||||
It reports one of `will load`, `will load but degrade` or `will not work`, with
|
||||
a `MK4xx` finding per site and an `unresolved:` note, carrying a file and a
|
||||
line, for every reach a static scan could not follow. Exit 0 clean, 1 on a
|
||||
fatal finding (or any finding under `--strict`), 2 on usage; `--json` emits the
|
||||
whole batch as one document, and `--quiet` prints the findings alone, so a
|
||||
clean mod prints nothing and the exit code is the answer. The rule ladder is
|
||||
`MK400`-`MK410` and is listed in `tools/modkit.py`'s section header.
|
||||
|
||||
Then the headless harness, which takes the generation without booting Gold:
|
||||
|
||||
```lua
|
||||
local run = T.sdk.loadMod("mods/my_mod", { generation = 2 })
|
||||
T.eq(run.mod and run.mod.state, "loaded",
|
||||
"runs on gen 2: " .. tostring(run.mod and run.mod.skipReason))
|
||||
T.eq(#run.errors, 0, "and loads with no boot errors")
|
||||
```
|
||||
|
||||
Everything else is the production path: same loader, same validate, same
|
||||
topological sort, same merge. Assert the state as well as the error count: a
|
||||
gate skip is deliberately not an error, so `#run.errors == 0` passes for a mod
|
||||
that never ran a line.
|
||||
|
||||
Neither substitutes for a real Gold boot, and the two output channels there are
|
||||
not the same. The adapter's own warnings (`Gen2Compat.warnOnce`) go to the log
|
||||
only, each attributed to the mod holding the facade. The boot error feed the
|
||||
manager shows is `loader.errors`: a failed mod, a duplicate id, a registry with
|
||||
no Gen 2 target, a cross-validation problem, and a require for a Gen 1 module
|
||||
the adapter does not serve. A skipped mod and a degraded member are on neither
|
||||
list, by design.
|
||||
@@ -0,0 +1,96 @@
|
||||
# RFC 0008 — Runtime mod option schema export
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `src/mods/Loader.lua`. Tests:
|
||||
`tests/mod_loader_tests.lua`. This RFC defines an optional filesystem
|
||||
contract; it does not require a native launcher or any other consumer.
|
||||
|
||||
## Motivation
|
||||
|
||||
A native launcher may want to present settings for installed mods before it
|
||||
starts the game. Running every mod's entry chunk in that launcher just to
|
||||
discover its settings would duplicate engine behavior and give the launcher
|
||||
an unnecessary code-execution surface. The engine already has the authoritative
|
||||
runtime schemas after mod loading, so it can publish a data-only snapshot for
|
||||
platform shells that want one.
|
||||
|
||||
## The exact contract
|
||||
|
||||
After the mod loader has finished running entry chunks, it may write
|
||||
`mod_option_schemas.json` beside `options.lua` in the same filesystem. The
|
||||
document is a snapshot of the current boot; it is not a second settings store
|
||||
and does not change how option values are read or written.
|
||||
|
||||
Version 1 has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"mods": {
|
||||
"example": [
|
||||
{"key":"enabled","type":"toggle","label":"Enabled","default":true},
|
||||
{"key":"mode","type":"choice","label":"Mode","default":"safe",
|
||||
"choices":[["Safe","safe"],["Fast","fast"]]},
|
||||
{"key":"rate","type":"number","label":"Rate","default":5,
|
||||
"min":0,"max":10,"step":1},
|
||||
{"key":"name","type":"text","label":"Name","default":"","maxLen":12}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`mods` is keyed by mod id. Its rows come from the runtime
|
||||
`mod.options:define` schema, or from the legacy manifest `options_schema` file
|
||||
when the runtime schema is absent. The supported row types are `toggle`,
|
||||
`choice`, `number`, and `text`. Their optional fields retain the meanings
|
||||
established by the existing in-game option UI: choices are `[label, value]`
|
||||
pairs, numeric rows may provide `min`, `max`, and `step`, and text rows may
|
||||
provide `maxLen`. A row may also use
|
||||
`visible_if = {key = "mode", equals = "compact"}` or replace `equals` with
|
||||
`not_equals`. This only hides the in-game menu row; the schema and stored value
|
||||
remain available, and consumers that do not implement conditions may ignore
|
||||
the field.
|
||||
|
||||
Only mods that are enabled and successfully loaded in the current boot are
|
||||
included. A disabled or failed mod must not contribute rows. If an older
|
||||
snapshot exists and the current boot has no schema-bearing mods, the producer
|
||||
overwrites it with `{"schema_version":1,"mods":{}}`; this prevents stale
|
||||
settings rows from surviving a disable or load failure. A fresh mod-free boot
|
||||
does not create the file, and a filesystem without write support is tolerated.
|
||||
|
||||
The producer writes the snapshot after entry chunks and the final load set
|
||||
have been established. Consumers must treat the file as untrusted input and
|
||||
must not execute anything from it.
|
||||
|
||||
## Compatibility and versioning
|
||||
|
||||
The contract is optional on both sides. A native consumer may be absent, and
|
||||
the engine continues normally if the file cannot be written. A native
|
||||
consumer is not required to render, validate, or persist every supported row;
|
||||
it may ignore an unknown row type or optional field.
|
||||
|
||||
For compatibility with files produced by the original unversioned prototype,
|
||||
a missing `schema_version` means version 1. Consumers must ignore documents
|
||||
with a newer version rather than guessing at their shape. Producers must bump
|
||||
the version whenever they change the document envelope or the meaning of an
|
||||
existing field. New optional row fields that older consumers can safely ignore
|
||||
do not require a bump. Version 1 is therefore the legacy unversioned format as
|
||||
well as the explicitly versioned format shown above.
|
||||
|
||||
## Migration note
|
||||
|
||||
Nothing. Existing mods, option values, and the in-game options UI are
|
||||
unchanged. Platforms that do not consume `mod_option_schemas.json` have no
|
||||
new integration requirement.
|
||||
|
||||
## Parity tests
|
||||
|
||||
`tests/mod_loader_tests.lua` verifies the explicit version, runtime and legacy
|
||||
row round-tripping, enabled/disabled filtering, failed-mod filtering,
|
||||
stale-snapshot clearing, and tolerance of a read-only filesystem.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is deprecated. The unversioned file form remains readable as legacy
|
||||
version 1; new producers write the explicit `schema_version` field.
|
||||
@@ -1,23 +1,288 @@
|
||||
# Native modding
|
||||
|
||||
The modding book lives on the
|
||||
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
|
||||
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
|
||||
|
||||
- [Getting started](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Getting-Started)
|
||||
- [Getting started](https://github.com/bryanthaboi/gen1recomp/wiki/Getting-Started)
|
||||
— install a mod, write a first one, enable and disable it.
|
||||
- [Tutorials](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Tutorials)
|
||||
- [Tutorials](https://github.com/bryanthaboi/gen1recomp/wiki/Tutorials)
|
||||
— twelve dependency-ordered rungs, each a runnable mod.
|
||||
- [Cookbook](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Cookbook)
|
||||
- [Cookbook](https://github.com/bryanthaboi/gen1recomp/wiki/Cookbook)
|
||||
— task-sized recipes.
|
||||
- [Registry reference](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Reference-Registries)
|
||||
- [Registry reference](https://github.com/bryanthaboi/gen1recomp/wiki/Reference-Registries)
|
||||
— every registry, generated from `src/mods/Schemas.lua`.
|
||||
|
||||
Regenerate the reference straight into a wiki checkout:
|
||||
Regenerate the reference. With no argument it writes in-repo, to
|
||||
`docs/modding/reference/registries.md`; name a wiki checkout to write the
|
||||
wiki's own page name into it instead:
|
||||
|
||||
```sh
|
||||
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
||||
luajit tools/gen_registry_docs.lua
|
||||
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
||||
```
|
||||
|
||||
## Manifest specification (`manifest.json`)
|
||||
|
||||
Every mod contains a root `manifest.json` defining its metadata, supported games, and dependencies for the engine loader.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_mod",
|
||||
"name": "My Cool Mod",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "GAMEPLAY",
|
||||
"games": ["gen1", "gen2"],
|
||||
"game_version": ">=0.0.0-dev <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [
|
||||
"helper_lib@^1.0.0",
|
||||
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
|
||||
],
|
||||
"optional_dependencies": [
|
||||
"gen1_modern_ui"
|
||||
],
|
||||
"required_imports": [
|
||||
{
|
||||
"id": "stadium2",
|
||||
"name": "Pokemon Stadium 2 ROM",
|
||||
"description": "Pokemon Stadium 2 (USA), any supported N64 byte order",
|
||||
"file": "stadium2.z64",
|
||||
"format": "n64",
|
||||
"size": 67108864,
|
||||
"md5": ["00000000000000000000000000000000"]
|
||||
}
|
||||
],
|
||||
"optional_imports": [
|
||||
{
|
||||
"id": "bonus_source",
|
||||
"name": "Optional bonus source",
|
||||
"file": "bonus.bin",
|
||||
"md5": "00000000000000000000000000000000"
|
||||
}
|
||||
],
|
||||
"conflicts": [],
|
||||
"permissions": ["engine_internals"],
|
||||
"description": "A brief description of the mod.",
|
||||
"github": "author/my_mod"
|
||||
}
|
||||
```
|
||||
|
||||
### Manifest Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). |
|
||||
| `name` | `string` | Human-readable title shown in launcher and manager. |
|
||||
| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). |
|
||||
| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). |
|
||||
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
||||
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
||||
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
|
||||
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
||||
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
||||
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
|
||||
| `required_imports` | `array` | User-supplied files required by this mod. The launcher validates and copies each file into this mod's `baseroms/` directory; the mod does not load while one is missing. |
|
||||
| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. |
|
||||
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
|
||||
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
|
||||
| `log_url` | `string` | Optional https URL for `mod.postLog` log reporting (api 2; requires the `network` permission). |
|
||||
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
|
||||
|
||||
### Declaring Dependencies & Scoping
|
||||
|
||||
Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats:
|
||||
|
||||
1. **Simple string**: `"mod_id"`
|
||||
2. **Version-pinned string**: `"mod_id@^1.2.0"`
|
||||
3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"`
|
||||
4. **Structured object**:
|
||||
```json
|
||||
{
|
||||
"id": "mod_id",
|
||||
"range": "^1.2.0",
|
||||
"games": ["gen2"],
|
||||
"github": "owner/repo"
|
||||
}
|
||||
```
|
||||
|
||||
#### Version-Scoped Dependencies
|
||||
When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it.
|
||||
|
||||
### Required user-supplied files
|
||||
|
||||
`required_imports` and `optional_imports` keep copyrighted or otherwise user-owned source material
|
||||
out of mod archives while giving every platform the same installation flow.
|
||||
Each object requires a stable `id`, a display `name`, a destination `file`
|
||||
(a filename, never a path), and one MD5 digest or an array of accepted MD5
|
||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||
`description` gives players dump or region guidance in the import panel.
|
||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
||||
per-import ceiling when an exact size is not appropriate. Every import also
|
||||
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
||||
filesystem reports an invalid size.
|
||||
|
||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
||||
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
||||
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
||||
mod: the launcher never scans or copies another mod's imported files merely
|
||||
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
|
||||
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
||||
permission is exposed. Missing `required_imports` block the mod before its
|
||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||
launcher panel but do not block loading.
|
||||
|
||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
||||
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
||||
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||
must not include anything beneath `baseroms/`. The engine records a validation
|
||||
receipt keyed by file size and modification time so launcher refreshes and
|
||||
later boots do not repeatedly hash an unchanged imported ROM.
|
||||
|
||||
New mobile code should call `love.system.pickFile("required_import")`. The
|
||||
older iOS-only `"stadium"` picker kind remains temporarily for compatibility.
|
||||
Android now returns `false` for unknown picker kinds instead of treating them
|
||||
as game-ROM picks.
|
||||
|
||||
### Platform import flow
|
||||
|
||||
The same per-mod validation and private `mods/<mod-id>/baseroms/` destination
|
||||
applies on every supported platform. Windows, macOS, and Linux use the
|
||||
launcher file chooser. Android uses the Storage Access Framework, and iOS uses
|
||||
the Files document picker; both stage the choice as `picked_required_import.bin`
|
||||
before validation. Xbox/UWP uses its native picker and hands the launcher a
|
||||
temporary path. Switch/NX has no host picker, so the player copies a file to
|
||||
`imports/baseroms/` over MTP and chooses the import again. No platform grants
|
||||
the mod a host filesystem path or bypasses the manifest's size, format, and MD5
|
||||
checks.
|
||||
|
||||
## Mods and Gold (Gen 2)
|
||||
|
||||
The mod API is one API across both generations, but Gold runs its own battle
|
||||
engine, overworld, script VM and save format, so a mod says which games it is
|
||||
for and Gold serves a declared subset of the surface.
|
||||
|
||||
- [`docs/preparing-your-mod-for-gen2.md`](preparing-your-mod-for-gen2.md)
|
||||
the migration guide: what breaks, the `games` manifest key, the module
|
||||
adapter, the patterns no adapter can fix, and a worked before/after.
|
||||
- [`docs/mod-api-gen2-compat.md`](mod-api-gen2-compat.md)
|
||||
the reference: every registry, hook and event, whether Gold serves it, and
|
||||
the record-shape differences where it does.
|
||||
|
||||
Start with the checker, which reads your manifest and scans your Lua against
|
||||
the adapter's own coverage table:
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py gen2check mods/my_mod
|
||||
```
|
||||
|
||||
## Editing maps in Tiled
|
||||
|
||||
Maps are data, not assets, so they can be authored in a real map editor and
|
||||
exported as a mod. `tools/tiled_export.py` builds a
|
||||
[Tiled](https://www.mapeditor.org) workspace out of the imported ROM cache:
|
||||
|
||||
```sh
|
||||
python3 tools/tiled_export.py # -> build/tiled/ (gitignored)
|
||||
```
|
||||
|
||||
Open `build/tiled/gen1.tiled-project`, edit any of the 222 maps (or
|
||||
`kanto.world` for the stitched overworld), and export with the
|
||||
`gen1-mod-export` extension — one map file, or a whole loadable mod folder.
|
||||
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.
|
||||
|
||||
## Read-only map overviews
|
||||
|
||||
`mod.world:mapOverview()` returns collision `rows` at map-cell resolution,
|
||||
optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
4x resolution. Visual rows contain Game Boy shades from `"0"` (lightest) to
|
||||
`"3"` (darkest); their matching width and height fields describe the grid.
|
||||
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
||||
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
||||
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
||||
the same contract while applying their own object and event visibility rules.
|
||||
|
||||
## Party ordering
|
||||
|
||||
Companion UIs and alternate party screens can call
|
||||
`mod.world:canReorderParty()` before offering a reorder action, then
|
||||
`mod.world:reorderParty(fromSlot, toSlot)` with one-based party slots. The
|
||||
operation is accepted only during idle overworld play; menus, movement,
|
||||
scripts, battles, and transitions leave the party untouched.
|
||||
|
||||
## Contextual field actions
|
||||
|
||||
`mod.world:availableFieldActions()` returns the field items and moves that can
|
||||
start at the player's current position. Both games expose `bicycle`, `fish`,
|
||||
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
|
||||
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
||||
contextual `squirtbottle` key item. Fishing rows include the owned rods that
|
||||
are valid choices. The list is empty while the world is busy, and omits an
|
||||
action whenever its item, move, badge, terrain, or engine state forbids it.
|
||||
The optional second return is `"world is busy"` during transient input locks
|
||||
or `"no overworld"` before a playable world exists.
|
||||
|
||||
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
|
||||
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
|
||||
and chooses automatically when only one rod is available. Invalid, stale, and
|
||||
busy requests return `nil` plus a reason without changing game state. Mods do
|
||||
not need generation-specific badge, terrain, bike, fishing, or field-move
|
||||
logic. Action lists are extensible; callers should render the records they
|
||||
understand and ignore unknown ids rather than assuming a fixed list length.
|
||||
|
||||
## Read-only battle snapshots
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
record while one is active. Gen 1 (Red, Blue, and Yellow) and Gold expose the
|
||||
same core fields:
|
||||
`revision`, `kind`, `catchable`, `prompt`, `message`, `turn`, `player`,
|
||||
`enemy`, `party`, `moves`, and `items`. Pokémon, moves, messages, and items in
|
||||
the result are detached records; changing them cannot change the battle.
|
||||
`revision` stays stable while the visible battle context is unchanged and
|
||||
advances when it changes, so a UI can skip rebuilding an identical view.
|
||||
|
||||
Pokémon records contain `species`, `name`, `level`, `hp`, `maxHp`, `status`,
|
||||
and `active` (plus `slot` in `party`). Move records contain `slot`, `id`,
|
||||
`name`, `pp`, `maxPp`, `type`, `power`, `accuracy`, and `disabled`. Gen 1 also
|
||||
reports the actual ruleset-aware `displayPower`, `hitChance` percentage, and
|
||||
`effectiveness` multiplier (`10` neutral, `20` super-effective, `5`
|
||||
resisted). Item rows contain `id`, `name`, `count`, `ball`, `needsTarget`, and
|
||||
an optional stock `catchChance` percentage.
|
||||
|
||||
`prompt` describes the currently visible choice (`menu`, `moves`, `party`,
|
||||
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
|
||||
phase owns input. Generation-specific features remain optional: Gen 1 includes
|
||||
battle medicine, balls, catch previews, Safari balls, and Mimic choices;
|
||||
Gold currently returns an empty `items` list rather than guessing at its
|
||||
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent
|
||||
optional ones.
|
||||
|
||||
## Battle menu intents
|
||||
|
||||
`mod.battle:submit(intent)` applies a validated choice to the snapshot the mod
|
||||
just read. Every intent needs a mod-owned, strictly increasing positive
|
||||
integer `id` and the latest snapshot `revision`. Stale, replayed, covered, or
|
||||
invalid choices return `nil` plus a reason without changing the battle.
|
||||
|
||||
The shared Red, Blue, Yellow, and Gold intents are:
|
||||
|
||||
- `{ kind = "menu", choice = "fight" }` (`party`, `item`, and `run` are the
|
||||
other accepted choices)
|
||||
- `{ kind = "move", slot = 1..4 }`
|
||||
- `{ kind = "back" }` while the move menu is active
|
||||
|
||||
Menu choices and moves use the same engine methods as the native controls;
|
||||
`party` and `item` open the native screens rather than exposing or duplicating
|
||||
their mutable logic. Tutorial, link, Safari, forced, stale, and covered battle
|
||||
states refuse these core intents. Use `mod.input` for ordinary text advance.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
@@ -83,6 +348,44 @@ Three rules worth knowing:
|
||||
Returning `nil` from `drawWorld` is a normal answer meaning "not this
|
||||
frame"; the engine draws the vanilla world instead.
|
||||
|
||||
## Variable-size overworld sprites
|
||||
|
||||
The `sprites` registry keeps the vanilla 16x16 grounded walker as its default,
|
||||
but a mod can describe any frame rectangle and anchor for player characters,
|
||||
NPCs, followers, mounts, vehicles, bosses, or other field actors:
|
||||
|
||||
```lua
|
||||
mod.content.sprites:register("SPRITE_COMPANION", {
|
||||
image = "mods/example/companion.png", -- one frame per row
|
||||
frames = 6,
|
||||
walker = true,
|
||||
frameWidth = 32,
|
||||
frameHeight = 32,
|
||||
anchorX = 16, -- frame-relative bottom-center anchor
|
||||
anchorY = 32,
|
||||
})
|
||||
```
|
||||
|
||||
`frameWidth` and `frameHeight` are sheet pixels. `anchorX` and `anchorY` are
|
||||
measured from each frame's top-left; when omitted they default to the frame's
|
||||
horizontal center and bottom edge, so a larger sprite grows upward while its
|
||||
feet stay on the same world cell. Omitting all four fields is exactly the
|
||||
vanilla 16x16 placement. The normal player/NPC/follower draw paths consume
|
||||
these values automatically, including horizontal flips and the fishing pose.
|
||||
|
||||
Custom render pipelines can use the same geometry without reproducing the
|
||||
pose rules:
|
||||
|
||||
```lua
|
||||
local geometry = sprite:getPoseGeometry(facing, walkPhase, stepFlip)
|
||||
-- geometry.quad, .x/.y/.width/.height, .anchorX/.anchorY, .mirror
|
||||
local originX, originY = sprite:getScreenOrigin(px, py, camX, camY)
|
||||
```
|
||||
|
||||
`getFrameGeometry(frame)` is the corresponding accessor for a specific
|
||||
zero-based sheet frame. Both accessors return fresh tables and share the
|
||||
renderer’s frame selection and mirror conventions.
|
||||
|
||||
## Battle sprite scaling
|
||||
|
||||
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
|
||||
@@ -123,6 +426,198 @@ 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.
|
||||
|
||||
## Durable tool storage and runtime checkpoints
|
||||
|
||||
`mod.save` remains the right place for state that should travel with the next
|
||||
normal Pokémon SAVE. Tools that need independently written, larger data-only
|
||||
records can use `mod.storage`; the engine scopes every logical key by game
|
||||
version, opaque playthrough identity, and mod id, and routes it through the same
|
||||
standard or portable persistence backend as saves:
|
||||
|
||||
```lua
|
||||
local context, code, message = mod.storage:context(game)
|
||||
local ok, code, message = mod.storage:write(game, "history/quick/q0001", {
|
||||
format = 1, createdAt = os.time(), payload = { money = 3000 },
|
||||
})
|
||||
local value, code, message = mod.storage:read(game, "history/quick/q0001")
|
||||
local keys, code, message = mod.storage:list(game, "history/quick")
|
||||
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||
```
|
||||
|
||||
For independently generated binary data, use the opaque byte methods. They
|
||||
accept and return the exact Lua string of bytes, including NUL bytes and bytes
|
||||
that are not valid text:
|
||||
|
||||
```lua
|
||||
local ok, code, message = mod.storage:writeBytes(
|
||||
game, "cache/maps/pallet/terrain", encodedMesh)
|
||||
local encodedMesh, code, message = mod.storage:readBytes(
|
||||
game, "cache/maps/pallet/terrain")
|
||||
```
|
||||
|
||||
Opaque values are limited to 512 MiB per key. The engine stores them without
|
||||
decoding, compression, or an engine-defined file format, and never executes
|
||||
them. A consuming mod owns validation of its format, fingerprint, checksum,
|
||||
and compression metadata. Byte writes are staged and compared byte-for-byte
|
||||
before replacement, and reads can recover a valid backup after an interrupted
|
||||
write. Existing table values and opaque byte values use one shared logical key
|
||||
space; delete a key before changing its value from one type to the other.
|
||||
|
||||
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
private. A title-selected context may additionally contain `normalSavedAt`, the
|
||||
validated matching ordinary-save chronology only; it never exposes normal-save
|
||||
progress or a slot/path handle.
|
||||
|
||||
At the title screen only, `mod.storage:selected(game)` returns a bound storage
|
||||
facade for the launcher-selected existing playthrough, or `nil, code, message`.
|
||||
Resolving this facade is non-allocating: it never allocates an identity, adopts a
|
||||
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
|
||||
`write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
|
||||
`list(prefix)`, and `delete(key)` methods have the same scoped and
|
||||
transactional contract as `mod.storage`, but remain restricted to the calling
|
||||
mod's selected existing namespace. It is intended for title tools that need to
|
||||
browse or manage durable history before the first normal SAVE.
|
||||
|
||||
Table values must contain serializable data only. Opaque values must be Lua
|
||||
strings. Keys are conservative slash-separated segments (letters, digits, `_`,
|
||||
`-`); paths and filesystem handles are never exposed. Table writes are staged
|
||||
and decode-verified; opaque writes are staged and byte-verified; reads recover
|
||||
from a valid staged/backup generation. Methods return structured errors for
|
||||
normal data, byte validation, and I/O failures. The playthrough identity is
|
||||
allocated lazily on the first storage/checkpoint call, so an unused API changes
|
||||
no save bytes.
|
||||
|
||||
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
|
||||
|
||||
```lua
|
||||
local capability = mod.checkpoints:inspect(game)
|
||||
if capability.canCapture then
|
||||
local checkpoint, code, message = mod.checkpoints:capture(game)
|
||||
-- Store the detached data-only checkpoint through mod.storage.
|
||||
end
|
||||
|
||||
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
|
||||
|
||||
-- After the tool has durably committed its first checkpoint, make a
|
||||
-- never-saved playthrough reachable through ordinary title boot exactly once.
|
||||
local anchored, anchorCode, anchorMessage =
|
||||
mod.checkpoints:ensureNormalSave(game, checkpoint)
|
||||
```
|
||||
|
||||
Checkpoint format 1 supports settled overworld control and proven battle
|
||||
player-decision safe points. Ordinary single-player wild/trainer encounters are
|
||||
supported. Scripted story battles are also supported when the engine can detach
|
||||
their current built-in battle command and data-only row continuation, rebind any
|
||||
NPC by stable id, and resume the story through a fresh runner. The suspended Lua
|
||||
coroutine is never serialized. Link, Safari, ghost, demo, opaque callback,
|
||||
non-data-only script, animation, message, queue, concurrent-script, and
|
||||
forced-action phases fail closed. New checkpoints preserve gameplay RNG, while legacy overworld records
|
||||
without RNG remain loadable. Capture excludes global options and runtime
|
||||
objects. Restore validates format, game/playthrough identity, content,
|
||||
coordinates, battle relationships, continuation, and RNG before mutation;
|
||||
preserves current options; suppresses normal map-entry/save-load/intro side
|
||||
effects; verifies a recapture; and rolls back runtime plus RNG in memory if
|
||||
reconstruction fails. Callers that need crash recovery should durably capture
|
||||
their own recovery checkpoint before restore.
|
||||
|
||||
Checkpoint ownership follows the persistence model rather than mod identity:
|
||||
|
||||
- canonical `game.save` progress, including every mod's `save.modData` /
|
||||
`mod.save` bucket and data-only fields added to saved Pokémon, rewinds;
|
||||
- global and per-mod options remain at their current values;
|
||||
- independently written `mod.storage` records do not rewind; and
|
||||
- mod-owned runtime objects, references, and caches are never serialized.
|
||||
|
||||
Successful restore emits `checkpoint.restored` only after reconstruction and
|
||||
differential recapture have committed. Mods that cache rewound progress or hold
|
||||
references to reconstructed runtime objects can re-read their own public state
|
||||
and rebuild at that point:
|
||||
|
||||
```lua
|
||||
mod.events:on("checkpoint.restored", function(ev)
|
||||
-- ev.kind is "overworld" or "battle"; ev.game is fully reconstructed.
|
||||
cachedQuestStage = mod.save:get("quest_stage", 0)
|
||||
rebuildRuntimeFor(ev.game, ev.kind)
|
||||
end)
|
||||
```
|
||||
|
||||
The event is not emitted for validation failure, failed reconstruction, or a
|
||||
successful rollback. Its payload contains no checkpoint data or other mod's
|
||||
private state. A mod that deliberately stores progress-coupled truth in
|
||||
`mod.storage` must version and reconcile that relationship itself; the engine
|
||||
cannot distinguish it safely from independent history, configuration, or cache
|
||||
data.
|
||||
|
||||
`mod.checkpoints:resume(game, checkpoint)` is the title-session counterpart to
|
||||
live `restore`. It validates the same data-only checkpoint against the
|
||||
engine-selected existing playthrough, reconstructs only after all validation
|
||||
passes, preserves current options, and verifies by recapture. A title session
|
||||
has no live gameplay rollback state: if reconstruction or verification fails,
|
||||
the engine rebuilds a usable title session and returns `false, code, message`.
|
||||
It never rewrites a normal Pokémon save. It is unavailable outside title and does
|
||||
not broaden capture or arbitrary-frame support.
|
||||
|
||||
`mod.checkpoints:ensureNormalSave(game, checkpoint)` is a separate live-runtime
|
||||
operation for durable checkpoint tools. It creates ordinary progress only when
|
||||
none exists, only after validating that the supplied checkpoint is the exact
|
||||
current safe runtime, and through the normal atomic save lifecycle. Once an
|
||||
ordinary save exists it returns `true, "already_exists"` without writing, so
|
||||
subsequent checkpoints and the player's later SAVE commands remain independent.
|
||||
Call it only after the tool's own checkpoint/index commit; treat an anchoring
|
||||
failure as a failed first checkpoint rather than claiming restart safety.
|
||||
See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
|
||||
codes.
|
||||
|
||||
At that same settled supported wild/trainer decision boundary, a tool may claim
|
||||
START through `battle.menu_auxiliary`. It receives `(next, game, context)`, where
|
||||
`context` is the data-only `{ kind = "wild" }` or `{ kind = "trainer" }`; it
|
||||
never receives the live battle controller. Return `true` to consume START after
|
||||
opening source-owned UI, or call `next(game, context)` to allow lower-priority
|
||||
handlers. With no handler, START remains inert. Ordinary encounters and the
|
||||
validated built-in scripted battle origins described by RFC 0005 are eligible;
|
||||
opaque scripts, link/Safari/ghost/demo battles, action queues,
|
||||
animation/messages, forced choices, and every phase that cannot safely be
|
||||
checkpointed remain excluded. Exceptions are contained by normal hook isolation
|
||||
and fall through without advancing a turn.
|
||||
|
||||
Gen 1 trainer encounters also expose `trainer.before_battle` after the
|
||||
challenge text and immediately before battle construction. This lets a mod
|
||||
defer the encounter while it collects a player choice through a registered
|
||||
screen, then resume with a battle-local view of the save party:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
|
||||
-- context = { trainerClass, partyIndex, mapId, npcId }
|
||||
mod.ui.push(game, "party_registration", {
|
||||
onConfirm = function(indices)
|
||||
continue({ playerPartyIndices = indices })
|
||||
end,
|
||||
onCancel = function()
|
||||
continue({ cancel = true })
|
||||
end,
|
||||
})
|
||||
return true
|
||||
end)
|
||||
```
|
||||
|
||||
Return `true` only when retaining `continue` for a later callback. Calling
|
||||
`continue({ cancel = true })` ends the encounter without constructing a battle;
|
||||
the normal encounter completion callback returns control to the overworld and
|
||||
no trainer-defeated state is written. A cancelled sight encounter is suppressed
|
||||
at the current player cell so it cannot immediately reopen; moving one cell or
|
||||
talking to the trainer permits a new challenge. Calling `continue()` uses the
|
||||
full save party; passing
|
||||
`{ playerPartyIndices = { 2, 4, 5 } }` uses those ordered, one-based party
|
||||
members for initial send, switching and forced replacement, exhaustion,
|
||||
experience traversal, and battle party displays. The continuation is one-shot.
|
||||
An empty, duplicate, out-of-range, or otherwise malformed list safely falls
|
||||
back to the full party. The view references the original Pokemon records and
|
||||
never reorders or replaces `game.save.party`; trainer battle checkpoints retain
|
||||
the selected indices. Mods remain responsible for selection policy and should
|
||||
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||
contract and compatibility guarantees.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
@@ -150,5 +645,408 @@ 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.
|
||||
|
||||
## Tool input and title-menu hooks
|
||||
|
||||
Tool mods that need to act once per game logic tick can wrap `input.step`.
|
||||
It runs immediately before queued button edges are promoted, so input added by
|
||||
the wrapper is visible during that same fixed step. The callback receives
|
||||
`(next, game, dt)` and must call `next(game, dt)`.
|
||||
|
||||
`input.pointer` delivers uncaptured gameplay pointer events -- touches and
|
||||
real mouse input alike. The callback receives `(next, game, ev)` where `ev`
|
||||
is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is
|
||||
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
|
||||
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
|
||||
are LOVE window units, the same space `render.hud`'s viewport and the touch
|
||||
overlay lay out in. The on-screen touch controls keep first refusal: a
|
||||
pointer that begins on a virtual control belongs to the pad for its whole
|
||||
lifecycle and never reaches the hook, while one that begins outside stays
|
||||
visible even if it later crosses a control. A real mouse reaches the hook
|
||||
without `POKEPORT_TOUCH` (synthesized `istouch` mouse twins are dropped, so
|
||||
a mobile touch fires once), and focus or visibility loss and input recovery
|
||||
deliver a `"cancelled"` for every pointer the hook saw pressed but not yet
|
||||
released. Return `true` without calling `next` to consume the event.
|
||||
|
||||
`mod.input` presses GB buttons source-safely. `mod.input:tap(game, btn)`
|
||||
queues exactly one `wasPressed` edge for the next fixed step and holds
|
||||
nothing; `local token = mod.input:press(game, btn)` holds the button until
|
||||
`mod.input:release(token)`. Buttons are `up`, `down`, `left`, `right`, `a`,
|
||||
`b`, `start` and `select`. Every press is its own input source inside the
|
||||
engine's multi-source bookkeeping, so releasing a token never clears a hold
|
||||
the keyboard, a controller, the touch overlay or another mod still owns;
|
||||
`release` is idempotent and refuses tokens taken by another mod.
|
||||
Outstanding tokens are released automatically on entry-chunk rollback, hot
|
||||
reload and input recovery.
|
||||
|
||||
`ui.title_menu.items` receives `(next, game, items)` and follows the same
|
||||
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
|
||||
for a tool to offer a fresh-session action before gameplay begins.
|
||||
|
||||
Ephemeral tools can wrap `save.write(next, game)` and return `false` to veto a
|
||||
progress write before world state is captured or any bytes reach disk.
|
||||
|
||||
`render.hud` receives `(next, game, viewport)` after the finished game frame is
|
||||
composited and before touch controls draw. The window-space viewport contains
|
||||
`width`, `height`, `gameX`, `gameY`, `gameWidth`, `gameHeight`, `scale`, `dpiX`,
|
||||
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
||||
playfield or pushing an updating game state.
|
||||
|
||||
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
|
||||
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
|
||||
the mod full control of the window, while calling `next` runs the engine's
|
||||
normal single-window composite so the mod can decorate around it. `ctx` carries
|
||||
the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
||||
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` /
|
||||
`setEnabled`) for driving a second physical display. `pollTouch()` returns the
|
||||
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
||||
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
|
||||
or push one onto a second screen, without the engine knowing the layout.
|
||||
|
||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
||||
for mods that need the engine's normal composite rather than its separate
|
||||
layers. It runs after registered present pipelines and before GBCFX,
|
||||
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
|
||||
`true` only while output ownership is needed, and the second receives
|
||||
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
||||
`gameHeight`, `scale`, `dpiX`,
|
||||
`dpiY`, and `generation`. Returning `true` from `render.output` takes over the
|
||||
window; calling `next(ctx)` keeps the normal presentation. Both hooks default
|
||||
to `false`. Enabling the seam requires a full-window canvas for that frame.
|
||||
With no `render.output` subscriber, or while `render.output_enabled` is false,
|
||||
the existing presentation path is unchanged. `render.compose` takes precedence
|
||||
when it owns the frame.
|
||||
|
||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||
palette-zone ownership. The state remains on the stack and keeps its normal
|
||||
update and input ownership, so a mod can mirror a native menu on another
|
||||
display without reimplementing it. The default is `true`. Treat the wrapper as
|
||||
a pure predicate: the renderer may ask it more than once per frame.
|
||||
|
||||
Scrollable list states expose `state.kind` for use with this hook. Generic
|
||||
lists fall back to their title; PC lists use stable, localization-independent
|
||||
identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
||||
`pc_box_change`, `pc_item_withdraw`, `pc_item_deposit`, and `pc_item_toss`.
|
||||
|
||||
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||
control the battle text/menu layer and the HP/status panels. Both receive
|
||||
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
||||
Both hooks apply to Gen 1 and Gen 2 battles.
|
||||
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
|
||||
for that battle, so hiding the bottom layer cannot leave their white backing
|
||||
behind under another overlay. Text boxes also pass through the hook as their
|
||||
own state, preserving selective control outside a battle; a wrapper that only
|
||||
owns battle presentation should return `false` only for its active battle or
|
||||
text-box state.
|
||||
|
||||
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
||||
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
||||
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
|
||||
category `Game.speedCategoryInStack` says is active right now. A mod may
|
||||
call `next(game)` and return its result to pass that resolution through, or
|
||||
return a different number outright to override it for that frame (a bot mod
|
||||
forcing 1X for one route segment, say, regardless of the category or saved
|
||||
option). The result is clamped to the nearest valid `GameSpeed.LEVELS` entry
|
||||
regardless of what a subscriber returns, so a bad value (0, negative, `nil`)
|
||||
cannot destabilize the fixed-step accumulator. This hook runs *after* link
|
||||
play's 1X lock and the `--speed`/equivalent run-argument override, both of
|
||||
which stay unconditional and are never visible to a subscriber.
|
||||
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
## Process-lifecycle hooks
|
||||
|
||||
These exist so a platform-specific launcher integration (a native shell
|
||||
that embeds this engine and wraps its window in platform UI) can live
|
||||
entirely in a mod instead of hand-patching `main.lua`, which every other
|
||||
engine change also touches.
|
||||
|
||||
`core.update` receives `(next, game, dt)` once per frame from
|
||||
`love.update`. Vanilla behavior is `game:update(dt)`, unconditionally. A
|
||||
mod may skip calling `next(game, dt)` to pause the simulation for that
|
||||
frame (e.g. while a native settings sheet is on top), and may run
|
||||
additional per-frame polling before or after that call regardless of
|
||||
whether it calls `next` -- useful for one-shot flags that must be observed
|
||||
every frame even while paused.
|
||||
|
||||
`core.quit_to_launcher` receives `(next)` once from `love.quit()`. `next()`
|
||||
returns the engine's own decision for whether closing the window should
|
||||
return to the Lua launcher instead of exiting; a mod may return `false`
|
||||
outright, without ever calling `next`, to veto that and let the process
|
||||
really quit -- for a platform host that owns its own "return to launcher"
|
||||
UI and would otherwise get looped straight back into the game it just
|
||||
quit.
|
||||
|
||||
A manifest may also declare `force_enable_env`, an environment variable
|
||||
name that re-enables the mod regardless of a saved disable in
|
||||
`options.mods` when that variable is set to `"1"`. This is for a mod that
|
||||
cannot function disabled on the one build where its env var is set (a
|
||||
platform-bridge mod bundled only with that build's launcher, for example).
|
||||
|
||||
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
|
||||
already falls straight through to the vanilla function when no mod has
|
||||
wrapped the name, at negligible cost.
|
||||
|
||||
## Detached Pokémon icon presentation
|
||||
|
||||
`mod.ui.PokemonIcon.draw(game, summary, x, y, opts)` draws the same party icon
|
||||
the native Party menu would resolve without exposing a live Pokémon record or
|
||||
the private Party menu. `summary` is the detached data-only shape
|
||||
`{ species = string, hp = integer, maxHp = integer }`; `opts.selected` and
|
||||
`opts.counter` optionally request the native selected-icon animation phase.
|
||||
|
||||
The engine retains icon ownership. Content registered through
|
||||
`mod.content.icons`, species `icon` definitions, asset overrides, and the
|
||||
public `pokemon.icon` hook therefore continue to compose. Invalid summaries
|
||||
return `false, code, message` and draw nothing. The helper is presentation
|
||||
only: it does not expose moves, status, checkpoint payloads, or mutable party
|
||||
state.
|
||||
|
||||
## Shared date and time presentation
|
||||
|
||||
The global Options menu owns `DATE FORMAT` (`DEVICE`, `DD-MM-YYYY`,
|
||||
`MM-DD-YYYY`, `YYYY-MM-DD`) and `TIME FORMAT` (`DEVICE`, `24 HOUR`, `12 HOUR`).
|
||||
These preferences live in `options.lua`, so checkpoint restore never rewinds
|
||||
them. `DEVICE` uses the process time locale when the platform provides one;
|
||||
the portable fallback is `DD-MM-YYYY` plus 24-hour time.
|
||||
|
||||
Mods format captured timestamps through the read-only public facade:
|
||||
|
||||
```lua
|
||||
local date = mod.datetime:date(game, createdAt)
|
||||
local time = mod.datetime:time(game, createdAt)
|
||||
local both = mod.datetime:dateTime(game, createdAt)
|
||||
```
|
||||
|
||||
The live `game` supplies only the current option context. Formatting never
|
||||
mutates the save, options, or timestamp, and invalid timestamps return
|
||||
`"----"`.
|
||||
|
||||
## Device power information
|
||||
|
||||
Sandboxed mods can read the host's battery state without receiving the rest
|
||||
of `love.system`:
|
||||
|
||||
```lua
|
||||
local state, percent = mod.device:powerInfo()
|
||||
```
|
||||
|
||||
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
|
||||
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
|
||||
the platform cannot report it. The facade is read-only and does not expose
|
||||
URL launching, clipboard access, or other system operations.
|
||||
|
||||
## Real-world steps
|
||||
|
||||
On iOS and Android the game counts the player's real-world steps natively
|
||||
(HealthKit / the hardware step counter). A mod reaches that bridge through
|
||||
the `steps` permission in `manifest.json`, which the player sees in the
|
||||
mod manager like every other permission:
|
||||
|
||||
```lua
|
||||
if mod.steps:available() then
|
||||
mod.steps:sync() -- async; OS consent sheet on first use
|
||||
end
|
||||
-- later, at a quiet moment:
|
||||
local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil
|
||||
```
|
||||
|
||||
`available()` is `false` on builds without the bridge (desktop) and for
|
||||
mods without the permission, so a probe is always safe. `sync()` asks the
|
||||
platform to refresh its count and returns whether there was a bridge to
|
||||
ask. `poll()` returns the next delivery for this mod — the engine consumes
|
||||
the native side's pending file itself, each permissioned mod receives its
|
||||
own copy of a delivery, and steps are anchored natively so the same walk
|
||||
is never delivered twice. Without the permission, `sync` and `poll` raise
|
||||
an error naming it.
|
||||
|
||||
## Background HTTP
|
||||
|
||||
`mod.fetch` is how a mod does work off the main thread. It is behind the
|
||||
`network` permission in `manifest.json`, the same one that gates
|
||||
`require("socket")`, and the player sees it in the mod manager.
|
||||
|
||||
```lua
|
||||
-- somewhere once
|
||||
local job = mod.fetch:get("https://example.com/data.json")
|
||||
|
||||
-- in a hook or update, every frame -- poll never blocks
|
||||
if job then
|
||||
local r = mod.fetch:poll(job)
|
||||
if r.status ~= "pending" then
|
||||
if r.status == "ok" then use(r.body) else warn(r.err) end
|
||||
mod.fetch:release(job)
|
||||
job = nil
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
`get(url, opts)` returns an opaque handle, or `nil` plus a reason. `opts`
|
||||
takes `accept` (a request Accept header) and `maxSeconds` (clamped to 30).
|
||||
`poll(handle)` returns `{ status, body, err, progress }` where `status` is
|
||||
`"pending"`, `"ok"`, `"error"` or `"cancelled"`; it is a copy, and it never
|
||||
blocks, so calling it every frame is the intended use. `release(handle)`
|
||||
frees a finished job — do it, or you will hit the ceiling. `cancel(handle)`
|
||||
drops a result you no longer want. `available()` is `false` when the build
|
||||
has no transport and for mods without the permission, so a probe is safe.
|
||||
|
||||
The rules worth knowing before you design around it:
|
||||
|
||||
- **http and https only.** The underlying transport also speaks `file://`,
|
||||
`ftp://` and `scp://`; those are refused, on the initial URL and on any
|
||||
redirect. `mod.fetch` is not a way to read a local file.
|
||||
- **Four requests in flight per mod.** The worker pool is shared with the
|
||||
launcher's own downloads, so one mod cannot fill it. Over the ceiling,
|
||||
`get` returns `nil` and a reason until you release something.
|
||||
- **Handles are yours alone.** A handle from another mod, a fabricated
|
||||
table, or a guessed number all poll as `"error"`.
|
||||
- **Your mod id is in the User-Agent**, so a server operator can see who is
|
||||
calling and a mod cannot pose as the launcher.
|
||||
- Jobs are released when your mod unloads.
|
||||
|
||||
This is deliberately not `love.thread`. A LÖVE thread is a fresh Lua state
|
||||
with a full standard library that the sandbox cannot reach, so handing one
|
||||
to a mod would undo every other rule; `mod.fetch`'s workers run engine
|
||||
code, so a mod gets asynchrony without gaining any new reach.
|
||||
|
||||
## Log reporting
|
||||
|
||||
`mod.postLog(body, opts)` is the one-way exception to the rule that a mod
|
||||
decides where it talks. It reports a debug/crash log to the https URL the
|
||||
manifest declares in `log_url`, and it is the only API that may not be
|
||||
pointed at a caller-chosen address:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": ["network"],
|
||||
"log_url": "https://logs.example.com/receive"
|
||||
}
|
||||
```
|
||||
|
||||
The URL is validated at load: it must be `https://`, and declaring it
|
||||
without the `network` permission is a load violation for api 2 mods. The
|
||||
destination is reviewed when the mod ships, not chosen per call, so a mod
|
||||
cannot aim this at arbitrary hosts or read back anything a server replies.
|
||||
|
||||
```lua
|
||||
-- fire and forget; poll() never blocks, same shape as mod.fetch
|
||||
local job = mod:postLog("session crashed at 0x1f3a\n" .. logText)
|
||||
```
|
||||
|
||||
`postLog(body, opts)` returns the same opaque handle as `mod.fetch:get`,
|
||||
polled and released through `mod.fetch:poll` / `mod.fetch:release`. `opts`
|
||||
is a closed list with one switch: `format`, either `"text"` (the default)
|
||||
or `"json"`. `json` wraps the body in an envelope of `{ ts, mod, format,
|
||||
body }` so a server can attribute and sort reports; any other key or value
|
||||
is refused before a job is submitted. The body is capped at 64 KB, the
|
||||
transfer is bounded by the same worker ceilings as `mod.fetch`, and the
|
||||
response body is never returned to the mod.
|
||||
|
||||
## Background jobs
|
||||
|
||||
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
|
||||
the CPU — generating a map, crunching a table, anything that would otherwise
|
||||
stall a frame. It is behind the `background` permission in `manifest.json`.
|
||||
|
||||
Ship the job as its own file inside your mod:
|
||||
|
||||
```lua
|
||||
-- mods/your_mod/jobs/crunch.lua
|
||||
local arg = ...
|
||||
local total = 0
|
||||
for i = 1, arg.n do total = total + i end
|
||||
return { total = total }
|
||||
```
|
||||
|
||||
```lua
|
||||
-- in your entry file
|
||||
local job = mod.job:run("jobs/crunch.lua", { n = 1e6 })
|
||||
|
||||
-- later, in a hook -- poll never blocks
|
||||
local r = mod.job:poll(job)
|
||||
if r.status == "ok" then
|
||||
use(r.result.total)
|
||||
mod.job:release(job)
|
||||
end
|
||||
```
|
||||
|
||||
`run(script, arg, opts)` returns an opaque handle, or `nil` plus a reason.
|
||||
`opts.maxSeconds` sets the job's time budget (default 5, clamped to 30).
|
||||
`poll(handle)` returns `{ status, result, err }` with `status` one of
|
||||
`"pending"`, `"ok"`, `"error"` or `"cancelled"`. `release(handle)` frees it.
|
||||
`available()` is `false` on a host without threads and for mods without the
|
||||
permission, so a probe is always safe.
|
||||
|
||||
**A job is pure compute.** This is the part to design around, not a detail:
|
||||
|
||||
- **Plain data in, plain data out.** Numbers, strings, booleans and tables of
|
||||
them. A function, userdata, a cycle or a table key that is not a string or
|
||||
number is refused at your `run` call with a reason. Nothing is shared —
|
||||
your argument is snapshotted, and mutating the original afterwards does not
|
||||
reach the job.
|
||||
- **No engine API, no game state, no storage.** `require` is refused inside a
|
||||
job, and there is no `mod` object. A job cannot read the party, write
|
||||
`mod.storage`, or touch a registry. Get what it needs into the argument and
|
||||
act on the result back on the main thread.
|
||||
- **Your script is a file in your mod folder.** The path goes through the same
|
||||
rules as `mod:read`; `..`, absolute paths and drive letters are refused.
|
||||
- **Two jobs per mod, four on the machine.** Over the limit, `run` returns
|
||||
`nil` and a reason until you release one.
|
||||
- **The budget bounds how long YOU wait, not how long the work runs.** Past
|
||||
`maxSeconds`, `poll` reports an error and the result is dropped if it ever
|
||||
arrives — but the thread runs to its own end. There is no way to stop a
|
||||
LÖVE thread from outside, and every attempt to stop one from inside was
|
||||
worse than the disease (a debug hook does not reliably interrupt LuaJIT,
|
||||
and raising from one wedged the whole process). `cancel(handle)` is the
|
||||
same deal: it drops the result, it does not stop the work.
|
||||
|
||||
So **write jobs that terminate.** A job with an infinite loop will keep one
|
||||
core busy until the game closes. It will not freeze the game — the main
|
||||
thread stays responsive and quitting still works — but nothing will reclaim
|
||||
that core in the meantime.
|
||||
|
||||
Your job script runs in the same sandbox your entry file does, so `io`, `os`,
|
||||
`debug`, `ffi`, `package` and `love.filesystem` are absent there too. That is
|
||||
the whole reason this exists rather than `love.thread`: a raw LÖVE thread is a
|
||||
fresh Lua state with a full standard library that the sandbox cannot reach, so
|
||||
handing one to a mod would undo every other rule. Here the worker builds your
|
||||
sandbox first and loads your chunk into it.
|
||||
|
||||
## Pre-sandbox globals (compat)
|
||||
|
||||
A mod written before the sandbox landed does not have to be updated to
|
||||
load. `io`, `package`, `dofile`, `loadfile`, `os.getenv`, `love.filesystem`,
|
||||
`love.system` and `love.event` are all present again as compat stand-ins
|
||||
(`src/mods/LegacyCompat.lua`), and assigning a LÖVE callback
|
||||
(`love.mousemoved = fn`) installs on the real table the way it always did.
|
||||
Every stand-in call logs one warning naming its replacement, and
|
||||
`loader:legacyReport(modId)` returns the same list with call counts, which
|
||||
is what a "needs updating" badge should read.
|
||||
|
||||
The stand-ins are not the old globals. Paths are classified rather than
|
||||
passed through:
|
||||
|
||||
- A path inside your own mod directory reads the file you shipped.
|
||||
- Anything else, including an absolute path, resolves into a private
|
||||
per-mod overlay at `mod_compat/<your id>/` under the save directory.
|
||||
Two mods naming the same path never see each other's bytes, and nothing
|
||||
is written outside the game tree.
|
||||
- A read misses through the overlay to your shipped file, then to
|
||||
`mod.storage`, so a half-migrated mod sees both.
|
||||
- A write over a path you shipped shadows it; the packaged file is never
|
||||
modified, and `mod:read` still returns the packaged bytes.
|
||||
- `love.filesystem.getSaveDirectory()` and `os.getenv("HOME")` answer with
|
||||
a virtual root, so a legacy mod that joins its own paths lands back in
|
||||
the same overlay.
|
||||
|
||||
`love.thread` stays refused. A LÖVE thread runs in a separate Lua state
|
||||
with the full standard library, which the sandbox in this state cannot
|
||||
reach, so a stand-in would be a hole rather than a reroute. The same goes
|
||||
for `ffi`, `debug`, `setfenv`, `os.execute`, `io.popen`, `love.run` and
|
||||
`love.errorhandler`. A mod that needs real background work needs an
|
||||
engine-owned facility, not a compat shim -- for HTTP that facility is
|
||||
[`mod.fetch`](#modfetch), which runs on the engine's own worker pool.
|
||||
|
||||
@@ -1,196 +1,38 @@
|
||||
# New features (deliberate additions beyond the original)
|
||||
# New Features
|
||||
|
||||
Intentional enhancements this port adds on top of faithful Pokémon Red
|
||||
behavior. They have no Game Boy equivalent and are kept by design.
|
||||
Genuine divergences from the original (things still missing, wrong, or
|
||||
approximated) live in docs/known-differences.md; faithfully-ported
|
||||
behavior is in docs/behavior-porting-notes.md.
|
||||
Features intentionally added beyond the original Pokémon Red, Blue, and Yellow games:
|
||||
|
||||
## Survey zoom
|
||||
* **Survey zoom** with connected-map rendering and configurable void fill
|
||||
* **Perspective tilt mode** for an HD-2D-style overworld
|
||||
* **Multiple color modes**, including original, SGB, advanced GBC, monochrome, and classic green
|
||||
* **Optional GBC screen effects**, including pixel grids, shadows, glare, and transparency
|
||||
* **Performance presets** and configurable FPS limits
|
||||
* **Peer-to-peer link play** for trades and battles between Red, Blue, and Yellow
|
||||
* **Persistent custom options** stored separately from game saves
|
||||
* **Optional widescreen battle layout**
|
||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||
* **Translation and custom font support**
|
||||
* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data
|
||||
* **Tiled map editing tools** for mod authors
|
||||
* **Pokédex diploma and printer image exports**
|
||||
* **Community mod browser**
|
||||
* **Soft reset button combination**
|
||||
* **Keyboard and controller rebinding**
|
||||
* **Mod profiles** with separate mod settings and save slots
|
||||
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage
|
||||
* **Improved launcher and save editor UI**, including background downloads and update checks
|
||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
||||
|
||||
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
|
||||
zooms the overworld between 1 pixel per world pixel (full survey) and 2×
|
||||
the window fit scale (close-up), in crisp integer steps. This has no Game
|
||||
Boy equivalent:
|
||||
## Pokémon Gold (Gen 2)
|
||||
|
||||
- Connected maps render their full bodies, and their NPCs appear as
|
||||
visual-only "ghosts", they wander but have no sight lines, triggers,
|
||||
dialogue, or collision until the map is actually entered.
|
||||
- Menus, text boxes, and battles draw at normal scale on top of the
|
||||
zoomed world. Zoom input is ignored while a script, menu, or battle is
|
||||
active; the zoom offset is persisted as `save.options.zoom` (default
|
||||
`0` = FIT) and survives New Game via `options.lua`.
|
||||
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
|
||||
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
|
||||
- Beyond the border ring the void fill repeats indefinitely (see VOID
|
||||
FILL below); interiors keep their own border block. Each visible map
|
||||
area is colorized with its own SGB palette (the original recolored the
|
||||
whole screen per map).
|
||||
- Neighbor maps load two connection hops out so corner-adjacent maps
|
||||
don't pop in and out, and ghost NPCs share instances with the real ones
|
||||
so their wander positions persist across seamless connection crossings
|
||||
(a warp or fresh map entry still respawns everything at its script
|
||||
position, like the original's per-entry sprite init).
|
||||
|
||||
## VOID FILL
|
||||
|
||||
The Options **VOID FILL** row picks what paints the infinite beyond-edge
|
||||
space on OVERWORLD-tileset maps during survey zoom:
|
||||
|
||||
- **TREES** (default): solid tree wall block `$0F`.
|
||||
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
|
||||
- **BLACK**: solid black.
|
||||
|
||||
Other tilesets are unchanged (house/cave borders stay as authored).
|
||||
Persisted as `save.options.voidFill`.
|
||||
|
||||
## Tilt mode
|
||||
|
||||
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
|
||||
tilt of the overworld through **OFF → 15° → 35° → 50° → OFF** for an HD-2D /
|
||||
diorama look. Like survey zoom this is purely presentational and has no
|
||||
Game Boy equivalent:
|
||||
|
||||
- The entire map tilts as one rigid ground plane, paths, grass, water,
|
||||
floors, and every background-tile structure (buildings, trees, fences,
|
||||
signs; in Gen 1 these are baked into the tile layer, not sprites), so
|
||||
rows above the player recede and rows below come toward the viewer. Only
|
||||
things that actually *stand* on the ground draw as upright billboards,
|
||||
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
||||
and the standing FX attached to them (emote bubbles, the fishing rod,
|
||||
the FLY bird). The Poké Center heal-machine overlay stays on the ground
|
||||
plane with the machine tiles (it is OAM glued to a BG graphic, not a
|
||||
standing sprite). An earlier revision tried
|
||||
billboarding buildings/trees/signs too (cutting them out of the ground
|
||||
per hand-curated per-tileset tables); that chased an endless tail of
|
||||
special cases, dense tree canopy, fences fused into grass, building
|
||||
facades with their own baked-in fake perspective, because Gen 1's art
|
||||
was never drawn with a clean seam between ground and standing scenery. It
|
||||
wasn't merged; tilting everything but the characters as one plane is the
|
||||
simpler, shipped tradeoff (buildings recede/foreshorten with the ground
|
||||
like a photo of a diorama, rather than standing fully upright next to
|
||||
a full-height character).
|
||||
- Cycling tweens the angle between levels over ~0.25s rather than snapping;
|
||||
with tilt fully off the world pass drops back onto the flat blit path, so
|
||||
flat rendering stays pixel-identical to tilt-off and off costs nothing.
|
||||
- Tilt input is gated exactly like survey zoom, honored only while
|
||||
free-roaming, ignored while a script, menu, or battle is active, and it
|
||||
composes with survey zoom (the zoom scale feeds the projection). The tilt
|
||||
level is persisted in `save.options.tilt` (default OFF).
|
||||
- It applies everywhere the overworld draws, interiors and caves included.
|
||||
Menus, text boxes, and battles render flat on top, unaffected, and the
|
||||
infinite beyond-the-border-ring fill stays flat by design.
|
||||
- Collision, movement, sight lines, triggers, encounters, and scripts are
|
||||
untouched; nothing about the tilt reaches gameplay.
|
||||
|
||||
## Colors mode
|
||||
|
||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||
through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
||||
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||
|
||||
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
||||
red BG palette + one green OBJ palette, every map, no per-map variation
|
||||
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
|
||||
The player/NPCs stay green over the red terrain via the OBP bake +
|
||||
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
|
||||
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||
per-species mon colors (`data/palettes_gbc.lua`).
|
||||
- **OG**: force the four DMG grays (colorization off).
|
||||
- **OG INV**: inverted DMG grays.
|
||||
- **SGB INV**: each SGB zone palette with shade order reversed.
|
||||
- **CLASSIC**: original Game Boy pea-soup greens
|
||||
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
|
||||
|
||||
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
|
||||
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
||||
override in the overworld pass). Persisted as `save.options.colors`; the
|
||||
`gbc` / `gbc_inv` save ids are kept for back-compat under the new labels.
|
||||
|
||||
## GBC FX
|
||||
|
||||
The `5` key (and the Options menu GBC FX row) cycles a "played on real
|
||||
unlit-GBC hardware" post-process through **OFF → 1 → 2 → 3 → 4**. The
|
||||
levels are a cumulative ladder:
|
||||
|
||||
- **1**: reflective-screen backing transparency.
|
||||
- **2**: + LCD pixel grid.
|
||||
- **3**: + pixel drop shadows.
|
||||
- **4**: + sunlight glare and rainbow shimmer with a drifting light.
|
||||
|
||||
It runs as a final present pass after world + UI composite in
|
||||
`Renderer:endFrame`, inspired by the Pixel Transparency RetroArch shader
|
||||
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
|
||||
Default OFF; persisted as `save.options.gbcfx`.
|
||||
|
||||
## Peer-to-peer link play (lua-enet)
|
||||
|
||||
Trades and link battles connect two copies of the game directly over
|
||||
lua-enet (ENet ships inside LÖVE, nothing to install, no server to run)
|
||||
on a reliable-ordered channel, replacing the original standalone Python
|
||||
room-code relay (`tools/relay_server.py`, deleted). HOST A GAME shows the
|
||||
host's LAN address (UDP 7777; `POKEPORT_LINK_PORT` overrides); JOIN A
|
||||
GAME enters it. Closing performs a graceful ENet disconnect so the final
|
||||
confirm/bye always lands; a vanished peer exits with "The link was
|
||||
broken." Internet play needs a forwarded UDP port or a VPN (deliberate
|
||||
tradeoff vs. the relay). Headless tests drive the protocol over an
|
||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||
also exercises real UDP pairing.
|
||||
|
||||
## Custom boot text
|
||||
|
||||
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
|
||||
"bois club" / "bryanthaboi", a deliberate branding customization. The
|
||||
rest of the boot beats (copyright splash, "presents" shooting-star, the
|
||||
Nidorino-vs-Gengar attract scene) mirror the original.
|
||||
|
||||
|
||||
## Custom Options
|
||||
|
||||
Options persist in a standalone `options.lua` (separate from the game
|
||||
progress `save.lua`), so audio/display/battle preferences survive New Game
|
||||
and aren't wiped when a save slot is cleared. Changing a row in the Options
|
||||
menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
|
||||
flushes the live options. Old saves that still embed an `options` table are
|
||||
migrated once into `options.lua` on load.
|
||||
|
||||
- Music / SFX volume
|
||||
- Music Filter
|
||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
|
||||
SuperPalettes + per-species mon colors)
|
||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
||||
`-`/`=` step one level and save
|
||||
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
|
||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
|
||||
a hard render frame-rate cap (`save.options.fpsCap`).
|
||||
|
||||
## Battle transition cascade + white battle letterbox
|
||||
|
||||
Into-battle wipes still run the original eight styles inside the classic
|
||||
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
|
||||
blocks cascade outward from that square into the surrounding world so the
|
||||
void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||
letterbox voids around the battle canvas fill **white** instead of black
|
||||
so the whole window reads as one continuous battle screen.
|
||||
|
||||
## On-screen touch controls (mobile)
|
||||
|
||||
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
|
||||
center) over the frame, using Xelu's CC0 controller prompts
|
||||
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
|
||||
finger does, sliding on the d-pad changes direction without lifting, and
|
||||
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
|
||||
appears while no controller is being used: the first gamepad button or
|
||||
stick push hides it, the next screen touch brings it back, and unplugging
|
||||
the last controller restores it immediately. Layout re-derives from the
|
||||
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
|
||||
* **UI that stays fixed while the overworld zooms**
|
||||
* **Border-block surrounds** for maps smaller than the screen
|
||||
* **Gold-specific launcher options**
|
||||
* **Optional widescreen battle layout**
|
||||
* **Skippable trade animation** with B or START
|
||||
* **QUIT and EXIT GAME** from the menus
|
||||
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||
* **On-screen touch pad** and controller SELECT for registered items
|
||||
* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals
|
||||
|
||||
@@ -0,0 +1,798 @@
|
||||
# Preparing your mod for Gen 2 (Gold)
|
||||
|
||||
You have a mod that works on Red, Blue or Yellow, and you want it to work on
|
||||
Gold. This is the migration guide: what breaks, what the engine papers over
|
||||
for you, what it refuses to paper over, and the order to do the work in.
|
||||
|
||||
`docs/mod-api-gen2-compat.md` is the reference for *what Gold serves*. This
|
||||
document is the procedure for *getting your mod there*. Read that one when you
|
||||
need to know whether a registry or a hook exists; read this one first.
|
||||
|
||||
## What actually breaks, and why
|
||||
|
||||
Gold is not a skin over the Gen 1 engine. It is a second engine living beside
|
||||
the first one: `src/core/Game2.lua` owns the boot, `src/world/gen2/World.lua`
|
||||
is the overworld, `src/battle/gen2/Battle.lua` is the battle, and
|
||||
`src/script/gen2/Vm.lua` runs the cart's own bytecode instead of a Lua row
|
||||
list. A Gold boot never loads `src/core/Game.lua`,
|
||||
`src/world/OverworldController.lua` or `src/battle/BattleState.lua` at all.
|
||||
The mod API on top is deliberately one API -- the same registry names, the
|
||||
same hook names, the same event names, the same `mod.*` facade -- so a mod
|
||||
that stays on that surface mostly moves across unchanged. What does not move
|
||||
is everything underneath it.
|
||||
|
||||
The failure that motivated all of this is quiet, which is what makes it worth
|
||||
a whole document. A mod with `engine_internals` writes
|
||||
`local Game = require("src.core.Game")` and patches a method on it. Under Gold
|
||||
that require used to succeed: the file is on disk, `require` finds it, hands
|
||||
back a perfectly good module table, and your patch lands on it. Nothing ever
|
||||
instantiates that table, so the patch runs zero times and the only symptom is
|
||||
that your mod does nothing. No error, no warning, no crash to bisect. Two
|
||||
things fixed that. First, a mod is not loaded on a Gold boot unless it says it
|
||||
is for Gold, so the default outcome is "not running" rather than "running
|
||||
wrong". Second, when it does say so, a require made from your own file is
|
||||
answered by an adapter (`src/mods/Gen2Compat.lua`) that presents the Gen 1 API
|
||||
over Gold's internals, and a member the adapter cannot honestly back reads nil
|
||||
instead of reading plausibly-wrong.
|
||||
|
||||
## Step 1: run the checker before you change anything
|
||||
|
||||
`modkit gen2check` reads your manifest, statically scans every `.lua` the
|
||||
package carries, and cross-references what it finds against the adapter's own
|
||||
coverage table. Run it first, because it tells you the size of the job in a
|
||||
few seconds.
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py gen2check <id-or-path> [<id-or-path>...]
|
||||
```
|
||||
|
||||
Real output, against a follower mod written for Yellow:
|
||||
|
||||
```
|
||||
-- PokePCFollowers_VoxelMerge: api 1, profile content, no games declared, permissions engine_internals, 0 dependencies, game_version unset
|
||||
MK400 ERROR manifest.json: no Gen 2 game in "games" (and no gen2compat), so a Gen 2 boot skips this mod; the rest of this report is what it would hit once it claims one
|
||||
MK404 ERROR main.lua:575: BattleState.newWild has no Gen 2 backing: Gold has no factory that returns an unpushed battle, and World:startBattle constructs and pushes in one call. A mod that wraps newWild to rewrite the species must be pointed at the encounter.species hook, which Gold raises with the same name and shape (World:rollEncounter); this reads nil
|
||||
MK404 ERROR main.lua:576: BattleState.newWild has no Gen 2 backing: ... ; nothing on a Gen 2 boot reads this write
|
||||
MK409 WARN main.lua:13: allow-lists a Gen 1 version string, which excludes this mod from a Gen 2 game by construction; test for the capability the code needs instead of the version
|
||||
MK409 WARN main.lua:424: ... (same, a second allow-list)
|
||||
MK409 WARN main.lua:565: ... (and a third)
|
||||
modkit: unresolved: 1 site: requires whose result is neither bound to a name nor indexed here, so where the module goes is not followed (main.lua:221)
|
||||
modkit: unresolved: 5 debug upvalue calls whose target function this scan could not tie to an engine module, so the local they reach could not be resolved (main.lua:279, main.lua:285, main.lua:288, main.lua:321 and 1 more)
|
||||
modkit: src.world.PikachuFollower.onMapEntered closes over 'shouldSpawn' on a Gen 2 boot, so the upvalue surgery at main.lua:325 lands as it does on Gen 1
|
||||
FAIL PokePCFollowers_VoxelMerge on gen 2: will not work (3 errors, 3 warnings)
|
||||
```
|
||||
|
||||
Three kinds of line, and the difference matters:
|
||||
|
||||
- **`MK4xx ERROR` / `MK4xx WARN`** are findings with a file and a line. Errors
|
||||
set the exit code; warnings do not unless you pass `--strict`.
|
||||
- **`modkit:` notes** are things the tool derived rather than found, or could
|
||||
not decide at all. They never change the exit code. The `shouldSpawn` note
|
||||
above is the tool resolving that member through the adapter on a Gen 2 boot,
|
||||
enumerating the function's real upvalues, and confirming the surgery lands;
|
||||
the `unresolved:` notes are the tool naming, with file and line, every reach
|
||||
it saw and could not follow.
|
||||
- **The verdict**: `will load`, `will load but degrade`, or `will not work`.
|
||||
|
||||
The rule ladder:
|
||||
|
||||
| rule | what it means |
|
||||
| --- | --- |
|
||||
| `MK400` | the manifest claims no Gen 2 game, so a Gen 2 boot skips the mod |
|
||||
| `MK401` | a dependency claims no Gen 2 game, which takes you down with it |
|
||||
| `MK402` | you require a Gen 1-only module the adapter does not serve |
|
||||
| `MK403` | a Gen 2 boot runs a `gen2/` sibling of the module instead |
|
||||
| `MK404` | a member you touch has no Gen 2 backing (the adapter's own reason is quoted) |
|
||||
| `MK405` | a member you touch degrades and says so once |
|
||||
| `MK406` | the signature moved under an alias |
|
||||
| `MK407` | `debug` upvalue surgery the Gen 2 arm cannot take: the member is not a function there, or the function does not close over that local |
|
||||
| `MK408` | upvalue surgery the scan could not resolve either way |
|
||||
| `MK409` | a version allow-list, or a Gen 1 screen id |
|
||||
| `MK410` | the entry chunk reads a member of a game that is not up yet |
|
||||
|
||||
Flags: `--strict` promotes warnings to failures, `--notes` prints the adapter's
|
||||
note for every *backed* member you touch (worth reading once per mod, because
|
||||
several backed members are backed with a caveat), `--json` emits one document
|
||||
for the whole batch, `--quiet` drops everything except the findings -- no
|
||||
header, no notes, no verdict line, so a clean mod prints nothing at all and the
|
||||
exit code is the whole answer. Exit code is 0 clean, 1 on a fatal finding, 2 on
|
||||
usage.
|
||||
|
||||
Name several mods in one invocation and they are read as one install set, so a
|
||||
mod and its dependencies can answer each other's `MK401`.
|
||||
|
||||
**What the checker cannot see, and now says so.** It is a static scan, not a
|
||||
run. It follows more than it used to -- a require made through your own
|
||||
`tryRequire`-style wrapper, `local ok, M = pcall(require, "...")`, an inline
|
||||
`require("src.world.Map").waterTiles(...)`, a bracket index `M["member"]`, a
|
||||
local hop `local F = M` -- so reaches that used to be invisible now produce
|
||||
real findings, and a mod that passed before can fail now.
|
||||
|
||||
Two places where it used to answer confidently and wrongly now do not.
|
||||
`local A, B = require("src.world.Map")` is read as binding `A`, which is what
|
||||
Lua does; it used to take the name nearest the `=` and pin the module on `B`,
|
||||
so every reach off `A` went unchecked and every reach off `B` was checked
|
||||
against a module that was never there. And a helper of your own is only read as
|
||||
upvalue surgery when the scan can see it forward its own `(function, name)`
|
||||
pair into the `debug` call; a helper that merely mentions `upvalue`, or that
|
||||
finds the slot by walking `debug.getupvalue`, no longer has its call sites
|
||||
read as naming an engine local, because they do not.
|
||||
|
||||
What it still cannot follow it names instead of ignoring. Every unfollowed
|
||||
reach comes back as an `unresolved:` note carrying a file and a line. The scan
|
||||
side raises one for:
|
||||
|
||||
- a require name built at runtime, whether handed in whole or concatenated
|
||||
(`require("src.world." .. name)` is as unfollowable as `require(name)`);
|
||||
- an engine module name handed to a call the scan does not follow;
|
||||
- an engine module name spelled in a literal with no require attached;
|
||||
- a require whose result is neither bound to a name nor indexed on the spot;
|
||||
- a require in a multiple assignment whose value it cannot pair to a name;
|
||||
- a name bound to a *member* of a module rather than the module;
|
||||
- an engine module indexed with a computed key;
|
||||
- `rawget` or `rawset` on a bound module: that goes straight to the table the
|
||||
require shim hands back, so on a Gen 2 boot it reads or writes the
|
||||
Gen2Compat facade and not the module behind it;
|
||||
- an engine module read as a value rather than indexed, so where it goes from
|
||||
there (a table field, a call argument, a metatable's `__index`) is not
|
||||
followed;
|
||||
- a `debug` upvalue call whose target function could not be tied to a module;
|
||||
- a call through one of your own upvalue helpers that the scan could not
|
||||
confirm carries an upvalue name through to the `debug` call.
|
||||
|
||||
Four more come from the coverage side rather than the scan: a dependency that
|
||||
is not installed beside your mod, a required name that is neither an adapter
|
||||
nor a module in this checkout, a module with no coverage row at all, and a Gen
|
||||
1 member the coverage table does not classify.
|
||||
|
||||
The practical consequence is worth stating plainly: an empty finding list
|
||||
*plus* no `unresolved:` notes now means the scan followed everything it saw,
|
||||
and an empty finding list on its own does not.
|
||||
|
||||
It is still silent on any member the adapter's coverage table does not record:
|
||||
the table lists 481 members across the 15 served modules, which is a large
|
||||
majority of what real mods touch and is not the whole Gen 1 API. A clean
|
||||
`gen2check` means "nothing known-broken was found", not "this works". Boot it.
|
||||
|
||||
## Step 2: declare which games the mod is for
|
||||
|
||||
Nothing moves on disk. A mod is installed once, into `mods/<id>/`, and that one
|
||||
directory serves every game. There is no `mods/gen1/`, no `mods/gen2/`, and no
|
||||
per-generation copy: targeting is something the manifest *declares*, not
|
||||
something the filesystem encodes.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_mod",
|
||||
"name": "My Mod",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"games": ["gen1", "gen2"]
|
||||
}
|
||||
```
|
||||
|
||||
`games` is an optional array. Each entry is one of:
|
||||
|
||||
| token | means |
|
||||
| --- | --- |
|
||||
| `"red"`, `"blue"`, `"yellow"`, `"gold"` | that one game (a version id from `GameVersion.ORDER`) |
|
||||
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
||||
| `"all"` | every game this engine has |
|
||||
|
||||
`src/mods/ModTargets.lua` is the one place those tokens are resolved, and it
|
||||
derives the list from `GameVersion.ORDER` rather than restating it, so a game
|
||||
added later needs no edit there. The scaffold writes the key for you:
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py scaffold my_mod --games gen1,gen2
|
||||
```
|
||||
|
||||
**Omitting `games` keeps the old meaning exactly.** No `games` key means Gen 1
|
||||
only, plus Gen 2 if the legacy `"gen2compat": true` flag is set. Every manifest
|
||||
written before the key existed means precisely what it always meant.
|
||||
`gen2compat` is still accepted and is purely additive: it *adds* the Gen 2
|
||||
games to whatever `games` says, so no manifest can lose a game it already ran
|
||||
on. `Manifest.validate` (`src/mods/Manifest.lua:210-224`) resolves the two
|
||||
into one ORDER-sorted `manifest.games` array and derives `manifest.gen2compat`
|
||||
from it, which is why `"games": ["gen2"]` is honoured by the loader's gate
|
||||
today with no other change.
|
||||
|
||||
An unknown token warns and is dropped under `api` 1 and refuses the manifest
|
||||
under `api` 2 (the normal `violation()` rule). A `games` array that names no
|
||||
game this engine knows falls back to the default rather than orphaning the mod.
|
||||
A non-array `games` is a hard error.
|
||||
|
||||
### What you are claiming
|
||||
|
||||
Adding a game to `games` is you saying *I have run this there*. It is not a
|
||||
request for best-effort support and the loader does not treat it as one: a mod
|
||||
that claims a game is loaded on that boot in full, with its registrations, its
|
||||
subscriptions and its entry chunk, exactly like a mod written for it. If it is
|
||||
half-working, the player sees a broken mod, not a partially-supported one. That
|
||||
is the whole reason the key exists rather than being inferred.
|
||||
|
||||
**Every token is enforced, per game.** `Loader:_gateGeneration`
|
||||
(`src/mods/Loader.lua:447`) gates on `ModTargets.supports(manifest, version,
|
||||
generation)` -- the same call both mod surfaces make -- so `"games": ["blue"]`
|
||||
really does not load on Red, and the skip line is the launcher's line, `For
|
||||
Blue, not Red`. `"games": ["gold"]` alone no longer loads on Red either: it
|
||||
names one game, and that game is Gold. A manifest with no `games` and no
|
||||
`gen2compat` still covers every Gen 1 game, so nothing written before the key
|
||||
existed changes behavior; what changed is that a version-id token is now a
|
||||
statement the boot keeps rather than a label the UIs draw. If you want a mod
|
||||
everywhere, say so: `["gen1", "gen2"]` or `["all"]`.
|
||||
|
||||
**Dependencies are contagious.** A mod whose hard dependency does not run here
|
||||
is left out too, carrying the dependency's own wording (`depends on X, which
|
||||
does not run here (For Blue, not Red)`). It is reported as a skip rather than a
|
||||
failure and neither mod lands on the boot error list, but the mod does not run.
|
||||
Every hard dependency in the chain has to cover the same games; `MK401` is the
|
||||
checker's version of this question for the Gen 2 half of it.
|
||||
|
||||
**The player can overrule you, in one direction only.** The in-game mod
|
||||
manager offers `TRY HERE ANYWAY` on the detail pane for any mod that does not
|
||||
claim *this* game (`src/mods/ManagerState.lua:386`), which now includes a Gen 1
|
||||
boot: a Blue-only mod is genuinely skipped on Red, so that row is the only way
|
||||
to run it there. The choice is **per game**: `options.modsGen2[id]` is a
|
||||
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto
|
||||
Gold. A stored legacy `options.modsGen2[id] = true` from before the key was
|
||||
per-game reads as "the Gen 2 games", which is the only set it could ever have
|
||||
affected, and it is expanded in place the next time the player answers. A
|
||||
forced mod loads normally and keeps a note saying its author never verified it
|
||||
here; the launcher shows it as `Forced onto Gold by you (untested)`. If the
|
||||
override cannot be persisted the manager says `COULD NOT SAVE` rather than
|
||||
promising a restart that would change nothing.
|
||||
|
||||
### What the player sees
|
||||
|
||||
All three surfaces read the same derivation -- the two UIs and the loader --
|
||||
so they cannot disagree about your mod. The launcher's mod panel carries a
|
||||
`Show for:` chip row (All games / Red / Gold / ...) and a per-mod tag from
|
||||
`ModTargets.chip` -- `GEN 1`, `GEN 1+2`, `RED/GOLD`, `BLUE` -- greyed out when
|
||||
the mod does not run on the selected game, with the line `Not for this game`
|
||||
(`src/import/LauncherView.lua:320`) and the detail from `ModTargets.detail`,
|
||||
`For Gen 1, not Gold`. The in-game manager shows the same thing as
|
||||
`ENABLED (NOT THIS GAME)` with the skipped glyph, plus an inert `FOR GEN 1+2`
|
||||
row on the detail screen. The launcher's dependency verdict asks the same
|
||||
question of your dependencies: a mod whose hard dependency does not run on the
|
||||
selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
|
||||
|
||||
### Scoping dependencies per game / generation
|
||||
|
||||
For mods targeting multiple generations (`"games": ["gen1", "gen2"]`), a hard
|
||||
dependency can be scoped to specific games so that it is only enforced when
|
||||
booting those games:
|
||||
|
||||
```json
|
||||
"dependencies": [
|
||||
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
|
||||
]
|
||||
```
|
||||
|
||||
When booting a Gen 1 game (Red, Blue, Yellow), the engine loader sees that
|
||||
`pokegear_cards` is scoped to `"gen2"` and will not skip or block the parent mod
|
||||
on Gen 1. When booting Gen 2 (Gold), `pokegear_cards` is strictly required.
|
||||
|
||||
For conditional integrations where the dependency is optional across the board,
|
||||
`optional_dependencies` remains the standard pattern.
|
||||
|
||||
### One limit worth knowing
|
||||
|
||||
**Enablement is per game.** The overlay
|
||||
`options.modsByVersion[version][id]` is read and written through
|
||||
`SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
|
||||
manager, and loader. Existing shared settings are copied to every game the
|
||||
first time this version sees the installed mods; from then on, each coloured
|
||||
game checkbox changes only that game's next boot. Nothing about this affects a
|
||||
mod author; it affects what a player can express.
|
||||
|
||||
Targeting is a different question from enablement and *is* enforced per game,
|
||||
as above. The two do not share a switch.
|
||||
|
||||
## Step 3: prefer the API over the modules
|
||||
|
||||
Before doing any adapter work, check whether you need the modules at all. In
|
||||
new code, take the live game from `mod.game` and the world from `mod.world`.
|
||||
Both resolve per generation inside the loader (`src/mods/Loader.lua:1021`):
|
||||
`mod.game` is `src/core/Game.lua`'s singleton under Gen 1 and the `Game2`
|
||||
*instance* Gold injected under Gen 2, read on every touch rather than cached;
|
||||
`mod.world` is `src/world/WorldAPI.lua` or `src/world/gen2/WorldAPI.lua` behind
|
||||
one method set. Neither needs `engine_internals`. The `game.ready` payload and
|
||||
every `ui.*` hook's first argument carry the same live game.
|
||||
|
||||
Anything you can express as a registry write, a hook or an event subscription
|
||||
is generation-agnostic already and needs nothing from this document. The
|
||||
adapter exists for the code that was written before Gold did, and for the small
|
||||
number of things the API genuinely does not reach.
|
||||
|
||||
## Step 4: the adapter, module by module
|
||||
|
||||
On a Gen 2 boot with mods present, `require` is interposed
|
||||
(`Loader:_installDevShim`, `src/mods/Loader.lua:184`) and a require *made from
|
||||
a mod's own chunk* for one of fifteen Gen 1 names is answered by
|
||||
`src/mods/Gen2Compat.lua`. Engine code is unaffected: the shim compares the
|
||||
caller's chunk name against the engine tree, so `src/render/PaletteFX.lua`
|
||||
requiring `src.core.Game` still gets the real Gen 1 module on both generations.
|
||||
This is not a dev-mode feature; it installs on any Gold boot that has mods.
|
||||
|
||||
| the name you require | kind | what you get | backed / warned / absent |
|
||||
| --- | --- | --- | --- |
|
||||
| `src.core.Game` | facade | a live proxy onto the `Game2` instance | 70 / 9 / 12 |
|
||||
| `src.world.OverworldController` | facade | over `src/world/gen2/World.lua` | 56 / 5 / 68 |
|
||||
| `src.world.Map` | alias | `src/world/gen2/Map.lua` | 28 / 2 / 9 |
|
||||
| `src.world.NPC` | alias | `src/world/gen2/Npc.lua` | 27 / 0 / 1 |
|
||||
| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua` | 22 / 0 / 0 |
|
||||
| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua` | 16 / 2 / 39 |
|
||||
| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua` | 15 / 2 / 16 |
|
||||
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 15 / 2 / 0 |
|
||||
| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` | 10 / 0 / 11 |
|
||||
| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` | 10 / 7 / 1 |
|
||||
| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua` | 8 / 0 / 1 |
|
||||
| `src.world.FieldDefaults` | facade | the `playerSprites` answer and named refusals | 5 / 2 / 3 |
|
||||
| `src.world.Collision` | facade | `DELTA` / `target` / `occupied` / `canMove` | 4 / 1 / 0 |
|
||||
| `src.ui.StartMenu` | facade | over `src/ui/gen2/StartMenu.lua` | 4 / 0 / 0 |
|
||||
| `src.ui.BoxMenu` | alias | `src/ui/gen2/PcMenu.lua` | 1 / 0 / 0 |
|
||||
|
||||
**Alias means the adapter *is* the Gen 2 module.** Your monkey-patch, your
|
||||
`rawset` sentinel and your `==` idempotency check all land on the table Gold
|
||||
actually runs, and `getmetatable(npc) == NPC` is true. Five names are aliases
|
||||
because nothing less would work: mods set their own trailer's metatable to
|
||||
`src.world.NPC`, a mod is handed `world.map` rather than building one, the
|
||||
loader builds every `mod.world` out of `src.world.WorldAPI` so a copy would
|
||||
give two, `src.world.PikachuFollower` is reached with `debug.setupvalue` on a
|
||||
file-local, and `Screens` caches `src.ui.BoxMenu` for `"Gen2PcMenu"` so a
|
||||
`.new` patch has to land there.
|
||||
|
||||
Note that `src.ui.BoxMenu` points at `src/ui/gen2/PcMenu.lua`, not at
|
||||
`src/ui/gen2/BoxMenu.lua`. Gen 1's `BoxMenu` is Bill's PC *top menu*, whose
|
||||
Gold counterpart is `PcMenu`; Gold's `BoxMenu` is the withdraw/deposit *list*
|
||||
that Gen 1 builds inline.
|
||||
|
||||
**Facade means a translating wrapper.** `.overworld` resolves `Game2.world`,
|
||||
`writeOptions` resolves `Game2:persistOptions`, `game.data.sprites` resolves
|
||||
`data.gen2Sprites`, `NPC.new(data, mapId, objDef)` is sniffed apart from
|
||||
`NPC.new(mapId, objDef, spriteDef)` and the movement vocabulary is translated
|
||||
with it. The four UI facades (`PartyMenu`, `StartMenu`, `OptionsMenu`,
|
||||
`BattleState`) are write-through: reads fall to the Gen 2 class and **writes go
|
||||
to the Gen 2 class**, so `PartyMenu.update = wrapper` still patches the live
|
||||
class Gold pushes. Your write also *reads back as your own value* -- after
|
||||
`PartyMenu.new = wrapper`, `PartyMenu.new` is `wrapper` and nothing else, so
|
||||
`rawequal` holds and an idempotency check works. That is what makes the ordinary
|
||||
capture-and-chain idiom safe: a wrapper that calls the value it captured reaches
|
||||
Gold's real constructor rather than re-entering the facade's own override.
|
||||
Writing `nil` clears the member instead of re-exposing the override underneath.
|
||||
|
||||
The `src.world.OverworldController` facade is a facade over the live `World`,
|
||||
not over a class, so seven of its fields (`map`, `player`, `npcs`, `entities`,
|
||||
`ghosts`, `npcPool`, `camera`) read **and write** through to the running world:
|
||||
Gen 1's module *is* the singleton, so a write has to land somewhere real. A
|
||||
write made before a world exists is dropped with a warning rather than
|
||||
shadowing the world it would have applied to.
|
||||
|
||||
### backed, warned, absent
|
||||
|
||||
The adapter publishes what it covers, and the checker consumes that same table
|
||||
rather than a copy of it. Exactly three statuses, and a member listed as both
|
||||
resolves to the weaker one:
|
||||
|
||||
- **`backed`** -- present, and it does the Gen 1 job on Gold. Read the note
|
||||
anyway where there is one: several backed members are backed with a caveat
|
||||
(`Boxes.COUNT` is 14 on Gold and not 12; `BattleState.say` ignores
|
||||
`sayAuto`'s delay because Gold's messages always auto-advance;
|
||||
`Collision.DELTA` is Gold's live table, so adding a key mutates Gold's own
|
||||
movement).
|
||||
- **`warned`** -- present, answers nil or degrades, and names itself once in
|
||||
the log with your mod attributed. `Game.renderer`, `Game.load`,
|
||||
`Game.step`, `game.data.field`, `game.data.constants`,
|
||||
`ScriptRunner.resume` / `.update` / `.parallel`, `PartyMenu.tmhm` and
|
||||
`OverworldController.neighbors` / `.npcByIndex` are here. `neighbors` is the
|
||||
shape of the whole category: Gold's rows are `{ id, ox, oy, image }` where
|
||||
Gen 1's are `{ map = mapDef, ox, oy }`, so the field warns and answers nil
|
||||
rather than handing back a list whose `nb.map` is nil on every row.
|
||||
- **`absent`** -- deliberately not on the table. It reads nil, which is the
|
||||
honest failure. `BattleState.newWild`, `OverworldController.rollEncounter`,
|
||||
`Map.warpPadOrHoleAt`, `PikachuFollower.shouldSpawn` and 157 others are
|
||||
here. (`shouldSpawn` is absent as a *module member* on both generations: it
|
||||
is a file-local, reached through `setShouldSpawn` or the upvalue of that
|
||||
name, and the coverage table says so rather than implying a field exists.)
|
||||
|
||||
"Absent" means *not served*, not *wrong*. Every one of them was left off for a
|
||||
stated reason, and the reason is in the coverage note. `BattleState.newWild` is
|
||||
the clearest case: Gold has no factory that returns an unpushed battle, because
|
||||
`World:startBattle` constructs and pushes in one call, so a `newWild` taking a
|
||||
species and a level would be a lie about what Gold's battle screen is. The
|
||||
route for the thing you were actually doing (rewriting the species of a wild
|
||||
encounter) is the `encounter.species` hook, which Gold raises under the same
|
||||
name with the same shape.
|
||||
|
||||
A member the table does not record is not a guarantee of anything. What it does
|
||||
depends on the adapter: an alias hands you the Gen 2 module's own member,
|
||||
whatever that is; a write-through facade falls to the Gen 2 class; the
|
||||
`src.core.Game` facade names it in the log and reads nil; the
|
||||
`src.world.OverworldController` facade reads nil silently. The checker is
|
||||
silent about it too.
|
||||
|
||||
### Reading the coverage yourself
|
||||
|
||||
The table is queryable, and it is the same query the checker makes:
|
||||
|
||||
```lua
|
||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||
|
||||
Gen2Compat.modules() -- the 15 served names, sorted
|
||||
Gen2Compat.serves("src.world.Map") -- true
|
||||
Gen2Compat.memberStatus("src.battle.BattleState", "newWild") -- "absent"
|
||||
|
||||
local c = Gen2Compat.coverage("src.world.Map")
|
||||
-- { module, kind = "facade"|"alias", target, members = { [name] = status },
|
||||
-- notes = { [name-or-topic] = "one line" } }
|
||||
```
|
||||
|
||||
`Gen2Compat.COVERAGE_VERSION` is 1 and `Gen2Compat.STATUS` carries the three
|
||||
status strings. `notes` keys are documentation topics, not a member list:
|
||||
dotted paths (`save.money`), field names (`warpAt`), hook names
|
||||
(`hook ui.pc.items`) and bare topics (`identity`, `iteration`, `rawset`) all
|
||||
appear there. `members` is the authoritative set.
|
||||
|
||||
To dump the lot for one module:
|
||||
|
||||
```sh
|
||||
luajit -e 'package.path="./?.lua;"..package.path
|
||||
local G=require("src.mods.Gen2Compat")
|
||||
local c=G.coverage("src.world.OverworldController")
|
||||
for m,s in pairs(c.members) do print(s,m) end
|
||||
for k,v in pairs(c.notes) do print("note",k,v) end'
|
||||
```
|
||||
|
||||
## The patterns no adapter can fix
|
||||
|
||||
Five shapes come up in nearly every real Gen 1 mod, and none of them can be
|
||||
fixed on the engine side without lying to you. Each one has a route that works
|
||||
on both generations.
|
||||
|
||||
### 1. A hardcoded version allow-list
|
||||
|
||||
```lua
|
||||
local v = GameVersion.get()
|
||||
if v ~= "red" and v ~= "blue" and v ~= "yellow" then return false end
|
||||
```
|
||||
|
||||
This excludes you from Gold by construction, and it does so *after* everything
|
||||
else in your mod has been made to work, which is why it produces the most
|
||||
confusing possible outcome: the adapter resolves, your patches land, and the
|
||||
feature still never appears. `MK409` catches it.
|
||||
|
||||
**Instead**, test for the thing the branch actually depends on. If it is there
|
||||
because a member might be missing, test the member:
|
||||
|
||||
```lua
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
if Follower.setShouldSpawn then ... end -- present on Gold, absent on Gen 1
|
||||
```
|
||||
|
||||
If it is there because a piece of per-cart content might be missing, test the
|
||||
content -- `mod.find` and the merged data tables answer that in both games.
|
||||
Version tests stay legitimate for genuinely per-cart *content*, which is what
|
||||
Yellow's starter rename is; they are never right as a gate on a whole feature.
|
||||
|
||||
### 2. String-matching a screen id
|
||||
|
||||
```lua
|
||||
if id == "BoxMenu" then ... end
|
||||
```
|
||||
|
||||
Gold's builtin screens are registered under `Gen2`-prefixed ids, so this
|
||||
matches nothing there. `Screens.GEN2_IDS` in `src/ui/Screens.lua` is the full
|
||||
list, 51 ids: `Gen2BoxMenu`, `Gen2PartyMenu`, `Gen2NamingScreen`,
|
||||
`Gen2Credits` and 47 more. `MK409` catches this exact line: it keys off the
|
||||
string literal itself, not off a screen-shaped word elsewhere on the line, so
|
||||
`if id == "BoxMenu" then` is flagged where it used to slip through. The price
|
||||
of that is deliberate breadth -- any literal equal to a Gen 1 screen id with a
|
||||
`Gen2` twin is warned about, wherever it appears -- so the message states what
|
||||
is true of the literal rather than guessing what the surrounding code meant.
|
||||
It is a warn, and reading past a false one costs you nothing.
|
||||
|
||||
**Instead**, either match both ids, or stop matching ids and take the seam the
|
||||
screen offers. Most screens a mod wants to decorate raise a hook whose name is
|
||||
shared across both generations -- `ui.start_menu.items`, `ui.options.rows`,
|
||||
`ui.party.submenu`, `ui.pc.items`, `ui.naming.grid`, `ui.list_menu` -- and a
|
||||
hook subscription needs no id at all. Where you genuinely must key off the id:
|
||||
|
||||
```lua
|
||||
local BOX_IDS = { BoxMenu = true, Gen2PcMenu = true }
|
||||
if BOX_IDS[id] then ... end
|
||||
```
|
||||
|
||||
Watch the pairing. `ui.pc.items` has the same name on both sides but a
|
||||
different menu behind it: Gen 1 raises it over the WHICH-PC list, Gold over
|
||||
Bill's PC's own rows. And Gen 1's `BoxMenu` pairs with `Gen2PcMenu`, not with
|
||||
`Gen2BoxMenu`.
|
||||
|
||||
### 3. `debug.setupvalue` on an engine local
|
||||
|
||||
```lua
|
||||
local idx = findUpvalue(PikachuFollower.update, "shouldSpawn")
|
||||
debug.setupvalue(PikachuFollower.update, idx, myPredicate)
|
||||
```
|
||||
|
||||
This only ever worked because the Gen 1 file happened to hold that predicate in
|
||||
a file-local of that name. Nothing about the engine promises it, and on the Gen
|
||||
2 side the local has to exist under the same name and hold the same thing for
|
||||
the surgery to land. Today it does: `src/world/gen2/Follower.lua:23` declares
|
||||
`local shouldSpawn` for exactly this reason, so follower mods reaching for it
|
||||
work unchanged on Gold. That is a deliberate courtesy, not a contract.
|
||||
|
||||
`MK407` fires in the two cases where the surgery cannot land: when a Gen 2 boot
|
||||
resolves the member to something that is not a function (so `debug.setupvalue`
|
||||
raises), and when the function it does resolve to does not close over that
|
||||
name, in which case the message quotes the upvalues it *does* close over. The
|
||||
check resolves the member through the adapter exactly as the loader does and
|
||||
enumerates the resolved function's real upvalues, so a local that merely
|
||||
appears somewhere in the Gen 2 file is never mistaken for one -- that used to
|
||||
be the check, and it blessed surgery that landed on nothing. `MK408` fires when
|
||||
the scan could not resolve the member either way, which is what you get when
|
||||
`luajit` is not on `PATH`: the check degrades to an honest warn, never to a
|
||||
reassuring note.
|
||||
|
||||
**Instead**, use the named seam when there is one, and fall back only when
|
||||
there is not:
|
||||
|
||||
```lua
|
||||
if Follower.setShouldSpawn then
|
||||
Follower.setShouldSpawn(myPredicate) -- Gen 2, and any future Gen 1 arm
|
||||
else
|
||||
patchUpvalue(Follower.update, "shouldSpawn", myPredicate) -- Gen 1 today
|
||||
end
|
||||
```
|
||||
|
||||
`Follower.setShouldSpawn` writes the same cell `debug.setupvalue` reaches, so
|
||||
the two cannot disagree. Note the presence test is doing real work:
|
||||
`src/world/PikachuFollower.lua` has no `setShouldSpawn`, so this is not a
|
||||
rename you can apply blindly. Note also that the predicate is called
|
||||
`(game, world)` on Gold where Gen 1 passes `(game, ow)` -- the same object under
|
||||
a different name, so a predicate reading `ow.player` or `ow.map` is unchanged.
|
||||
|
||||
### 4. Capturing state off `src.core.Game` at file scope
|
||||
|
||||
```lua
|
||||
local Game = require("src.core.Game")
|
||||
local save = Game.save -- nil forever
|
||||
local party = Game.save.party -- error at load
|
||||
```
|
||||
|
||||
The module require itself is fine and is meant to be: the Gen 2 `src.core.Game`
|
||||
is a proxy that reads the live `Game2` instance on *every* touch, precisely so
|
||||
that a mod capturing it at file scope, before a save or a world exists, keeps
|
||||
working once they do. What does not survive is capturing a *field* off it at
|
||||
file scope, which snapshots nil. This is true on Gen 1 as well; Gold just makes
|
||||
it bite more often because the entry chunk runs earlier relative to the world.
|
||||
`MK410` catches the file-scope read of a member the Gen 1 module only ever
|
||||
writes as `self.<name>`.
|
||||
|
||||
**Instead**, read through the facade at the moment you need the value, or take
|
||||
the live game from the `game.ready` payload:
|
||||
|
||||
```lua
|
||||
local Game = require("src.core.Game")
|
||||
mod.events:on("game.ready", function(ev)
|
||||
local game = ev.game -- the real Game2 instance
|
||||
local party = Game.save.party -- read now, not at file scope
|
||||
end)
|
||||
```
|
||||
|
||||
Three further properties of the proxy that a Gen 1 mod can trip over, all
|
||||
recorded in the coverage notes:
|
||||
|
||||
- **Identity.** The proxy can never compare equal to the `Game2` instance the
|
||||
`game.ready` payload carries. Lua 5.1 fires `__eq` only when both operands
|
||||
share a metatable, so `Game == ev.game` is false on Gold. Do not use it as
|
||||
an idempotency check.
|
||||
- **Iteration.** `pairs`, `next` and `rawget` see an *empty* table, because the
|
||||
proxy holds nothing of its own. Enumerate the `game.ready` payload instead.
|
||||
- **`rawset`.** `rawset(Game, k, v)` lands on the proxy, reads back correctly
|
||||
through the same facade, and is completely invisible to the engine. That
|
||||
read-back is what hides it. Use a plain assignment, which writes through to
|
||||
the live instance.
|
||||
|
||||
The save layout moved too, and those fields are absent rather than aliased so
|
||||
that a wrong read is loud rather than silent: `save.money` is
|
||||
`save.player.money`, `save.player.map` / `.x` / `.y` / `.facing` are
|
||||
`save.position.*`, and `save.player.rival` is `save.rival.name`. `save` itself
|
||||
is a straight pass-through on purpose.
|
||||
|
||||
### 5. Monkey-patching a class, and the two ways it goes wrong
|
||||
|
||||
Patching a shared class method is *supported*, and this is worth stating
|
||||
plainly because it is the thing most authors expect to have to rewrite. The
|
||||
four UI facades write through: `__newindex` forwards to the Gen 2 class, so
|
||||
|
||||
```lua
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local origUpdate = PartyMenu.update
|
||||
function PartyMenu.update(self, dt) ... return origUpdate(self, dt) end
|
||||
```
|
||||
|
||||
lands on the class Gold actually pushes. Aliases are the class, so the same
|
||||
holds there.
|
||||
|
||||
Two variants do not work, and neither can be made to.
|
||||
|
||||
**Patching a member the Gen 2 class does not have.** The write succeeds, reads
|
||||
back as your own function, and nothing ever calls it. `BattleState.newWild =
|
||||
wrapper` is the canonical case: the assignment is taken, and no Gold code path
|
||||
reads that name. This is the one place the read-back works against you, which
|
||||
is why `MK404` reports the write site separately from the read site.
|
||||
|
||||
**Patching a field on a live instance.** `menu.onSwitch = fn` writes a field
|
||||
Gen 2 never reads -- Gold takes it as `onChoose` at construction. Same for
|
||||
`menu.swapFrom` (renamed `switchFrom`) and for `StartMenu`'s `tx` / `ty` / `tw`
|
||||
/ `th` / `anchor` / `maxVisible`, which do not exist on Gold at all because the
|
||||
box is fixed at `Chrome.box(10, 0, 10, h)`. A write to any of them is inert.
|
||||
Pass what you need to `.new` instead: `PartyMenu.new(game, { onSwitch = f })`
|
||||
with no `battle`, `pickOnly` or `forceSwitch` opens the plain list and calls
|
||||
`onSwitch(mon, menu)` on A, which is the Gen 1 behavior the facade reproduces.
|
||||
|
||||
A close relative worth calling out because it errors rather than no-ops:
|
||||
`map.warpAt` is a name collision, not a rename. Gen 1's is a *table* keyed by
|
||||
cell; Gold's `Map:warpAt` is a *method* of the same name. `map.warpAt[cell]`
|
||||
and `pairs(map.warpAt)` both raise, which is loud but points at your mod.
|
||||
Enumerate `map.warps`, which Gold carries as an ordered array.
|
||||
|
||||
## A worked migration
|
||||
|
||||
Here is one real one, start to finish. The mod is a follower pack written for
|
||||
Red/Blue/Yellow. `gen2check` reports `MK400` on the manifest, `MK404` twice on
|
||||
`BattleState.newWild` and `MK409` on a version allow-list, plus a note
|
||||
confirming its `shouldSpawn` surgery lands.
|
||||
|
||||
**Before.** Three separate problems in about twenty lines.
|
||||
|
||||
```lua
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
return function(mod)
|
||||
-- (1) rewrite the starter encounter's species
|
||||
local origNewWild = BattleState.newWild
|
||||
BattleState.newWild = function(game, species, level, ...)
|
||||
if species == "PIKACHU" and level == 5 then species = "CHARMANDER" end
|
||||
return origNewWild(game, species, level, ...)
|
||||
end
|
||||
|
||||
-- (2) decide whether a follower spawns
|
||||
local newShouldSpawn = function(game, ow)
|
||||
local v = GameVersion.get()
|
||||
if v ~= "red" and v ~= "blue" and v ~= "yellow" then return false end
|
||||
return packSize(game) > 0
|
||||
end
|
||||
|
||||
-- (3) install it
|
||||
patchUpvalue(PikachuFollower.update, "shouldSpawn", newShouldSpawn)
|
||||
patchUpvalue(PikachuFollower.onMapEntered, "shouldSpawn", newShouldSpawn)
|
||||
end
|
||||
```
|
||||
|
||||
On Gold: (1) assigns onto a name nothing reads, so the species rewrite never
|
||||
happens. (2) returns false for every Gold boot, so no follower ever spawns.
|
||||
(3) actually works, and works on a predicate that has already decided to do
|
||||
nothing. Two silent failures and one correct mechanism pointed at them.
|
||||
|
||||
**After.** The manifest gains `"games": ["gen1", "gen2"]`, and:
|
||||
|
||||
```lua
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
|
||||
return function(mod)
|
||||
-- (1) the species of a wild encounter is a hook on both generations
|
||||
mod.hooks:wrap("encounter.species", function(next, enc, ctx)
|
||||
local rolled = next(enc, ctx)
|
||||
if rolled and rolled.species == "PIKACHU" and rolled.level == 5 then
|
||||
rolled.species = "CHARMANDER"
|
||||
end
|
||||
return rolled
|
||||
end)
|
||||
|
||||
-- (2) no cart check: whether there is a pack to walk is the whole question
|
||||
local newShouldSpawn = function(game, ow)
|
||||
return packSize(game) > 0
|
||||
end
|
||||
|
||||
-- (3) the named seam where there is one, the upvalue where there is not
|
||||
if PikachuFollower.setShouldSpawn then
|
||||
PikachuFollower.setShouldSpawn(newShouldSpawn)
|
||||
else
|
||||
patchUpvalue(PikachuFollower.update, "shouldSpawn", newShouldSpawn)
|
||||
patchUpvalue(PikachuFollower.onMapEntered, "shouldSpawn", newShouldSpawn)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
`gen2check` now reports clean, and the mod is shorter than it was on Gen 1
|
||||
alone. That is the usual shape of this work: two of the three fixes replace
|
||||
engine surgery with an API that existed the whole time, and only the third
|
||||
needs a generation branch.
|
||||
|
||||
The one change that is *not* a simplification is the hook's contract. A wrapper
|
||||
takes `(next, ...)` and must call `next` with the arguments it was handed, where
|
||||
the monkey-patch could do as it liked with them. `encounter.species` transforms
|
||||
a rolled `{ species, level }` and gets a `ctx` beside it: Gen 1 fills in
|
||||
`mapId`, `terrain` and `rng`, and Gold adds `daytime`, `environment`, `kind`
|
||||
(`"wild"` / `"contest"` / `"script"` / `"sweet_scent"`), `tables` and `data`.
|
||||
So the same
|
||||
subscription serves both games, and a Gold-only refinement is a field test
|
||||
rather than a second hook. That is the trade: a narrower seam that both engines
|
||||
raise, in exchange for not owning a function neither engine promised you.
|
||||
|
||||
## Testing
|
||||
|
||||
**Headless, without a Gold cache.** The SDK harness takes the generation
|
||||
directly, and everything after that is the production path -- same loader, same
|
||||
validate, same topological sort, same merge:
|
||||
|
||||
```lua
|
||||
local run = T.sdk.loadMod("mods/my_mod", { generation = 2 })
|
||||
T.eq(run.mod and run.mod.state, "loaded",
|
||||
"runs on gen 2: " .. tostring(run.mod and run.mod.skipReason))
|
||||
T.eq(#run.errors, 0, "and loads with no boot errors")
|
||||
run.release()
|
||||
```
|
||||
|
||||
**Assert the state, not just the error count.** A gate skip is deliberately not
|
||||
an error: `Loader:_skip` sets `mod.state` and `mod.skipReason` and stays off
|
||||
`loader.errors`, because neither the mod nor its dependency has a bug. So
|
||||
`T.eq(#run.errors, 0)` on its own passes for a mod that never ran a line, which
|
||||
is the one result you were testing to rule out. `run.mod.state` is `"loaded"`
|
||||
when the entry chunk ran and `"wrong_generation"` when the gate or the
|
||||
dependency contagion took it, with `run.mod.skipReason` carrying the sentence
|
||||
the manager would show. Keep the error assertion too: it is what catches a
|
||||
registry with no Gen 2 home and a require the adapter does not serve, both of
|
||||
which *do* land on `loader.errors`.
|
||||
|
||||
**On a real Gold boot.** Nothing above substitutes for running it. Import Gold
|
||||
in the launcher, enable your mod, and play the part your mod touches. Be
|
||||
precise about where the adapter talks to you, because the two channels are not
|
||||
the same:
|
||||
|
||||
- **The log** carries the adapter's own warnings, each attributed to the mod
|
||||
holding the facade (`[my_mod] Game.renderer has no Gen 2 backing: ...`), so a
|
||||
member that degraded tells you which one and why. `Gen2Compat.warnOnce` goes
|
||||
to `Logger.warn` and nowhere else -- these do **not** appear in the manager.
|
||||
- **The manager's error feed** (`loader.errors`) is a shorter list: a mod that
|
||||
failed validation, a duplicate mod id, a registry with no Gen 2 target, a
|
||||
cross-validation problem, and the one adapter-adjacent case, a require for a
|
||||
Gen 1 module the adapter does not serve. A skipped mod is not on it, and
|
||||
neither is a degraded member.
|
||||
|
||||
So: read the log for coverage problems, and the manager for load problems.
|
||||
|
||||
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
|
||||
profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
|
||||
|
||||
## What this guide does not promise
|
||||
|
||||
- **Coverage is partial and will stay partial.** 15 Gen 1 modules are served
|
||||
out of a much larger engine, and within those 15 the coverage table records
|
||||
291 backed members against 32 warned and 161 absent. The absent ones are not
|
||||
a backlog; most are absent because there is no honest Gen 2 answer, and each
|
||||
one carries its reason. The counts move as the adapter learns something: a
|
||||
member that turns out to answer nil is demoted from backed to warned or
|
||||
absent rather than left flattering the table.
|
||||
- **Absent is not broken, it is not-served.** A nil read is the designed
|
||||
outcome. If you would rather have an error, test for the member before you
|
||||
use it.
|
||||
- **The checker is a static scan.** It cannot follow a require built at
|
||||
runtime, cannot tie every `debug` call to a module, and says nothing at all
|
||||
about a member the coverage table does not record. What it *can* do is admit
|
||||
each of those individually, with a file and a line, as an `unresolved:` note.
|
||||
Read the notes as part of the report: a clean finding list with notes under
|
||||
it means "nothing known-broken was found in the part I could follow", and
|
||||
only a clean finding list with no notes means the scan followed everything.
|
||||
- **A backed member can still surprise you.** `backed` means the adapter took
|
||||
responsibility for the Gen 1 call shape, not that Gold behaves identically.
|
||||
Run `gen2check --notes` once and read the caveats on the members you touch.
|
||||
- **The adapter is not a compatibility layer for new code.** It exists so mods
|
||||
written before Gold existed keep working. If you are writing something now,
|
||||
`mod.game`, `mod.world`, the registries and the hooks mean the same thing in
|
||||
both games and need none of this.
|
||||
@@ -1,12 +1,11 @@
|
||||
# What This Port Requires
|
||||
|
||||
The packaged desktop app requires one user-supplied input on first boot: a
|
||||
canonical 1 MiB US Pokemon Red ROM.
|
||||
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
|
||||
|
||||
The importer verifies SHA-1
|
||||
`ea9bcae617fdf159b045185467ae58b2e4a48b9a`. Other revisions, Virtual
|
||||
Console releases, and Pokemon Blue are rejected rather than decoded with
|
||||
incorrect addresses.
|
||||
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
||||
for specific hashes). Other revisions and Virtual Console releases are rejected
|
||||
rather than decoded with incorrect addresses.
|
||||
|
||||
After verification, the app generates its private cache in the LÖVE save
|
||||
directory. It does not keep a copy of the ROM. Later boots use the cache.
|
||||
@@ -15,9 +14,11 @@ Python and Pillow are not required by the packaged app.
|
||||
## Bundled Metadata
|
||||
|
||||
Assembly removes high-level names and some relationships that the Lua port
|
||||
needs. `tools/rom_manifest.json` therefore contains:
|
||||
needs. The version-specific files `tools/rom_manifest.json`,
|
||||
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
|
||||
contain:
|
||||
|
||||
- the 3,268 ROM symbol addresses actually read by the extractor
|
||||
- the ROM symbol addresses actually read by the extractor
|
||||
- symbolic IDs and ordering for maps, species, moves, items, and trainers
|
||||
- source-erased dimensions, image names, and map object integration names
|
||||
- hand-ported field/script integration tables
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# Build Gen1Recomp for Nintendo Switch
|
||||
|
||||
Want to play a release build instead? Download the SD-ready zip and extract it
|
||||
at your microSD root. See [switch-install.md](switch-install.md).
|
||||
|
||||
This guide is for contributors who build Gen1Recomp for Switch from source.
|
||||
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`).
|
||||
> Runtime target is pinned [love-nx](https://github.com/retronx-team/love-nx)
|
||||
> `11.5-nx1`. Player install and limitations: [switch-install.md](switch-install.md).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites by OS
|
||||
|
||||
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
|
||||
WSL, not cmd.exe or PowerShell (AD-008).
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
|
||||
2. Install Switch tools (**required for `--fused`**):
|
||||
|
||||
```sh
|
||||
sudo dkp-pacman -S switch-dev
|
||||
```
|
||||
|
||||
3. OTA launcher toolchain, **native or Docker** (either is fine):
|
||||
|
||||
```sh
|
||||
bash scripts/switch/install_devkitpro_deps.sh # native
|
||||
# or install Docker (same pin as fused builds)
|
||||
```
|
||||
|
||||
4. Ensure `DEVKITPRO` is exported (typical macOS: `/opt/devkitpro`) and
|
||||
`nacptool` / `elf2nro` are on `PATH` (or under `$DEVKITPRO/tools/bin`).
|
||||
|
||||
Fused game builds can also use Docker when native `nacptool`/`elf2nro` are absent.
|
||||
|
||||
### Native OTA launcher (included in `--fused`)
|
||||
|
||||
In-console OTA uses a **separate DEVKITPRO NRO** (not LÖVE). The LÖVE
|
||||
self-updater (`Check.lua`) is disabled on NX. Source:
|
||||
`ports/switch/ota-launcher/`. Host protocol tests (no toolchain):
|
||||
|
||||
```sh
|
||||
make -C ports/switch/ota-launcher host-test
|
||||
# or
|
||||
scripts/switch/build_ota_launcher.sh # host-test first; NRO needs DEVKITPRO/Docker
|
||||
```
|
||||
|
||||
`--fused` always builds the fused game, native OTA launcher, and dual-NRO SD
|
||||
zip. The same `*-switch.zip` is the OTA download asset. **DEVKITPRO is
|
||||
required.** OTA launcher: native packages **or** Docker. Both are supported.
|
||||
|
||||
Release-like build from repo root:
|
||||
|
||||
```sh
|
||||
scripts/build_switch.sh --fetch --fused --version X.Y.Z
|
||||
```
|
||||
|
||||
See `ports/switch/ota-launcher/README.md` and
|
||||
`scripts/switch/ota_launcher.manifest`.
|
||||
|
||||
### Windows (Git Bash / MSYS2 / WSL)
|
||||
|
||||
1. Use a bash environment:
|
||||
- **MSYS2** with the [devkitPro](https://devkitpro.org/wiki/devkitPro_pacman)
|
||||
packages (preferred for native `nacptool`/`elf2nro`), or
|
||||
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
|
||||
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
|
||||
WSL if Docker bind-mounts from Git Bash paths misbehave.
|
||||
2. Install `switch-dev` (or rely on Docker fallback; see below).
|
||||
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
|
||||
|
||||
### What you must install yourself
|
||||
|
||||
| You install | Script does **not** install |
|
||||
| ----------- | --------------------------- |
|
||||
| bash, git, zip tooling the repo already expects | (none) |
|
||||
| `dkp-pacman` + `switch-dev` + OTA packages **or** Docker | `dkp-pacman -S …` |
|
||||
| A legal `.gb` ROM (to play) | Any ROM or game data |
|
||||
|
||||
---
|
||||
|
||||
## Mode glossary
|
||||
|
||||
`scripts/build_switch.sh` supports three modes (combinable as noted):
|
||||
|
||||
| Mode | What it does |
|
||||
| ---- | ------------ |
|
||||
| `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. |
|
||||
| `--loose` | Packs `game.love`, copies pinned `love.nro` → `dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. |
|
||||
| `--fused` | Builds fused game NRO, OTA launcher NRO, and dual-NRO SD zip. **Requires DEVKITPRO** + `switch-dev`. OTA launcher: native packages or Docker. GitHub Releases publish the **zip only**. |
|
||||
|
||||
Rules:
|
||||
|
||||
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
|
||||
- `--loose` and `--fused` are **XOR**. Pick one packaging path per run.
|
||||
- `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA).
|
||||
|
||||
### What `--fetch` downloads
|
||||
|
||||
Only the two pinned love-nx release assets (`love.nro`, `love.elf`). It does
|
||||
**not** install:
|
||||
|
||||
- devkitPro / `dkp-pacman` / `switch-dev`
|
||||
- Docker
|
||||
- ROMs, saves, or mods
|
||||
|
||||
---
|
||||
|
||||
## Native tools, then Docker
|
||||
|
||||
Fused packaging (`scripts/switch/build_fused.sh`):
|
||||
|
||||
1. Prefer native `nacptool` + `elf2nro` on `PATH` (or `$DEVKITPRO/tools/bin`).
|
||||
2. Else fall back to Docker using:
|
||||
- `GEN1_DKP_IMAGE` if set, otherwise
|
||||
- the image named in `scripts/switch/dkp-docker.image` (default
|
||||
`devkitpro/devkita64:latest`).
|
||||
|
||||
If neither native tools nor Docker work, the script exits non-zero with
|
||||
macOS / Linux / Windows / Docker hints and a pointer to this doc.
|
||||
|
||||
---
|
||||
|
||||
## Example commands
|
||||
|
||||
From the repo root:
|
||||
|
||||
```sh
|
||||
# Download pinned love-nx only
|
||||
scripts/build_switch.sh --fetch
|
||||
|
||||
# Loose pair for iteration (fetch + assemble)
|
||||
scripts/build_switch.sh --fetch --loose
|
||||
|
||||
# Fused game + OTA launcher + dual-NRO SD zip for a release-like artifact
|
||||
scripts/build_switch.sh --fetch --fused --version 0.2.0
|
||||
```
|
||||
|
||||
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
|
||||
The fused path also writes `gen1recomp-<ver>-switch.nro` (game),
|
||||
`gen1recomp-<ver>-launcher.nro`, `gen1recomp-<ver>-game.nro`,
|
||||
`gen1recomp-<ver>-switch.nro.sha256`, and `gen1recomp-<ver>-switch.zip`
|
||||
(+ `.sha256` sidecar for the zip).
|
||||
|
||||
Offline packaging smoke (no network, no nacptool required):
|
||||
|
||||
```sh
|
||||
bash scripts/switch/selftest_build_switch.sh
|
||||
bash scripts/switch/verify_payload.sh --self-test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI and release
|
||||
|
||||
Switch packaging has three automated surfaces (same policy as AD-010):
|
||||
|
||||
### Path-gated PR / push CI (`.github/workflows/ci.yml`)
|
||||
|
||||
When a change touches Switch packaging / Switch docs / NX runtime paths
|
||||
(`scripts/build_switch.sh`, `scripts/switch/**`, `docs/switch-*.md`,
|
||||
`tests/switch_ci_workflows_test.lua`, `tests/switch_transfer_docs_test.lua`,
|
||||
the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
|
||||
`src/core/GameVersion.lua`, `src/import/CacheFs.lua`, the NX engine suites
|
||||
`tests/engine/assets_version_fallback_test.lua`,
|
||||
`tests/engine/nx_generated_guard_test.lua`,
|
||||
`tests/engine/nx_yellow_boot_test.lua`,
|
||||
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
||||
or the Switch-related workflow YAML), CI runs:
|
||||
|
||||
1. **Offline selftest** on `ubuntu-latest` (forks **and** the main repo):
|
||||
`scripts/switch/selftest_build_switch.sh`,
|
||||
`scripts/switch/verify_payload.sh --self-test`,
|
||||
`luajit tests/switch_ci_workflows_test.lua`,
|
||||
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
|
||||
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
||||
`luajit tests/engine/nx_generated_guard_test.lua`,
|
||||
`luajit tests/engine/nx_yellow_boot_test.lua`).
|
||||
2. **Fused NRO build** only on the **main** repository
|
||||
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
||||
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
||||
head is that repo (same-repo push/PR). Fork CI never runs fused. Fork PRs into the main repo also skip Switch fused (offline selftest still runs) so untrusted head code is not executed on the self-hosted Mac; iOS device build eligibility is unchanged. Fused also waits for a successful offline selftest before starting on the Mac runner.
|
||||
3. On successful PR fused builds, a follow-up workflow posts a PR comment
|
||||
linking the Actions artifact named `gen1recomp-switch-nro`
|
||||
(comment tag `switch-build-result`; see
|
||||
`.github/workflows/switch-artifact-comment.yml`).
|
||||
|
||||
Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
|
||||
|
||||
### Release hard-fail (`.github/workflows/release.yml`)
|
||||
|
||||
GitHub Releases always build Switch on the same self-hosted Mac runner as the
|
||||
other platforms. This is a **hard gate** (no `continue-on-error`):
|
||||
|
||||
```sh
|
||||
scripts/build_switch.sh --fetch --fused --version "<release version>"
|
||||
```
|
||||
|
||||
A Switch packaging failure fails the entire release job. The release asset is
|
||||
`gen1recomp-<ver>-switch.zip` (SD-ready); the versioned `.nro` stays under
|
||||
`dist/switch/` for the packer and for PR CI artifacts.
|
||||
|
||||
### Runner provisioning
|
||||
|
||||
The self-hosted Mac runner **must** have **DEVKITPRO** installed and exported.
|
||||
`--fused` preflight fails early with setup steps if it is missing.
|
||||
|
||||
**One-time setup on the runner** (if not already present):
|
||||
|
||||
```sh
|
||||
# devkitPro pacman installer from https://devkitpro.org/wiki/devkitPro_pacman
|
||||
sudo dkp-pacman -S switch-dev
|
||||
export DEVKITPRO=/opt/devkitpro
|
||||
export PATH="$DEVKITPRO/tools/bin:$PATH"
|
||||
|
||||
# OTA launcher: pick one
|
||||
bash scripts/switch/install_devkitpro_deps.sh # native
|
||||
# or ensure Docker is installed (same pin as fused builds)
|
||||
```
|
||||
|
||||
CI and release still run `scripts/build_switch.sh --fetch --fused`. Preflight
|
||||
requires DEVKITPRO and either native OTA packages or Docker. Without all of
|
||||
that, the job fails with the setup steps above. Scripts never auto-run
|
||||
`dkp-pacman -S` during CI.
|
||||
|
||||
---
|
||||
|
||||
## Limitations / non-goals
|
||||
|
||||
These scripts and this guide do **not**:
|
||||
|
||||
- Push files to the console (no automated MTP / FTP / SD scripting)
|
||||
- Bundle or download any Pokémon ROM
|
||||
- Install `dkp-pacman` / `switch-dev` for you
|
||||
- Provide `nxlink` / netloader deploy (deferred; see [switch-transfer.md](switch-transfer.md))
|
||||
- Validate **Applet Mode**. Use title override (hold **R**) for full memory
|
||||
|
||||
Player install steps: [switch-install.md](switch-install.md).
|
||||
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
|
||||
[switch-transfer.md](switch-transfer.md).
|
||||