From 0b70d6c53519138bebf369b0b5dba14539133886 Mon Sep 17 00:00:00 2001 From: HighDrexler Date: Thu, 20 Aug 2026 17:09:40 -0400 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 c465f580060f1bd73d0a3e3d1ff994abaa8b578a Mon Sep 17 00:00:00 2001 From: HighDrexler Date: Thu, 20 Aug 2026 23:46:02 -0400 Subject: [PATCH 4/8] 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 c0bd4a35be7c794cbfb2036f82069791d0b27bf2 Mon Sep 17 00:00:00 2001 From: HighDrexler Date: Fri, 21 Aug 2026 07:33:24 -0400 Subject: [PATCH 5/8] 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 6/8] 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 4b0496bad1a9110d5aad3b5be551664098c28ec0 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 21 Aug 2026 14:40:51 +0200 Subject: [PATCH 7/8] 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 8/8] 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")