From 0b70d6c53519138bebf369b0b5dba14539133886 Mon Sep 17 00:00:00 2001 From: HighDrexler Date: Thu, 20 Aug 2026 17:09:40 -0400 Subject: [PATCH 01/15] chore: stage prepared large-import PR patch --- .github/pr-build/large-import.patch.b64.1 | 1 + .github/pr-build/large-import.patch.b64.2 | 1 + .github/workflows/apply-large-import-pr.yml | 37 +++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 .github/pr-build/large-import.patch.b64.1 create mode 100644 .github/pr-build/large-import.patch.b64.2 create mode 100644 .github/workflows/apply-large-import-pr.yml diff --git a/.github/pr-build/large-import.patch.b64.1 b/.github/pr-build/large-import.patch.b64.1 new file mode 100644 index 00000000..7c5254ec --- /dev/null +++ b/.github/pr-build/large-import.patch.b64.1 @@ -0,0 +1 @@ +ZGlmZiAtLWdpdCBhL2RvY3MvbW9kZGluZy5tZCBiL2RvY3MvbW9kZGluZy5tZAppbmRleCA0MDE2ZTFmLi4yNWU3NTk2IDEwMDY0NAotLS0gYS9kb2NzL21vZGRpbmcubWQKKysrIGIvZG9jcy9tb2RkaW5nLm1kCkBAIC0xMjIsMjEgKzEyMiw0MCBAQCBFYWNoIG9iamVjdCByZXF1aXJlcyBhIHN0YWJsZSBgaWRgLCBhIGRpc3BsYXkgYG5hbWVgLCBhIGRlc3RpbmF0aW9uIGBmaWxlYAogZGlnZXN0cy4gYGZvcm1hdGAgaXMgZWl0aGVyIGAicmF3ImAgKHRoZSBkZWZhdWx0KSBvciBgIm42NCJgLiBBbiBvcHRpb25hbAogYGRlc2NyaXB0aW9uYCBnaXZlcyBwbGF5ZXJzIGR1bXAgb3IgcmVnaW9uIGd1aWRhbmNlIGluIHRoZSBpbXBvcnQgcGFuZWwuCiBgc2l6ZWAgZGVjbGFyZXMgdGhlIGV4YWN0IGNhbm9uaWNhbCBieXRlIGxlbmd0aDsgYG1heF9zaXplYCBkZWNsYXJlcyBhIHNtYWxsZXIKLXBlci1pbXBvcnQgY2VpbGluZyB3aGVuIGFuIGV4YWN0IHNpemUgaXMgbm90IGFwcHJvcHJpYXRlLiBFdmVyeSBpbXBvcnQgYWxzbwotaGFzIGFuIGVuZ2luZS1lbmZvcmNlZCAxMjggTWlCIGNlaWxpbmcgYW5kIGlzIHJlamVjdGVkIGJlZm9yZSBoYXNoaW5nIHdoZW4gaXRzCi1maWxlc3lzdGVtIHJlcG9ydHMgYW4gaW52YWxpZCBzaXplLgorcGVyLWltcG9ydCBjZWlsaW5nIHdoZW4gYW4gZXhhY3Qgc2l6ZSBpcyBub3QgYXBwcm9wcmlhdGUuIFRoZSBlbmdpbmUgaGFyZCBsaW1pdAoraXMgMiBHaUIuIEltcG9ydHMgYWJvdmUgMTI4IE1pQiByZWNlaXZlIGFuIGV4cGxpY2l0IGZyZWUtc3BhY2UgY29uZmlybWF0aW9uIGFuZAordXNlIHRoZSBsYXVuY2hlcidzIHN0cmVhbWluZyBsYXJnZS1maWxlIHBhdGggcmF0aGVyIHRoYW4gYmVpbmcgbWF0ZXJpYWxpemVkIGFzCitvbmUgTHVhIHN0cmluZy4KIAogRm9yIGAibjY0ImAsIHRoZSBsYXVuY2hlciByZWNvZ25pemVzIGAuejY0YCwgYC52NjRgLCBhbmQgYC5uNjRgIGJ5dGUgb3JkZXJzLAogc3RyaXBzIGEgcmVjb2duaXplZCA1MTItYnl0ZSBjb3BpZXIgaGVhZGVyLCBjb252ZXJ0cyB0aGUgYnl0ZXMgdG8gY2Fub25pY2FsCiBiaWctZW5kaWFuIGAuejY0YCBvcmRlciwgYW5kIHRoZW4gY2hlY2tzIE1ENS4gVGhlIGNhbm9uaWNhbCBieXRlcyBhcmUgd3JpdHRlbgogdG8gYG1vZHMvPG1vZC1pZD4vYmFzZXJvbXMvPGZpbGU+YC4gRWFjaCBzZWxlY3Rpb24gaXMgYSBwcml2YXRlIGdyYW50IHRvIHRoYXQKIG1vZDogdGhlIGxhdW5jaGVyIG5ldmVyIHNjYW5zIG9yIGNvcGllcyBhbm90aGVyIG1vZCdzIGltcG9ydGVkIGZpbGVzIG1lcmVseQotYmVjYXVzZSBpdHMgbWFuaWZlc3QgbmFtZXMgdGhlIHNhbWUgZGlnZXN0LiBNb2RzIHJlYWQgdGhlIHJlc3VsdCB3aXRoIHRoZWlyIGV4aXN0aW5nIHNjb3BlZCBgbW9kOnJlYWRgIEFQSSwgZm9yCi1leGFtcGxlIGBtb2Q6cmVhZCgiYmFzZXJvbXMvc3RhZGl1bTIuejY0IilgOyBubyBob3N0IHBhdGggb3IgbmV3IGZpbGVzeXN0ZW0KK2JlY2F1c2UgaXRzIG1hbmlmZXN0IG5hbWVzIHRoZSBzYW1lIGRpZ2VzdC4gU21hbGwgc291cmNlcyBjYW4gc3RpbGwgYmUgcmVhZAord2l0aCB0aGUgZXhpc3Rpbmcgc2NvcGVkIGBtb2Q6cmVhZGAgQVBJLCBmb3IgZXhhbXBsZQorYG1vZDpyZWFkKCJiYXNlcm9tcy9zdGFkaXVtMi56NjQiKWAuIEZvciBsYXJnZSBzb3VyY2VzLCBwcmVmZXIgdGhlIGJvdW5kZWQKK2Btb2QuaW1wb3J0c2AgZmFjYWRlIGRlc2NyaWJlZCBiZWxvdzsgbm8gaG9zdCBwYXRoIG9yIG5ldyBnZW5lcmFsIGZpbGVzeXN0ZW0KIHBlcm1pc3Npb24gaXMgZXhwb3NlZC4gTWlzc2luZyBgcmVxdWlyZWRfaW1wb3J0c2AgYmxvY2sgdGhlIG1vZCBiZWZvcmUgaXRzCiBlbnRyeSBjaHVuayBydW5zOyBtaXNzaW5nIGBvcHRpb25hbF9pbXBvcnRzYCByZW1haW4gdmlzaWJsZSBpbiB0aGUgc2FtZQogbGF1bmNoZXIgcGFuZWwgYnV0IGRvIG5vdCBibG9jayBsb2FkaW5nLgogCisjIyMjIEJvdW5kZWQgYWNjZXNzIHRvIHZhbGlkYXRlZCBpbXBvcnRzCisKK0EgbG9hZGVkIG1vZCBjYW4gYWRkcmVzcyBvbmx5IGlkcyBkZWNsYXJlZCBieSBpdHMgb3duIGByZXF1aXJlZF9pbXBvcnRzYCBvcgorYG9wdGlvbmFsX2ltcG9ydHNgIGFycmF5czoKKworYGBgbHVhCitsb2NhbCBpbmZvLCBlcnIgPSBtb2QuaW1wb3J0czppbmZvKCJzdGFkaXVtMiIpCitsb2NhbCBoZWFkZXIsIGVyciA9IG1vZC5pbXBvcnRzOnJlYWQoInN0YWRpdW0yIiwgMCwgNDA5NikKK2BgYAorCitgcmVhZGAgdXNlcyB6ZXJvLWJhc2VkIG9mZnNldHMgYW5kIGlzIGNhcHBlZCBhdCA4IE1pQiBwZXIgY2FsbC4gVGhlIGVuZ2luZQorcmVjaGVja3MgdGhlIHN0b3JlZCBpbXBvcnQgYmVmb3JlIGV4cG9zaW5nIGl0LCBzZWVrcyBpbnRvIHRoZSBlbmdpbmUtb3duZWQKK2NvcHksIGFuZCBuZXZlciBnaXZlcyB0aGUgbW9kIGEgaG9zdCBwYXRoIG9yIGZpbGUgaGFuZGxlLiBUaGlzIGlzIGludGVuZGVkIGZvcgorbGFyZ2Ugc291cmNlIGZvcm1hdHMgd2hvc2UgdGFibGUvaW5kZXggY2FuIGJlIHBhcnNlZCB3aXRoIHNtYWxsIHJlYWRzIGJlZm9yZQorc2VsZWN0aXZlbHkgcmVhZGluZyB0aGUgcGF5bG9hZHMgYSB0cmFuc2Zvcm0gYWN0dWFsbHkgbmVlZHMuCisKIE1ENSBoZXJlIGlkZW50aWZpZXMgYSBrbm93biBkdW1wIGJlY2F1c2UgUk9NIGRhdGFiYXNlcyBjb21tb25seSBwdWJsaXNoIGl0OwogaXQgaXMgbm90IGEgc2VjdXJpdHkgb3IgYXV0aGVudGljaXR5IGd1YXJhbnRlZS4gRG8gbm90IHBhc3RlIHRoZSBTSEEtMSB1c2VkIGJ5CiBHZW4xUmVjb21wJ3Mgb3duIGdhbWUtUk9NIGltcG9ydGVyIGludG8gYW4gaW1wb3J0J3MgYG1kNWAgZmllbGQuIE1vZCBhcmNoaXZlcwpAQCAtNDQxLDYgKzQ2MCwyOCBAQCBkZWZhdWx0KiogKDF4IGZyb250LCAyeCBiYWNrKS4KICAgYmFsbC10by1waWMgZ3JvdyBtdWx0aXBsaWVzIHlvdXIgc2NhbGUgdGhyb3VnaCBlYWNoIHN0YWdlLCBzbyBhIHJlc2NhbGVkCiAgIG1vbiBzdGlsbCBncm93cyBpbnRvIHBsYWNlIGZyb20gdGhlIGJhbGwsIGdyb3VuZGVkIHRoZSB3aG9sZSB3YXkuCiAKKyMjIEluc3RhbGxhdGlvbi1zY29wZWQgZ2VuZXJhdGVkIGNhY2hlCisKK0dlbmVyYXRlZCBkYXRhIGRlcml2ZWQgZnJvbSBhIHZhbGlkYXRlZCB1c2VyIHNvdXJjZSBvZnRlbiBiZWxvbmdzIHRvIHRoZSBtb2QKK2luc3RhbGxhdGlvbiByYXRoZXIgdGhhbiB0byBvbmUgUG9rw6ltb24gc2F2ZS4gYG1vZC5jYWNoZWAgaXMgdGhhdCBuYW1lc3BhY2U6CisKK2BgYGx1YQorbG9jYWwgb2ssIGVyciA9IG1vZC5jYWNoZTp3cml0ZSgiZXh0cmFjdC92MS9hcmVuYS5iaW4iLCBlbmNvZGVkQXJlbmEpCitsb2NhbCBieXRlcywgZXJyID0gbW9kLmNhY2hlOnJlYWQoImV4dHJhY3QvdjEvYXJlbmEuYmluIikKK2xvY2FsIGluZm8gPSBtb2QuY2FjaGU6aW5mbygiZXh0cmFjdC92MS9hcmVuYS5iaW4iKQorbW9kLmNhY2hlOmRlbGV0ZSgiZXh0cmFjdC92MS9hcmVuYS5iaW4iKQorYGBgCisKK1RoZSBwaHlzaWNhbCByb290IGlzIGVuZ2luZS1vd25lZCAoYG1vZF9jYWNoZS88bW9kLWlkPi9gKSBhbmQgbmV2ZXIgZXhwb3NlZCB0bwordGhlIG1vZC4gS2V5cyBhcmUgc2FmZSByZWxhdGl2ZSBwYXRocyBhbmQgYSBzaW5nbGUgd3JpdGUgaXMgY2FwcGVkIGF0IDY0IE1pQi4KK1RoZSBjYWNoZSBkb2VzIG5vdCByZXdpbmQgd2l0aCBjaGVja3BvaW50cyBhbmQgaXMgbm90IHNjb3BlZCB0byBnYW1lIHZlcnNpb24sCitzbG90LCBvciBwbGF5dGhyb3VnaC4gVGhlIG1vZCBvd25zIGl0cyBnZW5lcmF0ZWQgZm9ybWF0LCBmaW5nZXJwcmludHMsIHJlYnVpbGQKK3BvbGljeSwgYW5kIGNvbXBsZXRpb24gbWFya2VyOyB0aGUgZW5naW5lIHRyZWF0cyB0aGUgYnl0ZXMgYXMgb3BhcXVlIGRhdGEuCisKK1VzZSBgbW9kLnN0b3JhZ2VgIGluc3RlYWQgd2hlbiB0aGUgZGF0YSBiZWxvbmdzIHRvIG9uZSBwbGF5dGhyb3VnaC4gVXNlCitgbW9kLmNhY2hlYCB3aGVuIGl0IGlzIGEgcmVwcm9kdWNpYmxlIGluc3RhbGxhdGlvbiBhcnRpZmFjdCB0aGF0IGNhbiBiZSByZWJ1aWx0Citmcm9tIGEgZGVjbGFyZWQgdXNlciBzb3VyY2UuCisKICMjIER1cmFibGUgdG9vbCBzdG9yYWdlIGFuZCBydW50aW1lIGNoZWNrcG9pbnRzCiAKIGBtb2Quc2F2ZWAgcmVtYWlucyB0aGUgcmlnaHQgcGxhY2UgZm9yIHN0YXRlIHRoYXQgc2hvdWxkIHRyYXZlbCB3aXRoIHRoZSBuZXh0CmRpZmYgLS1naXQgYS9zcmMvaW1wb3J0L0NhY2hlRnMubHVhIGIvc3JjL2ltcG9ydC9DYWNoZUZzLmx1YQppbmRleCBhM2JmZDg4Li4wMGVmOGU5IDEwMDY0NAotLS0gYS9zcmMvaW1wb3J0L0NhY2hlRnMubHVhCisrKyBiL3NyYy9pbXBvcnQvQ2FjaGVGcy5sdWEKQEAgLTI4Nyw2ICsyODcsNDMgQEAgZnVuY3Rpb24gQ2FjaGVGcy53cml0ZShyZWwsIGRhdGEpCiAgIHJldHVybiBsb3ZlLmZpbGVzeXN0ZW0ud3JpdGUocmVsLCBkYXRhKQogZW5kCiAKKy0tIE9wZW4gYSBjYWNoZS1yZWxhdGl2ZSBmaWxlIGZvciBzdHJlYW1pbmcgcmVwbGFjZW1lbnQuIFRoZSByZXR1cm5lZCBoYW5kbGUKKy0tIGhhcyB3cml0ZShieXRlcykgYW5kIGNsb3NlKCkgbWV0aG9kcyBhbmQgZm9sbG93cyB0aGUgc2FtZSBwb3J0YWJsZS9zYXZlLWRpcgorLS0gcm91dGluZyBhcyBDYWNoZUZzLndyaXRlIHdpdGhvdXQgZm9yY2luZyB0aGUgY2FsbGVyIHRvIGhvbGQgdGhlIHdob2xlIGZpbGUKKy0tIGluIG9uZSBMdWEgc3RyaW5nLgorZnVuY3Rpb24gQ2FjaGVGcy5vcGVuV3JpdGUocmVsKQorICByZWwgPSB3aXRoUHJlZml4KHJlbCkKKyAgbG9jYWwgcm9vdCA9IENhY2hlRnMucm9vdCgpCisgIGlmIHJvb3QgdGhlbgorICAgIGVuc3VyZVBhcmVudHMocm9vdCwgcmVsKQorICAgIGxvY2FsIGYsIGVyciA9IGlvLm9wZW4ocmVhbFBhdGgocm9vdCwgcmVsKSwgIndiIikKKyAgICBpZiBub3QgZiB0aGVuIHJldHVybiBuaWwsIGVyciBlbmQKKyAgICByZXR1cm4geworICAgICAgd3JpdGUgPSBmdW5jdGlvbihfLCBkYXRhKQorICAgICAgICBsb2NhbCBvaywgd3JpdGVFcnIgPSBmOndyaXRlKGRhdGEpCisgICAgICAgIGlmIG5vdCBvayB0aGVuIHJldHVybiBuaWwsIHdyaXRlRXJyIGVuZAorICAgICAgICByZXR1cm4gdHJ1ZQorICAgICAgZW5kLAorICAgICAgY2xvc2UgPSBmdW5jdGlvbigpIGY6Y2xvc2UoKSBlbmQsCisgICAgfQorICBlbmQKKyAgaWYgbm90IChsb3ZlIGFuZCBsb3ZlLmZpbGVzeXN0ZW0gYW5kIGxvdmUuZmlsZXN5c3RlbS5uZXdGaWxlKSB0aGVuCisgICAgcmV0dXJuIG5pbCwgInN0cmVhbWluZyBjYWNoZSB3cml0ZXMgYXJlIHVuYXZhaWxhYmxlIgorICBlbmQKKyAgbG9jYWwgcGFyZW50ID0gcmVsOm1hdGNoKCJeKC4qKS9bXi9dKyQiKQorICBpZiBwYXJlbnQgYW5kIG5vdCBsb3ZlLmZpbGVzeXN0ZW0uY3JlYXRlRGlyZWN0b3J5KHBhcmVudCkgdGhlbgorICAgIGxvY2FsIGluZm8gPSBsb3ZlLmZpbGVzeXN0ZW0uZ2V0SW5mbyhwYXJlbnQpCisgICAgbG9jYWwgcmVhc29uID0gaW5mbyBhbmQgKCJhICIgLi4gaW5mby50eXBlIC4uICIgYWxyZWFkeSBleGlzdHMgdGhlcmUiKQorICAgICAgb3IgInVua25vd24gcmVhc29uIgorICAgIHJldHVybiBuaWwsICJjb3VsZCBub3QgY3JlYXRlICIgLi4gcGFyZW50IC4uICI6ICIgLi4gcmVhc29uCisgIGVuZAorICBsb2NhbCBmaWxlLCBtYWtlRXJyID0gbG92ZS5maWxlc3lzdGVtLm5ld0ZpbGUocmVsKQorICBpZiBub3QgZmlsZSB0aGVuIHJldHVybiBuaWwsIG1ha2VFcnIgb3IgImNvdWxkIG5vdCBjcmVhdGUgY2FjaGUgZmlsZSIgZW5kCisgIGxvY2FsIG9rLCBvcGVuRXJyID0gZmlsZTpvcGVuKCJ3IikKKyAgaWYgbm90IG9rIHRoZW4gcmV0dXJuIG5pbCwgb3BlbkVyciBvciAiY291bGQgbm90IG9wZW4gY2FjaGUgZmlsZSIgZW5kCisgIHJldHVybiBmaWxlCitlbmQKKwogLS0gcmVhZCBjYWNoZS1yZWxhdGl2ZSBgcmVsYDsgcmV0dXJucyB0aGUgYnl0ZXMgb3IgbmlsCiBmdW5jdGlvbiBDYWNoZUZzLnJlYWQocmVsKQogICByZWwgPSB3aXRoUHJlZml4KHJlbCkKZGlmZiAtLWdpdCBhL3NyYy9pbXBvcnQvUm9tSW1wb3J0ZXIubHVhIGIvc3JjL2ltcG9ydC9Sb21JbXBvcnRlci5sdWEKaW5kZXggOWNlMTc2Zi4uNDQ2YmI5MCAxMDA2NDQKLS0tIGEvc3JjL2ltcG9ydC9Sb21JbXBvcnRlci5sdWEKKysrIGIvc3JjL2ltcG9ydC9Sb21JbXBvcnRlci5sdWEKQEAgLTM4Miw2ICszODIsMTAyIEBAIGxvY2FsIGZ1bmN0aW9uIGV4dGVybmFsRmlsZVNpemUocGF0aCkKICAgcmV0dXJuIHNpemUKIGVuZAogCitsb2NhbCBmdW5jdGlvbiBvcGVuSW1wb3J0U291cmNlKHBhdGgpCisgIC0tIERlc2t0b3AgcGlja2VyIHBhdGhzIGxpdmUgb3V0c2lkZSBMw5ZWRSdzIHZpcnR1YWwgZmlsZXN5c3RlbS4gUHJlZmVyIHRoZQorICAtLSBuYXRpdmUgZmlsZSBoYW5kbGUgc28gYSAxLjQ2IEdpQiBkaXNjIGlzIG5ldmVyIGNvcGllZCB0byBhIHRlbXAgZmlsZSBvcgorICAtLSByZWFkIGludG8gb25lIEx1YSBzdHJpbmcgYmVmb3JlIHZhbGlkYXRpb24uCisgIGxvY2FsIG5hdGl2ZSA9IGlvLm9wZW4ocGF0aCwgInJiIikKKyAgaWYgbmF0aXZlIHRoZW4KKyAgICBsb2NhbCBzaXplID0gbmF0aXZlOnNlZWsoImVuZCIpCisgICAgaWYgc2l6ZSB0aGVuIG5hdGl2ZTpzZWVrKCJzZXQiLCAwKSBlbmQKKyAgICByZXR1cm4geworICAgICAgc2l6ZSA9IHNpemUsCisgICAgICByZWFkID0gZnVuY3Rpb24oXywgbikgcmV0dXJuIG5hdGl2ZTpyZWFkKG4pIGVuZCwKKyAgICAgIGNsb3NlID0gZnVuY3Rpb24oKSBuYXRpdmU6Y2xvc2UoKSBlbmQsCisgICAgfQorICBlbmQKKyAgaWYgbG92ZSBhbmQgbG92ZS5maWxlc3lzdGVtIGFuZCBsb3ZlLmZpbGVzeXN0ZW0ubmV3RmlsZSB0aGVuCisgICAgbG9jYWwgZmlsZSwgbWFrZUVyciA9IGxvdmUuZmlsZXN5c3RlbS5uZXdGaWxlKHBhdGgpCisgICAgaWYgbm90IGZpbGUgdGhlbiByZXR1cm4gbmlsLCBtYWtlRXJyIG9yICJjb3VsZCBub3Qgb3BlbiBzb3VyY2UgZmlsZSIgZW5kCisgICAgbG9jYWwgb2ssIG9wZW5FcnIgPSBmaWxlOm9wZW4oInIiKQorICAgIGlmIG5vdCBvayB0aGVuIHJldHVybiBuaWwsIG9wZW5FcnIgb3IgImNvdWxkIG5vdCBvcGVuIHNvdXJjZSBmaWxlIiBlbmQKKyAgICBsb2NhbCBzaXplID0gZmlsZS5nZXRTaXplIGFuZCBmaWxlOmdldFNpemUoKSBvciBuaWwKKyAgICByZXR1cm4geworICAgICAgc2l6ZSA9IHNpemUsCisgICAgICByZWFkID0gZnVuY3Rpb24oXywgbikgcmV0dXJuIGZpbGU6cmVhZChuKSBlbmQsCisgICAgICBjbG9zZSA9IGZ1bmN0aW9uKCkgZmlsZTpjbG9zZSgpIGVuZCwKKyAgICB9CisgIGVuZAorICByZXR1cm4gbmlsLCAic3RyZWFtaW5nIHNvdXJjZSBhY2Nlc3MgaXMgdW5hdmFpbGFibGUiCitlbmQKKworbG9jYWwgZnVuY3Rpb24gc3RyZWFtUmVxdWlyZWRJbXBvcnQobWFuaWZlc3QsIGltcG9ydElkLCBzb3VyY2UpCisgIGxvY2FsIFJlcXVpcmVkSW1wb3J0cyA9IHJlcXVpcmUoInNyYy5tb2RzLlJlcXVpcmVkSW1wb3J0cyIpCisgIGxvY2FsIHNwZWMgPSBSZXF1aXJlZEltcG9ydHMuc3BlYyhtYW5pZmVzdCwgaW1wb3J0SWQpCisgIGlmIG5vdCBzcGVjIHRoZW4gcmV0dXJuIG5pbCwgIkltcG9ydCBkZWNsYXJhdGlvbiB3YXMgbm90IGZvdW5kLiIgZW5kCisgIGlmIHNwZWMuZm9ybWF0ID09ICJuNjQiIHRoZW4KKyAgICByZXR1cm4gbmlsLCAic3RyZWFtaW5nIGNhbm9uaWNhbGl6YXRpb24gaXMgdW5hdmFpbGFibGUgZm9yIE42NCBpbXBvcnRzIgorICBlbmQKKyAgbG9jYWwgaW5wdXQsIG9wZW5FcnIgPSBvcGVuSW1wb3J0U291cmNlKHNvdXJjZSkKKyAgaWYgbm90IGlucHV0IHRoZW4gcmV0dXJuIG5pbCwgb3BlbkVyciBlbmQKKyAgbG9jYWwgc2l6ZUVyciA9IFJlcXVpcmVkSW1wb3J0cy5zaXplRXJyb3Ioc3BlYywgaW5wdXQuc2l6ZSwgZmFsc2UpCisgIGlmIHNpemVFcnIgdGhlbiBpbnB1dDpjbG9zZSgpOyByZXR1cm4gbmlsLCBzaXplRXJyIGVuZAorCisgIGxvY2FsIENhY2hlRnMgPSByZXF1aXJlKCJzcmMuaW1wb3J0LkNhY2hlRnMiKQorICBsb2NhbCBkZXN0aW5hdGlvbiA9IFJlcXVpcmVkSW1wb3J0cy5wYXRoKG1hbmlmZXN0LCBzcGVjKQorICBsb2NhbCBzYXZlZFByZWZpeCA9IENhY2hlRnMucHJlZml4CisgIENhY2hlRnMucHJlZml4ID0gIiIKKyAgQ2FjaGVGcy5yZW1vdmUoUmVxdWlyZWRJbXBvcnRzLnJlY2VpcHRQYXRoKG1hbmlmZXN0LCBzcGVjKSkKKyAgQ2FjaGVGcy5yZW1vdmUoZGVzdGluYXRpb24pCisgIGxvY2FsIG91dHB1dCwgbWFrZUVyciA9IENhY2hlRnMub3BlbldyaXRlKGRlc3RpbmF0aW9uKQorICBpZiBub3Qgb3V0cHV0IHRoZW4KKyAgICBDYWNoZUZzLnByZWZpeCA9IHNhdmVkUHJlZml4CisgICAgaW5wdXQ6Y2xvc2UoKQorICAgIHJldHVybiBuaWwsIG1ha2VFcnIgb3IgImNvdWxkIG5vdCBjcmVhdGUgaW1wb3J0ZWQgZmlsZSIKKyAgZW5kCisKKyAgbG9jYWwgZnVuY3Rpb24gY2xlYW51cERlc3RpbmF0aW9uKCkKKyAgICBDYWNoZUZzLnJlbW92ZShkZXN0aW5hdGlvbikKKyAgICBDYWNoZUZzLnByZWZpeCA9IHNhdmVkUHJlZml4CisgIGVuZAorCisgIGxvY2FsIE1ENSA9IHJlcXVpcmUoInNyYy5tb2RzLlN0cmVhbU1ENSIpCisgIGxvY2FsIG1kNSA9IE1ENS5uZXcoKQorICBsb2NhbCB0b3RhbCwgY2h1bmtCeXRlcyA9IDAsIDQgKiAxMDI0ICogMTAyNAorICB3aGlsZSB0cnVlIGRvCisgICAgbG9jYWwgY2h1bmsgPSBpbnB1dDpyZWFkKGNodW5rQnl0ZXMpCisgICAgaWYgbm90IGNodW5rIG9yICNjaHVuayA9PSAwIHRoZW4gYnJlYWsgZW5kCisgICAgbWQ1OnVwZGF0ZShjaHVuaykKKyAgICBsb2NhbCB3cm90ZSwgd3JpdGVFcnIgPSBvdXRwdXQ6d3JpdGUoY2h1bmspCisgICAgaWYgd3JvdGUgPT0gZmFsc2Ugb3Igd3JvdGUgPT0gbmlsIHRoZW4KKyAgICAgIGlucHV0OmNsb3NlKCk7IG91dHB1dDpjbG9zZSgpOyBjbGVhbnVwRGVzdGluYXRpb24oKQorICAgICAgcmV0dXJuIG5pbCwgImNvdWxkIG5vdCBjb3B5IGltcG9ydDogIiAuLiB0 \ No newline at end of file diff --git a/.github/pr-build/large-import.patch.b64.2 b/.github/pr-build/large-import.patch.b64.2 new file mode 100644 index 00000000..e7aac75f --- /dev/null +++ b/.github/pr-build/large-import.patch.b64.2 @@ -0,0 +1 @@ +b3N0cmluZyh3cml0ZUVyciBvciAid3JpdGUgZmFpbGVkIikKKyAgICBlbmQKKyAgICB0b3RhbCA9IHRvdGFsICsgI2NodW5rCisgICAgaWYgI2NodW5rIDwgY2h1bmtCeXRlcyB0aGVuIGJyZWFrIGVuZAorICBlbmQKKyAgaW5wdXQ6Y2xvc2UoKQorICBvdXRwdXQ6Y2xvc2UoKQorCisgIGlmIGlucHV0LnNpemUgYW5kIHRvdGFsIH49IGlucHV0LnNpemUgdGhlbgorICAgIGNsZWFudXBEZXN0aW5hdGlvbigpCisgICAgcmV0dXJuIG5pbCwgKCJzb3VyY2UgcmVhZCBlbmRlZCBlYXJseSAoZXhwZWN0ZWQgJWQgYnl0ZXMsIGNvcGllZCAlZCkiKTpmb3JtYXQoaW5wdXQuc2l6ZSwgdG90YWwpCisgIGVuZAorICBsb2NhbCBzdG9yZWRTaXplRXJyID0gUmVxdWlyZWRJbXBvcnRzLnNpemVFcnJvcihzcGVjLCB0b3RhbCwgdHJ1ZSkKKyAgaWYgc3RvcmVkU2l6ZUVyciB0aGVuIGNsZWFudXBEZXN0aW5hdGlvbigpOyByZXR1cm4gbmlsLCBzdG9yZWRTaXplRXJyIGVuZAorICBsb2NhbCBkaWdlc3QgPSBtZDU6ZmluYWwoKQorICBDYWNoZUZzLnByZWZpeCA9IHNhdmVkUHJlZml4CisgIGxvY2FsIGFjY2VwdGVkLCBkZXRhaWwgPSBSZXF1aXJlZEltcG9ydHMuYWNjZXB0U3RvcmVkRGlnZXN0KAorICAgIG1hbmlmZXN0LCBpbXBvcnRJZCwgZGlnZXN0LCBsb3ZlLmZpbGVzeXN0ZW0pCisgIGlmIG5vdCBhY2NlcHRlZCB0aGVuCisgICAgQ2FjaGVGcy5wcmVmaXggPSAiIgorICAgIENhY2hlRnMucmVtb3ZlKGRlc3RpbmF0aW9uKQorICAgIENhY2hlRnMucHJlZml4ID0gc2F2ZWRQcmVmaXgKKyAgICByZXR1cm4gbmlsLCBkZXRhaWwKKyAgZW5kCisgIHJldHVybiB0cnVlLCBkZXRhaWwKK2VuZAorCiBsb2NhbCBmdW5jdGlvbiByZWFkRHJvcHBlZEZpbGUoZmlsZSkKICAgbG9jYWwgb2ssIG9wZW5FcnJvciA9IGZpbGU6b3BlbigiciIpCiAgIGlmIG5vdCBvayB0aGVuIHJldHVybiBuaWwsIG9wZW5FcnJvciBlbmQKQEAgLTEyMjgsMTEgKzEzMjQsMTMgQEAgbG9jYWwgZnVuY3Rpb24gY2hvb3NlUmVxdWlyZWRGaWxlKCkKICAgICAgICIkZD1OZXctT2JqZWN0IFN5c3RlbS5XaW5kb3dzLkZvcm1zLk9wZW5GaWxlRGlhbG9nOyIsCiAgICAgICAiJGQuVGl0bGU9JyIgLi4gcHJvbXB0IC4uICInOyIsCiAgICAgICAiJGQuRmlsdGVyPSdBbGwgZmlsZXMgKCouKil8Ki4qJzsiLAorICAgICAgLS0gUmVxdWlyZWQgaW1wb3J0cyBjYW4gYmUgbXVsdGktZ2lnYWJ5dGUgb3B0aWNhbC1kaXNjIGltYWdlcy4gRG8gTk9UCisgICAgICAtLSBzdGFnZSB0aGVtIHRocm91Z2ggJVRFTVAlOiB0aGF0IGRvdWJsZXMgZnJlZS1zcGFjZSByZXF1aXJlbWVudHMgYW5kIGEKKyAgICAgIC0tIGZhaWxlZCBDb3B5LUl0ZW0gY2FuIGxlYXZlIGEgcGxhdXNpYmxlLWxvb2tpbmcgdHJ1bmNhdGVkIHRlbXAgZmlsZS4KKyAgICAgIC0tIFN0cmVhbSB0aGUgc2VsZWN0ZWQgc291cmNlIGRpcmVjdGx5IGludG8gdGhlIG1vZC1vd25lZCBkZXN0aW5hdGlvbi4KICAgICAgICJpZigkZC5TaG93RGlhbG9nKCkgLWVxICdPSycpeyIsCi0gICAgICAiJHQ9Sm9pbi1QYXRoICRlbnY6VEVNUCAncG9rZXBvcnRfcmVxdWlyZWRfaW1wb3J0LmJpbic7IiwKLSAgICAgICJDb3B5LUl0ZW0gLUxpdGVyYWxQYXRoICRkLkZpbGVOYW1lIC1EZXN0aW5hdGlvbiAkdCAtRm9yY2U7IiwKICAgICAgICJbQ29uc29sZV06Ok91dHB1dEVuY29kaW5nPVtUZXh0LkVuY29kaW5nXTo6VVRGODsiLAotICAgICAgIltDb25zb2xlXTo6V3JpdGUoJHQpfSIsCisgICAgICAiW0NvbnNvbGVdOjpXcml0ZSgkZC5GaWxlTmFtZSl9IiwKICAgICB9KQogICAgIHJldHVybiBjb21tYW5kT3V0cHV0KAogICAgICAgJ3Bvd2Vyc2hlbGwgLU5vUHJvZmlsZSAtU1RBIC1Db21tYW5kICInIC4uIHNjcmlwdCAuLiAnIicpCkBAIC0yMDY5LDggKzIxNzAsMTMgQEAgZnVuY3Rpb24gUm9tSW1wb3J0ZXI6X2ltcG9ydFJlcXVpcmVkU291cmNlKG1vZElkLCBpbXBvcnRJZCwgc291cmNlLCBjb25maXJtZWQpCiAgICAgcmV0dXJuIG5pbAogICBlbmQKICAgbG9jYWwgUmVxdWlyZWRJbXBvcnRzID0gcmVxdWlyZSgic3JjLm1vZHMuUmVxdWlyZWRJbXBvcnRzIikKLSAgbG9jYWwgaW5mbyA9IGxvdmUuZmlsZXN5c3RlbS5nZXRJbmZvKHNvdXJjZSwgImZpbGUiKQotICBsb2NhbCBzaXplID0gaW5mbyBhbmQgaW5mby5zaXplIG9yIGV4dGVybmFsRmlsZVNpemUoc291cmNlKQorICAtLSBBIGRlc2t0b3AgcGlja2VyIHJldHVybnMgYSBob3N0IHBhdGguIEFzayB0aGUgaG9zdCBmaWxlIGhhbmRsZSBmaXJzdDsKKyAgLS0gbG92ZS5maWxlc3lzdGVtLmdldEluZm8gaXMgb25seSBhdXRob3JpdGF0aXZlIGZvciB2aXJ0dWFsL3NhdmUgcGF0aHMuCisgIGxvY2FsIHNpemUgPSBleHRlcm5hbEZpbGVTaXplKHNvdXJjZSkKKyAgaWYgbm90IHNpemUgdGhlbgorICAgIGxvY2FsIGluZm8gPSBsb3ZlLmZpbGVzeXN0ZW0uZ2V0SW5mbyhzb3VyY2UsICJmaWxlIikKKyAgICBzaXplID0gaW5mbyBhbmQgaW5mby5zaXplIG9yIG5pbAorICBlbmQKICAgbG9jYWwgc2l6ZUVyciA9IFJlcXVpcmVkSW1wb3J0cy5zaXplRXJyb3Ioc3BlYywgc2l6ZSwgZmFsc2UpCiAgIGlmIHNpemVFcnIgdGhlbgogICAgIHJlcXVpcmVkSW1wb3J0Tm90aWNlKHNlbGYsIG1vZElkLCBpbXBvcnRJZCwgc2l6ZUVycikKQEAgLTIwOTMsNiArMjE5OSwyMCBAQCBmdW5jdGlvbiBSb21JbXBvcnRlcjpfaW1wb3J0UmVxdWlyZWRTb3VyY2UobW9kSWQsIGltcG9ydElkLCBzb3VyY2UsIGNvbmZpcm1lZCkKICAgICB9CiAgICAgcmV0dXJuIG5pbAogICBlbmQKKyAgaWYgdHlwZShzaXplKSA9PSAibnVtYmVyIiBhbmQgc2l6ZSA+IFJlcXVpcmVkSW1wb3J0cy5MQVJHRV9XQVJOX0JZVEVTCisgICAgICBhbmQgc3BlYy5mb3JtYXQgfj0gIm42NCIgdGhlbgorICAgIGxvY2FsIG9rLCByZXN1bHQgPSBzdHJlYW1SZXF1aXJlZEltcG9ydChtYW5pZmVzdCwgaW1wb3J0SWQsIHNvdXJjZSkKKyAgICBpZiBvayB0aGVuCisgICAgICBzZWxmLnJlcXVpcmVkSW1wb3J0Tm90aWNlID0gbmlsCisgICAgICBzZWxmLm1vZE5vdGljZSA9IHsgb2sgPSB0cnVlLCB0ZXh0ID0gIkltcG9ydGVkICIgLi4gdG9zdHJpbmcoaW1wb3J0SWQpCisgICAgICAgIC4uICIgZm9yICIgLi4gdG9zdHJpbmcobWFuaWZlc3QubmFtZSBvciBtYW5pZmVzdC5pZCkgLi4gIi4iIH0KKyAgICAgIHNlbGY6X3JlZnJlc2hNb2RzKCkKKyAgICAgIHJldHVybiB0cnVlCisgICAgZW5kCisgICAgcmVxdWlyZWRJbXBvcnROb3RpY2Uoc2VsZiwgbW9kSWQsIGltcG9ydElkLCByZXN1bHQpCisgICAgc2VsZi5tb2ROb3RpY2UgPSBuaWwKKyAgICByZXR1cm4gbmlsCisgIGVuZAogICBsb2NhbCBkYXRhID0gbG92ZS5maWxlc3lzdGVtLnJlYWQoc291cmNlKQogICBpZiBub3QgZGF0YSB0aGVuIGRhdGEgPSByZWFkRXh0ZXJuYWxQYXRoKHNvdXJjZSkgZW5kCiAgIGlmIG5vdCBkYXRhIHRoZW4KZGlmZiAtLWdpdCBhL3NyYy9tb2RzL0xvYWRlci5sdWEgYi9zcmMvbW9kcy9Mb2FkZXIubHVhCmluZGV4IDhlYzQ3NjcuLmRkZmMzZjEgMTAwNjQ0Ci0tLSBhL3NyYy9tb2RzL0xvYWRlci5sdWEKKysrIGIvc3JjL21vZHMvTG9hZGVyLmx1YQpAQCAtOTYyLDYgKzk2Miw4IEBAIGZ1bmN0aW9uIExvYWRlcjpfYXBpKG1vZCkKICAgbG9jYWwgU3RvcmFnZSA9IGVuZ2luZVJlcXVpcmUoInNyYy5tb2RzLlN0b3JhZ2UiKQogICBsb2NhbCBzdG9yYWdlID0gU3RvcmFnZSBhbmQgU3RvcmFnZS5uZXcobW9kSWQsIGxvYWRlci5mcykKICAgbG9jYWwgQ2hlY2twb2ludCA9IGVuZ2luZVJlcXVpcmUoInNyYy5jb3JlLkNoZWNrcG9pbnQiKQorICBsb2NhbCBJbXBvcnRBY2Nlc3MgPSBlbmdpbmVSZXF1aXJlKCJzcmMubW9kcy5JbXBvcnRBY2Nlc3MiKQorICBsb2NhbCBpbXBvcnRBcGksIGluc3RhbGxDYWNoZSA9IEltcG9ydEFjY2Vzcy5uZXcobW9kLm1hbmlmZXN0LCBsb2FkZXIuZnMpCiAgIGxvY2FsIGFwaSA9IHsKICAgICBpZCA9IG1vZElkLAogICAgIHZlcnNpb24gPSBtb2QubWFuaWZlc3QudmVyc2lvbiwKQEAgLTExNjEsNiArMTE2MywxMiBAQCBmdW5jdGlvbiBMb2FkZXI6X2FwaShtb2QpCiAgICAgLS0gY2hlY2twb2ludC4gVGhlCiAgICAgLS0gZW5naW5lIGJpbmRzIHZlcnNpb24vcGxheXRocm91Z2gvbW9kIHNjb3BlIGFuZCBwb3J0YWJsZSBwZXJzaXN0ZW5jZTsKICAgICAtLSBjYWxsZXJzIG5ldmVyIHJlY2VpdmUgcGF0aHMgb3IgYSByYXcgZmlsZXN5c3RlbSBoYW5kbGUuCisgICAgLS0gUmVhZC1vbmx5IGJvdW5kZWQgYWNjZXNzIHRvIHRoaXMgbW9kJ3MgbWFuaWZlc3QtZGVjbGFyZWQsIGxhdW5jaGVyLXZhbGlkYXRlZAorICAgIC0tIGltcG9ydHMuIE5vIGhvc3QgcGF0aCBpcyBleHBvc2VkOyBsYXJnZSBzb3VyY2VzIGFyZSByZWFkIGluIGJvdW5kZWQgcmFuZ2VzLgorICAgIGltcG9ydHMgPSBpbXBvcnRBcGksCisgICAgLS0gSW5zdGFsbGF0aW9uLXNjb3BlZCBnZW5lcmF0ZWQgZGF0YSwgaW5kZXBlbmRlbnQgZnJvbSBQb2vDqW1vbiBzYXZlIHNsb3RzLgorICAgIC0tIFRoaXMgaXMgd2hlcmUgUk9NLWRlcml2ZWQgY2FjaGVzIGJlbG9uZzsgbW9kLnN0b3JhZ2UgcmVtYWlucyBwbGF5dGhyb3VnaC1zY29wZWQuCisgICAgY2FjaGUgPSBpbnN0YWxsQ2FjaGUsCiAgICAgc3RvcmFnZSA9IHsKICAgICAgIGNvbnRleHQgPSBmdW5jdGlvbihfLCBnYW1lKSByZXR1cm4gc3RvcmFnZTpjb250ZXh0KGdhbWUpIGVuZCwKICAgICAgIHNlbGVjdGVkID0gZnVuY3Rpb24oXywgZ2FtZSkgcmV0dXJuIHN0b3JhZ2U6c2VsZWN0ZWQoZ2FtZSkgZW5kLApkaWZmIC0tZ2l0IGEvc3JjL21vZHMvUmVxdWlyZWRJbXBvcnRzLmx1YSBiL3NyYy9tb2RzL1JlcXVpcmVkSW1wb3J0cy5sdWEKaW5kZXggODA2ZTkxNi4uMjVlOGFhOCAxMDA2NDQKLS0tIGEvc3JjL21vZHMvUmVxdWlyZWRJbXBvcnRzLmx1YQorKysgYi9zcmMvbW9kcy9SZXF1aXJlZEltcG9ydHMubHVhCkBAIC0xMjAsNiArMTIwLDM2IEBAIGxvY2FsIGZ1bmN0aW9uIGFjY2VwdHMoc3BlYywgZGlnZXN0KQogICByZXR1cm4gZmFsc2UKIGVuZAogCitsb2NhbCBmdW5jdGlvbiBzcGVjQnlJZChtYW5pZmVzdCwgaW1wb3J0SWQpCisgIGZvciBfLCBjYW5kaWRhdGUgaW4gaXBhaXJzKGFsbFNwZWNzKG1hbmlmZXN0KSkgZG8KKyAgICBpZiBjYW5kaWRhdGUuaWQgPT0gaW1wb3J0SWQgdGhlbiByZXR1cm4gY2FuZGlkYXRlIGVuZAorICBlbmQKKyAgcmV0dXJuIG5pbAorZW5kCisKK1JlcXVpcmVkSW1wb3J0cy5zcGVjID0gc3BlY0J5SWQKKworbG9jYWwgZnVuY3Rpb24gc3RyZWFtRGlnZXN0KGZzLCBwYXRoLCBjaHVua0J5dGVzKQorICBpZiBub3QgKGZzIGFuZCBmcy5uZXdGaWxlKSB0aGVuIHJldHVybiBuaWwsICJzdHJlYW1pbmcgZmlsZSBhY2Nlc3MgaXMgdW5hdmFpbGFibGUiIGVuZAorICBsb2NhbCBmaWxlLCBtYWtlRXJyID0gZnMubmV3RmlsZShwYXRoKQorICBpZiBub3QgZmlsZSB0aGVuIHJldHVybiBuaWwsIG1ha2VFcnIgb3IgImNvdWxkIG5vdCBvcGVuIHN0b3JlZCBpbXBvcnQiIGVuZAorICBsb2NhbCBvaywgb3BlbkVyciA9IGZpbGU6b3BlbigiciIpCisgIGlmIG5vdCBvayB0aGVuIHJldHVybiBuaWwsIG9wZW5FcnIgb3IgImNvdWxkIG5vdCBvcGVuIHN0b3JlZCBpbXBvcnQiIGVuZAorICBsb2NhbCBNRDUgPSByZXF1aXJlKCJzcmMubW9kcy5TdHJlYW1NRDUiKQorICBsb2NhbCBjdHggPSBNRDUubmV3KCkKKyAgY2h1bmtCeXRlcyA9IGNodW5rQnl0ZXMgb3IgKDQgKiAxMDI0ICogMTAyNCkKKyAgd2hpbGUgdHJ1ZSBkbworICAgIGxvY2FsIGRhdGEsIHJlYWRFcnIgPSBmaWxlOnJlYWQoY2h1bmtCeXRlcykKKyAgICBpZiBkYXRhIGFuZCAjZGF0YSA+IDAgdGhlbiBjdHg6dXBkYXRlKGRhdGEpIGVuZAorICAgIGlmIG5vdCBkYXRhIG9yICNkYXRhIDwgY2h1bmtCeXRlcyB0aGVuCisgICAgICBpZiByZWFkRXJyIHRoZW4gZmlsZTpjbG9zZSgpOyByZXR1cm4gbmlsLCByZWFkRXJyIGVuZAorICAgICAgYnJlYWsKKyAgICBlbmQKKyAgZW5kCisgIGZpbGU6Y2xvc2UoKQorICByZXR1cm4gY3R4OmZpbmFsKCkKK2VuZAorCiBmdW5jdGlvbiBSZXF1aXJlZEltcG9ydHMucGF0aChtYW5pZmVzdCwgc3BlYykKICAgcmV0dXJuIG1hbmlmZXN0LnBhdGggLi4gIi9iYXNlcm9tcy8iIC4uIHNwZWMuZmlsZQogZW5kCkBAIC0xODAsNiArMjEwLDM1IEBAIGxvY2FsIGZ1bmN0aW9uIHJlbW92ZVJlY2VpcHQobWFuaWZlc3QsIHNwZWMsIGZzKQogICBlbmQKIGVuZAogCistLSBGaW5hbGl6ZSBhIGNhbGxlci1zdHJlYW1lZCBpbXBvcnQgYWZ0ZXIgdGhlIGRlc3RpbmF0aW9uIGJ5dGVzIGhhdmUgYWxyZWFkeQorLS0gYmVlbiBjb3BpZWQgaW50byB0aGUgZW5naW5lLW93bmVkIGJhc2Vyb21zIHBhdGguIFRoaXMga2VlcHMgbGFyZ2UgaW1wb3J0cworLS0gb3V0IG9mIGEgc2luZ2xlIEx1YSBzdHJpbmcgd2hpbGUgcHJlc2VydmluZyB0aGUgc2FtZSBzaXplL01ENSByZWNlaXB0IHJ1bGVzLgorZnVuY3Rpb24gUmVxdWlyZWRJbXBvcnRzLmFjY2VwdFN0b3JlZERpZ2VzdChtYW5pZmVzdCwgaW1wb3J0SWQsIGRpZ2VzdCwgZnMpCisgIGZzID0gZnMgb3IgKGxvdmUgYW5kIGxvdmUuZmlsZXN5c3RlbSkKKyAgbG9jYWwgc3BlYyA9IHNwZWNCeUlkKG1hbmlmZXN0LCBpbXBvcnRJZCkKKyAgaWYgbm90IHNwZWMgdGhlbiByZXR1cm4gbmlsLCAidW5rbm93biByZXF1aXJlZCBpbXBvcnQ6ICIgLi4gdG9zdHJpbmcoaW1wb3J0SWQpIGVuZAorICBkaWdlc3QgPSB0b3N0cmluZyhkaWdlc3Qgb3IgIiIpOmxvd2VyKCkKKyAgaWYgbm90IGFjY2VwdHMoc3BlYywgZGlnZXN0KSB0aGVuCisgICAgcmV0dXJuIG5pbCwgKCJNRDUgbWlzbWF0Y2ggKGdvdCAlcykiKTpmb3JtYXQoZGlnZXN0IH49ICIiIGFuZCBkaWdlc3Qgb3IgInVuYXZhaWxhYmxlIikKKyAgZW5kCisgIGxvY2FsIHBhdGggPSBSZXF1aXJlZEltcG9ydHMucGF0aChtYW5pZmVzdCwgc3BlYykKKyAgbG9jYWwgaW5mbyA9IGZzIGFuZCBmcy5nZXRJbmZvIGFuZCBmcy5nZXRJbmZvKHBhdGgsICJmaWxlIikgb3IgbmlsCisgIGlmIG5vdCBpbmZvIHRoZW4gcmV0dXJuIG5pbCwgImNvcGllZCBpbXBvcnQgaXMgbWlzc2luZyIgZW5kCisgIGxvY2FsIHNpemVFcnIgPSBSZXF1aXJlZEltcG9ydHMuc2l6ZUVycm9yKHNwZWMsIGluZm8uc2l6ZSwgdHJ1ZSkKKyAgaWYgc2l6ZUVyciB0aGVuIHJldHVybiBuaWwsIHNpemVFcnIgZW5kCisgIGlmIGxvdmUgYW5kIGZzID09IGxvdmUuZmlsZXN5c3RlbSB0aGVuCisgICAgbG9jYWwgc2F2ZWRQcmVmaXggPSBDYWNoZUZzLnByZWZpeAorICAgIENhY2hlRnMucHJlZml4ID0gIiIKKyAgICBDYWNoZUZzLnJlbW92ZShyZW1vdmVkTWFya2VyKG1hbmlmZXN0LCBzcGVjKSkKKyAgICBDYWNoZUZzLnByZWZpeCA9IHNhdmVkUHJlZml4CisgIGVsc2VpZiBmcyBhbmQgZnMucmVtb3ZlIHRoZW4KKyAgICBmcy5yZW1vdmUocmVtb3ZlZE1hcmtlcihtYW5pZmVzdCwgc3BlYykpCisgIGVuZAorICB3cml0ZVJlY2VpcHQobWFuaWZlc3QsIHNwZWMsIGRpZ2VzdCwgaW5mbywgZnMpCisgIHJldHVybiB0cnVlLCBkaWdlc3QKK2VuZAorCisKIC0tIFZhbGlkYXRlIGJ5dGVzIGFnYWluc3QgYSBkZWNsYXJhdGlvbi4gIFRoZSByZXR1cm5lZCBkYXRhIGlzIGNhbm9uaWNhbGl6ZWQKIC0tIChub3RhYmx5IGZvciBONjQgYnl0ZSBvcmRlci9oZWFkZXIgdmFyaWFudHMpIGFuZCBpcyB3aGF0IG11c3QgYmUgc3RvcmVkLgogZnVuY3Rpb24gUmVxdWlyZWRJbXBvcnRzLnZhbGlkYXRlRGF0YShzcGVjLCBkYXRhLCBoYXNoRm4pCkBAIC0yMTcsNiArMjc2LDIyIEBAIGZ1bmN0aW9uIFJlcXVpcmVkSW1wb3J0cy52YWxpZGF0ZVN0b3JlZChtYW5pZmVzdCwgc3BlYywgZnMsIGhhc2hGbikKICAgbG9jYWwgY2FjaGVkID0gY2FjaGVkRGlnZXN0KG1hbmlmZXN0LCBzcGVjLCBmcywgaW5mbykKICAgaWYgY2FjaGVkIHRoZW4gcmV0dXJuIHRydWUsIGNhY2hlZCwgdHJ1ZSBlbmQKICAgcmVtb3ZlUmVjZWlwdChtYW5pZmVzdCwgc3BlYywgZnMpCisgIC0tIExhcmdlIHJhdyBpbXBvcnRzIChHYW1lQ3ViZSBkaXNjcywgZnV0dXJlIG9wdGljYWwgaW1hZ2VzLCBldGMuKSBtdXN0IG5ldmVyCisgIC0tIGJlIG1hdGVyaWFsaXplZCBpbnRvIG9uZSBMdWEgc3RyaW5nIG1lcmVseSBiZWNhdXNlIHRoZWlyIHZhbGlkYXRpb24KKyAgLS0gcmVjZWlwdCB3YXMgbG9zdC4gU3RyZWFtIHRoZSBNRDUgZGlyZWN0bHkgZnJvbSB0aGUgaW5zdGFsbGVkIGZpbGUuIE42NAorICAtLSBzb3VyY2VzIHN0YXkgb24gdGhlIGNhbm9uaWNhbGl6YXRpb24gcGF0aCBiZWNhdXNlIGJ5dGUtb3JkZXIvaGVhZGVyCisgIC0tIG5vcm1hbGl6YXRpb24gaXMgcGFydCBvZiB0aGVpciB2YWxpZGF0aW9uIGNvbnRyYWN0LgorICBpZiBpbmZvLnNpemUgYW5kIGluZm8uc2l6ZSA+IFJlcXVpcmVkSW1wb3J0cy5MQVJHRV9XQVJOX0JZVEVTCisgICAgICBhbmQgc3BlYy5mb3JtYXQgfj0gIm42NCIgYW5kIGZzLm5ld0ZpbGUgdGhlbgorICAgIGxvY2FsIGRpZ2VzdCwgaGFzaEVyciA9IHN0cmVhbURpZ2VzdChmcywgcGF0aCkKKyAgICBpZiBub3QgZGlnZXN0IHRoZW4gcmV0dXJuIG5pbCwgaGFzaEVyciBlbmQKKyAgICBpZiBub3QgYWNjZXB0cyhzcGVjLCBkaWdlc3QpIHRoZW4KKyAgICAgIHJldHVybiBuaWwsICgiTUQ1IG1pc21hdGNoIChnb3QgJXMpIik6Zm9ybWF0KGRpZ2VzdCkKKyAgICBlbmQKKyAgICBpbmZvID0gZnMuZ2V0SW5mbyhwYXRoLCAiZmlsZSIpIG9yIGluZm8KKyAgICB3cml0ZVJlY2VpcHQobWFuaWZlc3QsIHNwZWMsIGRpZ2VzdCwgaW5mbywgZnMpCisgICAgcmV0dXJuIHRydWUsIGRpZ2VzdCwgZmFsc2UKKyAgZW5kCiAgIGlmIG5vdCBmcy5yZWFkIHRoZW4gcmV0dXJuIG5pbCwgImZpbGUgY291bGQgbm90IGJlIHJlYWQiIGVuZAogICBsb2NhbCBkYXRhID0gZnMucmVhZChwYXRoKQogICBsb2NhbCBub3JtYWxpemVkLCBkZXRhaWwgPSBSZXF1aXJlZEltcG9ydHMudmFsaWRhdGVTdG9yZWREYXRhKHNwZWMsIGRhdGEsIGhhc2hGbikK \ No newline at end of file diff --git a/.github/workflows/apply-large-import-pr.yml b/.github/workflows/apply-large-import-pr.yml new file mode 100644 index 00000000..1ec658d0 --- /dev/null +++ b/.github/workflows/apply-large-import-pr.yml @@ -0,0 +1,37 @@ +name: Apply prepared large-import patch + +on: + push: + branches: + - fix/large-required-imports-v2 + +permissions: + contents: write + +jobs: + apply: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Reconstruct and apply patch + shell: bash + run: | + set -euo pipefail + base64 -d .github/pr-build/large-import.patch.b64.1 > /tmp/large-import.patch + base64 -d .github/pr-build/large-import.patch.b64.2 >> /tmp/large-import.patch + git apply --check /tmp/large-import.patch + git apply /tmp/large-import.patch + rm -rf .github/pr-build + rm -f .github/workflows/apply-large-import-pr.yml + - name: Commit prepared change + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix(mods): support large required imports end-to-end" + git push origin HEAD:fix/large-required-imports-v2 From 2d28d18bf672a59ccc8bbdbd44a7617cbfaa7227 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:10:01 +0000 Subject: [PATCH 02/15] fix(mods): support large required imports end-to-end --- .github/pr-build/large-import.patch.b64.1 | 1 - .github/pr-build/large-import.patch.b64.2 | 1 - .github/workflows/apply-large-import-pr.yml | 37 ------ docs/modding.md | 51 +++++++- src/import/CacheFs.lua | 37 ++++++ src/import/RomImporter.lua | 127 +++++++++++++++++++- src/mods/Loader.lua | 8 ++ src/mods/RequiredImports.lua | 75 ++++++++++++ 8 files changed, 288 insertions(+), 49 deletions(-) delete mode 100644 .github/pr-build/large-import.patch.b64.1 delete mode 100644 .github/pr-build/large-import.patch.b64.2 delete mode 100644 .github/workflows/apply-large-import-pr.yml diff --git a/.github/pr-build/large-import.patch.b64.1 b/.github/pr-build/large-import.patch.b64.1 deleted file mode 100644 index 7c5254ec..00000000 --- a/.github/pr-build/large-import.patch.b64.1 +++ /dev/null @@ -1 +0,0 @@ -ZGlmZiAtLWdpdCBhL2RvY3MvbW9kZGluZy5tZCBiL2RvY3MvbW9kZGluZy5tZAppbmRleCA0MDE2ZTFmLi4yNWU3NTk2IDEwMDY0NAotLS0gYS9kb2NzL21vZGRpbmcubWQKKysrIGIvZG9jcy9tb2RkaW5nLm1kCkBAIC0xMjIsMjEgKzEyMiw0MCBAQCBFYWNoIG9iamVjdCByZXF1aXJlcyBhIHN0YWJsZSBgaWRgLCBhIGRpc3BsYXkgYG5hbWVgLCBhIGRlc3RpbmF0aW9uIGBmaWxlYAogZGlnZXN0cy4gYGZvcm1hdGAgaXMgZWl0aGVyIGAicmF3ImAgKHRoZSBkZWZhdWx0KSBvciBgIm42NCJgLiBBbiBvcHRpb25hbAogYGRlc2NyaXB0aW9uYCBnaXZlcyBwbGF5ZXJzIGR1bXAgb3IgcmVnaW9uIGd1aWRhbmNlIGluIHRoZSBpbXBvcnQgcGFuZWwuCiBgc2l6ZWAgZGVjbGFyZXMgdGhlIGV4YWN0IGNhbm9uaWNhbCBieXRlIGxlbmd0aDsgYG1heF9zaXplYCBkZWNsYXJlcyBhIHNtYWxsZXIKLXBlci1pbXBvcnQgY2VpbGluZyB3aGVuIGFuIGV4YWN0IHNpemUgaXMgbm90IGFwcHJvcHJpYXRlLiBFdmVyeSBpbXBvcnQgYWxzbwotaGFzIGFuIGVuZ2luZS1lbmZvcmNlZCAxMjggTWlCIGNlaWxpbmcgYW5kIGlzIHJlamVjdGVkIGJlZm9yZSBoYXNoaW5nIHdoZW4gaXRzCi1maWxlc3lzdGVtIHJlcG9ydHMgYW4gaW52YWxpZCBzaXplLgorcGVyLWltcG9ydCBjZWlsaW5nIHdoZW4gYW4gZXhhY3Qgc2l6ZSBpcyBub3QgYXBwcm9wcmlhdGUuIFRoZSBlbmdpbmUgaGFyZCBsaW1pdAoraXMgMiBHaUIuIEltcG9ydHMgYWJvdmUgMTI4IE1pQiByZWNlaXZlIGFuIGV4cGxpY2l0IGZyZWUtc3BhY2UgY29uZmlybWF0aW9uIGFuZAordXNlIHRoZSBsYXVuY2hlcidzIHN0cmVhbWluZyBsYXJnZS1maWxlIHBhdGggcmF0aGVyIHRoYW4gYmVpbmcgbWF0ZXJpYWxpemVkIGFzCitvbmUgTHVhIHN0cmluZy4KIAogRm9yIGAibjY0ImAsIHRoZSBsYXVuY2hlciByZWNvZ25pemVzIGAuejY0YCwgYC52NjRgLCBhbmQgYC5uNjRgIGJ5dGUgb3JkZXJzLAogc3RyaXBzIGEgcmVjb2duaXplZCA1MTItYnl0ZSBjb3BpZXIgaGVhZGVyLCBjb252ZXJ0cyB0aGUgYnl0ZXMgdG8gY2Fub25pY2FsCiBiaWctZW5kaWFuIGAuejY0YCBvcmRlciwgYW5kIHRoZW4gY2hlY2tzIE1ENS4gVGhlIGNhbm9uaWNhbCBieXRlcyBhcmUgd3JpdHRlbgogdG8gYG1vZHMvPG1vZC1pZD4vYmFzZXJvbXMvPGZpbGU+YC4gRWFjaCBzZWxlY3Rpb24gaXMgYSBwcml2YXRlIGdyYW50IHRvIHRoYXQKIG1vZDogdGhlIGxhdW5jaGVyIG5ldmVyIHNjYW5zIG9yIGNvcGllcyBhbm90aGVyIG1vZCdzIGltcG9ydGVkIGZpbGVzIG1lcmVseQotYmVjYXVzZSBpdHMgbWFuaWZlc3QgbmFtZXMgdGhlIHNhbWUgZGlnZXN0LiBNb2RzIHJlYWQgdGhlIHJlc3VsdCB3aXRoIHRoZWlyIGV4aXN0aW5nIHNjb3BlZCBgbW9kOnJlYWRgIEFQSSwgZm9yCi1leGFtcGxlIGBtb2Q6cmVhZCgiYmFzZXJvbXMvc3RhZGl1bTIuejY0IilgOyBubyBob3N0IHBhdGggb3IgbmV3IGZpbGVzeXN0ZW0KK2JlY2F1c2UgaXRzIG1hbmlmZXN0IG5hbWVzIHRoZSBzYW1lIGRpZ2VzdC4gU21hbGwgc291cmNlcyBjYW4gc3RpbGwgYmUgcmVhZAord2l0aCB0aGUgZXhpc3Rpbmcgc2NvcGVkIGBtb2Q6cmVhZGAgQVBJLCBmb3IgZXhhbXBsZQorYG1vZDpyZWFkKCJiYXNlcm9tcy9zdGFkaXVtMi56NjQiKWAuIEZvciBsYXJnZSBzb3VyY2VzLCBwcmVmZXIgdGhlIGJvdW5kZWQKK2Btb2QuaW1wb3J0c2AgZmFjYWRlIGRlc2NyaWJlZCBiZWxvdzsgbm8gaG9zdCBwYXRoIG9yIG5ldyBnZW5lcmFsIGZpbGVzeXN0ZW0KIHBlcm1pc3Npb24gaXMgZXhwb3NlZC4gTWlzc2luZyBgcmVxdWlyZWRfaW1wb3J0c2AgYmxvY2sgdGhlIG1vZCBiZWZvcmUgaXRzCiBlbnRyeSBjaHVuayBydW5zOyBtaXNzaW5nIGBvcHRpb25hbF9pbXBvcnRzYCByZW1haW4gdmlzaWJsZSBpbiB0aGUgc2FtZQogbGF1bmNoZXIgcGFuZWwgYnV0IGRvIG5vdCBibG9jayBsb2FkaW5nLgogCisjIyMjIEJvdW5kZWQgYWNjZXNzIHRvIHZhbGlkYXRlZCBpbXBvcnRzCisKK0EgbG9hZGVkIG1vZCBjYW4gYWRkcmVzcyBvbmx5IGlkcyBkZWNsYXJlZCBieSBpdHMgb3duIGByZXF1aXJlZF9pbXBvcnRzYCBvcgorYG9wdGlvbmFsX2ltcG9ydHNgIGFycmF5czoKKworYGBgbHVhCitsb2NhbCBpbmZvLCBlcnIgPSBtb2QuaW1wb3J0czppbmZvKCJzdGFkaXVtMiIpCitsb2NhbCBoZWFkZXIsIGVyciA9IG1vZC5pbXBvcnRzOnJlYWQoInN0YWRpdW0yIiwgMCwgNDA5NikKK2BgYAorCitgcmVhZGAgdXNlcyB6ZXJvLWJhc2VkIG9mZnNldHMgYW5kIGlzIGNhcHBlZCBhdCA4IE1pQiBwZXIgY2FsbC4gVGhlIGVuZ2luZQorcmVjaGVja3MgdGhlIHN0b3JlZCBpbXBvcnQgYmVmb3JlIGV4cG9zaW5nIGl0LCBzZWVrcyBpbnRvIHRoZSBlbmdpbmUtb3duZWQKK2NvcHksIGFuZCBuZXZlciBnaXZlcyB0aGUgbW9kIGEgaG9zdCBwYXRoIG9yIGZpbGUgaGFuZGxlLiBUaGlzIGlzIGludGVuZGVkIGZvcgorbGFyZ2Ugc291cmNlIGZvcm1hdHMgd2hvc2UgdGFibGUvaW5kZXggY2FuIGJlIHBhcnNlZCB3aXRoIHNtYWxsIHJlYWRzIGJlZm9yZQorc2VsZWN0aXZlbHkgcmVhZGluZyB0aGUgcGF5bG9hZHMgYSB0cmFuc2Zvcm0gYWN0dWFsbHkgbmVlZHMuCisKIE1ENSBoZXJlIGlkZW50aWZpZXMgYSBrbm93biBkdW1wIGJlY2F1c2UgUk9NIGRhdGFiYXNlcyBjb21tb25seSBwdWJsaXNoIGl0OwogaXQgaXMgbm90IGEgc2VjdXJpdHkgb3IgYXV0aGVudGljaXR5IGd1YXJhbnRlZS4gRG8gbm90IHBhc3RlIHRoZSBTSEEtMSB1c2VkIGJ5CiBHZW4xUmVjb21wJ3Mgb3duIGdhbWUtUk9NIGltcG9ydGVyIGludG8gYW4gaW1wb3J0J3MgYG1kNWAgZmllbGQuIE1vZCBhcmNoaXZlcwpAQCAtNDQxLDYgKzQ2MCwyOCBAQCBkZWZhdWx0KiogKDF4IGZyb250LCAyeCBiYWNrKS4KICAgYmFsbC10by1waWMgZ3JvdyBtdWx0aXBsaWVzIHlvdXIgc2NhbGUgdGhyb3VnaCBlYWNoIHN0YWdlLCBzbyBhIHJlc2NhbGVkCiAgIG1vbiBzdGlsbCBncm93cyBpbnRvIHBsYWNlIGZyb20gdGhlIGJhbGwsIGdyb3VuZGVkIHRoZSB3aG9sZSB3YXkuCiAKKyMjIEluc3RhbGxhdGlvbi1zY29wZWQgZ2VuZXJhdGVkIGNhY2hlCisKK0dlbmVyYXRlZCBkYXRhIGRlcml2ZWQgZnJvbSBhIHZhbGlkYXRlZCB1c2VyIHNvdXJjZSBvZnRlbiBiZWxvbmdzIHRvIHRoZSBtb2QKK2luc3RhbGxhdGlvbiByYXRoZXIgdGhhbiB0byBvbmUgUG9rw6ltb24gc2F2ZS4gYG1vZC5jYWNoZWAgaXMgdGhhdCBuYW1lc3BhY2U6CisKK2BgYGx1YQorbG9jYWwgb2ssIGVyciA9IG1vZC5jYWNoZTp3cml0ZSgiZXh0cmFjdC92MS9hcmVuYS5iaW4iLCBlbmNvZGVkQXJlbmEpCitsb2NhbCBieXRlcywgZXJyID0gbW9kLmNhY2hlOnJlYWQoImV4dHJhY3QvdjEvYXJlbmEuYmluIikKK2xvY2FsIGluZm8gPSBtb2QuY2FjaGU6aW5mbygiZXh0cmFjdC92MS9hcmVuYS5iaW4iKQorbW9kLmNhY2hlOmRlbGV0ZSgiZXh0cmFjdC92MS9hcmVuYS5iaW4iKQorYGBgCisKK1RoZSBwaHlzaWNhbCByb290IGlzIGVuZ2luZS1vd25lZCAoYG1vZF9jYWNoZS88bW9kLWlkPi9gKSBhbmQgbmV2ZXIgZXhwb3NlZCB0bwordGhlIG1vZC4gS2V5cyBhcmUgc2FmZSByZWxhdGl2ZSBwYXRocyBhbmQgYSBzaW5nbGUgd3JpdGUgaXMgY2FwcGVkIGF0IDY0IE1pQi4KK1RoZSBjYWNoZSBkb2VzIG5vdCByZXdpbmQgd2l0aCBjaGVja3BvaW50cyBhbmQgaXMgbm90IHNjb3BlZCB0byBnYW1lIHZlcnNpb24sCitzbG90LCBvciBwbGF5dGhyb3VnaC4gVGhlIG1vZCBvd25zIGl0cyBnZW5lcmF0ZWQgZm9ybWF0LCBmaW5nZXJwcmludHMsIHJlYnVpbGQKK3BvbGljeSwgYW5kIGNvbXBsZXRpb24gbWFya2VyOyB0aGUgZW5naW5lIHRyZWF0cyB0aGUgYnl0ZXMgYXMgb3BhcXVlIGRhdGEuCisKK1VzZSBgbW9kLnN0b3JhZ2VgIGluc3RlYWQgd2hlbiB0aGUgZGF0YSBiZWxvbmdzIHRvIG9uZSBwbGF5dGhyb3VnaC4gVXNlCitgbW9kLmNhY2hlYCB3aGVuIGl0IGlzIGEgcmVwcm9kdWNpYmxlIGluc3RhbGxhdGlvbiBhcnRpZmFjdCB0aGF0IGNhbiBiZSByZWJ1aWx0Citmcm9tIGEgZGVjbGFyZWQgdXNlciBzb3VyY2UuCisKICMjIER1cmFibGUgdG9vbCBzdG9yYWdlIGFuZCBydW50aW1lIGNoZWNrcG9pbnRzCiAKIGBtb2Quc2F2ZWAgcmVtYWlucyB0aGUgcmlnaHQgcGxhY2UgZm9yIHN0YXRlIHRoYXQgc2hvdWxkIHRyYXZlbCB3aXRoIHRoZSBuZXh0CmRpZmYgLS1naXQgYS9zcmMvaW1wb3J0L0NhY2hlRnMubHVhIGIvc3JjL2ltcG9ydC9DYWNoZUZzLmx1YQppbmRleCBhM2JmZDg4Li4wMGVmOGU5IDEwMDY0NAotLS0gYS9zcmMvaW1wb3J0L0NhY2hlRnMubHVhCisrKyBiL3NyYy9pbXBvcnQvQ2FjaGVGcy5sdWEKQEAgLTI4Nyw2ICsyODcsNDMgQEAgZnVuY3Rpb24gQ2FjaGVGcy53cml0ZShyZWwsIGRhdGEpCiAgIHJldHVybiBsb3ZlLmZpbGVzeXN0ZW0ud3JpdGUocmVsLCBkYXRhKQogZW5kCiAKKy0tIE9wZW4gYSBjYWNoZS1yZWxhdGl2ZSBmaWxlIGZvciBzdHJlYW1pbmcgcmVwbGFjZW1lbnQuIFRoZSByZXR1cm5lZCBoYW5kbGUKKy0tIGhhcyB3cml0ZShieXRlcykgYW5kIGNsb3NlKCkgbWV0aG9kcyBhbmQgZm9sbG93cyB0aGUgc2FtZSBwb3J0YWJsZS9zYXZlLWRpcgorLS0gcm91dGluZyBhcyBDYWNoZUZzLndyaXRlIHdpdGhvdXQgZm9yY2luZyB0aGUgY2FsbGVyIHRvIGhvbGQgdGhlIHdob2xlIGZpbGUKKy0tIGluIG9uZSBMdWEgc3RyaW5nLgorZnVuY3Rpb24gQ2FjaGVGcy5vcGVuV3JpdGUocmVsKQorICByZWwgPSB3aXRoUHJlZml4KHJlbCkKKyAgbG9jYWwgcm9vdCA9IENhY2hlRnMucm9vdCgpCisgIGlmIHJvb3QgdGhlbgorICAgIGVuc3VyZVBhcmVudHMocm9vdCwgcmVsKQorICAgIGxvY2FsIGYsIGVyciA9IGlvLm9wZW4ocmVhbFBhdGgocm9vdCwgcmVsKSwgIndiIikKKyAgICBpZiBub3QgZiB0aGVuIHJldHVybiBuaWwsIGVyciBlbmQKKyAgICByZXR1cm4geworICAgICAgd3JpdGUgPSBmdW5jdGlvbihfLCBkYXRhKQorICAgICAgICBsb2NhbCBvaywgd3JpdGVFcnIgPSBmOndyaXRlKGRhdGEpCisgICAgICAgIGlmIG5vdCBvayB0aGVuIHJldHVybiBuaWwsIHdyaXRlRXJyIGVuZAorICAgICAgICByZXR1cm4gdHJ1ZQorICAgICAgZW5kLAorICAgICAgY2xvc2UgPSBmdW5jdGlvbigpIGY6Y2xvc2UoKSBlbmQsCisgICAgfQorICBlbmQKKyAgaWYgbm90IChsb3ZlIGFuZCBsb3ZlLmZpbGVzeXN0ZW0gYW5kIGxvdmUuZmlsZXN5c3RlbS5uZXdGaWxlKSB0aGVuCisgICAgcmV0dXJuIG5pbCwgInN0cmVhbWluZyBjYWNoZSB3cml0ZXMgYXJlIHVuYXZhaWxhYmxlIgorICBlbmQKKyAgbG9jYWwgcGFyZW50ID0gcmVsOm1hdGNoKCJeKC4qKS9bXi9dKyQiKQorICBpZiBwYXJlbnQgYW5kIG5vdCBsb3ZlLmZpbGVzeXN0ZW0uY3JlYXRlRGlyZWN0b3J5KHBhcmVudCkgdGhlbgorICAgIGxvY2FsIGluZm8gPSBsb3ZlLmZpbGVzeXN0ZW0uZ2V0SW5mbyhwYXJlbnQpCisgICAgbG9jYWwgcmVhc29uID0gaW5mbyBhbmQgKCJhICIgLi4gaW5mby50eXBlIC4uICIgYWxyZWFkeSBleGlzdHMgdGhlcmUiKQorICAgICAgb3IgInVua25vd24gcmVhc29uIgorICAgIHJldHVybiBuaWwsICJjb3VsZCBub3QgY3JlYXRlICIgLi4gcGFyZW50IC4uICI6ICIgLi4gcmVhc29uCisgIGVuZAorICBsb2NhbCBmaWxlLCBtYWtlRXJyID0gbG92ZS5maWxlc3lzdGVtLm5ld0ZpbGUocmVsKQorICBpZiBub3QgZmlsZSB0aGVuIHJldHVybiBuaWwsIG1ha2VFcnIgb3IgImNvdWxkIG5vdCBjcmVhdGUgY2FjaGUgZmlsZSIgZW5kCisgIGxvY2FsIG9rLCBvcGVuRXJyID0gZmlsZTpvcGVuKCJ3IikKKyAgaWYgbm90IG9rIHRoZW4gcmV0dXJuIG5pbCwgb3BlbkVyciBvciAiY291bGQgbm90IG9wZW4gY2FjaGUgZmlsZSIgZW5kCisgIHJldHVybiBmaWxlCitlbmQKKwogLS0gcmVhZCBjYWNoZS1yZWxhdGl2ZSBgcmVsYDsgcmV0dXJucyB0aGUgYnl0ZXMgb3IgbmlsCiBmdW5jdGlvbiBDYWNoZUZzLnJlYWQocmVsKQogICByZWwgPSB3aXRoUHJlZml4KHJlbCkKZGlmZiAtLWdpdCBhL3NyYy9pbXBvcnQvUm9tSW1wb3J0ZXIubHVhIGIvc3JjL2ltcG9ydC9Sb21JbXBvcnRlci5sdWEKaW5kZXggOWNlMTc2Zi4uNDQ2YmI5MCAxMDA2NDQKLS0tIGEvc3JjL2ltcG9ydC9Sb21JbXBvcnRlci5sdWEKKysrIGIvc3JjL2ltcG9ydC9Sb21JbXBvcnRlci5sdWEKQEAgLTM4Miw2ICszODIsMTAyIEBAIGxvY2FsIGZ1bmN0aW9uIGV4dGVybmFsRmlsZVNpemUocGF0aCkKICAgcmV0dXJuIHNpemUKIGVuZAogCitsb2NhbCBmdW5jdGlvbiBvcGVuSW1wb3J0U291cmNlKHBhdGgpCisgIC0tIERlc2t0b3AgcGlja2VyIHBhdGhzIGxpdmUgb3V0c2lkZSBMw5ZWRSdzIHZpcnR1YWwgZmlsZXN5c3RlbS4gUHJlZmVyIHRoZQorICAtLSBuYXRpdmUgZmlsZSBoYW5kbGUgc28gYSAxLjQ2IEdpQiBkaXNjIGlzIG5ldmVyIGNvcGllZCB0byBhIHRlbXAgZmlsZSBvcgorICAtLSByZWFkIGludG8gb25lIEx1YSBzdHJpbmcgYmVmb3JlIHZhbGlkYXRpb24uCisgIGxvY2FsIG5hdGl2ZSA9IGlvLm9wZW4ocGF0aCwgInJiIikKKyAgaWYgbmF0aXZlIHRoZW4KKyAgICBsb2NhbCBzaXplID0gbmF0aXZlOnNlZWsoImVuZCIpCisgICAgaWYgc2l6ZSB0aGVuIG5hdGl2ZTpzZWVrKCJzZXQiLCAwKSBlbmQKKyAgICByZXR1cm4geworICAgICAgc2l6ZSA9IHNpemUsCisgICAgICByZWFkID0gZnVuY3Rpb24oXywgbikgcmV0dXJuIG5hdGl2ZTpyZWFkKG4pIGVuZCwKKyAgICAgIGNsb3NlID0gZnVuY3Rpb24oKSBuYXRpdmU6Y2xvc2UoKSBlbmQsCisgICAgfQorICBlbmQKKyAgaWYgbG92ZSBhbmQgbG92ZS5maWxlc3lzdGVtIGFuZCBsb3ZlLmZpbGVzeXN0ZW0ubmV3RmlsZSB0aGVuCisgICAgbG9jYWwgZmlsZSwgbWFrZUVyciA9IGxvdmUuZmlsZXN5c3RlbS5uZXdGaWxlKHBhdGgpCisgICAgaWYgbm90IGZpbGUgdGhlbiByZXR1cm4gbmlsLCBtYWtlRXJyIG9yICJjb3VsZCBub3Qgb3BlbiBzb3VyY2UgZmlsZSIgZW5kCisgICAgbG9jYWwgb2ssIG9wZW5FcnIgPSBmaWxlOm9wZW4oInIiKQorICAgIGlmIG5vdCBvayB0aGVuIHJldHVybiBuaWwsIG9wZW5FcnIgb3IgImNvdWxkIG5vdCBvcGVuIHNvdXJjZSBmaWxlIiBlbmQKKyAgICBsb2NhbCBzaXplID0gZmlsZS5nZXRTaXplIGFuZCBmaWxlOmdldFNpemUoKSBvciBuaWwKKyAgICByZXR1cm4geworICAgICAgc2l6ZSA9IHNpemUsCisgICAgICByZWFkID0gZnVuY3Rpb24oXywgbikgcmV0dXJuIGZpbGU6cmVhZChuKSBlbmQsCisgICAgICBjbG9zZSA9IGZ1bmN0aW9uKCkgZmlsZTpjbG9zZSgpIGVuZCwKKyAgICB9CisgIGVuZAorICByZXR1cm4gbmlsLCAic3RyZWFtaW5nIHNvdXJjZSBhY2Nlc3MgaXMgdW5hdmFpbGFibGUiCitlbmQKKworbG9jYWwgZnVuY3Rpb24gc3RyZWFtUmVxdWlyZWRJbXBvcnQobWFuaWZlc3QsIGltcG9ydElkLCBzb3VyY2UpCisgIGxvY2FsIFJlcXVpcmVkSW1wb3J0cyA9IHJlcXVpcmUoInNyYy5tb2RzLlJlcXVpcmVkSW1wb3J0cyIpCisgIGxvY2FsIHNwZWMgPSBSZXF1aXJlZEltcG9ydHMuc3BlYyhtYW5pZmVzdCwgaW1wb3J0SWQpCisgIGlmIG5vdCBzcGVjIHRoZW4gcmV0dXJuIG5pbCwgIkltcG9ydCBkZWNsYXJhdGlvbiB3YXMgbm90IGZvdW5kLiIgZW5kCisgIGlmIHNwZWMuZm9ybWF0ID09ICJuNjQiIHRoZW4KKyAgICByZXR1cm4gbmlsLCAic3RyZWFtaW5nIGNhbm9uaWNhbGl6YXRpb24gaXMgdW5hdmFpbGFibGUgZm9yIE42NCBpbXBvcnRzIgorICBlbmQKKyAgbG9jYWwgaW5wdXQsIG9wZW5FcnIgPSBvcGVuSW1wb3J0U291cmNlKHNvdXJjZSkKKyAgaWYgbm90IGlucHV0IHRoZW4gcmV0dXJuIG5pbCwgb3BlbkVyciBlbmQKKyAgbG9jYWwgc2l6ZUVyciA9IFJlcXVpcmVkSW1wb3J0cy5zaXplRXJyb3Ioc3BlYywgaW5wdXQuc2l6ZSwgZmFsc2UpCisgIGlmIHNpemVFcnIgdGhlbiBpbnB1dDpjbG9zZSgpOyByZXR1cm4gbmlsLCBzaXplRXJyIGVuZAorCisgIGxvY2FsIENhY2hlRnMgPSByZXF1aXJlKCJzcmMuaW1wb3J0LkNhY2hlRnMiKQorICBsb2NhbCBkZXN0aW5hdGlvbiA9IFJlcXVpcmVkSW1wb3J0cy5wYXRoKG1hbmlmZXN0LCBzcGVjKQorICBsb2NhbCBzYXZlZFByZWZpeCA9IENhY2hlRnMucHJlZml4CisgIENhY2hlRnMucHJlZml4ID0gIiIKKyAgQ2FjaGVGcy5yZW1vdmUoUmVxdWlyZWRJbXBvcnRzLnJlY2VpcHRQYXRoKG1hbmlmZXN0LCBzcGVjKSkKKyAgQ2FjaGVGcy5yZW1vdmUoZGVzdGluYXRpb24pCisgIGxvY2FsIG91dHB1dCwgbWFrZUVyciA9IENhY2hlRnMub3BlbldyaXRlKGRlc3RpbmF0aW9uKQorICBpZiBub3Qgb3V0cHV0IHRoZW4KKyAgICBDYWNoZUZzLnByZWZpeCA9IHNhdmVkUHJlZml4CisgICAgaW5wdXQ6Y2xvc2UoKQorICAgIHJldHVybiBuaWwsIG1ha2VFcnIgb3IgImNvdWxkIG5vdCBjcmVhdGUgaW1wb3J0ZWQgZmlsZSIKKyAgZW5kCisKKyAgbG9jYWwgZnVuY3Rpb24gY2xlYW51cERlc3RpbmF0aW9uKCkKKyAgICBDYWNoZUZzLnJlbW92ZShkZXN0aW5hdGlvbikKKyAgICBDYWNoZUZzLnByZWZpeCA9IHNhdmVkUHJlZml4CisgIGVuZAorCisgIGxvY2FsIE1ENSA9IHJlcXVpcmUoInNyYy5tb2RzLlN0cmVhbU1ENSIpCisgIGxvY2FsIG1kNSA9IE1ENS5uZXcoKQorICBsb2NhbCB0b3RhbCwgY2h1bmtCeXRlcyA9IDAsIDQgKiAxMDI0ICogMTAyNAorICB3aGlsZSB0cnVlIGRvCisgICAgbG9jYWwgY2h1bmsgPSBpbnB1dDpyZWFkKGNodW5rQnl0ZXMpCisgICAgaWYgbm90IGNodW5rIG9yICNjaHVuayA9PSAwIHRoZW4gYnJlYWsgZW5kCisgICAgbWQ1OnVwZGF0ZShjaHVuaykKKyAgICBsb2NhbCB3cm90ZSwgd3JpdGVFcnIgPSBvdXRwdXQ6d3JpdGUoY2h1bmspCisgICAgaWYgd3JvdGUgPT0gZmFsc2Ugb3Igd3JvdGUgPT0gbmlsIHRoZW4KKyAgICAgIGlucHV0OmNsb3NlKCk7IG91dHB1dDpjbG9zZSgpOyBjbGVhbnVwRGVzdGluYXRpb24oKQorICAgICAgcmV0dXJuIG5pbCwgImNvdWxkIG5vdCBjb3B5IGltcG9ydDogIiAuLiB0 \ No newline at end of file diff --git a/.github/pr-build/large-import.patch.b64.2 b/.github/pr-build/large-import.patch.b64.2 deleted file mode 100644 index e7aac75f..00000000 --- a/.github/pr-build/large-import.patch.b64.2 +++ /dev/null @@ -1 +0,0 @@ -b3N0cmluZyh3cml0ZUVyciBvciAid3JpdGUgZmFpbGVkIikKKyAgICBlbmQKKyAgICB0b3RhbCA9IHRvdGFsICsgI2NodW5rCisgICAgaWYgI2NodW5rIDwgY2h1bmtCeXRlcyB0aGVuIGJyZWFrIGVuZAorICBlbmQKKyAgaW5wdXQ6Y2xvc2UoKQorICBvdXRwdXQ6Y2xvc2UoKQorCisgIGlmIGlucHV0LnNpemUgYW5kIHRvdGFsIH49IGlucHV0LnNpemUgdGhlbgorICAgIGNsZWFudXBEZXN0aW5hdGlvbigpCisgICAgcmV0dXJuIG5pbCwgKCJzb3VyY2UgcmVhZCBlbmRlZCBlYXJseSAoZXhwZWN0ZWQgJWQgYnl0ZXMsIGNvcGllZCAlZCkiKTpmb3JtYXQoaW5wdXQuc2l6ZSwgdG90YWwpCisgIGVuZAorICBsb2NhbCBzdG9yZWRTaXplRXJyID0gUmVxdWlyZWRJbXBvcnRzLnNpemVFcnJvcihzcGVjLCB0b3RhbCwgdHJ1ZSkKKyAgaWYgc3RvcmVkU2l6ZUVyciB0aGVuIGNsZWFudXBEZXN0aW5hdGlvbigpOyByZXR1cm4gbmlsLCBzdG9yZWRTaXplRXJyIGVuZAorICBsb2NhbCBkaWdlc3QgPSBtZDU6ZmluYWwoKQorICBDYWNoZUZzLnByZWZpeCA9IHNhdmVkUHJlZml4CisgIGxvY2FsIGFjY2VwdGVkLCBkZXRhaWwgPSBSZXF1aXJlZEltcG9ydHMuYWNjZXB0U3RvcmVkRGlnZXN0KAorICAgIG1hbmlmZXN0LCBpbXBvcnRJZCwgZGlnZXN0LCBsb3ZlLmZpbGVzeXN0ZW0pCisgIGlmIG5vdCBhY2NlcHRlZCB0aGVuCisgICAgQ2FjaGVGcy5wcmVmaXggPSAiIgorICAgIENhY2hlRnMucmVtb3ZlKGRlc3RpbmF0aW9uKQorICAgIENhY2hlRnMucHJlZml4ID0gc2F2ZWRQcmVmaXgKKyAgICByZXR1cm4gbmlsLCBkZXRhaWwKKyAgZW5kCisgIHJldHVybiB0cnVlLCBkZXRhaWwKK2VuZAorCiBsb2NhbCBmdW5jdGlvbiByZWFkRHJvcHBlZEZpbGUoZmlsZSkKICAgbG9jYWwgb2ssIG9wZW5FcnJvciA9IGZpbGU6b3BlbigiciIpCiAgIGlmIG5vdCBvayB0aGVuIHJldHVybiBuaWwsIG9wZW5FcnJvciBlbmQKQEAgLTEyMjgsMTEgKzEzMjQsMTMgQEAgbG9jYWwgZnVuY3Rpb24gY2hvb3NlUmVxdWlyZWRGaWxlKCkKICAgICAgICIkZD1OZXctT2JqZWN0IFN5c3RlbS5XaW5kb3dzLkZvcm1zLk9wZW5GaWxlRGlhbG9nOyIsCiAgICAgICAiJGQuVGl0bGU9JyIgLi4gcHJvbXB0IC4uICInOyIsCiAgICAgICAiJGQuRmlsdGVyPSdBbGwgZmlsZXMgKCouKil8Ki4qJzsiLAorICAgICAgLS0gUmVxdWlyZWQgaW1wb3J0cyBjYW4gYmUgbXVsdGktZ2lnYWJ5dGUgb3B0aWNhbC1kaXNjIGltYWdlcy4gRG8gTk9UCisgICAgICAtLSBzdGFnZSB0aGVtIHRocm91Z2ggJVRFTVAlOiB0aGF0IGRvdWJsZXMgZnJlZS1zcGFjZSByZXF1aXJlbWVudHMgYW5kIGEKKyAgICAgIC0tIGZhaWxlZCBDb3B5LUl0ZW0gY2FuIGxlYXZlIGEgcGxhdXNpYmxlLWxvb2tpbmcgdHJ1bmNhdGVkIHRlbXAgZmlsZS4KKyAgICAgIC0tIFN0cmVhbSB0aGUgc2VsZWN0ZWQgc291cmNlIGRpcmVjdGx5IGludG8gdGhlIG1vZC1vd25lZCBkZXN0aW5hdGlvbi4KICAgICAgICJpZigkZC5TaG93RGlhbG9nKCkgLWVxICdPSycpeyIsCi0gICAgICAiJHQ9Sm9pbi1QYXRoICRlbnY6VEVNUCAncG9rZXBvcnRfcmVxdWlyZWRfaW1wb3J0LmJpbic7IiwKLSAgICAgICJDb3B5LUl0ZW0gLUxpdGVyYWxQYXRoICRkLkZpbGVOYW1lIC1EZXN0aW5hdGlvbiAkdCAtRm9yY2U7IiwKICAgICAgICJbQ29uc29sZV06Ok91dHB1dEVuY29kaW5nPVtUZXh0LkVuY29kaW5nXTo6VVRGODsiLAotICAgICAgIltDb25zb2xlXTo6V3JpdGUoJHQpfSIsCisgICAgICAiW0NvbnNvbGVdOjpXcml0ZSgkZC5GaWxlTmFtZSl9IiwKICAgICB9KQogICAgIHJldHVybiBjb21tYW5kT3V0cHV0KAogICAgICAgJ3Bvd2Vyc2hlbGwgLU5vUHJvZmlsZSAtU1RBIC1Db21tYW5kICInIC4uIHNjcmlwdCAuLiAnIicpCkBAIC0yMDY5LDggKzIxNzAsMTMgQEAgZnVuY3Rpb24gUm9tSW1wb3J0ZXI6X2ltcG9ydFJlcXVpcmVkU291cmNlKG1vZElkLCBpbXBvcnRJZCwgc291cmNlLCBjb25maXJtZWQpCiAgICAgcmV0dXJuIG5pbAogICBlbmQKICAgbG9jYWwgUmVxdWlyZWRJbXBvcnRzID0gcmVxdWlyZSgic3JjLm1vZHMuUmVxdWlyZWRJbXBvcnRzIikKLSAgbG9jYWwgaW5mbyA9IGxvdmUuZmlsZXN5c3RlbS5nZXRJbmZvKHNvdXJjZSwgImZpbGUiKQotICBsb2NhbCBzaXplID0gaW5mbyBhbmQgaW5mby5zaXplIG9yIGV4dGVybmFsRmlsZVNpemUoc291cmNlKQorICAtLSBBIGRlc2t0b3AgcGlja2VyIHJldHVybnMgYSBob3N0IHBhdGguIEFzayB0aGUgaG9zdCBmaWxlIGhhbmRsZSBmaXJzdDsKKyAgLS0gbG92ZS5maWxlc3lzdGVtLmdldEluZm8gaXMgb25seSBhdXRob3JpdGF0aXZlIGZvciB2aXJ0dWFsL3NhdmUgcGF0aHMuCisgIGxvY2FsIHNpemUgPSBleHRlcm5hbEZpbGVTaXplKHNvdXJjZSkKKyAgaWYgbm90IHNpemUgdGhlbgorICAgIGxvY2FsIGluZm8gPSBsb3ZlLmZpbGVzeXN0ZW0uZ2V0SW5mbyhzb3VyY2UsICJmaWxlIikKKyAgICBzaXplID0gaW5mbyBhbmQgaW5mby5zaXplIG9yIG5pbAorICBlbmQKICAgbG9jYWwgc2l6ZUVyciA9IFJlcXVpcmVkSW1wb3J0cy5zaXplRXJyb3Ioc3BlYywgc2l6ZSwgZmFsc2UpCiAgIGlmIHNpemVFcnIgdGhlbgogICAgIHJlcXVpcmVkSW1wb3J0Tm90aWNlKHNlbGYsIG1vZElkLCBpbXBvcnRJZCwgc2l6ZUVycikKQEAgLTIwOTMsNiArMjE5OSwyMCBAQCBmdW5jdGlvbiBSb21JbXBvcnRlcjpfaW1wb3J0UmVxdWlyZWRTb3VyY2UobW9kSWQsIGltcG9ydElkLCBzb3VyY2UsIGNvbmZpcm1lZCkKICAgICB9CiAgICAgcmV0dXJuIG5pbAogICBlbmQKKyAgaWYgdHlwZShzaXplKSA9PSAibnVtYmVyIiBhbmQgc2l6ZSA+IFJlcXVpcmVkSW1wb3J0cy5MQVJHRV9XQVJOX0JZVEVTCisgICAgICBhbmQgc3BlYy5mb3JtYXQgfj0gIm42NCIgdGhlbgorICAgIGxvY2FsIG9rLCByZXN1bHQgPSBzdHJlYW1SZXF1aXJlZEltcG9ydChtYW5pZmVzdCwgaW1wb3J0SWQsIHNvdXJjZSkKKyAgICBpZiBvayB0aGVuCisgICAgICBzZWxmLnJlcXVpcmVkSW1wb3J0Tm90aWNlID0gbmlsCisgICAgICBzZWxmLm1vZE5vdGljZSA9IHsgb2sgPSB0cnVlLCB0ZXh0ID0gIkltcG9ydGVkICIgLi4gdG9zdHJpbmcoaW1wb3J0SWQpCisgICAgICAgIC4uICIgZm9yICIgLi4gdG9zdHJpbmcobWFuaWZlc3QubmFtZSBvciBtYW5pZmVzdC5pZCkgLi4gIi4iIH0KKyAgICAgIHNlbGY6X3JlZnJlc2hNb2RzKCkKKyAgICAgIHJldHVybiB0cnVlCisgICAgZW5kCisgICAgcmVxdWlyZWRJbXBvcnROb3RpY2Uoc2VsZiwgbW9kSWQsIGltcG9ydElkLCByZXN1bHQpCisgICAgc2VsZi5tb2ROb3RpY2UgPSBuaWwKKyAgICByZXR1cm4gbmlsCisgIGVuZAogICBsb2NhbCBkYXRhID0gbG92ZS5maWxlc3lzdGVtLnJlYWQoc291cmNlKQogICBpZiBub3QgZGF0YSB0aGVuIGRhdGEgPSByZWFkRXh0ZXJuYWxQYXRoKHNvdXJjZSkgZW5kCiAgIGlmIG5vdCBkYXRhIHRoZW4KZGlmZiAtLWdpdCBhL3NyYy9tb2RzL0xvYWRlci5sdWEgYi9zcmMvbW9kcy9Mb2FkZXIubHVhCmluZGV4IDhlYzQ3NjcuLmRkZmMzZjEgMTAwNjQ0Ci0tLSBhL3NyYy9tb2RzL0xvYWRlci5sdWEKKysrIGIvc3JjL21vZHMvTG9hZGVyLmx1YQpAQCAtOTYyLDYgKzk2Miw4IEBAIGZ1bmN0aW9uIExvYWRlcjpfYXBpKG1vZCkKICAgbG9jYWwgU3RvcmFnZSA9IGVuZ2luZVJlcXVpcmUoInNyYy5tb2RzLlN0b3JhZ2UiKQogICBsb2NhbCBzdG9yYWdlID0gU3RvcmFnZSBhbmQgU3RvcmFnZS5uZXcobW9kSWQsIGxvYWRlci5mcykKICAgbG9jYWwgQ2hlY2twb2ludCA9IGVuZ2luZVJlcXVpcmUoInNyYy5jb3JlLkNoZWNrcG9pbnQiKQorICBsb2NhbCBJbXBvcnRBY2Nlc3MgPSBlbmdpbmVSZXF1aXJlKCJzcmMubW9kcy5JbXBvcnRBY2Nlc3MiKQorICBsb2NhbCBpbXBvcnRBcGksIGluc3RhbGxDYWNoZSA9IEltcG9ydEFjY2Vzcy5uZXcobW9kLm1hbmlmZXN0LCBsb2FkZXIuZnMpCiAgIGxvY2FsIGFwaSA9IHsKICAgICBpZCA9IG1vZElkLAogICAgIHZlcnNpb24gPSBtb2QubWFuaWZlc3QudmVyc2lvbiwKQEAgLTExNjEsNiArMTE2MywxMiBAQCBmdW5jdGlvbiBMb2FkZXI6X2FwaShtb2QpCiAgICAgLS0gY2hlY2twb2ludC4gVGhlCiAgICAgLS0gZW5naW5lIGJpbmRzIHZlcnNpb24vcGxheXRocm91Z2gvbW9kIHNjb3BlIGFuZCBwb3J0YWJsZSBwZXJzaXN0ZW5jZTsKICAgICAtLSBjYWxsZXJzIG5ldmVyIHJlY2VpdmUgcGF0aHMgb3IgYSByYXcgZmlsZXN5c3RlbSBoYW5kbGUuCisgICAgLS0gUmVhZC1vbmx5IGJvdW5kZWQgYWNjZXNzIHRvIHRoaXMgbW9kJ3MgbWFuaWZlc3QtZGVjbGFyZWQsIGxhdW5jaGVyLXZhbGlkYXRlZAorICAgIC0tIGltcG9ydHMuIE5vIGhvc3QgcGF0aCBpcyBleHBvc2VkOyBsYXJnZSBzb3VyY2VzIGFyZSByZWFkIGluIGJvdW5kZWQgcmFuZ2VzLgorICAgIGltcG9ydHMgPSBpbXBvcnRBcGksCisgICAgLS0gSW5zdGFsbGF0aW9uLXNjb3BlZCBnZW5lcmF0ZWQgZGF0YSwgaW5kZXBlbmRlbnQgZnJvbSBQb2vDqW1vbiBzYXZlIHNsb3RzLgorICAgIC0tIFRoaXMgaXMgd2hlcmUgUk9NLWRlcml2ZWQgY2FjaGVzIGJlbG9uZzsgbW9kLnN0b3JhZ2UgcmVtYWlucyBwbGF5dGhyb3VnaC1zY29wZWQuCisgICAgY2FjaGUgPSBpbnN0YWxsQ2FjaGUsCiAgICAgc3RvcmFnZSA9IHsKICAgICAgIGNvbnRleHQgPSBmdW5jdGlvbihfLCBnYW1lKSByZXR1cm4gc3RvcmFnZTpjb250ZXh0KGdhbWUpIGVuZCwKICAgICAgIHNlbGVjdGVkID0gZnVuY3Rpb24oXywgZ2FtZSkgcmV0dXJuIHN0b3JhZ2U6c2VsZWN0ZWQoZ2FtZSkgZW5kLApkaWZmIC0tZ2l0IGEvc3JjL21vZHMvUmVxdWlyZWRJbXBvcnRzLmx1YSBiL3NyYy9tb2RzL1JlcXVpcmVkSW1wb3J0cy5sdWEKaW5kZXggODA2ZTkxNi4uMjVlOGFhOCAxMDA2NDQKLS0tIGEvc3JjL21vZHMvUmVxdWlyZWRJbXBvcnRzLmx1YQorKysgYi9zcmMvbW9kcy9SZXF1aXJlZEltcG9ydHMubHVhCkBAIC0xMjAsNiArMTIwLDM2IEBAIGxvY2FsIGZ1bmN0aW9uIGFjY2VwdHMoc3BlYywgZGlnZXN0KQogICByZXR1cm4gZmFsc2UKIGVuZAogCitsb2NhbCBmdW5jdGlvbiBzcGVjQnlJZChtYW5pZmVzdCwgaW1wb3J0SWQpCisgIGZvciBfLCBjYW5kaWRhdGUgaW4gaXBhaXJzKGFsbFNwZWNzKG1hbmlmZXN0KSkgZG8KKyAgICBpZiBjYW5kaWRhdGUuaWQgPT0gaW1wb3J0SWQgdGhlbiByZXR1cm4gY2FuZGlkYXRlIGVuZAorICBlbmQKKyAgcmV0dXJuIG5pbAorZW5kCisKK1JlcXVpcmVkSW1wb3J0cy5zcGVjID0gc3BlY0J5SWQKKworbG9jYWwgZnVuY3Rpb24gc3RyZWFtRGlnZXN0KGZzLCBwYXRoLCBjaHVua0J5dGVzKQorICBpZiBub3QgKGZzIGFuZCBmcy5uZXdGaWxlKSB0aGVuIHJldHVybiBuaWwsICJzdHJlYW1pbmcgZmlsZSBhY2Nlc3MgaXMgdW5hdmFpbGFibGUiIGVuZAorICBsb2NhbCBmaWxlLCBtYWtlRXJyID0gZnMubmV3RmlsZShwYXRoKQorICBpZiBub3QgZmlsZSB0aGVuIHJldHVybiBuaWwsIG1ha2VFcnIgb3IgImNvdWxkIG5vdCBvcGVuIHN0b3JlZCBpbXBvcnQiIGVuZAorICBsb2NhbCBvaywgb3BlbkVyciA9IGZpbGU6b3BlbigiciIpCisgIGlmIG5vdCBvayB0aGVuIHJldHVybiBuaWwsIG9wZW5FcnIgb3IgImNvdWxkIG5vdCBvcGVuIHN0b3JlZCBpbXBvcnQiIGVuZAorICBsb2NhbCBNRDUgPSByZXF1aXJlKCJzcmMubW9kcy5TdHJlYW1NRDUiKQorICBsb2NhbCBjdHggPSBNRDUubmV3KCkKKyAgY2h1bmtCeXRlcyA9IGNodW5rQnl0ZXMgb3IgKDQgKiAxMDI0ICogMTAyNCkKKyAgd2hpbGUgdHJ1ZSBkbworICAgIGxvY2FsIGRhdGEsIHJlYWRFcnIgPSBmaWxlOnJlYWQoY2h1bmtCeXRlcykKKyAgICBpZiBkYXRhIGFuZCAjZGF0YSA+IDAgdGhlbiBjdHg6dXBkYXRlKGRhdGEpIGVuZAorICAgIGlmIG5vdCBkYXRhIG9yICNkYXRhIDwgY2h1bmtCeXRlcyB0aGVuCisgICAgICBpZiByZWFkRXJyIHRoZW4gZmlsZTpjbG9zZSgpOyByZXR1cm4gbmlsLCByZWFkRXJyIGVuZAorICAgICAgYnJlYWsKKyAgICBlbmQKKyAgZW5kCisgIGZpbGU6Y2xvc2UoKQorICByZXR1cm4gY3R4OmZpbmFsKCkKK2VuZAorCiBmdW5jdGlvbiBSZXF1aXJlZEltcG9ydHMucGF0aChtYW5pZmVzdCwgc3BlYykKICAgcmV0dXJuIG1hbmlmZXN0LnBhdGggLi4gIi9iYXNlcm9tcy8iIC4uIHNwZWMuZmlsZQogZW5kCkBAIC0xODAsNiArMjEwLDM1IEBAIGxvY2FsIGZ1bmN0aW9uIHJlbW92ZVJlY2VpcHQobWFuaWZlc3QsIHNwZWMsIGZzKQogICBlbmQKIGVuZAogCistLSBGaW5hbGl6ZSBhIGNhbGxlci1zdHJlYW1lZCBpbXBvcnQgYWZ0ZXIgdGhlIGRlc3RpbmF0aW9uIGJ5dGVzIGhhdmUgYWxyZWFkeQorLS0gYmVlbiBjb3BpZWQgaW50byB0aGUgZW5naW5lLW93bmVkIGJhc2Vyb21zIHBhdGguIFRoaXMga2VlcHMgbGFyZ2UgaW1wb3J0cworLS0gb3V0IG9mIGEgc2luZ2xlIEx1YSBzdHJpbmcgd2hpbGUgcHJlc2VydmluZyB0aGUgc2FtZSBzaXplL01ENSByZWNlaXB0IHJ1bGVzLgorZnVuY3Rpb24gUmVxdWlyZWRJbXBvcnRzLmFjY2VwdFN0b3JlZERpZ2VzdChtYW5pZmVzdCwgaW1wb3J0SWQsIGRpZ2VzdCwgZnMpCisgIGZzID0gZnMgb3IgKGxvdmUgYW5kIGxvdmUuZmlsZXN5c3RlbSkKKyAgbG9jYWwgc3BlYyA9IHNwZWNCeUlkKG1hbmlmZXN0LCBpbXBvcnRJZCkKKyAgaWYgbm90IHNwZWMgdGhlbiByZXR1cm4gbmlsLCAidW5rbm93biByZXF1aXJlZCBpbXBvcnQ6ICIgLi4gdG9zdHJpbmcoaW1wb3J0SWQpIGVuZAorICBkaWdlc3QgPSB0b3N0cmluZyhkaWdlc3Qgb3IgIiIpOmxvd2VyKCkKKyAgaWYgbm90IGFjY2VwdHMoc3BlYywgZGlnZXN0KSB0aGVuCisgICAgcmV0dXJuIG5pbCwgKCJNRDUgbWlzbWF0Y2ggKGdvdCAlcykiKTpmb3JtYXQoZGlnZXN0IH49ICIiIGFuZCBkaWdlc3Qgb3IgInVuYXZhaWxhYmxlIikKKyAgZW5kCisgIGxvY2FsIHBhdGggPSBSZXF1aXJlZEltcG9ydHMucGF0aChtYW5pZmVzdCwgc3BlYykKKyAgbG9jYWwgaW5mbyA9IGZzIGFuZCBmcy5nZXRJbmZvIGFuZCBmcy5nZXRJbmZvKHBhdGgsICJmaWxlIikgb3IgbmlsCisgIGlmIG5vdCBpbmZvIHRoZW4gcmV0dXJuIG5pbCwgImNvcGllZCBpbXBvcnQgaXMgbWlzc2luZyIgZW5kCisgIGxvY2FsIHNpemVFcnIgPSBSZXF1aXJlZEltcG9ydHMuc2l6ZUVycm9yKHNwZWMsIGluZm8uc2l6ZSwgdHJ1ZSkKKyAgaWYgc2l6ZUVyciB0aGVuIHJldHVybiBuaWwsIHNpemVFcnIgZW5kCisgIGlmIGxvdmUgYW5kIGZzID09IGxvdmUuZmlsZXN5c3RlbSB0aGVuCisgICAgbG9jYWwgc2F2ZWRQcmVmaXggPSBDYWNoZUZzLnByZWZpeAorICAgIENhY2hlRnMucHJlZml4ID0gIiIKKyAgICBDYWNoZUZzLnJlbW92ZShyZW1vdmVkTWFya2VyKG1hbmlmZXN0LCBzcGVjKSkKKyAgICBDYWNoZUZzLnByZWZpeCA9IHNhdmVkUHJlZml4CisgIGVsc2VpZiBmcyBhbmQgZnMucmVtb3ZlIHRoZW4KKyAgICBmcy5yZW1vdmUocmVtb3ZlZE1hcmtlcihtYW5pZmVzdCwgc3BlYykpCisgIGVuZAorICB3cml0ZVJlY2VpcHQobWFuaWZlc3QsIHNwZWMsIGRpZ2VzdCwgaW5mbywgZnMpCisgIHJldHVybiB0cnVlLCBkaWdlc3QKK2VuZAorCisKIC0tIFZhbGlkYXRlIGJ5dGVzIGFnYWluc3QgYSBkZWNsYXJhdGlvbi4gIFRoZSByZXR1cm5lZCBkYXRhIGlzIGNhbm9uaWNhbGl6ZWQKIC0tIChub3RhYmx5IGZvciBONjQgYnl0ZSBvcmRlci9oZWFkZXIgdmFyaWFudHMpIGFuZCBpcyB3aGF0IG11c3QgYmUgc3RvcmVkLgogZnVuY3Rpb24gUmVxdWlyZWRJbXBvcnRzLnZhbGlkYXRlRGF0YShzcGVjLCBkYXRhLCBoYXNoRm4pCkBAIC0yMTcsNiArMjc2LDIyIEBAIGZ1bmN0aW9uIFJlcXVpcmVkSW1wb3J0cy52YWxpZGF0ZVN0b3JlZChtYW5pZmVzdCwgc3BlYywgZnMsIGhhc2hGbikKICAgbG9jYWwgY2FjaGVkID0gY2FjaGVkRGlnZXN0KG1hbmlmZXN0LCBzcGVjLCBmcywgaW5mbykKICAgaWYgY2FjaGVkIHRoZW4gcmV0dXJuIHRydWUsIGNhY2hlZCwgdHJ1ZSBlbmQKICAgcmVtb3ZlUmVjZWlwdChtYW5pZmVzdCwgc3BlYywgZnMpCisgIC0tIExhcmdlIHJhdyBpbXBvcnRzIChHYW1lQ3ViZSBkaXNjcywgZnV0dXJlIG9wdGljYWwgaW1hZ2VzLCBldGMuKSBtdXN0IG5ldmVyCisgIC0tIGJlIG1hdGVyaWFsaXplZCBpbnRvIG9uZSBMdWEgc3RyaW5nIG1lcmVseSBiZWNhdXNlIHRoZWlyIHZhbGlkYXRpb24KKyAgLS0gcmVjZWlwdCB3YXMgbG9zdC4gU3RyZWFtIHRoZSBNRDUgZGlyZWN0bHkgZnJvbSB0aGUgaW5zdGFsbGVkIGZpbGUuIE42NAorICAtLSBzb3VyY2VzIHN0YXkgb24gdGhlIGNhbm9uaWNhbGl6YXRpb24gcGF0aCBiZWNhdXNlIGJ5dGUtb3JkZXIvaGVhZGVyCisgIC0tIG5vcm1hbGl6YXRpb24gaXMgcGFydCBvZiB0aGVpciB2YWxpZGF0aW9uIGNvbnRyYWN0LgorICBpZiBpbmZvLnNpemUgYW5kIGluZm8uc2l6ZSA+IFJlcXVpcmVkSW1wb3J0cy5MQVJHRV9XQVJOX0JZVEVTCisgICAgICBhbmQgc3BlYy5mb3JtYXQgfj0gIm42NCIgYW5kIGZzLm5ld0ZpbGUgdGhlbgorICAgIGxvY2FsIGRpZ2VzdCwgaGFzaEVyciA9IHN0cmVhbURpZ2VzdChmcywgcGF0aCkKKyAgICBpZiBub3QgZGlnZXN0IHRoZW4gcmV0dXJuIG5pbCwgaGFzaEVyciBlbmQKKyAgICBpZiBub3QgYWNjZXB0cyhzcGVjLCBkaWdlc3QpIHRoZW4KKyAgICAgIHJldHVybiBuaWwsICgiTUQ1IG1pc21hdGNoIChnb3QgJXMpIik6Zm9ybWF0KGRpZ2VzdCkKKyAgICBlbmQKKyAgICBpbmZvID0gZnMuZ2V0SW5mbyhwYXRoLCAiZmlsZSIpIG9yIGluZm8KKyAgICB3cml0ZVJlY2VpcHQobWFuaWZlc3QsIHNwZWMsIGRpZ2VzdCwgaW5mbywgZnMpCisgICAgcmV0dXJuIHRydWUsIGRpZ2VzdCwgZmFsc2UKKyAgZW5kCiAgIGlmIG5vdCBmcy5yZWFkIHRoZW4gcmV0dXJuIG5pbCwgImZpbGUgY291bGQgbm90IGJlIHJlYWQiIGVuZAogICBsb2NhbCBkYXRhID0gZnMucmVhZChwYXRoKQogICBsb2NhbCBub3JtYWxpemVkLCBkZXRhaWwgPSBSZXF1aXJlZEltcG9ydHMudmFsaWRhdGVTdG9yZWREYXRhKHNwZWMsIGRhdGEsIGhhc2hGbikK \ No newline at end of file diff --git a/.github/workflows/apply-large-import-pr.yml b/.github/workflows/apply-large-import-pr.yml deleted file mode 100644 index 1ec658d0..00000000 --- a/.github/workflows/apply-large-import-pr.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Apply prepared large-import patch - -on: - push: - branches: - - fix/large-required-imports-v2 - -permissions: - contents: write - -jobs: - apply: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Reconstruct and apply patch - shell: bash - run: | - set -euo pipefail - base64 -d .github/pr-build/large-import.patch.b64.1 > /tmp/large-import.patch - base64 -d .github/pr-build/large-import.patch.b64.2 >> /tmp/large-import.patch - git apply --check /tmp/large-import.patch - git apply /tmp/large-import.patch - rm -rf .github/pr-build - rm -f .github/workflows/apply-large-import-pr.yml - - name: Commit prepared change - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix(mods): support large required imports end-to-end" - git push origin HEAD:fix/large-required-imports-v2 diff --git a/docs/modding.md b/docs/modding.md index 4016e1f1..25e7596d 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -122,21 +122,40 @@ Each object requires a stable `id`, a display `name`, a destination `file` 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. +per-import ceiling when an exact size is not appropriate. The engine hard limit +is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and +use the launcher's streaming large-file path rather than being materialized as +one Lua string. 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//baseroms/`. 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 +because its manifest names the same digest. Small sources can still be read +with the existing scoped `mod:read` API, for example +`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded +`mod.imports` facade described below; no host path or new general 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. +#### Bounded access to validated imports + +A loaded mod can address only ids declared by its own `required_imports` or +`optional_imports` arrays: + +```lua +local info, err = mod.imports:info("stadium2") +local header, err = mod.imports:read("stadium2", 0, 4096) +``` + +`read` uses zero-based offsets and is capped at 8 MiB per call. The engine +rechecks the stored import before exposing it, seeks into the engine-owned +copy, and never gives the mod a host path or file handle. This is intended for +large source formats whose table/index can be parsed with small reads before +selectively reading the payloads a transform actually needs. + 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 @@ -441,6 +460,28 @@ 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. +## Installation-scoped generated cache + +Generated data derived from a validated user source often belongs to the mod +installation rather than to one Pokémon save. `mod.cache` is that namespace: + +```lua +local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena) +local bytes, err = mod.cache:read("extract/v1/arena.bin") +local info = mod.cache:info("extract/v1/arena.bin") +mod.cache:delete("extract/v1/arena.bin") +``` + +The physical root is engine-owned (`mod_cache//`) and never exposed to +the mod. Keys are safe relative paths and a single write is capped at 64 MiB. +The cache does not rewind with checkpoints and is not scoped to game version, +slot, or playthrough. The mod owns its generated format, fingerprints, rebuild +policy, and completion marker; the engine treats the bytes as opaque data. + +Use `mod.storage` instead when the data belongs to one playthrough. Use +`mod.cache` when it is a reproducible installation artifact that can be rebuilt +from a declared user source. + ## Durable tool storage and runtime checkpoints `mod.save` remains the right place for state that should travel with the next diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index a3bfd889..00ef8e99 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -287,6 +287,43 @@ function CacheFs.write(rel, data) return love.filesystem.write(rel, data) end +-- Open a cache-relative file for streaming replacement. The returned handle +-- has write(bytes) and close() methods and follows the same portable/save-dir +-- routing as CacheFs.write without forcing the caller to hold the whole file +-- in one Lua string. +function CacheFs.openWrite(rel) + rel = withPrefix(rel) + local root = CacheFs.root() + if root then + ensureParents(root, rel) + local f, err = io.open(realPath(root, rel), "wb") + if not f then return nil, err end + return { + write = function(_, data) + local ok, writeErr = f:write(data) + if not ok then return nil, writeErr end + return true + end, + close = function() f:close() end, + } + end + if not (love and love.filesystem and love.filesystem.newFile) then + return nil, "streaming cache writes are unavailable" + end + local parent = rel:match("^(.*)/[^/]+$") + if parent and not love.filesystem.createDirectory(parent) then + local info = love.filesystem.getInfo(parent) + local reason = info and ("a " .. info.type .. " already exists there") + or "unknown reason" + return nil, "could not create " .. parent .. ": " .. reason + end + local file, makeErr = love.filesystem.newFile(rel) + if not file then return nil, makeErr or "could not create cache file" end + local ok, openErr = file:open("w") + if not ok then return nil, openErr or "could not open cache file" end + return file +end + -- read cache-relative `rel`; returns the bytes or nil function CacheFs.read(rel) rel = withPrefix(rel) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index d5b4aecf..446bb907 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -382,6 +382,102 @@ local function externalFileSize(path) return size end +local function openImportSource(path) + -- Desktop picker paths live outside LÖVE's virtual filesystem. Prefer the + -- native file handle so a 1.46 GiB disc is never copied to a temp file or + -- read into one Lua string before validation. + local native = io.open(path, "rb") + if native then + local size = native:seek("end") + if size then native:seek("set", 0) end + return { + size = size, + read = function(_, n) return native:read(n) end, + close = function() native:close() end, + } + end + if love and love.filesystem and love.filesystem.newFile then + local file, makeErr = love.filesystem.newFile(path) + if not file then return nil, makeErr or "could not open source file" end + local ok, openErr = file:open("r") + if not ok then return nil, openErr or "could not open source file" end + local size = file.getSize and file:getSize() or nil + return { + size = size, + read = function(_, n) return file:read(n) end, + close = function() file:close() end, + } + end + return nil, "streaming source access is unavailable" +end + +local function streamRequiredImport(manifest, importId, source) + local RequiredImports = require("src.mods.RequiredImports") + local spec = RequiredImports.spec(manifest, importId) + if not spec then return nil, "Import declaration was not found." end + if spec.format == "n64" then + return nil, "streaming canonicalization is unavailable for N64 imports" + end + local input, openErr = openImportSource(source) + if not input then return nil, openErr end + local sizeErr = RequiredImports.sizeError(spec, input.size, false) + if sizeErr then input:close(); return nil, sizeErr end + + local CacheFs = require("src.import.CacheFs") + local destination = RequiredImports.path(manifest, spec) + local savedPrefix = CacheFs.prefix + CacheFs.prefix = "" + CacheFs.remove(RequiredImports.receiptPath(manifest, spec)) + CacheFs.remove(destination) + local output, makeErr = CacheFs.openWrite(destination) + if not output then + CacheFs.prefix = savedPrefix + input:close() + return nil, makeErr or "could not create imported file" + end + + local function cleanupDestination() + CacheFs.remove(destination) + CacheFs.prefix = savedPrefix + end + + local MD5 = require("src.mods.StreamMD5") + local md5 = MD5.new() + local total, chunkBytes = 0, 4 * 1024 * 1024 + while true do + local chunk = input:read(chunkBytes) + if not chunk or #chunk == 0 then break end + md5:update(chunk) + local wrote, writeErr = output:write(chunk) + if wrote == false or wrote == nil then + input:close(); output:close(); cleanupDestination() + return nil, "could not copy import: " .. tostring(writeErr or "write failed") + end + total = total + #chunk + if #chunk < chunkBytes then break end + end + input:close() + output:close() + + if input.size and total ~= input.size then + cleanupDestination() + return nil, ("source read ended early (expected %d bytes, copied %d)"):format(input.size, total) + end + local storedSizeErr = RequiredImports.sizeError(spec, total, true) + if storedSizeErr then cleanupDestination(); return nil, storedSizeErr end + local digest = md5:final() + CacheFs.prefix = savedPrefix + local accepted, detail = RequiredImports.acceptStoredDigest( + manifest, importId, digest, love.filesystem) + if not accepted then + CacheFs.prefix = "" + CacheFs.remove(destination) + CacheFs.prefix = savedPrefix + return nil, detail + end + return true, detail +end + local function readDroppedFile(file) local ok, openError = file:open("r") if not ok then return nil, openError end @@ -1228,11 +1324,13 @@ local function chooseRequiredFile() "$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d.Title='" .. prompt .. "';", "$d.Filter='All files (*.*)|*.*';", + -- Required imports can be multi-gigabyte optical-disc images. Do NOT + -- stage them through %TEMP%: that doubles free-space requirements and a + -- failed Copy-Item can leave a plausible-looking truncated temp file. + -- Stream the selected source directly into the mod-owned destination. "if($d.ShowDialog() -eq 'OK'){", - "$t=Join-Path $env:TEMP 'pokeport_required_import.bin';", - "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", - "[Console]::Write($t)}", + "[Console]::Write($d.FileName)}", }) return commandOutput( 'powershell -NoProfile -STA -Command "' .. script .. '"') @@ -2072,8 +2170,13 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed) return nil end local RequiredImports = require("src.mods.RequiredImports") - local info = love.filesystem.getInfo(source, "file") - local size = info and info.size or externalFileSize(source) + -- A desktop picker returns a host path. Ask the host file handle first; + -- love.filesystem.getInfo is only authoritative for virtual/save paths. + local size = externalFileSize(source) + if not size then + local info = love.filesystem.getInfo(source, "file") + size = info and info.size or nil + end local sizeErr = RequiredImports.sizeError(spec, size, false) if sizeErr then requiredImportNotice(self, modId, importId, sizeErr) @@ -2096,6 +2199,20 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed) } return nil end + if type(size) == "number" and size > RequiredImports.LARGE_WARN_BYTES + and spec.format ~= "n64" then + local ok, result = streamRequiredImport(manifest, importId, source) + if ok then + self.requiredImportNotice = nil + self.modNotice = { ok = true, text = "Imported " .. tostring(importId) + .. " for " .. tostring(manifest.name or manifest.id) .. "." } + self:_refreshMods() + return true + end + requiredImportNotice(self, modId, importId, result) + self.modNotice = nil + return nil + end local data = love.filesystem.read(source) if not data then data = readExternalPath(source) end if not data then diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 8ec4767d..ddfc3f12 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -962,6 +962,8 @@ function Loader:_api(mod) local Storage = engineRequire("src.mods.Storage") local storage = Storage and Storage.new(modId, loader.fs) local Checkpoint = engineRequire("src.core.Checkpoint") + local ImportAccess = engineRequire("src.mods.ImportAccess") + local importApi, installCache = ImportAccess.new(mod.manifest, loader.fs) local api = { id = modId, version = mod.manifest.version, @@ -1161,6 +1163,12 @@ function Loader:_api(mod) -- checkpoint. The -- engine binds version/playthrough/mod scope and portable persistence; -- callers never receive paths or a raw filesystem handle. + -- Read-only bounded access to this mod's manifest-declared, launcher-validated + -- imports. No host path is exposed; large sources are read in bounded ranges. + imports = importApi, + -- Installation-scoped generated data, independent from Pokémon save slots. + -- This is where ROM-derived caches belong; mod.storage remains playthrough-scoped. + cache = installCache, storage = { context = function(_, game) return storage:context(game) end, selected = function(_, game) return storage:selected(game) end, diff --git a/src/mods/RequiredImports.lua b/src/mods/RequiredImports.lua index 806e916e..25e8aa8c 100644 --- a/src/mods/RequiredImports.lua +++ b/src/mods/RequiredImports.lua @@ -120,6 +120,36 @@ local function accepts(spec, digest) return false end +local function specById(manifest, importId) + for _, candidate in ipairs(allSpecs(manifest)) do + if candidate.id == importId then return candidate end + end + return nil +end + +RequiredImports.spec = specById + +local function streamDigest(fs, path, chunkBytes) + if not (fs and fs.newFile) then return nil, "streaming file access is unavailable" end + local file, makeErr = fs.newFile(path) + if not file then return nil, makeErr or "could not open stored import" end + local ok, openErr = file:open("r") + if not ok then return nil, openErr or "could not open stored import" end + local MD5 = require("src.mods.StreamMD5") + local ctx = MD5.new() + chunkBytes = chunkBytes or (4 * 1024 * 1024) + while true do + local data, readErr = file:read(chunkBytes) + if data and #data > 0 then ctx:update(data) end + if not data or #data < chunkBytes then + if readErr then file:close(); return nil, readErr end + break + end + end + file:close() + return ctx:final() +end + function RequiredImports.path(manifest, spec) return manifest.path .. "/baseroms/" .. spec.file end @@ -180,6 +210,35 @@ local function removeReceipt(manifest, spec, fs) end end +-- Finalize a caller-streamed import after the destination bytes have already +-- been copied into the engine-owned baseroms path. This keeps large imports +-- out of a single Lua string while preserving the same size/MD5 receipt rules. +function RequiredImports.acceptStoredDigest(manifest, importId, digest, fs) + fs = fs or (love and love.filesystem) + local spec = specById(manifest, importId) + if not spec then return nil, "unknown required import: " .. tostring(importId) end + digest = tostring(digest or ""):lower() + if not accepts(spec, digest) then + return nil, ("MD5 mismatch (got %s)"):format(digest ~= "" and digest or "unavailable") + end + local path = RequiredImports.path(manifest, spec) + local info = fs and fs.getInfo and fs.getInfo(path, "file") or nil + if not info then return nil, "copied import is missing" end + local sizeErr = RequiredImports.sizeError(spec, info.size, true) + if sizeErr then return nil, sizeErr end + if love and fs == love.filesystem then + local savedPrefix = CacheFs.prefix + CacheFs.prefix = "" + CacheFs.remove(removedMarker(manifest, spec)) + CacheFs.prefix = savedPrefix + elseif fs and fs.remove then + fs.remove(removedMarker(manifest, spec)) + end + writeReceipt(manifest, spec, digest, info, fs) + return true, digest +end + + -- Validate bytes against a declaration. The returned data is canonicalized -- (notably for N64 byte order/header variants) and is what must be stored. function RequiredImports.validateData(spec, data, hashFn) @@ -217,6 +276,22 @@ function RequiredImports.validateStored(manifest, spec, fs, hashFn) local cached = cachedDigest(manifest, spec, fs, info) if cached then return true, cached, true end removeReceipt(manifest, spec, fs) + -- Large raw imports (GameCube discs, future optical images, etc.) must never + -- be materialized into one Lua string merely because their validation + -- receipt was lost. Stream the MD5 directly from the installed file. N64 + -- sources stay on the canonicalization path because byte-order/header + -- normalization is part of their validation contract. + if info.size and info.size > RequiredImports.LARGE_WARN_BYTES + and spec.format ~= "n64" and fs.newFile then + local digest, hashErr = streamDigest(fs, path) + if not digest then return nil, hashErr end + if not accepts(spec, digest) then + return nil, ("MD5 mismatch (got %s)"):format(digest) + end + info = fs.getInfo(path, "file") or info + writeReceipt(manifest, spec, digest, info, fs) + return true, digest, false + end if not fs.read then return nil, "file could not be read" end local data = fs.read(path) local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn) From 911e11a3721179583a82bf8f48562d331cd08f25 Mon Sep 17 00:00:00 2001 From: HighDrexler Date: Thu, 20 Aug 2026 17:14:32 -0400 Subject: [PATCH 03/15] test(mods): document and cover streamed import access --- ...-streamed-mod-imports-and-install-cache.md | 141 ++++++++++++++ src/mods/ImportAccess.lua | 174 ++++++++++++++++++ src/mods/StreamMD5.lua | 101 ++++++++++ .../engine/mod_import_access_parity_test.lua | 42 +++++ tests/engine/mod_import_access_test.lua | 105 +++++++++++ .../engine/required_import_streaming_test.lua | 97 ++++++++++ tests/engine/stream_md5_test.lua | 33 ++++ 7 files changed, 693 insertions(+) create mode 100644 docs/rfcs/0008-streamed-mod-imports-and-install-cache.md create mode 100644 src/mods/ImportAccess.lua create mode 100644 src/mods/StreamMD5.lua create mode 100644 tests/engine/mod_import_access_parity_test.lua create mode 100644 tests/engine/mod_import_access_test.lua create mode 100644 tests/engine/required_import_streaming_test.lua create mode 100644 tests/engine/stream_md5_test.lua diff --git a/docs/rfcs/0008-streamed-mod-imports-and-install-cache.md b/docs/rfcs/0008-streamed-mod-imports-and-install-cache.md new file mode 100644 index 00000000..22240416 --- /dev/null +++ b/docs/rfcs/0008-streamed-mod-imports-and-install-cache.md @@ -0,0 +1,141 @@ +# RFC 0008 — Streamed mod imports and installation-scoped generated cache + +## Motivation + +`required_imports`/`optional_imports` can now describe files up to 2 GiB, but +the existing launcher and public mod API still assume imported bytes are small: + +* the Windows desktop picker stages a selected required import through a fixed + `%TEMP%/pokeport_required_import.bin` path before validation; +* the fallback import path materializes the selected file as one Lua string; +* after validation a mod can only use `mod:read("baseroms/...")`, which also + materializes the whole file; +* `mod.storage` is intentionally scoped to one Pokémon playthrough, so it is + not an appropriate home for a one-time generated asset cache shared by every + save using the same installed mod. + +This makes optical-disc-sized user sources impractical even though the manifest +schema already accepts them. A failed temporary staging copy can also turn a +valid large source into a smaller temporary file and produce a misleading +"wrong file size" rejection. + +A mod should be able to consume its own already-validated source incrementally +and compile derived runtime data once, without receiving a host path or general +filesystem access. + +## Decision being extended + +This extends the same legal/sandbox direction as **D11 asset transforms** +(`src/mods/AssetTransform.lua`): mods distribute recipes and derive bytes from +user-owned sources rather than shipping ROM-derived data. It also follows the +**D14 parity-gate** contract referenced by `tests/harness.lua` and +`tests/engine/gate_meta_coverage.lua` (the `21-testing-and-ci` plan): additive +extension points ship public-API coverage, no-mod parity coverage, and docs in +the same change. + +The historical D11 plan document is referenced by source comments but is not +present in the current repository tree; this RFC is the checked-in design +record for the new surface. + +## Exact API delta + +No manifest field changes. Existing `required_imports` and `optional_imports` +remain the declaration/validation authority. + +Two additive facades are added to the `mod` object. + +### `mod.imports` + +```lua +local info, err = mod.imports:info("source_id") +local bytes, err = mod.imports:read("source_id", offset, length) +``` + +* `source_id` must name an import declared by the calling mod. +* the import is rechecked through `RequiredImports.validateStored` before it is + exposed, so missing, replaced, or invalid optional imports are not readable; +* `offset` and `length` are zero-based byte coordinates; +* one read is capped at 8 MiB; +* no host path or file handle is returned; +* production reads seek into the engine-owned stored copy instead of reading + the whole source. + +`info()` returns declaration metadata plus stored size. It does not expose a +host path. + +### `mod.cache` + +```lua +mod.cache:write("extract/v1/model.bin", bytes) +local bytes = mod.cache:read("extract/v1/model.bin") +local info = mod.cache:info("extract/v1/model.bin") +mod.cache:delete("extract/v1/model.bin") +``` + +The cache is rooted at `mod_cache//`, follows the engine persistence +backend, and is independent of game version, launcher slot, and playthrough. +Paths are checked with `SafePath`; `..`, absolute paths, drive paths, and other +escapes remain unavailable. A single cache write is capped at 64 MiB so large +generated datasets are naturally split into independently replaceable files. + +The engine does not interpret cache bytes. Mods own generated-format versioning, +fingerprints, transactional completion markers, and rebuild policy. + +## Launcher/import transport delta + +For large raw required imports: + +1. desktop pickers return the original selected path instead of staging it + through a fixed temporary file; +2. the engine opens that source itself; +3. bytes are copied directly to the existing engine-owned + `mods//baseroms/` destination in 4 MiB chunks; +4. MD5 is updated incrementally during the copy; +5. the normal size/MD5 validation receipt is written only after the complete + destination passes validation; +6. partial destinations are removed on short reads, write failure, size + mismatch, or digest mismatch. + +N64 imports stay on the existing canonicalization path because byte-order and +copier-header normalization require transformation rather than a raw copy. + +If a validation receipt for an already-stored large raw import is missing, the +engine rebuilds it with streaming MD5 rather than a whole-file read. + +## Backward compatibility / migration + +**Existing mods do nothing.** This is additive. + +* manifest v1/v2 fields are unchanged; +* `mod:read`, `mod.storage`, registries, events, hooks, and legacy compatibility + retain their existing behavior; +* small required imports retain the existing in-memory validation path; +* N64 imports retain canonicalization and existing accepted byte orders; +* a mod that never touches `mod.imports` or `mod.cache` creates no new cache + files and observes no new behavior. + +The mod API integer is not bumped because no existing member changes meaning or +shape. + +## Security and legal posture + +The launcher remains the authority that validates user-supplied bytes. The new +facade narrows access rather than widening it: a mod can read only ids declared +in its own manifest, only after validation, and only in bounded ranges. It does +not receive host paths, `io`, or a raw filesystem handle. + +`mod.cache` is writable only beneath the calling mod's generated-cache root. +Nothing in this RFC permits packaged ROM-derived bytes; `modkit lint/pack` +continue to enforce the existing legal posture. + +## Parity guarantee + +The change ships with: + +* a no-mod/API-v1 parity test proving an empty load and an existing v1-style + `mod:read` load do not create cache data or change the old surface; +* a public mod-API test that reaches `mod.imports` and `mod.cache` through a + real `Loader` load, including bounded reads, undeclared/missing imports, + cache isolation, and traversal rejection; +* incremental MD5 vectors and a large-import streaming regression test; +* the existing engine suite, required-import suite, and mod lint gates. diff --git a/src/mods/ImportAccess.lua b/src/mods/ImportAccess.lua new file mode 100644 index 00000000..11f32380 --- /dev/null +++ b/src/mods/ImportAccess.lua @@ -0,0 +1,174 @@ +-- Scoped access to a mod's launcher-validated required/optional imports and +-- to installation-wide generated cache data. +-- +-- This intentionally does not expose host paths or raw filesystem handles. +-- Import reads are bounded and can only address ids declared by the calling +-- mod's manifest. Cache paths are confined to mod_cache// and are not +-- tied to a Pokémon playthrough. + +local RequiredImports = require("src.mods.RequiredImports") +local SafePath = require("src.mods.SafePath") +local SaveData = require("src.core.SaveData") + +local ImportAccess = {} + +ImportAccess.MAX_READ_BYTES = 8 * 1024 * 1024 +ImportAccess.MAX_CACHE_WRITE_BYTES = 64 * 1024 * 1024 + +local function specMap(manifest) + local out = {} + for _, spec in ipairs(RequiredImports.specs(manifest)) do out[spec.id] = spec end + return out +end + +local function parentOf(path) + return path:match("^(.*)/[^/]+$") +end + +local function copyInfo(info) + if not info then return nil end + return { type = info.type, size = info.size, modtime = info.modtime } +end + +local function fsReadRange(fs, path, offset, length) + if fs and type(fs.readRange) == "function" then + return fs.readRange(path, offset, length) + end + local newFile = fs and fs.newFile + if newFile then + local file, makeErr = newFile(path) + if not file then return nil, makeErr or "could not open import" end + local ok, openErr = file:open("r") + if not ok then return nil, openErr or "could not open import" end + local seekOk, seekErr = file:seek(offset) + if seekOk == nil or seekOk == false then + file:close() + return nil, seekErr or "could not seek import" + end + local data, readErr = file:read(length) + file:close() + return data, readErr + end + -- Injectable headless filesystems may expose only read(). Production + -- love.filesystem has newFile(), so large imports are never materialized + -- into one Lua string by this fallback. + if fs and fs.read then + local data = fs.read(path) + if type(data) ~= "string" then return nil, "could not read import" end + return data:sub(offset + 1, offset + length) + end + return nil, "random-access import reads are unavailable" +end + +local function validatedInfo(manifest, spec, fs) + local ok, detail = RequiredImports.validateStored(manifest, spec, fs) + if not ok then return nil, detail or "import is not validated" end + local path = RequiredImports.path(manifest, spec) + local info = fs.getInfo and fs.getInfo(path, "file") or nil + if not info then return nil, "import is missing" end + return info, detail +end + +local function makeCache(modId, fs) + local root = "mod_cache/" .. modId + local function pathFor(rel, what) + rel = SafePath.require(rel, what or "mod.cache path") + return root .. "/" .. rel + end + local cache = {} + + function cache:write(rel, bytes) + if type(bytes) ~= "string" then + return nil, "mod.cache:write expects a byte string" + end + if #bytes > ImportAccess.MAX_CACHE_WRITE_BYTES then + return nil, "mod.cache:write payload exceeds 64 MiB; split generated data into smaller files" + end + local path = pathFor(rel, "mod.cache:write") + local parent = parentOf(path) + if parent and fs.createDirectory then + local ok = fs.createDirectory(parent) + if ok == false then return nil, "could not create cache directory" end + end + if not fs.write then return nil, "cache writes are unavailable" end + return fs.write(path, bytes) + end + + function cache:read(rel) + local path = pathFor(rel, "mod.cache:read") + if not fs.read then return nil, "cache reads are unavailable" end + return fs.read(path) + end + + function cache:info(rel) + local path = pathFor(rel, "mod.cache:info") + if not fs.getInfo then return nil end + return copyInfo(fs.getInfo(path)) + end + + function cache:exists(rel) + local info = self:info(rel) + return info ~= nil and info.type == "file" + end + + + function cache:delete(rel) + local path = pathFor(rel, "mod.cache:delete") + if not fs.remove then return nil, "cache deletion is unavailable" end + return fs.remove(path) + end + + return cache +end + +function ImportAccess.new(manifest, fs) + local specs = specMap(manifest) + local cacheFs = SaveData.persistenceFs(fs) or fs + local imports = {} + + function imports:info(id) + local spec = specs[id] + if not spec then return nil, "undeclared import: " .. tostring(id) end + local info, digestOrErr = validatedInfo(manifest, spec, fs) + if not info then return nil, digestOrErr end + return { + id = spec.id, + name = spec.name, + file = spec.file, + size = info.size, + md5 = digestOrErr, + required = spec.required ~= false, + } + end + + function imports:read(id, offset, length) + local spec = specs[id] + if not spec then return nil, "undeclared import: " .. tostring(id) end + offset, length = tonumber(offset), tonumber(length) + if not offset or offset < 0 or offset % 1 ~= 0 then + return nil, "offset must be a non-negative integer" + end + if not length or length < 0 or length % 1 ~= 0 then + return nil, "length must be a non-negative integer" + end + if length > ImportAccess.MAX_READ_BYTES then + return nil, "single import read exceeds 8 MiB" + end + + local info, err = validatedInfo(manifest, spec, fs) + if not info then return nil, err end + local size = tonumber(info.size) or tonumber(spec.size) + if size and offset + length > size then return nil, "import read is out of bounds" end + if length == 0 then return "" end + + local path = RequiredImports.path(manifest, spec) + local data, readErr = fsReadRange(fs, path, offset, length) + if not data then return nil, readErr end + if #data ~= length then return nil, "short import read" end + return data + end + + return imports, makeCache(manifest.id, cacheFs) +end + +return ImportAccess diff --git a/src/mods/StreamMD5.lua b/src/mods/StreamMD5.lua new file mode 100644 index 00000000..1990e8ec --- /dev/null +++ b/src/mods/StreamMD5.lua @@ -0,0 +1,101 @@ +local bitlib = rawget(_G, "bit") or rawget(_G, "bit32") +if not bitlib then error("StreamMD5 requires bit or bit32") end + +local band, bor, bxor, bnot = bitlib.band, bitlib.bor, bitlib.bxor, bitlib.bnot +local lshift, rshift = bitlib.lshift, bitlib.rshift +local rol = bitlib.rol or bitlib.lrotate + +local K = { + 0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501, + 0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821, + 0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8, + 0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a, + 0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70, + 0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665, + 0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1, + 0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391, +} +local S = { + 7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22, + 5,9,14,20, 5,9,14,20, 5,9,14,20, 5,9,14,20, + 4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23, + 6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21, +} + +local function add32(a,b,c,d) + local n = (a or 0) + (b or 0) + (c or 0) + (d or 0) + return band(n, 0xffffffff) +end + +local function le32_from(s, i) + local b1,b2,b3,b4 = s:byte(i, i+3) + return bor(b1, lshift(b2,8), lshift(b3,16), lshift(b4,24)) +end + +local function le32_bytes(x) + return string.char( + band(x,0xff), band(rshift(x,8),0xff), + band(rshift(x,16),0xff), band(rshift(x,24),0xff)) +end + +local M = {} +local StreamMD5 = {} +StreamMD5.__index = StreamMD5 + +function StreamMD5.new() + return setmetatable({ + a=0x67452301, b=0xefcdab89, c=0x98badcfe, d=0x10325476, + bytes=0, buffer="", done=false, + }, StreamMD5) +end + +function StreamMD5:_block(block) + for j=1,16 do M[j] = le32_from(block, (j-1)*4+1) end + local a,b,c,d = self.a,self.b,self.c,self.d + for i=0,63 do + local f,g + if i < 16 then + f = bor(band(b,c), band(bnot(b),d)); g=i + elseif i < 32 then + f = bor(band(d,b), band(bnot(d),c)); g=(5*i+1)%16 + elseif i < 48 then + f = bxor(b,c,d); g=(3*i+5)%16 + else + f = bxor(c, bor(b,bnot(d))); g=(7*i)%16 + end + local tmp=d + d=c + c=b + b=add32(b, rol(add32(a,f,K[i+1],M[g+1]), S[i+1])) + a=tmp + end + self.a=add32(self.a,a); self.b=add32(self.b,b) + self.c=add32(self.c,c); self.d=add32(self.d,d) +end + +function StreamMD5:update(data) + assert(not self.done, "StreamMD5 context already finalized") + assert(type(data)=="string", "StreamMD5:update expects a string") + self.bytes = self.bytes + #data + local s = self.buffer .. data + local full = #s - (#s % 64) + for i=1,full,64 do self:_block(s:sub(i,i+63)) end + self.buffer = s:sub(full+1) + return self +end + +function StreamMD5:final() + assert(not self.done, "StreamMD5 context already finalized") + local originalBytes = self.bytes + local padLen = (56 - ((originalBytes + 1) % 64)) % 64 + local bits = originalBytes * 8 + local lo = bits % 4294967296 + local hi = math.floor(bits / 4294967296) % 4294967296 + self:update("\128" .. string.rep("\0", padLen) .. le32_bytes(lo) .. le32_bytes(hi)) + assert(#self.buffer == 0, "MD5 finalization left a partial block") + self.done=true + local raw = le32_bytes(self.a)..le32_bytes(self.b)..le32_bytes(self.c)..le32_bytes(self.d) + return (raw:gsub(".", function(ch) return string.format("%02x", ch:byte()) end)) +end + +return StreamMD5 diff --git a/tests/engine/mod_import_access_parity_test.lua b/tests/engine/mod_import_access_parity_test.lua new file mode 100644 index 00000000..fc2f7c73 --- /dev/null +++ b/tests/engine/mod_import_access_parity_test.lua @@ -0,0 +1,42 @@ +-- No-mod and API-v1 parity coverage for additive mod.imports/mod.cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +-- No mod installed: no generated cache namespace is touched. +local emptyFiles = {} +local none = T.sdk.loadNone({ fs = T.sdk.memfs(emptyFiles) }) +T.eq(#none.errors, 0, "zero-mod load remains clean") +for path in pairs(emptyFiles) do + T.check(path:sub(1, 10) ~= "mod_cache/", + "zero-mod load creates no installation cache data") +end +none.release() + +-- Existing API-v1 style file access still behaves exactly as before. The new +-- facades are additive members; mod:read remains the same scoped read path. +local files = { + ["mods/v1_read_probe/manifest.json"] = [[{ + "id": "v1_read_probe", + "name": "V1 Read Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 1 + }]], + ["mods/v1_read_probe/main.lua"] = [[ + local mod = ... + mod.exports.payload = mod:read("payload.txt") + mod.exports.hasImports = type(mod.imports) == "table" + mod.exports.hasCache = type(mod.cache) == "table" + ]], + ["mods/v1_read_probe/payload.txt"] = "unchanged-v1-read", +} +local run = T.sdk.loadMods({ "mods/v1_read_probe" }, { fs = T.sdk.memfs(files) }) +T.eq(#run.errors, 0, "API-v1 probe still loads") +local out = run.loader.exports.v1_read_probe +T.eq(out.payload, "unchanged-v1-read", "mod:read keeps its v1 behavior") +T.eq(out.hasImports, true, "new import facade is additive") +T.eq(out.hasCache, true, "new cache facade is additive") +run.release() + +T.finish("mod_import_access_parity") diff --git a/tests/engine/mod_import_access_test.lua b/tests/engine/mod_import_access_test.lua new file mode 100644 index 00000000..f861e73f --- /dev/null +++ b/tests/engine/mod_import_access_test.lua @@ -0,0 +1,105 @@ +-- Public mod-API coverage for bounded validated imports + installation cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local digest = "00000000000000000000000000000000" +local files = { + ["mods/import_api_probe/manifest.json"] = [[{ + "id": "import_api_probe", + "name": "Import API Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "required_imports": [{ + "id": "disc", + "name": "Disc", + "file": "source.iso", + "md5": ["00000000000000000000000000000000"], + "size": 16 + }], + "optional_imports": [{ + "id": "optional", + "name": "Optional", + "file": "optional.bin", + "md5": ["11111111111111111111111111111111"], + "size": 4, + "required": false + }] + }]], + ["mods/import_api_probe/main.lua"] = [[ + local mod = ... + local info, infoErr = mod.imports:info("disc") + local slice, sliceErr = mod.imports:read("disc", 4, 6) + local oob, oobErr = mod.imports:read("disc", 15, 2) + local missing, missingErr = mod.imports:info("optional") + local undeclared, undeclaredErr = mod.imports:read("not_declared", 0, 1) + + local wrote, writeErr = mod.cache:write("extract/v1/probe.bin", "abc") + local cached, readErr = mod.cache:read("extract/v1/probe.bin") + local cacheInfo = mod.cache:info("extract/v1/probe.bin") + local escaped = pcall(function() mod.cache:write("../escape.bin", "x") end) + + mod.exports.result = { + info = info, infoErr = infoErr, + slice = slice, sliceErr = sliceErr, + oob = oob, oobErr = oobErr, + missing = missing, missingErr = missingErr, + undeclared = undeclared, undeclaredErr = undeclaredErr, + wrote = wrote, writeErr = writeErr, + cached = cached, readErr = readErr, + cacheInfo = cacheInfo, + escaped = escaped, + } + ]], + ["mods/import_api_probe/baseroms/source.iso"] = "0123456789abcdef", + ["mods/import_api_probe/baseroms/.required-import-disc.validated"] = + "v1\n" .. digest .. "\n16\n1\n", +} + +local baseFs = T.sdk.memfs(files) +local oldInfo = baseFs.getInfo +function baseFs.getInfo(path) + local info = oldInfo(path) + if info and info.type == "file" then + return { type = "file", size = #(files[path] or ""), modtime = 1 } + end + return info +end +function baseFs.readRange(path, offset, length) + local body = files[path] + if not body then return nil end + return body:sub(offset + 1, offset + length) +end +function baseFs.createDirectory() return true end +function baseFs.remove(path) files[path] = nil return true end + +local run = T.sdk.loadMods({ "mods/import_api_probe" }, { fs = baseFs }) +T.eq(#run.errors, 0, + "the import/cache probe loads through the production Loader") + +local out = run.loader.exports.import_api_probe.result +T.check(type(out.info) == "table", "declared validated import has info") +T.eq(out.info.size, 16, "import info reports stored size") +T.eq(out.slice, "456789", "bounded read seeks into the validated import") +T.eq(out.sliceErr, nil, "bounded read has no error") +T.eq(out.oob, nil, "out-of-bounds read is refused") +T.check(type(out.oobErr) == "string" and out.oobErr:find("out of bounds", 1, true), + "out-of-bounds read explains the refusal") +T.eq(out.missing, nil, "missing optional import is not exposed") +T.check(type(out.missingErr) == "string", "missing optional import returns an error") +T.eq(out.undeclared, nil, "undeclared import id is refused") +T.check(type(out.undeclaredErr) == "string" + and out.undeclaredErr:find("undeclared", 1, true), + "undeclared import refusal is explicit") + +T.check(out.wrote == true, "installation cache write succeeds") +T.eq(out.cached, "abc", "installation cache read returns exact bytes") +T.eq(out.cacheInfo and out.cacheInfo.size, 3, "installation cache info is scoped") +T.eq(out.escaped, false, "cache traversal is rejected by the public facade") +T.eq(files["mod_cache/import_api_probe/extract/v1/probe.bin"], "abc", + "cache bytes live under the calling mod id") +T.eq(files["escape.bin"], nil, "cache traversal created nothing outside its root") + +run.release() +T.finish("mod_import_access") diff --git a/tests/engine/required_import_streaming_test.lua b/tests/engine/required_import_streaming_test.lua new file mode 100644 index 00000000..ec038105 --- /dev/null +++ b/tests/engine/required_import_streaming_test.lua @@ -0,0 +1,97 @@ +-- Regression for large raw required imports: direct source -> engine-owned +-- baseroms destination, incremental MD5, no whole-file staging requirement. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local RomImporter = require("src.import.RomImporter") +local RequiredImports = require("src.mods.RequiredImports") + +-- The stock headless filesystem intentionally omits love.filesystem.newFile. +-- Add the smallest streaming-file facade this transport path needs so the +-- regression drives the same seek/read/write API production LÖVE provides. +local oldNewFile = love.filesystem.newFile +local oldGetInfo = love.filesystem.getInfo +love.filesystem.newFile = function(path) + local body, pos, mode = love.filesystem.read(path) or "", 1, nil + local file = {} + function file:open(m) + mode = m + if m == "w" then body, pos = "", 1 else pos = 1 end + return true + end + function file:read(n) + local out = body:sub(pos, pos + n - 1) + pos = pos + #out + if out == "" then return nil end + return out + end + function file:write(bytes) + if mode ~= "w" then return nil, "not open for write" end + body = body:sub(1, pos - 1) .. bytes .. body:sub(pos + #bytes) + pos = pos + #bytes + love.filesystem.write(path, body) + return true + end + function file:seek(offset) pos = offset + 1 return offset end + function file:getSize() return #body end + function file:close() + if mode == "w" then love.filesystem.write(path, body) end + mode = nil + return true + end + return file +end +love.filesystem.getInfo = function(path, filter) + local info = oldGetInfo(path, filter) + if info and info.type == "file" then + local bytes = love.filesystem.read(path) or "" + return { type = "file", size = #bytes, modtime = 1 } + end + return info +end + +local source = "required_import_streaming_source.tmp" +local f = assert(io.open(source, "wb")) +f:write("abc") +f:close() + +local manifest = { + id = "stream_probe", + name = "Stream Probe", + path = "mods/stream_probe", + required_imports = { + { id = "source", name = "Source", file = "source.bin", format = "raw", + size = 3, md5 = { "900150983cd24fb0d6963f7d28e17f72" } }, + }, + optional_imports = {}, +} + +local importer = setmetatable({ + mods = { { id = "stream_probe", manifest = manifest } }, + requiredImportNotice = nil, + modNotice = nil, + _refreshMods = function(self) self._refreshed = true end, +}, RomImporter) + +-- Exercise the exact large-file branch with tiny fixture bytes by lowering the +-- threshold for this test. Production keeps the 128 MiB confirmation/streaming +-- threshold; the copy/hash algorithm is identical. +local oldWarn = RequiredImports.LARGE_WARN_BYTES +RequiredImports.LARGE_WARN_BYTES = 2 +local ok = importer:_importRequiredSource("stream_probe", "source", source, true) +RequiredImports.LARGE_WARN_BYTES = oldWarn + +T.eq(ok, true, "large raw required import streams successfully") +T.eq(love.filesystem.read("mods/stream_probe/baseroms/source.bin"), "abc", + "streamed destination preserves exact source bytes") +T.eq(importer.requiredImportNotice, nil, "successful stream leaves no import error") +T.eq(importer._refreshed, true, "successful stream refreshes the mod list") + +love.filesystem.remove("mods/stream_probe/baseroms/source.bin") +love.filesystem.remove("mods/stream_probe/baseroms/source.iso") +os.remove(source) +love.filesystem.newFile = oldNewFile +love.filesystem.getInfo = oldGetInfo + +T.finish("required_import_streaming") diff --git a/tests/engine/stream_md5_test.lua b/tests/engine/stream_md5_test.lua new file mode 100644 index 00000000..c75879b3 --- /dev/null +++ b/tests/engine/stream_md5_test.lua @@ -0,0 +1,33 @@ +-- Incremental MD5 vectors used by large required-import streaming. +package.path = "./?.lua;./?/init.lua;" .. package.path + +-- Plain Lua runners may expose bit32 while production LuaJIT exposes bit. +if not rawget(_G, "bit") and not rawget(_G, "bit32") then + local ok, bit32 = pcall(require, "bit32") + if ok then _G.bit32 = bit32 end +end + +local T = require("tests.modkit") +local MD5 = require("src.mods.StreamMD5") + +local vectors = { + { "", "d41d8cd98f00b204e9800998ecf8427e" }, + { "a", "0cc175b9c0f1b6a831c399e269772661" }, + { "abc", "900150983cd24fb0d6963f7d28e17f72" }, + { "message digest", "f96b697d7cb7938d525a2f31aaf161d0" }, + { "abcdefghijklmnopqrstuvwxyz", "c3fcd3d76192e4007dfb496cca67e13b" }, +} + +for _, row in ipairs(vectors) do + local ctx = MD5.new() + for i = 1, #row[1], 3 do ctx:update(row[1]:sub(i, i + 2)) end + T.eq(ctx:final(), row[2], "incremental MD5 vector: " .. row[1]) +end + +-- Cross a large number of block boundaries without building one giant string. +local million = MD5.new() +for _ = 1, 1000 do million:update(string.rep("a", 1000)) end +T.eq(million:final(), "7707d6ae4e027c70eea2a935c2296f21", + "RFC 1321 million-a vector") + +T.finish("stream_md5") From 5714555847d7220f30e937ba57d54d4a37c8c0c4 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Thu, 20 Aug 2026 20:12:41 -0500 Subject: [PATCH 04/15] feat: enhance Goldenrod Game Corner graphics extraction Added functions to write raw graphics assets for the Slot Machine and Card Flip games, ensuring that the necessary files are generated when the corresponding symbols are present in the ROM. Updated the manifest to include required symbols for these assets, preventing fallback to labelled cells when graphics are missing. --- src/import/RomExtractorGen2.lua | 58 +++++++++----- src/import/RomImporter.lua | 6 ++ src/ui/gen2/CardFlip.lua | 12 ++- src/ui/gen2/SlotMachine.lua | 136 +++++++++++++++++--------------- tools/make_gold_manifest.py | 8 ++ 5 files changed, 133 insertions(+), 87 deletions(-) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index d3736b8e..a044833c 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -5258,9 +5258,26 @@ function RomExtractorGen2:extractMenuGfx() end -- Goldenrod Game Corner: Slot Machine graphics assets + local CacheFs = require("src.import.CacheFs") + local function packBytes(bytes) + local chars = {} + for i = 1, #bytes do chars[i] = string.char(bytes[i]) end + return table.concat(chars) + end + local function writeRaw(relative, bytes) + local ok, writeError = CacheFs.write( + "assets/generated/" .. relative, packBytes(bytes)) + if not ok then + error("could not write " .. relative .. ": " .. tostring(writeError)) + end + end + + local slots = nil if self.symbols["Slots1LZ"] then local raw1 = self:decompressLz3Symbol("Slots1LZ") self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png") + slots = slots or {} + slots.sheet1 = "assets/generated/slots/gold_slots_1.png" end if self.symbols["Slots2LZ"] then local raw2 = self:decompressLz3Symbol("Slots2LZ") @@ -5269,6 +5286,8 @@ function RomExtractorGen2:extractMenuGfx() raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF) end self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png") + slots = slots or {} + slots.sheet2 = "assets/generated/slots/gold_slots_2.png" end if self.symbols["Slots3LZ"] then local raw3 = self:decompressLz3Symbol("Slots3LZ") @@ -5283,55 +5302,58 @@ function RomExtractorGen2:extractMenuGfx() -- Y=192: Chansey 5 (Egg Drop pose, 24x32) -- Y=224: Egg (8x16 at X=0) self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_actors.png", true) + slots = slots or {} + slots.sheet3 = "assets/generated/slots/gold_slots_3.png" end if self.symbols["SlotsTilemap"] then local symbol = self:symbol("SlotsTilemap") local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12) - self:save(tm, "slots/gold_slots.tilemap") + writeRaw("slots/gold_slots.tilemap", tm) + slots = slots or {} + slots.tilemap = "assets/generated/slots/gold_slots.tilemap" end + if slots then out.slots = slots end -- Goldenrod Game Corner: Card Flip graphics assets + local cardFlip = nil if self.symbols["CardFlipLZ01"] then local raw1 = self:decompressLz3Symbol("CardFlipLZ01") self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png") + cardFlip = cardFlip or {} + cardFlip.sheet1 = "assets/generated/card_flip/card_flip_1.png" end if self.symbols["CardFlipLZ02"] then local raw2 = self:decompressLz3Symbol("CardFlipLZ02") self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png") + cardFlip = cardFlip or {} + cardFlip.sheet2 = "assets/generated/card_flip/card_flip_2.png" end if self.symbols["CardFlipLZ03"] then local raw3 = self:decompressLz3Symbol("CardFlipLZ03") self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png") + cardFlip = cardFlip or {} + cardFlip.sheet3 = "assets/generated/card_flip/card_flip_3.png" end if self.symbols["CardFlipOnButtonGFX"] then local symbol = self:symbol("CardFlipOnButtonGFX") self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png") + cardFlip = cardFlip or {} + cardFlip.on = "assets/generated/card_flip/on.png" end if self.symbols["CardFlipOffButtonGFX"] then local symbol = self:symbol("CardFlipOffButtonGFX") self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png") + cardFlip = cardFlip or {} + cardFlip.off = "assets/generated/card_flip/off.png" end if self.symbols["CardFlipTilemap"] then local symbol = self:symbol("CardFlipTilemap") local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12) - self:save(tm, "card_flip/card_flip.tilemap") + writeRaw("card_flip/card_flip.tilemap", tm) + cardFlip = cardFlip or {} + cardFlip.tilemap = "assets/generated/card_flip/card_flip.tilemap" end - - out.slots = { - sheet1 = "assets/generated/slots/gold_slots_1.png", - sheet2 = "assets/generated/slots/gold_slots_2.png", - sheet3 = "assets/generated/slots/gold_slots_3.png", - tilemap = "assets/generated/slots/gold_slots.tilemap", - } - - out.cardFlip = { - sheet1 = "assets/generated/card_flip/card_flip_1.png", - sheet2 = "assets/generated/card_flip/card_flip_2.png", - sheet3 = "assets/generated/card_flip/card_flip_3.png", - on = "assets/generated/card_flip/on.png", - off = "assets/generated/card_flip/off.png", - tilemap = "assets/generated/card_flip/card_flip.tilemap", - } + if cardFlip then out.cardFlip = cardFlip end self:write("menu_gfx", out) self:tick("Menu graphics", 1, 1) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 3b2c2ac2..5b7fe222 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -140,6 +140,12 @@ local VERSION_REQUIRED_FILES_OVERRIDE = { -- before the four ball tiles were extracted (#1502). "assets/generated/battle/hud/balls.png", "assets/generated/audio/programs.bin", + -- Goldenrod Game Corner reel + board art (#1581). menu_gfx.lua used to + -- advertise these paths even when Slots*LZ / CardFlip* were absent from + -- the manifest, so a cache that never wrote the PNGs still looked + -- complete and SlotMachine crashed on its labelled-cell fallback. + "assets/generated/slots/gold_slots_1.png", + "assets/generated/card_flip/card_flip_1.png", }, } -- Same Gen 2 extract, so a Silver cache is complete when the same files exist. diff --git a/src/ui/gen2/CardFlip.lua b/src/ui/gen2/CardFlip.lua index a1b596ce..b9db6352 100644 --- a/src/ui/gen2/CardFlip.lua +++ b/src/ui/gen2/CardFlip.lua @@ -49,9 +49,9 @@ -- Textbox at (0,12) with an 18x4 interior -- -- The cart's own art (gfx/card_flip/card_flip_1..3.2bpp.lz and --- gfx/card_flip/card_flip.tilemap) is NOT in the cache: no `cardFlip` entry is --- written into menu_gfx.lua yet, so the board draws as labelled cells until one --- appears. +-- gfx/card_flip/card_flip.tilemap) is extracted into assets/generated/card_flip/ +-- when the Gold/Silver manifest carries CardFlip*. Until those files exist, +-- the board draws as labelled cells. local Chrome = require("src.ui.gen2.Chrome") local CoinCase = require("src.core.gen2.CoinCase") @@ -616,10 +616,8 @@ local TILEMAP = nil local function getCardFlipTilemap() if TILEMAP == nil then local path = "assets/generated/card_flip/card_flip.tilemap" - local f = io.open(path, "rb") - if f then - local data = f:read("*a") - f:close() + local data = love and love.filesystem and love.filesystem.read(path) + if data and #data > 0 then TILEMAP = {} for i = 1, #data do TILEMAP[i] = string.byte(data, i) diff --git a/src/ui/gen2/SlotMachine.lua b/src/ui/gen2/SlotMachine.lua index 83bd38fb..a85006dd 100644 --- a/src/ui/gen2/SlotMachine.lua +++ b/src/ui/gen2/SlotMachine.lua @@ -39,11 +39,9 @@ -- tiles at (2,13),(3,13),(2,14),(3,14) and the ▼ at -- (18,17) -- --- The cart's own reel art (gfx/slots/slots_1..3.2bpp.lz plus --- gfx/slots/slots.tilemap) is NOT in the cache: src/import/RomExtractorGen2.lua --- writes no `slots` entry into menu_gfx.lua yet. SlotMachine:sheet() reads one --- the moment it appears and falls back to labelled cells until then, the same --- way src/ui/gen2/PackGfx.lua degrades. +-- Reel art (gfx/slots/slots_1..3.2bpp.lz + slots.tilemap) is extracted into +-- assets/generated/slots/ when the Gold/Silver manifest carries Slots*LZ. +-- Until those files exist, drawReels falls back to labelled cells. local Chrome = require("src.ui.gen2.Chrome") local CoinCase = require("src.core.gen2.CoinCase") @@ -1160,10 +1158,8 @@ local TILEMAP = nil local function getTilemap() if TILEMAP == nil then local path = "assets/generated/slots/gold_slots.tilemap" - local f = io.open(path, "rb") - if f then - local data = f:read("*a") - f:close() + local data = love and love.filesystem and love.filesystem.read(path) + if data and #data > 0 then TILEMAP = {} for i = 1, #data do TILEMAP[i] = string.byte(data, i) @@ -1175,6 +1171,14 @@ local function getTilemap() return TILEMAP or nil end +-- Placeholder 2x2 symbol cell used when reel sheets are not in the cache yet. +local function cell(tx, ty, label) + local G = love.graphics + G.setColor(0, 0, 0, 1) + G.rectangle("line", tx * 8, ty * 8, 16, 16) + Chrome.print(label, tx, ty + 1) +end + function SlotMachine:sheets() if self.sheet1 == nil then self.sheet1 = TileSheet.new({ path = "assets/generated/slots/gold_slots_1.png", wide = 2, firstTile = 0 }) @@ -1253,36 +1257,40 @@ function SlotMachine:drawReels() -- Draw 4 consecutive 2x2 symbols from bottom to top, exactly matching SlotMachine.window for row = 0, 3 do local sym = strip[a + row + 1] - local py = 64 - (row * 16) + dy - local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0] - s2.palette = pal - - -- 2x2 tiles in 2-wide sheet: - -- sym + 0 = top-left (col 0, row 0) - -- sym + 1 = top-right (col 1, row 0) - -- sym + 2 = bottom-left (col 0, row 1) - -- sym + 3 = bottom-right (col 1, row 1) - local t0 = s2:quad(sym + 0) - local t1 = s2:quad(sym + 1) - local t2 = s2:quad(sym + 2) - local t3 = s2:quad(sym + 3) - local img = s2:image() - - if img and t0 and t1 and t2 and t3 then - local function drawSym() - G.draw(img, t0, rx, py) - G.draw(img, t1, rx + 8, py) - G.draw(img, t2, rx, py + 8) - G.draw(img, t3, rx + 8, py + 8) - end - if GbcPalette.available() then - GbcPalette.with(pal, drawSym) - else - drawSym() - end + if type(sym) ~= "number" then + cell(REEL_X[i], REEL_ROW[row + 1], "?") else - -- Fallback label - cell(REEL_X[i], REEL_ROW[row + 1], SlotMachine.LABELS[sym] or "?") + local py = 64 - (row * 16) + dy + local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0] + s2.palette = pal + + -- 2x2 tiles in 2-wide sheet: + -- sym + 0 = top-left (col 0, row 0) + -- sym + 1 = top-right (col 1, row 0) + -- sym + 2 = bottom-left (col 0, row 1) + -- sym + 3 = bottom-right (col 1, row 1) + local t0 = s2:quad(sym + 0) + local t1 = s2:quad(sym + 1) + local t2 = s2:quad(sym + 2) + local t3 = s2:quad(sym + 3) + local img = s2:image() + + if img and t0 and t1 and t2 and t3 then + local function drawSym() + G.draw(img, t0, rx, py) + G.draw(img, t1, rx + 8, py) + G.draw(img, t2, rx, py + 8) + G.draw(img, t3, rx + 8, py + 8) + end + if GbcPalette.available() then + GbcPalette.with(pal, drawSym) + else + drawSym() + end + else + -- Fallback label + cell(REEL_X[i], REEL_ROW[row + 1], SlotMachine.LABELS[sym] or "?") + end end end end @@ -1439,31 +1447,35 @@ function SlotMachine:drawMessage() and self.phase == "payoutText" then local _, s2 = self:sheets() local sym = self.matched - local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0] - s2.palette = pal - local t0 = s2:quad(sym + 0) - local t1 = s2:quad(sym + 1) - local t2 = s2:quad(sym + 2) - local t3 = s2:quad(sym + 3) - local img = s2:image() - local G = love.graphics - local px, py = PAYOUT_SYMBOL_X * 8, PAYOUT_SYMBOL_Y * 8 - if img and t0 and t1 and t2 and t3 then - local function drawWin() - G.setColor(1, 1, 1, 1) - G.draw(img, t0, px, py) - G.draw(img, t1, px + 8, py) - G.draw(img, t2, px, py + 8) - G.draw(img, t3, px + 8, py + 8) - end - G.setColor(1, 1, 1, 1) - if GbcPalette.available() then - GbcPalette.with(pal, drawWin) - else - drawWin() - end + if type(sym) ~= "number" then + cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, "?") else - cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, SlotMachine.LABELS[self.matched] or "?") + local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0] + s2.palette = pal + local t0 = s2:quad(sym + 0) + local t1 = s2:quad(sym + 1) + local t2 = s2:quad(sym + 2) + local t3 = s2:quad(sym + 3) + local img = s2:image() + local G = love.graphics + local px, py = PAYOUT_SYMBOL_X * 8, PAYOUT_SYMBOL_Y * 8 + if img and t0 and t1 and t2 and t3 then + local function drawWin() + G.setColor(1, 1, 1, 1) + G.draw(img, t0, px, py) + G.draw(img, t1, px + 8, py) + G.draw(img, t2, px, py + 8) + G.draw(img, t3, px + 8, py + 8) + end + G.setColor(1, 1, 1, 1) + if GbcPalette.available() then + GbcPalette.with(pal, drawWin) + else + drawWin() + end + else + cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, SlotMachine.LABELS[sym] or "?") + end end end end diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py index 0176f072..02828172 100644 --- a/tools/make_gold_manifest.py +++ b/tools/make_gold_manifest.py @@ -785,6 +785,14 @@ REQUIRED_SYMBOLS = { "KabutoPuzzleLZ", "OmanytePuzzleLZ", "AerodactylPuzzleLZ", "HoOhPuzzleLZ", "UnownPuzzleStartCancelLZ", "UnownPuzzleCursorGFX", "PuzzlePieceBorderData.TileBordersGFX", + # Goldenrod Game Corner (engine/games/slot_machine.asm + card_flip.asm). + # Slots1LZ/2LZ/3LZ are the reel + actor sheets; SlotsTilemap is the 20x12 + # BG map. CardFlipLZ01..03 + On/Off button tiles and CardFlipTilemap are + # the odds-board art. Without these in the manifest the extractor skips + # the files and SlotMachine/CardFlip fall back to labelled cells. + "Slots1LZ", "Slots2LZ", "Slots3LZ", "SlotsTilemap", + "CardFlipLZ01", "CardFlipLZ02", "CardFlipLZ03", + "CardFlipOnButtonGFX", "CardFlipOffButtonGFX", "CardFlipTilemap", # Emote bubbles (data/sprites/emotes.asm): showemote's ! over a trainer # who just spotted the player, and the other faces scripts use. "ShockEmote", "QuestionEmote", "HappyEmote", "SadEmote", From 780246c4f6626f09914dd5b7249481429a8798e3 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Thu, 20 Aug 2026 20:40:37 -0500 Subject: [PATCH 05/15] feat: add deinterleave function and enhance graphics extraction for Slots and Card Flip Implemented a new deinterleave function in ImageWriter to restore row-major order for PNGs. Updated RomExtractorGen2 to utilize this function for extracting Slot Machine graphics, and added new functions for padding and writing graphics sheets. Updated the ROM manifests to include necessary symbols for Slots and Card Flip assets. --- src/import/ImageWriter.lua | 20 +++++ src/import/RomExtractorGen2.lua | 149 ++++++++++++++++++++++++++++---- tools/rom_manifest_gold.json | 40 +++++++++ tools/rom_manifest_silver.json | 40 +++++++++ 4 files changed, 230 insertions(+), 19 deletions(-) diff --git a/src/import/ImageWriter.lua b/src/import/ImageWriter.lua index 9ab64fc7..3b57e347 100644 --- a/src/import/ImageWriter.lua +++ b/src/import/ImageWriter.lua @@ -125,6 +125,26 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile) return out end +-- Inverse of pret tools/gfx --interleave (pokecrystal tools/gfx.c). +-- Build-time interleave stores each vertical 8x16 pair as consecutive 8x8 +-- tiles for OBJ mode; this restores row-major sheet order for PNGs. +function ImageWriter.deinterleave(raw, width, bytesPerTile) + bytesPerTile = bytesPerTile or 16 + local widthTiles = width / 8 + local numTiles = #raw / bytesPerTile + local out = {} + for i = 0, numTiles - 1 do + local row = math.floor(i / widthTiles) + local src = i * 2 - (row % 2 == 1 + and widthTiles * (row + 1) - 1 + or widthTiles * row) + for offset = 1, bytesPerTile do + out[i * bytesPerTile + offset] = raw[src * bytesPerTile + offset] + end + end + return out +end + function ImageWriter.save(image, path) local ok, fileData = pcall(image.encode, image, "png") if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index a044833c..d2ff2435 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -5272,36 +5272,146 @@ function RomExtractorGen2:extractMenuGfx() end end + -- Canonical sheet sizes match the cart art the UI indexes (and pret's + -- gfx/slots + gfx/card_flip PNGs). ROM LZ streams are those sheets after + -- Makefile gfx transforms; reverse what the decompressed bytes still carry. + local SLOTS1_W, SLOTS1_H = 16, 152 + local SLOTS2_W, SLOTS2_H = 16, 256 + local SLOTS3_W, SLOTS3_H = 24, 240 + local CARD1_W, CARD1_H = 128, 32 + local CARD2_W, CARD2_H = 24, 160 + local CARD3_W, CARD3_H = 8, 56 + + local function pad2bpp(raw, width, height) + local need = width * height / 4 + while #raw < need do raw[#raw + 1] = 0 end + while #raw > need do table.remove(raw) end + return raw + end + + local function writeSheet(raw, width, height, relative, transparent) + self:write2bpp(pad2bpp(raw, width, height), width, height, relative, + transparent) + end + + -- Slots3LZ is unique 8x16 OBJ columns (interleave + remove-duplicates + + -- remove-xflip). Rebuild the 24x240 actor sheet the UI quads expect from + -- OAMData_SlotsGolem / Chansey* / Egg (data/sprite_anims/oam.asm), same + -- pattern as title-screen Ho-Oh frame composition above. + local function composeSlotsActors(raw) + local tileCount = math.floor(#raw / 16) + local tiles = {} + for index = 0, tileCount - 1 do + local one = {} + for b = 1, 16 do one[b] = raw[index * 16 + b] or 0 end + tiles[index] = ImageWriter.decode2bpp(one, 8, 8, true) + end + local sheet = ImageWriter.blank(SLOTS3_W, SLOTS3_H, 1, 1, 1, 0) + local function blit8x16(tileId, dx, dy, flipX) + local top, bot = tiles[tileId], tiles[tileId + 1] + if not (top and bot) then return end + ImageWriter.blit(sheet, top, dx, dy, 0, 0, 8, 8, flipX) + ImageWriter.blit(sheet, bot, dx, dy + 8, 0, 0, 8, 8, flipX) + end + local function blitPose(poseY, base, entries) + for _, e in ipairs(entries) do + blit8x16(base + e.t, (e.x + 2) * 8, poseY + (e.y + 2) * 8, e.xf) + end + end + local golem = { + { x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 }, + { x = 0, y = -2, t = 0x00, xf = true }, + { x = -2, y = 0, t = 0x04 }, { x = -1, y = 0, t = 0x06 }, + { x = 0, y = 0, t = 0x04, xf = true }, + } + local chansey = { + { + { x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 }, + { x = 0, y = -2, t = 0x04 }, + { x = -2, y = 0, t = 0x06 }, { x = -1, y = 0, t = 0x08 }, + { x = 0, y = 0, t = 0x0a }, + }, + { + { x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 }, + { x = 0, y = -2, t = 0x04 }, + { x = -2, y = 0, t = 0x0c }, { x = -1, y = 0, t = 0x0e }, + { x = 0, y = 0, t = 0x10 }, + }, + { + { x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 }, + { x = 0, y = -2, t = 0x04 }, + { x = -2, y = 0, t = 0x12 }, { x = -1, y = 0, t = 0x14 }, + { x = 0, y = 0, t = 0x16 }, + }, + { + { x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 }, + { x = 0, y = -2, t = 0x04 }, + { x = -2, y = 0, t = 0x18 }, { x = -1, y = 0, t = 0x1a }, + { x = 0, y = 0, t = 0x1c }, + }, + { + { x = -2, y = -2, t = 0x1e }, { x = -1, y = -2, t = 0x20 }, + { x = 0, y = -2, t = 0x22 }, + { x = -2, y = 0, t = 0x24 }, { x = -1, y = 0, t = 0x26 }, + { x = 0, y = 0, t = 0x28 }, + }, + } + blitPose(0, 0x00, golem) + blitPose(32, 0x08, golem) + for index, frame in ipairs(chansey) do + blitPose(32 + index * 32, 0x10, frame) + end + blit8x16(0x3a, 0, 224, false) + return sheet + end + + -- card_flip_2.2bpp uses --remove-whitespace: blank tiles in column 2 of the + -- 3-wide header strip (indices 2,5,...,23) are dropped from the ROM stream. + -- Re-insert them so HEADER_TILE_MAP / MON_ANCHORS (pret sheet indices) work. + local function expandCardFlip2(compact) + local need = CARD2_W * CARD2_H / 4 + local out = {} + for i = 1, need do out[i] = 0 end + local whitespace = { + [2] = true, [5] = true, [8] = true, [11] = true, + [14] = true, [17] = true, [20] = true, [23] = true, + } + local src = 0 + for tile = 0, 59 do + if not whitespace[tile] then + for b = 1, 16 do + out[tile * 16 + b] = compact[src * 16 + b] or 0 + end + src = src + 1 + end + end + return out + end + local slots = nil if self.symbols["Slots1LZ"] then + -- --trim-whitespace drops the final empty tile (37 of 38). local raw1 = self:decompressLz3Symbol("Slots1LZ") - self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png") + writeSheet(raw1, SLOTS1_W, SLOTS1_H, "slots/gold_slots_1.png") slots = slots or {} slots.sheet1 = "assets/generated/slots/gold_slots_1.png" end if self.symbols["Slots2LZ"] then - local raw2 = self:decompressLz3Symbol("Slots2LZ") - -- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity + local raw2 = ImageWriter.deinterleave( + self:decompressLz3Symbol("Slots2LZ"), SLOTS2_W) + -- Commercial Gold stores the Seven symbol with inverted bit polarity. for i = 1, math.min(64, #raw2) do raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF) end - self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png") + writeSheet(raw2, SLOTS2_W, SLOTS2_H, "slots/gold_slots_2.png") slots = slots or {} slots.sheet2 = "assets/generated/slots/gold_slots_2.png" end if self.symbols["Slots3LZ"] then local raw3 = self:decompressLz3Symbol("Slots3LZ") - self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true) - -- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing: - -- Y=0: Golem 1 (Standing, 24x32) - -- Y=32: Golem 2 (Ball, 24x32) - -- Y=64: Chansey 1 (Standing / Step 1, 24x32) - -- Y=96: Chansey 2 (Step 2, 24x32) - -- Y=128: Chansey 3 (Step 3, 24x32) - -- Y=160: Chansey 4 (Arm raised / Step 4, 24x32) - -- Y=192: Chansey 5 (Egg Drop pose, 24x32) - -- Y=224: Egg (8x16 at X=0) - self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_actors.png", true) + local actors = composeSlotsActors(raw3) + self:save(actors, "slots/gold_slots_3.png") + self:save(actors, "slots/gold_slots_actors.png") slots = slots or {} slots.sheet3 = "assets/generated/slots/gold_slots_3.png" end @@ -5317,20 +5427,21 @@ function RomExtractorGen2:extractMenuGfx() -- Goldenrod Game Corner: Card Flip graphics assets local cardFlip = nil if self.symbols["CardFlipLZ01"] then + -- --trim-whitespace: 62 of 64 tiles in the ROM stream. local raw1 = self:decompressLz3Symbol("CardFlipLZ01") - self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png") + writeSheet(raw1, CARD1_W, CARD1_H, "card_flip/card_flip_1.png") cardFlip = cardFlip or {} cardFlip.sheet1 = "assets/generated/card_flip/card_flip_1.png" end if self.symbols["CardFlipLZ02"] then - local raw2 = self:decompressLz3Symbol("CardFlipLZ02") - self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png") + local raw2 = expandCardFlip2(self:decompressLz3Symbol("CardFlipLZ02")) + writeSheet(raw2, CARD2_W, CARD2_H, "card_flip/card_flip_2.png") cardFlip = cardFlip or {} cardFlip.sheet2 = "assets/generated/card_flip/card_flip_2.png" end if self.symbols["CardFlipLZ03"] then local raw3 = self:decompressLz3Symbol("CardFlipLZ03") - self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png") + writeSheet(raw3, CARD3_W, CARD3_H, "card_flip/card_flip_3.png") cardFlip = cardFlip or {} cardFlip.sheet3 = "assets/generated/card_flip/card_flip_3.png" end diff --git a/tools/rom_manifest_gold.json b/tools/rom_manifest_gold.json index 2a54f118..8e191e63 100644 --- a/tools/rom_manifest_gold.json +++ b/tools/rom_manifest_gold.json @@ -17667,6 +17667,46 @@ "wBaseUnusedFrontpic": [ 1, 53554 + ], + "Slots1LZ": [ + 36, + 31138 + ], + "Slots2LZ": [ + 36, + 31522 + ], + "Slots3LZ": [ + 36, + 32130 + ], + "SlotsTilemap": [ + 36, + 30898 + ], + "CardFlipLZ01": [ + 56, + 21795 + ], + "CardFlipLZ02": [ + 56, + 22197 + ], + "CardFlipLZ03": [ + 56, + 21736 + ], + "CardFlipOnButtonGFX": [ + 56, + 21779 + ], + "CardFlipOffButtonGFX": [ + 56, + 21763 + ], + "CardFlipTilemap": [ + 56, + 22809 ] }, "tilesets": { diff --git a/tools/rom_manifest_silver.json b/tools/rom_manifest_silver.json index 2992385f..ff9b91d4 100644 --- a/tools/rom_manifest_silver.json +++ b/tools/rom_manifest_silver.json @@ -17667,6 +17667,46 @@ "wBaseUnusedFrontpic": [ 1, 53554 + ], + "Slots1LZ": [ + 36, + 31138 + ], + "Slots2LZ": [ + 36, + 31522 + ], + "Slots3LZ": [ + 36, + 32130 + ], + "SlotsTilemap": [ + 36, + 30898 + ], + "CardFlipLZ01": [ + 56, + 21795 + ], + "CardFlipLZ02": [ + 56, + 22197 + ], + "CardFlipLZ03": [ + 56, + 21736 + ], + "CardFlipOnButtonGFX": [ + 56, + 21779 + ], + "CardFlipOffButtonGFX": [ + 56, + 21763 + ], + "CardFlipTilemap": [ + 56, + 22809 ] }, "tilesets": { From c465f580060f1bd73d0a3e3d1ff994abaa8b578a Mon Sep 17 00:00:00 2001 From: HighDrexler Date: Thu, 20 Aug 2026 23:46:02 -0400 Subject: [PATCH 06/15] fix(mods): harden streamed import cleanup --- src/import/RomImporter.lua | 138 +++++++++++------- src/mods/RequiredImports.lua | 16 +- .../engine/required_import_streaming_test.lua | 68 +++++++++ 3 files changed, 171 insertions(+), 51 deletions(-) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 446bb907..f3e6a672 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -388,8 +388,16 @@ local function openImportSource(path) -- read into one Lua string before validation. local native = io.open(path, "rb") if native then - local size = native:seek("end") - if size then native:seek("set", 0) end + local size, sizeErr = native:seek("end") + if size == nil or size == false then + native:close() + return nil, sizeErr or "could not determine source file size" + end + local reset, resetErr = native:seek("set", 0) + if reset == nil or reset == false then + native:close() + return nil, resetErr or "could not rewind source file" + end return { size = size, read = function(_, n) return native:read(n) end, @@ -410,7 +418,6 @@ local function openImportSource(path) end return nil, "streaming source access is unavailable" end - local function streamRequiredImport(manifest, importId, source) local RequiredImports = require("src.mods.RequiredImports") local spec = RequiredImports.spec(manifest, importId) @@ -426,58 +433,91 @@ local function streamRequiredImport(manifest, importId, source) local CacheFs = require("src.import.CacheFs") local destination = RequiredImports.path(manifest, spec) local savedPrefix = CacheFs.prefix + local output + local inputClosed, outputClosed = false, false + + local function closeInput() + if inputClosed then return end + inputClosed = true + pcall(function() input:close() end) + end + + local function closeOutput() + if outputClosed or not output then return end + outputClosed = true + pcall(function() output:close() end) + end + + local resultOk, resultDetail CacheFs.prefix = "" - CacheFs.remove(RequiredImports.receiptPath(manifest, spec)) - CacheFs.remove(destination) - local output, makeErr = CacheFs.openWrite(destination) - if not output then - CacheFs.prefix = savedPrefix - input:close() - return nil, makeErr or "could not create imported file" - end - - local function cleanupDestination() + local ran, thrown = xpcall(function() + CacheFs.remove(RequiredImports.receiptPath(manifest, spec)) CacheFs.remove(destination) - CacheFs.prefix = savedPrefix - end - - local MD5 = require("src.mods.StreamMD5") - local md5 = MD5.new() - local total, chunkBytes = 0, 4 * 1024 * 1024 - while true do - local chunk = input:read(chunkBytes) - if not chunk or #chunk == 0 then break end - md5:update(chunk) - local wrote, writeErr = output:write(chunk) - if wrote == false or wrote == nil then - input:close(); output:close(); cleanupDestination() - return nil, "could not copy import: " .. tostring(writeErr or "write failed") + local makeErr + output, makeErr = CacheFs.openWrite(destination) + if not output then + resultDetail = makeErr or "could not create imported file" + return end - total = total + #chunk - if #chunk < chunkBytes then break end - end - input:close() - output:close() - if input.size and total ~= input.size then - cleanupDestination() - return nil, ("source read ended early (expected %d bytes, copied %d)"):format(input.size, total) - end - local storedSizeErr = RequiredImports.sizeError(spec, total, true) - if storedSizeErr then cleanupDestination(); return nil, storedSizeErr end - local digest = md5:final() - CacheFs.prefix = savedPrefix - local accepted, detail = RequiredImports.acceptStoredDigest( - manifest, importId, digest, love.filesystem) - if not accepted then - CacheFs.prefix = "" - CacheFs.remove(destination) + local MD5 = require("src.mods.StreamMD5") + local md5 = MD5.new() + local total, chunkBytes = 0, 4 * 1024 * 1024 + while true do + local chunk = input:read(chunkBytes) + if not chunk or #chunk == 0 then break end + md5:update(chunk) + local wrote, writeErr = output:write(chunk) + if wrote == false or wrote == nil then + resultDetail = "could not copy import: " +.. tostring(writeErr or "write failed") + return + end + total = total + #chunk + if #chunk < chunkBytes then break end + end + + closeInput() + closeOutput() + if input.size and total ~= input.size then + resultDetail = ("source read ended early (expected %d bytes, copied %d)") + :format(input.size, total) + return + end + local storedSizeErr = RequiredImports.sizeError(spec, total, true) + if storedSizeErr then resultDetail = storedSizeErr; return end + local digest = md5:final() + + -- acceptStoredDigest uses the normal engine path/receipt rules, so + -- restore the caller's prefix before handing control to it. CacheFs.prefix = savedPrefix - return nil, detail - end - return true, detail -end + local accepted, detail = RequiredImports.acceptStoredDigest( + manifest, importId, digest, love.filesystem) + if not accepted then resultDetail = detail; return end + resultOk, resultDetail = true, detail + end, function(err) + if debug and debug.traceback then + return debug.traceback(tostring(err), 2) + end + return tostring(err) + end) + -- finally: resource handles and the process-global CacheFs prefix must + -- be restored even when a read/hash/write helper raises a Lua error. + closeInput() + closeOutput() + CacheFs.prefix = "" + if not ran or not resultOk then + pcall(function() CacheFs.remove(destination) end) + end + CacheFs.prefix = savedPrefix + + if not ran then + return nil, "could not copy import: " .. tostring(thrown) + end + if not resultOk then return nil, resultDetail end + return true, resultDetail +end local function readDroppedFile(file) local ok, openError = file:open("r") if not ok then return nil, openError end diff --git a/src/mods/RequiredImports.lua b/src/mods/RequiredImports.lua index 25e8aa8c..cfdb9b42 100644 --- a/src/mods/RequiredImports.lua +++ b/src/mods/RequiredImports.lua @@ -228,9 +228,21 @@ function RequiredImports.acceptStoredDigest(manifest, importId, digest, fs) if sizeErr then return nil, sizeErr end if love and fs == love.filesystem then local savedPrefix = CacheFs.prefix - CacheFs.prefix = "" - CacheFs.remove(removedMarker(manifest, spec)) + local ok, prefixErr = xpcall(function() + CacheFs.prefix = "" + CacheFs.remove(removedMarker(manifest, spec)) + CacheFs.prefix = savedPrefix + -- writeReceipt has its own temporary CacheFs prefix switch. Keep it + -- inside this guard too so a write error cannot leak global state. + writeReceipt(manifest, spec, digest, info, fs) + end, function(err) + return tostring(err) + end) CacheFs.prefix = savedPrefix + if not ok then + return nil, "could not finalize import receipt: " .. tostring(prefixErr) + end + return true, digest elseif fs and fs.remove then fs.remove(removedMarker(manifest, spec)) end diff --git a/tests/engine/required_import_streaming_test.lua b/tests/engine/required_import_streaming_test.lua index ec038105..143de5c4 100644 --- a/tests/engine/required_import_streaming_test.lua +++ b/tests/engine/required_import_streaming_test.lua @@ -88,6 +88,74 @@ T.eq(love.filesystem.read("mods/stream_probe/baseroms/source.bin"), "abc", T.eq(importer.requiredImportNotice, nil, "successful stream leaves no import error") T.eq(importer._refreshed, true, "successful stream refreshes the mod list") +local CacheFs = require("src.import.CacheFs") + +-- A thrown writer error must not leak the temporary empty CacheFs prefix. +local workingNewFile = love.filesystem.newFile +local workingPrefix = CacheFs.prefix +CacheFs.prefix = "sentinel/" +love.filesystem.newFile = function(path) + local file = workingNewFile(path) + if path == "mods/stream_probe/baseroms/source.bin" then + function file:write() + error("forced writer failure") + end + end + return file +end +importer.requiredImportNotice = nil +RequiredImports.LARGE_WARN_BYTES = 2 +local failedWrite = importer:_importRequiredSource( + "stream_probe", "source", source, true) +RequiredImports.LARGE_WARN_BYTES = oldWarn +T.eq(failedWrite, nil, "thrown streaming writer error is contained") +T.eq(CacheFs.prefix, "sentinel/", + "streaming writer error restores CacheFs.prefix") +love.filesystem.newFile = workingNewFile +CacheFs.prefix = workingPrefix + +-- acceptStoredDigest has its own prefix switch for marker/receipt I/O. +-- Even an unexpected CacheFs failure must restore the caller's prefix. +love.filesystem.write("mods/stream_probe/baseroms/source.bin", "abc") +local workingRemove = CacheFs.remove +CacheFs.prefix = "sentinel/" +CacheFs.remove = function() + error("forced marker removal failure") +end +local accepted = RequiredImports.acceptStoredDigest( + manifest, "source", "900150983cd24fb0d6963f7d28e17f72", love.filesystem) +T.eq(accepted, nil, "acceptStoredDigest contains CacheFs failure") +T.eq(CacheFs.prefix, "sentinel/", + "acceptStoredDigest failure restores CacheFs.prefix") +CacheFs.remove = workingRemove +CacheFs.prefix = workingPrefix + +-- Native sources are rejected cleanly if seek-to-start fails after the +-- size probe; never return a handle left sitting at EOF. +local realIoOpen = io.open +local fakeCloses = 0 +io.open = function(path, mode) + if path ~= source then return realIoOpen(path, mode) end + local calls = 0 + return { + seek = function(_, whence) + calls = calls + 1 + if whence == "end" then return 3 end + if whence == "set" then return nil, "forced rewind failure" end + return nil, "unexpected seek" + end, + close = function() fakeCloses = fakeCloses + 1 end, + } +end +importer.requiredImportNotice = nil +RequiredImports.LARGE_WARN_BYTES = 2 +local failedSeek = importer:_importRequiredSource( + "stream_probe", "source", source, true) +RequiredImports.LARGE_WARN_BYTES = oldWarn +io.open = realIoOpen +T.eq(failedSeek, nil, "failed native rewind rejects import source") +T.ok(fakeCloses >= 2, "failed native rewind closes probed source handles") + love.filesystem.remove("mods/stream_probe/baseroms/source.bin") love.filesystem.remove("mods/stream_probe/baseroms/source.iso") os.remove(source) From 8c0d0ace4d8e132c8bbe00c55d0f71f3711b6838 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 21 Aug 2026 07:14:24 -0400 Subject: [PATCH 07/15] CLOSES #1367, CLOSES #1585, CLOSES #1611, CLOSES #1612, CLOSES #1636, CLOSES #1638, CLOSES #1641 --- main.lua | 2 + .../android/app/src/main/AndroidManifest.xml | 4 +- src/core/Game.lua | 11 +- src/core/GamepadMap.lua | 11 ++ src/core/Input.lua | 7 +- src/core/Orientation.lua | 102 ++++++++++++++---- src/core/Sound.lua | 8 ++ src/import/LauncherSettings.lua | 11 +- src/import/LauncherView.lua | 9 +- src/import/RomImporter.lua | 23 +++- src/ui/OptionsMenu.lua | 4 +- src/ui/PadCursor.lua | 4 + src/ui/gen2/EvolutionAnim.lua | 16 ++- tests/engine/launcher_scroll_test.lua | 8 +- tests/engine/orientation_option.lua | 6 +- 15 files changed, 174 insertions(+), 52 deletions(-) diff --git a/main.lua b/main.lua index 2c352977..1ffe5a89 100644 --- a/main.lua +++ b/main.lua @@ -877,6 +877,8 @@ love.handlers = love.handlers or {} function love.handlers.audiosuspend() local ChipAudio = package.loaded["src.core.ChipAudio"] if ChipAudio then pcall(ChipAudio.setSuspended, true) end + local Sound = package.loaded["src.core.Sound"] + if Sound then pcall(Sound.onDeviceReset) end end function love.handlers.audioreset() diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index a12254e6..2c7d8798 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -54,7 +54,7 @@ -- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone. -- --- SDL only re-reads the hint when the window is created or its resizable --- flag changes (SDL_androidwindow.c: Android_CreateWindow / --- Android_SetWindowResizable both call Android_JNI_SetOrientation). LOVE --- 11.5 exposes neither hints nor a resizable setter, so apply() goes through --- the FFI to SDL's C API: set the hint, then pulse the window's resizable --- flag off and back on -- each edge makes the Android backend recompute the --- requested orientation, so a change from the launcher or the OPTION menu --- takes hold immediately, and the flag ends where it started (conf.lua sets --- resizable on mobile). Everything is pcall-guarded: desktop, iOS (the --- Info.plist governs there) and headless stubs make this a no-op. +-- Android only re-reads the hint at window creation or on a resizable-flag +-- change (SDL_androidwindow.c), and SDL_SetWindowResizable early-returns on +-- a fullscreen window (SDL_video.c:2237) -- which LOVE's Android window +-- always is -- so the hint never reached a running activity (#1638). +-- apply() sets the hint for a later window, then goes over JNI for the live +-- one. iOS needs only the hint. Desktop and headless stubs no-op. local Orientation = {} @@ -58,6 +54,11 @@ function Orientation.isAndroid() return love.system.getOS() == "Android" end +function Orientation.isIOS() + if not love or not love.system or not love.system.getOS then return false end + return love.system.getOS() == "iOS" +end + function Orientation.cycle(mode, dir) local cur, idx = Orientation.normalize(mode), 1 for i, m in ipairs(Orientation.MODES) do @@ -67,6 +68,15 @@ function Orientation.cycle(mode, dir) return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1] end +-- ActivityInfo constants, what setOrientationBis lands on per hint after +-- GameActivity's *_SENSOR -> *_USER remap (#716). +local REQUESTED = { + auto = 13, + portrait = 1, + landscape = 11, + reverseLandscape = 8, +} + -- The SDL2 C API this module needs. cdef errors on redefinition, so run it -- once and remember whether it took; ffi itself may be absent (plain Lua -- test interpreters), hence the pcall'd require. @@ -77,33 +87,79 @@ local function sdlFfi() if cdefOk == nil then cdefOk = pcall(ffi.cdef, [[ typedef struct SDL_Window SDL_Window; + typedef union { int32_t i; int64_t pad; } love_jvalue; int SDL_SetHint(const char *name, const char *value); SDL_Window *SDL_GL_GetCurrentWindow(void); void SDL_SetWindowResizable(SDL_Window *window, int resizable); + void *SDL_AndroidGetJNIEnv(void); + void *SDL_AndroidGetActivity(void); ]]) end if not cdefOk then return nil end return ffi end --- Push the mode into the live activity. Returns true when the hint reached --- SDL (the symbols resolved), false on any non-Android / stubbed platform. +-- Slot numbers in JNINativeInterface (jni.h). +local JNI_EXCEPTION_CLEAR = 17 +local JNI_DELETE_LOCAL_REF = 23 +local JNI_GET_OBJECT_CLASS = 31 +local JNI_GET_METHOD_ID = 33 +local JNI_CALL_VOID_METHOD_A = 63 + +-- What Android_JNI_SetOrientation reaches, called directly: the hint path +-- cannot re-run on a live fullscreen window (SDL_video.c:2237). +local function setRequestedOrientation(ffi, requested) + local env = ffi.C.SDL_AndroidGetJNIEnv() + if env == nil then return false end + local activity = ffi.C.SDL_AndroidGetActivity() + if activity == nil then return false end + local fns = ffi.cast("void***", env)[0] + local getObjectClass = ffi.cast("void *(*)(void *, void *)", fns[JNI_GET_OBJECT_CLASS]) + local getMethodID = ffi.cast( + "void *(*)(void *, void *, const char *, const char *)", fns[JNI_GET_METHOD_ID]) + local callVoidMethodA = ffi.cast( + "void (*)(void *, void *, void *, love_jvalue *)", fns[JNI_CALL_VOID_METHOD_A]) + local deleteLocalRef = ffi.cast("void (*)(void *, void *)", fns[JNI_DELETE_LOCAL_REF]) + local exceptionClear = ffi.cast("void (*)(void *)", fns[JNI_EXCEPTION_CLEAR]) + + local ok = false + local cls = getObjectClass(env, activity) + if cls ~= nil then + local mid = getMethodID(env, cls, "setRequestedOrientation", "(I)V") + if mid ~= nil then + local args = ffi.new("love_jvalue[1]") + args[0].pad = 0 + args[0].i = requested + callVoidMethodA(env, activity, mid, args) + ok = true + end + exceptionClear(env) + deleteLocalRef(env, cls) + end + deleteLocalRef(env, activity) + return ok +end + +-- Returns true only when the request actually landed, never unconditionally +-- as it once did (#1638). function Orientation.apply(mode) - if not Orientation.isAndroid() then return false end + local android = Orientation.isAndroid() + if not (android or Orientation.isIOS()) then return false end local ffi = sdlFfi() if not ffi then return false end mode = Orientation.normalize(mode) - local ok = pcall(function() - -- "SDL_IOS_ORIENTATIONS" is SDL_HINT_ORIENTATIONS's name (SDL_hints.h); - -- despite the IOS in the string, the Android backend reads it too. + local ok, reached = pcall(function() + -- SDL_HINT_ORIENTATIONS is "SDL_IOS_ORIENTATIONS" in the SDL2 Android + -- ships and "SDL_ORIENTATIONS" in the SDL3 the iOS app links; each + -- engine ignores the other's key. ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode]) - local win = ffi.C.SDL_GL_GetCurrentWindow() - if win ~= nil then - ffi.C.SDL_SetWindowResizable(win, 0) - ffi.C.SDL_SetWindowResizable(win, 1) - end + ffi.C.SDL_SetHint("SDL_ORIENTATIONS", HINTS[mode]) + -- On iOS the hint is the lock: UIKit re-asks on every rotation + -- (SDL_uikitviewcontroller.m supportedInterfaceOrientations). + if not android then return true end + return setRequestedOrientation(ffi, REQUESTED[mode]) end) - return ok + return ok and reached == true end function Orientation.applyOptions(opts) diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 9aea0c7d..f2982353 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -154,8 +154,14 @@ local function newSfxSource(data, key, def, pitch, tempo, plain) return newFileSource(def) end +local function deviceSuspended() + local ChipAudio = package.loaded["src.core.ChipAudio"] + return ChipAudio ~= nil and ChipAudio.isSuspended() +end + local function playPath(data, key, def, pitch, tempo, plain) if not love.audio or not def then return nil end + if deviceSuspended() then return nil end local src = cache[key] if src == false then return nil end -- known bad, already logged if not src then @@ -602,6 +608,7 @@ end -- cache carries no clips (Red/Blue) or headless. function Sound.playPikaCry(data, n) if not love.audio then return nil end + if deviceSuspended() then return nil end local count = data.audio and data.audio.pikaCries if not count then return nil end n = math.max(1, math.min(count, n or 1)) @@ -633,6 +640,7 @@ end -- like the original's PlayCry -> WaitForSoundToFinish can poll it function Sound.playCry(data, species, pikaClip) if not love.audio then return nil end + if deviceSuspended() then return nil end -- Yellow voices every Pikachu cry with the PCM clips (the chip cry is -- never used for the species there). Which clip is a property of the -- call site in the original -- every caller of PlayPikachuSoundClip sets diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index b832121e..ab820348 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -303,15 +303,12 @@ local function coreRows(opts, hooks) end) end - -- ORIENTATION (#592): Android only -- the lock rides SDL's orientation - -- hint, which iOS reads only at startup (the Info.plist governs there) and - -- desktop ignores. Unlike the other launcher rows this one live-applies: - -- the window exists here too, and rotating under the player's finger is - -- the only feedback that reads. + -- ORIENTATION (#592, #1638): mobile only. Unlike the other launcher rows + -- this one live-applies: the window exists here too, and rotating under + -- the player's finger is the only feedback that reads. do - local osName = love.system and love.system.getOS and love.system.getOS() local okOr, Orientation = pcall(require, "src.core.Orientation") - if okOr and osName == "Android" then + if okOr and (Orientation.isAndroid() or Orientation.isIOS()) then add(Strings("ORIENTATION"), function() return Strings(Orientation.modeLabel(opts.orientation)) end, function(dir) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index a5d6f204..11a36a41 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -3017,9 +3017,13 @@ local function buildTextModal(imp, m, key, title, body, closeFn) local h = math.floor(math.min(m.H - 2 * m.pad, 460 * m.s)) local px, py, pw, ph = modalPanel(m, w, h) local cy = py + pad - Kit.text("button", Kit.ellipsize("button", title, pw - 2 * pad), + local xW = math.max(Kit.tapMin(), math.floor(30 * m.s)) + Kit.text("button", Kit.ellipsize("button", title, + pw - 2 * pad - xW - math.floor(8 * m.s)), px + pad, cy, PAL.heading) - cy = cy + Kit.textHeight("button") + math.floor(10 * m.s) + btn(imp, px + pw - pad - xW, cy, xW, xW, key .. "-x", "X", + { font = "small", action = closeFn }) + cy = cy + math.max(Kit.textHeight("button"), xW) + math.floor(10 * m.s) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local bodyH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s) @@ -5071,6 +5075,7 @@ function LauncherView.draw(imp) -- whole stage draws shielded (no clicks, no hover, no focus ring) while -- one is up; buildModals lowers the shield for the modal's own controls. imp._modalUpNow = modalUp(imp) + if imp._modalUpNow then imp:_blurPanelFields() end Kit.blockClicks = imp._modalUpNow local step = Kit.scrollStep(m.s) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 5b7fe222..5845f810 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2854,6 +2854,7 @@ function RomImporter:_updatePadCursor(dt) local overY = 0 if ny > oy + h then overY = ny - (oy + h) elseif ny < oy then overY = ny - oy end + if math.abs(ay) > PAD_DEAD and not self._padStickCentered then overY = 0 end if overY ~= 0 and self._flex then require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) end @@ -2913,7 +2914,11 @@ end function RomImporter:gamepadaxis(_, axis, value) if axis == "leftx" or axis == "lefty" or axis == "righty" then self._padAxis[axis] = value - if math.abs(value) > PAD_DEAD then self:_activatePadCursor() end + if math.abs(value) > PAD_DEAD then + self:_activatePadCursor() + elseif axis == "lefty" then + self._padStickCentered = true + end end end @@ -2922,18 +2927,21 @@ end -- fire the virtual cursor's click twice off one A press (#620). function RomImporter:joystickpressed(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end local padButton = GamepadMap.mapRawToGamepadButton(button) if padButton then self:gamepadpressed(joystick, padButton) end end function RomImporter:joystickreleased(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end local padButton = GamepadMap.mapRawToGamepadButton(button) if padButton then self:gamepadreleased(joystick, padButton) end end function RomImporter:joystickaxis(joystick, axis, value) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end if axis == 1 then self:gamepadaxis(joystick, "leftx", value) elseif axis == 2 then @@ -2943,6 +2951,7 @@ end function RomImporter:joystickhat(joystick, hat, direction) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end for _, dir in ipairs(self._rawHatDirs[hat] or {}) do self._padDir[dir] = nil end @@ -4003,6 +4012,18 @@ function RomImporter:_disarmTextInput() end end +function RomImporter:_blurPanelFields() + if not (self._findSearchFocus or self._skinUrlFocus) then return end + if self._indexPrompt or self._rename or self._settingsText + or self._profileSavePrompt or self._profileRenamePrompt + or (self._syncModal and self._syncFocus) then + return + end + self._findSearchFocus = false + self._skinUrlFocus = false + self:_disarmTextInput() +end + function RomImporter:_beginRename(version, id) local label for _, slot in ipairs(self.slots[version] or {}) do diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d79d2b5d..67ca0414 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -580,8 +580,8 @@ local function buildRows(game) end rows = filtered end - -- ORIENTATION only on Android, the one platform Orientation.apply reaches. - if not Orientation.isAndroid() then + -- ORIENTATION only on the platforms Orientation.apply reaches (#1638). + if not (Orientation.isAndroid() or Orientation.isIOS()) then local filtered = {} for _, row in ipairs(rows) do if row.id ~= "orientation" then filtered[#filtered + 1] = row end diff --git a/src/ui/PadCursor.lua b/src/ui/PadCursor.lua index 2286e23a..4e8ea9b2 100644 --- a/src/ui/PadCursor.lua +++ b/src/ui/PadCursor.lua @@ -151,6 +151,7 @@ end function PadCursor.joystickpressed(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return nil end + if GamepadMap.isAccelerometer(joystick) then return nil end local padButton = GamepadMap.mapRawToGamepadButton(button) if padButton then return PadCursor.gamepadpressed(joystick, padButton) end return nil @@ -158,12 +159,14 @@ end function PadCursor.joystickreleased(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end local padButton = GamepadMap.mapRawToGamepadButton(button) if padButton then PadCursor.gamepadreleased(joystick, padButton) end end function PadCursor.joystickaxis(joystick, axisIndex, value) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end if axisIndex == 1 then PadCursor.gamepadaxis(joystick, "leftx", value) elseif axisIndex == 2 then @@ -173,6 +176,7 @@ end function PadCursor.joystickhat(joystick, hat, direction) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end for _, d in ipairs(rawHatDirs[hat] or {}) do dir[d] = nil end diff --git a/src/ui/gen2/EvolutionAnim.lua b/src/ui/gen2/EvolutionAnim.lua index b3c4f02c..72a57224 100644 --- a/src/ui/gen2/EvolutionAnim.lua +++ b/src/ui/gen2/EvolutionAnim.lua @@ -252,6 +252,8 @@ function EvolutionAnim:setPhase(phase) full = self.full, }) end + local stack = self.game and self.game.stack + if stack and stack.top and stack:top() == self then stack:pop() end return end end @@ -321,8 +323,18 @@ end function EvolutionAnim:update(_dt) local input = self.game and self.game.input local phase = self.phase - -- onDone has already fired; the caller pops this state on its own beat. - if phase == "done" then return end + -- onDone has already fired. + if phase == "done" then + local stack = self.game and self.game.stack + if stack and stack.top and stack:top() == self then stack:pop() end + return + end + + if phase == "waitingLearn" then + local stack = self.game and self.game.stack + if stack and stack.top and stack:top() == self then self:nextLearn() end + return + end if phase == "flash" then return self:updateFlash(input) diff --git a/tests/engine/launcher_scroll_test.lua b/tests/engine/launcher_scroll_test.lua index b896fba3..89b48242 100644 --- a/tests/engine/launcher_scroll_test.lua +++ b/tests/engine/launcher_scroll_test.lua @@ -291,9 +291,15 @@ eq(edgeImp._pageScrollMax, 0, "with no page scroll left to catch the notch") check(ereg.y + ereg.h < 720, "and a region that ends above the safe area") edgeImp._padCursorActive = true edgeImp._padCursor = { x = ereg.x + 20, y = 719 } -edgeImp._padAxis = { lefty = 1 } edgeImp._padDir = {} pointer(ereg.x + 20, 719) +edgeImp:gamepadaxis(nil, "lefty", 1) +edgeImp:_updatePadCursor(0.5) +eq(edgeImp._wheelY or 0, 0, + "an axis that has never read under the deadzone cannot edge-scroll") +edgeImp._padCursor = { x = ereg.x + 20, y = 719 } +edgeImp:gamepadaxis(nil, "lefty", 0) +edgeImp:gamepadaxis(nil, "lefty", 1) edgeImp:_updatePadCursor(0.5) check((edgeImp._wheelY or 0) < 0, "the edge push synthesizes a notch") LauncherView.draw(edgeImp) diff --git a/tests/engine/orientation_option.lua b/tests/engine/orientation_option.lua index cb000198..5946fbfd 100644 --- a/tests/engine/orientation_option.lua +++ b/tests/engine/orientation_option.lua @@ -55,8 +55,12 @@ end love.system.getOS = function() return "OS X" end T.eq(Orientation.apply("portrait"), false, "desktop apply is a refused no-op") +-- iOS posed but no SDL loaded: the FFI path must fail closed, not claim the +-- lock took. love.system.getOS = function() return "iOS" end -T.eq(Orientation.apply("portrait"), false, "iOS defers to the Info.plist") +T.eq(Orientation.apply("portrait"), false, "iOS apply fails closed with no SDL") +local okIOS = pcall(Orientation.applyOptions, { orientation = "portrait" }) +T.eq(okIOS, true, "posed-iOS apply never raises") -- Android posed but no SDL loaded in this process: the FFI path must fail -- closed inside its pcall, never throw. love.system.getOS = function() return "Android" end From c0bd4a35be7c794cbfb2036f82069791d0b27bf2 Mon Sep 17 00:00:00 2001 From: HighDrexler Date: Fri, 21 Aug 2026 07:33:24 -0400 Subject: [PATCH 08/15] test(mods): fix streamed import rewind assertion --- tests/engine/required_import_streaming_test.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/engine/required_import_streaming_test.lua b/tests/engine/required_import_streaming_test.lua index 143de5c4..e7a446db 100644 --- a/tests/engine/required_import_streaming_test.lua +++ b/tests/engine/required_import_streaming_test.lua @@ -154,7 +154,8 @@ local failedSeek = importer:_importRequiredSource( RequiredImports.LARGE_WARN_BYTES = oldWarn io.open = realIoOpen T.eq(failedSeek, nil, "failed native rewind rejects import source") -T.ok(fakeCloses >= 2, "failed native rewind closes probed source handles") +T.eq(fakeCloses >= 2, true, + "failed native rewind closes probed source handles") love.filesystem.remove("mods/stream_probe/baseroms/source.bin") love.filesystem.remove("mods/stream_probe/baseroms/source.iso") From decaa006b2cdf67538071873777c5d5067b363dd Mon Sep 17 00:00:00 2001 From: HighDrexler Date: Fri, 21 Aug 2026 08:01:00 -0400 Subject: [PATCH 09/15] test: lock large imports to streaming path after confirmation --- tests/engine/required_import_streaming_test.lua | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/engine/required_import_streaming_test.lua b/tests/engine/required_import_streaming_test.lua index e7a446db..1de44ddf 100644 --- a/tests/engine/required_import_streaming_test.lua +++ b/tests/engine/required_import_streaming_test.lua @@ -74,6 +74,20 @@ local importer = setmetatable({ _refreshMods = function(self) self._refreshed = true end, }, RomImporter) +-- Reproduce the Windows failure seen with a 1.46 GiB optical-disc image: +-- after the user accepts the "Large import" confirmation, the old path calls +-- love.filesystem.read(source) and attempts to materialize the entire source as +-- one Lua string. A large raw source must go straight to the streaming branch. +local ordinaryLoveRead = love.filesystem.read +local forbiddenWholeSourceReads = 0 +love.filesystem.read = function(path, ...) + if path == source then + forbiddenWholeSourceReads = forbiddenWholeSourceReads + 1 + error("large external required import used whole-file love.filesystem.read") + end + return ordinaryLoveRead(path, ...) +end + -- Exercise the exact large-file branch with tiny fixture bytes by lowering the -- threshold for this test. Production keeps the 128 MiB confirmation/streaming -- threshold; the copy/hash algorithm is identical. @@ -81,8 +95,11 @@ local oldWarn = RequiredImports.LARGE_WARN_BYTES RequiredImports.LARGE_WARN_BYTES = 2 local ok = importer:_importRequiredSource("stream_probe", "source", source, true) RequiredImports.LARGE_WARN_BYTES = oldWarn +love.filesystem.read = ordinaryLoveRead T.eq(ok, true, "large raw required import streams successfully") +T.eq(forbiddenWholeSourceReads, 0, + "post-confirm large import never materializes the external source as one Lua string") T.eq(love.filesystem.read("mods/stream_probe/baseroms/source.bin"), "abc", "streamed destination preserves exact source bytes") T.eq(importer.requiredImportNotice, nil, "successful stream leaves no import error") From 48b39c8519cd8d888964f2a238cdfecc8a6b8f07 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 21 Aug 2026 08:23:21 -0400 Subject: [PATCH 10/15] CLOSES #1570 --- src/core/Game2.lua | 119 ++++++++++++++---- .../engine/gen2_controller_parity_bug1570.lua | 105 ++++++++++++++++ 2 files changed, 201 insertions(+), 23 deletions(-) create mode 100644 tests/engine/gen2_controller_parity_bug1570.lua diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 3eee6a6a..cd3bf3da 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -18,6 +18,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Clock = require("src.core.gen2.Clock") local FixedStep = require("src.core.FixedStep") local Font = require("src.render.Font") +local GamepadMap = require("src.core.GamepadMap") local Input = require("src.core.Input") local Music = require("src.core.Music") local Save = require("src.core.gen2.Save") @@ -57,20 +58,6 @@ Game2.__index = Game2 local function noop() end -for _, name in ipairs({ - "joystickpressed", "joystickreleased", "joystickaxis", "joystickhat", - "joystickadded", -}) do - Game2[name] = noop -end - --- Not a noop, because the overlay has to come back on its own: a player who --- unplugs the only controller would otherwise have to tap a blind screen to --- get the pad back (src/core/Game.lua:869 does the same). -function Game2:joystickremoved() - TouchControls:joystickremoved() -end - -- THE FRAME AND INPUT SEAMS. -- -- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad @@ -1993,6 +1980,13 @@ function Game2:applyOptions() end end +function Game2:_cycleSpeed(dir) + local GameSpeed = require("src.core.GameSpeed") + self.options.speed = GameSpeed.cycle(self.options.speed, dir) + if self.save then self.save.options = self.options end + self:persistOptions() +end + -- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS -- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been -- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written @@ -2003,27 +1997,49 @@ end -- the PACK's move-item, the party menu's reorder and half the soft-reset chord -- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button -- to find out killed the process. It reaches Input like every other button now. -function Game2:gamepadpressed(_joystick, button) +function Game2:gamepadpressed(joystick, button) -- a controller is being used: the touch overlay steps aside until the next -- screen touch (mobile only; a no-op elsewhere) TouchControls:noteGamepad() - -- The shoulders cycle GAME SPEED, as they do in the Gen 1 path. - if button == "rightshoulder" or button == "leftshoulder" then - local GameSpeed = require("src.core.GameSpeed") - local dir = button == "rightshoulder" and 1 or -1 - self.options.speed = GameSpeed.cycle(self.options.speed, dir) - if self.save then self.save.options = self.options end - self:persistOptions() + local selectHeld = Input:isDown("select") + if not selectHeld and joystick and joystick.isGamepadDown then + local ok, down = pcall(function() + return joystick:isGamepadDown("back") + end) + selectHeld = ok and down == true + end + -- shoulders and triggers cycle GAME SPEED, as in src/core/Game.lua:881 + if not selectHeld then + if button == "rightshoulder" or button == "righttrigger" then + self:_cycleSpeed(1) + return + elseif button == "leftshoulder" or button == "lefttrigger" then + self:_cycleSpeed(-1) + return + end + end + local top = self.stack and self.stack:top() + if top and top.onGamepadPressed then + top:onGamepadPressed(button) return end + if selectHeld then + local digit = GamepadMap.displayChordDigit(button) + if digit then + self:keypressed(digit) + return + end + end -- START opens the start menu in the overworld; it used to quit, from before -- there was a menu to open. - Input:gamepadpressed(_joystick, button) + Input:gamepadpressed(joystick, button) end function Game2:gamepadreleased(joystick, button) Input:gamepadreleased(joystick, button) + local top = self.stack and self.stack:top() + if top and top.onGamepadReleased then top:onGamepadReleased(button) end end function Game2:gamepadaxis(joystick, axis, value) @@ -2032,4 +2048,61 @@ function Game2:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value) end +-- The raw joystick road, same bodies as src/core/Game.lua:935 (#620, #632, #1570). +local function isRawStick(joystick) + return not (joystick and joystick.isGamepad and joystick:isGamepad()) +end + +function Game2:joystickpressed(joystick, button) + if GamepadMap.isAccelerometer(joystick) then return end + TouchControls:noteGamepad() + local top = self.stack and self.stack:top() + if isRawStick(joystick) and top and top.onJoystickPressed then + top:onJoystickPressed(button) + return + end + Input:joystickpressed(joystick, button) +end + +function Game2:joystickreleased(joystick, button) + if GamepadMap.isAccelerometer(joystick) then return end + Input:joystickreleased(joystick, button) + local top = self.stack and self.stack:top() + if isRawStick(joystick) and top and top.onJoystickReleased then + top:onJoystickReleased(button) + end +end + +function Game2:joystickaxis(joystick, axis, value) + if GamepadMap.isAccelerometer(joystick) then return end + if math.abs(value) > 0.5 then TouchControls:noteGamepad() end + Input:joystickaxis(joystick, axis, value) +end + +function Game2:joystickhat(joystick, hat, direction) + if GamepadMap.isAccelerometer(joystick) then return end + if direction ~= "c" then TouchControls:noteGamepad() end + Input:joystickhat(joystick, hat, direction) +end + +-- src/core/Game.lua:1015 (#799) +function Game2:recoverInput() + Input:reset() + Input:reconcile() + TouchControls:reset() + if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end + self:cancelPointers() +end + +function Game2:joystickadded() + self:recoverInput() +end + +-- The overlay comes back on its own when the last pad is unplugged +-- (src/core/Game.lua:1044). +function Game2:joystickremoved() + self:recoverInput() + TouchControls:joystickremoved() +end + return Game2 diff --git a/tests/engine/gen2_controller_parity_bug1570.lua b/tests/engine/gen2_controller_parity_bug1570.lua new file mode 100644 index 00000000..9fa429d7 --- /dev/null +++ b/tests/engine/gen2_controller_parity_bug1570.lua @@ -0,0 +1,105 @@ +-- Gold noop'd the raw joystick road, so a stick with no SDL game-controller-db +-- entry pressed nothing in Gold while working in Gen 1 (#1570). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +local Game = require("src.core.Game") +local Game2 = require("src.core.Game2") +local Input = require("src.core.Input") + +local CALLBACKS = { + "keypressed", "keyreleased", + "gamepadpressed", "gamepadreleased", "gamepadaxis", + "joystickpressed", "joystickreleased", "joystickaxis", "joystickhat", + "joystickadded", "joystickremoved", +} + +for _, name in ipairs(CALLBACKS) do + eq(type(Game[name]), "function", "Gen 1 routes " .. name) + eq(type(Game2[name]), "function", "Gold routes " .. name) +end + +local rawJoy = { isGamepad = function() return false end } +local gamepadJoy = { isGamepad = function() return true end } + +local function newGold(top) + return setmetatable({ + options = {}, + stack = { top = function() return top end }, + }, Game2) +end + +Input:init() +local gold = newGold(nil) + +gold:joystickpressed(rawJoy, 1) +Input:step() +check(Input:wasPressed("a"), "raw stick button 1 presses GB A in Gold") +check(Input:isDown("a"), "raw stick hold survives the step in Gold") +gold:joystickreleased(rawJoy, 1) +Input:step() +check(not Input:isDown("a"), "raw stick release clears the hold in Gold") + +gold:joystickhat(rawJoy, 1, "l") +Input:step() +check(Input:wasPressed("left"), "raw hat left presses GB LEFT in Gold") +gold:joystickhat(rawJoy, 1, "c") +Input:step() +check(not Input:isDown("left"), "raw hat centre clears GB LEFT in Gold") + +gold:joystickaxis(rawJoy, 2, 1) +Input:step() +check(Input:wasPressed("down"), "raw axis 2 presses GB DOWN in Gold") +gold:joystickaxis(rawJoy, 2, 0) +Input:step() +check(not Input:isDown("down"), "raw axis 2 back to centre clears GB DOWN") + +-- A recognized pad raises BOTH roads for one press; the raw half must not +-- re-assert the factory map underneath a rebind (#620, #632). +Input:init() +gold = newGold(nil) +gold:gamepadpressed(gamepadJoy, "a") +gold:joystickpressed(gamepadJoy, 1) +gold:joystickpressed(gamepadJoy, 2) +Input:step() +check(Input:wasPressed("a"), "recognized pad A reaches Input in Gold") +check(not Input:wasPressed("b"), "raw must not stack B onto a recognized pad") + +-- src/ui/BindingsMenu.lua's capture slots: Gold now hands the top state first +-- refusal on both roads, the way src/core/Game.lua does. +Input:init() +local captured = {} +local capturingTop = { + onJoystickPressed = function(_, button) captured.joy = button end, + onGamepadPressed = function(_, button) captured.pad = button end, +} +gold = newGold(capturingTop) +gold:joystickpressed(rawJoy, 3) +gold:gamepadpressed(gamepadJoy, "y") +Input:step() +eq(captured.joy, 3, "an armed CONTROLS row captures the raw button") +eq(captured.pad, "y", "an armed CONTROLS row captures the pad button") +check(not Input:wasPressed("a"), "a captured press never reaches gameplay") + +-- Hotplug: the reset+reconcile Gen 1 runs, so a pad that vanished mid-hold +-- cannot strand a direction down (#799). +Input:init() +gold = newGold(nil) +gold:joystickpressed(rawJoy, 1) +Input:step() +check(Input:isDown("a"), "held before the hotplug") +gold:joystickadded(rawJoy) +Input:step() +check(not Input:isDown("a"), "joystickadded drops stranded holds in Gold") + +gold:joystickpressed(rawJoy, 1) +Input:step() +gold:joystickremoved(rawJoy) +Input:step() +check(not Input:isDown("a"), "joystickremoved drops stranded holds in Gold") + +T.finish() From a56add6d177f84ca6295b0efddd62b4b2a96ea1f Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 21 Aug 2026 08:30:44 -0400 Subject: [PATCH 11/15] screen position fixes for gen 2 --- src/ui/gen2/BoxMenu.lua | 3 +-- src/ui/gen2/CardFlip.lua | 3 +-- src/ui/gen2/CenterPcMenu.lua | 3 +-- src/ui/gen2/ContestMenu.lua | 3 +-- src/ui/gen2/CopyrightSplash.lua | 3 +-- src/ui/gen2/Credits.lua | 3 +-- src/ui/gen2/DecorationMenu.lua | 3 +-- src/ui/gen2/EggHatchAnim.lua | 5 +---- src/ui/gen2/EvolutionAnim.lua | 5 +---- src/ui/gen2/GameFreakPresents.lua | 3 +-- src/ui/gen2/GoldSilverIntro.lua | 3 +-- src/ui/gen2/HallOfFame.lua | 3 +-- src/ui/gen2/ItemPcMenu.lua | 3 +-- src/ui/gen2/MailCompose.lua | 3 +-- src/ui/gen2/MailRead.lua | 3 +-- src/ui/gen2/MainMenu.lua | 3 +-- src/ui/gen2/NamePick.lua | 3 +-- src/ui/gen2/OptionsMenu.lua | 3 +-- src/ui/gen2/PackMenu.lua | 3 +-- src/ui/gen2/PartyMenu.lua | 3 +-- src/ui/gen2/PcMenu.lua | 3 +-- src/ui/gen2/PokedexMenu.lua | 3 +-- src/ui/gen2/PrizeMenu.lua | 3 +-- src/ui/gen2/SaveMenu.lua | 3 +-- src/ui/gen2/SlotMachine.lua | 3 +-- src/ui/gen2/SummaryMenu.lua | 3 +-- src/ui/gen2/TradeAnim.lua | 5 +---- src/ui/gen2/TrainerCard.lua | 3 +-- src/ui/gen2/UnownPuzzle.lua | 3 +-- tests/engine/screen_position.lua | 17 +++++++++++++++++ 30 files changed, 46 insertions(+), 64 deletions(-) diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index b019a12b..c4abcbaa 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -838,8 +838,7 @@ function BoxMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/CardFlip.lua b/src/ui/gen2/CardFlip.lua index b9db6352..88e7a170 100644 --- a/src/ui/gen2/CardFlip.lua +++ b/src/ui/gen2/CardFlip.lua @@ -972,8 +972,7 @@ function CardFlip:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/CenterPcMenu.lua b/src/ui/gen2/CenterPcMenu.lua index 49d6794a..222f7213 100644 --- a/src/ui/gen2/CenterPcMenu.lua +++ b/src/ui/gen2/CenterPcMenu.lua @@ -313,8 +313,7 @@ function CenterPcMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/ContestMenu.lua b/src/ui/gen2/ContestMenu.lua index cc309f6a..52f5a52d 100644 --- a/src/ui/gen2/ContestMenu.lua +++ b/src/ui/gen2/ContestMenu.lua @@ -180,8 +180,7 @@ function ContestMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/CopyrightSplash.lua b/src/ui/gen2/CopyrightSplash.lua index e5271b1b..abeed958 100644 --- a/src/ui/gen2/CopyrightSplash.lua +++ b/src/ui/gen2/CopyrightSplash.lua @@ -108,8 +108,7 @@ function CopyrightSplash:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/Credits.lua b/src/ui/gen2/Credits.lua index 97ca86ba..8ab716e0 100644 --- a/src/ui/gen2/Credits.lua +++ b/src/ui/gen2/Credits.lua @@ -845,8 +845,7 @@ function Credits:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/DecorationMenu.lua b/src/ui/gen2/DecorationMenu.lua index fedc2db3..f8c100f6 100644 --- a/src/ui/gen2/DecorationMenu.lua +++ b/src/ui/gen2/DecorationMenu.lua @@ -267,8 +267,7 @@ function DecorationMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/EggHatchAnim.lua b/src/ui/gen2/EggHatchAnim.lua index 6b62db6d..931fd3b4 100644 --- a/src/ui/gen2/EggHatchAnim.lua +++ b/src/ui/gen2/EggHatchAnim.lua @@ -43,8 +43,6 @@ local EggHatchAnim = {} EggHatchAnim.__index = EggHatchAnim EggHatchAnim.isOpaque = true -local SCREEN_W, SCREEN_H = 160, 144 - -- Hatch_UpdateFrontpicBGMapCenter is called twice with different hlcoords: -- the egg sits at (7,4) and the hatchling at (6,3). Both are `lb bc, 7, 7` -- PlaceGraphic boxes, and the pic inside that box has already been padded to @@ -446,8 +444,7 @@ function EggHatchAnim:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/EvolutionAnim.lua b/src/ui/gen2/EvolutionAnim.lua index 72a57224..753d2880 100644 --- a/src/ui/gen2/EvolutionAnim.lua +++ b/src/ui/gen2/EvolutionAnim.lua @@ -40,8 +40,6 @@ local EvolutionAnim = {} EvolutionAnim.__index = EvolutionAnim EvolutionAnim.isOpaque = true -local SCREEN_W, SCREEN_H = 160, 144 - -- PrepMonFrontpic's box: hlcoord 7, 2, `lb bc, 7, 7`. local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7 @@ -573,8 +571,7 @@ function EvolutionAnim:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/GameFreakPresents.lua b/src/ui/gen2/GameFreakPresents.lua index 668edfcb..7eab4456 100644 --- a/src/ui/gen2/GameFreakPresents.lua +++ b/src/ui/gen2/GameFreakPresents.lua @@ -372,8 +372,7 @@ function GameFreakPresents:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/GoldSilverIntro.lua b/src/ui/gen2/GoldSilverIntro.lua index b8eeb39f..1d6aabb3 100644 --- a/src/ui/gen2/GoldSilverIntro.lua +++ b/src/ui/gen2/GoldSilverIntro.lua @@ -1033,8 +1033,7 @@ function GoldSilverIntro:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/HallOfFame.lua b/src/ui/gen2/HallOfFame.lua index 8d593003..23ee87ce 100644 --- a/src/ui/gen2/HallOfFame.lua +++ b/src/ui/gen2/HallOfFame.lua @@ -657,8 +657,7 @@ function HallOfFame:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index 3984f95e..9ef4babe 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -592,8 +592,7 @@ function ItemPcMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/MailCompose.lua b/src/ui/gen2/MailCompose.lua index 409f6a93..4101c4c9 100644 --- a/src/ui/gen2/MailCompose.lua +++ b/src/ui/gen2/MailCompose.lua @@ -341,8 +341,7 @@ function MailCompose:drawWidescreen(winW, winH) G.setColor(1, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/MailRead.lua b/src/ui/gen2/MailRead.lua index d3b22235..b96c857a 100644 --- a/src/ui/gen2/MailRead.lua +++ b/src/ui/gen2/MailRead.lua @@ -94,8 +94,7 @@ function MailRead:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/MainMenu.lua b/src/ui/gen2/MainMenu.lua index d6f97b70..15d653ec 100644 --- a/src/ui/gen2/MainMenu.lua +++ b/src/ui/gen2/MainMenu.lua @@ -218,8 +218,7 @@ function MainMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/NamePick.lua b/src/ui/gen2/NamePick.lua index ccfe5ff8..3f2dbee6 100644 --- a/src/ui/gen2/NamePick.lua +++ b/src/ui/gen2/NamePick.lua @@ -225,8 +225,7 @@ function NamePick:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 0d3a3e11..89decadb 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -466,8 +466,7 @@ function OptionsMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 72aa6132..10384b76 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -1027,8 +1027,7 @@ function PackMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index 6cae3190..cefa59aa 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -849,8 +849,7 @@ function PartyMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/PcMenu.lua b/src/ui/gen2/PcMenu.lua index 3d283807..cf365eca 100644 --- a/src/ui/gen2/PcMenu.lua +++ b/src/ui/gen2/PcMenu.lua @@ -453,8 +453,7 @@ function PcMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index fbd14aef..a0c4a685 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -1462,8 +1462,7 @@ function PokedexMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/PrizeMenu.lua b/src/ui/gen2/PrizeMenu.lua index d3b166b3..41f43756 100644 --- a/src/ui/gen2/PrizeMenu.lua +++ b/src/ui/gen2/PrizeMenu.lua @@ -560,8 +560,7 @@ function PrizeMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua index 31811fed..c0eeb47b 100644 --- a/src/ui/gen2/SaveMenu.lua +++ b/src/ui/gen2/SaveMenu.lua @@ -228,8 +228,7 @@ function SaveMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/SlotMachine.lua b/src/ui/gen2/SlotMachine.lua index a85006dd..fe0bded1 100644 --- a/src/ui/gen2/SlotMachine.lua +++ b/src/ui/gen2/SlotMachine.lua @@ -1535,8 +1535,7 @@ function SlotMachine:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 7fdbdd74..9ccc3723 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -1175,8 +1175,7 @@ function SummaryMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/TradeAnim.lua b/src/ui/gen2/TradeAnim.lua index f0af7576..bd404038 100644 --- a/src/ui/gen2/TradeAnim.lua +++ b/src/ui/gen2/TradeAnim.lua @@ -58,8 +58,6 @@ local TradeAnimView = {} TradeAnimView.__index = TradeAnimView TradeAnimView.isOpaque = true -local SCREEN_W, SCREEN_H = 160, 144 - -- PlaceGraphic's box and the stats panel's Textbox, in tiles. local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7 local PANEL_X, PANEL_Y = 3, 0 @@ -876,8 +874,7 @@ function TradeAnimView:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/TrainerCard.lua b/src/ui/gen2/TrainerCard.lua index f3bdb4ee..4b993c52 100644 --- a/src/ui/gen2/TrainerCard.lua +++ b/src/ui/gen2/TrainerCard.lua @@ -437,8 +437,7 @@ function TrainerCard:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/UnownPuzzle.lua b/src/ui/gen2/UnownPuzzle.lua index 1b707f3b..ff8e7d8a 100644 --- a/src/ui/gen2/UnownPuzzle.lua +++ b/src/ui/gen2/UnownPuzzle.lua @@ -580,8 +580,7 @@ function UnownPuzzle:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/tests/engine/screen_position.lua b/tests/engine/screen_position.lua index c0b679c1..0616b3ca 100644 --- a/tests/engine/screen_position.lua +++ b/tests/engine/screen_position.lua @@ -110,4 +110,21 @@ eq(sy, cy2, "with a skin the Gold origin ignores the mode") TouchSkin.setActive(nil) ScreenPosition.setMode("center") +-- A hand-rolled centre in drawWidescreen skips the lift and the playfield +-- rect that Chrome.fitOrigin carries. +local listing = assert(io.popen('grep -rln "drawWidescreen" src/ui/gen2')) +local offenders = {} +for path in listing:lines() do + local f = assert(io.open(path, "r")) + local body = f:read("*a") + f:close() + if body:find("winH %- %d+ %* scale") + or body:find("winH %- SCREEN_H %* scale") then + offenders[#offenders + 1] = path + end +end +listing:close() +check(#offenders == 0, "no Gold screen centres its widescreen panel by hand: " + .. table.concat(offenders, " ")) + T.finish("screen_position") From 4b0496bad1a9110d5aad3b5be551664098c28ec0 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 21 Aug 2026 14:40:51 +0200 Subject: [PATCH 12/15] feat(mod-api): expose charge decision hook --- CONTRIBUTING-mods.md | 2 +- docs/mod-api-gen2-compat.md | 5 +- docs/modding.md | 11 + docs/rfcs/0011-battle-charge-required.md | 92 +++++++ src/battle/BattleState.lua | 12 +- src/battle/gen2/Battle.lua | 9 + tests/engine/battle_charge_required.lua | 311 +++++++++++++++++++++++ tests/engine/gate_gen2_mod_api.lua | 3 +- 8 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 docs/rfcs/0011-battle-charge-required.md create mode 100644 tests/engine/battle_charge_required.lua diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 40147fae..3030138f 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -173,7 +173,7 @@ 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 -and Silver today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1, +and Silver today (40 of the 46 registries, 40 event and 44 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 diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 0e7e7ffd..896b9187 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -24,7 +24,7 @@ The short version, for an author deciding what to write: 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 +- **40 event names and 44 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 @@ -539,7 +539,8 @@ gains a field instead of the name gaining a prefix. `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.crit`, `battle.accuracy`, `battle.charge_required`, + `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`, diff --git a/docs/modding.md b/docs/modding.md index 4016e1f1..c8807b26 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -633,6 +633,17 @@ 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. +Both battle engines expose the guarded `battle.charge_required` hook when a +charge-capable move is selected for its initial turn and the active ruleset +would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is +`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to +skip only that initial charge and continue through the ordinary move pipeline; +call `next(ctx)` to keep it. The hook does not run for the release turn or when +the active ruleset already skips charging (for example, Gold Solarbeam in +sun). PP use, accuracy, damage, animation, and secondary effects remain owned +by the engine. With no subscriber, the vanilla decision runs without building +the hook context. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload diff --git a/docs/rfcs/0011-battle-charge-required.md b/docs/rfcs/0011-battle-charge-required.md new file mode 100644 index 00000000..310bec00 --- /dev/null +++ b/docs/rfcs/0011-battle-charge-required.md @@ -0,0 +1,92 @@ +# RFC 0011: Charge-required battle hook + +## Status + +Proposed. + +## Motivation + +A battle-mechanics mod can change damage through `battle.damage` and register +move effects, but it cannot conditionally skip the first turn of an existing +charge move. In Gen 1, the engine decides and stores the charge continuation +before any public effect callback can run. Reaching into `user.charging`, +`user.chargeReady`, or generation-specific volatile state is private, +checkpoint-fragile, and would require a mod to duplicate move-pipeline policy. + +Weather is the immediate example: a portable sun rule needs Solarbeam to +resolve on selection while leaving Fly, Dig, PP use, hit resolution, animation, +and secondary effects to the engine. The capability is generic and useful to +other ruleset and move-mechanics mods. + +## Decision and plan extended + +This implements **D-AT-002: charge-stage policy remains mod authority through a +generic guarded engine decision seam**. The consuming design is tracked in the +Adaptive Trainers implementation plan, +[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md), +Task 8. The delta follows the additive, guarded hook convention documented by +Route B in `CONTRIBUTING-mods.md`; it contains no weather, move-id, trainer, or +Adaptive Trainers policy. + +## Exact API delta + +Both the Gen 1 and Gen 2 battle engines add this guarded hook: + +```lua +mod.hooks:wrap("battle.charge_required", function(next, ctx) + -- ctx = { + -- battle = live battle controller, + -- user = attacking battler, + -- target = defending battler, + -- move = merged move record, + -- charge = true, + -- isCalled = false, + -- } + if should_resolve_now(ctx) then return false end + return next(ctx) +end) +``` + +The call site is the initial-use charge decision, after announcement and PP +handling but before charge state, invulnerability, charge animation, or charge +text is created. It runs only when the active engine rules would otherwise +require a charge. It does not run on the release turn. Returning exactly +`false` skips that initial charge and continues through the engine-owned move +pipeline. Any other downstream return preserves the charge. `isCalled` is true +when Metronome or Mirror Move selected the move. + +Gold keeps its native sun decision first, so Solarbeam in native sun already +requires no charge and does not invoke the hook. Gen 1 link battles use the +shared Gen 1 move pipeline and therefore receive the same seam; normal link +mod-compatibility rules continue to govern deterministic peers. + +The hot path first calls `Runtime.wantsHook("battle.charge_required")`. With no +subscriber, no hook payload table is allocated and the existing branch runs +unchanged. + +## Migration and compatibility + +Existing mods change nothing. The hook name and payload are additive. With no +wrapper installed, Red, Blue, Yellow, Gold, and Silver retain their previous +charge state, PP use, text, animation, accuracy, damage, and native weather +behavior. Existing charge-move data and effect records require no migration. + +A mod adopting the seam should call `next(ctx)` unless it deliberately wants to +skip this charge. It should not mutate private charge fields or re-run the move. + +## Verification + +- `tests/engine/battle_charge_required.lua` exercises the real Gen 1 and Gen 2 + engines through a sandboxed public mod, including false-to-skip, next-to-keep, + release-turn behavior, called-move PP semantics, shared payload shape, and + native Gold sun behavior. +- The same test proves no-mod charge/release parity and replaces + `Runtime.call` with a sentinel behind a false `Runtime.wantsHook` guard. +- `tests/engine/gate_hooks.lua` discovers the new catalog name and proves empty + chains preserve vanilla values and allocation behavior. +- `tests/engine/gate_gen2_mod_api.lua` requires a guarded site in both + generations and keeps the compatibility reference list complete. + +## Deprecation etiquette + +Nothing is removed, renamed, superseded, or deprecated. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 4a61ef0a..3a005361 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3980,7 +3980,17 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- record (chargeText) and the invulnerability from semiInvulnerable, -- falling back to the id tables (Fly AND Dig go semi-invulnerable: -- ChargeEffect sets INVULNERABLE for both) - if record and record.charge and not releasing then + local chargeRequired = record and record.charge ~= nil and not releasing + if chargeRequired and Runtime.wantsHook("battle.charge_required") then + local required = Runtime.call("battle.charge_required", function(c) + return c.charge + end, { + battle = self, user = user, target = target, move = move, + charge = true, isCalled = isCalled or false, + }) + chargeRequired = required ~= false + end + if chargeRequired then self:cancelMoveAnim() user.charging = moveInst user.chargeReady = true diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 06d57035..9346f9dd 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -1494,6 +1494,15 @@ function Battle:useMove(attacker, defender, moveId) if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then charge = nil end + if charge and not charging and Runtime.wantsHook("battle.charge_required") then + local required = Runtime.call("battle.charge_required", function(c) + return c.charge + end, { + battle = self, user = attacker, target = defender, move = def, + charge = true, isCalled = (self.copyDepth or 0) > 0, + }) + if required == false then charge = nil end + end if charge and not charging then state.chargeMove = moveId state.vanished = charge.vanish or nil diff --git a/tests/engine/battle_charge_required.lua b/tests/engine/battle_charge_required.lua new file mode 100644 index 00000000..3fb66cc9 --- /dev/null +++ b/tests/engine/battle_charge_required.lua @@ -0,0 +1,311 @@ +-- Public, shared battle.charge_required hook. +-- +-- A mod that adds weather to Gen 1 needs to let SolarBeam resolve on the +-- turn it is selected without replacing the move, mutating private battle +-- state, or reimplementing the damage pipeline. This case loads a real +-- sandboxed mod through the public SDK and drives the real Gen 1 and Gold +-- battle engines. It also pins each generation's empty-chain decision. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") +local Font = require("src.render.Font") +local Gen2Battle = require("src.battle.gen2.Battle") +local Gen2Mon = require("src.battle.gen2.Mon") +local Pokemon = require("src.pokemon.Pokemon") +local Runtime = require("src.mods.Runtime") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") + +local function lowRoll(a) + return a or 0 +end + +local function queueHasText(battle, fragment) + for _, row in ipairs(battle.queue or {}) do + if row.text and row.text:find(fragment, 1, true) then return true end + end + return false +end + +local function eventHasText(battle, fragment) + for _, row in ipairs(battle.events or {}) do + if row.kind == "message" and row.text + and row.text:find(fragment, 1, true) then return true end + end + return false +end + +local function gen1Data() + local data = T.fixtures.fresh() + data.moves.SOLARBEAM = { + id = "SOLARBEAM", index = 80, name = "SOLARBEAM", type = "GRASS", + power = 120, accuracy = 100, pp = 10, effect = "CHARGE_EFFECT", + } + data.moves.FLY = { + id = "FLY", index = 81, name = "FLY", type = "FLYING", + power = 70, accuracy = 95, pp = 15, effect = "FLY_EFFECT", + } + data.moves.DIG = { + id = "DIG", index = 82, name = "DIG", type = "GROUND", + power = 100, accuracy = 100, pp = 10, effect = "FLY_EFFECT", + } + Font.load(data) + TypeChart.load(data) + return data +end + +local function gen1Battle(data, moveId) + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + local move = { id = moveId, pp = 10, maxPp = 10 } + save.party[1].moves = { move } + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + local game = { data = data, save = save, stack = stack, + input = { wasPressed = function() return false end, + isDown = function() return false end } } + local battle = BattleState.newWild(game, "FIXMON_B", 20) + battle.rng = lowRoll + return battle, battle.player, battle.enemy, move +end + +local G2_TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + GRASS = { id = "GRASS", index = 22, category = "special" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, + GROUND = { id = "GROUND", index = 4, category = "physical" }, +} + +local G2_MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, + type = "NORMAL", accuracy = 100, pp = 35, + effect = "EFFECT_NORMAL_HIT" }, + SOLARBEAM = { id = "SOLARBEAM", name = "SOLARBEAM", power = 120, + type = "GRASS", accuracy = 100, pp = 10, + effect = "EFFECT_SOLARBEAM" }, + FLY = { id = "FLY", name = "FLY", power = 70, type = "FLYING", + accuracy = 95, pp = 15, effect = "EFFECT_FLY" }, + DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND", + accuracy = 100, pp = 10, effect = "EFFECT_FLY" }, +} + +local G2_DATA = { + pokemon = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + MACHOP = { + id = "MACHOP", index = 66, name = "MACHOP", + baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, + levelMoves = {}, evolutions = {}, + }, + }, + moves = G2_MOVES, + type_chart = { types = G2_TYPES, matchups = {} }, + items = {}, +} + +local G2_DVS = { attack = 15, defense = 15, speed = 15, special = 15 } +G2_DVS.hp = Gen2Mon.hpDV(G2_DVS) + +local function gen2Battle(moveId, weather) + local player = Gen2Mon.new(G2_DATA, "MACHOP", 30, { dvs = G2_DVS }) + local move = { id = moveId, pp = 10, maxPp = 10 } + player.moves = { move } + local wild = Gen2Mon.new(G2_DATA, "MACHOP", 20, { dvs = G2_DVS }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Gen2Battle.new({ data = G2_DATA, party = { player }, + wild = wild, random = function(n) return math.max(0, (n or 1) - 1) end }) + battle.weather = weather + return battle, player, wild, move +end + +-- No-mod parity: Red always charges a charge-capable move on initial use, +-- spends PP only on that initial use, and resolves on the continuation. +do + local run = T.sdk.loadNone() + local battle, player, enemy, move = gen1Battle(gen1Data(), "SOLARBEAM") + local hp = enemy.mon.hp + battle:performMove(player, enemy, move) + T.eq(enemy.mon.hp, hp, "Gen 1 no-mod initial SolarBeam only charges") + T.eq(player.charging, move, "Gen 1 no-mod stores the selected move") + T.eq(move.pp, 9, "Gen 1 no-mod charge spends one PP") + T.check(queueHasText(battle, "took in sunlight"), + "Gen 1 no-mod keeps the charge text") + battle:performMove(player, enemy, move) + T.check(enemy.mon.hp < hp, "Gen 1 no-mod continuation resolves damage") + T.eq(move.pp, 9, "Gen 1 no-mod continuation spends no second PP") + run.release() +end + +-- No-mod parity: Gold's native answer remains weather-sensitive. Solarbeam +-- charges without sun and skips charge under sun. +do + local run = T.sdk.loadNone({ generation = 2 }) + local battle, player, wild, move = gen2Battle("SOLARBEAM") + local hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.eq(wild.hp, hp, "Gold no-mod initial Solarbeam charges without sun") + T.eq(player.volatile.chargeMove, "SOLARBEAM", + "Gold no-mod stores Solarbeam without sun") + T.eq(move.pp, 9, "Gold no-mod charge spends one PP") + + battle, player, wild, move = gen2Battle("SOLARBEAM", "sun") + hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, "Gold no-mod sun skips Solarbeam charge") + T.eq(player.volatile.chargeMove, nil, + "Gold no-mod sun stores no charge continuation") + T.eq(move.pp, 9, "Gold no-mod sun still spends exactly one PP") + run.release() +end + +local MOD = { + ["mods/charge_probe/manifest.json"] = [[{ + "id": "charge_probe", + "name": "Charge Required Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["all"] + }]], + ["mods/charge_probe/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("battle.charge_required", function(nextFn, ctx) + mod.exports.calls = (mod.exports.calls or 0) + 1 + mod.exports.last = { + battle = ctx.battle ~= nil, + user = ctx.user ~= nil, + target = ctx.target ~= nil, + move = ctx.move and ctx.move.id, + charge = ctx.charge, + isCalled = ctx.isCalled, + } + if ctx.move.id == "SOLARBEAM" then return false end + return nextFn(ctx) + end) + ]], +} + +-- Public mod API, Gen 1: one conditional false resolves through the ordinary +-- damage pipeline on the first turn. Fly and Dig keep their vanilla charge +-- state, invulnerability, PP, and text; the release does not call the hook. +do + local run = T.sdk.loadMods({ "mods/charge_probe" }, { + fs = T.sdk.memfs(MOD), + }) + T.eq(#run.errors, 0, + "the public charge hook mod loads clean (" .. tostring(run.errors[1]) .. ")") + local data = gen1Data() + local battle, player, enemy, move = gen1Battle(data, "SOLARBEAM") + local hp = enemy.mon.hp + battle:performMove(player, enemy, move) + T.check(enemy.mon.hp < hp, + "a public Gen 1 hook can resolve SolarBeam on its initial use") + T.eq(move.pp, 9, "the one-turn Gen 1 resolution spends one PP") + T.eq(player.charging, nil, "the bypass creates no Gen 1 continuation") + T.check(not queueHasText(battle, "took in sunlight"), + "the bypass emits no Gen 1 charge text") + + local out = run.loader.exports.charge_probe or {} + T.eq(out.calls, 1, "the public Gen 1 hook fires once on initial use") + T.same(out.last, { + battle = true, user = true, target = true, move = "SOLARBEAM", + charge = true, isCalled = false, + }, "the public Gen 1 hook receives the generation-neutral context") + + for _, id in ipairs({ "FLY", "DIG" }) do + battle, player, enemy, move = gen1Battle(data, id) + local beforeCalls = out.calls or 0 + battle:performMove(player, enemy, move) + T.eq(player.charging, move, id .. " still charges through next(ctx)") + T.eq(player.invulnerable, true, id .. " still becomes invulnerable") + T.eq(move.pp, 9, id .. " still spends one PP on its charge turn") + T.check(queueHasText(battle, id == "FLY" and "flew up" or "dug a hole"), + id .. " still emits its charge text") + T.eq(out.calls, beforeCalls + 1, id .. " calls the hook on initial use") + battle:performMove(player, enemy, move) + T.eq(out.calls, beforeCalls + 1, + id .. " release does not call the initial-use hook again") + T.eq(move.pp, 9, id .. " release spends no second PP") + end + + -- Called charge-capable moves get the same initial-use seam and say so. + battle, player, enemy, move = gen1Battle(data, "FLY") + battle:performMove(player, enemy, move, true) + out = run.loader.exports.charge_probe or {} + T.eq(out.last and out.last.isCalled, true, + "the Gen 1 context marks a called charge move") + T.eq(move.pp, 10, "a called Gen 1 charge move keeps called-move PP semantics") + run.release() +end + +-- Public mod API, Gold: the same wrapper bypasses the ordinary no-sun charge +-- branch, preserves one PP spend, and emits no charge text. +do + local run = T.sdk.loadMods({ "mods/charge_probe" }, { + fs = T.sdk.memfs(MOD), generation = 2, + }) + T.eq(#run.errors, 0, + "the shared charge hook mod loads clean on Gold") + local battle, player, wild, move = gen2Battle("SOLARBEAM") + local hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, + "the public Gold hook resolves Solarbeam on its initial use") + T.eq(move.pp, 9, "the one-turn Gold resolution spends one PP") + T.eq(player.volatile.chargeMove, nil, + "the bypass creates no Gold charge continuation") + T.check(not eventHasText(battle, "took in sunlight"), + "the bypass emits no Gold charge text") + local out = run.loader.exports.charge_probe or {} + T.same(out.last, { + battle = true, user = true, target = true, move = "SOLARBEAM", + charge = true, isCalled = false, + }, "Gold receives the same generation-neutral hook context") + + local calls = out.calls + battle, player, wild, move = gen2Battle("SOLARBEAM", "sun") + hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, + "Gold native sun still resolves before the public charge decision") + T.eq(out.calls, calls, + "the hook does not run when the active rules already skip charge") + run.release() +end + +-- Guard parity: with no subscriber, neither generation reaches Runtime.call; +-- both take their vanilla decision without constructing/dispatching a ctx. +do + local battle, player, enemy, move = gen1Battle(gen1Data(), "SOLARBEAM") + local battle2, player2, enemy2 = gen2Battle("SOLARBEAM") + local oldWants, oldCall = Runtime.wantsHook, Runtime.call + Runtime.wantsHook = function(name) + T.eq(name, "battle.charge_required", "the Gen 1 guard checks the hook name") + return false + end + Runtime.call = function() + error("unsubscribed charge hot path dispatched", 0) + end + local ok, err = pcall(battle.performMove, battle, player, enemy, move) + T.check(ok, "the guarded Gen 1 hot path does not dispatch: " .. tostring(err)) + + Runtime.wantsHook = function(name) + T.eq(name, "battle.charge_required", "the Gold guard checks the hook name") + return false + end + ok, err = pcall(battle2.useMove, battle2, player2, enemy2, "SOLARBEAM") + T.check(ok, "the guarded Gold hot path does not dispatch: " .. tostring(err)) + Runtime.wantsHook, Runtime.call = oldWants, oldCall +end + +T.finish("battle charge required") diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index beabb51c..01558e97 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -402,7 +402,8 @@ local GEN2_HOOKS = { "ui.pc.items", "ui.list_menu", "transition.style", -- battle - "battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order", + "battle.damage", "battle.crit", "battle.accuracy", + "battle.charge_required", "battle.turn_order", "battle.enemy_action", "battle.run", "battle.exp_award", "exp.gain", "catch.rate", "trainer.party", -- one wrap cancels or forces an evolution in either game: Gold passes `data` From bb0f156497e7ad73c5a0553f9b724cac3dffeb0e Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 21 Aug 2026 14:52:11 +0200 Subject: [PATCH 13/15] test(mod-api): cover Gold charge continuations --- tests/engine/battle_charge_required.lua | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/engine/battle_charge_required.lua b/tests/engine/battle_charge_required.lua index 3fb66cc9..c046639a 100644 --- a/tests/engine/battle_charge_required.lua +++ b/tests/engine/battle_charge_required.lua @@ -157,6 +157,11 @@ do T.eq(player.volatile.chargeMove, "SOLARBEAM", "Gold no-mod stores Solarbeam without sun") T.eq(move.pp, 9, "Gold no-mod charge spends one PP") + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, "Gold no-mod continuation resolves damage") + T.eq(player.volatile.chargeMove, nil, + "Gold no-mod continuation clears stored charge state") + T.eq(move.pp, 9, "Gold no-mod continuation spends no second PP") battle, player, wild, move = gen2Battle("SOLARBEAM", "sun") hp = wild.hp @@ -273,6 +278,35 @@ do }, "Gold receives the same generation-neutral hook context") local calls = out.calls + battle, player, wild, move = gen2Battle("DIG") + hp = wild.hp + battle:useMove(player, wild, "DIG") + T.eq(wild.hp, hp, "Gold next(ctx) keeps the initial charge turn") + T.eq(player.volatile.chargeMove, "DIG", + "Gold next(ctx) stores the selected charge move") + T.eq(player.volatile.vanished, true, + "Gold next(ctx) preserves semi-invulnerability") + T.eq(move.pp, 9, "Gold next(ctx) spends one PP on the charge turn") + T.eq(out.calls, calls + 1, + "Gold next(ctx) invokes the hook once on initial use") + battle:useMove(player, wild, "DIG") + T.check(wild.hp < hp, "Gold next(ctx) release resolves damage") + T.eq(out.calls, calls + 1, + "Gold release does not invoke the charge hook again") + T.eq(move.pp, 9, "Gold release spends no second PP") + + calls = out.calls + battle, player, wild, move = gen2Battle("TACKLE") + battle.copyDepth = 1 + battle:useMove(player, wild, "FLY") + T.eq(out.calls, calls + 1, "a called Gold charge move invokes the hook") + T.eq(out.last and out.last.isCalled, true, + "the Gold context marks a called charge move") + T.eq(move.pp, 10, "a called Gold move spends no known-move PP") + T.eq(player.volatile.chargeMove, "FLY", + "a called Gold charge move can keep its charge through next(ctx)") + + calls = out.calls battle, player, wild, move = gen2Battle("SOLARBEAM", "sun") hp = wild.hp battle:useMove(player, wild, "SOLARBEAM") From 5c1837b1eb61ca3501f47c443e44e8c38cd2cd53 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 21 Aug 2026 09:17:06 -0400 Subject: [PATCH 14/15] maybe smoother mobile scroll on installed mods --- src/import/LauncherView.lua | 211 +++++++++++++------------- src/import/RomImporter.lua | 16 +- tests/engine/launcher_scroll_test.lua | 29 ++++ 3 files changed, 142 insertions(+), 114 deletions(-) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 11a36a41..a3cfac09 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -2142,124 +2142,127 @@ local function buildModsPanel(imp, x, y, w, availH, m) + math.floor(8 * m.s) local listTop = cy - -- One continuous list: every row is laid out, the region scroll moves - -- through all of it, and only rows inside the region's viewport draw -- - -- so the per-frame cost stays bounded by the window, not the list. + -- One continuous list: derive the rows that can touch the viewport before + -- entering the loop. Drawing was already culled, but scanning every + -- installed row to discover that defeats the point on a large mod library. local view = imp._tabRegionRect local viewTop = view and view.y or listTop local viewBot = view and (view.y + view.h) or (listTop + availH) - for i = 1, #mods do + local stride = rowH + gap + local first = math.max(1, + math.ceil((viewTop - rowH - listTop) / stride) + 1) + local last = math.min(#mods, + math.floor((viewBot - listTop) / stride) + 1) + for i = first, last do local mod = mods[i] local ry = listTop + (i - 1) * (rowH + gap) - if ry + rowH >= viewTop and ry <= viewBot then - local rowKey = rowKeyFor(imp, "mod-row-", mod.id) - local isFullyDisabled = true - if mod.enabledByVersion then - for _, on in pairs(mod.enabledByVersion) do - if on then isFullyDisabled = false; break end - end - else - isFullyDisabled = not mod.enabled + local rowKey = rowKeyFor(imp, "mod-row-", mod.id) + local isFullyDisabled = true + if mod.enabledByVersion then + for _, on in pairs(mod.enabledByVersion) do + if on then isFullyDisabled = false; break end end + else + isFullyDisabled = not mod.enabled + end - local focused = Kit.focusable(rowKey, x, ry, w, rowH) - local hot = focused or Kit.hover(x, ry, w, rowH) - if isFullyDisabled then - Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted") - else - Kit.card(x, ry, w, rowH, hot) - end - local pad = math.floor(12 * m.s) - local px, inner = x + pad, w - 2 * pad - local ly = ry + math.floor(10 * m.s) + local focused = Kit.focusable(rowKey, x, ry, w, rowH) + local hot = focused or Kit.hover(x, ry, w, rowH) + if isFullyDisabled then + Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted") + else + Kit.card(x, ry, w, rowH, hot) + end + local pad = math.floor(12 * m.s) + local px, inner = x + pad, w - 2 * pad + local ly = ry + math.floor(10 * m.s) - local togGap = math.floor(5 * m.s) + 1 - local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) + local togGap = math.floor(5 * m.s) + 1 + local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) - -- These answer separate games, not a single shared install flag. The - -- importer receives the game id so an experimental confirmation also - -- applies only to the checkbox the player pressed. - local flipped = false - local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s) - Kit.text("micro", gamesLabel, px, - gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted) - local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s) - for _, game in ipairs(GameVersion.ORDER) do - local togKey = "mod-toggle-" .. mod.id .. "-" .. game - if modGameCheckbox(tx, gamesY, togH, - mod.enabledByVersion and mod.enabledByVersion[game] == true, - game, togKey, not safeMode) then - local version = game - queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) - flipped = true - end - tx = tx + togH + togGap + -- These answer separate games, not a single shared install flag. The + -- importer receives the game id so an experimental confirmation also + -- applies only to the checkbox the player pressed. + local flipped = false + local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s) + Kit.text("micro", gamesLabel, px, + gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted) + local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s) + for _, game in ipairs(GameVersion.ORDER) do + local togKey = "mod-toggle-" .. mod.id .. "-" .. game + if modGameCheckbox(tx, gamesY, togH, + mod.enabledByVersion and mod.enabledByVersion[game] == true, + game, togKey, not safeMode) then + local version = game + queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) + flipped = true end - -- The checkboxes sit inside the row's rect, so their press also passes the - -- row hit test; `flipped` gates the row action to everywhere else. - if not flipped - and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then - local id = mod.id - queueAction(imp, rowKey, function() imp._modActions = id end) - end - local textW = inner + tx = tx + togH + togGap + end + -- The checkboxes sit inside the row's rect, so their press also passes the + -- row hit test; `flipped` gates the row action to everywhere else. + if not flipped + and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then + local id = mod.id + queueAction(imp, rowKey, function() imp._modActions = id end) + end + local textW = inner - local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s) - -- the games the mod is for, beside its category: the same chip the - -- in-game manager shows (src/mods/ModTargets.lua) - local gamesW = mod.targets - and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0 - local nameShown = Kit.ellipsize("button", mod.name, - textW - badgeW - gamesW - math.floor(12 * m.s)) - local headingCol = isFullyDisabled and PAL.muted or PAL.heading - Kit.text("button", nameShown, px, ly, headingCol) - local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s) - Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge, - mod.experimental and PAL.yellow or PAL.muted) - if mod.targets then - Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW, - Kit.textHeight("button"), mod.targets, - mod.targetsHere == false and PAL.steel or PAL.blue) - end - ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) + local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s) + -- the games the mod is for, beside its category: the same chip the + -- in-game manager shows (src/mods/ModTargets.lua) + local gamesW = mod.targets + and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0 + local nameShown = Kit.ellipsize("button", mod.name, + textW - badgeW - gamesW - math.floor(12 * m.s)) + local headingCol = isFullyDisabled and PAL.muted or PAL.heading + Kit.text("button", nameShown, px, ly, headingCol) + local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s) + Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge, + mod.experimental and PAL.yellow or PAL.muted) + if mod.targets then + Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW, + Kit.textHeight("button"), mod.targets, + mod.targetsHere == false and PAL.steel or PAL.blue) + end + ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) - -- version + status + update state - local statusText, statusCol = modStatusColor(mod.status) - local line = "v" .. tostring(mod.version or "?") .. " " .. statusText - Kit.text("small", line, px, ly, statusCol) - local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s) - if imp:_modInfoPending(mod.id) then - -- An inline spinner, because this row's release check is genuinely in - -- flight -- the list stays usable while it resolves. - Loader.dot(lx, ly, Kit.textHeight("small")) - Kit.text("small", Strings("Checking..."), - lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted) - elseif info and info.status == "available" then - Kit.text("small", Strings("v%s available", tostring(info.latest)), - lx, ly, PAL.yellow) - elseif info and info.status == "current" then - Kit.text("small", Strings("up to date"), lx, ly, PAL.muted) - elseif info and info.status == "error" then - Kit.text("small", Strings("check failed"), lx, ly, PAL.red) - end - ly = ly + Kit.textHeight("small") + math.floor(2 * m.s) + -- version + status + update state + local statusText, statusCol = modStatusColor(mod.status) + local line = "v" .. tostring(mod.version or "?") .. " " .. statusText + Kit.text("small", line, px, ly, statusCol) + local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s) + if imp:_modInfoPending(mod.id) then + -- An inline spinner, because this row's release check is genuinely in + -- flight -- the list stays usable while it resolves. + Loader.dot(lx, ly, Kit.textHeight("small")) + Kit.text("small", Strings("Checking..."), + lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted) + elseif info and info.status == "available" then + Kit.text("small", Strings("v%s available", tostring(info.latest)), + lx, ly, PAL.yellow) + elseif info and info.status == "current" then + Kit.text("small", Strings("up to date"), lx, ly, PAL.muted) + elseif info and info.status == "error" then + Kit.text("small", Strings("check failed"), lx, ly, PAL.red) + end + ly = ly + Kit.textHeight("small") + math.floor(2 * m.s) - -- one line of description, or the download stats when we have them - -- (download count in green so popularity reads at a glance) - if info and info.downloads then - local d = info.dates - local dl = ModUpdate.downloadsLine(info.downloads.total) - local dates = ModUpdate.datesLine(d and d.first, d and d.latest) - local segs = {} - if dl then segs[#segs + 1] = { dl, PAL.green } end - if dates then - segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail } - end - segLine("small", segs, px, ly, textW) - elseif (mod.description or "") ~= "" then - Kit.text("small", Kit.ellipsize("small", mod.description, textW), - px, ly, PAL.detail) + -- one line of description, or the download stats when we have them + -- (download count in green so popularity reads at a glance) + if info and info.downloads then + local d = info.dates + local dl = ModUpdate.downloadsLine(info.downloads.total) + local dates = ModUpdate.datesLine(d and d.first, d and d.latest) + local segs = {} + if dl then segs[#segs + 1] = { dl, PAL.green } end + if dates then + segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail } end + segLine("small", segs, px, ly, textW) + elseif (mod.description or "") ~= "" then + Kit.text("small", Kit.ellipsize("small", mod.description, textW), + px, ly, PAL.detail) end end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 5845f810..88ce014e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -4154,7 +4154,6 @@ function RomImporter:_refreshMods() end self.mods = kept end - self:_syncModUpdateInfo(false) end -- Point the MODS panel at one game (or nil for all of them) and relist, so @@ -4168,15 +4167,12 @@ function RomImporter:_ensureMods() if not self.mods then self:_refreshMods() end end --- Resolve cached (or freshly fetched) GitHub status for every mod that --- declares a github field. force=true bypasses the 6h cache on every repo. --- Results live on self.modUpdateInfo[id] = { status, latest, best, releases }. --- ASYNC (was synchronous). This runs on every _refreshMods -- boot, and any --- toggle or install -- and used to make one blocking curl call per mod with a --- github field, in a loop, on the render thread. A handful of mods was a --- multi-second freeze of the whole launcher. Now each mod gets a handle and --- they resolve together across later frames; a mod whose cache is still fresh --- resolves on the first pump with no network at all. +-- Resolve GitHub status for every installed mod that declares a github field. +-- This is deliberately opt-in: only the explicit "Check for updates" action +-- calls it. Opening, scrolling, toggling, or relisting the MODS tab must not +-- create a burst of release work behind the list. force=true bypasses the 6h +-- cache on every repo. Results live on self.modUpdateInfo[id] = { +-- status, latest, best, releases } and resolve asynchronously across frames. function RomImporter:_syncModUpdateInfo(force) local ModUpdate = require("src.mods.ModUpdate") self.modUpdateInfo = self.modUpdateInfo or {} diff --git a/tests/engine/launcher_scroll_test.lua b/tests/engine/launcher_scroll_test.lua index 89b48242..a9440ced 100644 --- a/tests/engine/launcher_scroll_test.lua +++ b/tests/engine/launcher_scroll_test.lua @@ -388,6 +388,35 @@ mdown = false LauncherView.update(gameImp, 0.016) love.mouse.isDown = nil +-- Opening or relisting MODS is display work only. Release checks are an +-- explicit action, otherwise each installed GitHub mod creates background +-- work exactly when the player starts scrolling the panel. +do + local savedMods = package.loaded["src.mods.LauncherMods"] + local savedSaveData = package.loaded["src.core.SaveData"] + local listCalls, updateCalls = 0, 0 + package.loaded["src.mods.LauncherMods"] = { + list = function() + listCalls = listCalls + 1 + return { { id = "test", github = "owner/repo" } } + end, + } + package.loaded["src.core.SaveData"] = { + loadOptions = function() return {} end, + isSafeMode = function() return false end, + } + local refreshImp = setmetatable({ + modStraysChecked = true, + _syncModUpdateInfo = function() updateCalls = updateCalls + 1 end, + }, RomImporter) + refreshImp:_refreshMods() + eq(listCalls, 1, "relisting MODS still obtains its installed rows") + eq(updateCalls, 0, + "relisting MODS does not start release checks without the explicit button") + package.loaded["src.mods.LauncherMods"] = savedMods + package.loaded["src.core.SaveData"] = savedSaveData +end + local function read(path) local f = assert(io.open(path, "r")) local src = f:read("*a") From b0ff1552f29727c93ada9e73d298c755912269fc Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 21 Aug 2026 14:03:25 -0400 Subject: [PATCH 15/15] skins fixerino --- docs/skin-studio.md | 76 ++- main.lua | 25 +- src/core/Game2.lua | 6 +- src/core/Performance.lua | 7 +- src/core/ScreenPosition.lua | 5 + src/core/TouchControls.lua | 24 +- src/core/TouchSkin.lua | 59 ++- src/import/LauncherSettings.lua | 24 +- src/import/LauncherView.lua | 148 +++++- src/import/RomImporter.lua | 16 +- src/render/Zoom.lua | 34 +- src/ui/OptionsMenu.lua | 9 +- src/ui/SkinStudio.lua | 592 ++++++++++++++++++++-- src/ui/gen2/OptionsMenu.lua | 12 +- tests/drivers/skin_studio_shot.lua | 12 +- tests/drivers/touch_skin_shot.lua | 34 +- tests/engine/launcher_gold_touch_rows.lua | 9 + tests/engine/launcher_scroll_test.lua | 25 +- tests/engine/performance_tiers.lua | 6 + tests/engine/skin_studio_test.lua | 19 + tests/engine/skin_studio_ux.lua | 4 + tests/engine/touch_skin_test.lua | 37 +- tests/mod_ui_tests.lua | 9 + 23 files changed, 1065 insertions(+), 127 deletions(-) diff --git a/docs/skin-studio.md b/docs/skin-studio.md index ff487233..ecae75a7 100644 --- a/docs/skin-studio.md +++ b/docs/skin-studio.md @@ -1,11 +1,11 @@ # Touch skins and the Skin Studio A **skin** replaces the on-screen controls wholesale: a bezel image, a -control layout, and the rectangle the Game Boy screen is drawn into. Engine: +control layout, and a screen-placement anchor. Engine: `src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua` -(draw and input), `src/render/Renderer.lua` (the screen viewport), +(draw and input), `src/render/Renderer.lua` (screen placement), `src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export), -`src/ui/SkinStudio.lua` (the desktop editor). Tests: +`src/ui/SkinStudio.lua` (the responsive skin editor). Tests: `tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`, `tests/engine/skin_studio_ux.lua`, `tests/engine/skin_studio_image_import.lua`, @@ -13,8 +13,10 @@ control layout, and the rectangle the Game Boy screen is drawn into. Engine: `tests/engine/launcher_skins_tab.lua`, `tests/engine/launcher_skins_ux.lua`. -Skins are picked in the launcher's **Skins** tab, which also imports them and -opens the studio. `options.touchControls.skin` holds the folder name. +The launcher's **Skins** tab imports skins, shows the enabled skin, exports it, +and is the one place that turns skin use off. **My Skins** holds the visual +grid, pagination, edit, delete and per-skin export actions. +`options.touchControls.skin` holds the folder name. ## Formats @@ -30,7 +32,7 @@ as-is. Supported keys: | `overlays` | page count | | `overlayN_name` | page name, the target of `next_target` | | `overlayN_overlay` | bezel image | -| `overlayN_full_screen` | stretch the page to the window | +| `overlayN_full_screen` | cover the window with the page without deforming its artwork | | `overlayN_rect` | page placement, default `0,0,1,1` | | `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen | | `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults | @@ -98,8 +100,8 @@ corners fire two directions. `screens[1].outputFrame` (or the legacy `gameScreenFrame`) becomes the screen cutout. A portrait page with neither keeps `mappingSize` as the overlay aspect, sits at the bottom of the window, and puts the Game Boy picture in the leftover space above -- the -usual GBA4iOS controller-deck layout. Pages that name a screen rect still -stretch to the window the way Delta does. Host functions map to +usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the +game into it. Host functions map to engine hotkeys: `menu` to `menu_toggle`, `fastForward` to `hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`; `quickSave` and `quickLoad` have nothing to bind to and drop to decoration. @@ -135,7 +137,7 @@ to decoration and never captures a touch. As an extension to the format, `key:` presses any keyboard key, which is how a skin button reaches a mod hotkey. -## The screen viewport +## Screen placement `overlayN_viewport` is the cutout the picture is fitted into. The Game Boy screen keeps its whole-pixel scale and letterboxes inside that rect rather than @@ -146,6 +148,17 @@ that lets a widescreen bezel take the filling survey-zoom world view instead. A viewport also implies the faithful-ratio lock. Without it the world pass expands to fill the cutout and you get more map instead of a Game Boy screen. +Zoom still steps around that hole: OUT shows more map inside it, IN enlarges +the world, and the start menu stays at the hole's fit scale instead of +shrinking with the map. + +An image-backed portrait overlay that has no explicit vertical anchor is treated +as a controller deck: it is contained without deformation and pinned to the +bottom on taller screens. The spare space belongs to the game above it. + +When a skin is active, **SCREEN POS** reads **SKIN**: placement comes from the +skin rather than the normal Center / Upper / Top setting. + Border art often ships with a transparent hole and no `viewport` key. **Detect screen from bezel** in the studio measures the hole out of the art's alpha channel and writes the rect. @@ -153,10 +166,8 @@ channel and writes the rect. ## Bezels versus pads A skin whose active page binds nothing is a frame rather than a pad: a TV -surround, a handheld shell, a Super Game Boy border. Those draw on **desktop** -as well, where the touch overlay itself does not, and a gamepad does not hide -them. Anything that binds a button still follows the usual mobile / -`POKEPORT_TOUCH` rule. +surround, a handheld shell, a Super Game Boy border. Selected skins draw on +**desktop** as well as mobile; a gamepad does not hide them. ## Installing @@ -190,9 +201,20 @@ shipping branding. ## The studio -Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open -that skin. Desktop only: the launcher does not offer it on Android or iOS, -because it wants a mouse, typed coordinates and room for an inspector. +Launcher, Skins tab, **My Skins** opens the Studio library on desktop and +mobile. **My Skins** is the only visual grid: real bezel previews plus create +and import actions, with each card owning Edit, Export and (for installed +skins) Delete. Choosing Edit opens a separate, canvas-first editor; the old +New/Load workspace controls are deliberately not duplicated inside that editor. + +The editor keeps its canvas unobstructed and puts the contextual actions in a +compact lower tray: add/control binding, button and bezel artwork, pages, +screen placement, freeform/10:9 screen shape and deletion. Touches select, drag and resize the +same controls that a mouse edits on desktop. + +The launcher’s **Turn skins off** button clears the selected skin and disables +skin use. With no skin enabled, mobile falls back to the built-in pad; that pad +is not itself a skin card. **Canvas.** A mock device at a chosen preset, so a phone skin is authored at phone proportions on a desktop monitor. @@ -216,13 +238,16 @@ and of the page itself when it comes within a few pixels, and the guide it snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be typed to the coordinate its art was drawn at. **Back** and **Front** move the selection through the draw order. Bind, hitbox shape, hit reach and idle and -pressed images are per control; the bezel, the pages and the screen cutout are -per page. The cutout is itself a draggable element with a 10:9 lock. +pressed images are per control; the bezel, the pages and the screen anchor are +per page. The SCREEN anchor is itself draggable and resizable; its default +shape is freeform, with an optional 10:9 lock. **Bind** opens a grid of every bind the engine understands: the eight Game Boy -buttons, the diagonal pairs, every hotkey, a few `key:` entries, and -decoration. The COMBINE chips at the top toggle one part at a time, which is -how a pipe bind like `left|down` is built without typing it. +buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration. +The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10` +as `key:` controls, so a mobile button invokes the exact same game path as +its desktop shortcut. The COMBINE chips at the top toggle one part at a time, +which is how a pipe bind like `left|down` is built without typing it. **Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or `u` / shift+`u` without a keyboard modifier). The stack holds the last 50 @@ -247,12 +272,13 @@ the **Import** button there and beside each row opens the host file picker (`src zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the window does the same for whichever slot was last touched. A new bezel does not -move the screen cutout: press **Detect screen from bezel** to measure it out of +move the screen anchor: press **Detect screen from bezel** to measure it out of the art's alpha. -**Testing.** **Test** makes the canvas live: clicking presses real Game Boy -buttons and the footer reports what is held. **Play** saves the skin, selects -it, and boots the game with it. +**Testing.** **Test** renders a game-composition preview behind the live overlay: +the 160x144 picture letterboxes inside the screen cutout, matching gameplay. +Clicking presses real Game Boy buttons and the footer reports what is held. +**Play** saves the skin, selects it, and boots the game with it. **Saving.** **Save** writes `skins//skin.lua` and copies every image the skin names, so the folder stands alone. **Export** offers three formats, and diff --git a/main.lua b/main.lua index 1ffe5a89..c73b10ec 100644 --- a/main.lua +++ b/main.lua @@ -302,6 +302,8 @@ local function makeLauncher() forceImport = forceImport, onEditSave = openEditor, onEditTouchControls = openTouchControlsEditor, + -- Skin Studio owns a touch-first layout as well as the desktop workspace. + -- Keep the compatibility predicate so external hosts using it still work. onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop() and openSkinStudio or nil, }) @@ -931,7 +933,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure) if love.system.getOS() == "iOS" then return end return TouchEditor.touchpressed(id, x, y) end - if Studio then return end + if Studio then return Studio.touchpressed(id, x, y) end if Importer then -- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are -- polled inside the view; the istouch filter on mousepressed still drops @@ -948,7 +950,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure) if love.system.getOS() == "iOS" then return end return TouchEditor.touchmoved(id, x, y) end - if Studio then return end + if Studio then return Studio.touchmoved(id, x, y) end if Importer then return Importer:touchmoved(id, x, y, dx, dy, pressure) end @@ -962,7 +964,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure) if love.system.getOS() == "iOS" then return end return TouchEditor.touchreleased(id, x, y) end - if Studio then return end + if Studio then return Studio.touchreleased(id, x, y) end if Importer then return Importer:touchreleased(id, x, y, dx, dy, pressure) end @@ -1015,7 +1017,12 @@ function love.mousepressed(x, y, button, istouch) if love.system.getOS() == "Android" then return end return TouchEditor.mousepressed(x, y, button) end - if Studio then return Studio.mousepressed(x, y, button) end + if Studio then + -- Mobile LÖVE sends both a touch event and an `istouch` mouse twin. + -- Studio consumes the real finger stream above, so discard the twin. + if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end + return Studio.mousepressed(x, y, button) + end if Importer then -- love.touchpressed already forwards the primary touch into FlexLove for -- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both @@ -1050,7 +1057,10 @@ function love.mousereleased(x, y, button, istouch) if love.system.getOS() == "Android" then return end return TouchEditor.mousereleased(x, y, button) end - if Studio then return Studio.mousereleased(x, y, button) end + if Studio then + if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end + return Studio.mousereleased(x, y, button) + end if Importer then return end if editorMode and EditorApp.mousereleased then return EditorApp.mousereleased(x, y, button) @@ -1068,7 +1078,10 @@ function love.mousemoved(x, y, dx, dy, istouch) if love.system.getOS() == "Android" then return end return TouchEditor.mousemoved(x, y) end - if Studio then return Studio.mousemoved(x, y) end + if Studio then + if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end + return Studio.mousemoved(x, y) + end if editorMode or Importer then return end if mouseTouch then if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end diff --git a/src/core/Game2.lua b/src/core/Game2.lua index cd3bf3da..c1236a37 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1955,7 +1955,11 @@ function Game2:applyOptions() local options = self.options or {} Music.applyOptions(options) require("src.core.Sound").applyOptions(options) - require("src.render.Zoom").applyOptions(options) + local Zoom = require("src.render.Zoom") + Zoom.applyOptions(options) + local caps = require("src.core.Performance").applyOptions(options) + Zoom.allowSurvey = caps.survey + if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end require("src.render.Tilt").applyOptions(options) require("src.render.GbcPalette").applyOptions(options) -- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame. diff --git a/src/core/Performance.lua b/src/core/Performance.lua index a3d2945a..b15b520e 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -86,8 +86,11 @@ function Performance.detect() local cores = processorCount() -- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project - -- already ships a build for): the weakest target here. - if isArm and os ~= "Android" and os ~= "iOS" then + -- already ships a build for): the weakest target here. Desktop ARM + -- (Apple Silicon "OS X", Windows-on-ARM) is not a handheld — those + -- used to resolve AUTO → LOW, which stripped survey zoom-out from + -- OPTIONS so the ZOOM row only offered IN. + if isArm and os == "Linux" then return "low" end -- Phones and tablets: GBC FX is already force-disabled here (issue #136); diff --git a/src/core/ScreenPosition.lua b/src/core/ScreenPosition.lua index d4834db4..506fccf6 100644 --- a/src/core/ScreenPosition.lua +++ b/src/core/ScreenPosition.lua @@ -12,10 +12,12 @@ function ScreenPosition.normalize(v) end function ScreenPosition.label(v) + if ScreenPosition.skinActive() then return "SKIN" end return LABELS[ScreenPosition.normalize(v)] end function ScreenPosition.cycle(v, dir) + if ScreenPosition.skinActive() then return ScreenPosition.normalize(v) end v = ScreenPosition.normalize(v) local modes = ScreenPosition.MODES local cur = 1 @@ -44,6 +46,9 @@ end function ScreenPosition.skinActive(w, h) local ok, TouchSkin = pcall(require, "src.core.TouchSkin") if not ok or type(TouchSkin.viewport) ~= "function" then return false end + if (not w or not h) and love and love.graphics and love.graphics.getDimensions then + w, h = love.graphics.getDimensions() + end local okv, x = pcall(TouchSkin.viewport, w, h) return okv and x ~= nil end diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 32190f90..50c28048 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -293,7 +293,9 @@ function TouchControls:applyOptions(opts) -- launcher editor round-trips through config() (#806) self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics) TouchSkin.setOverlayLive(self.active) - self:selectSkin(cfg.skin) + -- Off means off everywhere: do not leave a hidden selected skin behind to + -- influence renderer placement on desktop or with a controller attached. + self:selectSkin(cfg.enabled and cfg.skin or nil) self.layouts = cfg.layouts self.layoutW, self.layoutH = nil, nil self.layoutOx, self.layoutOy = nil, nil @@ -335,7 +337,10 @@ function TouchControls:visible() local art = TouchSkin.active ~= nil or self.img ~= nil if self.preview then return art end if self.enabled == false or not art then return false end - if TouchSkin.active and TouchSkin.decorativeOnly() then return true end + -- A selected skin is also a desktop/TV bezel. Input remains gated in + -- touchpressed, but the artwork must not disappear when a controller is + -- connected or the platform is not touch-first. + if TouchSkin.active then return true end return self.active and not self.controllerHidden end @@ -778,12 +783,19 @@ local function drawIcon(img, zone, pressed, alphaMul) zone.cy - img:getHeight() * scale / 2, 0, scale, scale) end -local function drawStretched(img, x, y, w, h, alpha) +local function drawCovered(img, x, y, w, h, alpha) if not img or alpha <= 0 then return end local iw, ih = img:getWidth(), img:getHeight() if iw <= 0 or ih <= 0 then return end + -- Cover the assigned box with one uniform scale and crop the excess. The + -- old independent X/Y scale made portrait art visibly squash on wide + -- displays (and vice versa). + local s = math.max(w / iw, h / ih) + local dw, dh = iw * s, ih * s love.graphics.setColor(1, 1, 1, math.min(1, alpha)) - love.graphics.draw(img, x, y, 0, w / iw, h / ih) + love.graphics.setScissor(x, y, w, h) + love.graphics.draw(img, x + (w - dw) * 0.5, y + (h - dh) * 0.5, 0, s, s) + love.graphics.setScissor() end function TouchControls:drawSkin(alphaMul) @@ -795,7 +807,7 @@ function TouchControls:drawSkin(alphaMul) love.graphics.push("all") love.graphics.origin() - drawStretched(page.image, bx, by, bw, bh, opacity) + drawCovered(page.image, bx, by, bw, bh, opacity) local pressed = {} for _, touch in pairs(self.touches or {}) do @@ -810,7 +822,7 @@ function TouchControls:drawSkin(alphaMul) TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy) local alpha = opacity if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end - drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha) + drawCovered(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha) end end diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index 1b79d620..23463f50 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -636,6 +636,19 @@ local function applyPixelScale(page) return true end +-- An overlay image is its own design canvas. Older RetroArch cfg files often +-- omit `aspect_ratio`; reading the dimensions here keeps that legacy art and +-- all of its normalized controls on the same uniform scale. +function TouchSkin.applyImageAspect(page) + if not page or page.aspectFromCfg or not page.image + or not page.image.getDimensions then return false end + local iw, ih = page.image:getDimensions() + if not iw or not ih or iw <= 0 or ih <= 0 then return false end + page.aspect = iw / ih + page.aspectFromImage = true + return true +end + function TouchSkin.load(root, id) local cfgPath, format, prefix = findConfig(root) if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end @@ -665,6 +678,7 @@ function TouchSkin.load(root, id) elseif page.pdfPath then rasterizePdfPage(page, root) end + TouchSkin.applyImageAspect(page) if not applyPixelScale(page) then return nil, "could not read " .. tostring(page.imagePath) .. ", which " .. page.name .. " measures its coordinates against" @@ -771,6 +785,28 @@ function TouchSkin.find(id) return nil end +-- Remove only a user-installed skin. Bundled skins are shipped with the +-- game and intentionally have no delete affordance. +function TouchSkin.remove(id) + local entry = TouchSkin.find(id) + if not entry then return nil, "no skin " .. tostring(id) end + if entry.source ~= "user" then return nil, "bundled skins cannot be deleted" end + if not (love and love.filesystem and love.filesystem.remove) then + return nil, "no writable filesystem" + end + local function removeTree(path) + if isDir(path) and love.filesystem.getDirectoryItems then + for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do + local ok, err = removeTree(path .. "/" .. name) + if not ok then return nil, err end + end + end + local ok, err = love.filesystem.remove(path) + return ok and true or nil, err + end + return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id)) +end + function TouchSkin.assetPaths(skin) local out, seen = {}, {} local function add(rel) @@ -1284,7 +1320,7 @@ function TouchSkin.pageBox(page, w, h, ox, oy) -- full_screen means "relative to the window, not the game viewport". -- When the cfg also names an aspect_ratio, that window is then fitted -- to the overlay's design aspect so buttons do not stretch. #1503 - local fit = ((not page.fullScreen) or page.aspectFromCfg) + local fit = ((not page.fullScreen) or page.aspectFromCfg or page.aspectFromImage) and page.aspect and page.aspect > 0 and h > 0 if fit then local displayAspect = w / h @@ -1306,6 +1342,11 @@ function TouchSkin.pageBox(page, w, h, ox, oy) by = oy + extra elseif anchor == "top" then by = oy + elseif page.aspect < 1 then + -- A portrait bezel with controls is a controller deck. On an + -- unusually tall display, pin the deck to the lower edge and leave + -- the additional room for the game above it. + by = oy + extra else by = oy + extra * 0.5 end @@ -1359,8 +1400,10 @@ function TouchSkin.decorativeOnly() end function TouchSkin.drawable() - if not TouchSkin.active then return false end - return TouchSkin.overlayLive or TouchSkin.decorativeOnly() + -- A selected skin is a presentation choice, not a mobile-only input mode. + -- Its artwork and screen placement therefore belong on every platform; + -- `overlayLive` still controls whether touch input is available. + return TouchSkin.active ~= nil end function TouchSkin.hasViewport() @@ -1410,6 +1453,16 @@ function TouchSkin.pageViewport(page, w, h, ox, oy) return nil end +-- Centre of the page's screen cutout. The renderer fits the 160x144 +-- picture into that rect; this helper is for the studio preview. +function TouchSkin.screenCenter(w, h, ox, oy, page) + page = page or TouchSkin.page() + if not page then return nil end + local x, y, vw, vh = TouchSkin.pageViewport(page, w, h, ox, oy) + if not x then return nil end + return x + vw * 0.5, y + vh * 0.5 +end + function TouchSkin.viewport(w, h, ox, oy) local page = TouchSkin.page() if not page or not TouchSkin.drawable() then return nil end diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index ab820348..7d828cab 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -6,8 +6,8 @@ -- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the -- next boot's applyOptions pick the values up. Every ladder mirrors -- OptionsMenu's semantics and stored values; when editing one, keep the two --- in sync. ZOOM is deliberately absent: its range depends on the live --- renderer's fit scale (Renderer:fitScale), which does not exist here. +-- in sync. ZOOM uses the live window's integer fit (same 160×144 rule as +-- Renderer:fitScale) so the row can offer OUT/FIT/IN without a running game. -- -- Rows are the same descriptor idiom OptionRows draws in game: -- { label, value = fn() -> string, step = fn(dir) -> changed, @@ -278,6 +278,16 @@ local function coreRows(opts, hooks) end) end + local okZ, Zoom = pcall(require, "src.render.Zoom") + if okZ then + add(Strings("ZOOM"), + function() return Zoom.offsetLabel(opts.zoom or 0) end, + function(dir) + Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale()) + return true + end) + end + local okTile, TileRenderer = pcall(require, "src.render.TileRenderer") if okTile and TileRenderer.VOID_FILLS then add(Strings("VOID FILL"), @@ -643,6 +653,16 @@ local function gen2Rows(opts, hooks) end) end + local okZ, Zoom = pcall(require, "src.render.Zoom") + if okZ then + add(Strings("ZOOM"), + function() return Zoom.offsetLabel(opts.zoom or 0) end, + function(dir) + Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale()) + return true + end) + end + local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill") if okFill and BorderFill.VOID_FILLS then add(Strings("VOID FILL"), diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index a3cfac09..860b5e8b 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -753,12 +753,15 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) -- and one below the top-right corner notch, like the DMG cart. local grooveW = w * 0.115 local grooveH = math.max(1, h * 0.009) + local grooveScale = { 1.22, 1.10, 1.00, 1.00, 1.10, 1.22 } + local grooveInset = w * 0.02 for i = 0, 5 do local ry = mainTop + h * 0.014 + i * h * 0.021 - cartPolygon(cartQuad(project, -halfW + w * 0.02, ry, - grooveW, grooveH, faceZ), side, 0.7) - cartPolygon(cartQuad(project, halfW - grooveW - w * 0.02, ry, - grooveW, grooveH, faceZ), side, 0.7) + local gw = grooveW * grooveScale[i + 1] + cartPolygon(cartQuad(project, -halfW + grooveInset, ry, + gw, grooveH, faceZ), side, 0.7) + cartPolygon(cartQuad(project, halfW - gw - grooveInset, ry, + gw, grooveH, faceZ), side, 0.7) end -- The thin diagonal mold ridge cut into each long side a little below -- the grip grooves, mirrored left/right. @@ -774,12 +777,14 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) { project(x1 + nx * t, y1 + ny * t, faceZ) }, }, side, 0.7) end - local dgY = mainTop + h * 0.20 + local dgY = mainTop + h * 0.25 diagonal(-halfW + w * 0.006, dgY, -halfW + w * 0.085, dgY + h * 0.038) diagonal(halfW - w * 0.006, dgY, halfW - w * 0.085, dgY + h * 0.038) - -- The Nintendo GAME BOY recess: one stadium pill sunk into the shell. - local pillX, pillW = -halfW + w * 0.17, w * 0.62 - local pillY, pillH = mainTop + h * 0.024, h * 0.115 + -- The pill recess: one stadium pill sunk into the shell. + local pillX, pillW = -halfW + w * 0.19, w * 0.62 + local pillY, pillH = mainTop + h * 0.015, h * 0.115 + -- log out pillH + cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55) local inX, inY = w * 0.008, h * 0.008 cartPill(project, pillX + inX, pillY + inY, @@ -2272,8 +2277,8 @@ end -- ---------------------------------------------------------- find mods panel --- SKINS tab: pick the on-screen skin, import one, or open the desktop studio. -local function buildSkinsPanel(imp, x, y, w, availH, m) +-- SKINS tab: pick the on-screen skin, import one, or open Skin Studio. +local function buildSkinsPanelLegacy(imp, x, y, w, availH, m) local skins = imp:_ensureSkins() local active = imp:_activeSkin() local gap = m.gap @@ -2327,7 +2332,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) end cy = cy + urlH + math.floor(8 * m.s) - -- Studio button. Desktop only: the host supplies the hook nowhere else. + -- The Studio reflows to a touch-first canvas plus inspector on phones. if imp.onOpenSkinStudio then local label = Strings("Open Skin Studio") local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s)) @@ -2347,6 +2352,64 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) Kit.caption(x, cy, Strings("INSTALLED")) cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) + -- Keep the actionable list below for detailed metadata and exports, but + -- lead with a visual picker: skins are much easier to recognize by their + -- bezel than by a folder name. Cards use the same loaded art that the + -- runtime draws, so they cannot drift from the selected skin. + local previewGap = math.floor(8 * m.s) + local previewCols = w >= math.floor(420 * m.s) and 2 or 1 + local previewW = (w - previewGap * (previewCols - 1)) / previewCols + local previewH = math.max(108 * m.s, Kit.tapMin() * 2) + local previewCount = #skins + for i, entry in ipairs(skins) do + local n = i - 1 + local px = x + (n % previewCols) * (previewW + previewGap) + local py = cy + math.floor(n / previewCols) * (previewH + previewGap) + local key = "skin-preview-" .. entry.id + local selected = active == entry.id + local focused = Kit.focusable(key, px, py, previewW, previewH) + Kit.card(px, py, previewW, previewH, selected and "selected" + or (focused or Kit.hover(px, py, previewW, previewH))) + local pad = math.floor(8 * m.s) + local artH = math.floor(previewH * 0.60) + Theme.fillRounded(px + pad, py + pad, previewW - pad * 2, artH, + PAL.bg, 1, Theme.cardRadius() * 0.6) + local art = entry.preview + if art and art.getDimensions then + local iw, ih = art:getDimensions() + if iw > 0 and ih > 0 then + local scale = math.min((previewW - pad * 4) / iw, (artH - pad * 2) / ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(art, px + (previewW - iw * scale) * 0.5, + py + pad + (artH - ih * scale) * 0.5, 0, scale, scale) + end + else + Theme.strokeRounded(px + previewW * 0.23, py + pad + artH * 0.15, + previewW * 0.54, artH * 0.42, PAL.line, Theme.A.hairline, 1, 2) + Theme.fillRounded(px + previewW * 0.20, py + pad + artH * 0.64, + previewW * 0.22, artH * 0.17, PAL.steel, 0.75, 3) + Theme.fillRounded(px + previewW * 0.60, py + pad + artH * 0.61, + previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11) + Theme.fillRounded(px + previewW * 0.75, py + pad + artH * 0.56, + previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11) + end + Kit.text("mono", Kit.ellipsize("mono", entry.id, previewW - pad * 2), + px + pad, py + pad + artH + math.floor(5 * m.s), + selected and PAL.green or PAL.heading) + if selected then + Kit.text("micro", Strings("IN USE"), px + pad, + py + previewH - pad - Kit.textHeight("micro"), PAL.green) + end + if Kit.press(px, py, previewW, previewH) or Kit._activateId == key then + queueAction(imp, key, function() imp:_useSkin(entry.id) end) + end + end + if previewCount > 0 then + cy = cy + math.ceil(previewCount / previewCols) * previewH + + math.max(0, math.ceil(previewCount / previewCols) - 1) * previewGap + + gap + end + local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s)) imp._skinGear = imp._skinGear or love.graphics.newImage("assets/launcher/gear.png") @@ -2460,6 +2523,69 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) return cy + hintH - y end +-- The launcher is the short path: bring a skin in, see what is enabled, or +-- turn skin use off. Browsing, pagination and per-skin editing/export live +-- together in My Skins, where they are useful instead of competing here. +local function buildSkinsPanel(imp, x, y, w, availH, m) + local cy, gap, bh = y, m.gap, m.btnH + local active = imp:_activeSkin() + + Kit.text("button", Strings("Skins"), x, cy, PAL.heading) + local importW = math.min(w * 0.46, + Kit.textWidth("small", imp:_skinsImportButtonLabel()) + math.floor(24 * m.s)) + btn(imp, x + w - importW, cy, importW, bh, "skins-import", + imp:_skinsImportButtonLabel(), { kind = "accent", font = "small", + action = function() imp:chooseSkin() end }) + cy = cy + bh + gap + + if imp._skinNotice then + cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w, + imp._skinNotice.ok and PAL.green or PAL.red, 2) + gap + end + + local addW = Kit.textWidth("small", Strings("Add")) + math.floor(24 * m.s) + if imp._skinFetch then + Loader.inline(x, cy, w, bh, Strings("Downloading %s...", + tostring(imp._skinFetch.name or ""))) + else + btn(imp, x + w - addW, cy, addW, bh, "skins-url-add", Strings("Add"), { + kind = "accent", font = "small", action = function() imp:_addSkinFromUrl() end }) + textField(imp, x, cy, w - addW - gap, bh, "skins-url", imp.skinUrl or "", + Strings("Paste a skin link (.zip, .cfg, .deltaskin)"), + imp._skinUrlFocus == true, function() imp:_toggleSkinUrlFocus() end) + end + cy = cy + bh + gap + + local currentH = active and (bh * 2 + gap * 2) or (bh + gap * 2) + Kit.card(x, cy, w, currentH) + Kit.caption(x + gap, cy + gap, "CURRENT SKIN") + local current = active and tostring(active) or Strings("No skin enabled") + Kit.text("mono", Kit.ellipsize("mono", current, w - gap * 2), x + gap, + cy + gap + Kit.textHeight("small") + math.floor(4 * m.s), + active and PAL.green or PAL.muted) + local buttonY = cy + bh + gap + local half = (w - gap * 3) * 0.5 + if active then + btn(imp, x + gap, buttonY, half, bh, "skins-export-current", + Strings("Export current"), { font = "small", + action = function() imp:_exportSkin(active, "native") end }) + btn(imp, x + gap * 2 + half, buttonY, half, bh, "skins-off", + Strings("Turn skins off"), { kind = "danger", font = "small", + action = function() imp:_disableSkins() end }) + end + cy = cy + currentH + gap + + if imp.onOpenSkinStudio then + btn(imp, x, cy, w, bh, "skins-my-skins", Strings("My Skins"), { + kind = "accent", font = "small", + action = function() imp.onOpenSkinStudio(imp.modScope or "red", active) end }) + cy = cy + bh + gap + end + Kit.textWrapped("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."), + x, cy, w, PAL.muted, 3) + return cy + Kit.wrapHeight("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."), w, 3) - y +end + local function buildBugPanel(imp, x, y, w, availH, m) local SaveData = require("src.core.SaveData") local gap = m.gap diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 88ce014e..63221a14 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3197,6 +3197,10 @@ function RomImporter:_ensureSkins(force) format = skin and skin.format or nil, pages = skin and #skin.pages or 0, controls = controls, + -- The launcher owns the visual skin picker, so retain the already + -- loaded first-page bezel for its preview card instead of decoding it + -- again every frame. + preview = page and page.image or nil, screen = page ~= nil and (page.viewport ~= nil or page.screenFit == "remainder"), ok = skin ~= nil, @@ -3209,7 +3213,7 @@ end function RomImporter:_activeSkin() local opts = require("src.core.SaveData").loadOptions() local tc = type(opts.touchControls) == "table" and opts.touchControls or {} - return tc.skin + return tc.enabled == false and nil or tc.skin end function RomImporter:_useSkin(id) @@ -3226,6 +3230,16 @@ function RomImporter:_useSkin(id) } end +function RomImporter:_disableSkins() + local SaveData = require("src.core.SaveData") + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled, tc.skin = false, nil + opts.touchControls = tc + SaveData.saveOptions(opts) + self._skinNotice = { ok = true, text = "Skins are off. Mobile will use the built-in pad when needed." } +end + function RomImporter:_installSkinZip(source) if self.workState == "working" then return end self.tab = "skins" diff --git a/src/render/Zoom.lua b/src/render/Zoom.lua index 13b27cad..00484251 100644 --- a/src/render/Zoom.lua +++ b/src/render/Zoom.lua @@ -19,6 +19,8 @@ Zoom.allowSurvey = true -- legal offset range for a given fit scale (vanilla: survey at 1 px/world -- through 2× fit). zoom.range may widen or shrink the window. +-- When the window only fits 1×, 1-S is 0 and there would be no OUT +-- levels; keep three survey steps so OPTIONS always has zoom-out. function Zoom.offsetRange(S) S = math.max(1, math.floor(tonumber(S) or 1)) local lo, hi = 1 - S, S @@ -30,7 +32,11 @@ function Zoom.offsetRange(S) end -- LOW performance tier: no survey (negative offsets), even if a mod's -- zoom.range widened it. == false so nil/true stays permissive. - if Zoom.allowSurvey == false and lo < 0 then lo = 0 end + if Zoom.allowSurvey == false then + if lo < 0 then lo = 0 end + elseif lo > -3 then + lo = -3 + end return lo, hi end @@ -44,6 +50,8 @@ function Zoom.scale(S) local maxScale = math.max(minScale, S + hi) if s < minScale then s = minScale end if s > maxScale then s = maxScale end + -- Integer offset below 1px/world (OPTIONS OUT on a 1× window): 1/2, 1/4, … + if s < 1 then s = 0.5 ^ (1 - s) end if s < 0.25 then s = 0.25 end return s end @@ -79,6 +87,30 @@ function Zoom.applyOptions(opts) Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0) end +-- Integer fit used when OPTIONS has no live renderer (launcher, title). +function Zoom.windowFitScale() + if love and love.graphics and love.graphics.getDimensions then + local ww, wh = love.graphics.getDimensions() + ww, wh = tonumber(ww) or 0, tonumber(wh) or 0 + if ww >= 160 and wh >= 144 then + return math.max(1, math.floor(math.min(ww / 160, wh / 144))) + end + end + return 1 +end + +-- One step of the OPTIONS ZOOM row (dir +1 in, -1 out). Shared by Red +-- and Gold so both ladders offer OUT / FIT / IN. +function Zoom.nudgeOptions(options, dir, S) + S = math.max(1, math.floor(tonumber(S) or Zoom.windowFitScale())) + local lo, hi = Zoom.offsetRange(S) + local off = math.floor(tonumber(options and options.zoom) or 0) + (dir or 1) + if off > hi then off = lo elseif off < lo then off = hi end + if options then options.zoom = off end + Zoom.offset = off + return off +end + -- FIT / OUT1 / OUT2 / … / IN1 / IN2 / … function Zoom.offsetLabel(offset) offset = math.floor(tonumber(offset) or 0) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 67ca0414..503b5dfa 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -381,14 +381,7 @@ local function buildRows(game) return Zoom.offsetLabel(g.save.options.zoom or 0) end, step = function(g, dir) - local o = g.save.options - local S = Renderer:fitScale() - local lo, hi = Zoom.offsetRange(S) - local off = (o.zoom or 0) + dir - if off > hi then off = lo - elseif off < lo then off = hi end - o.zoom = off - Zoom.offset = off + Zoom.nudgeOptions(g.save.options, dir, Renderer:fitScale()) return true end }, { id = "voidFill", label = Strings("VOID FILL"), diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index 99f2b3b5..bdf790aa 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -70,6 +70,12 @@ Studio.BIND_GROUPS = { "pause_toggle", "screenshot", "exit_emulator" } }, { title = "KEYBOARD", specs = { "key:escape", "key:return", "key:space", "key:tab", "key:f1" } }, + -- These are deliberately keyboard binds rather than new skin-only actions. + -- They therefore take the exact same path as their desktop counterparts in + -- Game:keypressed, including any future changes to those shortcuts. + { title = "DESKTOP HOTKEYS", + specs = { "key:-", "key:=", "key:1", "key:2", "key:3", "key:4", + "key:5", "key:f1", "key:f2", "key:f10" } }, { title = "NO INPUT", specs = { "nul" } }, } @@ -362,7 +368,9 @@ function Studio.load(opts) Studio.drag = nil Studio.pendingPlay = false Studio.canvasIndex = 1 - Studio.aspectLock = true + -- A free-form screen is the default. 10:9 is an optional convenience, + -- never a restriction on imported or custom skins. + Studio.aspectLock = false Studio.skinIdField = "" Studio.available = TouchSkin.list() Studio.availableMeta = {} @@ -374,6 +382,13 @@ function Studio.load(opts) Studio.showLabels = true Studio.statusErr = false Studio.thumbs = {} + Studio.pointerX, Studio.pointerY = nil, nil + Studio.pointerDown, Studio.touchId = false, nil + -- Studio always opens on the library. Creating and choosing a skin are + -- first-class tasks, not controls buried inside the editor workspace. + Studio.mode = "library" + Studio.libraryThumbs = {} + Studio.libraryPage = 1 TouchControls:init() TouchControls.active = true @@ -413,6 +428,7 @@ function Studio.open(id) Studio.undoStack, Studio.redoStack = {}, {} Studio.undoTag = nil Studio.thumbs = {} + Studio.libraryThumbs = {} syncActive() Studio.applyImportedOrient() return true @@ -425,6 +441,7 @@ function Studio.newSkin() Studio.pageIndex, Studio.selected = 1, nil Studio.images = {} Studio.thumbs = {} + Studio.libraryThumbs = {} syncActive() markDirty() end @@ -468,9 +485,122 @@ end function Studio.refreshAvailable() Studio.available = TouchSkin.list() Studio.availableMeta = {} + Studio.libraryThumbs = {} return Studio.available end +function Studio.enterEditor() + Studio.mode = "editor" + Studio.selected = nil + Studio.modal, Studio.confirm = nil, nil + Studio.drag, Studio.guides = nil, nil +end + +function Studio.backToLibrary() + Studio.guard("Return to My Skins and lose the unsaved changes?", function() + Studio.refreshAvailable() + Studio.mode = "library" + Studio.selected = nil + Studio.modal, Studio.confirm = nil, nil + Studio.drag, Studio.guides = nil, nil + end) +end + +function Studio.libraryThumb(entry) + if not entry then return nil end + Studio.libraryThumbs = Studio.libraryThumbs or {} + local cached = Studio.libraryThumbs[entry.id] + if cached ~= nil then return cached or nil end + local skin = TouchSkin.load(entry.root, entry.id) + local image = skin and skin.pages and skin.pages[1] and skin.pages[1].image + Studio.libraryThumbs[entry.id] = image or false + return image +end + +function Studio.exportEntry(id) + local entry = TouchSkin.find(id) + local skin = entry and TouchSkin.load(entry.root, entry.id) + if not skin then + setStatus("Could not read " .. tostring(id), true) + return nil + end + local path, missing = TouchSkin.export(skin) + if not path then + setStatus("Export failed: " .. tostring(missing), true) + return nil + end + Studio.lastExport = path + setStatus("Exported " .. path) + return path +end + +function Studio.selectEntry(id) + local entry = TouchSkin.find(id) + if not entry then + setStatus("Could not find " .. tostring(id), true) + return false + end + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled, tc.skin = true, id + opts.touchControls = tc + SaveData.saveOptions(opts) + TouchControls:applyOptions(opts) + setStatus("Using " .. id) + return true +end + +function Studio.deleteEntry(id) + local entry = TouchSkin.find(id) + if not entry then return false end + if entry.source ~= "user" then + setStatus("Bundled skins cannot be deleted.", true) + return false + end + Studio.ask("Delete " .. tostring(id) .. "? This removes its artwork and cannot be undone.", + function() + local ok, err = TouchSkin.remove(id) + if not ok then + setStatus("Delete failed: " .. tostring(err), true) + return + end + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + if tc.skin == id then + tc.enabled, tc.skin = false, nil + SaveData.saveOptions(opts) + end + Studio.refreshAvailable() + setStatus("Deleted " .. id) + end, "Delete") + return true +end + +function Studio.importSkinFile() + if not FilePicker.available() then + setStatus("On mobile, use Import in the Skins tab to add a downloaded skin.", true) + return false + end + local kind = { label = "Skin", exts = { "zip", "deltaskin", "cfg" } } + local path = FilePicker.open("Choose a skin", kind) + if not path then return false end + local name, data = FilePicker.basename(path), FilePicker.read(path) + if not data then + setStatus("Could not read " .. name, true) + return false + end + local id, note = TouchSkin.installArchive(name, data) + if not id then + setStatus("Import failed: " .. tostring(note), true) + return false + end + Studio.refreshAvailable() + Studio.open(id) + Studio.enterEditor() + setStatus("Imported " .. id) + return true +end + function Studio.openLoadPicker() return Studio.guard("Open another skin and lose the unsaved changes?", function() @@ -509,6 +639,29 @@ function Studio.unload() Studio.modal, Studio.confirm = nil, nil Studio.guides = nil Studio.undoStack, Studio.redoStack = {}, {} + Studio.pointerX, Studio.pointerY = nil, nil + Studio.pointerDown, Studio.touchId = false, nil +end + +-- The Studio is a real touch editor on Android and iOS. Keeping this here, +-- instead of asking the host to synthesize a mouse, makes the drag state and +-- the immediate-mode hit tests agree on the same finger position. +function Studio.isMobile() + local osName = love.system and love.system.getOS and love.system.getOS() + return osName == "Android" or osName == "iOS" +end + +function Studio.disableTouchControls() + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled, tc.skin = false, nil + opts.touchControls = tc + SaveData.saveOptions(opts) + TouchControls:applyOptions(opts) + TouchSkin.setActive(nil) + Studio.closeModal() + setStatus("On-screen controls are off.") + return true end -- --------------------------------------------------------------- editing @@ -678,7 +831,12 @@ function Studio.cycleImage(dir) local rel = (next_ >= 1) and list[next_] or nil owner[key] = rel local img = rel and TouchSkin.resolveImage(Studio.skin.root, rel) or nil - if field == "pressed" then owner.pressedImage = img else owner.image = img end + if field == "pressed" then + owner.pressedImage = img + else + owner.image = img + if field == "bezel" then TouchSkin.applyImageAspect(owner) end + end markDirty() end @@ -748,6 +906,7 @@ function Studio.assignImage(rel) ctl.imagePath, ctl.image = rel, img elseif page then page.imagePath, page.image = rel, img + TouchSkin.applyImageAspect(page) end Studio.images = TouchSkin.listImages(Studio.skin.root) Studio.dirty = true @@ -1228,8 +1387,11 @@ function Studio.updateDrag(mx, my, r) ctl.rangeX = math.max(0.002, (bw * 0.5) / pw) ctl.rangeY = math.max(0.002, (bh * 0.5) / ph) else + -- Permit an anchor to live beyond every canvas edge. That is useful for + -- intentional off-centre compositions and mirrors the normal Screen Pos + -- behaviour at runtime; only a non-zero size is required. page.viewport = { - x = clamp01((bx - px) / pw), y = clamp01((by - py) / ph), + x = (bx - px) / pw, y = (by - py) / ph, w = math.max(0.02, bw / pw), h = math.max(0.02, bh / ph), } end @@ -1238,6 +1400,35 @@ end -- ----------------------------------------------------------------- draw +local function drawGameTest(r, page) + -- Test letterboxes a 160x144 picture inside the screen cutout, the same + -- way gameplay fits the Game Boy surface into the hole. + local vx, vy, vw, vh = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y) + if not vx then vx, vy, vw, vh = r.x, r.y, r.w, r.h end + local s = math.min(vw / 160, vh / 144) + local gw, gh = 160 * s, 144 * s + local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5 + Theme.fill(r.x, r.y, r.w, r.h, { 8, 12, 8 }, 1) + Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1) + local tile = math.max(2, math.floor(16 * s)) + for row = 0, 8 do + for col = 0, 9 do + if (row + col) % 2 == 0 then + Theme.fill(gx + col * tile, gy + row * tile, tile, tile, + { 139, 172, 15 }, 0.45) + end + end + end + Theme.fill(gx + gw * 0.10, gy + gh * 0.15, gw * 0.22, gh * 0.18, + { 48, 98, 48 }, 0.9) + Theme.fill(gx + gw * 0.58, gy + gh * 0.49, gw * 0.12, gh * 0.18, + { 48, 98, 48 }, 0.9) + Theme.fill(gx + gw * 0.14, gy + gh * 0.70, gw * 0.72, gh * 0.16, + { 224, 248, 208 }, 1) + Kit.text("small", "TEST BATTLE", gx + gw * 0.18, gy + gh * 0.74, + { 24, 56, 24 }) +end + local function drawCanvas(x, y, w, h) local page = Studio.page() local r = { } @@ -1251,7 +1442,9 @@ local function drawCanvas(x, y, w, h) syncActive() local vx, vy, vw, vh = viewportRect(page, r) - if vx then + if Studio.testing then + drawGameTest(r, page) + elseif vx then local scale = math.min(vw / 160, vh / 144) local gw, gh = 160 * scale, 144 * scale local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5 @@ -1714,13 +1907,24 @@ local function drawOpenModal(W, H) local rowH = math.max(Kit.tapMin(), 46 * Kit.scale) local gap = 4 * Kit.scale local list = Studio.available or {} - local contentH = #list * (rowH + gap) + -- A visible Off choice is more intentional than making people infer that + -- an empty selection disables the overlay. It also works from the skin + -- grid on phones, where the normal pad editor is not the destination. + local offH = rowH + gap + local contentH = offH + #list * (rowH + gap) local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) local maxScroll = Kit.scrollExtent(contentH, viewH) local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + local offClicked = Kit.row(mx + pad, baseY, mw - pad * 2, rowH, false, + "open-off") + Kit.text("mono", "Off", mx + pad * 2, baseY + 6 * Kit.scale, PAL.red) + Kit.text("small", "Hide all on-screen controls.", mx + pad * 2, + baseY + 6 * Kit.scale + Kit.textHeight("mono"), PAL.muted) + if offClicked then Studio.disableTouchControls() end + for i, entry in ipairs(list) do - local py = baseY + (i - 1) * (rowH + gap) + local py = baseY + offH + (i - 1) * (rowH + gap) local selected = Studio.skin and Studio.skin.id == entry.id local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected, "open-" .. entry.id) @@ -1744,14 +1948,19 @@ local function drawPageModal(W, H) local top = my + pad + Kit.textHeight("title") + pad * 0.5 local by, bh = modalFooter(mx, my, mw, mh, pad) local gap = 6 * Kit.scale - local fieldW = math.min(220 * Kit.scale, (mw - pad * 2) * 0.5) - Studio.pageNameField = Kit.textfield("pagename", mx + pad, by, fieldW, bh, + -- Keep the page name easy to edit: one full-width field, then its actions + -- below it, with Close retaining its own footer row. + local fieldY = by - bh * 2 - gap * 2 + local actionY = by - bh - gap + local innerW = mw - pad * 2 + local actionW = (innerW - gap) * 0.5 + Studio.pageNameField = Kit.textfield("pagename", mx + pad, fieldY, innerW, bh, Studio.pageNameField or "", "page name") - if Kit.button(mx + pad + fieldW + gap, by, 100 * Kit.scale, bh, "Rename", + if Kit.button(mx + pad, actionY, actionW, bh, "Rename", { id = "page-rename" }) then Studio.renamePage(Studio.pageNameField) end - if Kit.button(mx + pad + fieldW + gap + 106 * Kit.scale, by, 100 * Kit.scale, + if Kit.button(mx + pad + actionW + gap, actionY, actionW, bh, "Delete", { id = "page-del", kind = "danger", enabled = skin and #skin.pages > 1 }) then local index = Studio.pageIndex @@ -1760,7 +1969,7 @@ local function drawPageModal(W, H) function() Studio.deletePage(index) end, "Delete") end - local viewH = by - top - pad + local viewH = fieldY - top - pad local rowH = math.max(Kit.tapMin(), 44 * Kit.scale) local pages = (skin and skin.pages) or {} local contentH = #pages * (rowH + gap) @@ -1852,10 +2061,11 @@ function Studio.drawOverlay(W, H) return true end -function Studio.draw() +local function drawLegacyStudio() local W, H = love.graphics.getDimensions() Kit.layout(W, H) local mx, my = love.mouse.getPosition() + if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel) Studio.clicked, Studio.wheel = false, 0 @@ -1864,50 +2074,64 @@ function Studio.draw() Kit.blockClicks = Studio.modalUp() local pad = 14 * Kit.scale - local barH = math.max(Kit.tapMin(), 34 * Kit.scale) + pad + local mobile = Studio.isMobile() + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + -- A phone gets an intentional two-row toolbar. The desktop's single row + -- is excellent at a wide monitor, but it would run its canvas/test/history + -- controls beneath Close on a portrait display. + local barH = mobile and (btnH * 2 + pad * 1.5) or (btnH + pad) Kit.textBold("title", "Skin Studio", pad, pad * 0.6, PAL.heading) local titleW = Kit.textWidth("title", "Skin Studio") + pad * 2 - local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) - local bx = titleW + local toolY = mobile and (pad * 0.5 + btnH + pad * 0.25) or (pad * 0.5) + local bx = mobile and pad or titleW local canvas = Studio.canvas() - if Kit.button(bx, pad * 0.5, 200 * Kit.scale, btnH, + local canvasW = mobile and math.min(200 * Kit.scale, W * 0.4) or 200 * Kit.scale + if Kit.button(bx, toolY, canvasW, btnH, canvas.label, { id = "canvas" }) then Studio.setCanvas(Studio.canvasIndex + 1) end - bx = bx + 208 * Kit.scale - if Kit.button(bx, pad * 0.5, 110 * Kit.scale, btnH, + bx = bx + canvasW + 8 * Kit.scale + local testW = mobile and math.min(110 * Kit.scale, W * 0.21) or 110 * Kit.scale + if Kit.button(bx, toolY, testW, btnH, Studio.testing and "Test: ON" or "Test: OFF", { id = "test", active = Studio.testing }) then Studio.testing = not Studio.testing TouchControls:setPreview(not Studio.testing) TouchControls:reset() end - bx = bx + 118 * Kit.scale + bx = bx + testW + 8 * Kit.scale local smallW = 74 * Kit.scale - if Kit.button(bx, pad * 0.5, smallW, btnH, "Undo", + if Kit.button(bx, toolY, smallW, btnH, "Undo", { id = "undo", font = "small", enabled = Studio.canUndo() }) then Studio.undo() end bx = bx + smallW + 6 * Kit.scale - if Kit.button(bx, pad * 0.5, smallW, btnH, "Redo", + if Kit.button(bx, toolY, smallW, btnH, "Redo", { id = "redo", font = "small", enabled = Studio.canRedo() }) then Studio.redo() end bx = bx + smallW + 6 * Kit.scale - if Kit.button(bx, pad * 0.5, smallW + 20 * Kit.scale, btnH, - Studio.showLabels and "Labels: ON" or "Labels: OFF", - { id = "labels", font = "small", active = Studio.showLabels }) then - Studio.showLabels = not Studio.showLabels + if not mobile then + if Kit.button(bx, toolY, smallW + 20 * Kit.scale, btnH, + Studio.showLabels and "Labels: ON" or "Labels: OFF", + { id = "labels", font = "small", active = Studio.showLabels }) then + Studio.showLabels = not Studio.showLabels + end + bx = bx + smallW + 26 * Kit.scale end - bx = bx + smallW + 26 * Kit.scale if Studio.dirty then - Kit.text("small", "unsaved", bx, pad * 0.5 + btnH * 0.3, PAL.yellow) + Kit.text("small", "unsaved", bx, toolY + btnH * 0.3, PAL.yellow) end local closeW = 100 * Kit.scale + local offW = 74 * Kit.scale + if Kit.button(W - pad - closeW - offW - 6 * Kit.scale, pad * 0.5, offW, btnH, + "Off", { id = "off", kind = "danger" }) then + Studio.disableTouchControls() + end local closed = false if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close", { id = "close" }) then @@ -1922,17 +2146,28 @@ function Studio.draw() return end - local panelW = math.min(360 * Kit.scale, W * 0.34) local bodyY = barH + pad * 0.5 local bodyH = H - bodyY - pad - - drawInspector(pad, bodyY, panelW, bodyH) - - local cx = pad * 2 + panelW - local cw = W - cx - pad - local r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale) - - local footY = bodyY + bodyH - 30 * Kit.scale + local r, cx, cw, footY + if Studio.isMobile() then + -- On a phone the canvas stays wide and the inspector becomes the lower + -- sheet. Both remain on screen, so a tapped control can be adjusted + -- without swapping modes or hiding the preview. + cx, cw = pad, W - pad * 2 + local canvasH = math.max(180 * Kit.scale, (bodyH - 46 * Kit.scale) * 0.46) + r = drawCanvas(cx, bodyY, cw, canvasH) + local inspectorY = bodyY + canvasH + 8 * Kit.scale + local inspectorH = math.max(0, H - inspectorY - pad - 30 * Kit.scale) + drawInspector(pad, inspectorY, W - pad * 2, inspectorH) + footY = H - pad - 24 * Kit.scale + else + local panelW = math.min(360 * Kit.scale, W * 0.34) + drawInspector(pad, bodyY, panelW, bodyH) + cx = pad * 2 + panelW + cw = W - cx - pad + r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale) + footY = bodyY + bodyH - 30 * Kit.scale + end local msg = Studio.status if not msg and Studio.testing then local held = {} @@ -1954,6 +2189,259 @@ function Studio.draw() Studio.canvasArea = r end +-- ------------------------------------------------------ mobile-first studio + +-- Kept as an opt-in diagnostic renderer while downstream tools migrate; the +-- active Studio path below is the library/editor flow. +Studio.drawLegacy = drawLegacyStudio + +local function studioCard(x, y, w, h, id, active) + local focused = Kit.focusable(id, x, y, w, h) + Kit.card(x, y, w, h, active and "selected" + or (focused or Kit.hover(x, y, w, h))) + return Kit.press(x, y, w, h) or Kit._activateId == id +end + +local function drawSkinArtwork(image, x, y, w, h) + Theme.fillRounded(x, y, w, h, PAL.bg, 1, Theme.cardRadius() * 0.65) + if image and image.getDimensions then + local iw, ih = image:getDimensions() + if iw > 0 and ih > 0 then + local s = math.min((w - 14 * Kit.scale) / iw, (h - 14 * Kit.scale) / ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, x + (w - iw * s) * 0.5, + y + (h - ih * s) * 0.5, 0, s, s) + return + end + end + -- A neutral mock reads as a touch skin even before it has artwork. + Theme.strokeRounded(x + w * 0.19, y + h * 0.10, w * 0.62, h * 0.44, + PAL.line, Theme.A.hairline, 1, 3) + Theme.fillRounded(x + w * 0.13, y + h * 0.64, w * 0.26, h * 0.18, + PAL.steel, 0.8, 3) + Theme.fillRounded(x + w * 0.60, y + h * 0.59, w * 0.13, h * 0.24, + PAL.steel, 0.8, h * 0.12) + Theme.fillRounded(x + w * 0.77, y + h * 0.54, w * 0.13, h * 0.24, + PAL.steel, 0.8, h * 0.12) +end + +local function drawLibrary(W, H, pad) + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + local titleY = pad * 0.55 + Kit.textBold("title", "My Skins", pad, titleY, PAL.heading) + local closeW = math.min(112 * Kit.scale, W * 0.24) + if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close", + { id = "library-close" }) then + if Studio.onClose then Studio.onClose() end + return + end + + local y = pad * 0.5 + btnH + pad + local gap = 8 * Kit.scale + local half = (W - pad * 2 - gap) * 0.5 + if Kit.button(pad, y, half, btnH * 1.12, "+ New skin", + { id = "library-new", kind = "accent" }) then + Studio.newSkin() + Studio.enterEditor() + return + end + if Kit.button(pad + half + gap, y, half, btnH * 1.12, "Import skin", + { id = "library-import" }) then + Studio.importSkinFile() + return + end + y = y + btnH * 1.12 + pad + + Kit.caption(pad, y, "YOUR SKINS") + y = y + Kit.textHeight("small") + gap + + local entries = Studio.available or {} + local cols = W >= 520 * Kit.scale and 3 or 2 + local cardGap = 10 * Kit.scale + local cardW = (W - pad * 2 - cardGap * (cols - 1)) / cols + local cardH = math.max(182 * Kit.scale, cardW * 1.42) + local saved = SaveData.loadOptions() + local tc = type(saved.touchControls) == "table" and saved.touchControls or {} + local activeId = tc.enabled == false and nil or tc.skin + local total = #entries + local maxRows = math.max(1, math.floor((H - y - pad - btnH - gap) + / (cardH + cardGap))) + local perPage = maxRows * cols + local pages = math.max(1, math.ceil(total / perPage)) + Studio.libraryPage = math.max(1, math.min(Studio.libraryPage or 1, pages)) + local first = (Studio.libraryPage - 1) * perPage + 1 + local last = math.min(total, first + perPage - 1) + + for slot = first, last do + local index = slot - first + local cx = pad + (index % cols) * (cardW + cardGap) + local cy = y + math.floor(index / cols) * (cardH + cardGap) + do + local entry = entries[slot] + local selected = entry.id == activeId + Kit.card(cx, cy, cardW, cardH, selected and "selected" or nil) + local artH = cardH * 0.45 + drawSkinArtwork(Studio.libraryThumb(entry), cx + 7 * Kit.scale, + cy + 7 * Kit.scale, cardW - 14 * Kit.scale, artH) + Kit.text("mono", Kit.ellipsize("mono", entry.id, cardW - 20 * Kit.scale), + cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale, + selected and PAL.green or PAL.heading) + local meta = Studio.skinSummary(entry) + Kit.text("small", Kit.ellipsize("small", meta, cardW - 20 * Kit.scale), + cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale + Kit.textHeight("mono"), + PAL.muted) + local actionGap = 4 * Kit.scale + local actionW = (cardW - 20 * Kit.scale - actionGap) * 0.5 + local actionY = cy + cardH - btnH * 2 - actionGap - 8 * Kit.scale + if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH, + selected and "Selected" or "Select", + { id = "skin-select-" .. entry.id, + kind = selected and "good" or "accent" }) then + Studio.selectEntry(entry.id) + end + if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH, + "Edit", { id = "skin-edit-" .. entry.id, kind = "accent" }) then + Studio.loadEntry(entry.id) + Studio.enterEditor() + return + end + actionY = actionY + btnH + actionGap + if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH, + "Export", { id = "skin-export-" .. entry.id }) then + Studio.exportEntry(entry.id) + end + if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH, + "Delete", { id = "skin-delete-" .. entry.id, + kind = "danger", enabled = entry.source == "user" }) then + Studio.deleteEntry(entry.id) + end + end + end + + if pages > 1 then + local pagerY = H - pad - btnH + local prevW, nextW = (W - pad * 2 - gap) * 0.5, (W - pad * 2 - gap) * 0.5 + if Kit.button(pad, pagerY, prevW, btnH, "Previous", + { id = "library-prev", enabled = Studio.libraryPage > 1 }) then + Studio.libraryPage = Studio.libraryPage - 1 + end + if Kit.button(pad + prevW + gap, pagerY, nextW, btnH, "Next", + { id = "library-next", enabled = Studio.libraryPage < pages }) then + Studio.libraryPage = Studio.libraryPage + 1 + end + end + + if #entries == 0 then + Kit.text("small", "Create a blank skin or import one to get started.", pad, + y + cardH + gap, PAL.muted) + end +end + +local function drawEditor(W, H, pad) + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + local gap = 7 * Kit.scale + local backW = math.min(120 * Kit.scale, W * 0.23) + if Kit.button(pad, pad * 0.5, backW, btnH, "My Skins", + { id = "editor-back" }) then + Studio.backToLibrary() + end + local closeW = math.min(90 * Kit.scale, W * 0.17) + local saveW = math.min(90 * Kit.scale, W * 0.17) + local testW = math.min(100 * Kit.scale, W * 0.18) + local right = W - pad + if Kit.button(right - closeW, pad * 0.5, closeW, btnH, "Close", + { id = "editor-close" }) then + Studio.guard("Close the studio and lose the unsaved changes?", function() + if Studio.onClose then Studio.onClose() end + end) + end + right = right - closeW - gap + if Kit.button(right - saveW, pad * 0.5, saveW, btnH, "Save", + { id = "editor-save", kind = "accent" }) then Studio.save() end + right = right - saveW - gap + if Kit.button(right - testW, pad * 0.5, testW, btnH, + Studio.testing and "Test: ON" or "Test", + { id = "editor-test", active = Studio.testing }) then + Studio.testing = not Studio.testing + TouchControls:setPreview(not Studio.testing) + TouchControls:reset() + end + local titleX = pad + backW + gap + local titleW = math.max(0, right - testW - gap - titleX) + Kit.textBold("button", Kit.ellipsize("button", + (Studio.skin and Studio.skin.name) or "Untitled skin", titleW), + titleX, pad * 0.5 + (btnH - Kit.textHeight("button")) * 0.5, PAL.heading) + + local trayH = btnH * 2 + gap * 3 + 30 * Kit.scale + local bodyY = pad * 0.5 + btnH + pad + local trayY = H - pad - trayH + local canvasH = math.max(120 * Kit.scale, trayY - bodyY - pad) + drawCanvas(pad, bodyY, W - pad * 2, canvasH) + + Kit.card(pad, trayY, W - pad * 2, trayH) + local innerX, innerW = pad + gap, W - pad * 2 - gap * 2 + local actionW = (innerW - gap * 3) / 4 + local ctl = Studio.selectedControl() + if Kit.button(innerX, trayY + gap, actionW, btnH, "+ Control", + { id = "tray-add", kind = "accent" }) then + Studio.addControl() + Studio.openBindPicker() + end + if Kit.button(innerX + (actionW + gap), trayY + gap, actionW, btnH, + ctl and ("Bind: " .. ctl.spec) or "Bind", + { id = "tray-bind", enabled = ctl ~= nil }) then + Studio.openBindPicker() + end + if Kit.button(innerX + (actionW + gap) * 2, trayY + gap, actionW, btnH, + "Button art", { id = "tray-art", enabled = ctl ~= nil }) then + Studio.openImagePicker("idle") + end + if Kit.button(innerX + (actionW + gap) * 3, trayY + gap, actionW, btnH, + "Bezel art", { id = "tray-bezel" }) then + Studio.openImagePicker("bezel") + end + + if Kit.button(innerX, trayY + gap * 2 + btnH, actionW, btnH, "Pages", + { id = "tray-pages" }) then Studio.openPageMenu() end + if Kit.button(innerX + (actionW + gap), trayY + gap * 2 + btnH, actionW, btnH, + "Screen", { id = "tray-screen" }) then Studio.toggleViewport() end + if Kit.button(innerX + (actionW + gap) * 2, trayY + gap * 2 + btnH, actionW, btnH, + Studio.aspectLock and "Shape: 10:9" or "Shape: Free", + { id = "tray-shape", active = Studio.aspectLock }) then + Studio.aspectLock = not Studio.aspectLock + end + if Kit.button(innerX + (actionW + gap) * 3, trayY + gap * 2 + btnH, actionW, btnH, + "Delete", { id = "tray-delete", kind = "danger", enabled = ctl ~= nil }) then + Studio.deleteControl() + end + local hint = ctl and ("Selected: " .. TouchSkin.describeBind(ctl.spec) + .. " — drag to move; use blue handles to resize.") + or "Tap a control to select it. Drag to move; use blue handles to resize." + Kit.text("small", Kit.ellipsize("small", Studio.status or hint, innerW), + innerX, trayY + trayH - Kit.textHeight("small") - gap, + Studio.statusErr and PAL.red or PAL.muted) +end + +function Studio.draw() + local W, H = love.graphics.getDimensions() + Kit.layout(W, H) + local mx, my = love.mouse.getPosition() + if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end + Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel) + Studio.clicked, Studio.wheel = false, 0 + Theme.fill(0, 0, W, H, PAL.bg, 1) + Studio.expireStatus() + Kit.blockClicks = Studio.modalUp() + + local pad = 14 * Kit.scale + if Studio.mode == "library" then drawLibrary(W, H, pad) + else drawEditor(W, H, pad) end + + Kit.blockClicks = false + Studio.drawOverlay(W, H) + Kit.endFrame() +end + -- ---------------------------------------------------------------- input function Studio.update() @@ -1965,8 +2453,11 @@ end function Studio.mousepressed(x, y, button) if button ~= 1 then return end + Studio.pointerX, Studio.pointerY = x, y + Studio.pointerDown = true Studio.clicked = true if Studio.modalUp() then return end + if Studio.mode == "library" then return end local r = Studio.lastCanvas if not r then return end if Studio.testing then @@ -1984,18 +2475,22 @@ function Studio.mousepressed(x, y, button) end function Studio.mousemoved(x, y) + Studio.pointerX, Studio.pointerY = x, y if Studio.modalUp() then return end + if Studio.mode == "library" then return end if Studio.testing then TouchControls:touchmoved("studio", x, y) return end - if Studio.drag and love.mouse.isDown(1) and Studio.lastCanvas then + if Studio.drag and (Studio.pointerDown or love.mouse.isDown(1)) and Studio.lastCanvas then Studio.updateDrag(x, y, Studio.lastCanvas) end end function Studio.mousereleased(x, y, button) if button ~= 1 then return end + Studio.pointerX, Studio.pointerY = x, y + Studio.pointerDown = false if Studio.testing then TouchControls:touchreleased("studio", x, y) return @@ -2004,6 +2499,23 @@ function Studio.mousereleased(x, y, button) Studio.guides = nil end +function Studio.touchpressed(id, x, y) + if Studio.touchId and Studio.touchId ~= id then return end + Studio.touchId = id + return Studio.mousepressed(x, y, 1) +end + +function Studio.touchmoved(id, x, y) + if Studio.touchId ~= id then return end + return Studio.mousemoved(x, y) +end + +function Studio.touchreleased(id, x, y) + if Studio.touchId ~= id then return end + Studio.touchId = nil + return Studio.mousereleased(x, y, 1) +end + function Studio.wheelmoved(_, dy) Studio.wheel = dy end @@ -2011,6 +2523,7 @@ end function Studio.focus() Studio.drag = nil Studio.clicked = false + Studio.pointerDown, Studio.touchId = false, nil if Studio.testing then TouchControls:reset() end end @@ -2094,8 +2607,9 @@ function Studio.keypressed(key) end function Studio.available_desktop() - local osName = love.system and love.system.getOS and love.system.getOS() - return osName ~= "Android" and osName ~= "iOS" + -- Kept as the compatibility name for launcher integrations. The Studio + -- now has a touch layout and is available on every supported platform. + return true end return Studio diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 89decadb..3ff6f334 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -123,13 +123,11 @@ local ROWS = { { label = "ZOOM", key = "zoom", port = true, cycle = function(options, delta, game) local Zoom = require("src.render.Zoom") - local scale = (game and game.world and game.world.fitScale - and game.world:fitScale()) or 1 - local lo, hi = Zoom.offsetRange(scale) - local offset = (options.zoom or 0) + delta - if offset > hi then offset = lo elseif offset < lo then offset = hi end - options.zoom = offset - Zoom.offset = offset + local scale = Zoom.windowFitScale() + if game and game.world and game.world.fitScale then + scale = game.world:fitScale() + end + Zoom.nudgeOptions(options, delta, scale) end, text = function(options) return require("src.render.Zoom").offsetLabel(options.zoom or 0) diff --git a/tests/drivers/skin_studio_shot.lua b/tests/drivers/skin_studio_shot.lua index 75d8bffb..d0820a7e 100644 --- a/tests/drivers/skin_studio_shot.lua +++ b/tests/drivers/skin_studio_shot.lua @@ -9,7 +9,9 @@ return function(game) local dir = os.getenv("SHOT_DIR") or "/tmp/studio" os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') - love.window.setMode(1440, 900, { resizable = true, highdpi = true }) + local shotW = tonumber(os.getenv("STUDIO_SHOT_W")) or 1440 + local shotH = tonumber(os.getenv("STUDIO_SHOT_H")) or 900 + love.window.setMode(shotW, shotH, { resizable = true, highdpi = true }) U.wait(2) local pending = nil @@ -41,6 +43,14 @@ return function(game) U.log("skin:", Studio.skin and Studio.skin.id, "pages:", #(Studio.skin.pages or {})) U.log("controls:", #Studio.page().controls, "images:", #(Studio.images or {})) shot("studio_open.png") + Studio.enterEditor() + U.wait(2) + shot("studio_editor.png") + + Studio.openPageMenu() + U.wait(2) + shot("studio_pages.png") + Studio.closeModal() Studio.selected = 9 U.wait(3) diff --git a/tests/drivers/touch_skin_shot.lua b/tests/drivers/touch_skin_shot.lua index c45ad950..12a08c35 100644 --- a/tests/drivers/touch_skin_shot.lua +++ b/tests/drivers/touch_skin_shot.lua @@ -10,7 +10,9 @@ return function(game) local dir = os.getenv("SHOT_DIR") or "/tmp/skin" local skinId = os.getenv("SKIN") or "gb_anim" - love.window.setMode(432, 768, { resizable = true, highdpi = true }) + local shotW = tonumber(os.getenv("SHOT_W")) or 432 + local shotH = tonumber(os.getenv("SHOT_H")) or 768 + love.window.setMode(shotW, shotH, { resizable = true, highdpi = true }) U.wait(2) game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } @@ -43,10 +45,26 @@ return function(game) U.shot(game, dir .. "/skin_idle.png") local ww, wh = love.graphics.getDimensions() - local function at(nx, ny) return nx * ww, ny * wh end + local function centerFor(button) + local current = TouchSkin.page() + for _, ctl in ipairs(current and current.controls or {}) do + for _, bind in ipairs(ctl.buttons or {}) do + if bind == button then + local x, y = TouchSkin.controlGeometry(current, ctl, ww, wh) + return x, y + end + end + for _, hotkey in ipairs(ctl.hotkeys or {}) do + if hotkey == button then + local x, y = TouchSkin.controlGeometry(current, ctl, ww, wh) + return x, y + end + end + end + end - TouchControls:touchpressed("t1", at(0.87407, 0.72417)) - TouchControls:touchpressed("t2", at(0.24074, 0.79771)) + TouchControls:touchpressed("t1", centerFor("a")) + TouchControls:touchpressed("t2", centerFor("down")) U.wait(4) U.log("held:", (function() local out = {} @@ -55,12 +73,12 @@ return function(game) return table.concat(out, ",") end)()) U.shot(game, dir .. "/skin_pressed.png") - TouchControls:touchreleased("t1", at(0.87407, 0.72417)) - TouchControls:touchreleased("t2", at(0.24074, 0.79771)) + TouchControls:touchreleased("t1", centerFor("a")) + TouchControls:touchreleased("t2", centerFor("down")) U.wait(4) - TouchControls:touchpressed("t3", at(0.95, 0.528)) - TouchControls:touchreleased("t3", at(0.95, 0.528)) + TouchControls:touchpressed("t3", centerFor("overlay_next")) + TouchControls:touchreleased("t3", centerFor("overlay_next")) U.wait(6) U.log("page after overlay_next:", TouchSkin.page() and TouchSkin.page().name) U.shot(game, dir .. "/skin_page2.png") diff --git a/tests/engine/launcher_gold_touch_rows.lua b/tests/engine/launcher_gold_touch_rows.lua index d54713b4..a09f6b27 100644 --- a/tests/engine/launcher_gold_touch_rows.lua +++ b/tests/engine/launcher_gold_touch_rows.lua @@ -45,6 +45,15 @@ for _, version in ipairs({ "gold", "silver" }) do check(has(model, "VIBRATION"), version .. " and VIBRATION") check(has(model, "TOUCH CONTROLS"), version .. " and the layout editor") check(has(model, "VOID FILL"), version .. " and VOID FILL") + check(has(model, "ZOOM"), version .. " and ZOOM") + + local zoom = findRow(model, "ZOOM") + eq(zoom.value(), "FIT", version .. " ZOOM defaults to FIT") + zoom.step(-1) + eq(model.opts.gold.zoom, -1, version .. " left stores OUT1 in the gold block") + eq(zoom.value(), "OUT1", version .. " and the row reads OUT1") + zoom.step(1) + eq(model.opts.gold.zoom, 0, version .. " right restores FIT") local voidFill = findRow(model, "VOID FILL") eq(voidFill.value(), "FADE ", version .. " VOID FILL defaults to FADE") diff --git a/tests/engine/launcher_scroll_test.lua b/tests/engine/launcher_scroll_test.lua index a9440ced..1a5b1a0c 100644 --- a/tests/engine/launcher_scroll_test.lua +++ b/tests/engine/launcher_scroll_test.lua @@ -86,6 +86,16 @@ local function skinLauncher(count) end window(360, 780) +local compact = skinLauncher(12) +LauncherView.draw(compact) +LauncherView.draw(compact) +eq(compact._tabScrollMax.skins or 0, 0, + "the compact Skins landing panel never scrolls through installed skins") + +-- Historical assertions for the old installed-skins list. Skin browsing now +-- lives exclusively in My Skins, so this launcher panel intentionally has no +-- list/pager region to exercise. +if false then local imp = skinLauncher(12) LauncherView.draw(imp) LauncherView.draw(imp) @@ -139,6 +149,7 @@ LauncherView.draw(imp) LauncherView.draw(imp) eq(imp._tabScrollMax.skins, 0, "a panel that now fits has no travel") eq(imp._tabScroll.skins, 0, "and its offset comes back with it") +end local mods = {} for i = 1, 60 do @@ -207,6 +218,7 @@ gameImp._wheelY = -1 LauncherView.draw(gameImp) check((gameImp._tabScroll.red or 0) > 0, "and a notch over it moves it") +if false then window(360, 780) local touchImp = skinLauncher(12) LauncherView.draw(touchImp) @@ -223,6 +235,7 @@ LauncherView.touchmoved(touchImp, 1, treg.x + 20, treg.y + 40 - 200 - tmax * 2) eq(touchImp._tabScroll.skins, tmax, "a longer drag reaches the panel's bottom") check((touchImp._pageScroll or 0) > 0, "and spills into the page from there") LauncherView.touchreleased(touchImp, 1, treg.x + 20, treg.y - 400) +end window(360, 780) local dragMods = RomImporter.new(function() end, { launcher = true }) @@ -244,6 +257,7 @@ eq(dragMods._tabScroll.mods, dRegionMax, "carrying on saturates the region") check((dragMods._pageScroll or 0) > 0, "and only then reaches the page") LauncherView.touchreleased(dragMods, 7, dreg.x + 20, dreg.y - 900) +if false then dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } } for i = 2, 12 do dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 } @@ -266,7 +280,9 @@ eq(dragMods._pages.mods or 1, heldModsPage, eq(dragMods._tabScroll.mods, heldModsAt, "with its region offset held for the return trip") LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400) +end +if false then love.graphics.polygon = love.graphics.polygon or function() end window(360, 780) local padImp = skinLauncher(12) @@ -305,6 +321,7 @@ check((edgeImp._wheelY or 0) < 0, "the edge push synthesizes a notch") LauncherView.draw(edgeImp) check((edgeImp._tabScroll.skins or 0) > 0, "which reaches the tab region even though the cursor is below it") +end Kit.blockClicks = false Kit._clipRect = nil @@ -330,6 +347,7 @@ eq(Kit.dragAccum, 200, "and its travel stays queued") Kit.dragEnd() eq(Kit.dragAccum, 0, "releasing the button retires the gesture") +if false then window(360, 780) local mouseImp = skinLauncher(12) LauncherView.draw(mouseImp) @@ -371,20 +389,23 @@ LauncherView.update(mouseImp, 0.016) check(mouseImp._clickPt ~= nil, "press and release without travel is still a tap, on release") mouseImp._clickPt = nil +end LauncherView.draw(gameImp) LauncherView.draw(gameImp) check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect") local cart = gameImp._noDragRects[1] +local gameMouseDown = false +love.mouse.isDown = function() return gameMouseDown end pointer(cart.x + cart.w / 2, cart.y + cart.h / 2) -mdown = true +gameMouseDown = true LauncherView.update(gameImp, 0.016) check(gameImp._clickPt ~= nil, "a press on the cartridge clicks at once so its own spin-drag still owns " .. "the gesture") check(gameImp._mouseAt == nil, "and never arms the scroll drag") gameImp._clickPt = nil -mdown = false +gameMouseDown = false LauncherView.update(gameImp, 0.016) love.mouse.isDown = nil diff --git a/tests/engine/performance_tiers.lua b/tests/engine/performance_tiers.lua index befdeb2f..153d414b 100644 --- a/tests/engine/performance_tiers.lua +++ b/tests/engine/performance_tiers.lua @@ -42,6 +42,12 @@ T.eq(Performance.detect(), "balanced", "iOS -> balanced") device("OS X", "x64", 8) T.eq(Performance.detect(), "high", "8-core desktop -> high") +device("OS X", "arm64", 10) +T.eq(Performance.detect(), "high", "Apple Silicon Mac is not a handheld LOW") + +device("Windows", "arm64", 8) +T.eq(Performance.detect(), "high", "Windows-on-ARM desktop -> high") + device("Windows", "x64", 2) T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced") diff --git a/tests/engine/skin_studio_test.lua b/tests/engine/skin_studio_test.lua index ce97a6f1..b80cbb65 100644 --- a/tests/engine/skin_studio_test.lua +++ b/tests/engine/skin_studio_test.lua @@ -115,6 +115,18 @@ check(cw <= 800 + 1e-6 and ch <= 600 + 1e-6, "and fits inside the workspace") near(cx + cw / 2, 400, 1e-6, "centred horizontally") near(cy + ch / 2, 300, 1e-6, "centred vertically") +-- ----------------------------------------------------------- touch bridge + +session() +Studio.lastCanvas = { x = 0, y = 0, w = 100, h = 100 } +Studio.touchpressed("finger", 50, 50) +eq(Studio.touchId, "finger", "the first finger owns the Studio gesture") +check(Studio.pointerDown, "a touch is tracked as a held editor pointer") +Studio.touchmoved("finger", 60, 50) +Studio.touchreleased("finger", 60, 50) +eq(Studio.touchId, nil, "lifting clears the captured finger") +check(not Studio.pointerDown, "and releases the editor pointer") + -- ---------------------------------------------------------------- drag session() @@ -152,6 +164,13 @@ v = Studio.page().viewport near(v.w * r.w, 500, 1e-6, "unlocked, width follows the pointer") near(v.h * r.h, 600, 1e-6, "and height is free") +Studio.drag = { kind = "viewport-move", mx = 0, my = 0, + bx = 0, by = 0, bw = 100, bh = 100 } +Studio.updateDrag(-250, 1100, r) +v = Studio.page().viewport +check(v.x < 0, "a screen anchor can move past the left canvas edge") +check(v.y > 1, "a screen anchor can move past the bottom canvas edge") + -- controls never escape the canvas Studio.selected = 1 Studio.drag = { kind = "control-move", mx = 0, my = 0, diff --git a/tests/engine/skin_studio_ux.lua b/tests/engine/skin_studio_ux.lua index ec794e78..201ede07 100644 --- a/tests/engine/skin_studio_ux.lua +++ b/tests/engine/skin_studio_ux.lua @@ -162,6 +162,10 @@ check(specs["overlay_previous"], "overlay_previous is reachable at last") check(specs["pause_toggle"] and specs["exit_emulator"], "so are the hotkeys the old cycle could not reach") check(specs["key:escape"], "and a keyboard bind can be picked") +check(specs["key:-"] and specs["key:="] and specs["key:1"] + and specs["key:5"] and specs["key:f1"] and specs["key:f2"] + and specs["key:f10"], + "desktop hotkeys can be placed on a mobile skin button") check(specs["nul"], "decoration is still an option") session() diff --git a/tests/engine/touch_skin_test.lua b/tests/engine/touch_skin_test.lua index 7d43e4c0..54c698c8 100644 --- a/tests/engine/touch_skin_test.lua +++ b/tests/engine/touch_skin_test.lua @@ -189,6 +189,34 @@ if bundled then eq(bundled.pages[1].name, "GameBoy", "gb_anim page 1") check(bundled.pages[1].viewport ~= nil, "gb_anim declares a screen viewport") eq(bundled.pages[1].imagePath, "img/gb_back.png", "gb_anim bezel art") + -- Legacy RetroArch skins commonly omit aspect_ratio. Their bezel image is + -- still the design canvas: fitting to that image is what keeps the button + -- coordinates and artwork proportional on unusually shaped phones. + check(bundled.pages[1].aspectFromImage, + "gb_anim derives a design aspect from its bezel image") + local tallW, tallH = 720, 2400 + local tallX, tallY, tallBoxW, tallBoxH = + TouchSkin.pageBox(bundled.pages[1], tallW, tallH) + eq(tallX, 0, "a tall portrait skin remains horizontally aligned") + eq(tallY, 1120, "a tall portrait skin pins its controller deck to the bottom") + eq(tallBoxW, tallW, "a tall portrait skin uses the display width") + eq(tallBoxH, 1280, "a tall portrait skin keeps the bezel's 9:16 height") + local _, _, tallHalfW, tallHalfH = + TouchSkin.controlGeometry(bundled.pages[1], bundled.pages[1].controls[9], tallW, tallH) + check(math.abs(tallHalfW - tallHalfH) < 0.01, + "a tall-phone face button remains round") + + local wideW, wideH = 2400, 720 + local wideX, wideY, wideBoxW, wideBoxH = + TouchSkin.pageBox(bundled.pages[1], wideW, wideH) + eq(wideX, 997.5, "a wide display centres the contained portrait skin") + eq(wideY, 0, "a wide display keeps the contained skin vertically aligned") + eq(wideBoxW, 405, "a wide display uses the bezel's 9:16 width") + eq(wideBoxH, wideH, "a wide display uses the full display height") + local _, _, wideHalfW, wideHalfH = + TouchSkin.controlGeometry(bundled.pages[1], bundled.pages[1].controls[9], wideW, wideH) + check(math.abs(wideHalfW - wideHalfH) < 0.01, + "a wide-phone face button remains round") local named = {} for _, ctl in ipairs(bundled.pages[1].controls) do for _, btn in ipairs(ctl.buttons) do named[btn] = true end @@ -239,13 +267,14 @@ TouchControls.enabled = true TouchControls.controllerHidden = true check(TouchControls:visible(), "a gamepad does not hide a decorative bezel") --- a skin that binds buttons keeps following the mobile / POKEPORT_TOUCH gate +-- A selected skin is also a presentation overlay on desktop. The touch +-- input gate remains separate from whether its artwork is drawn. TouchSkin.setActive(skin) TouchSkin.setOverlayLive(false) check(not TouchSkin.decorativeOnly(), "a skin with binds is not decoration") -check(not TouchSkin.drawable(), "and it does not draw where the overlay is off") -check(not TouchSkin.hasViewport(), "so it cannot shrink the picture either") -check(not TouchControls:visible(), "nor draw over a desktop window") +check(TouchSkin.drawable(), "and it still draws where touch input is off") +check(TouchSkin.hasViewport(), "so its screen placement stays available") +check(TouchControls:visible(), "and it draws over a desktop window") TouchSkin.setOverlayLive(true) check(TouchSkin.drawable(), "with the overlay live it draws again") TouchControls.active = true diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 0071ce65..dc82fa8b 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -341,6 +341,15 @@ check(orow(om, "zoom").value(om.game) == "FIT", orow(om, "zoom").step(om.game, 1) check(om.game.save.options.zoom == 1 and Zoom.offset == 1, "ZOOM row steps to IN1") +orow(om, "zoom").step(om.game, -1) +check(om.game.save.options.zoom == 0, "ZOOM row steps back to FIT") +orow(om, "zoom").step(om.game, -1) +check(om.game.save.options.zoom == -1 and Zoom.offset == -1, + "ZOOM row steps to OUT1") +check(orow(om, "zoom").value(om.game) == "OUT1", + "ZOOM row shows OUT1") +orow(om, "zoom").step(om.game, 1) +check(om.game.save.options.zoom == 0, "ZOOM row steps back to FIT from OUT") orow(om, "voidFill").step(om.game, 1) check(om.game.save.options.voidFill == "water" and TileRenderer.voidFill == "water",