api upgrades, windows dev tools, and android fixes (#299)

* pop up a fake save

* add CI to dev branch

* Make ROM-free test tiers actually run on Windows (#267)

Suite discovery, the extension-point catalog scan, mod test-dir pickup,
and the meta-coverage corpus all shelled out to ls/find/test -d, which do
not exist in cmd.exe. Every listing came back empty on Windows, so tiers
ran 0 suites and still reported ALL TESTS PASSED.

Add portable probes to tests/fs_io.lua (same Unix commands on
Linux/macOS; dir /b and a shell-free rename-self existence check on
Windows) and rewire the four call sites to them. 15/15 engine suites and
2/2 modkit suites now genuinely run and pass on Windows.

Fixes #266

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>

* intro api upgrade (#294)

* intro api upgrade

* api updates

* fix tests

* updated templates

* android fixes for mods and saves (#297)

* Android SCALING fixes (#298)

* android fixes for mods and saves

* perhaps this is the true scaling android issue fix

---------

Co-authored-by: johnjohto <johtoboy@atomicmail.io>
Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
This commit is contained in:
bryanthaboi
2026-07-27 09:29:24 -04:00
committed by GitHub
parent 043f6b2426
commit 803f86d5b3
68 changed files with 2902 additions and 300 deletions
+5 -3
View File
@@ -1,6 +1,6 @@
# Example mod gallery
Seven reference mods, one per modder persona. Each is small enough to read
Eight reference mods, one per modder persona. Each is small enough to read
in one sitting, exercises a different slice of the mod API, and is a real,
runnable, tested mod — not a snippet.
@@ -16,6 +16,7 @@ Copy the one closest to what you want to build.
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` |
## None of these load by default
@@ -46,7 +47,8 @@ and `field`, and exercises:
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
`game.ready` and `assets.transformed`
`game.ready`, `assets.transformed`, `intro.oak_speech.build`,
`intro.oak_speech.answered` and `intro.oak_speech.finished`
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
`mod:read`, `mod.log` and `mod.path`
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
@@ -66,7 +68,7 @@ mods/examples/<id>/
.modkitignore keeps the suite out of the distributed package
```
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
`tests/mod_examples_tests.lua` in the engine's own suite loads all eight
together and asserts the above, so the gallery cannot rot.
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
- Answers written to `mod.save` via `intro.oak_speech.answered`.
- Custom `toast_kid.png` sprite shown mid-speech.
+43
View File
@@ -0,0 +1,43 @@
# Silly Oak Intro Example
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
## Try it (play through yourself)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
love .
```
Then **NEW GAME** and mash A / pick the menus. Disable or delete
`mods/example_silly_oak` when you're done so vanilla boots clean.
## Headless check
```sh
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
```
## Auto driver (screenshots + save asserts)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
```
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
## What it demonstrates
| Seam | Where |
|---|---|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list |
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids |
| step kinds `say` / `yesno` / `choice` | `main.lua` |
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
| `events:on("intro.oak_speech.answered")` | `main.lua``mod.save` |
| `events:on("intro.oak_speech.finished")` | `main.lua` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

+107
View File
@@ -0,0 +1,107 @@
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
-- swaps (oak / rival / player / pokemon / a custom image), and answers
-- that land in mod.save.
--
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
return function(mod)
local toastPic = mod.path .. "/assets/toast_kid.png"
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
-- after oak says hello, immediately derail
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "silly_quiz_intro",
kind = "say",
pic = "oak",
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
})
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
id = "silly_toast",
kind = "yesno",
pic = "oak",
saveKey = "likes_toast",
text = "Do you like\ntoast?",
})
-- brand new sprite mid-speech
mod.ui.insertStepAfter(steps, "silly_toast", {
id = "silly_toast_kid",
kind = "say",
pic = { type = "image", path = toastPic },
reveal = "fade",
saveKey = nil,
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
})
-- existing mon with a wipe + cry, parked after the real demo mon
mod.ui.insertStepAfter(steps, "demo_mon", {
id = "silly_mew",
kind = "say",
pic = { type = "pokemon", id = "MEW" },
reveal = "wipe",
cry = "MEW",
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
})
mod.ui.insertStepAfter(steps, "silly_mew", {
id = "silly_snack",
kind = "choice",
pic = "oak",
saveKey = "snack",
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
})
-- swap to rival pic for a loaded question before naming him
mod.ui.insertStepBefore(steps, "ask_rival_name", {
id = "silly_trust",
kind = "choice",
pic = "rival",
reveal = "fade",
saveKey = "trusts_rival",
text = "Look at this kid.\nTrustworthy?",
choices = { "SURE", "NO" },
values = { true, false },
})
-- player pic for one last bit after both names are set
mod.ui.insertStepAfter(steps, "name_rival", {
id = "silly_pineapple",
kind = "yesno",
pic = "player",
saveKey = "pineapple_on_pizza",
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
})
mod.ui.insertStepAfter(steps, "silly_pineapple", {
id = "silly_closing",
kind = "say",
pic = "oak",
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
})
return steps
end)
-- every answered step with a saveKey lands in mod.save (and therefore
-- save.modData[mod.id] once the slot is written)
mod.events:on("intro.oak_speech.answered", function(ev)
if not ev.saveKey then return end
mod.save:set(ev.saveKey, ev.value)
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
end)
mod.events:on("intro.oak_speech.finished", function(ev)
local answers = ev.answers or {}
for key, value in pairs(answers) do
if mod.save:get(key) == nil then
mod.save:set(key, value)
end
end
mod.save:set("quiz_done", true)
mod.log:info("silly oak quiz done")
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_silly_oak",
"name": "Silly Oak Intro Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "UI",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
}
+21
View File
@@ -0,0 +1,21 @@
-- Sharing metadata for the manager detail pane.
return {
summary = "Oak asks dumb questions during the intro and remembers your answers.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "intro", "ui", "oak", "hooks" },
differences = {
changed = {
"Oak's NEW GAME speech gains extra questions and sprite beats",
},
added = {
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
"Custom Toast Kid pic mid-intro",
},
known = { "vanilla naming and the shrink-away still run" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
},
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,159 @@
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
-- and answers landing in mod.save.
--
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
-- fresh checkout without a ROM skip cleanly -- the gallery is also
-- covered by tests/mod_examples_tests.lua when generated data is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local function hasGenerated()
local handle = io.open("data/generated/constants.lua", "r")
if handle then handle:close() return true end
return false
end
if not hasGenerated() then
print("example_silly_oak_test skipped (needs data/generated/)")
os.exit(0)
end
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local OakSpeech = require("src.ui.OakSpeech")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local mod = run.mod
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
local ModUI = require("src.ui.ModUI")
local bucket = function()
return run.loader.modSave.example_silly_oak or {}
end
local toastPath = (mod.path or "mods/examples/example_silly_oak")
.. "/assets/toast_kid.png"
-- ------- build hook injects every silly beat around vanilla anchors
local speech = OakSpeech.new({
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = { push = function() end, pop = function() end },
}, nil)
local steps = speech:buildSteps()
local ids = {}
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
local function has(id)
for _, x in ipairs(ids) do if x == id then return true end end
return false
end
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
"vanilla anchors still present")
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
"toast quiz beats injected")
T.check(has("silly_mew") and has("silly_snack"),
"MEW reveal and snack choice injected")
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
"rival trust + pineapple beats injected")
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
local function indexOf(id)
for i, x in ipairs(ids) do if x == id then return i end end
return 0
end
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
"Toast Kid shows before the demo mon")
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
"MEW shows after the demo mon")
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
"trust question is before rival naming")
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
and indexOf("silly_pineapple") < indexOf("legend"),
"pineapple lands between rival name and the legend beat")
-- ------- step shapes cover choice / yesno / custom image / pokemon
local byId = {}
for _, step in ipairs(steps) do byId[step.id] = step end
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
"Toast Kid uses a custom image pic")
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
"MEW beat uses pokemon pic + cry")
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
{ type = "pokemon", id = "MEW", flip = true }, speech)
local customImg = OakSpeech.resolvePic({ data = Data },
{ type = "image", path = toastPath }, speech)
-- headless love stub may return nil images; the call itself must not throw
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
or type(oakImg) == "table",
"oak shorthand resolves without error")
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
or type(rivalImg) == "table",
"rival shorthand resolves without error")
T.check(playerImg == speech.playerPic or playerImg == nil
or type(playerImg) == "userdata" or type(playerImg) == "table",
"player shorthand resolves without error")
T.check(mewFlip == true, "pokemon flip flag is honored")
T.check(customImg ~= nil or true, "custom image path is accepted")
-- ------- answered event writes mod.save (loader.modSave bucket)
Runtime.emit("intro.oak_speech.answered", {
saveKey = "likes_toast", value = true, label = "YES", index = 1,
step = byId.silly_toast, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
step = byId.silly_snack, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
step = byId.silly_trust, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
step = byId.silly_pineapple, speech = speech,
})
Runtime.emit("intro.oak_speech.finished", {
speech = speech, answers = speech.answers,
})
local saved = bucket()
T.eq(saved.likes_toast, true, "likes_toast saved")
T.eq(saved.snack, "OLD ROD", "snack saved")
T.eq(saved.trusts_rival, false, "trusts_rival saved")
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
-- ------- ModUI step helpers (public surface)
local tiny = {
{ id = "a", kind = "say" },
{ id = "b", kind = "say" },
}
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
ModUI.removeStep(tiny, "mid")
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
run.release()
T.finish("example_silly_oak")